diff --git a/.github/scripts/append-local-plugin-release-intent.mjs b/.github/scripts/append-local-plugin-release-intent.mjs new file mode 100644 index 000000000..035a2f447 --- /dev/null +++ b/.github/scripts/append-local-plugin-release-intent.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const INTENT_SCHEMA = "memos.local-plugin.release-intent.v1"; +export const INTENT_MARKER = "doc-agent-local-plugin-release-intent"; + +function fail(message) { + throw new Error(String(message)); +} + +function stableVersion(raw) { + const value = String(raw || "").trim().replace(/^v/, ""); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) { + fail(`local plugin release intent requires a stable SemVer; received ${raw || ""}`); + } + return value; +} + +export function buildLocalPluginReleaseIntent({ + enabled, + version = "", + tag = "", + sourceSha = "", + evidenceDigest = "", +} = {}) { + const active = enabled === true || String(enabled) === "true"; + if (!/^[0-9a-f]{64}$/.test(String(evidenceDigest || ""))) { + fail("local plugin release intent requires a SHA-256 evidence_digest"); + } + if (!active) { + return { + schema: INTENT_SCHEMA, + enabled: false, + version: "", + tag: "", + source_sha: "", + evidence_digest: evidenceDigest, + }; + } + + const normalizedVersion = stableVersion(version); + const expectedTag = `memos-local-plugin-v${normalizedVersion}`; + if (String(tag || "").trim() !== expectedTag) { + fail(`local plugin release intent tag must equal ${expectedTag}`); + } + if (!/^[0-9a-f]{40}$/.test(String(sourceSha || "").trim())) { + fail("enabled local plugin release intent requires the 40-character published tag commit SHA"); + } + return { + schema: INTENT_SCHEMA, + enabled: true, + version: `v${normalizedVersion}`, + tag: expectedTag, + source_sha: String(sourceSha).trim(), + evidence_digest: evidenceDigest, + }; +} + +export function appendIntentToReleaseNotes(notes, intent) { + const source = String(notes || "").trimEnd(); + if (!source) fail("MemOS release notes are empty"); + if (source.includes(`\n`; +} + +export function main() { + const notesFile = String(process.env.RELEASE_NOTES_FILE || "").trim(); + const outputFile = String(process.env.OUTPUT_RELEASE_NOTES_FILE || notesFile).trim(); + if (!notesFile || !outputFile) fail("RELEASE_NOTES_FILE and OUTPUT_RELEASE_NOTES_FILE are required"); + const intent = buildLocalPluginReleaseIntent({ + enabled: process.env.LOCAL_PLUGIN_RELEASE_ENABLED, + version: process.env.LOCAL_PLUGIN_VERSION, + tag: process.env.LOCAL_PLUGIN_TAG, + sourceSha: process.env.LOCAL_PLUGIN_TAG_SHA, + evidenceDigest: process.env.LOCAL_PLUGIN_EVIDENCE_DIGEST, + }); + writeFileSync(outputFile, appendIntentToReleaseNotes(readFileSync(notesFile, "utf8"), intent), "utf8"); + console.log(`Appended ${INTENT_SCHEMA} marker (enabled=${intent.enabled}).`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/append-local-plugin-release-intent.test.mjs b/.github/scripts/append-local-plugin-release-intent.test.mjs new file mode 100644 index 000000000..c92ee8bff --- /dev/null +++ b/.github/scripts/append-local-plugin-release-intent.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + INTENT_SCHEMA, + appendIntentToReleaseNotes, + buildLocalPluginReleaseIntent, +} from "./append-local-plugin-release-intent.mjs"; + +const digest = "a".repeat(64); + +test("disabled intent contains no guessed version or source SHA", () => { + assert.deepEqual( + buildLocalPluginReleaseIntent({ enabled: false, evidenceDigest: digest }), + { + schema: INTENT_SCHEMA, + enabled: false, + version: "", + tag: "", + source_sha: "", + evidence_digest: digest, + }, + ); +}); + +test("enabled intent binds version, immutable tag, source SHA, and evidence", () => { + const intent = buildLocalPluginReleaseIntent({ + enabled: true, + version: "2.0.14", + tag: "memos-local-plugin-v2.0.14", + sourceSha: "b".repeat(40), + evidenceDigest: digest, + }); + assert.equal(intent.version, "v2.0.14"); + assert.equal(intent.source_sha, "b".repeat(40)); + assert.match(appendIntentToReleaseNotes("## What's Changed\n", intent), /doc-agent-local-plugin-release-intent/); +}); + +test("enabled intent fails closed for mismatched tags and prereleases", () => { + assert.throws( + () => buildLocalPluginReleaseIntent({ + enabled: true, + version: "2.0.14", + tag: "memos-local-plugin-v2.0.15", + sourceSha: "b".repeat(40), + evidenceDigest: digest, + }), + /must equal/, + ); + assert.throws( + () => buildLocalPluginReleaseIntent({ + enabled: true, + version: "2.0.14-beta.1", + tag: "memos-local-plugin-v2.0.14-beta.1", + sourceSha: "b".repeat(40), + evidenceDigest: digest, + }), + /stable SemVer/, + ); +}); + +test("release notes refuse duplicate intent markers", () => { + assert.throws( + () => appendIntentToReleaseNotes("## Notes\n", {}), + /already contain/, + ); +}); diff --git a/.github/scripts/audit-local-plugin-package.mjs b/.github/scripts/audit-local-plugin-package.mjs new file mode 100644 index 000000000..ae65c7467 --- /dev/null +++ b/.github/scripts/audit-local-plugin-package.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, relative, sep } from "node:path"; +import { pathToFileURL } from "node:url"; + +const FORBIDDEN_PATHS = [ + /(^|\/)\.env(?:\.|$)/i, + /(^|\/)\.npmrc$/i, + /(^|\/)\.git(?:\/|$)/i, + /(^|\/)(?:id_rsa|id_ed25519)$/i, + /\.(?:pem|p12|pfx|key)$/i, +]; +const SECRET_PATTERNS = [ + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + /(?:^|[^A-Za-z0-9])npm_[A-Za-z0-9]{20,}/, + /(?:^|[^A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}/, + /github_pat_[A-Za-z0-9_]{20,}/, + /\/\/(?:registry\.)?npmjs\.org\/:_authToken\s*=/i, + /Authorization\s*[:=]\s*Bearer\s+[A-Za-z0-9._-]{16,}/i, + /(?:^|[^A-Za-z0-9])sk-[A-Za-z0-9]{20,}/, + /(?:^|[^A-Z0-9])AKIA[0-9A-Z]{16}(?:[^A-Z0-9]|$)/, +]; + +function collectFiles(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(path)); + } else if (entry.isFile()) { + files.push(path); + } + } + return files; +} + +export function auditPackage(tarball) { + const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); + const unsafeArchivePaths = listing.filter( + (entry) => + entry.startsWith("/") || + entry.split("/").includes("..") || + !entry.startsWith("package/") || + FORBIDDEN_PATHS.some((pattern) => pattern.test(entry)), + ); + if (unsafeArchivePaths.length > 0) { + throw new Error(`package contains forbidden path: ${unsafeArchivePaths[0]}`); + } + const linkedEntries = execFileSync("tar", ["-tvzf", tarball], { encoding: "utf8" }) + .split("\n") + .filter((line) => /^[lh]/.test(line)); + if (linkedEntries.length > 0) { + throw new Error("package contains a symbolic or hard link; refusing unsafe extraction"); + } + + const extractDirectory = mkdtempSync(join(tmpdir(), "memos-local-plugin-audit-")); + try { + execFileSync("tar", ["-xzf", tarball, "-C", extractDirectory]); + const packageRoot = join(extractDirectory, "package"); + const files = collectFiles(packageRoot); + const secretFiles = []; + let scannedTextFileCount = 0; + for (const file of files) { + const size = statSync(file).size; + if (size === 0 || size > 2 * 1024 * 1024) { + continue; + } + const bytes = readFileSync(file); + if (bytes.includes(0)) { + continue; + } + scannedTextFileCount += 1; + const text = bytes.toString("utf8"); + if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) { + secretFiles.push(relative(packageRoot, file).split(sep).join("/")); + } + } + if (secretFiles.length > 0) { + throw new Error(`package contains a credential-like value in ${secretFiles[0]}`); + } + return { + tarball: basename(tarball), + archive_entry_count: listing.length, + scanned_text_file_count: scannedTextFileCount, + forbidden_path_count: 0, + credential_finding_count: 0, + status: "pass", + }; + } finally { + rmSync(extractDirectory, { recursive: true, force: true }); + } +} + +export function main() { + const tarball = process.env.RELEASE_TARBALL || ""; + const reportFile = process.env.PACKAGE_AUDIT_REPORT || ""; + if (!tarball || !reportFile) { + throw new Error("RELEASE_TARBALL and PACKAGE_AUDIT_REPORT are required"); + } + const report = auditPackage(tarball); + writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + console.log(`Package audit passed for ${report.tarball}; scanned ${report.archive_entry_count} entries.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/audit-local-plugin-package.test.mjs b/.github/scripts/audit-local-plugin-package.test.mjs new file mode 100644 index 000000000..4a2a17ae9 --- /dev/null +++ b/.github/scripts/audit-local-plugin-package.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { auditPackage } from "./audit-local-plugin-package.mjs"; + +function withPackage(files, callback) { + const directory = mkdtempSync(join(tmpdir(), "memos-package-audit-test-")); + const packageDirectory = join(directory, "package"); + mkdirSync(packageDirectory); + for (const [path, contents] of Object.entries(files)) { + const target = join(packageDirectory, path); + mkdirSync(join(target, ".."), { recursive: true }); + writeFileSync(target, contents, "utf8"); + } + const tarball = join(directory, "package.tgz"); + execFileSync("tar", ["-czf", tarball, "-C", directory, "package"]); + try { + callback(tarball); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test("accepts expected package content including public telemetry configuration", () => { + withPackage( + { + "package.json": '{"name":"@memtensor/memos-local-plugin","version":"2.0.13-beta.1"}\n', + "telemetry.credentials.json": '{"endpoint":"https://example.invalid/rum","pid":"public-id"}\n', + "dist/index.js": "export const ok = true;\n", + }, + (tarball) => { + const report = auditPackage(tarball); + assert.equal(report.status, "pass"); + assert.equal(report.credential_finding_count, 0); + }, + ); +}); + +test("rejects credential files and credential-like values", () => { + withPackage({ ".npmrc": "//registry.npmjs.org/:_authToken=npm_example\n" }, (tarball) => { + assert.throws(() => auditPackage(tarball), /forbidden path/); + }); + withPackage({ "dist/config.js": `const token = "github_pat_${"a".repeat(24)}";\n` }, (tarball) => { + assert.throws(() => auditPackage(tarball), /credential-like value/); + }); +}); + +test("rejects package symlinks before extraction", () => { + const directory = mkdtempSync(join(tmpdir(), "memos-package-audit-link-test-")); + const packageDirectory = join(directory, "package"); + mkdirSync(packageDirectory); + symlinkSync("/tmp", join(packageDirectory, "unsafe-link")); + const tarball = join(directory, "package.tgz"); + execFileSync("tar", ["-czf", tarball, "-C", directory, "package"]); + try { + assert.throws(() => auditPackage(tarball), /symbolic or hard link/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/draft-local-plugin-release-notes.mjs b/.github/scripts/draft-local-plugin-release-notes.mjs index 3ea25c6fe..ce73381d6 100644 --- a/.github/scripts/draft-local-plugin-release-notes.mjs +++ b/.github/scripts/draft-local-plugin-release-notes.mjs @@ -72,10 +72,10 @@ export function displayVersion(raw) { return value ? `v${value}` : ""; } -export function isLegacyPackageOnlyRelease({ targetVersion, npmDistTag = "" } = {}) { +export function isLegacyPackageOnlyRelease({ targetVersion, npmDistTag = "", forcePackageOnly = false } = {}) { const parsed = parseSemver(targetVersion); const distTag = String(npmDistTag || "").trim(); - return Boolean(parsed?.prerelease) || Boolean(distTag && distTag !== "latest"); + return Boolean(forcePackageOnly) || Boolean(parsed?.prerelease) || Boolean(distTag && distTag !== "latest"); } export function versionFromTag(tag) { @@ -1034,7 +1034,7 @@ function markdownFromReleaseItems(items, coverage) { return `${lines.join("\n").trim()}\n`; } -export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = {}) { +export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "", docsSyncMode = "" } = {}) { const version = evidence?.target_version || ""; const targetPackageVersion = cleanVersion(version); const gitRef = evidence?.git_ref || ""; @@ -1045,11 +1045,15 @@ export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = { const commitCount = Array.isArray(evidence?.commits) ? evidence.commits.length : 0; const packageChanges = Array.isArray(evidence?.package_changes) ? evidence.package_changes : []; const versionChange = packageChanges.find((item) => item.field === "version"); + const prerelease = Boolean(parseSemver(targetPackageVersion)?.prerelease) || distTag !== "latest"; + const deferredToMemosRelease = docsSyncMode === "defer_to_memos_release"; const lines = [ "## Changelog", "", - "### Prerelease", - `- Published ${PRODUCT_TITLE.en} ${version} as a package prerelease for validation through the npm \`${distTag}\` dist-tag.`, + prerelease ? "### Prerelease" : "### Package Release", + prerelease + ? `- Published ${PRODUCT_TITLE.en} ${version} as a package prerelease for validation through the npm \`${distTag}\` dist-tag.` + : `- Published ${PRODUCT_TITLE.en} ${version} through the npm \`${distTag}\` dist-tag.`, "", "### Release Evidence", `- Package tag: ${currentTag}`, @@ -1061,11 +1065,15 @@ export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = { const previousPackageVersion = versionChange?.before || "unknown"; lines.push(`- Package version: ${previousPackageVersion} -> ${targetPackageVersion}`); lines.push(""); - lines.push("This legacy prerelease is package-only and does not update the MemOS-Docs Plugin tab."); + lines.push( + deferredToMemosRelease + ? "This package build is part of the MemOS whole-repository release. It does not create an independent local-plugin GitHub Release; docs sync is deferred to the MemOS release.published event." + : "This standalone package-only publish does not create a GitHub Release or update the MemOS-Docs Plugin tab.", + ); return { ok: true, needs_review: false, - confidence: "legacy-package-only", + confidence: deferredToMemosRelease ? "memos-release-deferred" : "legacy-package-only", release_items: [], coverage: { needs_review: false, @@ -1075,9 +1083,15 @@ export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = { covered_refs: [], missing_required: [], invalid_item_refs: [], - policy: "legacy local-plugin prereleases are package-only and do not create docs payloads", + policy: deferredToMemosRelease + ? "weekly local-plugin package publication defers docs to the MemOS whole-repository release.published event" + : "standalone local-plugin publishes are package-only and do not create GitHub Releases or docs payloads", }, - warnings: ["legacy package-only prerelease skipped Doc Agent draft and docs payload generation"], + warnings: [ + deferredToMemosRelease + ? "local-plugin docs sync is deferred to the MemOS whole-repository release.published event" + : "standalone package publish skipped GitHub Release, Doc Agent draft, and docs payload generation", + ], release_notes_markdown: `${lines.join("\n").trim()}\n`, }; } @@ -1169,8 +1183,14 @@ export function validateManualNotes(notes) { fail("Manual release notes evidence coverage must explicitly set needs_review=false."); } for (const item of payload.items) { - if (!item?.text_cn || !item?.text_en || !Array.isArray(item?.source_refs) || item.source_refs.length === 0) { - fail("Every manual release-note item must include text_cn, text_en, and source_refs."); + if ( + !RELEASE_CATEGORY_ORDER.includes(String(item?.category || "")) || + !item?.text_cn || + !item?.text_en || + !Array.isArray(item?.source_refs) || + item.source_refs.length === 0 + ) { + fail("Every manual release-note item must include a valid category, text_cn, text_en, and source_refs."); } if (!CJK_RE.test(String(item.text_cn || ""))) { fail("Every manual release-note item text_cn must contain Chinese text."); @@ -1182,6 +1202,35 @@ export function validateManualNotes(notes) { return text; } +export function manualDraftFromNotes(notes, evidence) { + const text = validateManualNotes(notes); + const match = text.match(//); + const payload = JSON.parse(match[1]); + const draft = postprocessDraftFromEvidence( + { + ok: true, + needs_review: false, + confidence: "manual-evidence-bound", + release_items: payload.items, + coverage: payload.coverage, + warnings: [], + }, + evidence, + ); + const validationReport = validationReportFromPostprocessedDraft(draft); + if (!validationReport.ok) { + fail(`Manual release notes failed evidence validation: ${JSON.stringify(validationReport)}`); + } + return { + ...draft, + confidence: "manual-evidence-bound", + validation_report: validationReport, + validation_attempt_count: 1, + repair_attempt_count: 0, + release_notes_markdown: ensureSourceHint(text), + }; +} + function isRetryableStatus(status) { return status === 408 || status === 425 || status === 429 || status >= 500; } @@ -1366,26 +1415,17 @@ export async function main() { const currentTag = process.env.RELEASE_TAG || `${CURRENT_TAG_PREFIX}${targetVersion}`; const npmDistTag = String(process.env.NPM_DIST_TAG || "").trim(); - const legacyPackageOnly = isLegacyPackageOnlyRelease({ targetVersion, npmDistTag }); + const docsSyncMode = String(process.env.DOCS_SYNC_MODE || "").trim(); + const forcePackageOnly = String(process.env.FORCE_PACKAGE_ONLY_RELEASE || "").trim() === "true"; + const legacyPackageOnly = isLegacyPackageOnlyRelease({ targetVersion, npmDistTag, forcePackageOnly }); + const includePrereleaseBaseline = isLegacyPackageOnlyRelease({ targetVersion, npmDistTag }); const notesPath = process.env.RELEASE_NOTES_FILE || join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes.md`); mkdirSync(dirname(notesPath), { recursive: true }); - const manualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim(); - if (manualNotes) { - const notes = legacyPackageOnly - ? validateLegacyPackageNotes(manualNotes) - : ensureSourceHint(validateManualNotes(manualNotes)); - writeFileSync(notesPath, notes, "utf8"); - appendOutput("release_notes_file", notesPath); - appendOutput("draft_used", "false"); - console.log(`Using manually provided release notes: ${notesPath}`); - return; - } - const previousTag = findPreviousTag(targetVersion, currentTag, { - includePrerelease: legacyPackageOnly, + includePrerelease: includePrereleaseBaseline, }); if (!previousTag) { fail(`Cannot find a previous local plugin tag before ${currentTag}.`); @@ -1396,8 +1436,34 @@ export async function main() { const evidencePath = join(tmpdir(), `memos-local-plugin-${targetVersion}-evidence.json`); writeFileSync(evidencePath, JSON.stringify(evidenceForInspection(evidence), null, 2), "utf8"); + const manualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim(); + if (manualNotes) { + const draft = legacyPackageOnly + ? legacyPackageDraftFromEvidence(evidence, { npmDistTag, docsSyncMode }) + : manualDraftFromNotes(manualNotes, evidence); + const notes = legacyPackageOnly + ? validateLegacyPackageNotes(manualNotes) + : draft.release_notes_markdown; + const draftPath = join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes-draft.json`); + writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8"); + writeFileSync(notesPath, notes, "utf8"); + appendOutput("release_notes_file", notesPath); + appendOutput("evidence_file", evidencePath); + appendOutput("draft_file", draftPath); + appendOutput("draft_used", legacyPackageOnly ? "false" : "true"); + appendOutput("previous_tag", previousTag); + appendOutput("current_tag", currentTag); + appendOutput("current_ref", currentRef); + appendOutput("draft_confidence", draft.confidence); + appendOutput("missing_required_count", String(draft.coverage?.missing_required_count ?? "")); + appendOutput("validation_attempt_count", String(draft.validation_attempt_count ?? 0)); + appendOutput("repair_attempt_count", String(draft.repair_attempt_count ?? 0)); + console.log(`Using manually provided evidence-bound release notes: ${notesPath}`); + return; + } + if (legacyPackageOnly) { - const draft = legacyPackageDraftFromEvidence(evidence, { npmDistTag }); + const draft = legacyPackageDraftFromEvidence(evidence, { npmDistTag, docsSyncMode }); const draftPath = join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes-draft.json`); writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8"); writeFileSync(notesPath, draft.release_notes_markdown, "utf8"); @@ -1414,7 +1480,7 @@ export async function main() { appendOutput("validation_attempt_count", "0"); appendOutput("repair_attempt_count", "0"); - console.log(`Generated package-only prerelease notes without Doc Agent: ${notesPath}`); + console.log(`Generated standalone package inspection notes without Doc Agent: ${notesPath}`); console.log(`Previous tag: ${previousTag}`); console.log(`Current tag: ${currentTag}`); console.log(`Current evidence ref: ${currentRef}`); diff --git a/.github/scripts/draft-local-plugin-release-notes.test.mjs b/.github/scripts/draft-local-plugin-release-notes.test.mjs index 40abe1174..46937e26c 100644 --- a/.github/scripts/draft-local-plugin-release-notes.test.mjs +++ b/.github/scripts/draft-local-plugin-release-notes.test.mjs @@ -12,6 +12,7 @@ import { ensureSourceHint, isLegacyPackageOnlyRelease, legacyPackageDraftFromEvidence, + manualDraftFromNotes, parseSemver, RELEASE_NOTE_GUIDANCE, postprocessDraftFromEvidence, @@ -60,6 +61,27 @@ test("detects legacy package-only prereleases", () => { assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "" }), false); }); +test("can force a stable standalone publish to remain package-only", () => { + assert.equal( + isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "latest", forcePackageOnly: true }), + true, + ); + assert.equal( + isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "latest", forcePackageOnly: false }), + false, + ); +}); + +test("weekly package-only notes explain that docs wait for the MemOS Release", () => { + const result = legacyPackageDraftFromEvidence(evidence, { + npmDistTag: "latest", + docsSyncMode: "defer_to_memos_release", + }); + assert.match(result.release_notes_markdown, /MemOS whole-repository release/); + assert.match(result.release_notes_markdown, /release\.published event/); + assert.doesNotMatch(result.release_notes_markdown, /does not .* update the MemOS-Docs Plugin tab/); +}); + test("treats SemVer build metadata as stable metadata, not a prerelease channel", () => { assert.equal(parseSemver("2.0.13+build.7").prerelease, ""); assert.equal(parseSemver("2.0.13-beta.1+build.7").prerelease, "beta.1"); @@ -673,7 +695,7 @@ test("manual notes require bilingual evidence refs and passed coverage", () => { - local memory `; assert.equal(validateManualNotes(valid), valid); assert.match(ensureSourceHint(valid), /source-id=openclaw-local-plugin/); @@ -686,12 +708,47 @@ test("manual notes require bilingual evidence refs and passed coverage", () => { - local memory `), /text_cn must contain Chinese/, ); }); +test("manual stable notes are revalidated against collected evidence", () => { + const notes = `## Changelog + +### Fixed +- 修复 Hermes 桥接状态恢复。 + +`; + const evidence = { + commits: [{ + sha: "abc1234567890abc1234567890abc1234567890", + short_sha: "abc1234", + subject: "fix: restore Hermes bridge state", + }], + important_commits: [{ + sha: "abc1234567890abc1234567890abc1234567890", + short_sha: "abc1234", + subject: "fix: restore Hermes bridge state", + }], + release_note_guidance: { + source_ref_category_hints: [{ source_refs: ["abc1234"], category: "Fixed" }], + }, + }; + const draft = manualDraftFromNotes(notes, evidence); + assert.equal(draft.ok, true); + assert.equal(draft.release_items[0].category, "Fixed"); + assert.match(draft.release_notes_markdown, /source-id=openclaw-local-plugin/); + + assert.throws( + () => manualDraftFromNotes(notes.replaceAll("abc1234", "deadbee"), evidence), + /failed evidence validation/, + ); +}); + test("legacy package manual notes reject docs payloads", () => { const valid = `## Changelog diff --git a/.github/scripts/inspect-local-plugin-release-state.mjs b/.github/scripts/inspect-local-plugin-release-state.mjs new file mode 100644 index 000000000..de0c85421 --- /dev/null +++ b/.github/scripts/inspect-local-plugin-release-state.mjs @@ -0,0 +1,232 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const ALLOWED_DIST_TAGS = new Set(["latest", "beta", "next", "alpha"]); + +export function validateVersionChannel(version, distTag) { + const match = SEMVER_PATTERN.exec(version); + if (!match) { + throw new Error(`version must be valid SemVer without a leading v; received ${version}`); + } + const prereleaseIdentifiers = (match[4] || "").split(".").filter(Boolean); + if ( + prereleaseIdentifiers.some( + (part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"), + ) + ) { + throw new Error(`numeric prerelease identifiers must not contain leading zeroes; received ${version}`); + } + if (!ALLOWED_DIST_TAGS.has(distTag)) { + throw new Error(`npm dist-tag must be one of latest, beta, next, or alpha; received ${distTag}`); + } + + const prerelease = match[4] || ""; + if (!prerelease && distTag !== "latest") { + throw new Error(`stable version ${version} must use npm dist-tag latest`); + } + if (prerelease && distTag === "latest") { + throw new Error(`prerelease version ${version} must not use npm dist-tag latest`); + } + + const prereleaseChannel = prerelease.split(".")[0]; + if (["beta", "alpha", "next"].includes(prereleaseChannel) && prereleaseChannel !== distTag) { + throw new Error( + `prerelease channel ${prereleaseChannel} must match npm dist-tag ${prereleaseChannel}`, + ); + } + if (prerelease && !["beta", "alpha", "next"].includes(prereleaseChannel) && distTag !== "next") { + throw new Error( + `unrecognized prerelease channel ${prereleaseChannel} must use npm dist-tag next`, + ); + } + + return { prerelease: Boolean(prerelease), prereleaseChannel }; +} + +export function classifyReleaseState({ tagExists }) { + return tagExists ? "complete" : "fresh"; +} + +export function validateExistingTagVersions( + { packageVersion, manifestVersion }, + { releaseTag, expectedVersion }, +) { + if (packageVersion !== expectedVersion) { + throw new Error( + `tag ${releaseTag} contains package version ${packageVersion}, expected ${expectedVersion}`, + ); + } + if (manifestVersion !== expectedVersion) { + throw new Error( + `tag ${releaseTag} contains Hermes manifest version ${manifestVersion || ""}, expected ${expectedVersion}`, + ); + } +} + +export function validateExistingTagSource( + { tagCommit, parentCommits, changedFiles }, + { releaseTag, expectedSourceSha }, +) { + if (tagCommit === expectedSourceSha) { + return; + } + if (parentCommits.length !== 1 || parentCommits[0] !== expectedSourceSha) { + throw new Error( + `tag ${releaseTag} does not point to the selected package source or its direct release metadata commit`, + ); + } + const allowedMetadataFiles = new Set([ + "apps/memos-local-plugin/package.json", + "apps/memos-local-plugin/package-lock.json", + "apps/memos-local-plugin/adapters/hermes/plugin.yaml", + ]); + const unexpected = changedFiles.filter((file) => !allowedMetadataFiles.has(file)); + if (unexpected.length > 0) { + throw new Error( + `tag ${releaseTag} release commit changes non-metadata file ${unexpected[0]}`, + ); + } +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function runWithRetry(command, args, { missingPattern, label }) { + let lastResult; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const result = spawnSync(command, args, { + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + lastResult = result; + if (result.status === 0) { + return { exists: true, stdout: result.stdout.trim() }; + } + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + if (missingPattern.test(output)) { + return { exists: false, stdout: "" }; + } + if (attempt < 3) { + console.log(`::notice::${label} failed on attempt ${attempt}/3; retrying.`); + execFileSync("sleep", [String(attempt * 5)]); + } + } + const detail = `${lastResult?.stdout || ""}\n${lastResult?.stderr || ""}`.trim().slice(0, 1200); + throw new Error(`${label} failed after three attempts${detail ? `: ${detail}` : ""}`); +} + +function inspectRemoteTag(releaseTag, version, expectedSourceSha) { + const output = runWithRetry( + "git", + [ + "ls-remote", + "--tags", + "origin", + `refs/tags/${releaseTag}`, + `refs/tags/${releaseTag}^{}`, + ], + { missingPattern: /this-pattern-never-matches/i, label: `remote tag lookup for ${releaseTag}` }, + ).stdout; + if (!output) { + return { exists: false, commit: "" }; + } + + const lines = output.split("\n").filter(Boolean); + const peeled = lines.find((line) => line.endsWith(`refs/tags/${releaseTag}^{}`)); + const direct = lines.find((line) => line.endsWith(`refs/tags/${releaseTag}`)); + const remoteCommit = (peeled || direct || "").split(/\s+/)[0]; + if (!remoteCommit) { + throw new Error(`could not resolve remote tag ${releaseTag}`); + } + + const inspectionRef = `refs/memos-release-inspection/${releaseTag}`; + runWithRetry( + "git", + ["fetch", "--force", "--no-tags", "origin", `refs/tags/${releaseTag}:${inspectionRef}`], + { missingPattern: /this-pattern-never-matches/i, label: `fetch release tag ${releaseTag}` }, + ); + const fetchedCommit = run("git", ["rev-parse", `${inspectionRef}^{commit}`]); + if (fetchedCommit !== remoteCommit) { + throw new Error( + `remote tag ${releaseTag} changed while it was inspected (${remoteCommit} -> ${fetchedCommit})`, + ); + } + + const packageJson = JSON.parse( + run("git", ["show", `${inspectionRef}:apps/memos-local-plugin/package.json`]), + ); + const hermesManifest = run("git", [ + "show", + `${inspectionRef}:apps/memos-local-plugin/adapters/hermes/plugin.yaml`, + ]); + const manifestVersion = /^version:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(hermesManifest)?.[1] || ""; + validateExistingTagVersions( + { packageVersion: packageJson.version, manifestVersion }, + { releaseTag, expectedVersion: version }, + ); + run("git", ["cat-file", "-e", `${expectedSourceSha}^{commit}`]); + const commitLine = run("git", ["rev-list", "--parents", "-n", "1", fetchedCommit]); + const [, ...parentCommits] = commitLine.split(/\s+/); + const changedFiles = run("git", [ + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + fetchedCommit, + ]) + .split("\n") + .filter(Boolean); + validateExistingTagSource( + { tagCommit: fetchedCommit, parentCommits, changedFiles }, + { releaseTag, expectedSourceSha }, + ); + + return { exists: true, commit: fetchedCommit }; +} + +export function main() { + const version = process.env.RELEASE_VERSION || ""; + const releaseTag = process.env.RELEASE_TAG || ""; + const distTag = process.env.NPM_DIST_TAG || ""; + const expectedSourceSha = process.env.EXPECTED_PACKAGE_SOURCE_SHA || ""; + const outputFile = process.env.GITHUB_OUTPUT || ""; + + if (!version || !releaseTag || !distTag || !expectedSourceSha) { + throw new Error( + "RELEASE_VERSION, RELEASE_TAG, NPM_DIST_TAG, and EXPECTED_PACKAGE_SOURCE_SHA are required", + ); + } + if (releaseTag !== `memos-local-plugin-v${version}`) { + throw new Error(`release tag ${releaseTag} does not match version ${version}`); + } + validateVersionChannel(version, distTag); + + const tag = inspectRemoteTag(releaseTag, version, expectedSourceSha); + const state = classifyReleaseState({ tagExists: tag.exists }); + console.log(`Standalone package tag state: ${state}`); + if (tag.commit) { + console.log(`Existing release tag commit: ${tag.commit}`); + } + if (outputFile) { + appendFileSync(outputFile, `state=${state}\ntag_commit=${tag.commit}\n`, "utf8"); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/inspect-local-plugin-release-state.test.mjs b/.github/scripts/inspect-local-plugin-release-state.test.mjs new file mode 100644 index 000000000..600e10e0c --- /dev/null +++ b/.github/scripts/inspect-local-plugin-release-state.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyReleaseState, + validateExistingTagSource, + validateExistingTagVersions, + validateVersionChannel, +} from "./inspect-local-plugin-release-state.mjs"; + +test("accepts matching stable and prerelease npm channels", () => { + assert.deepEqual(validateVersionChannel("2.0.13", "latest"), { + prerelease: false, + prereleaseChannel: "", + }); + assert.deepEqual(validateVersionChannel("2.0.13-beta.1", "beta"), { + prerelease: true, + prereleaseChannel: "beta", + }); + assert.deepEqual(validateVersionChannel("2.0.13-rc.1", "next"), { + prerelease: true, + prereleaseChannel: "rc", + }); +}); + +test("rejects malformed versions and mismatched npm channels", () => { + assert.throws(() => validateVersionChannel("v2.0.13", "latest"), /valid SemVer/); + assert.throws(() => validateVersionChannel("2.0.13-beta.01", "beta"), /leading zeroes/); + assert.throws(() => validateVersionChannel("2.0.13", "beta"), /must use npm dist-tag latest/); + assert.throws(() => validateVersionChannel("2.0.13-beta.1", "latest"), /must not use/); + assert.throws(() => validateVersionChannel("2.0.13-beta.1", "next"), /must match/); + assert.throws(() => validateVersionChannel("2.0.13-rc.1", "beta"), /must use npm dist-tag next/); +}); + +test("classifies standalone package metadata only by tag presence", () => { + assert.equal(classifyReleaseState({ tagExists: false }), "fresh"); + assert.equal(classifyReleaseState({ tagExists: true }), "complete"); +}); + +test("rejects an existing tag whose package or Hermes version differs", () => { + const expected = { + releaseTag: "memos-local-plugin-v2.0.13-beta.1", + expectedVersion: "2.0.13-beta.1", + }; + assert.doesNotThrow(() => + validateExistingTagVersions( + { packageVersion: expected.expectedVersion, manifestVersion: expected.expectedVersion }, + expected, + ), + ); + assert.throws( + () => + validateExistingTagVersions( + { packageVersion: "2.0.12", manifestVersion: expected.expectedVersion }, + expected, + ), + /contains package version 2\.0\.12/, + ); + assert.throws( + () => + validateExistingTagVersions( + { packageVersion: expected.expectedVersion, manifestVersion: "2.0.12" }, + expected, + ), + /Hermes manifest version 2\.0\.12/, + ); +}); + +test("accepts only the selected source or its metadata-only release commit as tag target", () => { + const expected = { + releaseTag: "memos-local-plugin-v2.0.13-beta.1", + expectedSourceSha: "source-sha", + }; + assert.doesNotThrow(() => + validateExistingTagSource( + { tagCommit: "source-sha", parentCommits: ["older"], changedFiles: [] }, + expected, + ), + ); + assert.doesNotThrow(() => + validateExistingTagSource( + { + tagCommit: "release-sha", + parentCommits: ["source-sha"], + changedFiles: [ + "apps/memos-local-plugin/package.json", + "apps/memos-local-plugin/package-lock.json", + "apps/memos-local-plugin/adapters/hermes/plugin.yaml", + ], + }, + expected, + ), + ); + assert.throws( + () => + validateExistingTagSource( + { tagCommit: "wrong-sha", parentCommits: ["other-source"], changedFiles: [] }, + expected, + ), + /does not point to the selected package source/, + ); + assert.throws( + () => + validateExistingTagSource( + { + tagCommit: "release-sha", + parentCommits: ["source-sha"], + changedFiles: ["apps/memos-local-plugin/src/index.ts"], + }, + expected, + ), + /changes non-metadata file/, + ); +}); diff --git a/.github/scripts/prepare-local-plugin-formal-sync.mjs b/.github/scripts/prepare-local-plugin-formal-sync.mjs new file mode 100644 index 000000000..7724accc7 --- /dev/null +++ b/.github/scripts/prepare-local-plugin-formal-sync.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const FORMAL_SYNC_SCHEMA = "memos.product-release.formal-sync.v1"; +const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/; +const CATEGORIES = new Set(["Added", "Improved", "Fixed"]); + +function fail(message) { + throw new Error(String(message)); +} + +export function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function sha256Json(value) { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + +function evidenceRefs(evidence) { + const refs = new Set(); + for (const commit of evidence.commits || []) { + for (const value of [commit.short_sha, commit.sha, ...(commit.source_refs || [])]) { + if (String(value || "").trim()) refs.add(String(value).trim()); + } + } + for (const pr of evidence.pull_requests || []) { + if (String(pr.number || "").trim()) refs.add(`#${pr.number}`); + } + return refs; +} + +export function validateFormalDraft(evidence, draft) { + const items = Array.isArray(draft?.release_items) ? draft.release_items : []; + const validRefs = evidenceRefs(evidence); + const issues = []; + if (draft?.ok === false || draft?.needs_review) issues.push("draft is not approved by the quality gate"); + if (!items.length) issues.push("release_items is empty"); + if (items.length > 12) issues.push(`release_items exceeds 12: ${items.length}`); + const covered = new Set(); + items.forEach((item, index) => { + if (!CATEGORIES.has(String(item.category || ""))) issues.push(`item ${index + 1} has invalid category`); + const cn = String(item.text_cn || "").trim(); + const en = String(item.text_en || "").trim(); + if (!cn || !CJK_RE.test(cn) || cn.length > 180) issues.push(`item ${index + 1} has invalid Chinese text`); + if (!en || CJK_RE.test(en) || en.length > 220) issues.push(`item ${index + 1} has invalid English text`); + const refs = Array.isArray(item.source_refs) ? item.source_refs.map(String).filter(Boolean) : []; + if (!refs.length) issues.push(`item ${index + 1} has no source_refs`); + for (const ref of refs) { + covered.add(ref); + if (!validRefs.has(ref)) issues.push(`item ${index + 1} has unknown source_ref ${ref}`); + } + }); + for (const required of evidence.required_source_refs || []) { + const accepted = Array.isArray(required.accepted_refs) ? required.accepted_refs.map(String) : []; + if (!accepted.some((ref) => covered.has(ref))) { + issues.push(`important source ${required.short_sha || required.sha || "unknown"} is not covered`); + } + } + const coverage = draft?.coverage || {}; + if (coverage.needs_review || Number(coverage.missing_required_count || 0) !== 0) { + issues.push("draft coverage still requires review"); + } + if (issues.length) fail(`formal docs sync draft validation failed: ${issues.join("; ")}`); + return items.map((item) => ({ + category: String(item.category), + text_cn: String(item.text_cn).trim(), + text_en: String(item.text_en).trim(), + source_refs: [...new Set(item.source_refs.map(String))], + })); +} + +export function buildFormalSyncRequest({ version, tag, sourceSha, evidence, draft, publishedAt }) { + const normalizedVersion = String(version || "").trim().replace(/^v/, ""); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(normalizedVersion)) { + fail(`formal docs sync requires a stable SemVer; received ${version || ""}`); + } + const expectedTag = `memos-local-plugin-v${normalizedVersion}`; + if (String(tag || "").trim() !== expectedTag) fail(`formal docs sync tag must equal ${expectedTag}`); + if (!/^[0-9a-f]{40}$/.test(String(sourceSha || "").trim())) fail("formal docs sync requires a 40-character tag commit SHA"); + if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) fail("formal docs sync evidence must be an object"); + if (evidence.product_id !== "openclaw-local-plugin") fail("formal docs sync evidence has an unexpected product_id"); + if (evidence.repo !== "MemTensor/MemOS") fail("formal docs sync evidence has an unexpected repository"); + if (String(evidence.target_version || "") !== `v${normalizedVersion}`) { + fail(`formal docs sync evidence target_version must equal v${normalizedVersion}`); + } + if (String(evidence.current_tag || "") !== expectedTag) { + fail(`formal docs sync evidence current_tag must equal ${expectedTag}`); + } + if (!/^[0-9a-f]{7,40}$/.test(String(evidence.git_ref || "").trim())) { + fail("formal docs sync evidence git_ref must be a commit SHA"); + } + const releaseItems = validateFormalDraft(evidence, draft); + const evidenceDigest = sha256Json(evidence); + return { + schema: FORMAL_SYNC_SCHEMA, + source_id: "openclaw-local-plugin", + source_repo: "MemTensor/MemOS", + version: `v${normalizedVersion}`, + tag: expectedTag, + source_sha: String(sourceSha).trim(), + evidence_digest: evidenceDigest, + idempotency_key: `openclaw-local-plugin:${expectedTag}:${sourceSha}:${evidenceDigest}`, + published_at: String(publishedAt || "").trim(), + evidence, + release_notes: { + release_items: releaseItems, + coverage: draft.coverage || {}, + }, + }; +} + +export function main() { + const evidenceFile = String(process.env.EVIDENCE_FILE || "").trim(); + const draftFile = String(process.env.DRAFT_FILE || "").trim(); + const outputFile = String(process.env.FORMAL_SYNC_REQUEST_FILE || "").trim(); + if (!evidenceFile || !draftFile || !outputFile) fail("EVIDENCE_FILE, DRAFT_FILE, and FORMAL_SYNC_REQUEST_FILE are required"); + const payload = buildFormalSyncRequest({ + version: process.env.RELEASE_VERSION, + tag: process.env.RELEASE_TAG, + sourceSha: process.env.RELEASE_TAG_SHA, + evidence: JSON.parse(readFileSync(evidenceFile, "utf8")), + draft: JSON.parse(readFileSync(draftFile, "utf8")), + publishedAt: process.env.RELEASE_PUBLISHED_AT, + }); + writeFileSync(outputFile, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + console.log(`Prepared ${FORMAL_SYNC_SCHEMA} request for ${payload.tag}; evidence digest ${payload.evidence_digest}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/prepare-local-plugin-formal-sync.test.mjs b/.github/scripts/prepare-local-plugin-formal-sync.test.mjs new file mode 100644 index 000000000..e98366e2c --- /dev/null +++ b/.github/scripts/prepare-local-plugin-formal-sync.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildFormalSyncRequest, validateFormalDraft } from "./prepare-local-plugin-formal-sync.mjs"; + +const sha = "a".repeat(40); +const evidence = { + product_id: "openclaw-local-plugin", + repo: "MemTensor/MemOS", + target_version: "v2.0.14", + current_tag: "memos-local-plugin-v2.0.14", + git_ref: sha.slice(0, 12), + commits: [{ sha, short_sha: sha.slice(0, 8), source_refs: [sha.slice(0, 8), sha, "#123"] }], + pull_requests: [{ number: "123" }], + required_source_refs: [{ short_sha: sha.slice(0, 8), accepted_refs: [sha.slice(0, 8), sha, "#123"] }], +}; +const draft = { + ok: true, + needs_review: false, + release_items: [{ + category: "Fixed", + text_cn: "**桥接恢复**:修复重启后的配置恢复流程。", + text_en: "**Bridge recovery**: Fixed configuration recovery after restart.", + source_refs: ["#123"], + }], + coverage: { needs_review: false, missing_required_count: 0 }, +}; + +test("builds an evidence-bound stable formal sync request", () => { + const payload = buildFormalSyncRequest({ + version: "2.0.14", + tag: "memos-local-plugin-v2.0.14", + sourceSha: sha, + evidence, + draft, + publishedAt: "2026-08-04T00:00:00Z", + }); + assert.equal(payload.version, "v2.0.14"); + assert.match(payload.evidence_digest, /^[0-9a-f]{64}$/); + assert.match(payload.idempotency_key, /memos-local-plugin-v2\.0\.14/); +}); + +test("rejects prerelease formal sync and evidence-free bullets", () => { + assert.throws( + () => buildFormalSyncRequest({ + version: "2.0.14-beta.1", + tag: "memos-local-plugin-v2.0.14-beta.1", + sourceSha: sha, + evidence, + draft, + }), + /stable SemVer/, + ); + assert.throws( + () => validateFormalDraft(evidence, { + ...draft, + release_items: [{ ...draft.release_items[0], source_refs: ["not-real"] }], + }), + /unknown source_ref/, + ); +}); + +test("rejects uncovered important evidence and mixed-language English", () => { + assert.throws( + () => validateFormalDraft(evidence, { + ...draft, + release_items: [{ + ...draft.release_items[0], + text_en: "Fixed 桥接 recovery.", + source_refs: [], + }], + }), + /invalid English text/, + ); +}); + +test("rejects evidence copied from another version, tag, repository, or source", () => { + for (const invalidEvidence of [ + { ...evidence, repo: "someone/else" }, + { ...evidence, target_version: "v2.0.13" }, + { ...evidence, current_tag: "memos-local-plugin-v2.0.13" }, + { ...evidence, git_ref: "not-a-sha" }, + ]) { + assert.throws( + () => buildFormalSyncRequest({ + version: "2.0.14", + tag: "memos-local-plugin-v2.0.14", + sourceSha: sha, + evidence: invalidEvidence, + draft, + publishedAt: "2026-08-04T00:00:00Z", + }), + /evidence/, + ); + } +}); diff --git a/.github/scripts/prepare-memos-release.mjs b/.github/scripts/prepare-memos-release.mjs index 270c69535..bcf2e8e42 100644 --- a/.github/scripts/prepare-memos-release.mjs +++ b/.github/scripts/prepare-memos-release.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; export const PRODUCT_ID = "openclaw-local-plugin"; export const PRODUCT_PATH = "apps/memos-local-plugin"; @@ -112,6 +113,7 @@ export function cleanLocalPluginVersion(raw, label = "local_plugin_version") { if (!value) fail(`${label} is required.`); if (value.startsWith("v")) fail(`${label} must not include a leading v.`); if (!parseSemver(value)) fail(`${label} must be a valid semver version, for example 2.0.12.`); + if (value.includes("+")) fail(`${label} must not contain SemVer build metadata.`); return value; } @@ -168,14 +170,40 @@ export function incrementPatchVersion(raw) { return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; } -export function validatePublishConfirmation({ dryRun, version, confirmation }) { +export function validatePublishConfirmation({ dryRun, version, localPluginVersion = "", confirmation }) { if (String(dryRun) === "true") return; - const expected = `PUBLISH v${cleanVersion(version)}`; + const requestedLocalPluginVersion = String(localPluginVersion || "").trim(); + const expected = requestedLocalPluginVersion + ? `PUBLISH v${cleanVersion(version)} WITH LOCAL PLUGIN v${cleanLocalPluginVersion(requestedLocalPluginVersion)}` + : `PUBLISH v${cleanVersion(version)}`; if (String(confirmation || "").trim() !== expected) { fail(`dry_run=false requires publish_confirmation to exactly equal: ${expected}`); } } +export function localPluginTagForVersion(raw) { + return `memos-local-plugin-v${cleanLocalPluginVersion(raw)}`; +} + +export function stableLocalPluginTags(tags, { excludeVersion = "" } = {}) { + const excluded = String(excludeVersion || "").trim().replace(/^v/, ""); + return tags + .map((tag) => String(tag || "").trim()) + .map((tag) => { + const match = /^memos-local-plugin-v(.+)$/.exec(tag); + if (!match) return null; + const parsed = parseSemver(match[1]); + if (!parsed || parsed.prerelease.length || match[1] === excluded) return null; + return { tag, version: match[1], parsed }; + }) + .filter(Boolean) + .sort((a, b) => compareSemver(b.version, a.version)); +} + +export function findPreviousStableLocalPluginTag(tags, { requestedVersion = "" } = {}) { + return stableLocalPluginTags(tags, { excludeVersion: requestedVersion })[0] || null; +} + export function validateReleaseTarget({ dryRun, targetRef }) { if (String(dryRun) === "true") return; const value = String(targetRef || "main").trim(); @@ -200,13 +228,47 @@ export function findPreviousMemOSTag(targetVersion, currentTag, tags) { .sort((a, b) => compareSemver(b.version, a.version))[0]?.tag || ""; } -function listTags() { - return tryGit(["tag", "--list", "v*"]) +function listTags(pattern = "*") { + return tryGit(["tag", "--list", pattern]) .split("\n") .map((tag) => tag.trim()) .filter(Boolean); } +export function npmVersionLookupResult({ status, output }) { + const text = String(output || ""); + if (status === 0) return true; + if (/E404|404 Not Found|No match found|is not in this registry/i.test(text)) return false; + throw new Error(`npm version lookup was inconclusive: ${redact(text).slice(0, 600)}`); +} + +function npmVersionExists(version) { + const override = String(process.env.LOCAL_PLUGIN_NPM_VERSION_EXISTS_OVERRIDE || "").trim(); + if (override === "true") return true; + if (override === "false") return false; + + let last; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const result = spawnSync( + "npm", + ["view", `@memtensor/memos-local-plugin@${version}`, "version", "--prefer-online"], + { encoding: "utf8", env: process.env, stdio: ["ignore", "pipe", "pipe"] }, + ); + last = result; + try { + return npmVersionLookupResult({ + status: result.status, + output: `${result.stdout || ""}\n${result.stderr || ""}`, + }); + } catch (error) { + if (attempt === 3) throw error; + warn(`npm version lookup attempt ${attempt}/3 was inconclusive; retrying without guessing release state.`); + execFileSync("sleep", [String(attempt * 5)]); + } + } + fail(`npm version lookup failed: ${redact(last?.stderr || last?.stdout || "unknown error")}`); +} + function resolveRef(ref) { const value = String(ref || "HEAD").trim() || "HEAD"; for (const candidate of [value, value.startsWith("origin/") ? "" : `origin/${value}`].filter(Boolean)) { @@ -483,7 +545,11 @@ function localPluginPackageVersions(previousTag, currentRef) { }; } -export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = "") { +export function validateLocalPluginVersionPlan( + evidence, + expectedVersionInput = "", + { requestedTagExists = false, npmVersionExists = false, recoveryEnabled = false } = {}, +) { const expectedVersionRaw = String(expectedVersionInput || "").trim(); const previousReleasedVersion = cleanLocalPluginVersion( evidence.local_plugin_previous_version_raw || evidence.local_plugin_package_previous_version_raw, @@ -497,63 +563,68 @@ export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = evidence.local_plugin_package_version_raw || evidence.local_plugin_version_raw, "local plugin package.json version", ); - const currentPackageIsPrerelease = (parseSemver(currentPackageVersion)?.prerelease || []).length > 0; - const packageOrder = compareSemver(currentPackageVersion, previousPackageVersion); - const packageVsReleasedOrder = compareSemver(currentPackageVersion, previousReleasedVersion); - if (packageOrder < 0) { + const hasProductChanges = Boolean(evidence.has_product_changes); + const hasUserFacingChanges = Boolean(evidence.has_user_facing_product_changes); + const nextPatchVersion = incrementPatchVersion(previousReleasedVersion); + const releaseRequested = Boolean(expectedVersionRaw); + const expectedVersion = releaseRequested + ? cleanLocalPluginVersion(expectedVersionRaw, "local_plugin_version input") + : ""; + + if (expectedVersion && parseSemver(expectedVersion)?.prerelease.length) { + fail("MemOS weekly local_plugin_version must be a stable SemVer. Use the standalone publisher for prereleases."); + } + if (releaseRequested && !hasUserFacingChanges) { fail( - `MemOS local plugin package version moved backwards: ${displayVersion(previousPackageVersion)} -> ${displayVersion(currentPackageVersion)}.`, + hasProductChanges + ? "local_plugin_version was provided, but no unpublished user-facing feature/fix/performance evidence was found" + : "local_plugin_version was provided, but no unpublished apps/memos-local-plugin/** changes were found", ); } - - const hasProductChanges = Boolean(evidence.has_product_changes); - const hasUserFacingChanges = Boolean(evidence.has_user_facing_product_changes); - let expectedVersion = ""; - let resolvedVersion = previousReleasedVersion; - let versionSource = hasProductChanges ? "no_user_facing_product_changes" : "no_product_path_changes"; - let autoIncremented = false; - let inputIgnored = false; - let inputIgnoredReason = ""; - - if (hasUserFacingChanges) { - expectedVersion = expectedVersionRaw - ? cleanLocalPluginVersion(expectedVersionRaw, "local_plugin_version input") - : ""; - resolvedVersion = currentPackageVersion; - versionSource = `${PRODUCT_PATH}/package.json`; - if (currentPackageIsPrerelease) { - resolvedVersion = incrementPatchVersion(previousReleasedVersion); - versionSource = "auto_patch_from_previous_released_version_prerelease_package_ignored"; - autoIncremented = true; - } else if (packageVsReleasedOrder <= 0) { - resolvedVersion = incrementPatchVersion(previousReleasedVersion); - versionSource = "auto_patch_from_previous_released_version"; - autoIncremented = true; - } - } else if (expectedVersionRaw) { - inputIgnored = true; - inputIgnoredReason = hasProductChanges - ? "local plugin path changed, but no user-facing feature/fix/performance evidence was found" - : "no local plugin path changes in apps/memos-local-plugin/**"; + if (releaseRequested && expectedVersion !== nextPatchVersion) { + fail( + `MemOS weekly local_plugin_version must be the next stable patch after ${displayVersion(previousReleasedVersion)}: expected ${displayVersion(nextPatchVersion)}, received ${displayVersion(expectedVersion)}. Use the standalone publisher for an intentional major/minor release.`, + ); } - - if (expectedVersion && expectedVersion !== resolvedVersion) { + if (releaseRequested && (requestedTagExists || npmVersionExists) && !recoveryEnabled) { + const usedBy = [requestedTagExists ? "git tag" : "", npmVersionExists ? "npm" : ""].filter(Boolean).join(" and "); fail( - `local_plugin_version input ${displayVersion(expectedVersion)} does not match the resolved MemOS local plugin docs version ${displayVersion(resolvedVersion)}.`, + `${displayVersion(expectedVersion)} is already used by ${usedBy}. Normal weekly releases require a new version; enable explicit recovery only for a verified partial failure from this same source.`, ); } + if (releaseRequested && recoveryEnabled && requestedTagExists !== npmVersionExists) { + fail( + `Recovery requires both npm and ${localPluginTagForVersion(expectedVersion)} to exist. Refusing an incomplete or ambiguous recovery state.`, + ); + } + + const resolvedVersion = releaseRequested ? expectedVersion : previousReleasedVersion; + const inputIgnoredReason = !releaseRequested && hasUserFacingChanges + ? "unpublished user-facing local plugin changes were detected, but local_plugin_version was left blank" + : !releaseRequested && hasProductChanges + ? "local plugin path changes were detected, but no user-facing evidence requires a release" + : !releaseRequested + ? "no unpublished local plugin path changes were detected" + : ""; return { ok: true, expected_version: expectedVersion ? displayVersion(expectedVersion) : "", previous_version: displayVersion(previousReleasedVersion), version: displayVersion(resolvedVersion), - version_changed: resolvedVersion !== previousReleasedVersion, - version_required: Boolean(evidence.has_user_facing_product_changes), - version_source: versionSource, - auto_incremented: autoIncremented, - input_ignored: inputIgnored, + version_changed: releaseRequested, + version_required: releaseRequested, + release_requested: releaseRequested, + pending_local_plugin_changes: !releaseRequested && hasUserFacingChanges, + version_source: releaseRequested ? "manual_weekly_release_opt_in" : "latest_stable_local_plugin_tag", + auto_incremented: false, + input_ignored: false, input_ignored_reason: inputIgnoredReason, input_raw: expectedVersionRaw, + next_patch_version: displayVersion(nextPatchVersion), + local_plugin_tag: releaseRequested ? localPluginTagForVersion(expectedVersion) : "", + requested_tag_exists: Boolean(requestedTagExists), + npm_version_exists: Boolean(npmVersionExists), + recovery_enabled: Boolean(recoveryEnabled), package_previous_version: displayVersion(previousPackageVersion), package_version: displayVersion(currentPackageVersion), package_version_changed: previousPackageVersion !== currentPackageVersion, @@ -578,8 +649,17 @@ function collectPatchSnippets(range, changedFiles) { return snippets; } -export function collectLocalPluginEvidence({ previousTag, currentTag, currentRef, targetVersion, repo }) { - const range = `${previousTag}..${currentRef}`; +export function collectLocalPluginEvidence({ + previousTag, + previousLocalPluginTag, + previousLocalPluginVersion, + currentTag, + currentRef, + targetVersion, + repo, +}) { + const evidenceBaseline = previousLocalPluginTag || previousTag; + const range = `${evidenceBaseline}..${currentRef}`; const commitText = tryGit([ "log", "--format=%H%x09%h%x09%an%x09%ad%x09%s", @@ -623,18 +703,21 @@ export function collectLocalPluginEvidence({ previousTag, currentTag, currentRef const evidenceCommits = evidenceCommitsForRelease(commits, aggregateItems, { revertedKeys }); const importantCommits = evidenceCommits.filter((commit) => isImportantCommit(commit, { revertedKeys })); const skipReason = localPluginSkipReason({ changedFiles, importantCommits }); - const localPluginVersion = localPluginPackageVersions(previousTag, currentRef); + const localPluginVersion = localPluginPackageVersions(evidenceBaseline, currentRef); return { product_id: PRODUCT_ID, product_title: PRODUCT_TITLE, repo, release_repo: repo, - previous_tag: previousTag, + previous_tag: evidenceBaseline, current_tag: currentTag, + memos_previous_tag: previousTag, + memos_current_tag: currentTag, + local_plugin_previous_tag: previousLocalPluginTag || "", target_version: displayVersion(targetVersion), memos_release_version: displayVersion(targetVersion), - local_plugin_previous_version: localPluginVersion.previous_version, - local_plugin_previous_version_raw: localPluginVersion.previous_version_raw, + local_plugin_previous_version: displayVersion(previousLocalPluginVersion || localPluginVersion.previous_version_raw), + local_plugin_previous_version_raw: previousLocalPluginVersion || localPluginVersion.previous_version_raw, local_plugin_version: localPluginVersion.version, local_plugin_version_raw: localPluginVersion.version_raw, local_plugin_version_changed: localPluginVersion.version_changed, @@ -669,7 +752,7 @@ export function collectLocalPluginEvidence({ previousTag, currentTag, currentRef important_diff: { [PRODUCT_PATHS[0]]: collectPatchSnippets(range, changedFiles), }, - package_changes: packageChanges(previousTag, currentRef), + package_changes: packageChanges(evidenceBaseline, currentRef), test_changes: changedFiles.filter((item) => /(^|\/)(test|tests|__tests__)\//.test(item.path) || /\.test\./.test(item.path)), docs_changes: changedFiles.filter((item) => /\.(md|mdx|rst)$/i.test(item.path)), release_note_quality_request: { @@ -1037,6 +1120,34 @@ function duplicateKeyForItem(item) { } export function validateDraft(draft, evidence) { + const operatorSkippedRelease = + evidence.local_plugin_release_requested === false && + evidence.dry_run === false; + if (operatorSkippedRelease) { + const issues = []; + if (!draft.ok) issues.push({ kind: "draft_not_ok", message: "draft ok=false" }); + if (draft.needs_review) issues.push({ kind: "needs_review", message: "draft needs review" }); + if (draft.release_items.length) { + issues.push({ + kind: "unexpected_release_items", + message: "release_items must be empty when the operator skipped local-plugin publishing", + }); + } + return { + ok: issues.length === 0, + needs_review: issues.length > 0, + issue_count: issues.length, + issues, + skipped_by_operator: true, + coverage: { + required_count: 0, + covered_required_count: 0, + missing_required_count: 0, + missing_required_refs: [], + }, + }; + } + const issues = []; const validRefs = new Set(); for (const commit of evidence.commits || []) { @@ -1156,12 +1267,17 @@ export function validateDraft(draft, evidence) { } export async function requestDocAgentDraft(evidence) { - if (!evidence.has_user_facing_product_changes) { + const dryRunPreview = evidence.dry_run === true || String(evidence.dry_run) === "true"; + const operatorSkippedRelease = !evidence.local_plugin_release_requested && !dryRunPreview; + if (!evidence.has_user_facing_product_changes || operatorSkippedRelease) { + const warning = operatorSkippedRelease + ? "MemOS release operator left local_plugin_version blank; skipped the Doc Agent draft request for this real release." + : evidence.skip_reason || "No user-facing MemOS local plugin changes in this MemOS release range."; return { ok: true, needs_review: false, confidence: "high", - warnings: [evidence.skip_reason || "No user-facing MemOS local plugin changes in this MemOS release range."], + warnings: [warning], release_items: [], coverage: { required_count: 0, covered_required_count: 0, missing_required_count: 0 }, validation_attempt_count: 1, @@ -1288,7 +1404,13 @@ export function buildDocsPreview(draft, evidence) { has_product_changes: evidence.has_product_changes, has_user_facing_product_changes: evidence.has_user_facing_product_changes, skip_reason: evidence.skip_reason, - docs_action: draft.release_items.length ? "preview_plugin_tab_entry" : "skip_plugin_tab_entry", + docs_action: evidence.local_plugin_release_requested + ? draft.release_items.length + ? "preview_plugin_tab_entry" + : "skip_plugin_tab_entry" + : evidence.pending_local_plugin_changes + ? "skip_pending_manual_local_plugin_version" + : "skip_plugin_tab_entry", would_create_docs_pr: false, files: ["content/cn/plugin-changelog.yml", "content/en/plugin-changelog.yml"], cn: makeSide("zh"), @@ -1301,7 +1423,8 @@ export function docsPreviewMarkdown(preview, draft, evidence) { `# ${PRODUCT_TITLE.zh}-${evidence.local_plugin_version || evidence.current_tag}`, "", `- source: ${evidence.repo}`, - `- memos_release_range: ${evidence.previous_tag}...${evidence.current_tag}`, + `- memos_release_range: ${evidence.memos_previous_tag}...${evidence.memos_current_tag}`, + `- local_plugin_evidence_range: ${evidence.local_plugin_previous_tag}...${evidence.git_ref}`, `- local_plugin_version: ${evidence.local_plugin_version || "n/a"}`, `- local_plugin_previous_version: ${evidence.local_plugin_previous_version || "n/a"}`, `- local_plugin_version_source: ${evidence.local_plugin_version_source || `${PRODUCT_PATH}/package.json`}`, @@ -1352,15 +1475,40 @@ function writeJson(path, value) { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +export function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function sha256Json(value) { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + export async function run() { const version = cleanVersion(process.env.RELEASE_VERSION); if (!version) fail("RELEASE_VERSION is required."); - if (!parseSemver(version)) fail(`Invalid semver version: ${version}`); + const parsedReleaseVersion = parseSemver(version); + if (!parsedReleaseVersion) fail(`Invalid semver version: ${version}`); + if (parsedReleaseVersion.prerelease.length || version.includes("+")) { + fail(`MemOS Release — Publish only accepts a stable X.Y.Z version; received ${version}.`); + } const dryRun = String(process.env.DRY_RUN ?? "true"); + const localPluginVersionInput = String(process.env.LOCAL_PLUGIN_VERSION || "").trim(); + const localPluginRecoveryEnabled = String(process.env.RECOVER_EXISTING_LOCAL_PLUGIN_PUBLISH || "") === "true"; + if (localPluginRecoveryEnabled && !localPluginVersionInput) { + fail("recover_existing_local_plugin_publish=true requires local_plugin_version."); + } validatePublishConfirmation({ dryRun, version, + localPluginVersion: localPluginVersionInput, confirmation: process.env.PUBLISH_CONFIRMATION || "", }); @@ -1369,8 +1517,27 @@ export async function run() { const targetRefInput = process.env.TARGET_REF || "main"; validateReleaseTarget({ dryRun, targetRef: targetRefInput }); const target = resolveRef(targetRefInput); - const previousTag = process.env.PREVIOUS_TAG || findPreviousMemOSTag(version, currentTag, listTags()); + const allTags = listTags(); + const previousTag = process.env.PREVIOUS_TAG || findPreviousMemOSTag(version, currentTag, allTags); if (!previousTag) fail(`Cannot find previous MemOS v* tag before ${currentTag}.`); + const requestedLocalPluginVersion = localPluginVersionInput + ? cleanLocalPluginVersion(localPluginVersionInput, "local_plugin_version input") + : ""; + const previousLocalPlugin = findPreviousStableLocalPluginTag(allTags, { + requestedVersion: requestedLocalPluginVersion, + }); + if (!previousLocalPlugin) { + fail("Cannot find a previous stable memos-local-plugin-v* tag for local plugin evidence and version validation."); + } + const requestedLocalPluginTag = requestedLocalPluginVersion + ? localPluginTagForVersion(requestedLocalPluginVersion) + : ""; + const requestedLocalPluginTagExists = Boolean( + requestedLocalPluginTag && tryGit(["rev-parse", "--verify", `refs/tags/${requestedLocalPluginTag}^{commit}`]), + ); + const requestedNpmVersionExists = requestedLocalPluginVersion + ? npmVersionExists(requestedLocalPluginVersion) + : false; const existingTag = existingReleaseTagState(currentTag, target.sha); if (existingTag.publish_blocked && dryRun !== "true") { fail(existingTag.message); @@ -1389,12 +1556,18 @@ export async function run() { }); const evidence = collectLocalPluginEvidence({ previousTag, + previousLocalPluginTag: previousLocalPlugin.tag, + previousLocalPluginVersion: previousLocalPlugin.version, currentTag, currentRef: target.sha, targetVersion: version, repo, }); - const localPluginVersionPlan = validateLocalPluginVersionPlan(evidence, process.env.LOCAL_PLUGIN_VERSION || ""); + const localPluginVersionPlan = validateLocalPluginVersionPlan(evidence, localPluginVersionInput, { + requestedTagExists: requestedLocalPluginTagExists, + npmVersionExists: requestedNpmVersionExists, + recoveryEnabled: localPluginRecoveryEnabled, + }); evidence.local_plugin_version_plan = localPluginVersionPlan; evidence.local_plugin_previous_version = localPluginVersionPlan.previous_version; evidence.local_plugin_previous_version_raw = localPluginVersionPlan.previous_version.replace(/^v/, ""); @@ -1406,6 +1579,14 @@ export async function run() { evidence.local_plugin_version_input_ignored = localPluginVersionPlan.input_ignored; evidence.local_plugin_version_input_ignored_reason = localPluginVersionPlan.input_ignored_reason; evidence.local_plugin_version_input_raw = localPluginVersionPlan.input_raw; + evidence.local_plugin_release_requested = localPluginVersionPlan.release_requested; + evidence.pending_local_plugin_changes = localPluginVersionPlan.pending_local_plugin_changes; + evidence.local_plugin_tag = localPluginVersionPlan.local_plugin_tag; + evidence.local_plugin_tag_exists = localPluginVersionPlan.requested_tag_exists; + evidence.local_plugin_npm_version_exists = localPluginVersionPlan.npm_version_exists; + evidence.local_plugin_recovery_enabled = localPluginVersionPlan.recovery_enabled; + evidence.local_plugin_next_patch_version = localPluginVersionPlan.next_patch_version; + evidence.dry_run = dryRun === "true"; evidence.local_plugin_package_previous_version = localPluginVersionPlan.package_previous_version; evidence.local_plugin_package_previous_version_raw = localPluginVersionPlan.package_previous_version.replace(/^v/, ""); evidence.local_plugin_package_version = localPluginVersionPlan.package_version; @@ -1438,13 +1619,28 @@ export async function run() { const docsPreviewMarkdownFile = join(outputRoot, "local-plugin-docs-preview.md"); const docsPreviewMarkdownAliasFile = join(outputRoot, "docs-preview.md"); const qualityReportFile = join(outputRoot, "quality-report.json"); + const releaseIntentPreviewFile = join(outputRoot, "local-plugin-release-intent.json"); const readmeFile = join(outputRoot, "README.md"); writeFileSync(releaseNotesFile, `${releaseNotes.body.trim()}\n`, "utf8"); writeFileSync(releaseNotesAliasFile, `${releaseNotes.body.trim()}\n`, "utf8"); const redactedEvidence = JSON.parse(redact(JSON.stringify(evidence, null, 2))); + const evidenceDigest = sha256Json(redactedEvidence); writeJson(evidenceFile, redactedEvidence); writeJson(evidenceAliasFile, redactedEvidence); + writeJson(releaseIntentPreviewFile, { + schema: "memos.local-plugin.release-intent.v1", + enabled: Boolean(localPluginVersionPlan.release_requested), + version: localPluginVersionPlan.expected_version, + tag: localPluginVersionPlan.local_plugin_tag, + source_sha: localPluginVersionPlan.release_requested ? target.sha : "", + evidence_digest: evidenceDigest, + status: localPluginVersionPlan.release_requested + ? "preview_pending_package_publish" + : localPluginVersionPlan.pending_local_plugin_changes + ? "skipped_by_release_operator" + : "no_release_requested", + }); writeJson(draftFile, draft); writeJson(docsPreviewFile, preview); writeJson(docsPreviewAliasFile, preview); @@ -1470,6 +1666,15 @@ export async function run() { local_plugin_version_input_ignored: evidence.local_plugin_version_input_ignored, local_plugin_version_input_ignored_reason: evidence.local_plugin_version_input_ignored_reason, local_plugin_expected_version: localPluginVersionPlan.expected_version, + local_plugin_release_requested: localPluginVersionPlan.release_requested, + pending_local_plugin_changes: localPluginVersionPlan.pending_local_plugin_changes, + local_plugin_tag: localPluginVersionPlan.local_plugin_tag, + local_plugin_previous_tag: evidence.local_plugin_previous_tag, + local_plugin_next_patch_version: localPluginVersionPlan.next_patch_version, + local_plugin_tag_exists: localPluginVersionPlan.requested_tag_exists, + local_plugin_npm_version_exists: localPluginVersionPlan.npm_version_exists, + local_plugin_recovery_enabled: localPluginVersionPlan.recovery_enabled, + evidence_digest: evidenceDigest, local_plugin_package_version: evidence.local_plugin_package_version, local_plugin_package_previous_version: evidence.local_plugin_package_previous_version, local_plugin_package_version_changed: evidence.local_plugin_package_version_changed, @@ -1522,6 +1727,15 @@ export async function run() { `- local_plugin_version_input_ignored: ${evidence.local_plugin_version_input_ignored}`, `- local_plugin_version_input_ignored_reason: ${evidence.local_plugin_version_input_ignored_reason || "n/a"}`, `- local_plugin_expected_version: ${localPluginVersionPlan.expected_version || "n/a"}`, + `- local_plugin_release_requested: ${localPluginVersionPlan.release_requested}`, + `- pending_local_plugin_changes: ${localPluginVersionPlan.pending_local_plugin_changes}`, + `- local_plugin_tag: ${localPluginVersionPlan.local_plugin_tag || "n/a"}`, + `- local_plugin_previous_tag: ${evidence.local_plugin_previous_tag}`, + `- local_plugin_next_patch_version: ${localPluginVersionPlan.next_patch_version}`, + `- local_plugin_tag_exists: ${localPluginVersionPlan.requested_tag_exists}`, + `- local_plugin_npm_version_exists: ${localPluginVersionPlan.npm_version_exists}`, + `- local_plugin_recovery_enabled: ${localPluginVersionPlan.recovery_enabled}`, + `- evidence_digest: ${evidenceDigest}`, `- local_plugin_package_version: ${evidence.local_plugin_package_version}`, `- local_plugin_package_previous_version: ${evidence.local_plugin_package_previous_version}`, `- local_plugin_package_version_changed: ${evidence.local_plugin_package_version_changed}`, @@ -1552,6 +1766,7 @@ export async function run() { "- docs-preview.md", "- docs-preview.json", "- quality-report.json", + "- local-plugin-release-intent.json", "", ].join("\n"), "utf8", @@ -1564,6 +1779,8 @@ export async function run() { appendOutput("docs_preview_file", docsPreviewFile); appendOutput("docs_preview_markdown_file", docsPreviewMarkdownFile); appendOutput("quality_report_file", qualityReportFile); + appendOutput("release_intent_preview_file", releaseIntentPreviewFile); + appendOutput("evidence_digest", evidenceDigest); appendOutput("source_id", PRODUCT_ID); appendOutput("previous_tag", previousTag); appendOutput("current_tag", currentTag); @@ -1576,6 +1793,14 @@ export async function run() { appendOutput("local_plugin_version_input_ignored", String(evidence.local_plugin_version_input_ignored)); appendOutput("local_plugin_version_input_ignored_reason", evidence.local_plugin_version_input_ignored_reason || ""); appendOutput("local_plugin_expected_version", localPluginVersionPlan.expected_version || ""); + appendOutput("local_plugin_release_requested", String(localPluginVersionPlan.release_requested)); + appendOutput("pending_local_plugin_changes", String(localPluginVersionPlan.pending_local_plugin_changes)); + appendOutput("local_plugin_tag", localPluginVersionPlan.local_plugin_tag || ""); + appendOutput("local_plugin_previous_tag", evidence.local_plugin_previous_tag || ""); + appendOutput("local_plugin_next_patch_version", localPluginVersionPlan.next_patch_version || ""); + appendOutput("local_plugin_tag_exists", String(localPluginVersionPlan.requested_tag_exists)); + appendOutput("local_plugin_npm_version_exists", String(localPluginVersionPlan.npm_version_exists)); + appendOutput("local_plugin_recovery_enabled", String(localPluginVersionPlan.recovery_enabled)); appendOutput("local_plugin_package_version", evidence.local_plugin_package_version); appendOutput("local_plugin_package_previous_version", evidence.local_plugin_package_previous_version); appendOutput("local_plugin_package_version_changed", String(evidence.local_plugin_package_version_changed)); diff --git a/.github/scripts/prepare-memos-release.test.mjs b/.github/scripts/prepare-memos-release.test.mjs index f0f734a7c..0de350430 100644 --- a/.github/scripts/prepare-memos-release.test.mjs +++ b/.github/scripts/prepare-memos-release.test.mjs @@ -17,9 +17,12 @@ import { docsPreviewMarkdown, existingReleaseTagState, fallbackTopicForText, + findPreviousStableLocalPluginTag, findPreviousMemOSTag, generateGitHubReleaseNotes, incrementPatchVersion, + localPluginTagForVersion, + npmVersionLookupResult, requestDocAgentDraft, sourceRefsFromText, validateDraft, @@ -37,6 +40,10 @@ const evidence = { repo: "MemTensor/MemOS", previous_tag: "v2.0.24", current_tag: "v2.0.25", + memos_previous_tag: "v2.0.24", + memos_current_tag: "v2.0.25", + local_plugin_previous_tag: "memos-local-plugin-v2.0.10", + git_ref: "0123456789abcdef0123456789abcdef01234567", local_plugin_previous_version: "v2.0.10", local_plugin_previous_version_raw: "2.0.10", local_plugin_version: "v2.0.11", @@ -49,6 +56,8 @@ const evidence = { local_plugin_package_version: "v2.0.11", local_plugin_package_version_raw: "2.0.11", local_plugin_package_version_changed: true, + local_plugin_release_requested: true, + pending_local_plugin_changes: false, product_paths: ["apps/memos-local-plugin/**"], has_product_changes: true, has_user_facing_product_changes: true, @@ -178,257 +187,93 @@ test("rejects leading v in manual version input", () => { assert.equal(cleanLocalPluginVersion("2.0.12"), "2.0.12"); assert.throws(() => cleanLocalPluginVersion(""), /is required/); assert.throws(() => cleanLocalPluginVersion("v2.0.12"), /must not include a leading v/); + assert.throws(() => cleanLocalPluginVersion("2.0.12+build.1"), /must not contain SemVer build metadata/); assert.equal(incrementPatchVersion("2.0.12"), "2.0.13"); assert.throws(() => incrementPatchVersion("2.0.12-beta.1"), /Cannot auto-increment prerelease/); }); -test("resolves the local plugin docs version from package or auto patch increment", () => { - assert.deepEqual(validateLocalPluginVersionPlan(evidence, ""), { - ok: true, - expected_version: "", - previous_version: "v2.0.10", - version: "v2.0.11", - version_changed: true, - version_required: true, - version_source: "apps/memos-local-plugin/package.json", - auto_incremented: false, - input_ignored: false, - input_ignored_reason: "", - input_raw: "", - package_previous_version: "v2.0.10", - package_version: "v2.0.11", - package_version_changed: true, - }); - assert.deepEqual(validateLocalPluginVersionPlan(evidence, "2.0.11"), { - ok: true, - expected_version: "v2.0.11", - previous_version: "v2.0.10", - version: "v2.0.11", - version_changed: true, - version_required: true, - version_source: "apps/memos-local-plugin/package.json", - auto_incremented: false, - input_ignored: false, - input_ignored_reason: "", - input_raw: "2.0.11", - package_previous_version: "v2.0.10", - package_version: "v2.0.11", - package_version_changed: true, - }); - assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12"), /does not match/); +test("leaves local-plugin publishing disabled when local_plugin_version is blank", () => { + const plan = validateLocalPluginVersionPlan(evidence, ""); + assert.equal(plan.release_requested, false); + assert.equal(plan.pending_local_plugin_changes, true); + assert.equal(plan.version, "v2.0.10"); + assert.equal(plan.next_patch_version, "v2.0.11"); + assert.equal(plan.local_plugin_tag, ""); + assert.match(plan.input_ignored_reason, /left blank/); +}); - assert.deepEqual( - validateLocalPluginVersionPlan( - { - ...evidence, - local_plugin_previous_version: "v2.0.11", - local_plugin_previous_version_raw: "2.0.11", - local_plugin_version: "v2.0.12-beta.1", - local_plugin_version_raw: "2.0.12-beta.1", - local_plugin_version_changed: true, - local_plugin_package_previous_version: "v2.0.11", - local_plugin_package_previous_version_raw: "2.0.11", - local_plugin_package_version: "v2.0.12-beta.1", - local_plugin_package_version_raw: "2.0.12-beta.1", - local_plugin_package_version_changed: true, - }, - "2.0.12", - ), - { - ok: true, - expected_version: "v2.0.12", - previous_version: "v2.0.11", - version: "v2.0.12", - version_changed: true, - version_required: true, - version_source: "auto_patch_from_previous_released_version_prerelease_package_ignored", - auto_incremented: true, - input_ignored: false, - input_ignored_reason: "", - input_raw: "2.0.12", - package_previous_version: "v2.0.11", - package_version: "v2.0.12-beta.1", - package_version_changed: true, - }, - ); +test("accepts only the next unused stable patch for a weekly local-plugin release", () => { + const plan = validateLocalPluginVersionPlan(evidence, "2.0.11"); + assert.equal(plan.release_requested, true); + assert.equal(plan.pending_local_plugin_changes, false); + assert.equal(plan.version, "v2.0.11"); + assert.equal(plan.version_source, "manual_weekly_release_opt_in"); + assert.equal(plan.local_plugin_tag, "memos-local-plugin-v2.0.11"); + assert.equal(plan.package_version, "v2.0.11"); + assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12"), /next stable patch/); + assert.throws(() => validateLocalPluginVersionPlan(evidence, "3.0.0"), /next stable patch/); + assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.11-beta.1"), /stable SemVer/); +}); - assert.deepEqual( - validateLocalPluginVersionPlan({ - ...evidence, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.10", - local_plugin_package_version_raw: "2.0.10", - local_plugin_package_version_changed: false, - }), - { - ok: true, - expected_version: "", - previous_version: "v2.0.10", - version: "v2.0.11", - version_changed: true, - version_required: true, - version_source: "auto_patch_from_previous_released_version", - auto_incremented: true, - input_ignored: false, - input_ignored_reason: "", - input_raw: "", - package_previous_version: "v2.0.10", - package_version: "v2.0.10", - package_version_changed: false, - }, - ); - assert.doesNotThrow(() => - validateLocalPluginVersionPlan( - { - ...evidence, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.10", - local_plugin_package_version_raw: "2.0.10", - local_plugin_package_version_changed: false, - }, - "2.0.11", - ), - ); +test("fails when a weekly local-plugin version is supplied without publishable evidence", () => { assert.throws( - () => - validateLocalPluginVersionPlan( - { - ...evidence, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.10", - local_plugin_package_version_raw: "2.0.10", - local_plugin_package_version_changed: false, - }, - "2.0.12", - ), - /does not match/, - ); - assert.deepEqual( - validateLocalPluginVersionPlan({ - ...evidence, - has_user_facing_product_changes: false, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.10", - local_plugin_package_version_raw: "2.0.10", - local_plugin_package_version_changed: false, - }), - { - ok: true, - expected_version: "", - previous_version: "v2.0.10", - version: "v2.0.10", - version_changed: false, - version_required: false, - version_source: "no_user_facing_product_changes", - auto_incremented: false, - input_ignored: false, - input_ignored_reason: "", - input_raw: "", - package_previous_version: "v2.0.10", - package_version: "v2.0.10", - package_version_changed: false, - }, + () => validateLocalPluginVersionPlan({ ...evidence, has_product_changes: false, has_user_facing_product_changes: false }, "2.0.11"), + /no unpublished apps\/memos-local-plugin/, ); assert.throws( - () => - validateLocalPluginVersionPlan({ - ...evidence, - local_plugin_package_previous_version: "v2.0.10", - local_plugin_package_previous_version_raw: "2.0.10", - local_plugin_package_version: "v2.0.9", - local_plugin_package_version_raw: "2.0.9", - }), - /moved backwards/, + () => validateLocalPluginVersionPlan({ ...evidence, has_user_facing_product_changes: false }, "2.0.11"), + /no unpublished user-facing/, + ); + const skipped = validateLocalPluginVersionPlan( + { ...evidence, has_product_changes: false, has_user_facing_product_changes: false }, + "", ); + assert.equal(skipped.release_requested, false); + assert.equal(skipped.pending_local_plugin_changes, false); }); -test("ignores local plugin version input when the release has no local-plugin path changes", () => { - assert.deepEqual( - validateLocalPluginVersionPlan( - { - ...evidence, - has_product_changes: false, - has_user_facing_product_changes: false, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.10", - local_plugin_package_version_raw: "2.0.10", - local_plugin_package_version_changed: false, - }, - "v9.9.9", - ), - { - ok: true, - expected_version: "", - previous_version: "v2.0.10", - version: "v2.0.10", - version_changed: false, - version_required: false, - version_source: "no_product_path_changes", - auto_incremented: false, - input_ignored: true, - input_ignored_reason: "no local plugin path changes in apps/memos-local-plugin/**", - input_raw: "v9.9.9", - package_previous_version: "v2.0.10", - package_version: "v2.0.10", - package_version_changed: false, - }, +test("used npm/tag versions fail closed unless complete recovery is explicit", () => { + assert.throws( + () => validateLocalPluginVersionPlan(evidence, "2.0.11", { requestedTagExists: true }), + /already used by git tag/, + ); + assert.throws( + () => validateLocalPluginVersionPlan(evidence, "2.0.11", { npmVersionExists: true }), + /already used by npm/, ); + assert.throws( + () => validateLocalPluginVersionPlan(evidence, "2.0.11", { + requestedTagExists: true, + npmVersionExists: false, + recoveryEnabled: true, + }), + /Recovery requires both npm/, + ); + const recovered = validateLocalPluginVersionPlan(evidence, "2.0.11", { + requestedTagExists: true, + npmVersionExists: true, + recoveryEnabled: true, + }); + assert.equal(recovered.recovery_enabled, true); + assert.equal(recovered.release_requested, true); }); -test("ignores local plugin version input for maintenance-only local-plugin changes", () => { - assert.deepEqual( - validateLocalPluginVersionPlan( - { - ...evidence, - has_product_changes: true, - has_user_facing_product_changes: false, - local_plugin_previous_version: "v2.0.10", - local_plugin_previous_version_raw: "2.0.10", - local_plugin_version: "v2.0.10", - local_plugin_version_raw: "2.0.10", - local_plugin_version_changed: false, - local_plugin_package_version: "v2.0.12", - local_plugin_package_version_raw: "2.0.12", - local_plugin_package_version_changed: true, - }, - "2.0.12", - ), - { - ok: true, - expected_version: "", - previous_version: "v2.0.10", - version: "v2.0.10", - version_changed: false, - version_required: false, - version_source: "no_user_facing_product_changes", - auto_incremented: false, - input_ignored: true, - input_ignored_reason: "local plugin path changed, but no user-facing feature/fix/performance evidence was found", - input_raw: "2.0.12", - package_previous_version: "v2.0.10", - package_version: "v2.0.12", - package_version_changed: true, - }, +test("resolves stable local-plugin tag baselines independently from MemOS tags", () => { + const tags = [ + "v2.0.27", + "memos-local-plugin-v2.0.10", + "memos-local-plugin-v2.0.12-beta.1", + "memos-local-plugin-v2.0.11", + ]; + const previous = findPreviousStableLocalPluginTag(tags); + assert.equal(previous.tag, "memos-local-plugin-v2.0.11"); + assert.equal(previous.version, "2.0.11"); + assert.equal(localPluginTagForVersion("2.0.12"), "memos-local-plugin-v2.0.12"); + assert.equal(npmVersionLookupResult({ status: 0, output: '"2.0.12"' }), true); + assert.equal(npmVersionLookupResult({ status: 1, output: "E404 Not Found" }), false); + assert.throws( + () => npmVersionLookupResult({ status: 1, output: "ECONNRESET" }), + /npm version lookup was inconclusive: ECONNRESET/, ); }); @@ -441,6 +286,21 @@ test("requires an exact publish confirmation for non-dry-run releases", () => { assert.doesNotThrow(() => validatePublishConfirmation({ dryRun: "false", version: "2.0.25", confirmation: "PUBLISH v2.0.25" }), ); + assert.throws( + () => validatePublishConfirmation({ + dryRun: "false", + version: "2.0.25", + localPluginVersion: "2.0.11", + confirmation: "PUBLISH v2.0.25", + }), + /WITH LOCAL PLUGIN v2\.0\.11/, + ); + assert.doesNotThrow(() => validatePublishConfirmation({ + dryRun: "false", + version: "2.0.25", + localPluginVersion: "2.0.11", + confirmation: "PUBLISH v2.0.25 WITH LOCAL PLUGIN v2.0.11", + })); }); test("publish workflow defaults real releases to draft before release.published", () => { @@ -455,17 +315,30 @@ test("publish workflow defaults real releases to draft before release.published" assert.match(workflow, /wait_for_remote_tag\(\)/); assert.match(workflow, /wait_for_release_visibility\(\)/); assert.match(workflow, /create_release_if_missing\(\)/); - assert.match(workflow, /--json isDraft,tagName,targetCommitish,url/); + assert.match(workflow, /--json body,isDraft,tagName,targetCommitish,url/); assert.match(workflow, /target_commitish/); + assert.match(workflow, /already exists with different notes or local-plugin intent/); assert.match(workflow, /GitHub Release \$\{CURRENT_TAG\} targets \$\{target_commitish\}, expected \$\{TARGET_SHA\}/); assert.match(workflow, /exists after a failed create response; treating it as success/); assert.match(workflow, /did not become visible in time/); assert.match(workflow, /Publish manually to trigger release\.published/); + assert.match(workflow, /local_plugin_version:/); + assert.match(workflow, /Leave blank to skip local-plugin npm\/tag\/docs/); + assert.match(workflow, /uses: \.\/\.github\/workflows\/memos-local-plugin-publish\.yml/); + assert.match(workflow, /docs_sync_mode: defer_to_memos_release/); + assert.match(workflow, /needs\.prepare\.outputs\.local_plugin_release_requested == 'true'/); + assert.match(workflow, /permissions:\n\s+contents: write\n\s+uses: \.\/\.github\/workflows\/memos-local-plugin-publish\.yml/); + assert.match(workflow, /version: \$\{\{ needs\.prepare\.outputs\.local_plugin_version \}\}/); + assert.match(workflow, /needs\.publish-local-plugin\.result == 'success'/); + assert.match(workflow, /needs\.publish-local-plugin\.result == 'skipped'/); + assert.match(workflow, /append-local-plugin-release-intent\.mjs/); + assert.match(workflow, /WITH LOCAL PLUGIN v\$\{LOCAL_PLUGIN_VERSION\}/); }); test("legacy standalone local-plugin publisher requires an extra non-dry-run confirmation", () => { const workflow = readFileSync(join(workflowsDir, "memos-local-plugin-publish.yml"), "utf8"); assert.match(workflow, /legacy_publish_confirmation:/); + assert.match(workflow, /workflow_call:/); assert.match(workflow, /legacy_publish_confirmation:\n\s+description:.*\n\s+required: false\n\s+type: string/s); assert.match(workflow, /guard-legacy-publish:/); assert.match(workflow, /guard-legacy-publish:\n\s+runs-on: ubuntu-latest\n\s+timeout-minutes: 5/); @@ -473,6 +346,56 @@ test("legacy standalone local-plugin publisher requires an extra non-dry-run con assert.match(workflow, /standalone local-plugin npm publisher for beta or latest package releases/); assert.match(workflow, /MemOS Release — Publish remains the weekly whole-repo release path/); assert.match(workflow, /needs: guard-legacy-publish/); + assert.match(workflow, /Git ref to build package code from/); + assert.match(workflow, /release automation always uses this workflow revision/); + assert.match(workflow, /SemVer build metadata is not supported for npm\/tag publishing/); + assert.equal((workflow.match(/Checkout trusted release automation scripts/g) || []).length, 2); + assert.equal((workflow.match(/Use trusted release automation scripts/g) || []).length, 2); + assert.match(workflow, /ref:\s+\$\{\{ github\.workflow_sha \}\}/); + assert.match(workflow, /package_source_sha:/); + assert.match(workflow, /needs\.guard-legacy-publish\.outputs\.package_source_sha/); + assert.match(workflow, /persist-credentials: false/); + assert.match(workflow, /Formal publish source .* is not in .* history/); + assert.match(workflow, /Formal publishing must use the latest release automation from/); + assert.match(workflow, /Select \$\{DEFAULT_BRANCH\} in Run workflow and retry/); + assert.match(workflow, /cp -R \.release-workflow\/\.github\/scripts \.github\/scripts/); + assert.match(workflow, /Package source ref: \$\(git rev-parse --short HEAD\)/); + assert.match(workflow, /Release automation ref: \$\{\{ github\.workflow_sha \}\}/); + assert.match(workflow, /Inspect existing standalone package tag state/); + assert.match(workflow, /inspect-local-plugin-release-state\.mjs/); + assert.match(workflow, /EXPECTED_PACKAGE_SOURCE_SHA/); + assert.match(workflow, /RELEASE_METADATA_STATE/); + assert.match(workflow, /audit-local-plugin-package\.mjs/); + assert.match(workflow, /FORCE_PACKAGE_ONLY_RELEASE: \$\{\{ inputs\.docs_sync_mode == 'defer_to_memos_release' \}\}/); + assert.match(workflow, /if \[ -n "\$\{DOCS_SYNC_MODE\}" \]; then/); + assert.doesNotMatch(workflow, /EVENT_NAME: \$\{\{ github\.event_name \}\}/); + assert.equal( + (workflow.match(/inputs\.docs_sync_mode != 'defer_to_memos_release' && inputs\.tag == 'latest'/g) || []).length, + 5, + ); + assert.match(workflow, /Create standalone package tag/); + assert.match(workflow, /git commit -m "\$\{release_commit_message\}"/); + assert.match(workflow, /git push origin "refs\/tags\/\$\{release_tag\}"/); + assert.match(workflow, /DOC_AGENT_RELEASE_NOTES_DRAFT_URL/); + assert.match(workflow, /DOC_AGENT_RELEASE_SYNC_URL/); + assert.match(workflow, /prepare-local-plugin-formal-sync\.mjs/); + assert.match(workflow, /send-product-release-sync\.mjs/); + assert.match(workflow, /inputs\.tag == 'latest' && !contains\(inputs\.version, '-'\)/); + assert.match(workflow, /docs-preview\.md/); + assert.match(workflow, /docs-preview\.json/); + assert.match(workflow, /quality-report\.json/); + assert.match(workflow, /skip_prerelease_docs/); + assert.match(workflow, /defer_to_memos_release_published/); + assert.doesNotMatch(workflow, /gh release (?:create|view)/); + assert.doesNotMatch(workflow, /pull-requests:\s*write/); + assert.doesNotMatch(workflow, /gh pr (?:create|view)/); + assert.doesNotMatch(workflow, /release_branch/); + assert.doesNotMatch(workflow, /push release branch|refs\/heads\/release\//); + assert.doesNotMatch(workflow, /cp "\$\{RELEASE_TARBALL\}" "\$\{inspection_dir\}\/"/); + assert.match(workflow, /actions\/checkout@[0-9a-f]{40} # v7\.0\.1/); + assert.match(workflow, /actions\/setup-node@[0-9a-f]{40} # v6\.4\.0/); + assert.match(workflow, /actions\/upload-artifact@[0-9a-f]{40} # v7\.0\.1/); + assert.match(workflow, /actions\/download-artifact@[0-9a-f]{40} # v8\.0\.1/); }); test("legacy standalone local-plugin post-merge dry run is not push-triggered", () => { @@ -1066,6 +989,40 @@ test("allows the draft service one initial response plus three repair attempts", } }); +test("real weekly release skips Doc Agent drafting when local_plugin_version is blank", async () => { + const originalFetch = globalThis.fetch; + let callCount = 0; + try { + globalThis.fetch = async () => { + callCount += 1; + throw new Error("Doc Agent must not be called"); + }; + const draft = await requestDocAgentDraft({ + ...evidence, + dry_run: false, + local_plugin_release_requested: false, + pending_local_plugin_changes: true, + has_user_facing_product_changes: true, + }); + assert.equal(callCount, 0); + assert.equal(draft.ok, true); + assert.deepEqual(draft.release_items, []); + assert.match(draft.warnings[0], /left local_plugin_version blank/); + const validation = validateDraft(draft, { + ...evidence, + dry_run: false, + local_plugin_release_requested: false, + pending_local_plugin_changes: true, + has_user_facing_product_changes: true, + }); + assert.equal(validation.ok, true); + assert.equal(validation.skipped_by_operator, true); + assert.equal(validation.coverage.required_count, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("fails closed when the draft service exhausts all repair attempts", async () => { const originalFetch = globalThis.fetch; const originalUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; diff --git a/.github/scripts/publish-local-plugin.sh b/.github/scripts/publish-local-plugin.sh index 976b70a12..b81d66a61 100755 --- a/.github/scripts/publish-local-plugin.sh +++ b/.github/scripts/publish-local-plugin.sh @@ -14,8 +14,9 @@ if [ ! -s "${RELEASE_TARBALL}" ]; then fi npm_visibility_attempts="${NPM_VISIBILITY_ATTEMPTS:-10}" -npm_ambiguous_visibility_attempts="${NPM_AMBIGUOUS_VISIBILITY_ATTEMPTS:-3}" +npm_ambiguous_visibility_attempts="${NPM_AMBIGUOUS_VISIBILITY_ATTEMPTS:-10}" npm_visibility_delay_seconds="${NPM_VISIBILITY_DELAY_SECONDS:-5}" +release_metadata_state="${RELEASE_METADATA_STATE:-fresh}" validate_positive_integer() { local name="$1" @@ -39,6 +40,14 @@ validate_positive_integer "NPM_VISIBILITY_ATTEMPTS" "${npm_visibility_attempts}" validate_positive_integer "NPM_AMBIGUOUS_VISIBILITY_ATTEMPTS" "${npm_ambiguous_visibility_attempts}" validate_non_negative_integer "NPM_VISIBILITY_DELAY_SECONDS" "${npm_visibility_delay_seconds}" +case "${release_metadata_state}" in + fresh|complete) ;; + *) + echo "::error::RELEASE_METADATA_STATE must be fresh or complete; received ${release_metadata_state}." + exit 2 + ;; +esac + script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" npm_view_log="${RUNNER_TEMP}/memos-local-plugin-npm-view.log" @@ -94,6 +103,47 @@ wait_for_npm_version() { return 1 } +npm_dist_tag_matches() { + local output_file="${RUNNER_TEMP}/memos-local-plugin-npm-dist-tags.json" + local status + set +e + npm view "${PACKAGE_NAME}" dist-tags --json --prefer-online >"${output_file}" 2>&1 + status=$? + set -e + if [ "${status}" != 0 ]; then + sed -n '1,120p' "${output_file}" + return 1 + fi + node -e ' + const fs = require("node:fs"); + const tags = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (tags[process.argv[2]] !== process.argv[3]) process.exit(1); + ' "${output_file}" "${NPM_DIST_TAG}" "${RELEASE_VERSION}" +} + +wait_for_npm_dist_tag() { + local attempt + local delay + for attempt in $(seq 1 "${npm_visibility_attempts}"); do + if npm_dist_tag_matches; then + echo "npm dist-tag ${NPM_DIST_TAG} points to ${RELEASE_VERSION}." + return 0 + fi + if [ "${attempt}" = "${npm_visibility_attempts}" ]; then + echo "::error::npm dist-tag ${NPM_DIST_TAG} did not point to ${RELEASE_VERSION} after ${npm_visibility_attempts} attempts." + return 1 + fi + delay=$((npm_visibility_delay_seconds * attempt)) + if [ "${delay}" -gt 30 ]; then + delay=30 + fi + echo "::notice::npm dist-tag ${NPM_DIST_TAG} is not updated yet; retrying in ${delay}s." + if [ "${delay}" -gt 0 ]; then + sleep "${delay}" + fi + done +} + remote_tag_exists() { local release_tag="$1" local attempt @@ -125,6 +175,8 @@ verify_published_package() { local verify_tarball local package_version local manifest_version + local local_content_fingerprint + local registry_content_fingerprint mkdir -p "${verify_directory}" bash "${script_directory}/retry.sh" --label "download published npm package" -- \ @@ -166,71 +218,90 @@ verify_published_package() { echo "::error::Published Hermes manifest version ${manifest_version} does not match ${RELEASE_VERSION}." exit 1 fi + + archive_content_fingerprint() { + local archive="$1" + local listing="${RUNNER_TEMP}/memos-local-plugin-archive-listing.txt" + tar -tzf "${archive}" \ + | awk '!/\/$/' \ + | LC_ALL=C sort > "${listing}" + while IFS= read -r entry; do + printf '%s\0' "${entry}" + tar -xOf "${archive}" "${entry}" | sha256sum | awk '{print $1}' + done < "${listing}" | sha256sum | awk '{print $1}' + } + + local_content_fingerprint="$(archive_content_fingerprint "${RELEASE_TARBALL}")" + registry_content_fingerprint="$(archive_content_fingerprint "${verify_tarball}")" + if [ "${local_content_fingerprint}" != "${registry_content_fingerprint}" ]; then + echo "::error::The npm registry tarball content does not match the locally validated release tarball. Refusing to create or recover a tag for different source content." + exit 1 + fi } published_version_visible=false +published_version_preexisting=false if npm_version_exists; then published_version_visible=true + published_version_preexisting=true + if [ "${RECOVER_EXISTING_NPM_RELEASE:-false}" != "true" ]; then + echo "::error::${PACKAGE_NAME}@${RELEASE_VERSION} already exists. Normal releases require an unused version; enable recovery only after release-owner verification of a partial failure." + exit 1 + fi if remote_tag_exists "${RELEASE_TAG}"; then - echo "${PACKAGE_NAME}@${RELEASE_VERSION} and ${RELEASE_TAG} already exist; treating this as an idempotent rerun." - elif [ "${RECOVER_EXISTING_NPM_RELEASE:-false}" = "true" ]; then - echo "Recovery mode enabled; npm version exists, so publish is skipped." + echo "Recovery mode enabled; the existing npm version and tag will be verified and reused." else - echo "::error::npm version exists but ${RELEASE_TAG} does not. Refusing to invent release metadata without explicit recovery mode." - exit 1 + echo "Recovery mode enabled; npm version exists and the missing tag may be reconstructed after package verification." fi else + if [ "${release_metadata_state}" != "fresh" ]; then + echo "::error::Tag state is ${release_metadata_state}, but ${PACKAGE_NAME}@${RELEASE_VERSION} is absent from npm. Refusing to publish after tag metadata already exists." + exit 1 + fi + attempt_directory="${RUNNER_TEMP}/memos-local-plugin-npm-publish-attempts" mkdir -p "${attempt_directory}" - publish_accepted=false - - for attempt in 1 2 3; do - set +e - npm publish "${RELEASE_TARBALL}" --access public --tag "${NPM_DIST_TAG}" >"${attempt_directory}/${attempt}.log" 2>&1 - publish_status=$? - set -e - sed -n '1,160p' "${attempt_directory}/${attempt}.log" - - if [ "${publish_status}" = 0 ]; then - publish_accepted=true - break - fi + set +e + npm publish "${RELEASE_TARBALL}" \ + --access public \ + --tag "${NPM_DIST_TAG}" \ + --fetch-retries=0 \ + >"${attempt_directory}/1.log" 2>&1 + publish_status=$? + set -e + sed -n '1,160p' "${attempt_directory}/1.log" + if [ "${publish_status}" != 0 ]; then if wait_for_npm_version "${npm_ambiguous_visibility_attempts}"; then - echo "Publish returned an error, but npm now contains the requested version." - publish_accepted=true - break - fi - - if [ "${attempt}" = 3 ]; then + echo "Publish returned an error, but npm now contains the requested version. No second publish request was sent." + else RELEASE_FAILURE_PHASE=npm-publish \ RELEASE_FAILURE_ATTEMPT_DIR="${attempt_directory}" \ node "${script_directory}/draft-local-plugin-release-notes.mjs" \ || echo "::warning::Failed to send the exhausted-retry notification." - echo "::error::npm publish failed after three attempts." + echo "::error::npm publish returned an error and the version remained unavailable. Refusing an automatic second publish request; inspect npm before retrying." exit 1 fi - - delay=$((npm_visibility_delay_seconds * attempt)) - if [ "${delay}" -gt 0 ]; then - sleep "${delay}" - fi - done + fi if wait_for_npm_version "${npm_visibility_attempts}"; then published_version_visible=true else - if [ "${publish_accepted}" = "true" ]; then - echo "::warning::npm publish succeeded, but ${PACKAGE_NAME}@${RELEASE_VERSION} is not visible after propagation retries; continuing with tag, Release, and PR creation." - else - echo "::error::npm publish did not succeed and ${PACKAGE_NAME}@${RELEASE_VERSION} is still absent." - exit 1 - fi + echo "::error::npm accepted the publish request, but ${PACKAGE_NAME}@${RELEASE_VERSION} is not visible after propagation retries. Stop before tag creation and use recovery mode only after npm becomes visible." + exit 1 fi fi if [ "${published_version_visible}" = "true" ]; then verify_published_package + if [ "${published_version_preexisting}" = "false" ]; then + wait_for_npm_dist_tag + elif npm_dist_tag_matches; then + echo "Existing npm dist-tag ${NPM_DIST_TAG} still points to ${RELEASE_VERSION}." + else + echo "::notice::Existing npm version ${RELEASE_VERSION} was verified, but mutable dist-tag ${NPM_DIST_TAG} now points elsewhere. Leaving it unchanged during recovery/idempotent rerun." + fi else - echo "::warning::Skipping registry tarball verification until ${PACKAGE_NAME}@${RELEASE_VERSION} becomes visible." + echo "::error::Internal error: npm package visibility was not established." + exit 1 fi diff --git a/.github/scripts/publish-local-plugin.test.mjs b/.github/scripts/publish-local-plugin.test.mjs index 76e1121a8..caa649ed2 100644 --- a/.github/scripts/publish-local-plugin.test.mjs +++ b/.github/scripts/publish-local-plugin.test.mjs @@ -26,8 +26,13 @@ increment_counter() { case "\${1:-}" in view) + if [ "\${3:-}" = "dist-tags" ]; then + increment_counter dist_tag >/dev/null + printf '{"%s":"%s"}\n' "\${NPM_DIST_TAG}" "\${NPM_MOCK_DIST_TAG_VERSION:-\${RELEASE_VERSION}}" + exit 0 + fi view_count="$(increment_counter view)" - if [ "\${NPM_MOCK_SCENARIO}" = "eventually-visible" ] && [ "\${view_count}" -ge 4 ]; then + if [ "\${NPM_MOCK_SCENARIO}" = "already-visible" ] || { { [ "\${NPM_MOCK_SCENARIO}" = "eventually-visible" ] || [ "\${NPM_MOCK_SCENARIO}" = "publish-error-eventually-visible" ]; } && [ "\${view_count}" -ge 4 ]; }; then printf '%s\\n' "\${RELEASE_VERSION}" exit 0 fi @@ -38,7 +43,8 @@ case "\${1:-}" in publish) increment_counter publish >/dev/null printf '%s' "\${2:-}" > "\${NPM_MOCK_STATE_DIR}/published-argument" - if [ "\${NPM_MOCK_SCENARIO}" = "publish-fails" ]; then + printf '%s' "$*" > "\${NPM_MOCK_STATE_DIR}/publish-arguments" + if [ "\${NPM_MOCK_SCENARIO}" = "publish-fails" ] || [ "\${NPM_MOCK_SCENARIO}" = "publish-error-eventually-visible" ]; then echo "npm error code E500" >&2 exit 1 fi @@ -70,6 +76,9 @@ case "\${1:-}" in printf 'version: %s\\n' \ "\${NPM_MOCK_MANIFEST_VERSION:-\${RELEASE_VERSION}}" \ > "\${pack_root}/package/adapters/hermes/plugin.yaml" + if [ -n "\${NPM_MOCK_EXTRA_CONTENT:-}" ]; then + printf '%s\\n' "\${NPM_MOCK_EXTRA_CONTENT}" > "\${pack_root}/package/registry-only.txt" + fi tar -czf "\${destination}/\${filename}" -C "\${pack_root}" package printf '[{"filename":"%s"}]\\n' "\${filename}" exit 0 @@ -81,6 +90,15 @@ case "\${1:-}" in esac `; +const mockGit = `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "ls-remote" ]; then + exit 2 +fi +echo "Unexpected git command: $*" >&2 +exit 2 +`; + function readCounter(stateDirectory, name) { try { return Number(readFileSync(join(stateDirectory, name), "utf8")); @@ -97,10 +115,27 @@ function runScenario(scenario, overrides = {}) { mkdirSync(stateDirectory); const npmPath = join(binDirectory, "npm"); + const gitPath = join(binDirectory, "git"); const releaseTarball = join(fixtureDirectory, "release.tgz"); writeFileSync(npmPath, mockNpm, "utf8"); chmodSync(npmPath, 0o755); - writeFileSync(releaseTarball, "release fixture", "utf8"); + writeFileSync(gitPath, mockGit, "utf8"); + chmodSync(gitPath, 0o755); + const localPackRoot = join(fixtureDirectory, "local-pack-root"); + mkdirSync(join(localPackRoot, "package", "adapters", "hermes"), { recursive: true }); + writeFileSync( + join(localPackRoot, "package", "package.json"), + '{"name":"@memtensor/memos-local-plugin","version":"2.0.12"}\n', + "utf8", + ); + writeFileSync( + join(localPackRoot, "package", "adapters", "hermes", "plugin.yaml"), + "version: 2.0.12\n", + "utf8", + ); + spawnSync("tar", ["-czf", releaseTarball, "-C", localPackRoot, "package"], { + encoding: "utf8", + }); const result = spawnSync("bash", [publishScript], { cwd: fixtureDirectory, @@ -120,7 +155,7 @@ function runScenario(scenario, overrides = {}) { NPM_MOCK_SCENARIO: scenario, NPM_MOCK_STATE_DIR: stateDirectory, NPM_VISIBILITY_ATTEMPTS: "3", - NPM_AMBIGUOUS_VISIBILITY_ATTEMPTS: "2", + NPM_AMBIGUOUS_VISIBILITY_ATTEMPTS: "3", NPM_VISIBILITY_DELAY_SECONDS: "0", ...overrides, }, @@ -138,6 +173,13 @@ function runScenario(scenario, overrides = {}) { return ""; } })(), + publishArguments: (() => { + try { + return readFileSync(join(stateDirectory, "publish-arguments"), "utf8"); + } catch { + return ""; + } + })(), }; rmSync(fixtureDirectory, { recursive: true, force: true }); return outcome; @@ -151,26 +193,45 @@ test("waits through two post-publish 404 responses before the version becomes vi assert.equal(result.viewCount, 4); assert.equal(result.packCount, 1); assert.match(result.publishedArgument, /release\.tgz$/); + assert.match(result.publishArguments, /--fetch-retries=0/); assert.match(result.stdout, /became visible on attempt 3/); }); -test("continues release metadata creation when publish succeeds but visibility remains delayed", () => { +test("stops before tag creation when publish succeeds but visibility remains delayed", () => { const result = runScenario("always-missing"); - assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.status, 0); assert.equal(result.publishCount, 1); assert.equal(result.viewCount, 4); assert.equal(result.packCount, 0); - assert.match(result.stdout, /npm publish succeeded.*continuing with tag, Release, and PR creation/s); - assert.match(result.stdout, /Skipping registry tarball verification/); + assert.match(result.stdout + result.stderr, /Stop before tag creation/); }); test("fails when publish fails and the requested version remains absent", () => { const result = runScenario("publish-fails"); assert.notEqual(result.status, 0); - assert.equal(result.publishCount, 3); - assert.match(result.stdout + result.stderr, /npm publish failed after three attempts/); + assert.equal(result.publishCount, 1); + assert.match(result.stdout + result.stderr, /Refusing an automatic second publish request/); +}); + +test("does not issue a second publish when an error becomes visible after propagation", () => { + const result = runScenario("publish-error-eventually-visible"); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.publishCount, 1); + assert.equal(result.packCount, 1); + assert.match(result.stdout, /No second publish request was sent/); +}); + +test("fails when the requested npm dist-tag points to another version", () => { + const result = runScenario("eventually-visible", { + NPM_MOCK_DIST_TAG_VERSION: "2.0.11", + }); + + assert.notEqual(result.status, 0); + assert.equal(result.publishCount, 1); + assert.match(result.stdout + result.stderr, /did not point to 2\.0\.12/); }); test("fails when the published Hermes manifest version differs", () => { @@ -185,3 +246,41 @@ test("fails when the published Hermes manifest version differs", () => { /Published Hermes manifest version 2\.0\.11 does not match 2\.0\.12/, ); }); + +test("fails recovery when registry package content differs from the validated tarball", () => { + const result = runScenario("eventually-visible", { + NPM_MOCK_EXTRA_CONTENT: "different package content", + }); + + assert.notEqual(result.status, 0); + assert.equal(result.packCount, 1); + assert.match( + result.stdout + result.stderr, + /registry tarball content does not match the locally validated release tarball/, + ); +}); + +test("does not require a mutable dist-tag to point to an older preexisting version", () => { + const result = runScenario("already-visible", { + RECOVER_EXISTING_NPM_RELEASE: "true", + RELEASE_METADATA_STATE: "fresh", + NPM_MOCK_DIST_TAG_VERSION: "2.0.13", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.publishCount, 0); + assert.equal(result.packCount, 1); + assert.match(result.stdout, /mutable dist-tag latest now points elsewhere/); +}); + +test("rejects an already-used npm version outside explicit recovery", () => { + const result = runScenario("already-visible", { + RECOVER_EXISTING_NPM_RELEASE: "false", + RELEASE_METADATA_STATE: "fresh", + }); + + assert.notEqual(result.status, 0); + assert.equal(result.publishCount, 0); + assert.equal(result.packCount, 0); + assert.match(result.stdout + result.stderr, /Normal releases require an unused version/); +}); diff --git a/.github/scripts/send-product-release-sync.mjs b/.github/scripts/send-product-release-sync.mjs new file mode 100644 index 000000000..e8fe57c7a --- /dev/null +++ b/.github/scripts/send-product-release-sync.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +function fail(message) { + throw new Error(String(message)); +} + +export function validateFormalSyncResponse(payload) { + if (!payload || typeof payload !== "object") fail("106 formal sync returned a non-object response"); + if (payload.handled !== true) fail(`106 formal sync did not handle the request: ${payload.skip_reason || "unknown reason"}`); + if (payload.ok !== true) fail(`106 formal sync failed: ${payload.skip_reason || payload.detail || "unknown reason"}`); + if (payload.source_id !== "openclaw-local-plugin") fail(`106 formal sync returned unexpected source_id ${payload.source_id || ""}`); + return payload; +} + +async function main() { + const url = String(process.env.DOC_AGENT_RELEASE_SYNC_URL || "").trim(); + const token = String(process.env.DOC_AGENT_RELEASE_SYNC_TOKEN || "").trim(); + const requestFile = String(process.env.FORMAL_SYNC_REQUEST_FILE || "").trim(); + if (!url) fail("DOC_AGENT_RELEASE_SYNC_URL secret is required for a stable standalone release"); + if (!token) fail("DOC_AGENT_RELEASE_SYNC_TOKEN secret is required for a stable standalone release"); + if (!requestFile) fail("FORMAL_SYNC_REQUEST_FILE is required"); + + const response = await fetch(url, { + method: "POST", + signal: AbortSignal.timeout(30_000), + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: readFileSync(requestFile, "utf8"), + }); + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { detail: `non-JSON response (HTTP ${response.status})` }; + } + if (!response.ok) fail(`106 formal sync HTTP ${response.status}: ${payload.detail || payload.skip_reason || "request failed"}`); + const result = validateFormalSyncResponse(payload); + console.log(`106 formal sync accepted ${result.source_id}; idempotent_replay=${Boolean(result.idempotent_replay)}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/send-product-release-sync.test.mjs b/.github/scripts/send-product-release-sync.test.mjs new file mode 100644 index 000000000..861918b91 --- /dev/null +++ b/.github/scripts/send-product-release-sync.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { validateFormalSyncResponse } from "./send-product-release-sync.mjs"; + +test("accepts handled successful local plugin sync", () => { + const result = validateFormalSyncResponse({ handled: true, ok: true, source_id: "openclaw-local-plugin" }); + assert.equal(result.ok, true); +}); + +test("rejects wrong routes and failed syncs", () => { + assert.throws(() => validateFormalSyncResponse({ handled: false, ok: true }), /did not handle/); + assert.throws( + () => validateFormalSyncResponse({ handled: true, ok: false, source_id: "openclaw-local-plugin", skip_reason: "quality" }), + /quality/, + ); + assert.throws( + () => validateFormalSyncResponse({ handled: true, ok: true, source_id: "memos-cloud-cli" }), + /unexpected source_id/, + ); +}); + +test("formal sync network request has a bounded timeout", () => { + const script = readFileSync(new URL("./send-product-release-sync.mjs", import.meta.url), "utf8"); + assert.match(script, /signal: AbortSignal\.timeout\(30_000\)/); +}); diff --git a/.github/workflows/memos-local-plugin-publish.yml b/.github/workflows/memos-local-plugin-publish.yml index 196f698f7..221f68ca2 100644 --- a/.github/workflows/memos-local-plugin-publish.yml +++ b/.github/workflows/memos-local-plugin-publish.yml @@ -9,22 +9,28 @@ on: tag: description: "npm dist-tag (latest for production, beta/next/alpha for testing)" required: true + type: choice + options: + - latest + - beta + - next + - alpha default: "latest" git_ref: - description: "Git ref to build from (branch, tag, or SHA). Leave blank to use the branch selected above." + description: "Git ref to build package code from. Leave blank for normal releases; release automation always uses this workflow revision." required: false default: "" release_notes: - description: "Optional Markdown release notes. latest uses Doc Agent evidence; beta/non-latest can use package-only notes." + description: "Optional Markdown notes. Stable latest releases must still pass evidence/source_refs quality checks." required: false default: "" dry_run: - description: "Draft release notes and build artifacts only. Skip npm publish, tag, GitHub Release, and release PR." + description: "Prepare package notes and build artifacts only. Skip npm publish and tag creation." required: true type: boolean default: true recover_existing_npm_release: - description: "Allow reconstructing a missing tag/Release for an npm version. Keep false for normal releases." + description: "Allow reconstructing a missing tag for an existing npm version. Keep false for normal releases." required: true type: boolean default: false @@ -36,40 +42,42 @@ on: workflow_call: inputs: version: - description: "Version to publish or dry-run (e.g. 2.0.10 or 2.0.11-beta.1)" required: true type: string tag: - description: "npm dist-tag (latest for production, beta/next/alpha for testing)" - required: false + required: true type: string - default: "latest" git_ref: - description: "Git ref to build from. Leave blank to use the caller ref." - required: false + required: true type: string - default: "" release_notes: - description: "Optional Markdown release notes. latest uses Doc Agent evidence; beta/non-latest can use package-only notes." required: false type: string default: "" dry_run: - description: "Draft release notes and build artifacts only. Skip npm publish, tag, GitHub Release, and release PR." - required: false + required: true type: boolean - default: true recover_existing_npm_release: - description: "Allow reconstructing a missing tag/Release for an npm version. Keep false for normal releases." required: false type: boolean default: false - legacy_publish_confirmation: - description: "Required only when dry_run=false. Must exactly equal: LEGACY PUBLISH memos-local-plugin-v" + docs_sync_mode: + required: true + type: string + memos_release_version: + required: true + type: string + caller_publish_confirmation: required: false type: string default: "" - + outputs: + local_plugin_tag: + value: ${{ jobs.publish.outputs.local_plugin_tag }} + local_plugin_tag_sha: + value: ${{ jobs.publish.outputs.local_plugin_tag_sha }} + package_source_sha: + value: ${{ jobs.publish.outputs.package_source_sha }} concurrency: group: memos-local-plugin-publish cancel-in-progress: false @@ -79,38 +87,127 @@ defaults: working-directory: apps/memos-local-plugin permissions: - contents: write - pull-requests: write + contents: read jobs: guard-legacy-publish: runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + package_source_sha: ${{ steps.resolve-package-source.outputs.sha }} steps: - - name: Validate legacy standalone publish confirmation + - name: Validate release inputs and standalone publish confirmation shell: bash working-directory: . env: DRY_RUN: ${{ inputs.dry_run }} RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} LEGACY_PUBLISH_CONFIRMATION: ${{ inputs.legacy_publish_confirmation }} + DOCS_SYNC_MODE: ${{ inputs.docs_sync_mode }} + MEMOS_RELEASE_VERSION: ${{ inputs.memos_release_version }} + CALLER_PUBLISH_CONFIRMATION: ${{ inputs.caller_publish_confirmation }} run: | set -euo pipefail - if [ "${DRY_RUN}" = "true" ]; then + node -e ' + const version = process.argv[1]; + const distTag = process.argv[2]; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version); + if (!match) throw new Error(`version must be valid SemVer without a leading v; received ${version}`); + if (version.includes("+")) throw new Error(`SemVer build metadata is not supported for npm/tag publishing; received ${version}`); + const prereleaseIdentifiers = (match[4] || "").split(".").filter(Boolean); + if (prereleaseIdentifiers.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) { + throw new Error(`numeric prerelease identifiers must not contain leading zeroes; received ${version}`); + } + if (!["latest", "beta", "next", "alpha"].includes(distTag)) { + throw new Error(`unsupported npm dist-tag ${distTag}`); + } + const channel = (match[4] || "").split(".")[0]; + if (!channel && distTag !== "latest") throw new Error(`stable version ${version} must use latest`); + if (channel && distTag === "latest") throw new Error(`prerelease version ${version} must not use latest`); + if (["beta", "alpha", "next"].includes(channel) && channel !== distTag) { + throw new Error(`prerelease channel ${channel} must use npm dist-tag ${channel}`); + } + if (channel && !["beta", "alpha", "next"].includes(channel) && distTag !== "next") { + throw new Error(`prerelease channel ${channel} must use npm dist-tag next`); + } + ' "${RELEASE_VERSION}" "${NPM_DIST_TAG}" + + if [ -n "${DOCS_SYNC_MODE}" ]; then + if [ "${DOCS_SYNC_MODE}" != "defer_to_memos_release" ]; then + echo "::error::Reusable local-plugin publishing only accepts docs_sync_mode=defer_to_memos_release." + exit 1 + fi + if ! [[ "${MEMOS_RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::memos_release_version is invalid: ${MEMOS_RELEASE_VERSION:-}" + exit 1 + fi + if [ "${DRY_RUN}" != "true" ]; then + expected="PUBLISH v${MEMOS_RELEASE_VERSION} WITH LOCAL PLUGIN v${RELEASE_VERSION}" + if [ "${CALLER_PUBLISH_CONFIRMATION}" != "${expected}" ]; then + echo "::error::The calling MemOS release confirmation must exactly equal: ${expected}" + exit 1 + fi + fi + echo "Validated MemOS weekly-release caller; docs sync is deferred to the MemOS release.published event." + elif [ -n "${DOCS_SYNC_MODE}" ] || [ -n "${MEMOS_RELEASE_VERSION}" ] || [ -n "${CALLER_PUBLISH_CONFIRMATION}" ]; then + echo "::error::Internal reusable-workflow inputs are not accepted from workflow_dispatch." + exit 1 + elif [ "${DRY_RUN}" = "true" ]; then echo "dry_run=true; legacy standalone publish confirmation is not required." + else + expected="LEGACY PUBLISH memos-local-plugin-v${RELEASE_VERSION}" + if [ "${LEGACY_PUBLISH_CONFIRMATION}" != "${expected}" ]; then + echo "::error::This workflow is the standalone local-plugin npm publisher for beta or latest package releases." + echo "::error::MemOS Release — Publish remains the weekly whole-repo release path and can also update the Plugin tab from apps/memos-local-plugin/** changes." + echo "::error::To intentionally run this legacy publisher, set legacy_publish_confirmation exactly to: ${expected}" + exit 1 + fi + fi + + - name: Resolve package source once + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.git_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Pin and validate package source + id: resolve-package-source + shell: bash + working-directory: . + env: + DRY_RUN: ${{ inputs.dry_run }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + package_source_sha="$(git rev-parse HEAD)" + test -f apps/memos-local-plugin/package.json + echo "sha=${package_source_sha}" >> "${GITHUB_OUTPUT}" + echo "Package source SHA: ${package_source_sha}" + echo "Release automation SHA: ${WORKFLOW_SHA}" + + if [ "${DRY_RUN}" = "true" ]; then exit 0 fi - expected="LEGACY PUBLISH memos-local-plugin-v${RELEASE_VERSION}" - if [ "${LEGACY_PUBLISH_CONFIRMATION}" != "${expected}" ]; then - echo "::error::This workflow is the standalone local-plugin npm publisher for beta or latest package releases." - echo "::error::MemOS Release — Publish remains the weekly whole-repo release path and can also update the Plugin tab from apps/memos-local-plugin/** changes." - echo "::error::To intentionally run this legacy publisher, set legacy_publish_confirmation exactly to: ${expected}" + git fetch --no-tags origin \ + "refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" \ + "${WORKFLOW_SHA}" + default_branch_sha="$(git rev-parse "refs/remotes/origin/${DEFAULT_BRANCH}")" + if ! git merge-base --is-ancestor "${package_source_sha}" "refs/remotes/origin/${DEFAULT_BRANCH}"; then + echo "::error::Formal publish source ${package_source_sha} is not in ${DEFAULT_BRANCH} history. Merge it before publishing." + exit 1 + fi + if [ "${WORKFLOW_SHA}" != "${default_branch_sha}" ]; then + echo "::error::Formal publishing must use the latest release automation from ${DEFAULT_BRANCH} (${default_branch_sha}); this run uses ${WORKFLOW_SHA}. Select ${DEFAULT_BRANCH} in Run workflow and retry." exit 1 fi build-prebuilds: needs: guard-legacy-publish + timeout-minutes: 25 strategy: matrix: include: @@ -124,14 +221,38 @@ jobs: platform: win32-x64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.git_ref || github.ref }} + ref: ${{ needs.guard-legacy-publish.outputs.package_source_sha }} fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-node@v4 + - name: Checkout trusted release automation scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: .release-workflow + sparse-checkout: | + .github/scripts + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Use trusted release automation scripts + shell: bash + working-directory: . + run: | + set -euo pipefail + rm -rf .github/scripts + mkdir -p .github + cp -R .release-workflow/.github/scripts .github/scripts + rm -rf .release-workflow + echo "Package source ref: $(git rev-parse --short HEAD)" + echo "Release automation ref: ${{ github.workflow_sha }}" + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0 with: node-version: 22 + package-manager-cache: false - name: Install dependencies shell: bash @@ -151,31 +272,68 @@ jobs: ' - name: Upload prebuild artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: prebuild-${{ matrix.platform }} path: apps/memos-local-plugin/prebuilds/${{ matrix.platform }}/better_sqlite3.node publish: - needs: build-prebuilds + needs: + - guard-legacy-publish + - build-prebuilds runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write + outputs: + local_plugin_tag: ${{ steps.release_tag.outputs.tag }} + local_plugin_tag_sha: ${{ steps.release_tag.outputs.tag_sha }} + package_source_sha: ${{ needs.guard-legacy-publish.outputs.package_source_sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.git_ref || github.ref }} + ref: ${{ needs.guard-legacy-publish.outputs.package_source_sha }} fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-node@v4 + - name: Checkout trusted release automation scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: .release-workflow + sparse-checkout: | + .github/scripts + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Use trusted release automation scripts + shell: bash + working-directory: . + run: | + set -euo pipefail + rm -rf .github/scripts + mkdir -p .github + cp -R .release-workflow/.github/scripts .github/scripts + rm -rf .release-workflow + echo "Package source ref: $(git rev-parse --short HEAD)" + echo "Release automation ref: ${{ github.workflow_sha }}" + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.4.0 with: node-version: 22 registry-url: https://registry.npmjs.org + package-manager-cache: false - name: Test npm publish helper working-directory: . - run: node --test .github/scripts/publish-local-plugin.test.mjs + run: | + node --test \ + .github/scripts/publish-local-plugin.test.mjs \ + .github/scripts/inspect-local-plugin-release-state.test.mjs \ + .github/scripts/audit-local-plugin-package.test.mjs - name: Download all prebuilds - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: apps/memos-local-plugin/prebuilds pattern: prebuild-* @@ -206,7 +364,8 @@ jobs: cp prebuilds/linux-x64/better_sqlite3.node node_modules/better-sqlite3/build/Release/better_sqlite3.node ' - - name: Generate telemetry credentials + - name: Generate telemetry credentials for a real publish + if: ${{ inputs.dry_run != true }} run: bash ../../.github/scripts/retry.sh --label "generate telemetry credentials" -- node scripts/generate-telemetry-credentials.cjs env: MEMOS_ARMS_ENDPOINT: ${{ secrets.MEMOS_ARMS_ENDPOINT }} @@ -279,23 +438,31 @@ jobs: fi sha256sum "${release_tarball}" > "${release_tarball}.sha256" + package_audit_report="${RUNNER_TEMP}/memos-local-plugin-package-audit.json" + RELEASE_TARBALL="${release_tarball}" \ + PACKAGE_AUDIT_REPORT="${package_audit_report}" \ + node ../../.github/scripts/audit-local-plugin-package.mjs { echo "RELEASE_TARBALL=${release_tarball}" echo "RELEASE_TARBALL_SHA256=${release_tarball}.sha256" + echo "PACKAGE_AUDIT_REPORT=${package_audit_report}" } >> "${GITHUB_ENV}" - - name: Draft GitHub Release notes + - name: Prepare standalone package inspection notes id: release_notes working-directory: . env: RELEASE_VERSION: ${{ inputs.version }} RELEASE_TAG: memos-local-plugin-v${{ inputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} + RELEASE_EVIDENCE_REF: ${{ needs.guard-legacy-publish.outputs.package_source_sha }} MANUAL_RELEASE_NOTES: ${{ inputs.release_notes }} - DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} - DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} - DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} - run: bash .github/scripts/retry.sh --attempts 2 --label "draft GitHub Release notes" -- node .github/scripts/draft-local-plugin-release-notes.mjs + FORCE_PACKAGE_ONLY_RELEASE: ${{ inputs.docs_sync_mode == 'defer_to_memos_release' }} + DOCS_SYNC_MODE: ${{ inputs.docs_sync_mode }} + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ (inputs.docs_sync_mode != 'defer_to_memos_release' && inputs.tag == 'latest' && !contains(inputs.version, '-')) && secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || '' }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ (inputs.docs_sync_mode != 'defer_to_memos_release' && inputs.tag == 'latest' && !contains(inputs.version, '-')) && secrets.DOC_AGENT_RELEASE_FAILURE_URL || '' }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ (inputs.docs_sync_mode != 'defer_to_memos_release' && inputs.tag == 'latest' && !contains(inputs.version, '-')) && secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN || '' }} + run: bash .github/scripts/retry.sh --attempts 2 --label "prepare package inspection notes" -- node .github/scripts/draft-local-plugin-release-notes.mjs - name: Prepare release notes inspection artifact working-directory: . @@ -313,6 +480,8 @@ jobs: MISSING_REQUIRED_COUNT: ${{ steps.release_notes.outputs.missing_required_count }} VALIDATION_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.validation_attempt_count }} REPAIR_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.repair_attempt_count }} + NPM_DIST_TAG: ${{ inputs.tag }} + DOCS_SYNC_MODE: ${{ inputs.docs_sync_mode }} run: | set -euo pipefail inspection_dir="${RUNNER_TEMP}/memos-local-plugin-release-notes-inspection" @@ -325,21 +494,77 @@ jobs: cp "${RELEASE_NOTES_FILE}" "${inspection_dir}/release-notes.md" cp "${RUNNER_TEMP}/memos-local-plugin-pack.json" "${inspection_dir}/npm-pack.json" - cp "${RELEASE_TARBALL}" "${inspection_dir}/" cp "${RELEASE_TARBALL_SHA256}" "${inspection_dir}/" - if [ -n "${EVIDENCE_FILE}" ] && [ -s "${EVIDENCE_FILE}" ]; then - cp "${EVIDENCE_FILE}" "${inspection_dir}/evidence.json" + cp "${PACKAGE_AUDIT_REPORT}" "${inspection_dir}/package-audit.json" + if [ -z "${EVIDENCE_FILE}" ] || [ ! -s "${EVIDENCE_FILE}" ]; then + echo "::error::Evidence JSON file was not generated." + exit 1 fi - if [ "${DRAFT_USED:-}" = "true" ]; then - if [ -z "${DRAFT_FILE}" ] || [ ! -s "${DRAFT_FILE}" ]; then - echo "::error::Draft JSON file was not generated." - exit 1 - fi - cp "${DRAFT_FILE}" "${inspection_dir}/release-notes-draft.json" - elif [ -n "${DRAFT_FILE}" ] && [ -s "${DRAFT_FILE}" ]; then - cp "${DRAFT_FILE}" "${inspection_dir}/release-notes-draft.json" + if [ -z "${DRAFT_FILE}" ] || [ ! -s "${DRAFT_FILE}" ]; then + echo "::error::Draft JSON file was not generated." + exit 1 + fi + cp "${EVIDENCE_FILE}" "${inspection_dir}/evidence.json" + cp "${DRAFT_FILE}" "${inspection_dir}/release-notes-draft.json" + + if [ "${DOCS_SYNC_MODE}" = "defer_to_memos_release" ]; then + docs_action="defer_to_memos_release_published" + docs_summary="Docs sync is deferred to the MemOS whole-repository Release and its explicit local-plugin intent." + elif [ "${NPM_DIST_TAG}" = "latest" ] && [[ "${RELEASE_VERSION}" != *-* ]]; then + docs_action="formal_sync_after_verified_package_publish" + docs_summary="After npm and tag verification, the authenticated 106 formal-sync route updates the Plugin tab and deployment chain." + else + docs_action="skip_prerelease_docs" + docs_summary="Prerelease packages stop after npm, tag, and inspection artifacts; they do not update the Plugin tab or deployment chain." fi + jq \ + --arg schema "memos.local-plugin.quality-report.v1" \ + --arg version "v${RELEASE_VERSION}" \ + --arg docs_action "${docs_action}" \ + '{ + schema: $schema, + version: $version, + docs_action: $docs_action, + ok: (.ok == true), + needs_review: (.needs_review == true), + confidence: (.confidence // ""), + item_count: ((.release_items // []) | length), + coverage: (.coverage // {}), + language_issues: (.language_issues // []), + validation_report: (.validation_report // {}), + validation_attempt_count: (.validation_attempt_count // 0), + repair_attempt_count: (.repair_attempt_count // 0) + }' "${DRAFT_FILE}" > "${inspection_dir}/quality-report.json" + + jq \ + --arg schema "memos.local-plugin.docs-preview.v1" \ + --arg version "v${RELEASE_VERSION}" \ + --arg docs_action "${docs_action}" \ + --arg summary "${docs_summary}" \ + '{ + schema: $schema, + version: $version, + docs_action: $docs_action, + would_create_independent_plugin_release: false, + summary: $summary, + release_items: (.release_items // []) + }' "${DRAFT_FILE}" > "${inspection_dir}/docs-preview.json" + + { + echo "# MemOS Local Plugin docs preview" + echo + echo "- version: v${RELEASE_VERSION}" + echo "- action: ${docs_action}" + echo "- independent local-plugin GitHub Release: false" + echo + echo "${docs_summary}" + if [ "${docs_action}" != "skip_prerelease_docs" ]; then + echo + sed '/