diff --git a/.github/fixtures/cli-release-notes.md b/.github/fixtures/cli-release-notes.md new file mode 100644 index 0000000..f53e813 --- /dev/null +++ b/.github/fixtures/cli-release-notes.md @@ -0,0 +1,27 @@ +## Changelog + +### Improved + +- **Release safety checks**: Validates evidence, assets, and immutable publication metadata before a CLI release can write externally. + + + + diff --git a/.github/scripts/draft-cli-release-notes.mjs b/.github/scripts/draft-cli-release-notes.mjs new file mode 100644 index 0000000..64e4217 --- /dev/null +++ b/.github/scripts/draft-cli-release-notes.mjs @@ -0,0 +1,1094 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; + +export const PRODUCT_ID = "memos-cloud-cli"; +export const PRODUCT_TITLE = { zh: "MemOS CLI", en: "MemOS CLI" }; + +const REPOSITORY = "MemTensor/MemOS-Cloud-CLI"; +const RELEASE_NOTES_MARKER = "doc-agent-release-notes-json"; +const RELEASE_CATEGORY_ORDER = ["Added", "Improved", "Fixed"]; +const RELEASE_TO_DOC_CATEGORY = { + Added: "New Features", + Improved: "Improvements", + Fixed: "Bug Fixes", +}; +const RELEASE_ASSET_TARGETS = ["darwin-arm64", "darwin-x64", "linux-x64", "windows-x64"]; +const MAX_DRAFT_REPAIR_ATTEMPTS = 3; +const MAX_RELEASE_ITEMS = 12; +const MAX_TEXT_CN_CHARS = 180; +const MAX_TEXT_EN_CHARS = 220; +const CJK_RE = /[\u3400-\u9fff\uf900-\ufaff]/; + +export const RELEASE_NOTE_LIMITS = { + max_items: MAX_RELEASE_ITEMS, + max_text_cn_chars: MAX_TEXT_CN_CHARS, + max_text_en_chars: MAX_TEXT_EN_CHARS, + max_repair_attempts: MAX_DRAFT_REPAIR_ATTEMPTS, +}; + +export const RELEASE_NOTE_QUALITY_REQUEST = { + schema: "memos.plugin.release_notes.quality_request.v1", + candidate_count: 3, + selection_policy: [ + "Generate multiple candidate CLI release-note drafts when supported.", + "Score candidates against evidence coverage, source_ref validity, bilingual language separation, installer/binary accuracy, and docs-preview readability.", + "Return only the best candidate in release_items/release_notes_markdown; include candidate scoring metadata only in debug fields when available.", + ], + repair_policy: { + max_repair_attempts: MAX_DRAFT_REPAIR_ATTEMPTS, + use_validation_report: true, + fail_closed_after_exhaustion: true, + }, +}; + +function fail(message) { + throw new Error(String(message)); +} + +function warn(message) { + console.error(`::warning::${message}`); +} + +function git(args, options = {}) { + return execFileSync("git", args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trim(); +} + +function gitText(args) { + try { + return git(args); + } catch { + return ""; + } +} + +function requiredEnv(name) { + const value = String(process.env[name] || "").trim(); + if (!value) fail(`${name} is required.`); + return value; +} + +export function cleanVersion(raw) { + const value = String(raw || "").trim().replace(/^v/, ""); + if ( + !/^(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-]+)*)?$/.test( + value, + ) + ) { + fail(`Invalid release version: ${raw || "(empty)"}`); + } + return value; +} + +export function parseSemver(raw) { + const match = String(raw || "") + .replace(/^v/, "") + .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-]+)*)?$/, + ); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || "", + }; +} + +function comparePrereleaseIdentifiers(left, right) { + const leftNumeric = /^(0|[1-9]\d*)$/.test(left); + const rightNumeric = /^(0|[1-9]\d*)$/.test(right); + if (leftNumeric && rightNumeric) return Number(left) - Number(right); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +function comparePrerelease(left, right) { + if (left === right) return 0; + if (!left) return 1; + if (!right) return -1; + + const leftParts = left.split("."); + const rightParts = right.split("."); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const leftPart = leftParts[index]; + const rightPart = rightParts[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + const order = comparePrereleaseIdentifiers(leftPart, rightPart); + if (order !== 0) return order; + } + return 0; +} + +export function compareSemver(a, b) { + const left = parseSemver(a); + const right = parseSemver(b); + if (!left || !right) return String(a).localeCompare(String(b)); + for (const key of ["major", "minor", "patch"]) { + if (left[key] !== right[key]) return left[key] - right[key]; + } + return comparePrerelease(left.prerelease, right.prerelease); +} + +export function resolvePreviousRef(targetVersion, currentTag, explicitRef = "") { + const ref = String(explicitRef || "").trim(); + if (ref) { + git(["rev-parse", "--verify", `${ref}^{commit}`]); + return ref; + } + + const localTagsBeforeFetch = listLocalCliTags(); + try { + git(["fetch", "--tags", "--force", "origin"], { stdio: ["ignore", "ignore", "ignore"] }); + } catch { + if (localTagsBeforeFetch.length === 0) { + warn("Failed to fetch tags; using local tags."); + } + } + + const tag = listLocalCliTags() + .filter((item) => item !== currentTag && parseSemver(item) && compareSemver(item, targetVersion) < 0) + .sort((a, b) => compareSemver(b, a))[0]; + if (!tag) { + fail("No real previous CLI tag exists. Backfill a baseline tag first, or provide RELEASE_PREVIOUS_REF only from a migration-only caller."); + } + return tag; +} + +function listLocalCliTags() { + return git(["tag", "--list", "v*"]) + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} + +function readJsonFile(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return {}; + } +} + +function gitShowJson(ref, file) { + try { + return JSON.parse(git(["show", `${ref}:${file}`])); + } catch { + return {}; + } +} + +function tagInfo(ref, label = ref) { + const text = git(["show", "--no-patch", "--format=%H%n%ci%n%s", `${ref}^{commit}`]); + const [sha = "", date = "", subject = ""] = text.split("\n"); + return { tag: label, ref, sha, date, subject }; +} + +function commits(range) { + return git(["log", "--format=%H%x09%h%x09%s", "--no-merges", range]) + .split("\n") + .filter(Boolean) + .map((line) => { + const [sha = "", short_sha = "", subject = ""] = line.split("\t"); + return { sha, short_sha, subject }; + }); +} + +function files(range) { + return git(["diff", "--name-status", range]) + .split("\n") + .filter(Boolean) + .map((line) => { + const parts = line.split("\t"); + return { status: parts[0], path: parts.at(-1), ...(parts.length === 3 ? { old_path: parts[1] } : {}) }; + }); +} + +function versionFromToml(text) { + return String(text || "").match(/version\s*=\s*"([^"]+)"/)?.[1] || ""; +} + +function versionFromInit(text) { + return String(text || "").match(/__version__\s*=\s*"([^"]+)"/)?.[1] || ""; +} + +function versionFileChanges(previousRef) { + const previousPackage = gitShowJson(previousRef, "package.json"); + const currentPackage = readJsonFile("package.json"); + const previousPyproject = versionFromToml(gitText(["show", `${previousRef}:pyproject.toml`])); + const currentPyproject = versionFromToml(readFileSync("pyproject.toml", "utf8")); + const previousInit = versionFromInit(gitText(["show", `${previousRef}:src/memos_cli/__init__.py`])); + const currentInit = versionFromInit(readFileSync("src/memos_cli/__init__.py", "utf8")); + return [ + { file: "package.json", before: previousPackage.version || "", after: currentPackage.version || "" }, + { file: "pyproject.toml", before: previousPyproject, after: currentPyproject }, + { file: "src/memos_cli/__init__.py", before: previousInit, after: currentInit }, + ].filter((item) => item.before !== item.after); +} + +function releaseAssetContractForEvidence() { + const contract = readJsonFile("release-assets.json"); + return { + schema: contract.schema || 1, + targets: Array.isArray(contract.targets) ? contract.targets : RELEASE_ASSET_TARGETS, + public_base_url_configured: Boolean(contract.public_base_url), + }; +} + +function refsForGuidance(commit) { + const refs = []; + if (commit.short_sha) refs.push(commit.short_sha); + for (const match of String(commit.subject || "").matchAll(/#(\d+)/g)) { + const ref = `#${match[1]}`; + if (!refs.includes(ref)) refs.push(ref); + } + return refs; +} + +function categoryHintForSubject(subject) { + const value = String(subject || ""); + const lower = value.toLowerCase(); + if (lower.startsWith("release:") || /^chore(\([^)]+\))?:\s*(release|version|bump)\b/i.test(value) || /^test(\([^)]+\))?:/i.test(value)) { + return null; + } + if (/^(feat|feature|add)(\([^)]+\))?:|^add\s+/i.test(value)) { + return { category: "Added", reason: "new user-facing CLI capability or command behavior" }; + } + if (/^(perf|performance|refactor|improve|enhance)(\([^)]+\))?:/i.test(value) || /build|pack|publish|postinstall|pyinstaller|binary|installer|oss|sync-version/i.test(value)) { + return { category: "Improved", reason: "CLI packaging, installer, compatibility, or reliability improvement" }; + } + if (/^(fix|hotfix|bugfix)(\([^)]+\))?:|^fix\s+#\d+/i.test(value)) { + return { category: "Fixed", reason: "specific CLI bug fix" }; + } + return null; +} + +function releaseNoteGuidanceForCommits(commitList) { + return { + category_policy: { + Added: "Use for newly exposed CLI commands, options, workflows, or installation capabilities.", + Improved: "Use for packaging, binary distribution, compatibility, docs, postinstall, OSS, or release reliability improvements.", + Fixed: "Use for concrete broken CLI behavior, installer failures, API path regressions, encoding issues, or binary launch failures.", + }, + source_ref_category_hints: commitList + .map((commit) => { + const hint = categoryHintForSubject(commit.subject); + const source_refs = refsForGuidance(commit); + return hint && source_refs.length ? { ...hint, source_refs, subject: commit.subject } : null; + }) + .filter(Boolean), + }; +} + +export function collectEvidence({ targetVersion, currentTag, previousRef }) { + const range = `${previousRef}..HEAD`; + const commitList = commits(range); + const changed = files(range); + const repo = process.env.GITHUB_REPOSITORY || REPOSITORY; + const numbers = new Set(commitList.flatMap((item) => [...item.subject.matchAll(/#(\d+)/g)].map((match) => match[1]))); + const previousPackage = gitShowJson(previousRef, "package.json"); + const currentPackage = readJsonFile("package.json"); + const releaseAssetContract = releaseAssetContractForEvidence(); + + return { + product_id: PRODUCT_ID, + product_title: PRODUCT_TITLE, + release_note_quality_request: RELEASE_NOTE_QUALITY_REQUEST, + release_note_guidance: releaseNoteGuidanceForCommits(commitList), + repo, + previous_tag: previousRef, + current_tag: currentTag, + current_ref: "HEAD", + diff_range: range, + target_version: `v${cleanVersion(targetVersion)}`, + git_ref: git(["rev-parse", "--short=12", "HEAD"]), + previous: tagInfo(previousRef), + current: tagInfo("HEAD", currentTag), + commits: commitList, + pull_requests: [...numbers].map((number) => ({ number, url: `https://github.com/${repo}/pull/${number}` })), + changed_files: changed, + diff_stat: git(["diff", "--stat", range]), + important_diff: { + "cli/**": git([ + "diff", + "--unified=2", + range, + "--", + "src", + "scripts", + "package.json", + "pyproject.toml", + "README.md", + "README-zh.md", + "npm/README.md", + "skills/memos-memory", + ]).slice(0, 24000), + }, + package_changes: ["name", "version"] + .filter((key) => previousPackage[key] !== currentPackage[key]) + .map((key) => ({ field: key, before: previousPackage[key], after: currentPackage[key] })), + version_file_changes: versionFileChanges(previousRef), + release_asset_contract: releaseAssetContract, + postinstall_contract: { + asset_pattern: "memos--.tar.gz", + supported_targets: releaseAssetContract.targets, + skip_download_env: "MEMOS_INSTALL_SKIP_DOWNLOAD", + override_url_env: "MEMOS_BINARY_URL", + }, + test_changes: changed.filter((item) => item.path.startsWith("tests/") || item.path.endsWith(".test.js") || item.path.endsWith(".test.mjs")), + docs_changes: changed.filter((item) => /^README|^npm\/README|^docs\/|^skills\/memos-memory\//i.test(item.path)), + }; +} + +export function evidenceForInspection(evidence) { + const { + important_diff: _importantDiff, + release_note_guidance: guidance = {}, + release_note_quality_request: _releaseNoteQualityRequest, + ...publicEvidence + } = evidence || {}; + return { + ...publicEvidence, + release_note_guidance: { + source_ref_category_hints: Array.isArray(guidance.source_ref_category_hints) ? guidance.source_ref_category_hints : [], + }, + redactions: { + important_diff: "omitted from public workflow artifacts; sent only to the configured draft service", + prompt_guidance: "omitted from public workflow artifacts", + release_note_quality_request: "omitted from public workflow artifacts; sent only to the configured draft service", + }, + }; +} + +export function draftForInspection(draft) { + return { + ok: Boolean(draft?.ok), + needs_review: Boolean(draft?.needs_review), + confidence: draft?.confidence || "", + release_items: Array.isArray(draft?.release_items) ? draft.release_items : [], + coverage: draft?.coverage || {}, + warnings: Array.isArray(draft?.warnings) ? draft.warnings : [], + docs_categories: draft?.docs_categories || { cn: {}, en: {} }, + language_issues: Array.isArray(draft?.language_issues) ? draft.language_issues : [], + readability_issues: Array.isArray(draft?.readability_issues) ? draft.readability_issues : [], + postprocess: draft?.postprocess || {}, + validation_report: draft?.validation_report || {}, + validation_attempt_count: Number(draft?.validation_attempt_count || 0), + repair_attempt_count: Number(draft?.repair_attempt_count || 0), + repair_attempts: Array.isArray(draft?.repair_attempts) ? draft.repair_attempts : [], + redactions: { + server_debug_fields: "omitted from public workflow artifacts", + model_and_prompt_details: "omitted from public workflow artifacts", + }, + }; +} + +function appendOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) return; + writeFileSync(process.env.GITHUB_OUTPUT, `${name}<<__DOC_AGENT_EOF__\n${value}\n__DOC_AGENT_EOF__\n`, { + flag: "a", + }); +} + +export function ensureSourceHint(notes) { + const hint = ``; + return notes.includes("doc-agent: source-id=") ? `${notes.trim()}\n` : `${notes.trim()}\n\n${hint}\n`; +} + +function normalizeReleaseItem(raw) { + const category = RELEASE_CATEGORY_ORDER.includes(String(raw?.category || "").trim()) ? String(raw.category).trim() : ""; + const text_cn = String(raw?.text_cn || "").trim().replace(/^-+\s*/, ""); + const text_en = String(raw?.text_en || "").trim().replace(/^-+\s*/, ""); + const source_refs = Array.isArray(raw?.source_refs) + ? raw.source_refs.map(normalizeSourceRef).filter(Boolean) + : []; + return category && text_cn && text_en && source_refs.length ? { category, text_cn, text_en, source_refs } : null; +} + +function normalizeSourceRef(value) { + const text = String(value || "").trim().replace(/^[`[(\s]+|[`)\],.;\s]+$/g, ""); + if (/^#\d+$/.test(text)) return text; + if (/^[a-fA-F0-9]{7,40}$/.test(text)) return text.toLowerCase(); + if (/^\d{2,}$/.test(text)) return `#${text}`; + return ""; +} + +function sourceRefsForCommit(commit) { + const refs = []; + if (commit.short_sha) refs.push(String(commit.short_sha).toLowerCase()); + if (commit.sha) refs.push(String(commit.sha).toLowerCase()); + for (const match of String(commit.subject || "").matchAll(/#(\d+)/g)) refs.push(`#${match[1]}`); + return [...new Set(refs.map(normalizeSourceRef).filter(Boolean))]; +} + +function evidenceSourceIndex(evidence) { + const validRefs = new Set(); + const required = []; + for (const commit of Array.isArray(evidence?.commits) ? evidence.commits : []) { + const refs = sourceRefsForCommit(commit); + refs.forEach((ref) => validRefs.add(ref)); + if (categoryHintForSubject(commit.subject)) { + required.push({ + subject: commit.subject, + refs, + preferred_ref: refs[0] || "", + }); + } + } + for (const pr of Array.isArray(evidence?.pull_requests) ? evidence.pull_requests : []) { + const ref = normalizeSourceRef(`#${pr.number}`); + if (ref) validRefs.add(ref); + } + return { validRefs, required }; +} + +function languageIssuesFromReleaseItems(items) { + const issues = []; + items.forEach((item, index) => { + if (!CJK_RE.test(item.text_cn)) { + issues.push({ index, field: "text_cn", issue: "Chinese output must contain Chinese/CJK text." }); + } + if (CJK_RE.test(item.text_en)) { + issues.push({ index, field: "text_en", issue: "English output must not contain Chinese/CJK text." }); + } + }); + return issues; +} + +function readabilityIssuesFromReleaseItems(items) { + const issues = []; + if (items.length > MAX_RELEASE_ITEMS) { + issues.push({ + field: "release_items", + item_count: items.length, + max_item_count: MAX_RELEASE_ITEMS, + issue: "Plugin changelog output must be grouped into concise product-facing bullets.", + }); + } + items.forEach((item, index) => { + if (item.text_cn && item.text_cn.length > MAX_TEXT_CN_CHARS) { + issues.push({ + index, + field: "text_cn", + current_length: item.text_cn.length, + max_length: MAX_TEXT_CN_CHARS, + issue: "Chinese release-note text is too long for the Plugin tab.", + }); + } + if (item.text_en && item.text_en.length > MAX_TEXT_EN_CHARS) { + issues.push({ + index, + field: "text_en", + current_length: item.text_en.length, + max_length: MAX_TEXT_EN_CHARS, + issue: "English release-note text is too long for the Plugin tab.", + }); + } + }); + return issues; +} + +function categoriesFromReleaseItems(items) { + const releaseCategories = {}; + const docsCategories = { cn: {}, en: {} }; + for (const item of items) { + if (!releaseCategories[item.category]) releaseCategories[item.category] = []; + releaseCategories[item.category].push(item); + + const docsCategory = RELEASE_TO_DOC_CATEGORY[item.category]; + if (!docsCategory) continue; + if (!docsCategories.cn[docsCategory]) docsCategories.cn[docsCategory] = []; + if (!docsCategories.en[docsCategory]) docsCategories.en[docsCategory] = []; + docsCategories.cn[docsCategory].push(item.text_cn); + docsCategories.en[docsCategory].push(item.text_en); + } + return { releaseCategories, docsCategories }; +} + +function markdownFromReleaseItems(items, coverage) { + const lines = ["## Changelog", ""]; + for (const category of RELEASE_CATEGORY_ORDER) { + const categoryItems = items.filter((item) => item.category === category); + if (!categoryItems.length) continue; + lines.push(`### ${category}`, ""); + for (const item of categoryItems) lines.push(`- ${item.text_en}`); + lines.push(""); + } + const payload = { items, coverage }; + lines.push(`"); + return `${lines.join("\n").trim()}\n`; +} + +function coverageFromReleaseItems(evidence, draft, items) { + const index = evidenceSourceIndex(evidence); + const usedRefs = new Set(items.flatMap((item) => item.source_refs)); + const invalid_source_refs = [...usedRefs].filter((ref) => !index.validRefs.has(ref)); + const missing_required_refs = index.required + .filter((required) => !required.refs.some((ref) => usedRefs.has(ref))) + .map((required) => ({ + source_ref: required.preferred_ref, + subject: required.subject, + })); + return { + ...(draft?.coverage || {}), + required_count: index.required.length, + covered_required_count: Math.max(0, index.required.length - missing_required_refs.length), + missing_required_count: missing_required_refs.length, + missing_required_refs, + invalid_source_refs, + needs_review: Boolean(draft?.coverage?.needs_review) || missing_required_refs.length > 0 || invalid_source_refs.length > 0, + }; +} + +export function postprocessDraftFromEvidence(evidence, draft) { + const rawItems = Array.isArray(draft?.release_items) ? draft.release_items : Array.isArray(draft?.items) ? draft.items : []; + const items = rawItems.map(normalizeReleaseItem).filter(Boolean); + const languageIssues = languageIssuesFromReleaseItems(items); + const readabilityIssues = readabilityIssuesFromReleaseItems(items); + const coverage = coverageFromReleaseItems(evidence, draft, items); + if (languageIssues.length > 0 || readabilityIssues.length > 0 || items.length === 0) coverage.needs_review = true; + const { releaseCategories, docsCategories } = categoriesFromReleaseItems(items); + const validationIssues = [ + ...(items.length ? [] : [{ issue: "release_items is empty after normalization" }]), + ...languageIssues, + ...readabilityIssues, + ...(coverage.invalid_source_refs || []).map((ref) => ({ field: "source_refs", source_ref: ref, issue: "source_ref is not present in git evidence" })), + ...(coverage.missing_required_refs || []).map((item) => ({ field: "coverage", source_ref: item.source_ref, subject: item.subject, issue: "important commit is not covered by any release note item" })), + ]; + const warnings = Array.isArray(draft?.warnings) ? [...draft.warnings] : []; + if (validationIssues.length > 0) warnings.push("release notes failed local validation and require repair before publishing"); + if (readabilityIssues.length > 0) warnings.push("release notes readability validation failed; the Plugin tab draft must be repaired before publishing"); + return { + ...draft, + ok: items.length > 0 && !coverage.needs_review, + needs_review: Boolean(coverage.needs_review), + release_items: items, + release_categories: releaseCategories, + docs_categories: docsCategories, + coverage, + warnings, + language_issues: languageIssues, + readability_issues: readabilityIssues, + validation_report: { + ok: validationIssues.length === 0, + issue_count: validationIssues.length, + language_issue_count: languageIssues.length, + readability_issue_count: readabilityIssues.length, + issues: validationIssues, + }, + release_notes_markdown: markdownFromReleaseItems(items, coverage), + }; +} + +function previewDateFromPublishedAt(value) { + const text = String(value || "").trim(); + if (!text) return ""; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +} + +export function docsPreviewFromDraft(draft, { targetVersion, publishedAt = "" } = {}) { + const version = `v${cleanVersion(targetVersion || draft?.target_version || "0.0.0")}`; + const date = previewDateFromPublishedAt(publishedAt); + const docsCategories = draft?.docs_categories || { cn: {}, en: {} }; + const buildEntry = (locale) => ({ + name: version, + date, + products: { + plugin: Object.fromEntries( + Object.entries(docsCategories[locale] || {}).map(([category, items]) => [ + category, + [{ type: PRODUCT_TITLE[locale === "cn" ? "zh" : "en"], changedInfo: items }], + ]), + ), + }, + }); + return { + schema: "memos.plugin.docs_preview.v1", + product_id: PRODUCT_ID, + repo: REPOSITORY, + version, + date, + date_source: publishedAt ? "provided published_at" : "GitHub Release published_at at publish time", + docs_files: { + cn: "content/cn/plugin-changelog.yml", + en: "content/en/plugin-changelog.yml", + }, + entries: { + cn: buildEntry("cn"), + en: buildEntry("en"), + }, + }; +} + +export function markdownFromDocsPreview(preview) { + const lines = [ + "# MemOS-Docs Plugin Changelog Preview", + "", + `- product_id: ${preview.product_id}`, + `- version: ${preview.version}`, + `- date: ${preview.date}`, + `- zh file: ${preview.docs_files.cn}`, + `- en file: ${preview.docs_files.en}`, + ]; + for (const [locale, title] of [["cn", "中文预览"], ["en", "English Preview"]]) { + lines.push("", `## ${title}`); + const plugin = preview.entries[locale]?.products?.plugin || {}; + const categories = Object.keys(plugin); + if (categories.length === 0) { + lines.push("", "- No plugin changelog items would be rendered."); + continue; + } + for (const category of categories) { + lines.push("", `### ${category}`); + for (const group of plugin[category] || []) { + lines.push("", `- type: ${group.type}`); + for (const item of group.changedInfo || []) lines.push(` - ${item}`); + } + } + } + return `${lines.join("\n").trim()}\n`; +} + +function releaseNotesPayloadFromDraft(draft) { + const rawItems = Array.isArray(draft?.release_items) ? draft.release_items : Array.isArray(draft?.items) ? draft.items : []; + const items = rawItems.map(normalizeReleaseItem).filter(Boolean); + return { items, coverage: draft?.coverage || { needs_review: Boolean(draft?.needs_review) } }; +} + +function ensureMachineReadablePayload(notes, draft) { + if (notes.includes(RELEASE_NOTES_MARKER)) return notes; + const payload = releaseNotesPayloadFromDraft(draft); + if (!payload.items.length) { + fail("Doc Agent draft did not include release_items/source_refs for the hidden docs payload."); + } + return `${notes.trim()}\n\n\n`; +} + +export function validateManualNotes(notes) { + const text = String(notes || "").trim(); + if (!/^## Changelog\s*$/m.test(text)) fail("Manual release notes require a ## Changelog heading."); + const payload = manualPayloadFromNotes(text); + if (!Array.isArray(payload?.items) || payload.items.length === 0 || payload?.coverage?.needs_review !== false) { + fail("Manual release-note evidence must have non-empty items and passed coverage."); + } + const items = payload.items.map(normalizeReleaseItem).filter(Boolean); + const languageIssues = languageIssuesFromReleaseItems(items); + const readabilityIssues = readabilityIssuesFromReleaseItems(items); + if (items.length !== payload.items.length) { + fail("Manual release-note evidence has invalid categories, text, or source_refs."); + } + if (languageIssues.length > 0) { + fail("Manual release-note evidence must keep Chinese text in text_cn and English text in text_en."); + } + if (readabilityIssues.length > 0) { + fail("Manual release-note items must stay concise enough for the Plugin tab preview."); + } + return text; +} + +function manualPayloadFromNotes(notes) { + const match = String(notes || "").match(new RegExp(``)); + if (!match) fail("Manual release notes require the doc-agent-release-notes-json evidence block."); + try { + return JSON.parse(match[1]); + } catch { + fail("Manual release-note evidence JSON is invalid."); + } +} + +export function manualDraftFromEvidence(evidence, notes) { + const validNotes = ensureSourceHint(validateManualNotes(notes)); + const payload = manualPayloadFromNotes(validNotes); + const draft = postprocessDraftFromEvidence(evidence, { + ok: true, + needs_review: false, + confidence: "manual", + release_items: payload.items, + coverage: payload.coverage, + warnings: [], + }); + return { + ...draft, + confidence: "manual", + validation_attempt_count: 1, + repair_attempt_count: 0, + repair_attempts: [ + { + stage: "manual", + attempt: 1, + ok: draft.ok, + needs_review: draft.needs_review, + validation_report: draft.validation_report, + }, + ], + postprocess: { + applied: true, + source: "manual_release_notes", + final_item_count: draft.release_items.length, + }, + release_notes_markdown: validNotes, + }; +} + +export function qualityReportFromDraft( + draft, + { targetVersion = "", previousRef = "", currentTag = "", currentRef = "HEAD", draftUsed = true } = {}, +) { + const validation = draft?.validation_report || {}; + const coverage = draft?.coverage || {}; + const issues = Array.isArray(validation.issues) ? validation.issues : []; + const languageIssues = Array.isArray(draft?.language_issues) ? draft.language_issues : []; + const readabilityIssues = Array.isArray(draft?.readability_issues) ? draft.readability_issues : []; + const invalidRefs = Array.isArray(coverage.invalid_source_refs) ? coverage.invalid_source_refs : []; + const missingRequired = Array.isArray(coverage.missing_required_refs) ? coverage.missing_required_refs : []; + return { + schema: "memos.plugin.release_notes.quality_report.v1", + product_id: PRODUCT_ID, + repo: REPOSITORY, + target_version: targetVersion ? `v${cleanVersion(targetVersion)}` : "", + previous_ref: previousRef, + current_tag: currentTag, + current_ref: currentRef, + draft_used: Boolean(draftUsed), + ok: Boolean(draft?.ok) && !draft?.needs_review && validation.ok !== false, + needs_review: Boolean(draft?.needs_review) || validation.ok === false, + limits: RELEASE_NOTE_LIMITS, + item_count: Array.isArray(draft?.release_items) ? draft.release_items.length : 0, + issue_count: Number(validation.issue_count ?? issues.length), + language_issue_count: Number(validation.language_issue_count ?? languageIssues.length), + readability_issue_count: Number(validation.readability_issue_count ?? readabilityIssues.length), + invalid_source_ref_count: invalidRefs.length, + missing_required_count: Number(coverage.missing_required_count ?? missingRequired.length), + coverage, + issues, + validation_attempt_count: Number(draft?.validation_attempt_count || 0), + repair_attempt_count: Number(draft?.repair_attempt_count || 0), + attempts: Array.isArray(draft?.repair_attempts) ? draft.repair_attempts : [], + }; +} + +function cleanError(value) { + return String(value || "") + .replace(/Bearer\s+\S+/gi, "Bearer ***") + .replace(/sk-[\w-]+/g, "sk-***") + .replace(/https?:\/\/[^\s"'<>]+/gi, "https://***") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, "***") + .replace(/\s+/g, " ") + .slice(0, 600); +} + +function retryable(status) { + return [408, 425, 429].includes(status) || status >= 500; +} + +async function reportFailure(evidence, attempts, finalError, fetchImpl, phase = "release-notes") { + if (attempts.length < 3 || !process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN) return; + const failureUrl = String(process.env.DOC_AGENT_RELEASE_FAILURE_URL || "").trim(); + if (!failureUrl) { + warn("DOC_AGENT_RELEASE_FAILURE_URL is not configured; skipping exhausted-retry report."); + return; + } + const response = await fetchImpl(failureUrl, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN}`, + }, + body: JSON.stringify({ + product_id: PRODUCT_ID, + repository: evidence.repo, + version: evidence.target_version, + phase, + run_id: process.env.GITHUB_RUN_ID || `${evidence.current_tag}-cli`, + run_url: process.env.GITHUB_RUN_ID ? `https://github.com/${evidence.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` : "", + attempts: attempts.slice(0, 3).map((item, index) => ({ + attempt: index + 1, + error_code: item.error_code || "DRAFT_FAILED", + message: cleanError(item.message || item.error), + retryable: Boolean(item.retryable), + })), + final_error: cleanError(finalError), + }), + }); + if (!response.ok) throw new Error(`Failure-report endpoint returned HTTP ${response.status}`); +} + +export async function reportExternalFailureFromEnv({ fetchImpl = fetch } = {}) { + const phase = String(process.env.RELEASE_FAILURE_PHASE || "").trim(); + const attemptDir = String(process.env.RELEASE_FAILURE_ATTEMPT_DIR || "").trim(); + if (!phase || !attemptDir) fail("RELEASE_FAILURE_PHASE and RELEASE_FAILURE_ATTEMPT_DIR are required."); + const attempts = [1, 2, 3].map((attempt) => { + let message = "attempt log is unavailable"; + try { + message = readFileSync(join(attemptDir, `${attempt}.log`), "utf8"); + } catch { + // The failure report should not replace the original publish failure. + } + return { error_code: phase.toUpperCase().replace(/[^A-Z0-9]+/g, "_"), message: cleanError(message), retryable: true }; + }); + const targetVersion = cleanVersion(process.env.RELEASE_VERSION); + return reportFailure( + { + repo: process.env.GITHUB_REPOSITORY || REPOSITORY, + target_version: `v${targetVersion}`, + current_tag: process.env.RELEASE_TAG || `v${targetVersion}`, + }, + attempts, + attempts[2].message, + fetchImpl, + phase, + ); +} + +export async function requestDraft(evidence, { fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) { + const token = requiredEnv("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN"); + const draftUrl = requiredEnv("DOC_AGENT_RELEASE_NOTES_DRAFT_URL"); + const attempts = []; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetchImpl(draftUrl, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + ...evidence, + workflow_retry_context: { attempt, previous_errors: attempts.map((item) => item.message) }, + }), + }); + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + throw Object.assign(new Error(`non-JSON response: HTTP ${response.status}`), { + retryable: retryable(response.status), + code: `HTTP_${response.status}`, + }); + } + if (!response.ok) { + throw Object.assign(new Error(`HTTP ${response.status} ${text.slice(0, 400)}`), { + retryable: retryable(response.status), + code: `HTTP_${response.status}`, + }); + } + if (!payload.ok || payload.needs_review || payload.coverage?.needs_review !== false || (!String(payload.release_notes_markdown || "").trim() && !hasStructuredDraftItems(payload))) { + const message = `Doc Agent draft requires review: ${JSON.stringify(payload.coverage || {})} ${(payload.warnings || []).join("; ")}`; + if (hasStructuredDraftItems(payload)) { + warn(`${message} Continuing with local validation and repair because the draft service returned structured release_items.`); + return payload; + } + if (payload.attempts?.length >= 3) await reportFailure(evidence, payload.attempts, message, fetchImpl); + fail(message); + } + return payload; + } catch (error) { + const item = { + error_code: error?.code || "DRAFT_REQUEST", + message: cleanError(error?.message || error), + retryable: Boolean(error?.retryable), + }; + attempts.push(item); + if (!item.retryable || attempt === 3) { + await reportFailure(evidence, attempts, item.message, fetchImpl); + fail(`Doc Agent draft failed on attempt ${attempt}: ${item.message}`); + } + warn(`Draft attempt ${attempt} failed; retrying: ${item.message}`); + await sleep(250 * 2 ** (attempt - 1)); + } + } +} + +function hasStructuredDraftItems(payload) { + return Array.isArray(payload?.release_items) && payload.release_items.map(normalizeReleaseItem).some(Boolean); +} + +export async function requestValidatedDraft( + evidence, + { fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}, +) { + let draft = postprocessDraftFromEvidence(evidence, await requestDraft(evidence, { fetchImpl, sleep })); + const repairAttempts = []; + for (let attempt = 1; draft.needs_review && attempt <= MAX_DRAFT_REPAIR_ATTEMPTS; attempt += 1) { + warn( + `CLI release notes validation failed after ${attempt === 1 ? "initial draft validation" : `repair validation attempt ${attempt - 1}`}; requesting draft repair ${attempt}/${MAX_DRAFT_REPAIR_ATTEMPTS}: ${draft.validation_report?.issues?.map((item) => item.field || item.issue).join(", ")}`, + ); + const repairEvidence = { + ...evidence, + release_note_repair_context: { + attempt, + max_attempts: MAX_DRAFT_REPAIR_ATTEMPTS, + validation_report: draft.validation_report, + previous_release_items: draft.release_items, + previous_warnings: draft.warnings, + }, + }; + const repaired = await requestDraft(repairEvidence, { fetchImpl, sleep }); + draft = postprocessDraftFromEvidence(evidence, repaired); + repairAttempts.push({ + attempt, + ok: draft.ok, + needs_review: draft.needs_review, + validation_report: draft.validation_report, + }); + } + return { + ...draft, + validation_attempt_count: 1 + repairAttempts.length, + repair_attempt_count: repairAttempts.length, + repair_attempts: repairAttempts, + }; +} + +async function reportValidationFailureIfExhausted(evidence, draft, fetchImpl = fetch) { + if (Number(draft?.repair_attempt_count || 0) < MAX_DRAFT_REPAIR_ATTEMPTS) return; + const repairAttempts = Array.isArray(draft?.repair_attempts) ? draft.repair_attempts : []; + const attempts = repairAttempts.slice(-MAX_DRAFT_REPAIR_ATTEMPTS).map((item) => ({ + error_code: "RELEASE_NOTES_VALIDATION", + message: JSON.stringify(item.validation_report || item), + retryable: true, + })); + try { + await reportFailure( + evidence, + attempts, + JSON.stringify(draft?.validation_report || draft?.coverage || {}), + fetchImpl, + "release-notes-validation", + ); + } catch (error) { + warn(`Failed to report exhausted release-note validation: ${cleanError(error?.message || error)}`); + } +} + +export async function main() { + const targetVersion = cleanVersion(process.env.RELEASE_VERSION); + const currentTag = process.env.RELEASE_TAG || `v${targetVersion}`; + const notesPath = process.env.RELEASE_NOTES_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-release-notes.md`); + const evidencePath = process.env.RELEASE_EVIDENCE_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-evidence.json`); + const draftPath = process.env.RELEASE_DRAFT_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-release-notes-draft.json`); + const docsPreviewPath = process.env.RELEASE_DOCS_PREVIEW_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-docs-preview.json`); + const docsPreviewMarkdownPath = process.env.RELEASE_DOCS_PREVIEW_MARKDOWN_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-docs-preview.md`); + const qualityReportPath = process.env.RELEASE_QUALITY_REPORT_FILE || join(tmpdir(), `memos-cloud-cli-${targetVersion}-quality-report.json`); + mkdirSync(dirname(notesPath), { recursive: true }); + mkdirSync(dirname(evidencePath), { recursive: true }); + mkdirSync(dirname(draftPath), { recursive: true }); + mkdirSync(dirname(docsPreviewPath), { recursive: true }); + mkdirSync(dirname(docsPreviewMarkdownPath), { recursive: true }); + mkdirSync(dirname(qualityReportPath), { recursive: true }); + + const previousRef = resolvePreviousRef(targetVersion, currentTag, process.env.RELEASE_PREVIOUS_REF || ""); + const evidence = collectEvidence({ targetVersion, currentTag, previousRef }); + writeFileSync(evidencePath, JSON.stringify(evidenceForInspection(evidence), null, 2), "utf8"); + const manual = String(process.env.MANUAL_RELEASE_NOTES || "").trim(); + const draftUsed = !manual; + let draft; + if (manual) { + try { + draft = manualDraftFromEvidence(evidence, manual); + } catch (error) { + const issue = { field: "manual_release_notes", issue: cleanError(error?.message || error) }; + draft = { + ok: false, + needs_review: true, + confidence: "manual", + release_items: [], + docs_categories: { cn: {}, en: {} }, + coverage: { needs_review: true, missing_required_count: 0, invalid_source_refs: [] }, + warnings: [issue.issue], + language_issues: [], + readability_issues: [], + validation_report: { ok: false, issue_count: 1, language_issue_count: 0, readability_issue_count: 0, issues: [issue] }, + validation_attempt_count: 1, + repair_attempt_count: 0, + repair_attempts: [{ stage: "manual", attempt: 1, ok: false, needs_review: true, validation_report: { ok: false, issues: [issue] } }], + release_notes_markdown: manual, + }; + } + } else { + try { + draft = await requestValidatedDraft(evidence); + } catch (error) { + const issue = { field: "draft_service", issue: cleanError(error?.message || error) }; + draft = { + ok: false, + needs_review: true, + confidence: "failed", + release_items: [], + docs_categories: { cn: {}, en: {} }, + coverage: { needs_review: true, missing_required_count: 0, invalid_source_refs: [] }, + warnings: [issue.issue], + language_issues: [], + readability_issues: [], + validation_report: { ok: false, issue_count: 1, language_issue_count: 0, readability_issue_count: 0, issues: [issue] }, + validation_attempt_count: 0, + repair_attempt_count: 0, + repair_attempts: [], + release_notes_markdown: "", + }; + } + } + + const docsPreview = docsPreviewFromDraft(draft, { targetVersion }); + const qualityReport = qualityReportFromDraft(draft, { + targetVersion, + previousRef, + currentTag, + currentRef: "HEAD", + draftUsed, + }); + writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8"); + writeFileSync(docsPreviewPath, JSON.stringify(docsPreview, null, 2), "utf8"); + writeFileSync(docsPreviewMarkdownPath, markdownFromDocsPreview(docsPreview), "utf8"); + writeFileSync(qualityReportPath, JSON.stringify(qualityReport, null, 2), "utf8"); + if (String(draft.release_notes_markdown || "").trim()) { + const notes = draftUsed + ? ensureSourceHint(ensureMachineReadablePayload(draft.release_notes_markdown, draft)) + : ensureSourceHint(draft.release_notes_markdown); + writeFileSync(notesPath, notes, "utf8"); + } + + for (const [key, value] of Object.entries({ + release_notes_file: notesPath, + evidence_file: evidencePath, + draft_file: draftPath, + docs_preview_file: docsPreviewPath, + docs_preview_markdown_file: docsPreviewMarkdownPath, + quality_report_file: qualityReportPath, + draft_used: String(draftUsed), + previous_tag: previousRef, + current_tag: currentTag, + current_ref: "HEAD", + draft_confidence: String(draft.confidence || ""), + missing_required_count: String(draft.coverage?.missing_required_count ?? ""), + validation_attempt_count: String(draft.validation_attempt_count ?? ""), + repair_attempt_count: String(draft.repair_attempt_count ?? ""), + })) { + appendOutput(key, value); + } + + if (!draft.ok || draft.needs_review) { + if (draftUsed) await reportValidationFailureIfExhausted(evidence, draft); + fail(`Postprocessed CLI release notes require review: ${JSON.stringify(draft.validation_report || draft.coverage || {})}`); + } +} + +const isDirect = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirect) { + const run = process.env.RELEASE_FAILURE_PHASE ? reportExternalFailureFromEnv : main; + run().catch((error) => { + console.error(`::error::${cleanError(error?.message || error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/draft-cli-release-notes.test.mjs b/.github/scripts/draft-cli-release-notes.test.mjs new file mode 100644 index 0000000..24acd69 --- /dev/null +++ b/.github/scripts/draft-cli-release-notes.test.mjs @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + cleanVersion, + compareSemver, + docsPreviewFromDraft, + ensureSourceHint, + manualDraftFromEvidence, + postprocessDraftFromEvidence, + qualityReportFromDraft, + reportExternalFailureFromEnv, + requestDraft, + requestValidatedDraft, + RELEASE_NOTE_QUALITY_REQUEST, + resolvePreviousRef, + validateManualNotes, +} from "./draft-cli-release-notes.mjs"; + +const evidence = { repo: "MemTensor/MemOS-Cloud-CLI", current_tag: "v1.0.6", target_version: "v1.0.6" }; +const cliEvidence = { + ...evidence, + release_note_quality_request: RELEASE_NOTE_QUALITY_REQUEST, + commits: [ + { + sha: "abc1234abc1234abc1234abc1234abc1234abc1", + short_sha: "abc1234", + subject: "feat: add CLI binary installer", + }, + ], + pull_requests: [], +}; +const response = (status, body) => ({ status, ok: status >= 200 && status < 300, async text() { return JSON.stringify(body); } }); + +test("CLI manual notes remain evidence backed", () => { + const notes = `## Changelog\n\n### Added\n- command\n\n`; + assert.equal(validateManualNotes(notes), notes); + assert.match(ensureSourceHint(notes), /source-id=memos-cloud-cli/); + assert.equal(cleanVersion("v1.0.6"), "1.0.6"); +}); + +test("CLI manual notes cannot bypass language or source-ref validation", () => { + const mixedLanguageNotes = `## Changelog\n\n### Added\n- command\n\n`; + assert.throws(() => validateManualNotes(mixedLanguageNotes), /Chinese text in text_cn and English text in text_en/); + + const invalidRefNotes = `## Changelog\n\n### Added\n- command\n\n`; + assert.throws(() => validateManualNotes(invalidRefNotes), /invalid categories, text, or source_refs/); +}); + +test("CLI manual notes cannot bypass docs readability validation", () => { + const longNotes = `## Changelog\n\n### Improved\n- command\n\n`; + assert.throws(() => validateManualNotes(longNotes), /concise enough/); +}); + +test("CLI manual notes are reconciled against real git evidence", () => { + const validNotes = `## Changelog\n\n### Added\n- command\n\n`; + const validDraft = manualDraftFromEvidence(cliEvidence, validNotes); + assert.equal(validDraft.ok, true); + assert.equal(validDraft.coverage.missing_required_count, 0); + assert.equal(validDraft.coverage.invalid_source_refs.length, 0); + assert.equal(validDraft.validation_attempt_count, 1); + assert.equal(validDraft.repair_attempt_count, 0); + + const inventedRefNotes = validNotes.replaceAll("abc1234", "deadbee"); + const invalidDraft = manualDraftFromEvidence(cliEvidence, inventedRefNotes); + assert.equal(invalidDraft.ok, false); + assert.deepEqual(invalidDraft.coverage.invalid_source_refs, ["deadbee"]); + assert.equal(invalidDraft.coverage.missing_required_count, 1); +}); + +test("CLI quality report is compact, inspectable, and fail-closed", () => { + const draft = manualDraftFromEvidence( + cliEvidence, + `## Changelog\n\n### Added\n- command\n\n`, + ); + const report = qualityReportFromDraft(draft, { + targetVersion: "1.0.7", + previousRef: "v1.0.6", + currentTag: "v1.0.7", + currentRef: "abc1234", + draftUsed: false, + }); + assert.equal(report.schema, "memos.plugin.release_notes.quality_report.v1"); + assert.equal(report.ok, true); + assert.equal(report.needs_review, false); + assert.equal(report.item_count, 1); + assert.equal(report.missing_required_count, 0); + assert.equal(report.limits.max_items, 12); + assert.equal(report.limits.max_repair_attempts, 3); + assert.equal(report.draft_used, false); +}); + +test("CLI requests multi-candidate release-note quality from the draft service", () => { + assert.equal(RELEASE_NOTE_QUALITY_REQUEST.candidate_count, 3); + assert.match(RELEASE_NOTE_QUALITY_REQUEST.selection_policy.join("\n"), /source_ref validity/); + assert.equal(RELEASE_NOTE_QUALITY_REQUEST.repair_policy.max_repair_attempts, 3); +}); + +test("CLI postprocess rejects mixed-language output and missing important refs", () => { + const draft = postprocessDraftFromEvidence(cliEvidence, { + ok: true, + needs_review: false, + coverage: { needs_review: false }, + release_items: [ + { + category: "Added", + text_cn: "新增 CLI 安装器", + text_en: "Added CLI 安装器", + source_refs: ["deadbee"], + }, + ], + }); + assert.equal(draft.ok, false); + assert.equal(draft.needs_review, true); + assert.deepEqual(draft.coverage.invalid_source_refs, ["deadbee"]); + assert.equal(draft.coverage.missing_required_count, 1); + assert.equal(draft.language_issues.length, 1); +}); + +test("CLI postprocess rejects docs output that is too fragmented", () => { + const noisyItems = Array.from({ length: 13 }, (_item, index) => ({ + category: "Improved", + text_cn: `**CLI 优化 ${index + 1}**:优化发布说明展示效果。`, + text_en: `**CLI improvement ${index + 1}**: Refined release-note presentation.`, + source_refs: ["abc1234"], + })); + const draft = postprocessDraftFromEvidence(cliEvidence, { + ok: true, + needs_review: false, + coverage: { needs_review: false }, + release_items: noisyItems, + }); + + assert.equal(draft.ok, false); + assert.equal(draft.needs_review, true); + assert.ok(draft.readability_issues.some((issue) => issue.field === "release_items")); + assert.ok(draft.validation_report.issues.some((issue) => issue.field === "release_items")); +}); + +test("CLI postprocess rejects docs bullets that are too long", () => { + const draft = postprocessDraftFromEvidence(cliEvidence, { + ok: true, + needs_review: false, + coverage: { needs_review: false }, + release_items: [ + { + category: "Improved", + text_cn: `**CLI 安装器优化**:${"用于发布说明质量验证的重复中文描述。".repeat(12)}`, + text_en: `**CLI installer improvements**: ${"This repeated English detail is intentionally too verbose for a changelog bullet. ".repeat(6)}`, + source_refs: ["abc1234"], + }, + ], + }); + + assert.equal(draft.ok, false); + assert.equal(draft.needs_review, true); + assert.ok(draft.readability_issues.some((issue) => issue.field === "text_cn")); + assert.ok(draft.readability_issues.some((issue) => issue.field === "text_en")); + assert.ok(draft.validation_report.issues.some((issue) => issue.field === "text_en")); +}); + +test("CLI repairs structured needs-review drafts with validation context", async () => { + const previous = { ...process.env }; + try { + Object.assign(process.env, { + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "test-token", + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: "https://example.invalid/internal/release-notes/draft", + }); + const calls = []; + const fetchImpl = async (_url, options) => { + const body = JSON.parse(options.body); + calls.push(body); + if (calls.length === 1) { + return response(200, { + ok: false, + needs_review: true, + coverage: { needs_review: true }, + release_items: [ + { + category: "Added", + text_cn: "新增 CLI 安装器", + text_en: "Added CLI 安装器", + source_refs: ["abc1234"], + }, + ], + }); + } + return response(200, { + ok: true, + needs_review: false, + confidence: "high", + coverage: { needs_review: false }, + release_items: [ + { + category: "Added", + text_cn: "新增 CLI 二进制安装器", + text_en: "Added the CLI binary installer.", + source_refs: ["abc1234"], + }, + ], + }); + }; + const draft = await requestValidatedDraft(cliEvidence, { fetchImpl, sleep: async () => {} }); + assert.equal(draft.ok, true); + assert.equal(draft.repair_attempt_count, 1); + assert.equal(calls[0].release_note_quality_request.candidate_count, 3); + assert.equal(calls[1].release_note_repair_context.validation_report.issue_count, 1); + } finally { + process.env = previous; + } +}); + +test("CLI docs preview renders plugin changelog entries from validated items", () => { + const draft = postprocessDraftFromEvidence(cliEvidence, { + ok: true, + needs_review: false, + coverage: { needs_review: false }, + release_items: [ + { + category: "Added", + text_cn: "新增 CLI 二进制安装器", + text_en: "Added the CLI binary installer.", + source_refs: ["abc1234"], + }, + ], + }); + const preview = docsPreviewFromDraft(draft, { targetVersion: "1.0.7", publishedAt: "2026-07-24T02:00:00Z" }); + assert.equal(preview.entries.cn.name, "v1.0.7"); + assert.equal(preview.entries.cn.date, "2026-07-24"); + assert.equal(preview.entries.cn.products.plugin["New Features"][0].type, "MemOS CLI"); + assert.deepEqual(preview.entries.en.products.plugin["New Features"][0].changedInfo, ["Added the CLI binary installer."]); +}); + +test("CLI SemVer comparison handles prerelease numbers and ignores build metadata", () => { + assert.equal(compareSemver("1.0.0-beta.10", "1.0.0-beta.9") > 0, true); + assert.equal(compareSemver("1.0.0-beta.20", "1.0.0-beta.19") > 0, true); + assert.equal(compareSemver("1.0.0", "1.0.0-beta.99") > 0, true); + assert.equal(compareSemver("1.0.0-beta.1+build.2", "1.0.0-beta.1+build.1"), 0); + assert.equal(compareSemver("1.0.0-alpha.1", "1.0.0-alpha.beta") < 0, true); +}); + +test("CLI previous tag selection uses SemVer precedence for prerelease numbers", () => { + const directory = mkdtempSync(join(tmpdir(), "cli-semver-tags-")); + const previousCwd = process.cwd(); + try { + process.chdir(directory); + execFileSync("git", ["init"], { stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "test"], { stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.invalid"], { stdio: "ignore" }); + writeFileSync("package.json", '{"private":true}\n', "utf8"); + execFileSync("git", ["add", "package.json"], { stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { stdio: "ignore" }); + for (const tag of [ + "v1.0.0-beta.1", + "v1.0.0-beta.2", + "v1.0.0-beta.9", + "v1.0.0-beta.10", + "v1.0.0-beta.19", + ]) { + execFileSync("git", ["tag", tag], { stdio: "ignore" }); + } + + assert.equal(resolvePreviousRef("1.0.0-beta.10", "v1.0.0-beta.10"), "v1.0.0-beta.9"); + assert.equal(resolvePreviousRef("1.0.0-beta.20", "v1.0.0-beta.20"), "v1.0.0-beta.19"); + } finally { + process.chdir(previousCwd); + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CLI external retries are reported after the third attempt", async () => { + const directory = mkdtempSync(join(tmpdir(), "cli-release-failure-")); + const previous = { ...process.env }; + try { + for (const attempt of [1, 2, 3]) writeFileSync(join(directory, `${attempt}.log`), `npm failure ${attempt}`); + Object.assign(process.env, { + RELEASE_FAILURE_PHASE: "npm-publish", + RELEASE_FAILURE_ATTEMPT_DIR: directory, + RELEASE_VERSION: "1.0.6", + RELEASE_TAG: "v1.0.6", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "test-token", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/internal/release-workflow/failure", + }); + let report; + await reportExternalFailureFromEnv({ + fetchImpl: async (_url, options) => { report = JSON.parse(options.body); return response(200, { ok: true }); }, + }); + assert.equal(report.phase, "npm-publish"); + assert.equal(report.attempts.length, 3); + } finally { + process.env = previous; + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CLI retries transient draft failures and reports the third", async () => { + const previous = { ...process.env }; + try { + Object.assign(process.env, { + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "test-token", + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: "https://example.invalid/internal/release-notes/draft", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/internal/release-workflow/failure", + }); + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }); + return url.includes("/failure") ? response(200, { ok: true }) : response(503, { detail: "busy" }); + }; + await assert.rejects(requestDraft(evidence, { fetchImpl, sleep: async () => {} }), /attempt 3/); + assert.equal(calls.filter((item) => item.url.includes("/failure")).length, 1); + } finally { + process.env = previous; + } +}); + +test("CLI exhausted draft failures redact configured URLs and tokens", async () => { + const previous = { ...process.env }; + try { + Object.assign(process.env, { + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "test-token", + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: "https://example.invalid/internal/release-notes/draft", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/internal/release-workflow/failure", + }); + const calls = []; + const fetchImpl = async (url, options) => { + if (url.includes("/failure")) { + calls.push({ url, body: JSON.parse(options.body) }); + return response(200, { ok: true }); + } + throw Object.assign(new Error("connect ECONNREFUSED https://example.invalid/internal/release-notes/draft with Bearer test-token"), { + retryable: true, + }); + }; + await assert.rejects(requestDraft(evidence, { fetchImpl, sleep: async () => {} }), /https:\/\/\*\*\*/); + const report = calls[0].body; + assert.equal(report.attempts.length, 3); + assert.doesNotMatch(JSON.stringify(report), /example\.invalid|internal\/release-notes|test-token/); + } finally { + process.env = previous; + } +}); diff --git a/.github/scripts/release-workflow-contract.test.mjs b/.github/scripts/release-workflow-contract.test.mjs new file mode 100644 index 0000000..532aae5 --- /dev/null +++ b/.github/scripts/release-workflow-contract.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const releaseWorkflow = readFileSync(new URL("../workflows/release.yml", import.meta.url), "utf8"); +const dryRunWorkflow = readFileSync(new URL("../workflows/release-dry-run.yml", import.meta.url), "utf8"); + +test("CLI reusable dry run is read-only and cannot publish", () => { + assert.doesNotMatch(releaseWorkflow, /workflow_call:/); + assert.match(dryRunWorkflow, /workflow_call:/); + assert.match(dryRunWorkflow, /permissions:\s*\n\s*contents: read/); + assert.match(dryRunWorkflow, /persist-credentials: false/); + assert.doesNotMatch(dryRunWorkflow, /NPM_TOKEN/); + assert.doesNotMatch(dryRunWorkflow, /OSS_ACCESS_KEY/); + assert.doesNotMatch(dryRunWorkflow, /npm publish/); + assert.doesNotMatch(dryRunWorkflow, /gh release create/); + assert.doesNotMatch(dryRunWorkflow, /contents: write/); + assert.doesNotMatch(dryRunWorkflow, /pull-requests: write/); +}); + +test("CLI publication persists an immutable source before OSS and npm", () => { + const durable = releaseWorkflow.indexOf("Push a durable release source before external publication"); + const oss = releaseWorkflow.indexOf("Upload assets to OSS and verify each object"); + const npm = releaseWorkflow.indexOf('npm publish --access public --tag "${NPM_DIST_TAG}"'); + assert.ok(durable >= 0 && durable < oss); + assert.ok(durable < npm); + assert.match(releaseWorkflow, /EXPECTED_RELEASE_COMMIT: \$\{\{ needs\.metadata\.outputs\.release_commit_sha \}\}/); + assert.match(releaseWorkflow, /npm records gitHead/); + assert.match(releaseWorkflow, /git tag -a "\$\{RELEASE_TAG\}" "\$\{RELEASE_COMMIT_SHA\}"/); + assert.match(releaseWorkflow, /--target "\$\{RELEASE_COMMIT_SHA\}"/); +}); + +test("CLI release inventory and create reconciliation fail closed", () => { + assert.match(releaseWorkflow, /gh api --paginate --slurp/); + assert.match(releaseWorkflow, /validate-github-release-inventory\.mjs/); + assert.match(releaseWorkflow, /Refusing to issue a second create request/); + assert.doesNotMatch(releaseWorkflow, /gh release view/); +}); + +test("CLI inspection always carries evidence and quality artifacts", () => { + for (const artifact of [ + "release-notes.md", + "evidence.json", + "quality-report.json", + "docs-preview.md", + "docs-preview.json", + "release-assets-manifest.json", + "npm-pack.json", + ]) { + assert.match(dryRunWorkflow, new RegExp(artifact.replaceAll(".", "\\."))); + } + assert.match(releaseWorkflow, /if: \$\{\{ always\(\) \}\}/); + assert.match(releaseWorkflow, /quality_report_file/); +}); + +test("automatic npm-only binary reconstruction is disabled", () => { + assert.match(releaseWorkflow, /Automatic npm-only recovery is disabled for CLI binaries/); + assert.match(releaseWorkflow, /Backfill an audited baseline tag from npm gitHead/); +}); diff --git a/.github/scripts/validate-github-release-inventory.mjs b/.github/scripts/validate-github-release-inventory.mjs new file mode 100644 index 0000000..4052db7 --- /dev/null +++ b/.github/scripts/validate-github-release-inventory.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const DOC_AGENT_SOURCE_ID = "memos-cloud-cli"; + +export function flattenReleasePages(value) { + if (!Array.isArray(value)) return []; + const pages = value.every((item) => Array.isArray(item)) ? value : [value]; + return pages.flat().filter((item) => item && typeof item === "object"); +} + +export function docAgentSourceIds(body) { + return [ + ...String(body || "").matchAll( + //g, + ), + ].map((match) => match[1]); +} + +function releaseSummary(release) { + return { + id: Number(release?.id || 0), + tag_name: String(release?.tag_name || ""), + draft: Boolean(release?.draft), + prerelease: Boolean(release?.prerelease), + target_commitish: String(release?.target_commitish || ""), + created_at: String(release?.created_at || ""), + }; +} + +export function inspectReleaseInventory({ + pages, + tag, + expectedDraft, + expectedPrerelease, + expectedTargetCommitish, + requiredSourceId = DOC_AGENT_SOURCE_ID, + requireExisting = false, +} = {}) { + const releaseTag = String(tag || "").trim(); + const matches = flattenReleasePages(pages).filter( + (release) => String(release?.tag_name || "").trim() === releaseTag, + ); + const summaries = matches.map(releaseSummary); + const errors = []; + + if (!releaseTag) errors.push("release tag is required"); + if (matches.length === 0) { + if (requireExisting) errors.push(`GitHub Release ${releaseTag} was not visible after creation`); + return { + ok: errors.length === 0, + state: "absent", + tag: releaseTag, + count: 0, + releases: [], + errors, + }; + } + if (matches.length > 1) { + errors.push(`found ${matches.length} GitHub Releases for ${releaseTag}; refusing ambiguous release metadata`); + return { + ok: false, + state: "ambiguous", + tag: releaseTag, + count: matches.length, + releases: summaries, + errors, + }; + } + + const release = matches[0]; + if (typeof expectedDraft === "boolean" && Boolean(release.draft) !== expectedDraft) { + errors.push(`GitHub Release ${releaseTag} draft=${Boolean(release.draft)}, expected ${expectedDraft}`); + } + if (typeof expectedPrerelease === "boolean" && Boolean(release.prerelease) !== expectedPrerelease) { + errors.push(`GitHub Release ${releaseTag} prerelease=${Boolean(release.prerelease)}, expected ${expectedPrerelease}`); + } + const expectedTarget = String(expectedTargetCommitish || "").trim(); + if (expectedTarget && String(release.target_commitish || "").trim() !== expectedTarget) { + errors.push(`GitHub Release ${releaseTag} targets ${String(release.target_commitish || "")}, expected ${expectedTarget}`); + } + + const expectedSourceId = String(requiredSourceId || "").trim(); + const sourceIds = docAgentSourceIds(release.body); + if (expectedSourceId && (sourceIds.length !== 1 || sourceIds[0] !== expectedSourceId)) { + errors.push( + `GitHub Release ${releaseTag} must contain exactly one Doc Agent source id ${expectedSourceId}; found ${sourceIds.join(", ") || "none"}`, + ); + } + return { + ok: errors.length === 0, + state: "existing", + tag: releaseTag, + count: 1, + releases: summaries, + errors, + }; +} + +function required(name) { + const value = String(process.env[name] || "").trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function parseBoolean(name) { + const value = required(name); + if (!["true", "false"].includes(value)) throw new Error(`${name} must be true or false`); + return value === "true"; +} + +export function main() { + const report = inspectReleaseInventory({ + pages: JSON.parse(readFileSync(required("RELEASE_INVENTORY_FILE"), "utf8")), + tag: required("RELEASE_TAG"), + expectedDraft: parseBoolean("EXPECTED_RELEASE_DRAFT"), + expectedPrerelease: parseBoolean("EXPECTED_RELEASE_PRERELEASE"), + expectedTargetCommitish: String(process.env.EXPECTED_RELEASE_TARGET || "").trim(), + requiredSourceId: String(process.env.REQUIRED_DOC_AGENT_SOURCE_ID || "").trim() || DOC_AGENT_SOURCE_ID, + requireExisting: String(process.env.REQUIRE_EXISTING_RELEASE || "false").trim() === "true", + }); + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.ok) process.exitCode = 1; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + try { + main(); + } catch (error) { + console.error(String(error?.message || error)); + process.exitCode = 1; + } +} diff --git a/.github/scripts/validate-github-release-inventory.test.mjs b/.github/scripts/validate-github-release-inventory.test.mjs new file mode 100644 index 0000000..3334457 --- /dev/null +++ b/.github/scripts/validate-github-release-inventory.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DOC_AGENT_SOURCE_ID, + docAgentSourceIds, + inspectReleaseInventory, +} from "./validate-github-release-inventory.mjs"; + +function release(overrides = {}) { + return { + id: 107, + tag_name: "v1.0.7", + draft: false, + prerelease: false, + target_commitish: "abc123", + body: `## Changelog\n\n`, + created_at: "2026-07-27T00:00:00Z", + ...overrides, + }; +} + +test("CLI inventory accepts one exact release and source id", () => { + const report = inspectReleaseInventory({ + pages: [[release()]], + tag: "v1.0.7", + expectedDraft: false, + expectedPrerelease: false, + expectedTargetCommitish: "abc123", + }); + assert.equal(report.ok, true); + assert.deepEqual(docAgentSourceIds(release().body), ["memos-cloud-cli"]); +}); + +test("CLI inventory fails closed on duplicate releases including drafts", () => { + const report = inspectReleaseInventory({ + pages: [[release(), release({ id: 108, draft: true })]], + tag: "v1.0.7", + expectedDraft: false, + expectedPrerelease: false, + }); + assert.equal(report.ok, false); + assert.equal(report.state, "ambiguous"); + assert.match(report.errors[0], /2 GitHub Releases/); +}); + +test("CLI inventory verifies target, flags, and source routing", () => { + const report = inspectReleaseInventory({ + pages: [[release({ + draft: true, + prerelease: true, + target_commitish: "wrong", + body: "", + })]], + tag: "v1.0.7", + expectedDraft: false, + expectedPrerelease: false, + expectedTargetCommitish: "abc123", + }); + assert.equal(report.ok, false); + assert.equal(report.errors.length, 4); +}); + +test("CLI inventory can require post-create visibility", () => { + const report = inspectReleaseInventory({ + pages: [[]], + tag: "v1.0.7", + expectedDraft: false, + expectedPrerelease: false, + requireExisting: true, + }); + assert.equal(report.ok, false); + assert.match(report.errors[0], /not visible/); +}); diff --git a/.github/scripts/validate-release-confirmation.mjs b/.github/scripts/validate-release-confirmation.mjs new file mode 100644 index 0000000..4992e05 --- /dev/null +++ b/.github/scripts/validate-release-confirmation.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +export function expectedReleaseConfirmation(version) { + const cleanVersion = String(version || "").trim().replace(/^[vV]/, ""); + return `PUBLISH v${cleanVersion}`; +} + +export function validateReleaseConfirmation({ version, dryRun, confirmation }) { + const isDryRun = String(dryRun ?? "true").trim().toLowerCase() === "true"; + const expected = expectedReleaseConfirmation(version); + + if (isDryRun) { + return { + ok: true, + expected, + reason: "dry_run=true; publish confirmation is not required.", + }; + } + + if (String(confirmation || "").trim() === expected) { + return { + ok: true, + expected, + reason: "publish confirmation accepted.", + }; + } + + return { + ok: false, + expected, + reason: + "dry_run=false would publish externally and create release metadata; " + + `publish_confirmation must exactly equal '${expected}'.`, + }; +} + +export function main(env = process.env) { + const result = validateReleaseConfirmation({ + version: env.RELEASE_VERSION, + dryRun: env.DRY_RUN, + confirmation: env.PUBLISH_CONFIRMATION, + }); + + if (!result.ok) { + throw new Error(result.reason); + } + + console.log(result.reason); + if (result.expected) { + console.log(`Expected confirmation: ${result.expected}`); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + main(); + } catch (error) { + console.error(`::error::${error?.message || String(error)}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/validate-release-confirmation.test.mjs b/.github/scripts/validate-release-confirmation.test.mjs new file mode 100644 index 0000000..542d22b --- /dev/null +++ b/.github/scripts/validate-release-confirmation.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + expectedReleaseConfirmation, + validateReleaseConfirmation, +} from "./validate-release-confirmation.mjs"; + +test("builds the exact publish confirmation phrase from a version", () => { + assert.equal(expectedReleaseConfirmation("1.0.7"), "PUBLISH v1.0.7"); + assert.equal(expectedReleaseConfirmation("v1.0.7-beta.1"), "PUBLISH v1.0.7-beta.1"); +}); + +test("does not require publish confirmation for dry runs", () => { + assert.equal( + validateReleaseConfirmation({ + version: "1.0.7", + dryRun: "true", + confirmation: "", + }).ok, + true, + ); +}); + +test("requires exact publish confirmation before a real release", () => { + assert.equal( + validateReleaseConfirmation({ + version: "1.0.7", + dryRun: "false", + confirmation: "", + }).ok, + false, + ); + assert.equal( + validateReleaseConfirmation({ + version: "1.0.7", + dryRun: "false", + confirmation: "PUBLISH 1.0.7", + }).ok, + false, + ); + assert.equal( + validateReleaseConfirmation({ + version: "1.0.7", + dryRun: "false", + confirmation: "PUBLISH v1.0.7", + }).ok, + true, + ); +}); diff --git a/.github/workflows/post-merge-release-dry-run.yml b/.github/workflows/post-merge-release-dry-run.yml new file mode 100644 index 0000000..ec4c69a --- /dev/null +++ b/.github/workflows/post-merge-release-dry-run.yml @@ -0,0 +1,35 @@ +name: MemOS CLI - Post-Merge Release Dry Run + +on: + push: + branches: + - main + paths: + - ".github/workflows/release.yml" + - ".github/workflows/release-dry-run.yml" + - ".github/workflows/pre-merge-release-dry-run.yml" + - ".github/workflows/post-merge-release-dry-run.yml" + - ".github/scripts/**" + - "scripts/**" + - "package.json" + - "release-assets.json" + +permissions: + contents: read + +jobs: + inspect: + if: ${{ github.repository == 'MemTensor/MemOS-Cloud-CLI' }} + permissions: + contents: read + uses: ./.github/workflows/release-dry-run.yml + with: + version: "1.0.7" + tag: "latest" + baseline_ref: "c18ced5" + artifact_name: "memos-cloud-cli-post-merge-release-inspection" + release_notes: "" + secrets: + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} diff --git a/.github/workflows/pre-merge-release-dry-run.yml b/.github/workflows/pre-merge-release-dry-run.yml new file mode 100644 index 0000000..b99dfe8 --- /dev/null +++ b/.github/workflows/pre-merge-release-dry-run.yml @@ -0,0 +1,57 @@ +name: MemOS CLI - Pre-Merge Release Dry Run + +on: + push: + branches: + - "docs-sync/**" + paths: + - ".github/workflows/release.yml" + - ".github/workflows/release-dry-run.yml" + - ".github/workflows/pre-merge-release-dry-run.yml" + - ".github/workflows/post-merge-release-dry-run.yml" + - ".github/scripts/**" + - "scripts/**" + - "package.json" + - "release-assets.json" + +permissions: + contents: read + +jobs: + inspect: + if: ${{ github.repository == 'MemTensor/MemOS-Cloud-CLI' }} + permissions: + contents: read + uses: ./.github/workflows/release-dry-run.yml + with: + version: "1.0.7" + tag: "latest" + baseline_ref: "c18ced5" + artifact_name: "memos-cloud-cli-pre-merge-release-inspection" + release_notes: | + ## Changelog + + ### Improved + - **Release safety checks**: Validates evidence, assets, and immutable publication metadata before a CLI release can write externally. + + + + diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 0000000..1ab9f87 --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,304 @@ +name: MemOS CLI - Reusable Release Dry Run + +on: + workflow_call: + inputs: + version: + description: "Version to inspect (for example 1.0.7 or 1.0.7-beta.1)." + required: true + type: string + tag: + description: "npm dist-tag paired with the inspected version." + required: false + type: string + default: "latest" + git_ref: + description: "Git ref containing the workflow and CLI source." + required: false + type: string + default: "" + release_notes: + description: "Optional evidence-backed Markdown. Leave blank to request a Doc Agent draft." + required: false + type: string + default: "" + baseline_ref: + description: "Migration-only previous ref used until the first real CLI baseline tag exists." + required: false + type: string + default: "" + artifact_name: + description: "Inspection artifact name." + required: false + type: string + default: "memos-cloud-cli-release-inspection" + secrets: + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: + description: "Doc Agent release-note draft endpoint." + required: false + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: + description: "Bearer token for the Doc Agent draft endpoint." + required: false + DOC_AGENT_RELEASE_FAILURE_URL: + description: "Optional exhausted-failure reporting endpoint." + required: false + +permissions: + contents: read + +jobs: + metadata: + permissions: + contents: read + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.metadata.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Validate immutable dry-run inputs + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ "${RELEASE_VERSION}" == [vV]* ]]; then + echo "::error::version must not include a leading v." + exit 1 + fi + if ! [[ "${RELEASE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "::error::version must be a valid SemVer value." + exit 1 + fi + if ! [[ "${NPM_DIST_TAG}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::tag must be a valid npm dist-tag." + exit 1 + fi + if [[ "${RELEASE_VERSION}" == *-* ]] && [ "${NPM_DIST_TAG}" = "latest" ]; then + echo "::error::Prerelease versions cannot use the latest npm dist-tag." + exit 1 + fi + if [[ "${RELEASE_VERSION}" != *-* ]] && [ "${NPM_DIST_TAG}" != "latest" ]; then + echo "::error::Stable versions must use the latest npm dist-tag." + exit 1 + fi + + - name: Synchronize and verify every version source + id: metadata + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + node scripts/sync-version.js "${RELEASE_VERSION}" + node scripts/sync-version.js "${RELEASE_VERSION}" --check + echo "version=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Run release, installer, and Python tests + env: + MEMOS_INSTALL_SKIP_DOWNLOAD: "1" + run: | + set -euo pipefail + npm install --ignore-scripts + npm test + node --test .github/scripts/*.test.mjs + python -m pip install -e . + python -m unittest discover -s tests -p 'test_*.py' + python -m unittest discover -s scripts -p '*test*.py' + npm pack --dry-run --json > "${RUNNER_TEMP}/memos-cloud-cli-pack.json" + + - name: Draft and validate evidence-backed Release notes + id: release_notes + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: v${{ inputs.version }} + RELEASE_PREVIOUS_REF: ${{ inputs.baseline_ref || vars.MEMOS_CLOUD_CLI_RELEASE_BASELINE_REF }} + 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: node .github/scripts/draft-cli-release-notes.mjs + + - name: Prepare metadata inspection + if: ${{ always() }} + shell: bash + env: + DRAFT_STEP_OUTCOME: ${{ steps.release_notes.outcome }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_NOTES_FILE: ${{ steps.release_notes.outputs.release_notes_file }} + EVIDENCE_FILE: ${{ steps.release_notes.outputs.evidence_file }} + DRAFT_FILE: ${{ steps.release_notes.outputs.draft_file }} + DOCS_PREVIEW_FILE: ${{ steps.release_notes.outputs.docs_preview_file }} + DOCS_PREVIEW_MARKDOWN_FILE: ${{ steps.release_notes.outputs.docs_preview_markdown_file }} + QUALITY_REPORT_FILE: ${{ steps.release_notes.outputs.quality_report_file }} + DRAFT_USED: ${{ steps.release_notes.outputs.draft_used }} + PREVIOUS_REF: ${{ steps.release_notes.outputs.previous_tag }} + VALIDATION_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.validation_attempt_count }} + REPAIR_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.repair_attempt_count }} + run: | + set -euo pipefail + inspection_dir="${RUNNER_TEMP}/memos-cloud-cli-release-metadata" + mkdir -p "${inspection_dir}" + copy_if_present() { + local source_file="$1" + local target_name="$2" + if [ -n "${source_file}" ] && [ -s "${source_file}" ]; then + cp "${source_file}" "${inspection_dir}/${target_name}" + fi + } + copy_if_present "${RELEASE_NOTES_FILE}" "release-notes.md" + copy_if_present "${EVIDENCE_FILE}" "evidence.json" + copy_if_present "${DRAFT_FILE}" "release-notes-draft.json" + copy_if_present "${DOCS_PREVIEW_FILE}" "docs-preview.json" + copy_if_present "${DOCS_PREVIEW_MARKDOWN_FILE}" "docs-preview.md" + copy_if_present "${QUALITY_REPORT_FILE}" "quality-report.json" + copy_if_present "${RUNNER_TEMP}/memos-cloud-cli-pack.json" "npm-pack.json" + cp package.json pyproject.toml src/memos_cli/__init__.py "${inspection_dir}/" + + if [ "${DRAFT_STEP_OUTCOME}" = "success" ] && [ ! -s "${inspection_dir}/release-notes.md" ]; then + echo "::error::release-notes.md was not generated." + exit 1 + fi + if [ "${DRAFT_STEP_OUTCOME}" = "success" ] && [ ! -s "${inspection_dir}/quality-report.json" ]; then + echo "::error::quality-report.json was not generated." + exit 1 + fi + + { + echo "# MemOS CLI read-only release inspection" + echo + echo "- draft_step_outcome: ${DRAFT_STEP_OUTCOME}" + echo "- version: ${RELEASE_VERSION}" + echo "- draft_used: ${DRAFT_USED:-unknown}" + echo "- previous_ref: ${PREVIOUS_REF:-n/a}" + echo "- validation_attempt_count: ${VALIDATION_ATTEMPT_COUNT:-n/a}" + echo "- repair_attempt_count: ${REPAIR_ATTEMPT_COUNT:-n/a}" + echo "- token_permissions: contents:read" + echo "- repository_credentials_persisted: false" + echo "- external_writes: none" + } > "${inspection_dir}/README.md" + + - name: Upload metadata inspection + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: memos-cloud-cli-release-metadata + path: ${{ runner.temp }}/memos-cloud-cli-release-metadata + if-no-files-found: error + + build: + needs: metadata + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + script: bash scripts/build-binary.sh + - os: macos-15-intel + target: darwin-x64 + script: bash scripts/build-binary.sh + - os: ubuntu-22.04 + target: linux-x64 + script: bash scripts/build-binary.sh + - os: windows-2022 + target: windows-x64 + script: pwsh -File scripts/build-binary.ps1 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref || github.ref }} + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Synchronize version and build native archive + run: | + node scripts/sync-version.js "${{ needs.metadata.outputs.version }}" + ${{ matrix.script }} + + - name: Verify target archive + shell: bash + run: test -s "dist/memos-${{ needs.metadata.outputs.version }}-${{ matrix.target }}.tar.gz" + + - uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.target }} + path: dist/memos-${{ needs.metadata.outputs.version }}-${{ matrix.target }}.tar.gz + if-no-files-found: error + + assemble: + needs: [metadata, build] + permissions: + contents: read + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref || github.ref }} + persist-credentials: false + + - uses: actions/download-artifact@v4 + with: + pattern: cli-* + path: release-assets + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: memos-cloud-cli-release-metadata + path: inspection + + - name: Validate complete asset matrix and append the manifest + run: | + python scripts/upload_release_assets.py \ + --version "${{ needs.metadata.outputs.version }}" \ + --assets-dir release-assets \ + --output inspection/release-assets-manifest.json \ + --dry-run + + - name: Confirm read-only inspection contract + shell: bash + run: | + set -euo pipefail + for file in release-notes.md evidence.json quality-report.json docs-preview.md docs-preview.json release-assets-manifest.json npm-pack.json; do + test -s "inspection/${file}" + done + { + echo + echo "Validated assets:" + echo "- darwin-arm64" + echo "- darwin-x64" + echo "- linux-x64" + echo "- windows-x64" + } >> inspection/README.md + + - name: Upload complete release inspection + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: inspection + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9863db..5760ec8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,422 @@ -name: release +name: MemOS CLI - Build & Publish on: workflow_dispatch: - push: - tags: - - "v*" + inputs: + version: + description: "Release version, with or without leading v (for example 1.0.7)" + required: true + tag: + description: "npm dist-tag (latest for production, beta/next/alpha for testing)" + required: true + default: "latest" + git_ref: + description: "Git ref to build. Leave blank to use the selected branch." + required: false + default: "" + release_notes: + description: "Optional evidence-backed Markdown. Leave blank to draft it from git evidence." + required: false + default: "" + dry_run: + description: "Build and validate all assets without OSS/npm/GitHub mutations." + required: true + type: boolean + default: true + publish_confirmation: + description: "Required only when dry_run=false. Must exactly equal: PUBLISH v" + required: false + default: "" + recover_existing_npm_release: + description: "Explicitly allow reconstructing GitHub metadata for a version already on npm. Keep false for normal releases." + required: true + type: boolean + default: false +concurrency: + group: memos-cloud-cli-release + cancel-in-progress: false permissions: contents: write + pull-requests: write jobs: + metadata: + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.metadata.outputs.version }} + tag_name: ${{ steps.metadata.outputs.tag_name }} + release_title: ${{ steps.metadata.outputs.release_title }} + release_commit_sha: ${{ steps.release_source.outputs.release_commit_sha }} + release_branch: ${{ steps.release_source.outputs.release_branch }} + created_release_commit: ${{ steps.release_source.outputs.created_release_commit }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref || github.ref }} + fetch-depth: 0 + persist-credentials: ${{ inputs.dry_run != true }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Validate release inputs + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }} + RELEASE_GIT_REF: ${{ inputs.git_ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + version="${RELEASE_VERSION#v}" + if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::version must be a semver value like 1.0.7 or 1.0.7-beta.1." + exit 1 + fi + if [ -z "${NPM_DIST_TAG//[[:space:]]/}" ]; then + echo "::error::tag is required." + exit 1 + fi + if ! [[ "${NPM_DIST_TAG}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::tag must be a valid npm dist-tag." + exit 1 + fi + if [[ "${version}" == *-* ]] && [ "${NPM_DIST_TAG}" = "latest" ]; then + echo "::error::Prerelease versions cannot use the latest npm dist-tag." + exit 1 + fi + if [[ "${version}" != *-* ]] && [ "${NPM_DIST_TAG}" != "latest" ]; then + echo "::error::Stable versions must use the latest npm dist-tag." + exit 1 + fi + if [ "${DRY_RUN}" != "true" ] && [ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]; then + echo "::error::Real releases must be dispatched from the protected default branch ${DEFAULT_BRANCH}; got ${GITHUB_REF}." + exit 1 + fi + if [ "${DRY_RUN}" != "true" ] && [ -n "${RELEASE_GIT_REF//[[:space:]]/}" ] && [ "${RELEASE_GIT_REF}" != "${DEFAULT_BRANCH}" ] && [ "${RELEASE_GIT_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]; then + echo "::error::Real releases cannot override git_ref away from the protected default branch ${DEFAULT_BRANCH}." + exit 1 + fi + if [ "${RECOVER_EXISTING_NPM_RELEASE}" = "true" ]; then + echo "::error::Automatic npm-only recovery is disabled for CLI binaries. Backfill an audited baseline tag from npm gitHead instead of rebuilding historical assets from a different source tree." + exit 1 + fi + node .github/scripts/validate-release-confirmation.mjs + + - name: Normalize metadata and synchronize every version source + id: metadata + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + version="${RELEASE_VERSION#v}" + node scripts/sync-version.js "${version}" + node scripts/sync-version.js "${version}" --check + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "tag_name=v${version}" >> "${GITHUB_OUTPUT}" + echo "release_title=MemOS CLI v${version}" >> "${GITHUB_OUTPUT}" + + - name: Run release contract and Python tests + env: + MEMOS_INSTALL_SKIP_DOWNLOAD: "1" + run: | + npm install --ignore-scripts + npm test + node --test .github/scripts/*.test.mjs + python -m pip install -e . + python -m unittest discover -s tests -p 'test_*.py' + python -m unittest discover -s scripts -p '*test*.py' + npm pack --dry-run --json > "${RUNNER_TEMP}/memos-cloud-cli-pack.json" + + - name: Draft evidence-backed GitHub Release notes + id: release_notes + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_TAG: ${{ steps.metadata.outputs.tag_name }} + RELEASE_PREVIOUS_REF: ${{ vars.MEMOS_CLOUD_CLI_RELEASE_BASELINE_REF }} + 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: node .github/scripts/draft-cli-release-notes.mjs + + - name: Push a durable release source before external publication + id: release_source + if: ${{ inputs.dry_run != true }} + shell: bash + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_TAG: ${{ steps.metadata.outputs.tag_name }} + 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: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + source_sha="$(git rev-parse HEAD)" + release_branch="release/memos-cloud-cli-${RELEASE_TAG}" + remote_branch_sha="$(git ls-remote --heads origin "${release_branch}" | awk '{print $1}')" + created_release_commit=false + + if [ -n "${remote_branch_sha}" ]; then + git fetch --no-tags origin "${release_branch}" + remote_branch_sha="$(git rev-parse FETCH_HEAD)" + remote_parent_sha="$(git rev-parse "${remote_branch_sha}^" 2>/dev/null || true)" + if [ "${remote_branch_sha}" != "${source_sha}" ] && [ "${remote_parent_sha}" != "${source_sha}" ]; then + echo "::error::${release_branch} is not derived from the selected release source; refusing to reuse or overwrite it." + exit 1 + fi + git checkout --detach "${remote_branch_sha}" + node scripts/sync-version.js "${RELEASE_VERSION}" --check + if [ "${remote_branch_sha}" != "${source_sha}" ]; then + created_release_commit=true + fi + echo "::notice::Reusing durable release branch ${release_branch} at ${remote_branch_sha}." + else + git add package.json pyproject.toml src/memos_cli/__init__.py + if ! git diff --staged --quiet; then + git commit -m "release: @memtensor/memos-cloud-cli v${RELEASE_VERSION}" + created_release_commit=true + fi + + if [ "${created_release_commit}" = "true" ]; then + attempt_dir="${RUNNER_TEMP}/memos-cloud-cli-release-branch-push-attempts" + mkdir -p "${attempt_dir}" + for attempt in 1 2 3; do + set +e + git push origin "HEAD:refs/heads/${release_branch}" >"${attempt_dir}/${attempt}.log" 2>&1 + push_status=$? + set -e + sed -n '1,120p' "${attempt_dir}/${attempt}.log" + if [ "${push_status}" = 0 ]; then + break + fi + if [ "${attempt}" = 3 ]; then + RELEASE_FAILURE_PHASE=github-release-branch-push \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node .github/scripts/draft-cli-release-notes.mjs \ + || echo "::warning::Failed to send exhausted branch-push evidence." + echo "::error::Failed to push durable release branch after three attempts." + exit 1 + fi + sleep "$((attempt * 5))" + done + fi + fi + + release_commit_sha="$(git rev-parse HEAD)" + node scripts/sync-version.js "${RELEASE_VERSION}" --check + { + echo "release_commit_sha=${release_commit_sha}" + echo "release_branch=${release_branch}" + echo "created_release_commit=${created_release_commit}" + } >> "${GITHUB_OUTPUT}" + + - name: Prepare release metadata artifact + if: ${{ always() }} + shell: bash + env: + DRAFT_STEP_OUTCOME: ${{ steps.release_notes.outcome }} + DRY_RUN: ${{ inputs.dry_run }} + RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + RELEASE_NOTES_FILE: ${{ steps.release_notes.outputs.release_notes_file }} + EVIDENCE_FILE: ${{ steps.release_notes.outputs.evidence_file }} + DRAFT_FILE: ${{ steps.release_notes.outputs.draft_file }} + DOCS_PREVIEW_FILE: ${{ steps.release_notes.outputs.docs_preview_file }} + DOCS_PREVIEW_MARKDOWN_FILE: ${{ steps.release_notes.outputs.docs_preview_markdown_file }} + QUALITY_REPORT_FILE: ${{ steps.release_notes.outputs.quality_report_file }} + DRAFT_USED: ${{ steps.release_notes.outputs.draft_used }} + PREVIOUS_TAG: ${{ steps.release_notes.outputs.previous_tag }} + CURRENT_TAG: ${{ steps.release_notes.outputs.current_tag }} + CURRENT_REF: ${{ steps.release_notes.outputs.current_ref }} + DRAFT_CONFIDENCE: ${{ steps.release_notes.outputs.draft_confidence }} + 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 }} + run: | + set -euo pipefail + metadata_dir="${RUNNER_TEMP}/memos-cloud-cli-release-metadata" + mkdir -p "${metadata_dir}" + + if [ "${DRAFT_STEP_OUTCOME}" = "success" ] && { [ -z "${RELEASE_NOTES_FILE}" ] || [ ! -s "${RELEASE_NOTES_FILE}" ]; }; then + echo "::error::Release notes file was not generated." + exit 1 + fi + + if [ -n "${RELEASE_NOTES_FILE}" ] && [ -s "${RELEASE_NOTES_FILE}" ]; then + cp "${RELEASE_NOTES_FILE}" "${metadata_dir}/release-notes.md" + fi + cp "${RUNNER_TEMP}/memos-cloud-cli-pack.json" "${metadata_dir}/npm-pack.json" + if [ -n "${EVIDENCE_FILE}" ] && [ -s "${EVIDENCE_FILE}" ]; then + cp "${EVIDENCE_FILE}" "${metadata_dir}/evidence.json" + 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}" "${metadata_dir}/release-notes-draft.json" + elif [ -n "${DRAFT_FILE}" ] && [ -s "${DRAFT_FILE}" ]; then + cp "${DRAFT_FILE}" "${metadata_dir}/release-notes-draft.json" + fi + if [ -n "${DOCS_PREVIEW_FILE}" ] && [ -s "${DOCS_PREVIEW_FILE}" ]; then + cp "${DOCS_PREVIEW_FILE}" "${metadata_dir}/docs-preview.json" + fi + if [ -n "${DOCS_PREVIEW_MARKDOWN_FILE}" ] && [ -s "${DOCS_PREVIEW_MARKDOWN_FILE}" ]; then + cp "${DOCS_PREVIEW_MARKDOWN_FILE}" "${metadata_dir}/docs-preview.md" + fi + if [ -n "${QUALITY_REPORT_FILE}" ] && [ -s "${QUALITY_REPORT_FILE}" ]; then + cp "${QUALITY_REPORT_FILE}" "${metadata_dir}/quality-report.json" + elif [ "${DRAFT_STEP_OUTCOME}" = "success" ]; then + echo "::error::Quality report was not generated." + exit 1 + fi + cp package.json pyproject.toml src/memos_cli/__init__.py "${metadata_dir}/" + + { + echo "# MemOS CLI release inspection" + echo + echo "- dry_run: ${DRY_RUN}" + echo "- draft_step_outcome: ${DRAFT_STEP_OUTCOME}" + echo "- version: ${RELEASE_VERSION}" + echo "- draft_used: ${DRAFT_USED:-unknown}" + echo "- previous_tag: ${PREVIOUS_TAG:-n/a}" + echo "- current_tag: ${CURRENT_TAG:-n/a}" + echo "- current_ref: ${CURRENT_REF:-n/a}" + echo "- draft_confidence: ${DRAFT_CONFIDENCE:-n/a}" + echo "- missing_required_count: ${MISSING_REQUIRED_COUNT:-n/a}" + echo "- validation_attempt_count: ${VALIDATION_ATTEMPT_COUNT:-n/a}" + echo "- repair_attempt_count: ${REPAIR_ATTEMPT_COUNT:-n/a}" + echo + echo "Files:" + echo + echo "- release-notes.md" + echo "- npm-pack.json" + echo "- quality-report.json" + echo "- package.json / pyproject.toml / src/memos_cli/__init__.py" + if [ -n "${EVIDENCE_FILE}" ] && [ -s "${EVIDENCE_FILE}" ]; then + echo "- evidence.json (redacted; no full diff or prompt guidance)" + fi + if [ -f "${metadata_dir}/release-notes-draft.json" ]; then + echo "- release-notes-draft.json" + fi + if [ -f "${metadata_dir}/docs-preview.md" ]; then + echo "- docs-preview.md" + echo "- docs-preview.json" + fi + } > "${metadata_dir}/README.md" + + { + echo "### MemOS CLI release notes" + echo + echo "- dry_run: \`${DRY_RUN}\`" + echo "- draft_used: \`${DRAFT_USED:-unknown}\`" + echo "- previous_tag: \`${PREVIOUS_TAG:-n/a}\`" + echo "- current_tag: \`${CURRENT_TAG:-n/a}\`" + echo "- current_ref: \`${CURRENT_REF:-n/a}\`" + echo "- draft_confidence: \`${DRAFT_CONFIDENCE:-n/a}\`" + echo "- missing_required_count: \`${MISSING_REQUIRED_COUNT:-n/a}\`" + echo "- validation_attempt_count: \`${VALIDATION_ATTEMPT_COUNT:-n/a}\`" + echo "- repair_attempt_count: \`${REPAIR_ATTEMPT_COUNT:-n/a}\`" + echo + echo "Download the workflow artifact \`memos-cloud-cli-release-inspection\` to review release-notes.md, release-notes-draft.json, redacted evidence.json, quality-report.json, npm-pack.json, docs-preview.md, docs-preview.json, and release-assets-manifest.json." + if [ -f "${metadata_dir}/docs-preview.md" ]; then + echo + echo "#### Docs preview" + echo + sed -n '1,120p' "${metadata_dir}/docs-preview.md" + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: release-metadata + path: ${{ runner.temp }}/memos-cloud-cli-release-metadata + if-no-files-found: error + build: + needs: metadata strategy: fail-fast: false matrix: include: + - os: macos-14 + target: darwin-arm64 + script: bash scripts/build-binary.sh + - os: macos-15-intel + target: darwin-x64 + script: bash scripts/build-binary.sh - os: ubuntu-22.04 - build_script: ./scripts/build-binary.sh - upload_name: linux-x64 + target: linux-x64 + script: bash scripts/build-binary.sh - os: windows-2022 - build_script: ./scripts/build-binary.ps1 - upload_name: windows-x64 - + target: windows-x64 + script: pwsh -File scripts/build-binary.ps1 runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.release_commit_sha || inputs.git_ref || github.ref }} + fetch-depth: 0 + persist-credentials: ${{ inputs.dry_run != true }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Synchronize version + run: node scripts/sync-version.js "${{ needs.metadata.outputs.version }}" + + - name: Build native archive + run: ${{ matrix.script }} + + - name: Verify target archive name + shell: bash + run: | + set -euo pipefail + test -s "dist/memos-${{ needs.metadata.outputs.version }}-${{ matrix.target }}.tar.gz" + + - uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.target }} + path: dist/memos-${{ needs.metadata.outputs.version }}-${{ matrix.target }}.tar.gz + if-no-files-found: error + + assemble-and-publish: + needs: [metadata, build] + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.release_commit_sha || inputs.git_ref || github.ref }} + fetch-depth: 0 + persist-credentials: ${{ inputs.dry_run != true }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org - uses: actions/setup-python@v5 with: @@ -34,22 +425,345 @@ jobs: - name: Upgrade pip run: python -m pip install --upgrade pip - - name: Build archive on Unix - if: runner.os != 'Windows' - run: bash ${{ matrix.build_script }} + - uses: actions/download-artifact@v4 + with: + pattern: cli-* + path: release-assets + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: release-metadata + path: release-metadata - - name: Build archive on Windows - if: runner.os == 'Windows' - run: pwsh -File ${{ matrix.build_script }} + - name: Validate the complete OSS/npm asset contract + run: | + node scripts/sync-version.js "${{ needs.metadata.outputs.version }}" + python scripts/upload_release_assets.py \ + --version "${{ needs.metadata.outputs.version }}" \ + --assets-dir release-assets \ + --output release-assets-manifest.json \ + --dry-run - - name: Upload build artifact + - name: Upload release inspection artifact uses: actions/upload-artifact@v4 with: - name: ${{ matrix.upload_name }} - path: dist/*.tar.gz + name: memos-cloud-cli-release-inspection + path: | + release-assets-manifest.json + release-metadata + if-no-files-found: error - - name: Upload release assets - if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 - with: - files: dist/*.tar.gz + - name: Stop before external writes in dry run + if: ${{ inputs.dry_run == true }} + run: echo "::notice::dry_run=true; OSS, npm, tag, GitHub Release, and version PR were skipped." + + - name: Reconcile tags and GitHub Release inventory before external writes + if: ${{ inputs.dry_run != true }} + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_NAME: "@memtensor/memos-cloud-cli" + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag_name }} + RELEASE_COMMIT_SHA: ${{ needs.metadata.outputs.release_commit_sha }} + NPM_DIST_TAG: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + tag_exists=false + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + tag_exists=true + git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_commit_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" + if [ "${tag_commit_sha}" != "${RELEASE_COMMIT_SHA}" ]; then + echo "::error::Existing tag ${RELEASE_TAG} targets ${tag_commit_sha}, expected ${RELEASE_COMMIT_SHA}." + exit 1 + fi + fi + + expected_prerelease=false + if [[ "${RELEASE_VERSION}" == *-* || "${NPM_DIST_TAG}" != "latest" ]]; then + expected_prerelease=true + fi + inventory_file="${RUNNER_TEMP}/memos-cloud-cli-prepublish-releases.json" + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "${inventory_file}" + inventory_report="$( + RELEASE_INVENTORY_FILE="${inventory_file}" \ + RELEASE_TAG="${RELEASE_TAG}" \ + EXPECTED_RELEASE_DRAFT=false \ + EXPECTED_RELEASE_PRERELEASE="${expected_prerelease}" \ + EXPECTED_RELEASE_TARGET="${RELEASE_COMMIT_SHA}" \ + REQUIRED_DOC_AGENT_SOURCE_ID=memos-cloud-cli \ + REQUIRE_EXISTING_RELEASE=false \ + node .github/scripts/validate-github-release-inventory.mjs + )" + inventory_state="$(printf '%s' "${inventory_report}" | node -e 'let value=""; process.stdin.on("data", chunk => value += chunk).on("end", () => process.stdout.write(JSON.parse(value).state));')" + npm_exists=false + if npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + npm_exists=true + fi + if [ "${npm_exists}" = "true" ]; then + npm_git_head="$(npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" gitHead | tr -d '[:space:]"')" + if [ "${npm_git_head}" != "${RELEASE_COMMIT_SHA}" ] || [ "${tag_exists}" != "true" ] || [ "${inventory_state}" != "existing" ]; then + echo "::error::Existing npm release is not accompanied by an exact gitHead, tag, and GitHub Release. Automatic binary metadata reconstruction is disabled." + exit 1 + fi + echo "Existing npm, tag, and GitHub Release form a complete idempotent release." + elif [ "${inventory_state}" = "existing" ]; then + echo "::error::GitHub Release ${RELEASE_TAG} exists while npm ${PACKAGE_NAME}@${RELEASE_VERSION} is absent; refusing an inconsistent release." + exit 1 + fi + + - name: Upload assets to OSS and verify each object + if: ${{ inputs.dry_run != true }} + env: + OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_SESSION_TOKEN: ${{ secrets.OSS_SESSION_TOKEN }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_REGION: ${{ secrets.OSS_REGION }} + MEMOS_CLI_OSS_PUBLIC_BASE_URL: ${{ secrets.MEMOS_CLI_OSS_PUBLIC_BASE_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: | + python -m pip install 'oss2>=2.19,<3' + python scripts/upload_release_assets.py \ + --version "${{ needs.metadata.outputs.version }}" \ + --assets-dir release-assets \ + --output release-assets-manifest.json \ + --runtime-contract-output release-assets.json + + - name: Publish npm package with postcondition checks + if: ${{ inputs.dry_run != true }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ github.token }} + MEMOS_INSTALL_SKIP_DOWNLOAD: "1" + MEMOS_RELEASE_REQUIRE_RESOLVED_ASSETS: "1" + PACKAGE_NAME: "@memtensor/memos-cloud-cli" + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag_name }} + NPM_DIST_TAG: ${{ inputs.tag }} + RECOVER_EXISTING_NPM_RELEASE: ${{ inputs.recover_existing_npm_release }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} + EXPECTED_RELEASE_COMMIT: ${{ needs.metadata.outputs.release_commit_sha }} + shell: bash + run: | + set -euo pipefail + npm install --ignore-scripts + already_published=false + if npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${RELEASE_VERSION} already exists and passed the earlier exact gitHead/tag/Release inventory reconciliation." + already_published=true + fi + if [ "${already_published}" != "true" ]; then + attempt_dir="${RUNNER_TEMP}/memos-cloud-cli-npm-publish-attempts" + mkdir -p "${attempt_dir}" + for attempt in 1 2 3; do + set +e + npm publish --access public --tag "${NPM_DIST_TAG}" >"${attempt_dir}/${attempt}.log" 2>&1 + publish_status=$? + set -e + sed -n '1,160p' "${attempt_dir}/${attempt}.log" + if [ "${publish_status}" = 0 ]; then + break + fi + if npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "Publish returned an error, but npm now contains the requested version." + break + fi + if [ "${attempt}" = 3 ]; then + RELEASE_FAILURE_PHASE=npm-publish \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node .github/scripts/draft-cli-release-notes.mjs \ + || echo "::warning::Failed to send the exhausted-retry notification." + echo "::error::npm publish failed after three attempts." + exit 1 + fi + sleep "$((attempt * 5))" + done + fi + npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version + npm_git_head="$(npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" gitHead | tr -d '[:space:]"')" + if [ "${npm_git_head}" != "${EXPECTED_RELEASE_COMMIT}" ]; then + echo "::error::npm records gitHead ${npm_git_head:-missing}, expected durable release commit ${EXPECTED_RELEASE_COMMIT}." + exit 1 + fi + dist_tag_version="$(npm view "${PACKAGE_NAME}" "dist-tags.${NPM_DIST_TAG}" | tr -d '[:space:]"')" + if [ "${dist_tag_version}" != "${RELEASE_VERSION}" ]; then + echo "::error::npm dist-tag ${NPM_DIST_TAG} points to ${dist_tag_version:-missing}, expected ${RELEASE_VERSION}." + exit 1 + fi + + - name: Create immutable tag, GitHub Release, and version PR + if: ${{ inputs.dry_run != true }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag_name }} + RELEASE_TITLE: ${{ needs.metadata.outputs.release_title }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RELEASE_COMMIT_SHA: ${{ needs.metadata.outputs.release_commit_sha }} + RELEASE_BRANCH: ${{ needs.metadata.outputs.release_branch }} + CREATED_RELEASE_COMMIT: ${{ needs.metadata.outputs.created_release_commit }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [ ! -s release-metadata/release-notes.md ]; then + echo "::error::Release notes file was not generated." + exit 1 + fi + + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_commit_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" + if [ "${tag_commit_sha}" != "${RELEASE_COMMIT_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} targets ${tag_commit_sha}, expected immutable release commit ${RELEASE_COMMIT_SHA}; refusing to move it." + exit 1 + fi + echo "Tag ${RELEASE_TAG} already targets ${RELEASE_COMMIT_SHA}; leaving it unchanged." + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${RELEASE_TAG}" "${RELEASE_COMMIT_SHA}" -m "${RELEASE_TITLE}" + attempt_dir="${RUNNER_TEMP}/memos-cloud-cli-tag-push-attempts" + mkdir -p "${attempt_dir}" + for attempt in 1 2 3; do + set +e + git push origin "refs/tags/${RELEASE_TAG}" >"${attempt_dir}/${attempt}.log" 2>&1 + push_status=$? + set -e + sed -n '1,120p' "${attempt_dir}/${attempt}.log" + if [ "${push_status}" = 0 ]; then + break + fi + remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}^{}" | awk '{print $1}')" + if [ "${remote_tag_sha}" = "${RELEASE_COMMIT_SHA}" ]; then + echo "Tag push returned an error, but the remote annotated tag now targets the expected commit." + break + fi + if [ "${attempt}" = 3 ]; then + RELEASE_FAILURE_PHASE=github-release-tag-push \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node .github/scripts/draft-cli-release-notes.mjs \ + || echo "::warning::Failed to send exhausted tag-push evidence." + echo "::error::Failed to push immutable tag after three attempts." + exit 1 + fi + sleep "$((attempt * 5))" + done + fi + + expected_prerelease=false + release_flags=() + if [[ "${RELEASE_VERSION}" == *-* || "${NPM_DIST_TAG}" != "latest" ]]; then + expected_prerelease=true + release_flags+=(--prerelease) + echo "Marking GitHub Release ${RELEASE_TAG} as prerelease because version=${RELEASE_VERSION}, npm_dist_tag=${NPM_DIST_TAG}." + fi + + inventory_dir="${RUNNER_TEMP}/memos-cloud-cli-release-inventory" + mkdir -p "${inventory_dir}" + fetch_inventory() { + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "${inventory_dir}/releases.json" + } + inspect_inventory() { + local require_existing="$1" + RELEASE_INVENTORY_FILE="${inventory_dir}/releases.json" \ + RELEASE_TAG="${RELEASE_TAG}" \ + EXPECTED_RELEASE_DRAFT=false \ + EXPECTED_RELEASE_PRERELEASE="${expected_prerelease}" \ + EXPECTED_RELEASE_TARGET="${RELEASE_COMMIT_SHA}" \ + REQUIRED_DOC_AGENT_SOURCE_ID=memos-cloud-cli \ + REQUIRE_EXISTING_RELEASE="${require_existing}" \ + node .github/scripts/validate-github-release-inventory.mjs + } + + fetch_inventory + inventory_report="$(inspect_inventory false)" + inventory_state="$(printf '%s' "${inventory_report}" | node -e 'let value=""; process.stdin.on("data", chunk => value += chunk).on("end", () => process.stdout.write(JSON.parse(value).state));')" + if [ "${inventory_state}" = "absent" ]; then + create_log="${inventory_dir}/create.log" + set +e + gh release create "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --target "${RELEASE_COMMIT_SHA}" \ + --title "${RELEASE_TITLE}" \ + --notes-file release-metadata/release-notes.md \ + "${release_flags[@]}" \ + release-assets/*.tar.gz >"${create_log}" 2>&1 + create_status=$? + set -e + sed -n '1,160p' "${create_log}" + if [ "${create_status}" != "0" ]; then + echo "::warning::GitHub Release create returned an error; reconciling inventory before deciding whether it failed." + fi + verified=false + for attempt in 1 2 3; do + fetch_inventory + if inspect_inventory true; then + verified=true + break + fi + sleep "$((attempt * 5))" + done + if [ "${verified}" != "true" ]; then + attempt_dir="${RUNNER_TEMP}/memos-cloud-cli-release-create-attempts" + mkdir -p "${attempt_dir}" + for attempt in 1 2 3; do cp "${create_log}" "${attempt_dir}/${attempt}.log"; done + RELEASE_FAILURE_PHASE=github-release-create \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node .github/scripts/draft-cli-release-notes.mjs \ + || echo "::warning::Failed to send exhausted GitHub Release evidence." + echo "::error::GitHub Release was not visible with the expected target and source id. Refusing to issue a second create request." + exit 1 + fi + else + echo "GitHub Release ${RELEASE_TAG} already exists with the exact expected metadata." + fi + + if [ "${CREATED_RELEASE_COMMIT}" != "true" ]; then + echo "No version changes require a PR." + else + existing_pr="$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${RELEASE_BRANCH}" --state open --json url --jq '.[0].url // ""')" + if [ -n "${existing_pr}" ]; then + echo "Version PR already exists: ${existing_pr}" + else + attempt_dir="${RUNNER_TEMP}/memos-cloud-cli-pr-create-attempts" + mkdir -p "${attempt_dir}" + created_pr=false + for attempt in 1 2 3; do + set +e + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DEFAULT_BRANCH}" \ + --head "${RELEASE_BRANCH}" \ + --title "release: @memtensor/memos-cloud-cli v${RELEASE_VERSION}" \ + --body "Synchronizes package.json, pyproject.toml, and src/memos_cli/__init__.py for the published CLI release." \ + >"${attempt_dir}/${attempt}.log" 2>&1 + pr_status=$? + set -e + sed -n '1,120p' "${attempt_dir}/${attempt}.log" + existing_pr="$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${RELEASE_BRANCH}" --state open --json url --jq '.[0].url // ""')" + if [ "${pr_status}" = "0" ] || [ -n "${existing_pr}" ]; then + created_pr=true + break + fi + sleep "$((attempt * 5))" + done + if [ "${created_pr}" != "true" ]; then + RELEASE_FAILURE_PHASE=github-release-pr-create \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node .github/scripts/draft-cli-release-notes.mjs \ + || echo "::warning::Failed to send exhausted PR-create evidence." + echo "::error::Failed to create release PR automatically after three attempts." + exit 1 + fi + fi + fi diff --git a/README-zh.md b/README-zh.md index ea57f0b..f11fc85 100644 --- a/README-zh.md +++ b/README-zh.md @@ -64,7 +64,7 @@ MemOS-CLI/ ```bash npm install -g @memtensor/memos-cloud-cli@beta ``` -npm 包会在安装时为当前平台下载预编译的 MemOS CLI 二进制,因此最终用户不需要本地 Python 运行环境。 +npm 包会在安装时下载预编译的 MemOS CLI 二进制,因此最终用户不需要本地 Python 运行环境。当前发布矩阵覆盖 macOS arm64/x64、Linux x64 和 Windows x64;其他平台会明确报不支持,不再请求不存在的产物。 ### 面向开发 diff --git a/README.md b/README.md index b899332..bc03af4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ MemOS-CLI/ npm install -g @memtensor/memos-cloud-cli@beta ``` -The npm package downloads a prebuilt MemOS CLI binary for the current platform during installation, so end users do not need a local Python environment. +The npm package downloads a prebuilt MemOS CLI binary, so end users do not need a local Python environment. Release artifacts currently cover macOS arm64/x64, Linux x64, and Windows x64; unsupported targets fail explicitly instead of requesting a nonexistent object. ### Development diff --git a/npm/README.md b/npm/README.md index d115420..f0e892f 100644 --- a/npm/README.md +++ b/npm/README.md @@ -3,5 +3,6 @@ This directory is reserved for the downloaded binary payload used by the npm package. - `npm/bin/` is populated by `scripts/postinstall.js` -- the published npm package should distribute platform binaries for runtime use +- the supported target matrix is defined in `release-assets.json` +- every supported target must be built and uploaded before npm publication - expected release asset pattern: `memos---.tar.gz` diff --git a/package.json b/package.json index 4df64aa..8e22357 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,20 @@ "memos": "bin/memos.js" }, "files": [ - "bin", - "scripts" + "bin/memos.js", + "scripts/postinstall.js", + "release-assets.json" ], "engines": { "node": ">=12" }, "scripts": { + "test": "node --test scripts/*.test.js", + "sync-version": "node scripts/sync-version.js", + "check-version": "node scripts/sync-version.js --check", "prepublishOnly": "node scripts/prepublish-check.js", - "publish:beta": "npm publish --tag beta", - "publish:latest": "npm publish --tag latest", + "publish:beta": "npm publish --access public --tag beta", + "publish:latest": "npm publish --access public --tag latest", "postinstall": "node scripts/postinstall.js" } } diff --git a/release-assets.json b/release-assets.json new file mode 100644 index 0000000..0cfa588 --- /dev/null +++ b/release-assets.json @@ -0,0 +1,13 @@ +{ + "schema": 1, + "bucket": "REPLACE_WITH_PRODUCTION_BUCKET", + "endpoint": "https://REPLACE_WITH_PRODUCTION_OSS_ENDPOINT", + "region": "REPLACE_WITH_PRODUCTION_REGION", + "public_base_url": "https://example.invalid/memos-cloud-cli", + "targets": [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "windows-x64" + ] +} diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 88ba213..bc48df0 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -6,42 +6,16 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const https = require("node:https"); +const crypto = require("node:crypto"); const { spawn } = require("node:child_process"); const pkg = require("../package.json"); +const releaseAssets = require("../release-assets.json"); +const SUPPORTED_TARGETS = new Set(releaseAssets.targets); +const MAX_REDIRECTS = 5; +const SHA256_RE = /^[a-f0-9]{64}$/i; -if (process.env.MEMOS_INSTALL_SKIP_DOWNLOAD === "1" || process.env.MEMOS_INSTALL_SKIP_DOWNLOAD === "true") { - process.exit(0); -} - -const target = resolveTarget(); -const assetName = `memos-${pkg.version}-${target}.tar.gz`; -const downloadUrl = - process.env.MEMOS_BINARY_URL || - `https://memos-test.oss-cn-shanghai.aliyuncs.com/${assetName}`; - -const installDir = path.join(__dirname, "..", "bin"); -const archivePath = path.join(os.tmpdir(), assetName); -const binaryName = process.platform === "win32" ? "memos.exe" : "memos"; - -fs.mkdirSync(installDir, { recursive: true }); - -if (!downloadUrl) { - console.error("MEMOS_BINARY_URL is not set"); - process.exit(1); -} - -download(downloadUrl, archivePath) - .then(() => extractArchive(archivePath, installDir)) - .then(() => clearQuarantine(path.join(installDir, binaryName))) - .then(() => makeExecutable(path.join(installDir, binaryName))) - .catch((error) => { - console.error(`Failed to install MemOS CLI binary from ${downloadUrl}`); - console.error(error.message); - process.exit(1); - }); - -function resolveTarget() { +function resolveTarget(platformName = process.platform, archName = process.arch) { const platformMap = { darwin: "darwin", linux: "linux", @@ -52,22 +26,116 @@ function resolveTarget() { x64: "x64", }; - const platform = platformMap[process.platform]; - const arch = archMap[process.arch]; + const platform = platformMap[platformName]; + const arch = archMap[archName]; + + const target = platform && arch ? `${platform}-${arch}` : ""; + if (!target || !SUPPORTED_TARGETS.has(target)) { + throw new Error(`Unsupported platform: ${platformName}/${archName}`); + } + return target; +} + +function validateDownloadUrl(value, baseUrl) { + let parsed; + try { + parsed = baseUrl ? new URL(value, baseUrl) : new URL(value); + } catch { + throw new Error(`Invalid binary download URL: ${value}`); + } + if (parsed.protocol !== "https:") { + throw new Error(`Refusing non-HTTPS binary download URL: ${parsed.protocol}`); + } + return parsed.toString(); +} + +function resolveAsset(target, { + contract = releaseAssets, + packageVersion = pkg.version, + overrideUrl = process.env.MEMOS_BINARY_URL, + overrideSha256 = process.env.MEMOS_BINARY_SHA256, +} = {}) { + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`Unsupported release asset target: ${target}`); + } + const assetName = `memos-${packageVersion}-${target}.tar.gz`; + if (overrideUrl) { + const checksum = String(overrideSha256 || "").trim().toLowerCase(); + if (!SHA256_RE.test(checksum)) { + throw new Error("MEMOS_BINARY_SHA256 is required when MEMOS_BINARY_URL overrides the signed release contract."); + } + return { + name: assetName, + url: validateDownloadUrl(overrideUrl), + sha256: checksum, + }; + } - if (!platform || !arch) { - throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`); + if (Number(contract.schema) !== 2 || String(contract.version || "") !== String(packageVersion)) { + throw new Error(`Release asset contract does not match npm package version ${packageVersion}.`); } + const asset = contract.assets && contract.assets[target]; + if (!asset || asset.name !== assetName || !SHA256_RE.test(String(asset.sha256 || ""))) { + throw new Error(`Release asset contract is incomplete for ${target}.`); + } + return { + name: assetName, + url: validateDownloadUrl(asset.url || `${contract.public_base_url}/${assetName}`), + sha256: String(asset.sha256).toLowerCase(), + }; +} - return `${platform}-${arch}`; +async function main() { + if (process.env.MEMOS_INSTALL_SKIP_DOWNLOAD === "1" || process.env.MEMOS_INSTALL_SKIP_DOWNLOAD === "true") return; + + const target = resolveTarget(); + const asset = resolveAsset(target); + const installDir = path.join(__dirname, "..", "bin"); + const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-cli-")); + const archivePath = path.join(temporaryDir, asset.name); + const binaryName = process.platform === "win32" ? "memos.exe" : "memos"; + fs.mkdirSync(installDir, { recursive: true }); + + try { + await download(asset.url, archivePath, asset.sha256); + await extractArchive(archivePath, installDir); + await clearQuarantine(path.join(installDir, binaryName)); + makeExecutable(path.join(installDir, binaryName)); + } finally { + try { + if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); + fs.rmdirSync(temporaryDir); + } catch { + // Cleanup failure must not hide the real download or extraction result. + } + } } -function download(url, destination) { +function download(url, destination, expectedSha256, redirectCount = 0) { + const downloadUrl = validateDownloadUrl(url); + const expected = String(expectedSha256 || "").trim().toLowerCase(); + if (!SHA256_RE.test(expected)) { + return Promise.reject(new Error("A valid SHA-256 checksum is required for the CLI binary.")); + } + if (redirectCount > MAX_REDIRECTS) { + return Promise.reject(new Error(`Binary download exceeded ${MAX_REDIRECTS} redirects.`)); + } return new Promise((resolve, reject) => { - const request = https.get(url, (response) => { + const request = https.get(downloadUrl, (response) => { if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { response.resume(); - download(response.headers.location, destination).then(resolve, reject); + if (redirectCount >= MAX_REDIRECTS) { + reject(new Error(`Binary download exceeded ${MAX_REDIRECTS} redirects.`)); + return; + } + let redirectUrl; + try { + redirectUrl = validateDownloadUrl(response.headers.location, downloadUrl); + } catch (error) { + reject(error); + return; + } + download(redirectUrl, destination, expected, redirectCount + 1).then(resolve, reject); return; } @@ -77,10 +145,30 @@ function download(url, destination) { return; } + const hash = crypto.createHash("sha256"); const file = fs.createWriteStream(destination); + response.on("data", (chunk) => hash.update(chunk)); response.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", reject); + file.on("finish", () => { + file.close(() => { + const actual = hash.digest("hex"); + if (actual !== expected) { + try { + fs.unlinkSync(destination); + } catch { + // The checksum failure remains the actionable error. + } + reject(new Error(`CLI binary SHA-256 mismatch: expected ${expected}, got ${actual}.`)); + return; + } + resolve(); + }); + }); + file.on("error", (error) => { + response.destroy(); + reject(error); + }); + response.on("error", reject); }); request.on("error", reject); @@ -124,4 +212,20 @@ function clearQuarantine(filePath) { child.on("exit", () => resolve()); child.on("error", () => resolve()); }); -} \ No newline at end of file +} + +if (require.main === module) { + main().catch((error) => { + console.error(`Failed to install MemOS CLI binary: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + MAX_REDIRECTS, + SUPPORTED_TARGETS, + download, + resolveAsset, + resolveTarget, + validateDownloadUrl, +}; diff --git a/scripts/postinstall.test.js b/scripts/postinstall.test.js new file mode 100644 index 0000000..8117519 --- /dev/null +++ b/scripts/postinstall.test.js @@ -0,0 +1,81 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { + MAX_REDIRECTS, + SUPPORTED_TARGETS, + resolveAsset, + resolveTarget, + validateDownloadUrl, +} = require("./postinstall.js"); + +test("installer support exactly matches the release build contract", () => { + assert.deepEqual([...SUPPORTED_TARGETS].sort(), [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "windows-x64", + ]); + assert.equal(resolveTarget("darwin", "arm64"), "darwin-arm64"); + assert.equal(resolveTarget("darwin", "x64"), "darwin-x64"); + assert.equal(resolveTarget("linux", "x64"), "linux-x64"); + assert.equal(resolveTarget("win32", "x64"), "windows-x64"); + assert.throws(() => resolveTarget("linux", "arm64"), /Unsupported platform/); + assert.throws(() => resolveTarget("win32", "arm64"), /Unsupported platform/); +}); + +test("installer resolves versioned assets only from a complete SHA-256 contract", () => { + const checksum = "a".repeat(64); + const contract = { + schema: 2, + version: "1.0.7", + public_base_url: "https://downloads.example.invalid/memos-cloud-cli", + targets: [...SUPPORTED_TARGETS], + assets: { + "linux-x64": { + name: "memos-1.0.7-linux-x64.tar.gz", + url: "https://downloads.example.invalid/memos-cloud-cli/memos-1.0.7-linux-x64.tar.gz", + sha256: checksum, + }, + }, + }; + assert.deepEqual( + resolveAsset("linux-x64", { contract, packageVersion: "1.0.7", overrideUrl: "" }), + { + name: "memos-1.0.7-linux-x64.tar.gz", + url: "https://downloads.example.invalid/memos-cloud-cli/memos-1.0.7-linux-x64.tar.gz", + sha256: checksum, + }, + ); + assert.throws( + () => resolveAsset("linux-x64", { contract, packageVersion: "1.0.8", overrideUrl: "" }), + /does not match npm package version/, + ); + assert.throws( + () => resolveAsset("darwin-arm64", { contract, packageVersion: "1.0.7", overrideUrl: "" }), + /incomplete/, + ); +}); + +test("installer refuses insecure URLs and unverified overrides", () => { + assert.equal(MAX_REDIRECTS, 5); + assert.match(validateDownloadUrl("https://downloads.example.invalid/file"), /^https:/); + assert.throws(() => validateDownloadUrl("http://downloads.example.invalid/file"), /non-HTTPS/); + assert.throws( + () => resolveAsset("linux-x64", { + packageVersion: "1.0.7", + overrideUrl: "https://downloads.example.invalid/custom.tar.gz", + overrideSha256: "", + }), + /MEMOS_BINARY_SHA256 is required/, + ); + assert.equal( + resolveAsset("linux-x64", { + packageVersion: "1.0.7", + overrideUrl: "https://downloads.example.invalid/custom.tar.gz", + overrideSha256: "b".repeat(64), + }).sha256, + "b".repeat(64), + ); +}); diff --git a/scripts/prepublish-check.js b/scripts/prepublish-check.js index ac6c77f..9ec79d9 100644 --- a/scripts/prepublish-check.js +++ b/scripts/prepublish-check.js @@ -6,10 +6,12 @@ const fs = require("node:fs"); const path = require("node:path"); const pkg = require("../package.json"); +const releaseAssets = require("../release-assets.json"); const rootDir = path.join(__dirname, ".."); const readmePath = path.join(rootDir, "README.md"); const issues = []; +const expectedTargets = ["darwin-arm64", "darwin-x64", "linux-x64", "windows-x64"]; if (!pkg.name || !pkg.name.startsWith("@memtensor/")) { issues.push("package.json name must use the @memtensor/ scope."); @@ -23,14 +25,47 @@ if (!pkg.bin || !pkg.bin.memos) { issues.push("package.json must define the memos bin entry."); } -if (!Array.isArray(pkg.files) || !pkg.files.includes("bin") || !pkg.files.includes("scripts")) { - issues.push("package.json files must include bin and scripts."); +if ( + !Array.isArray(pkg.files) || + !pkg.files.includes("bin/memos.js") || + !pkg.files.includes("scripts/postinstall.js") || + !pkg.files.includes("release-assets.json") +) { + issues.push("package.json files must include only the runtime npm wrapper files."); } if (!fs.existsSync(readmePath)) { issues.push("README.md is missing at the repository root."); } +if ( + !Array.isArray(releaseAssets.targets) || + JSON.stringify([...releaseAssets.targets].sort()) !== JSON.stringify([...expectedTargets].sort()) +) { + issues.push("release-assets.json must contain the complete four-platform target matrix."); +} + +if (process.env.MEMOS_RELEASE_REQUIRE_RESOLVED_ASSETS === "1") { + if (Number(releaseAssets.schema) !== 2 || releaseAssets.version !== pkg.version) { + issues.push("live publish requires a schema 2 release-assets.json matching package.json version."); + } + if (!String(releaseAssets.public_base_url || "").startsWith("https://")) { + issues.push("live publish requires an HTTPS release asset base URL."); + } + for (const target of expectedTargets) { + const asset = releaseAssets.assets && releaseAssets.assets[target]; + const expectedName = `memos-${pkg.version}-${target}.tar.gz`; + if ( + !asset || + asset.name !== expectedName || + !String(asset.url || "").startsWith("https://") || + !/^[a-f0-9]{64}$/i.test(String(asset.sha256 || "")) + ) { + issues.push(`live publish requires a versioned URL and SHA-256 for ${target}.`); + } + } +} + if (issues.length > 0) { console.error("Prepublish checks failed:"); for (const issue of issues) { diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..b24413e --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.join(__dirname, ".."); +const FILES = { + package: path.join(ROOT, "package.json"), + pyproject: path.join(ROOT, "pyproject.toml"), + init: path.join(ROOT, "src", "memos_cli", "__init__.py"), +}; + +function normalizeVersion(value) { + const version = String(value || "").trim().replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid release version: ${value || "(empty)"}`); + } + return version; +} + +function transformedContents(version, contents) { + const pkg = JSON.parse(contents.package); + pkg.version = version; + const packageText = `${JSON.stringify(pkg, null, 2)}\n`; + const pyprojectText = contents.pyproject.replace( + /(\[project\][\s\S]*?\nversion\s*=\s*)"[^"]+"/, + `$1"${version}"`, + ); + const initText = contents.init.replace(/__version__\s*=\s*"[^"]+"/, `__version__ = "${version}"`); + if (pyprojectText === contents.pyproject && !contents.pyproject.includes(`version = "${version}"`)) { + throw new Error("Could not update [project].version in pyproject.toml"); + } + if (initText === contents.init && !contents.init.includes(`__version__ = "${version}"`)) { + throw new Error("Could not update __version__ in src/memos_cli/__init__.py"); + } + return { package: packageText, pyproject: pyprojectText, init: initText }; +} + +function readContents() { + return Object.fromEntries(Object.entries(FILES).map(([key, file]) => [key, fs.readFileSync(file, "utf8")])); +} + +function syncVersion(version, { check = false } = {}) { + const normalized = normalizeVersion(version); + const current = readContents(); + const next = transformedContents(normalized, current); + const changed = Object.keys(next).filter((key) => next[key] !== current[key]); + if (check && changed.length) { + throw new Error(`Version ${normalized} is not synchronized in: ${changed.join(", ")}`); + } + if (!check) { + for (const key of changed) fs.writeFileSync(FILES[key], next[key], "utf8"); + } + return { version: normalized, changed }; +} + +if (require.main === module) { + try { + const version = process.env.RELEASE_VERSION || process.argv.find((arg) => !arg.startsWith("--") && arg !== process.argv[0] && arg !== process.argv[1]); + const result = syncVersion(version, { check: process.argv.includes("--check") }); + console.log(`${process.argv.includes("--check") ? "Verified" : "Synchronized"} MemOS CLI ${result.version}: ${result.changed.join(", ") || "already current"}`); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} + +module.exports = { normalizeVersion, syncVersion, transformedContents }; diff --git a/scripts/sync-version.test.js b/scripts/sync-version.test.js new file mode 100644 index 0000000..3231deb --- /dev/null +++ b/scripts/sync-version.test.js @@ -0,0 +1,22 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { normalizeVersion, transformedContents } = require("./sync-version.js"); + +test("normalizes a leading v and rejects non-semver values", () => { + assert.equal(normalizeVersion("v1.2.3"), "1.2.3"); + assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1"); + assert.throws(() => normalizeVersion("latest"), /Invalid release version/); +}); + +test("updates package, pyproject, and Python runtime version together", () => { + const next = transformedContents("1.2.3", { + package: '{"name":"x","version":"1.0.0"}\n', + pyproject: '[project]\nname = "x"\nversion = "1.0.0"\n', + init: '__version__ = "1.0.0"\n', + }); + assert.equal(JSON.parse(next.package).version, "1.2.3"); + assert.match(next.pyproject, /version = "1\.2\.3"/); + assert.match(next.init, /__version__ = "1\.2\.3"/); +}); diff --git a/scripts/upload_release_assets.py b/scripts/upload_release_assets.py new file mode 100644 index 0000000..2816296 --- /dev/null +++ b/scripts/upload_release_assets.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Validate and idempotently upload the CLI release matrix to Aliyun OSS.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import time +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent + + +def _clean_error(value: object) -> str: + return re.sub(r"Bearer\s+\S+", "Bearer ***", str(value or ""), flags=re.IGNORECASE)[:600] + + +def failure_payload(version: str, asset_name: str, errors: list[str]) -> dict[str, Any]: + clean_version = version.removeprefix("v") + attempts = [ + { + "attempt": index, + "error_code": "OSS_UPLOAD", + "message": _clean_error(error), + "retryable": True, + } + for index, error in enumerate(errors[:3], start=1) + ] + return { + "product_id": "memos-cloud-cli", + "repository": os.getenv("GITHUB_REPOSITORY", "MemTensor/MemOS-Cloud-CLI"), + "version": f"v{clean_version}", + "phase": "oss-upload", + "run_id": os.getenv("GITHUB_RUN_ID", f"v{clean_version}-cli"), + "run_url": ( + f"https://github.com/{os.getenv('GITHUB_REPOSITORY', 'MemTensor/MemOS-Cloud-CLI')}/actions/runs/{os.getenv('GITHUB_RUN_ID')}" + if os.getenv("GITHUB_RUN_ID") + else "" + ), + "attempts": attempts, + "final_error": _clean_error(f"{asset_name}: {errors[-1] if errors else 'unknown OSS failure'}"), + } + + +def report_exhausted_failure(version: str, asset_name: str, errors: list[str]) -> None: + token = os.getenv("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN", "").strip() + failure_url = os.getenv("DOC_AGENT_RELEASE_FAILURE_URL", "").strip() + if not token or len(errors) < 3: + return + if not failure_url: + print("::warning::DOC_AGENT_RELEASE_FAILURE_URL is not configured; skipping exhausted OSS retry report.") + return + request = urllib.request.Request( + failure_url, + data=json.dumps(failure_payload(version, asset_name, errors)).encode("utf-8"), + headers={"content-type": "application/json", "authorization": f"Bearer {token}"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + if response.status >= 300: + raise RuntimeError(f"failure-report endpoint returned HTTP {response.status}") + print(f"Reported exhausted OSS retries for {asset_name}") + except Exception as exc: # keep the original OSS failure as the workflow cause + print(f"::warning::Failed to report exhausted OSS retries: {_clean_error(exc)}") + + +def load_contract() -> dict[str, Any]: + return json.loads((ROOT / "release-assets.json").read_text(encoding="utf-8")) + + +def live_contract_from_environment(contract: dict[str, Any]) -> dict[str, Any]: + return { + **contract, + "bucket": os.getenv("OSS_BUCKET", "").strip() or contract.get("bucket"), + "endpoint": os.getenv("OSS_ENDPOINT", "").strip() or contract.get("endpoint"), + "region": os.getenv("OSS_REGION", "").strip() or contract.get("region"), + "public_base_url": ( + os.getenv("MEMOS_CLI_OSS_PUBLIC_BASE_URL", "").strip() + or contract.get("public_base_url") + ), + } + + +def validate_live_contract(contract: dict[str, Any]) -> None: + required = ("bucket", "endpoint", "region", "public_base_url", "targets") + missing = [name for name in required if not contract.get(name)] + if missing: + raise RuntimeError(f"release-assets.json is missing required live release fields: {', '.join(missing)}") + if not str(contract["public_base_url"]).startswith("https://"): + raise RuntimeError("release-assets.json public_base_url must be an HTTPS URL for live releases") + candidate = " ".join(str(contract.get(name, "")) for name in ("bucket", "endpoint", "public_base_url")).lower() + if re.search(r"(^|[^a-z0-9])(test|testing|dev|staging)([^a-z0-9]|$)", candidate): + raise RuntimeError( + "release-assets.json appears to point at a non-production OSS target; " + "update bucket/endpoint/public_base_url before a live CLI release" + ) + if "replace_with" in candidate or "example.invalid" in candidate: + raise RuntimeError( + "release-assets.json still contains placeholder OSS settings; " + "replace bucket/endpoint/region/public_base_url before a live CLI release" + ) + + +def runtime_contract( + version: str, + assets: list[dict[str, Any]], + public_base_url: str, +) -> dict[str, Any]: + clean_version = version.removeprefix("v") + base_url = str(public_base_url or "").strip().rstrip("/") + if not base_url.startswith("https://"): + raise RuntimeError("runtime asset public_base_url must be an HTTPS URL") + by_target: dict[str, Any] = {} + for asset in assets: + match = re.fullmatch( + rf"memos-{re.escape(clean_version)}-(darwin-arm64|darwin-x64|linux-x64|windows-x64)\.tar\.gz", + str(asset["name"]), + ) + if not match: + raise RuntimeError(f"cannot resolve target from release asset {asset['name']}") + by_target[match.group(1)] = { + "name": asset["name"], + "url": f"{base_url}/{asset['name']}", + "size": int(asset["size"]), + "sha256": asset["sha256"], + } + expected_targets = list(load_contract()["targets"]) + if sorted(by_target) != sorted(expected_targets): + raise RuntimeError("runtime asset contract does not contain the complete target matrix") + return { + "schema": 2, + "version": clean_version, + "public_base_url": base_url, + "targets": expected_targets, + "assets": by_target, + } + + +def expected_assets(version: str, assets_dir: Path) -> list[Path]: + clean_version = version.removeprefix("v") + if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", clean_version): + raise ValueError(f"invalid release version: {version}") + contract = load_contract() + return [assets_dir / f"memos-{clean_version}-{target}.tar.gz" for target in contract["targets"]] + + +def validate_assets(version: str, assets_dir: Path) -> list[dict[str, Any]]: + paths = expected_assets(version, assets_dir) + missing = [path.name for path in paths if not path.is_file() or path.stat().st_size == 0] + if missing: + raise ValueError(f"missing or empty release assets: {', '.join(missing)}") + return [ + { + "name": path.name, + "path": str(path), + "size": path.stat().st_size, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "md5": hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest(), + } + for path in paths + ] + + +def upload_assets(version: str, assets_dir: Path, *, dry_run: bool) -> dict[str, Any]: + assets = validate_assets(version, assets_dir) + result: dict[str, Any] = {"version": version.removeprefix("v"), "dry_run": dry_run, "assets": assets} + if dry_run: + return result + + try: + import oss2 + from oss2.credentials import EnvironmentVariableCredentialsProvider + except ImportError as exc: # pragma: no cover - exercised on the release runner + raise RuntimeError("oss2 is required for live OSS upload") from exc + + for name in ("OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET"): + if not os.getenv(name): + raise RuntimeError(f"{name} is required for live OSS upload") + contract = live_contract_from_environment(load_contract()) + validate_live_contract(contract) + auth = oss2.ProviderAuthV4(EnvironmentVariableCredentialsProvider()) + bucket = oss2.Bucket( + auth, + contract["endpoint"], + contract["bucket"], + region=contract["region"], + ) + for asset in assets: + key = asset["name"] + errors: list[str] = [] + for attempt in range(1, 4): + try: + if bucket.object_exists(key): + remote = bucket.head_object(key) + remote_etag = str(remote.etag or "").strip('"').lower() + if int(remote.content_length) != int(asset["size"]) or remote_etag != asset["md5"]: + raise RuntimeError(f"OSS object {key} exists with different content; refusing overwrite") + asset["upload"] = "already-present-and-verified" + break + bucket.put_object_from_file(key, asset["path"], headers={"x-oss-forbid-overwrite": "true"}) + remote = bucket.head_object(key) + remote_etag = str(remote.etag or "").strip('"').lower() + if int(remote.content_length) != int(asset["size"]) or remote_etag != asset["md5"]: + raise RuntimeError(f"OSS verification failed for {key}") + asset["upload"] = "uploaded-and-verified" + break + except Exception as exc: + errors.append(f"attempt {attempt}: {type(exc).__name__}: {exc}") + if attempt == 3: + report_exhausted_failure(version, key, errors) + raise RuntimeError(f"OSS operation failed after three attempts for {key}: {'; '.join(errors)}") from exc + time.sleep(attempt) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--assets-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--runtime-contract-output", type=Path) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + result = upload_assets(args.version, args.assets_dir, dry_run=args.dry_run) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + if args.runtime_contract_output: + contract = live_contract_from_environment(load_contract()) + if not args.dry_run: + validate_live_contract(contract) + resolved = runtime_contract( + args.version, + result["assets"], + str(contract.get("public_base_url") or ""), + ) + args.runtime_contract_output.write_text( + json.dumps(resolved, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/upload_release_assets_test.py b/scripts/upload_release_assets_test.py new file mode 100644 index 0000000..c4a370a --- /dev/null +++ b/scripts/upload_release_assets_test.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from upload_release_assets import ( + live_contract_from_environment, + runtime_contract, + validate_live_contract, +) + + +class ReleaseAssetContractTest(unittest.TestCase): + def setUp(self) -> None: + self.targets = [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "windows-x64", + ] + self.assets = [ + { + "name": f"memos-1.0.7-{target}.tar.gz", + "size": index + 100, + "sha256": f"{index + 1:064x}", + "md5": f"{index + 1:032x}", + } + for index, target in enumerate(self.targets) + ] + + def test_runtime_contract_pins_every_asset_by_version_url_and_sha256(self) -> None: + contract = runtime_contract( + "v1.0.7", + self.assets, + "https://downloads.example.invalid/memos-cloud-cli/", + ) + self.assertEqual(contract["schema"], 2) + self.assertEqual(contract["version"], "1.0.7") + self.assertEqual(sorted(contract["assets"]), sorted(self.targets)) + self.assertEqual( + contract["assets"]["linux-x64"]["url"], + "https://downloads.example.invalid/memos-cloud-cli/memos-1.0.7-linux-x64.tar.gz", + ) + self.assertRegex(contract["assets"]["windows-x64"]["sha256"], r"^[a-f0-9]{64}$") + + def test_runtime_contract_fails_closed_on_missing_target_or_insecure_url(self) -> None: + with self.assertRaisesRegex(RuntimeError, "complete target matrix"): + runtime_contract( + "1.0.7", + self.assets[:-1], + "https://downloads.example.invalid/memos-cloud-cli", + ) + with self.assertRaisesRegex(RuntimeError, "HTTPS"): + runtime_contract( + "1.0.7", + self.assets, + "http://downloads.example.invalid/memos-cloud-cli", + ) + + def test_live_configuration_comes_from_secrets_without_committing_endpoints(self) -> None: + placeholder = { + "bucket": "REPLACE_WITH_PRODUCTION_BUCKET", + "endpoint": "https://REPLACE_WITH_PRODUCTION_OSS_ENDPOINT", + "region": "REPLACE_WITH_PRODUCTION_REGION", + "public_base_url": "https://example.invalid/memos-cloud-cli", + "targets": self.targets, + } + env = { + "OSS_BUCKET": "release-artifacts", + "OSS_ENDPOINT": "https://oss.example.com", + "OSS_REGION": "region-1", + "MEMOS_CLI_OSS_PUBLIC_BASE_URL": "https://downloads.example.com/memos-cloud-cli", + } + with patch.dict(os.environ, env, clear=False): + resolved = live_contract_from_environment(placeholder) + validate_live_contract(resolved) + self.assertEqual(resolved["bucket"], "release-artifacts") + self.assertEqual( + resolved["public_base_url"], + "https://downloads.example.com/memos-cloud-cli", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_assets.py b/tests/test_release_assets.py new file mode 100644 index 0000000..567526d --- /dev/null +++ b/tests/test_release_assets.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.upload_release_assets import expected_assets, failure_payload, validate_assets, validate_live_contract + + +class ReleaseAssetContractTests(unittest.TestCase): + def test_expected_matrix_matches_installer_contract(self) -> None: + with tempfile.TemporaryDirectory() as directory: + names = [path.name for path in expected_assets("v1.2.3", Path(directory))] + self.assertEqual( + names, + [ + "memos-1.2.3-darwin-arm64.tar.gz", + "memos-1.2.3-darwin-x64.tar.gz", + "memos-1.2.3-linux-x64.tar.gz", + "memos-1.2.3-windows-x64.tar.gz", + ], + ) + + def test_validation_requires_every_nonempty_asset(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + paths = expected_assets("1.2.3", root) + for path in paths: + path.write_bytes(b"archive") + result = validate_assets("1.2.3", root) + self.assertEqual(len(result), 4) + self.assertEqual(result[0]["md5"], "888d0ee361af3603736f32131e7b20a2") + paths[-1].unlink() + with self.assertRaisesRegex(ValueError, "windows-x64"): + validate_assets("1.2.3", root) + + def test_version_must_be_release_semver(self) -> None: + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "invalid release version"): + expected_assets("1.2", Path(directory)) + + def test_failure_payload_preserves_three_sanitized_attempts(self) -> None: + payload = failure_payload( + "v1.2.3", + "memos-1.2.3-linux-x64.tar.gz", + ["attempt 1: timeout", "attempt 2: Bearer secret", "attempt 3: timeout"], + ) + self.assertEqual(payload["phase"], "oss-upload") + self.assertEqual(len(payload["attempts"]), 3) + self.assertEqual(payload["attempts"][1]["message"], "attempt 2: Bearer ***") + + def test_live_contract_rejects_test_asset_targets(self) -> None: + with self.assertRaisesRegex(RuntimeError, "non-production OSS target"): + validate_live_contract( + { + "bucket": "release-testing-bucket", + "endpoint": "https://oss-cn-shanghai.aliyuncs.com", + "region": "cn-shanghai", + "public_base_url": "https://release-testing.example.invalid", + "targets": ["linux-x64"], + }, + ) + + def test_live_contract_requires_https_download_base(self) -> None: + with self.assertRaisesRegex(RuntimeError, "HTTPS"): + validate_live_contract( + { + "bucket": "memos-release", + "endpoint": "https://oss-cn-shanghai.aliyuncs.com", + "region": "cn-shanghai", + "public_base_url": "http://example.com/memos", + "targets": ["linux-x64"], + }, + ) + + def test_live_contract_rejects_placeholder_asset_targets(self) -> None: + with self.assertRaisesRegex(RuntimeError, "placeholder OSS settings"): + validate_live_contract( + { + "bucket": "REPLACE_WITH_PRODUCTION_BUCKET", + "endpoint": "https://REPLACE_WITH_PRODUCTION_OSS_ENDPOINT", + "region": "REPLACE_WITH_PRODUCTION_REGION", + "public_base_url": "https://example.invalid/memos-cloud-cli", + "targets": ["linux-x64"], + }, + ) + + +if __name__ == "__main__": + unittest.main()