diff --git a/.github/scripts/prepare-cli-release.mjs b/.github/scripts/prepare-cli-release.mjs new file mode 100644 index 0000000..04df7d7 --- /dev/null +++ b/.github/scripts/prepare-cli-release.mjs @@ -0,0 +1,2002 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + appendFileSync, + existsSync, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const PRODUCT_ID = "memos-cloud-cli"; +export const PRODUCT_TITLE = { zh: "MemOS CLI", en: "MemOS CLI" }; +export const RELEASE_CATEGORIES = ["Added", "Improved", "Fixed"]; +export const DOCS_CATEGORIES = { + Added: "New Features", + Improved: "Improvements", + Fixed: "Bug Fixes", +}; +export const MAX_DRAFT_ATTEMPTS = 3; +export const MAX_RELEASE_ITEMS = 12; +export const MAX_TEXT_CN_CHARS = 180; +export const MAX_TEXT_EN_CHARS = 220; +export const RELEASE_FAULT_CASES = [ + "none", + "mixed_language", + "missing_source_refs", + "invalid_source_ref", + "missing_important_commit", + "thirteen_items", + "too_long", +]; +export const RELEASE_NOTE_METHODS = [ + { + source: "github-auto-generated-release-notes", + url: "https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes", + applied_as: + "Keep the public CLI Release body as GitHub-generated whole-repository What's Changed notes.", + }, + { + source: "keep-a-changelog", + url: "https://keepachangelog.com/en/1.1.0/", + applied_as: + "Group the shorter Plugin tab copy by Added, Improved, and Fixed.", + }, + { + source: "conventional-commits", + url: "https://www.conventionalcommits.org/en/v1.0.0/", + applied_as: + "Use commit types as evidence hints, never as final user-facing copy.", + }, +]; + +const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/; +const CORE_NUMBER_RE = /^(0|[1-9]\d*)$/; +const NUMERIC_IDENTIFIER_RE = /^\d+$/; +const PRIVATE_KEY_RE = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; +const AWS_ACCESS_KEY_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g; +const JWT_RE = + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g; +const TOKEN_RE = + /(github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|npm_[A-Za-z0-9_]+|xox[baprs]-[A-Za-z0-9-]+|Bearer\s+[A-Za-z0-9._~+/=-]+)/gi; +const PRIVATE_URL_RE = + /https?:\/\/(?:(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|localhost(?::\d+)?|[^/\s"'<>)]*\.(?:internal|local)(?::\d+)?)[^\s"'<>)]*/gi; +const PRIVATE_IP_RE = + /\b(?:(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(?::\d+)?\b/g; +const INTERNAL_HOST_RE = /\b[A-Za-z0-9.-]+\.(?:internal|local)(?::\d+)?\b/gi; +const AUTH_HEADER_RE = + /\b(Authorization\s*:\s*)(?:Basic|Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi; +const SECRET_ASSIGNMENT_RE = + /\b((?:api[_-]?key|access[_-]?key(?:[_-]?(?:id|secret))?|secret(?:[_-]?key)?|client[_-]?secret|password|passwd|token|signature|service_id)\s*[:=]\s*)["']?[^"'\s&),;]+/gi; + +function fail(message) { + throw new Error(String(message)); +} + +function warn(message) { + console.error(`::warning::${sanitizeError(message)}`); +} + +function sh(args, options = {}) { + return execFileSync("git", args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trim(); +} + +function tryGit(args) { + try { + return sh(args); + } catch { + return ""; + } +} + +function lines(value) { + return String(value || "") + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); +} + +export function redact(value) { + return String(value ?? "") + .replace(PRIVATE_KEY_RE, "[REDACTED_PRIVATE_KEY]") + .replace(AUTH_HEADER_RE, "$1[REDACTED_AUTH]") + .replace(TOKEN_RE, "[REDACTED_TOKEN]") + .replace(AWS_ACCESS_KEY_RE, "[REDACTED_TOKEN]") + .replace(JWT_RE, "[REDACTED_TOKEN]") + .replace(PRIVATE_URL_RE, "[REDACTED_INTERNAL_URL]") + .replace( + /https?:\/\/[^\s"'<>)]*\/internal(?:\/[^\s"'<>)]*)?/gi, + "[REDACTED_INTERNAL_URL]", + ) + .replace(INTERNAL_HOST_RE, "[REDACTED_INTERNAL_HOST]") + .replace( + /([?&](?:token|access_token|secret|signature|service_id)=)[^&\s"')]+/gi, + "$1[REDACTED]", + ) + .replace(SECRET_ASSIGNMENT_RE, "$1[REDACTED]") + .replace(PRIVATE_IP_RE, "[REDACTED_IP]"); +} + +export function hasSensitiveContent(value) { + const original = String(value ?? ""); + return redact(original) !== original; +} + +function assertNoSensitiveContent(value, label) { + if (hasSensitiveContent(value)) { + fail( + `${label} contains credential-like or internal content; redact the source data before release.`, + ); + } +} + +export function sanitizeError(value) { + return redact(value) + .replace(/https?:\/\/[^\s"'<>)]*/gi, "[REDACTED_URL]") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, "[REDACTED_IP]") + .replace(/\s+/g, " ") + .slice(0, 1000); +} + +export function cleanVersion(raw) { + const value = String(raw || "").trim(); + if (!value) fail("version is required."); + if (value.startsWith("v")) fail("version must not include a leading v."); + if (!parseSemver(value)) { + fail(`version must be valid SemVer, received: ${value}`); + } + return value; +} + +export function parseSemver(raw) { + const value = String(raw || "").trim().replace(/^v/, ""); + const match = value.match( + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/, + ); + if (!match) return null; + if (![match[1], match[2], match[3]].every((part) => CORE_NUMBER_RE.test(part))) { + return null; + } + const prerelease = match[4] ? match[4].split(".") : []; + if ( + prerelease.some( + (identifier) => + NUMERIC_IDENTIFIER_RE.test(identifier) && + !CORE_NUMBER_RE.test(identifier), + ) + ) { + return null; + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + }; +} + +function compareIdentifier(left, right) { + const leftNumeric = NUMERIC_IDENTIFIER_RE.test(left); + const rightNumeric = NUMERIC_IDENTIFIER_RE.test(right); + if (leftNumeric && rightNumeric) { + const a = BigInt(left); + const b = BigInt(right); + if (a > b) return 1; + if (a < b) return -1; + return 0; + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left.localeCompare(right); +} + +export function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + if (!a || !b) return String(left).localeCompare(String(right)); + for (const field of ["major", "minor", "patch"]) { + if (a[field] !== b[field]) return a[field] - b[field]; + } + if (!a.prerelease.length && !b.prerelease.length) return 0; + if (!a.prerelease.length) return 1; + if (!b.prerelease.length) return -1; + const length = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < length; index += 1) { + if (a.prerelease[index] === undefined) return -1; + if (b.prerelease[index] === undefined) return 1; + const result = compareIdentifier(a.prerelease[index], b.prerelease[index]); + if (result !== 0) return result; + } + return 0; +} + +export function findPreviousTag(version, currentTag, tags) { + const target = cleanVersion(version); + const parsedTarget = parseSemver(target); + const stableTarget = parsedTarget.prerelease.length === 0; + return tags + .map((tag) => String(tag || "").trim()) + .filter((tag) => tag !== currentTag && /^v\d+\.\d+\.\d+/.test(tag)) + .map((tag) => ({ tag, parsed: parseSemver(tag) })) + .filter((item) => item.parsed) + .filter((item) => !stableTarget || item.parsed.prerelease.length === 0) + .filter((item) => compareSemver(item.tag, target) < 0) + .sort((a, b) => compareSemver(b.tag, a.tag))[0]?.tag || ""; +} + +export function validatePublishConfirmation({ dryRun, version, confirmation }) { + if (String(dryRun) === "true") return; + const expected = `PUBLISH v${cleanVersion(version)}`; + if (String(confirmation || "").trim() !== expected) { + fail(`dry_run=false requires publish_confirmation to exactly equal: ${expected}`); + } +} + +export function validateDraftFirstRelease({ + dryRun, + createDraftRelease, +}) { + if (String(dryRun) === "true") return; + if (String(createDraftRelease).toLowerCase() !== "true") { + fail( + "dry_run=false requires create_draft_release=true so a release owner can review the Draft Release before release.published.", + ); + } +} + +export function validateReleaseTarget({ dryRun, targetRef }) { + if (String(dryRun) === "true") return; + if (String(targetRef || "main").trim() !== "main") { + fail("dry_run=false requires target_ref to be exactly main."); + } +} + +export function validateLiveReleaseSource({ + dryRun, + workflowRef, + defaultBranch = "main", + targetSha, + defaultBranchSha, +}) { + const branch = String(defaultBranch || "main").trim() || "main"; + if ( + String(workflowRef || "").trim() && + String(workflowRef).trim() !== `refs/heads/${branch}` + ) { + fail( + `release inspection must be dispatched from the protected default branch ${branch}; use target_ref to inspect another branch or commit.`, + ); + } + if (String(dryRun) === "true") return; + if ( + String(defaultBranchSha || "").trim() && + String(targetSha || "").trim() !== String(defaultBranchSha).trim() + ) { + fail( + `dry_run=false target must equal origin/${branch}; refusing a stale or non-default commit.`, + ); + } +} + +export function validateDocAgentConfiguration({ + allowOffline = false, + env = process.env, +} = {}) { + if (allowOffline) return; + const required = [ + "DOC_AGENT_RELEASE_NOTES_DRAFT_URL", + "DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN", + "DOC_AGENT_RELEASE_FAILURE_URL", + ]; + const missing = required.filter( + (name) => !String(env[name] || "").trim(), + ); + if (missing.length) { + fail(`missing required Actions secrets: ${missing.join(", ")}`); + } + const parsedUrls = {}; + for (const name of [ + "DOC_AGENT_RELEASE_NOTES_DRAFT_URL", + "DOC_AGENT_RELEASE_FAILURE_URL", + ]) { + let parsed; + try { + parsed = new URL(String(env[name]).trim()); + } catch { + fail(`${name} must be a valid HTTP(S) URL.`); + } + if (!/^https?:$/.test(parsed.protocol)) { + fail(`${name} must be a valid HTTP(S) URL.`); + } + parsedUrls[name] = parsed; + } + if ( + parsedUrls.DOC_AGENT_RELEASE_NOTES_DRAFT_URL.origin !== + parsedUrls.DOC_AGENT_RELEASE_FAILURE_URL.origin + ) { + fail( + "DOC_AGENT_RELEASE_FAILURE_URL must use the same origin as DOC_AGENT_RELEASE_NOTES_DRAFT_URL when sharing the draft token.", + ); + } +} + +export function validateFaultCase({ dryRun, faultCase }) { + const value = String(faultCase || "none").trim() || "none"; + if (!RELEASE_FAULT_CASES.includes(value)) { + fail(`unknown release fault case: ${value}`); + } + if (String(dryRun) !== "true" && value !== "none") { + fail("release fault injection is only allowed when dry_run=true."); + } + return value; +} + +export function sourceRefsFromText(text) { + const refs = new Set(); + const pattern = + /\(#(\d+)\)|\b(?:PR|Fix(?:es)?|Close[sd]?|Refs?|Issue|in)\s+#(\d+)|\/(?:pull|issues)\/(\d+)\b/gi; + for (const match of String(text || "").matchAll(pattern)) { + refs.add(`#${match[1] || match[2] || match[3]}`); + } + return [...refs]; +} + +function resolveRef(raw) { + const value = String(raw || "main").trim() || "main"; + for (const candidate of [ + value, + value.startsWith("origin/") ? "" : `origin/${value}`, + ].filter(Boolean)) { + const sha = tryGit(["rev-parse", "--verify", `${candidate}^{commit}`]); + if (sha) return { ref: candidate, sha }; + } + fail(`cannot resolve target_ref to a commit: ${value}`); +} + +function versionSources(ref) { + const packageText = tryGit(["show", `${ref}:package.json`]); + const pyprojectText = tryGit(["show", `${ref}:pyproject.toml`]); + const initText = tryGit(["show", `${ref}:src/memos_cli/__init__.py`]); + let packageVersion = ""; + try { + packageVersion = JSON.parse(packageText).version || ""; + } catch { + fail(`cannot parse package.json at ${ref}`); + } + return { + package_json: packageVersion, + pyproject_toml: + pyprojectText.match(/^version\s*=\s*"([^"]+)"/m)?.[1] || "", + python_init: + initText.match(/^__version__\s*=\s*"([^"]+)"/m)?.[1] || "", + }; +} + +export function validateVersionSources(version, sources) { + const mismatches = Object.entries(sources).filter( + ([, value]) => String(value || "") !== version, + ); + if (mismatches.length) { + fail( + `target_ref version files must all equal ${version}: ${mismatches + .map(([name, value]) => `${name}=${value || ""}`) + .join(", ")}`, + ); + } +} + +function commitBody(sha) { + return redact(tryGit(["show", "--no-patch", "--format=%B", sha])).slice( + 0, + 24000, + ); +} + +function touchedFilesForCommit(sha) { + return lines( + tryGit([ + "diff-tree", + "--root", + "--no-commit-id", + "--name-only", + "-r", + sha, + ]), + ); +} + +function refsForCommit(commit) { + return [ + ...new Set([ + commit.short_sha, + commit.sha, + ...sourceRefsFromText(`${commit.subject}\n${commit.body_excerpt}`), + ]), + ].filter(Boolean); +} + +function revertedCommitShas(commits) { + const reverted = new Set(); + for (const commit of commits) { + if (!/^revert\b/i.test(commit.subject)) continue; + const match = commit.body_excerpt.match( + /This reverts commit ([0-9a-f]{7,40})\b/i, + ); + if (match) reverted.add(match[1].toLowerCase()); + } + return reverted; +} + +function isReverted(commit, reverted) { + return [...reverted].some( + (sha) => + commit.sha.toLowerCase().startsWith(sha) || + sha.startsWith(commit.short_sha.toLowerCase()), + ); +} + +function isImportantCommit(commit, reverted) { + const subject = String(commit.subject || "").trim(); + if (!subject || isReverted(commit, reverted)) return false; + if (/^merge\b/i.test(subject)) return false; + if (commit.touched_files.length === 0) return false; + if (/^(ci|chore|docs|test|style|build)(\([^)]+\))?:/i.test(subject)) { + return false; + } + if ( + /^(?:feat|fix|perf|refactor)\((?:ci|build|release|workflow|test|tests|docs|chore|deps)\)!?:/i.test( + subject, + ) + ) { + return false; + } + if ( + /(?:bump|update|modify|prepare|修改|更新|调整).{0,24}(?:version|版本号)/i.test( + subject, + ) + ) { + return false; + } + if (/\b(?:workflow|release automation|build venv)\b/i.test(subject)) { + return false; + } + if ( + commit.touched_files.length > 0 && + commit.touched_files.every( + (path) => + path.startsWith(".github/") || + /(^|\/)(?:tests?|__tests__)\//.test(path) || + /(^|\/)docs?\//.test(path) || + /\.(?:test|spec)\.[^.]+$/i.test(path), + ) + ) { + return false; + } + if (/^revert\b/i.test(subject)) return true; + return /^(feat|fix|perf|refactor)(\([^)]+\))?!?:|^(add|fix|improv|optimi[sz]|support|allow|prevent|resolve|correct|stabili[sz]|compat)\b|新增|修复|优化|增强|支持|兼容|改进|纠正|避免/i.test(subject); +} + +function changedFiles(range) { + const statusByPath = new Map(); + for (const line of lines( + tryGit(["diff", "--name-status", "--find-renames", range]), + )) { + const fields = line.split("\t"); + const path = fields.at(-1); + statusByPath.set(path, { + status: fields[0], + path, + ...(fields.length === 3 ? { old_path: fields[1] } : {}), + }); + } + const stats = new Map(); + for (const line of lines(tryGit(["diff", "--numstat", range]))) { + const [additions, deletions, path] = line.split("\t"); + stats.set(path, { + additions: additions === "-" ? null : Number(additions), + deletions: deletions === "-" ? null : Number(deletions), + }); + } + return [...statusByPath.values()].map((item) => ({ + ...item, + ...(stats.get(item.path) || {}), + })); +} + +function patchSnippets(range, files) { + const interesting = files + .map((item) => item.path) + .filter( + (path) => + !path.startsWith(".github/") && + !/(^|\/)(tests?|__tests__)\//.test(path) && + /\.(py|js|mjs|json|toml|md|yaml|yml|sh|ps1)$/i.test(path), + ) + .slice(0, 12); + const snippets = []; + let total = 0; + for (const path of interesting) { + if (total >= 16000) break; + const raw = tryGit([ + "diff", + "--unified=1", + "--no-ext-diff", + range, + "--", + path, + ]); + if (!raw) continue; + const patch = redact(raw).slice(0, 5000); + total += patch.length; + snippets.push({ path, patch, truncated: raw.length > patch.length }); + } + return snippets; +} + +function packageChanges(previousTag, currentRef) { + const before = versionSources(previousTag); + const after = versionSources(currentRef); + return Object.keys(after) + .filter((field) => before[field] !== after[field]) + .map((field) => ({ field, before: before[field], after: after[field] })); +} + +export function collectCliEvidence({ + previousTag, + currentTag, + currentRef, + targetVersion, + repo, +}) { + const range = `${previousTag}..${currentRef}`; + const records = tryGit([ + "log", + "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1e", + range, + ]) + .split("\x1e") + .map((record) => record.trim()) + .filter(Boolean); + const commits = records.map((record) => { + const [sha = "", shortSha = "", author = "", date = "", subject = ""] = + record.split("\x1f"); + const commit = { + sha, + short_sha: shortSha, + author: redact(author), + date, + subject: redact(subject), + body_excerpt: commitBody(sha), + touched_files: touchedFilesForCommit(sha), + }; + return { ...commit, source_refs: refsForCommit(commit) }; + }); + const files = changedFiles(range); + const reverted = revertedCommitShas(commits); + const finalPaths = new Set( + files.flatMap((item) => [item.path, item.old_path].filter(Boolean)), + ); + const important = commits + .filter((commit) => isImportantCommit(commit, reverted)) + .filter((commit) => + commit.touched_files.some((path) => finalPaths.has(path)), + ); + const prNumbers = new Set( + commits.flatMap((commit) => + commit.source_refs + .filter((ref) => ref.startsWith("#")) + .map((ref) => ref.slice(1)), + ), + ); + return { + product_id: PRODUCT_ID, + product_title: PRODUCT_TITLE, + repo, + release_repo: repo, + previous_tag: previousTag, + current_tag: currentTag, + target_version: currentTag, + git_ref: currentRef, + evidence_scope: "whole_repository", + product_paths: ["**"], + has_product_changes: files.length > 0, + has_user_facing_product_changes: important.length > 0, + skip_reason: important.length + ? "" + : files.length + ? "repository changed, but no user-facing feat/fix/perf/refactor evidence was found" + : "no repository changes in the release range", + commits, + important_commits: important, + reverted_commit_shas: [...reverted], + required_source_refs: important.map((commit) => ({ + sha: commit.sha, + short_sha: commit.short_sha, + subject: commit.subject, + accepted_refs: commit.source_refs, + })), + pull_requests: [...prNumbers] + .sort((a, b) => Number(a) - Number(b)) + .map((number) => ({ + number, + url: `https://github.com/${repo}/pull/${number}`, + })), + changed_files: files, + diff_stat: { + text: redact(tryGit(["diff", "--stat=200,200", range])), + files: files.map(({ path, additions, deletions }) => ({ + path, + additions, + deletions, + })), + }, + important_diff: { + whole_repository: patchSnippets(range, files), + }, + package_changes: packageChanges(previousTag, currentRef), + test_changes: files.filter( + (item) => + /(^|\/)(test|tests|__tests__)\//.test(item.path) || + /\.test\./.test(item.path), + ), + docs_changes: files.filter((item) => /\.(md|mdx|rst)$/i.test(item.path)), + release_note_quality_request: { + candidate_count: 3, + max_repair_attempts: MAX_DRAFT_ATTEMPTS, + methodology: RELEASE_NOTE_METHODS, + require_source_refs: true, + require_bilingual_output: true, + require_docs_preview: true, + fail_closed: true, + scoring: [ + "evidence coverage", + "source_refs validity", + "Chinese and English language purity", + "Plugin tab readability", + ], + style_policy: [ + "Each bullet must name the concrete CLI behavior and explain its user-facing impact in one sentence.", + "Avoid generic restatements such as '新增了 X 功能', '优化了 X 性能', or '修复了 X 问题'.", + "Do not copy Conventional Commit prefixes or PR-number prose into user-facing text.", + ], + curation_policy: [ + "Summarize only user-visible MemOS CLI changes from the evidence.", + "Do not present CI, packaging, release automation, or test-only work as product features.", + "Group related commits into concise Added, Improved, or Fixed bullets.", + "Preserve all covered source_refs when commits are grouped.", + "Do not mention private endpoints, credentials, internal infrastructure, or raw build paths.", + ], + }, + target_surface: "memos_docs_plugin_changelog", + release_context: { + release_kind: "standalone_repository", + public_release_body: "github_generated_whats_changed", + docs_product_extraction: "whole_tag_range_after_release_published", + }, + release_note_methodology: RELEASE_NOTE_METHODS, + }; +} + +async function fetchJsonWithRetry( + url, + options, + { label, attempts = 3 } = {}, +) { + const errors = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(url, options); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + if (!response.ok) { + const error = new Error( + `HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`, + ); + error.errorCode = `HTTP_${response.status}`; + error.retryable = + response.status === 408 || + response.status === 409 || + response.status === 425 || + response.status === 429 || + response.status >= 500; + throw error; + } + return payload; + } catch (error) { + errors.push({ + error_code: error?.errorCode || "EXTERNAL_REQUEST", + message: sanitizeError(error?.message || error), + retryable: error?.retryable !== false, + }); + if (error?.retryable === false) { + const wrapped = new Error( + `${label} failed without retry: ${errors.at(-1).message}`, + ); + wrapped.attempts = errors; + throw wrapped; + } + if (attempt === attempts) { + const wrapped = new Error( + `${label} failed after ${attempts} attempts: ${errors + .map((item) => item.message) + .join(" | ")}`, + ); + wrapped.attempts = errors; + throw wrapped; + } + const backoff = Math.min(4000, 400 * 2 ** (attempt - 1)); + const jitter = Math.floor(Math.random() * 200); + await new Promise((resolve) => setTimeout(resolve, backoff + jitter)); + } + } + fail(`${label} failed.`); +} + +function optionalHttpUrlFromEnv(name) { + const value = String(process.env[name] || "").trim(); + if (!value) return ""; + let parsed; + try { + parsed = new URL(value); + } catch { + fail(`${name} must be a valid HTTP(S) URL.`); + } + if (!/^https?:$/.test(parsed.protocol)) { + fail(`${name} must be a valid HTTP(S) URL.`); + } + const draftUrl = String( + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || "", + ).trim(); + if (draftUrl) { + const draft = new URL(draftUrl); + if (draft.origin !== parsed.origin) { + fail( + `${name} must use the same origin as DOC_AGENT_RELEASE_NOTES_DRAFT_URL when sharing the draft token.`, + ); + } + } + return value; +} + +export async function reportFailure( + { + evidence, + attempts, + finalError, + phase = "release-notes", + }, + { fetchImpl = fetch } = {}, +) { + if (!Array.isArray(attempts) || attempts.length < 3) { + return { skipped: true, reason: "fewer than three exhausted attempts" }; + } + const url = optionalHttpUrlFromEnv("DOC_AGENT_RELEASE_FAILURE_URL"); + const token = String( + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN || "", + ).trim(); + if (!url) return { skipped: true, reason: "missing configured failure URL" }; + if (!token) return { skipped: true, reason: "missing configured token" }; + const runId = String(process.env.GITHUB_RUN_ID || "").trim(); + const response = await fetchImpl(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + product_id: PRODUCT_ID, + repository: evidence.repo, + version: evidence.target_version, + phase, + run_id: runId || `${evidence.current_tag}-cli`, + run_url: runId + ? `https://github.com/${evidence.repo}/actions/runs/${runId}` + : "", + attempts: attempts.slice(-3).map((attempt, index) => ({ + attempt: index + 1, + error_code: String(attempt?.error_code || "RELEASE_NOTES_FAILED"), + message: sanitizeError(attempt?.message || attempt).slice(0, 600), + retryable: Boolean(attempt?.retryable), + })), + final_error: sanitizeError(finalError).slice(0, 600), + }), + }); + if (!response.ok) { + throw new Error(`failure-report endpoint returned HTTP ${response.status}`); + } + const text = await response.text(); + try { + return text ? JSON.parse(text) : { ok: true }; + } catch { + return { ok: true }; + } +} + +async function reportFailureBestEffort(args) { + try { + return await reportFailure(args); + } catch (error) { + warn(`Failure notification was not delivered: ${error?.message || error}`); + return { skipped: true, reason: sanitizeError(error?.message || error) }; + } +} + +export async function generateGitHubReleaseNotes({ + repo, + currentTag, + targetSha, + previousTag, + token = process.env.GITHUB_TOKEN || "", +}) { + const fallback = (warning = "") => { + const name = `MemOS CLI ${currentTag}`; + const body = [ + "## What's Changed", + ...lines(tryGit(["log", "--format=%s", `${previousTag}..${targetSha}`])).map( + (subject) => `* ${redact(subject)}`, + ), + "", + `**Full Changelog**: https://github.com/${repo}/compare/${previousTag}...${currentTag}`, + "", + ].join("\n"); + assertNoSensitiveContent(name, "local fallback release name"); + assertNoSensitiveContent(body, "local fallback release notes"); + return { + source: warning ? "local-fallback-after-github-error" : "local-fallback", + name, + body, + warning, + }; + }; + if (!token) return fallback("GITHUB_TOKEN unavailable; used local preview."); + try { + const payload = await fetchJsonWithRetry( + `https://api.github.com/repos/${repo}/releases/generate-notes`, + { + method: "POST", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: JSON.stringify({ + tag_name: currentTag, + target_commitish: targetSha, + previous_tag_name: previousTag, + }), + }, + { label: "GitHub generated release notes" }, + ); + if (!String(payload.body || "").trim()) { + fail("GitHub generated release notes response was empty."); + } + const name = String(payload.name || `MemOS CLI ${currentTag}`); + const body = String(payload.body); + assertNoSensitiveContent(name, "GitHub generated release name"); + assertNoSensitiveContent(body, "GitHub generated release notes"); + return { + source: "github-generate-notes-api", + name, + body, + warning: "", + }; + } catch (error) { + if (String(process.env.ALLOW_OFFLINE_DOCS_PREVIEW || "") !== "true") { + throw error; + } + return fallback(sanitizeError(error?.message || error)); + } +} + +export function normalizeDraft(raw) { + return { + ok: raw?.ok !== false, + needs_review: Boolean(raw?.needs_review), + confidence: String(raw?.confidence || ""), + warnings: Array.isArray(raw?.warnings) + ? raw.warnings.map((item) => sanitizeError(item)) + : [], + candidate_selection: raw?.candidate_selection || {}, + release_items: (Array.isArray(raw?.release_items) + ? raw.release_items + : Array.isArray(raw?.items) + ? raw.items + : [] + ).map((item) => ({ + category: String(item?.category || "").trim(), + text_cn: String(item?.text_cn || "").trim(), + text_en: String(item?.text_en || "").trim(), + source_refs: Array.isArray(item?.source_refs) + ? [...new Set(item.source_refs.map((ref) => String(ref).trim()).filter(Boolean))] + : [], + })), + }; +} + +export function injectDraftFault( + draft, + evidence, + faultCase, + { validationRound = 1 } = {}, +) { + const value = String(faultCase || "none").trim() || "none"; + if (value === "none" || validationRound !== 1) return draft; + const injected = { + ...draft, + release_items: draft.release_items.map((item) => ({ + ...item, + source_refs: [...item.source_refs], + })), + }; + const first = injected.release_items[0]; + if (!first) return injected; + if (value === "mixed_language") { + first.text_en = "修复了 CLI authentication issue。"; + } else if (value === "missing_source_refs") { + first.source_refs = []; + } else if (value === "invalid_source_ref") { + first.source_refs = ["deadbee"]; + } else if (value === "missing_important_commit") { + const accepted = (evidence.required_source_refs || []).flatMap( + (required) => required.accepted_refs || [], + ); + for (const item of injected.release_items) { + item.source_refs = item.source_refs.filter( + (ref) => + !accepted.some((acceptedRef) => + sourceRefMatches(ref, acceptedRef), + ), + ); + } + } else if (value === "thirteen_items") { + injected.release_items = Array.from( + { length: MAX_RELEASE_ITEMS + 1 }, + (_, index) => ({ + ...first, + text_cn: `${first.text_cn}(故障注入 ${index + 1})`, + text_en: `${first.text_en} (fault injection ${index + 1})`, + source_refs: [...first.source_refs], + }), + ); + } else if (value === "too_long") { + first.text_cn = `**CLI 故障注入**:${"用于验证官网更新日志长度保护。".repeat(20)}`; + first.text_en = `**CLI fault injection**: ${"This text verifies the website changelog length guard. ".repeat(10)}`; + } + return injected; +} + +function duplicateKey(item) { + return [item.category, item.text_cn, item.text_en] + .map((value) => + value + .toLowerCase() + .replace(/^\*\*[^*]+\*\*\s*[::]\s*/, "") + .replace(/[#`*_()[\]{}::,,。.;;!!?\s-]+/g, " ") + .trim(), + ) + .join("|"); +} + +function looksLikeRawCommit(text) { + return /\b(feat|fix|perf|refactor|chore|docs|test|ci|build)(\([^)]+\))?!?:\s+/i.test( + String(text || ""), + ); +} + +function stripBoldPrefix(text) { + return String(text || "") + .trim() + .replace(/^\*\*[^*]+\*\*\s*[::]\s*/, "") + .trim(); +} + +function isGenericChineseDocsText(text) { + const body = stripBoldPrefix(text).replace(/\s+/g, ""); + if ( + /(便于|降低|减少|避免|确保|支持|适配|稳定|同步|处理|接入|配置|提示|定位)/.test( + body, + ) + ) { + return false; + } + return /^(新增了|修复了|优化了|增加了|更新了).{1,40}(功能|问题|性能|能力|体验)[。.]?$/.test( + body, + ); +} + +function isGenericEnglishDocsText(text) { + const body = stripBoldPrefix(text) + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (/\b(to|so|because|when|during|for|with|without)\b/.test(body)) { + return false; + } + return /^(added|fixed|improved|updated|enhanced)\b.{1,60}\b(feature|functionality|issue|bug|problem|performance|capability|experience)\.?$/.test( + body, + ); +} + +function sourceRefMatches(left, right) { + const a = String(left || "").trim(); + const b = String(right || "").trim(); + if (!a || !b) return false; + if (a === b) return true; + if (/^[0-9a-f]{7,40}$/i.test(a) && /^[0-9a-f]{7,40}$/i.test(b)) { + return a.toLowerCase().startsWith(b.toLowerCase()) || + b.toLowerCase().startsWith(a.toLowerCase()); + } + return false; +} + +export function validateDraft(draft, evidence) { + const issues = []; + const validRefs = new Set( + evidence.commits.flatMap((commit) => commit.source_refs || []), + ); + const userFacingRefs = (evidence.required_source_refs || []).flatMap( + (required) => required.accepted_refs || [], + ); + for (const pull of evidence.pull_requests || []) validRefs.add(`#${pull.number}`); + if (!draft.ok) issues.push({ kind: "draft_not_ok" }); + if (draft.needs_review) issues.push({ kind: "needs_review" }); + if ( + evidence.has_user_facing_product_changes && + draft.release_items.length === 0 + ) { + issues.push({ kind: "empty_release_items" }); + } + if ( + !evidence.has_user_facing_product_changes && + draft.release_items.length > 0 + ) { + issues.push({ kind: "unexpected_release_items_without_user_changes" }); + } + if (draft.release_items.length > MAX_RELEASE_ITEMS) { + issues.push({ + kind: "too_many_release_items", + actual: draft.release_items.length, + maximum: MAX_RELEASE_ITEMS, + }); + } + const seen = new Map(); + for (const [index, item] of draft.release_items.entries()) { + const key = duplicateKey(item); + if (seen.has(key)) { + issues.push({ + kind: "duplicate_release_item", + index, + duplicate_of: seen.get(key), + }); + } else { + seen.set(key, index); + } + if (!RELEASE_CATEGORIES.includes(item.category)) { + issues.push({ kind: "invalid_category", index, value: item.category }); + } + if (!item.text_cn || !CJK_RE.test(item.text_cn)) { + issues.push({ kind: "invalid_text_cn", index }); + } + if (!item.text_en || CJK_RE.test(item.text_en)) { + issues.push({ kind: "invalid_text_en", index }); + } + if (item.text_cn.length > MAX_TEXT_CN_CHARS) { + issues.push({ kind: "text_cn_too_long", index }); + } + if (item.text_en.length > MAX_TEXT_EN_CHARS) { + issues.push({ kind: "text_en_too_long", index }); + } + if (looksLikeRawCommit(item.text_cn) || looksLikeRawCommit(item.text_en)) { + issues.push({ kind: "raw_commit_subject", index }); + } + if (isGenericChineseDocsText(item.text_cn)) { + issues.push({ kind: "generic_text_cn", index }); + } + if (isGenericEnglishDocsText(item.text_en)) { + issues.push({ kind: "generic_text_en", index }); + } + if ( + redact(item.text_cn) !== item.text_cn || + redact(item.text_en) !== item.text_en || + /https?:\/\//i.test(item.text_cn) || + /https?:\/\//i.test(item.text_en) + ) { + issues.push({ kind: "sensitive_content", index }); + } + if (!item.source_refs.length) { + issues.push({ kind: "missing_source_refs", index }); + } + for (const ref of item.source_refs) { + if ( + hasSensitiveContent(ref) || + /https?:\/\//i.test(String(ref || "")) + ) { + issues.push({ kind: "sensitive_source_ref", index }); + } + if (![...validRefs].some((validRef) => sourceRefMatches(ref, validRef))) { + issues.push({ kind: "invalid_source_ref", index, ref }); + } + } + if ( + evidence.has_user_facing_product_changes && + item.source_refs.length > 0 && + !item.source_refs.some((ref) => + userFacingRefs.some((userFacingRef) => + sourceRefMatches(ref, userFacingRef), + ), + ) + ) { + issues.push({ + kind: "non_user_facing_source_refs", + index, + source_refs: item.source_refs, + }); + } + } + const covered = draft.release_items.flatMap((item) => item.source_refs); + const missing = (evidence.required_source_refs || []) + .filter( + (required) => + !required.accepted_refs.some((acceptedRef) => + covered.some((coveredRef) => + sourceRefMatches(coveredRef, acceptedRef), + ), + ), + ) + .map((required) => required.short_sha); + for (const ref of missing) { + issues.push({ kind: "missing_required_ref", ref }); + } + return { + ok: issues.length === 0, + needs_review: issues.length > 0, + issue_count: issues.length, + issues, + coverage: { + required_count: evidence.required_source_refs.length, + covered_required_count: + evidence.required_source_refs.length - missing.length, + missing_required_count: missing.length, + missing_required_refs: missing, + }, + }; +} + +function offlineDraft(evidence) { + const items = evidence.important_commits.slice(0, MAX_RELEASE_ITEMS).map((commit) => ({ + category: /^feat/i.test(commit.subject) + ? "Added" + : /^fix|^revert/i.test(commit.subject) + ? "Fixed" + : "Improved", + text_cn: `**CLI 变更 ${commit.short_sha}**:根据该提交证据生成的离线测试预览。`, + text_en: `**CLI change ${commit.short_sha}**: Offline test preview derived from this commit evidence.`, + source_refs: [commit.short_sha], + })); + return { + ok: true, + needs_review: false, + confidence: "test-only", + warnings: ["offline test fallback; production requires Doc Agent secrets"], + candidate_selection: { + requested_candidate_count: Number( + evidence.release_note_quality_request?.candidate_count || 1, + ), + received_candidate_count: Number( + evidence.release_note_quality_request?.candidate_count || 1, + ), + selected_candidate: 1, + policy: "offline test fallback", + }, + release_items: items, + }; +} + +function candidateScore(validation, draft) { + return [ + validation.ok ? 1 : 0, + validation.coverage.covered_required_count, + -validation.coverage.missing_required_count, + -validation.issue_count, + -Math.abs(Math.min(draft.release_items.length, 10) - 6), + ]; +} + +function compareScores(left, right) { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const difference = Number(left[index] || 0) - Number(right[index] || 0); + if (difference !== 0) return difference; + } + return 0; +} + +async function requestOneDraft({ + url, + token, + evidence, + candidateIndex, + candidateCount, + validationRound, + repairContext, + history, +}) { + return fetchJsonWithRetry( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ...evidence, + candidate_selection_context: { + candidate_index: candidateIndex, + candidate_count: candidateCount, + selection_policy: + "Generate an independent candidate; the workflow selects by deterministic evidence and readability checks.", + }, + workflow_retry_context: { + attempt: validationRound, + previous_errors: history, + }, + repair_context: repairContext, + }), + }, + { label: `Doc Agent CLI changelog candidate ${candidateIndex}` }, + ); +} + +export async function requestDocAgentDraft(evidence) { + if (!evidence.has_user_facing_product_changes) { + const draft = normalizeDraft({ + ok: true, + needs_review: false, + confidence: "high", + warnings: [evidence.skip_reason], + release_items: [], + }); + return { + ...draft, + validation_report: validateDraft(draft, evidence), + validation_attempt_count: 1, + repair_attempt_count: 0, + }; + } + const url = String( + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || "", + ).trim(); + const token = String( + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN || "", + ).trim(); + if ((!url || !token) && process.env.ALLOW_OFFLINE_DOCS_PREVIEW === "true") { + const draft = normalizeDraft(offlineDraft(evidence)); + return { + ...draft, + validation_report: validateDraft(draft, evidence), + validation_attempt_count: 1, + repair_attempt_count: 0, + }; + } + if (!url) fail("DOC_AGENT_RELEASE_NOTES_DRAFT_URL secret is required."); + if (!token) fail("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN secret is required."); + + const candidateCount = Number( + evidence.release_note_quality_request?.candidate_count || 3, + ); + const faultCase = String( + process.env.RELEASE_FAULT_CASE || "none", + ).trim() || "none"; + const candidates = []; + const requestErrors = []; + for (let candidateIndex = 1; candidateIndex <= candidateCount; candidateIndex += 1) { + try { + const payload = await requestOneDraft({ + url, + token, + evidence, + candidateIndex, + candidateCount, + validationRound: 1, + repairContext: null, + history: [], + }); + const draft = injectDraftFault( + normalizeDraft(payload), + evidence, + faultCase, + { validationRound: 1 }, + ); + const validation = validateDraft(draft, evidence); + candidates.push({ + candidate_index: candidateIndex, + draft, + validation, + score: candidateScore(validation, draft), + }); + } catch (error) { + requestErrors.push({ + candidate_index: candidateIndex, + error: sanitizeError(error?.message || error), + attempts: Array.isArray(error?.attempts) ? error.attempts : [], + }); + } + } + if (!candidates.length) { + const exhaustedAttempts = requestErrors.flatMap( + (item) => item.attempts || [], + ); + await reportFailureBestEffort({ + evidence, + attempts: exhaustedAttempts, + finalError: requestErrors.at(-1)?.error || "no candidate returned", + phase: "release-notes-candidates", + }); + fail( + `Doc Agent returned no candidate after ${candidateCount} independent requests: ${JSON.stringify(requestErrors)}`, + ); + } + candidates.sort((left, right) => compareScores(right.score, left.score)); + let selected = candidates[0]; + const selection = { + requested_candidate_count: candidateCount, + received_candidate_count: candidates.length, + selected_candidate: selected.candidate_index, + policy: + "best evidence-backed candidate by deterministic coverage, validation, and readability score", + candidates: candidates.map((candidate) => ({ + candidate_index: candidate.candidate_index, + ok: candidate.validation.ok, + score: candidate.score, + issue_count: candidate.validation.issue_count, + coverage: candidate.validation.coverage, + })), + request_errors: requestErrors, + }; + if (candidates.length !== candidateCount) { + const exhaustedAttempts = requestErrors.flatMap( + (item) => item.attempts || [], + ); + await reportFailureBestEffort({ + evidence, + attempts: exhaustedAttempts, + finalError: `received ${candidates.length}/${candidateCount} candidates`, + phase: "release-notes-candidates", + }); + fail( + `Doc Agent returned only ${candidates.length}/${candidateCount} candidates; refusing to reduce quality silently.`, + ); + } + if (selected.validation.ok) { + return { + ...selected.draft, + candidate_selection: selection, + validation_report: selected.validation, + validation_attempt_count: 1, + repair_attempt_count: 0, + }; + } + const validationFailures = []; + + const history = [ + { + validation_round: 1, + selected_candidate: selected.candidate_index, + validation: selected.validation, + }, + ]; + let repairContext = { + validation_report: selected.validation, + previous_release_items: selected.draft.release_items, + instructions: [ + "Repair only the reported validation issues.", + "Use only facts present in the evidence.", + "Return concise Added, Improved, or Fixed release_items.", + "Each item must contain text_cn, text_en, and valid source_refs.", + "Merge duplicate topics while preserving all source_refs.", + "Do not expose internal infrastructure or copy raw commit subjects.", + ], + }; + for (let repairAttempt = 1; repairAttempt <= MAX_DRAFT_ATTEMPTS; repairAttempt += 1) { + let payload; + try { + payload = await requestOneDraft({ + url, + token, + evidence, + candidateIndex: selected.candidate_index, + candidateCount, + validationRound: repairAttempt + 1, + repairContext, + history, + }); + } catch (error) { + await reportFailureBestEffort({ + evidence, + attempts: Array.isArray(error?.attempts) ? error.attempts : [], + finalError: error?.message || error, + phase: "release-notes-repair-request", + }); + throw error; + } + const draft = injectDraftFault( + normalizeDraft(payload), + evidence, + faultCase, + { validationRound: repairAttempt + 1 }, + ); + const validation = validateDraft(draft, evidence); + if (!validation.ok) { + validationFailures.push({ + error_code: "RELEASE_NOTES_VALIDATION", + message: JSON.stringify({ + repair_attempt: repairAttempt, + issues: validation.issues, + }), + retryable: false, + }); + } + history.push({ + validation_round: repairAttempt + 1, + selected_candidate: selected.candidate_index, + validation, + }); + if (validation.ok) { + return { + ...draft, + candidate_selection: selection, + validation_report: validation, + validation_attempt_count: repairAttempt + 1, + repair_attempt_count: repairAttempt, + }; + } + repairContext = { + validation_report: validation, + previous_release_items: draft.release_items, + instructions: repairContext.instructions, + }; + } + await reportFailureBestEffort({ + evidence, + attempts: validationFailures, + finalError: JSON.stringify( + history.at(-1)?.validation?.issues || [], + ), + phase: "release-notes-validation", + }); + fail( + `Doc Agent selected draft failed ${MAX_DRAFT_ATTEMPTS} repair attempts: ${JSON.stringify( + history.at(-1)?.validation?.issues || [], + )}`, + ); +} + +export function buildDocsPreview(draft, evidence) { + const side = (language) => { + const categories = {}; + for (const releaseCategory of RELEASE_CATEGORIES) { + const changedInfo = draft.release_items + .filter((item) => item.category === releaseCategory) + .map((item) => (language === "zh" ? item.text_cn : item.text_en)); + if (changedInfo.length) { + categories[DOCS_CATEGORIES[releaseCategory]] = [ + { + type: PRODUCT_TITLE[language], + changedInfo, + }, + ]; + } + } + return { + name: evidence.current_tag, + source: { + repo: evidence.repo, + tag: evidence.current_tag, + previous_tag: evidence.previous_tag, + release_url: `https://github.com/${evidence.repo}/releases/tag/${evidence.current_tag}`, + evidence_scope: "whole_repository", + }, + products: { plugin: categories }, + }; + }; + return { + source_id: PRODUCT_ID, + source_repo: evidence.repo, + previous_tag: evidence.previous_tag, + current_tag: evidence.current_tag, + evidence_scope: "whole_repository", + product_paths: ["**"], + release_items: draft.release_items, + docs_action: draft.release_items.length + ? "preview_plugin_tab_entry" + : "skip_plugin_tab_entry", + would_create_docs_pr: false, + files: [ + "content/cn/plugin-changelog.yml", + "content/en/plugin-changelog.yml", + ], + cn: side("zh"), + en: side("en"), + }; +} + +export function docsPreviewMarkdown(preview, draft, evidence) { + const output = [ + `# MemOS CLI-${evidence.current_tag}`, + "", + `- source: ${evidence.previous_tag}...${evidence.current_tag}`, + "- evidence_scope: whole_repository", + "- product_paths: **", + `- docs_action: ${preview.docs_action}`, + "- would_create_docs_pr: false", + "", + ]; + for (const category of RELEASE_CATEGORIES) { + const items = draft.release_items.filter( + (item) => item.category === category, + ); + if (!items.length) continue; + output.push(`## ${category}`, ""); + for (const item of items) { + output.push(`- CN: ${item.text_cn}`); + output.push(`- EN: ${item.text_en}`); + output.push(`- refs: ${item.source_refs.join(", ")}`, ""); + } + } + if (!draft.release_items.length) { + output.push(`No Plugin tab entry: ${evidence.skip_reason}`, ""); + } + return output.join("\n"); +} + +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) appendFileSync(outputFile, `${name}=${value}\n`, "utf8"); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function releaseContract(repo) { + return { + source_id: PRODUCT_ID, + source_repo: repo, + release_trigger: "release.published", + required_webhook_event: "release", + public_release_body: "github_generated_whats_changed", + docs_evidence: "whole_tag_range", + evidence_scope: "whole_repository", + product_paths: ["**"], + docs_files: [ + "content/cn/plugin-changelog.yml", + "content/en/plugin-changelog.yml", + ], + live_release_policy: { + target_ref: "main", + exact_confirmation: "PUBLISH v", + creates_draft_release: true, + manual_publish_required: true, + direct_publish_allowed: false, + }, + dry_run_side_effects: { + creates_tag: false, + creates_github_release: false, + creates_docs_pr: false, + deploys_pre: false, + deploys_gray: false, + deploys_production: false, + }, + }; +} + +function inspectionReadme({ + state, + evidence, + draft, + preview, + releaseNotes, +}) { + const coverage = draft.validation_report?.coverage || {}; + const selection = draft.candidate_selection || {}; + return [ + "# MemOS CLI release inspection", + "", + "## Decision", + "", + `- inspection_kind: ${state.inspectionKind}`, + `- quality_ok: ${Boolean(draft.validation_report?.ok)}`, + "- publish_blocked: false", + `- docs_action: ${preview.docs_action}`, + `- has_user_facing_product_changes: ${Boolean( + evidence.has_user_facing_product_changes, + )}`, + "", + "## Release boundary", + "", + `- previous_tag: ${evidence.previous_tag}`, + `- current_tag: ${evidence.current_tag}`, + `- target_ref_input: ${state.targetRefInput}`, + `- target_ref_resolved: ${state.targetRefResolved}`, + `- target_sha: ${state.targetSha}`, + `- existing_tag_status: ${state.existingTagStatus}`, + `- existing_tag_sha: ${state.existingTagSha || ""}`, + "- source_id: memos-cloud-cli", + "- evidence_scope: whole_repository", + "- product_paths: **", + "", + "## Quality result", + "", + `- requested_candidate_count: ${ + selection.requested_candidate_count || 0 + }`, + `- received_candidate_count: ${selection.received_candidate_count || 0}`, + `- selected_candidate: ${ + selection.selected_candidate ?? selection.selected_index ?? "" + }`, + `- coverage_required_count: ${coverage.required_count || 0}`, + `- coverage_missing_required_count: ${ + coverage.missing_required_count || 0 + }`, + `- repair_attempt_count: ${draft.repair_attempt_count || 0}`, + `- github_release_notes_source: ${releaseNotes.source}`, + "", + "## Review files", + "", + "- `github-release-notes.md` / `release-notes.md`: public GitHub Release body preview.", + "- `evidence.json`: redacted whole-repository tag-range evidence.", + "- `release-notes-draft.json`: accepted bilingual items and internal source refs.", + "- `docs-preview.md` / `docs-preview.json`: Plugin tab preview.", + "- `quality-report.json`: deterministic validation, coverage, candidates, and repair history.", + "- `release-contract.json`: trigger, target files, Draft-first policy, and side-effect contract.", + "", + "## Dry-run side effects", + "", + "This inspection does not create a tag, GitHub Release, MemOS-Docs PR, or deployment.", + "", + ].join("\n"); +} + +function writeBlockedInspection(root, state, error) { + const reason = sanitizeError(error?.message || error); + const repo = state.repo || "MemTensor/MemOS-Cloud-CLI"; + const placeholders = [ + [ + "github-release-notes.md", + `# GitHub Release notes unavailable\n\nPreparation stopped before a valid preview was produced.\n`, + ], + [ + "release-notes.md", + `# GitHub Release notes unavailable\n\nPreparation stopped before a valid preview was produced.\n`, + ], + [ + "docs-preview.md", + `# MemOS CLI changelog preview blocked\n\nThe release quality gate stopped before website copy could be accepted.\n`, + ], + [ + "README.md", + [ + "# MemOS CLI release inspection", + "", + "## Decision", + "", + `- inspection_kind: ${state.inspectionKind || "release_preview"}`, + "- quality_ok: false", + "- publish_blocked: true", + `- publish_block_reason: ${reason}`, + `- phase: ${state.phase || "prepare"}`, + `- existing_tag_status: ${state.existingTagStatus || "unknown"}`, + `- existing_tag_sha: ${state.existingTagSha || ""}`, + "", + "No tag, GitHub Release, MemOS-Docs PR, or deployment was created.", + "", + ].join("\n"), + ], + ]; + for (const [name, contents] of placeholders) { + const path = join(root, name); + if (!existsSync(path)) writeFileSync(path, contents, "utf8"); + } + const evidencePath = join(root, "evidence.json"); + if (!existsSync(evidencePath)) { + writeJson(evidencePath, { + product_id: PRODUCT_ID, + repo, + previous_tag: state.previousTag || "", + current_tag: state.currentTag || "", + target_version: state.currentTag || "", + git_ref: state.targetSha || "", + evidence_scope: "whole_repository", + product_paths: ["**"], + collection_status: "blocked_before_complete_evidence", + }); + } + const previewPath = join(root, "docs-preview.json"); + if (!existsSync(previewPath)) { + writeJson(previewPath, { + source_id: PRODUCT_ID, + source_repo: repo, + previous_tag: state.previousTag || "", + current_tag: state.currentTag || "", + evidence_scope: "whole_repository", + product_paths: ["**"], + docs_action: "blocked_by_quality_gate", + would_create_docs_pr: false, + files: [ + "content/cn/plugin-changelog.yml", + "content/en/plugin-changelog.yml", + ], + }); + } + writeJson(join(root, "release-notes-draft.json"), { + ok: false, + needs_review: true, + release_items: [], + phase: state.phase || "prepare", + error: reason, + }); + writeJson(join(root, "quality-report.json"), { + ok: false, + needs_review: true, + publish_blocked: true, + publish_block_reason: reason, + source_id: PRODUCT_ID, + previous_tag: state.previousTag || "", + current_tag: state.currentTag || "", + target_sha: state.targetSha || "", + evidence_scope: "whole_repository", + product_paths: ["**"], + inspection_kind: state.inspectionKind || "release_preview", + existing_tag_status: state.existingTagStatus || "unknown", + existing_tag_sha: state.existingTagSha || "", + phase: state.phase || "prepare", + fault_case: state.faultCase || "none", + error: reason, + }); + writeJson(join(root, "release-contract.json"), releaseContract(repo)); +} + +async function main() { + const outputBase = process.env.RUNNER_TEMP || tmpdir(); + const outputName = process.env.RUNNER_TEMP + ? "memos-cloud-cli-release-inspection" + : `memos-cloud-cli-release-inspection-${ + process.env.GITHUB_RUN_ID || randomUUID().slice(0, 8) + }`; + const root = join( + outputBase, + outputName, + ); + mkdirSync(root, { recursive: true }); + setOutput("inspection_dir", root); + const state = { + repo: + String(process.env.GITHUB_REPOSITORY || "").trim() || + "MemTensor/MemOS-Cloud-CLI", + phase: "validate-inputs", + inspectionKind: + String(process.env.RELEASE_CONTRACT_FIXTURE || "").toLowerCase() === + "true" + ? "synthetic_contract_fixture" + : "release_preview", + }; + + try { + const version = cleanVersion(process.env.RELEASE_VERSION); + const currentTag = `v${version}`; + const targetRef = String(process.env.TARGET_REF || "main").trim() || "main"; + const dryRun = String(process.env.DRY_RUN || "true").toLowerCase(); + const faultCase = validateFaultCase({ + dryRun, + faultCase: process.env.RELEASE_FAULT_CASE, + }); + state.faultCase = faultCase; + state.currentTag = currentTag; + state.targetRefInput = targetRef; + validatePublishConfirmation({ + dryRun, + version, + confirmation: process.env.PUBLISH_CONFIRMATION, + }); + validateDraftFirstRelease({ + dryRun, + createDraftRelease: process.env.CREATE_DRAFT_RELEASE, + }); + validateReleaseTarget({ dryRun, targetRef }); + validateDocAgentConfiguration({ + allowOffline: + String(process.env.ALLOW_OFFLINE_DOCS_PREVIEW || "").toLowerCase() === + "true", + }); + + state.phase = "resolve-release-source"; + const target = resolveRef(targetRef); + state.targetSha = target.sha; + state.targetRefResolved = target.ref; + const defaultBranch = + String(process.env.DEFAULT_BRANCH || "main").trim() || "main"; + const defaultBranchSha = tryGit([ + "rev-parse", + "--verify", + `origin/${defaultBranch}^{commit}`, + ]); + if (dryRun !== "true" && !defaultBranchSha) { + fail(`cannot resolve origin/${defaultBranch} for a live release.`); + } + validateLiveReleaseSource({ + dryRun, + workflowRef: process.env.GITHUB_REF, + defaultBranch, + targetSha: target.sha, + defaultBranchSha, + }); + validateVersionSources(version, versionSources(target.sha)); + const existingCurrentTag = tryGit([ + "rev-parse", + "--verify", + `refs/tags/${currentTag}^{commit}`, + ]); + state.existingTagSha = existingCurrentTag; + state.existingTagStatus = existingCurrentTag + ? existingCurrentTag === target.sha + ? "matches_target" + : "conflicts_target" + : "absent"; + if (existingCurrentTag && existingCurrentTag !== target.sha) { + fail( + `${currentTag} already points to ${existingCurrentTag}, expected ${target.sha}`, + ); + } + const tags = lines(tryGit(["tag", "--list", "v*"])); + const previousTag = findPreviousTag(version, currentTag, tags); + if (!previousTag) { + fail( + `cannot find a previous SemVer tag before ${currentTag}. ` + + "For the first automated release, create the verified v1.0.6 baseline tag first.", + ); + } + state.previousTag = previousTag; + + state.phase = "collect-evidence"; + const evidence = collectCliEvidence({ + previousTag, + currentTag, + currentRef: target.sha, + targetVersion: version, + repo: state.repo, + }); + evidence.release_state = { + existing_tag_status: state.existingTagStatus, + existing_tag_sha: state.existingTagSha, + publish_blocked: false, + publish_block_reason: "", + }; + evidence.inspection_kind = state.inspectionKind; + const evidenceFile = join(root, "evidence.json"); + writeJson(evidenceFile, evidence); + + state.phase = "generate-github-release-notes"; + const releaseNotes = await generateGitHubReleaseNotes({ + repo: state.repo, + currentTag, + targetSha: target.sha, + previousTag, + }); + const releaseNotesFile = join(root, "github-release-notes.md"); + writeFileSync(releaseNotesFile, releaseNotes.body, "utf8"); + writeFileSync(join(root, "release-notes.md"), releaseNotes.body, "utf8"); + + state.phase = "draft-plugin-changelog"; + const draft = await requestDocAgentDraft(evidence); + if (!draft.validation_report?.ok) { + fail("CLI changelog draft did not pass deterministic validation."); + } + const preview = buildDocsPreview(draft, evidence); + + state.phase = "write-inspection"; + const previewFile = join(root, "docs-preview.json"); + const previewMarkdownFile = join(root, "docs-preview.md"); + const qualityReportFile = join(root, "quality-report.json"); + const contractFile = join(root, "release-contract.json"); + const acceptedDraftFile = join(root, "release-notes-draft.json"); + writeJson(previewFile, preview); + writeFileSync( + previewMarkdownFile, + docsPreviewMarkdown(preview, draft, evidence), + "utf8", + ); + writeJson(acceptedDraftFile, { + ok: draft.validation_report.ok, + needs_review: draft.needs_review, + confidence: draft.confidence, + release_items: draft.release_items, + candidate_selection: draft.candidate_selection, + validation_report: draft.validation_report, + validation_attempt_count: draft.validation_attempt_count, + repair_attempt_count: draft.repair_attempt_count, + warnings: draft.warnings, + }); + writeJson(qualityReportFile, { + ok: draft.validation_report.ok, + needs_review: draft.needs_review, + publish_blocked: false, + publish_block_reason: "", + source_id: PRODUCT_ID, + previous_tag: previousTag, + current_tag: currentTag, + target_sha: target.sha, + evidence_scope: "whole_repository", + product_paths: ["**"], + inspection_kind: state.inspectionKind, + existing_tag_status: state.existingTagStatus, + existing_tag_sha: state.existingTagSha, + has_product_changes: evidence.has_product_changes, + has_user_facing_product_changes: + evidence.has_user_facing_product_changes, + docs_action: preview.docs_action, + fault_case: faultCase, + release_notes_source: releaseNotes.source, + validation_attempt_count: draft.validation_attempt_count, + repair_attempt_count: draft.repair_attempt_count, + validation: draft.validation_report, + coverage: draft.validation_report.coverage, + candidate_selection: draft.candidate_selection, + methodology: RELEASE_NOTE_METHODS, + warnings: [...draft.warnings, releaseNotes.warning].filter(Boolean), + }); + writeJson(contractFile, releaseContract(state.repo)); + writeFileSync( + join(root, "README.md"), + inspectionReadme({ + state, + evidence, + draft, + preview, + releaseNotes, + }), + "utf8", + ); + + setOutput("current_tag", currentTag); + setOutput("previous_tag", previousTag); + setOutput("target_ref", target.ref); + setOutput("target_sha", target.sha); + setOutput("release_notes_file", releaseNotesFile); + setOutput("docs_action", preview.docs_action); + setOutput("validation_attempt_count", draft.validation_attempt_count); + setOutput("repair_attempt_count", draft.repair_attempt_count); + console.log( + `Prepared ${PRODUCT_ID} ${previousTag}...${currentTag} inspection at ${root}`, + ); + } catch (error) { + writeBlockedInspection(root, state, error); + throw error; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(`::error::${sanitizeError(error?.message || error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/prepare-cli-release.test.mjs b/.github/scripts/prepare-cli-release.test.mjs new file mode 100644 index 0000000..8e5614f --- /dev/null +++ b/.github/scripts/prepare-cli-release.test.mjs @@ -0,0 +1,1539 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { + PRODUCT_ID, + buildDocsPreview, + cleanVersion, + collectCliEvidence, + compareSemver, + docsPreviewMarkdown, + findPreviousTag, + generateGitHubReleaseNotes, + hasSensitiveContent, + injectDraftFault, + parseSemver, + redact, + reportFailure, + requestDocAgentDraft, + sourceRefsFromText, + validateDocAgentConfiguration, + validateDraftFirstRelease, + validateDraft, + validateFaultCase, + validateLiveReleaseSource, + validatePublishConfirmation, + validateReleaseTarget, + validateVersionSources, +} from "./prepare-cli-release.mjs"; + +const SCRIPT_PATH = join( + process.cwd(), + ".github/scripts/prepare-cli-release.mjs", +); +const PUBLISH_SCRIPT_PATH = join( + process.cwd(), + ".github/scripts/publish-cli-release.sh", +); + +function fakeGitHubToken() { + return `ghp_${"a".repeat(36)}`; +} + +function fakeBearerCredential() { + return `Bearer ${fakeGitHubToken()}`; +} + +function trustedDispatchEnv(overrides = {}) { + return { + ...process.env, + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "MemTensor/MemOS-Cloud-CLI", + ...overrides, + }; +} + +function git(args) { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, "utf8"); +} + +function writeVersions(version) { + write( + "package.json", + `${JSON.stringify({ name: "@memtensor/memos-cloud-cli", version }, null, 2)}\n`, + ); + write( + "pyproject.toml", + `[project]\nname = "memos-cli"\nversion = "${version}"\n`, + ); + write( + "src/memos_cli/__init__.py", + `"""MemOS CLI."""\n\n__version__ = "${version}"\n`, + ); +} + +function commit(message, body = "") { + git(["add", "."]); + const args = ["commit", "-q", "-m", message]; + if (body) args.push("-m", body); + git(args); + return git(["rev-parse", "HEAD"]); +} + +function withFixture( + fn, + { + baselineVersion = "1.0.6", + baselineTag = `v${baselineVersion}`, + } = {}, +) { + const previous = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "memos-cli-release-")); + try { + process.chdir(root); + git(["init", "-q"]); + git(["config", "user.email", "release-test@example.invalid"]); + git(["config", "user.name", "Release Test"]); + writeVersions(baselineVersion); + write("src/memos_cli/main.py", "def app():\n return 'baseline'\n"); + commit(`chore: synthetic baseline ${baselineVersion}`); + git(["tag", baselineTag]); + return fn(root); + } finally { + process.chdir(previous); + } +} + +function runPublishFixture({ + remoteMainSha, + remoteTagSha = "", + releaseState = "missing", + recover = "false", + version = "2.0.0", +} = {}) { + const root = mkdtempSync(join(tmpdir(), "memos-cli-publish-")); + const mockBin = join(root, "mock-bin"); + const runnerTemp = join(root, "runner-temp"); + const callLog = join(root, "calls.log"); + const targetSha = "abc1234000000000000000000000000000000000"; + const effectiveRemoteMainSha = remoteMainSha ?? targetSha; + const currentTag = `v${version}`; + mkdirSync(mockBin, { recursive: true }); + mkdirSync(runnerTemp, { recursive: true }); + write( + join(root, "release-inspection/github-release-notes.md"), + "# What's Changed\n\n- Verified release note.\n", + ); + write(join(root, "dist/memos-test.tar.gz"), "test archive"); + const gitMock = join(mockBin, "git"); + write( + gitMock, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'git %s\\n' \"$*\" >> \"${CALL_LOG}\"", + "if [[ \"${1:-}\" == \"ls-remote\" && \"${2:-}\" == \"origin\" && \"${3:-}\" == \"refs/heads/main\" ]]; then", + " if [[ -n \"${MOCK_REMOTE_MAIN_SHA:-}\" ]]; then", + " printf '%s\\trefs/heads/main\\n' \"${MOCK_REMOTE_MAIN_SHA}\"", + " fi", + "elif [[ \"${1:-}\" == \"ls-remote\" && -n \"${MOCK_REMOTE_TAG_SHA:-}\" ]]; then", + " printf '%s\\trefs/tags/%s\\n' \"${MOCK_REMOTE_TAG_SHA}\" \"${CURRENT_TAG}\"", + "fi", + "", + ].join("\n"), + ); + chmodSync(gitMock, 0o755); + const ghMock = join(mockBin, "gh"); + write( + ghMock, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'gh %s\\n' \"$*\" >> \"${CALL_LOG}\"", + "if [[ \"${1:-}\" == \"release\" && \"${2:-}\" == \"view\" ]]; then", + " case \"${MOCK_RELEASE_STATE}\" in", + " missing)", + " echo 'release not found' >&2", + " exit 1", + " ;;", + " draft)", + " printf 'true\\tfalse\\thttps://example.invalid/draft\\n'", + " ;;", + " prerelease-draft)", + " printf 'true\\ttrue\\thttps://example.invalid/draft\\n'", + " ;;", + " published)", + " printf 'false\\tfalse\\thttps://example.invalid/published\\n'", + " ;;", + " *)", + " echo 'unexpected mock release state' >&2", + " exit 2", + " ;;", + " esac", + "fi", + "", + ].join("\n"), + ); + chmodSync(ghMock, 0o755); + let output = ""; + let failure; + try { + output = execFileSync("bash", [PUBLISH_SCRIPT_PATH], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${mockBin}:/usr/bin:/bin`, + CALL_LOG: callLog, + MOCK_REMOTE_MAIN_SHA: effectiveRemoteMainSha, + MOCK_REMOTE_TAG_SHA: remoteTagSha, + MOCK_RELEASE_STATE: releaseState, + CURRENT_TAG: currentTag, + TARGET_SHA: targetSha, + RECOVER_EXISTING_RELEASE: recover, + RELEASE_VERSION: version, + GITHUB_REPOSITORY: "MemTensor/MemOS-Cloud-CLI", + RUNNER_TEMP: runnerTemp, + GH_TOKEN: "test-token", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + failure = error; + output = `${String(error.stdout || "")}${String(error.stderr || "")}`; + } + return { + callLog: readFileSync(callLog, "utf8"), + currentTag, + failure, + output, + targetSha, + }; +} + +const evidence = { + commits: [ + { + sha: "abc1234000000000000000000000000000000000", + short_sha: "abc1234", + subject: "fix: explain authentication failures (#31)", + source_refs: [ + "abc1234", + "abc1234000000000000000000000000000000000", + "#31", + ], + }, + ], + pull_requests: [{ number: "31" }], + important_commits: [ + { + sha: "abc1234000000000000000000000000000000000", + short_sha: "abc1234", + subject: "fix: explain authentication failures (#31)", + }, + ], + required_source_refs: [ + { + short_sha: "abc1234", + accepted_refs: [ + "abc1234", + "abc1234000000000000000000000000000000000", + "#31", + ], + }, + ], + has_user_facing_product_changes: true, + repo: "MemTensor/MemOS-Cloud-CLI", + previous_tag: "v1.0.6", + current_tag: "v1.0.7", + target_version: "v1.0.7", + git_ref: "def5678", +}; + +const validDraft = { + ok: true, + needs_review: false, + confidence: "high", + warnings: [], + release_items: [ + { + category: "Fixed", + text_cn: + "**登录错误提示**:登录失败时展示更明确的认证原因,便于快速修正配置。", + text_en: + "**Authentication errors**: Shows a clearer cause after sign-in failures so configuration can be corrected quickly.", + source_refs: ["abc1234", "#31"], + }, + ], +}; + +test("uses SemVer precedence instead of lexical ordering", () => { + assert.ok(compareSemver("1.0.0-beta.10", "1.0.0-beta.9") > 0); + assert.ok(compareSemver("1.0.0", "1.0.0-beta.20") > 0); + assert.equal(compareSemver("1.0.0+build.2", "1.0.0+build.1"), 0); + assert.equal( + findPreviousTag("1.0.7", "v1.0.7", [ + "v1.0.6", + "v1.0.7-beta.1", + "v1.0.7", + "not-a-release", + ]), + "v1.0.6", + ); + assert.equal( + findPreviousTag("1.0.7-beta.10", "v1.0.7-beta.10", [ + "v1.0.6", + "v1.0.7-beta.9", + "v1.0.7-beta.2", + ]), + "v1.0.7-beta.9", + ); + assert.equal(parseSemver("1.0.07"), null); + assert.equal(parseSemver("1.0.7-beta.01"), null); + assert.ok( + compareSemver( + "1.0.7-beta.100000000000000000000", + "1.0.7-beta.99999999999999999999", + ) > 0, + ); +}); + +test("requires version without v and an exact live confirmation", () => { + assert.equal(cleanVersion("1.0.7"), "1.0.7"); + assert.throws(() => cleanVersion("v1.0.7"), /leading v/); + assert.doesNotThrow(() => + validatePublishConfirmation({ + dryRun: "true", + version: "1.0.7", + confirmation: "", + }), + ); + assert.throws( + () => + validatePublishConfirmation({ + dryRun: "false", + version: "1.0.7", + confirmation: "", + }), + /PUBLISH v1\.0\.7/, + ); + assert.doesNotThrow(() => + validatePublishConfirmation({ + dryRun: "false", + version: "1.0.7", + confirmation: "PUBLISH v1.0.7", + }), + ); +}); + +test("requires every live run to create a Draft Release for manual review", () => { + assert.doesNotThrow(() => + validateDraftFirstRelease({ + dryRun: "true", + createDraftRelease: "false", + }), + ); + assert.doesNotThrow(() => + validateDraftFirstRelease({ + dryRun: "false", + createDraftRelease: "true", + }), + ); + assert.throws( + () => + validateDraftFirstRelease({ + dryRun: "false", + createDraftRelease: "false", + }), + /requires create_draft_release=true/, + ); +}); + +test("allows non-main target refs only for dry runs", () => { + assert.doesNotThrow(() => + validateReleaseTarget({ dryRun: "true", targetRef: "feature/preview" }), + ); + assert.doesNotThrow(() => + validateReleaseTarget({ dryRun: "false", targetRef: "main" }), + ); + assert.throws( + () => + validateReleaseTarget({ + dryRun: "false", + targetRef: "feature/preview", + }), + /exactly main/, + ); +}); + +test("runs trusted workflow code from the default branch and restricts live targets", () => { + assert.throws( + () => + validateLiveReleaseSource({ + dryRun: "true", + workflowRef: "refs/heads/feature/preview", + defaultBranch: "main", + targetSha: "aaa", + defaultBranchSha: "bbb", + }), + /must be dispatched from the protected default branch/, + ); + assert.doesNotThrow(() => + validateLiveReleaseSource({ + dryRun: "true", + workflowRef: "refs/heads/main", + defaultBranch: "main", + targetSha: "aaa", + defaultBranchSha: "bbb", + }), + ); + assert.doesNotThrow(() => + validateLiveReleaseSource({ + dryRun: "false", + workflowRef: "refs/heads/main", + defaultBranch: "main", + targetSha: "aaa", + defaultBranchSha: "aaa", + }), + ); + assert.throws( + () => + validateLiveReleaseSource({ + dryRun: "false", + workflowRef: "refs/heads/feature/release", + defaultBranch: "main", + targetSha: "aaa", + defaultBranchSha: "aaa", + }), + /must be dispatched from the protected default branch/, + ); + assert.throws( + () => + validateLiveReleaseSource({ + dryRun: "false", + workflowRef: "refs/heads/main", + defaultBranch: "main", + targetSha: "aaa", + defaultBranchSha: "bbb", + }), + /stale or non-default commit/, + ); +}); + +test("preflights all Doc Agent secret names without exposing their values", () => { + assert.doesNotThrow(() => + validateDocAgentConfiguration({ + env: { + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: + "https://example.invalid/release-notes", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "not-logged", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/failure", + }, + }), + ); + assert.throws( + () => + validateDocAgentConfiguration({ + env: { + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: + "https://example.invalid/release-notes", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "not-logged", + }, + }), + /DOC_AGENT_RELEASE_FAILURE_URL/, + ); + assert.throws( + () => + validateDocAgentConfiguration({ + env: { + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: "file:///tmp/draft", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "not-logged", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/failure", + }, + }), + /HTTP\(S\) URL/, + ); + assert.doesNotThrow( + () => + validateDocAgentConfiguration({ + env: { + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: + "http://example.invalid/release-notes", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "not-logged", + DOC_AGENT_RELEASE_FAILURE_URL: "http://example.invalid/failure", + }, + }), + ); + assert.throws( + () => + validateDocAgentConfiguration({ + env: { + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: + "https://draft.example.invalid/release-notes", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "not-logged", + DOC_AGENT_RELEASE_FAILURE_URL: + "https://failure.example.invalid/failure", + }, + }), + /same origin/, + ); +}); + +test("allows fault injection only in dry runs", () => { + assert.equal( + validateFaultCase({ dryRun: "true", faultCase: "mixed_language" }), + "mixed_language", + ); + assert.throws( + () => + validateFaultCase({ + dryRun: "false", + faultCase: "missing_source_refs", + }), + /only allowed when dry_run=true/, + ); + assert.throws( + () => + validateFaultCase({ dryRun: "true", faultCase: "unknown_fault" }), + /unknown release fault case/, + ); +}); + +test("requires all three CLI version sources to match", () => { + assert.doesNotThrow(() => + validateVersionSources("1.0.7", { + package_json: "1.0.7", + pyproject_toml: "1.0.7", + python_init: "1.0.7", + }), + ); + assert.throws( + () => + validateVersionSources("1.0.7", { + package_json: "1.0.7", + pyproject_toml: "1.0.6", + python_init: "1.0.7", + }), + /pyproject_toml=1\.0\.6/, + ); +}); + +test("extracts PR references from common commit and GitHub wording", () => { + assert.deepEqual( + sourceRefsFromText( + "fix: auth failure (#31)\nFixes #32\nhttps://github.com/MemTensor/MemOS-Cloud-CLI/pull/33", + ), + ["#31", "#32", "#33"], + ); +}); + +test("collects the entire standalone CLI repository but filters release noise", () => { + withFixture(() => { + write("src/memos_cli/auth.py", "def explain_error():\n return 'expired'\n"); + const featureSha = commit("Fix memory API endpoint paths (#31)"); + write( + ".github/workflows/noise.yml", + "name: noise\non: workflow_dispatch\n", + ); + commit("ci: tune release workflow"); + writeVersions("1.0.7"); + commit("feat: modify version to 1.0.7"); + + const result = collectCliEvidence({ + previousTag: "v1.0.6", + currentTag: "v1.0.7", + currentRef: "HEAD", + targetVersion: "1.0.7", + repo: "MemTensor/MemOS-Cloud-CLI", + }); + + assert.equal(result.product_id, PRODUCT_ID); + assert.equal(result.evidence_scope, "whole_repository"); + assert.deepEqual(result.product_paths, ["**"]); + assert.ok( + result.changed_files.some((item) => item.path === "src/memos_cli/auth.py"), + ); + assert.ok( + result.changed_files.some( + (item) => item.path === ".github/workflows/noise.yml", + ), + ); + assert.deepEqual( + result.important_commits.map((item) => item.sha), + [featureSha], + ); + assert.equal(result.required_source_refs.length, 1); + assert.ok(result.required_source_refs[0].accepted_refs.includes("#31")); + assert.equal(result.package_changes.length, 3); + assert.equal( + result.release_context.docs_product_extraction, + "whole_tag_range_after_release_published", + ); + }); +}); + +test("does not publish a docs item for automation-only changes", () => { + withFixture(() => { + write(".github/workflows/release.yml", "name: release\n"); + commit("ci: improve changelog validation"); + const result = collectCliEvidence({ + previousTag: "v1.0.6", + currentTag: "v1.0.7", + currentRef: "HEAD", + targetVersion: "1.0.7", + repo: "MemTensor/MemOS-Cloud-CLI", + }); + assert.equal(result.has_product_changes, true); + assert.equal(result.has_user_facing_product_changes, false); + assert.match(result.skip_reason, /no user-facing/); + }); +}); + +test("does not treat fix-scoped CI or workflow-only changes as CLI features", () => { + withFixture(() => { + write(".github/workflows/release.yml", "name: hardened release\n"); + commit("fix(ci): harden release workflow"); + const result = collectCliEvidence({ + previousTag: "v1.0.6", + currentTag: "v1.0.7", + currentRef: "HEAD", + targetVersion: "1.0.7", + repo: "MemTensor/MemOS-Cloud-CLI", + }); + assert.equal(result.has_product_changes, true); + assert.equal(result.has_user_facing_product_changes, false); + assert.equal(result.important_commits.length, 0); + }); +}); + +test("does not announce a feature that was fully reverted in the same range", () => { + withFixture(() => { + write("src/memos_cli/temporary.py", "ENABLED = True\n"); + const featureSha = commit("feat: add temporary CLI mode (#41)"); + git(["revert", "--no-edit", featureSha]); + const result = collectCliEvidence({ + previousTag: "v1.0.6", + currentTag: "v1.0.7", + currentRef: "HEAD", + targetVersion: "1.0.7", + repo: "MemTensor/MemOS-Cloud-CLI", + }); + assert.equal(result.changed_files.length, 0); + assert.equal(result.important_commits.length, 0); + assert.equal(result.has_user_facing_product_changes, false); + }); +}); + +test("runs an end-to-end offline dry run and writes the inspection contract", () => { + withFixture((root) => { + writeVersions("99.99.99"); + write( + "src/memos_cli/auth.py", + "def explain_error():\n return 'credential expired'\n", + ); + commit("fix(auth): explain expired credentials (#31)"); + const runnerTemp = join(root, "runner-temp"); + execFileSync("node", [SCRIPT_PATH], { + encoding: "utf8", + env: trustedDispatchEnv({ + RELEASE_VERSION: "99.99.99", + TARGET_REF: "HEAD", + DRY_RUN: "true", + ALLOW_OFFLINE_DOCS_PREVIEW: "true", + RELEASE_CONTRACT_FIXTURE: "true", + RUNNER_TEMP: runnerTemp, + GITHUB_TOKEN: "", + }), + stdio: ["ignore", "pipe", "pipe"], + }); + const inspection = join( + runnerTemp, + "memos-cloud-cli-release-inspection", + ); + for (const name of [ + "README.md", + "github-release-notes.md", + "release-notes.md", + "evidence.json", + "release-notes-draft.json", + "docs-preview.md", + "docs-preview.json", + "quality-report.json", + "release-contract.json", + ]) { + assert.equal(existsSync(join(inspection, name)), true, name); + } + const report = JSON.parse( + readFileSync(join(inspection, "quality-report.json"), "utf8"), + ); + assert.equal(report.ok, true); + assert.equal(report.previous_tag, "v99.99.98"); + assert.equal(report.current_tag, "v99.99.99"); + assert.equal(report.inspection_kind, "synthetic_contract_fixture"); + assert.equal(report.publish_blocked, false); + assert.equal(report.existing_tag_status, "absent"); + assert.deepEqual(report.product_paths, ["**"]); + assert.equal(report.coverage.missing_required_count, 0); + const evidence = JSON.parse( + readFileSync(join(inspection, "evidence.json"), "utf8"), + ); + assert.deepEqual(evidence.product_paths, ["**"]); + const acceptedDraft = JSON.parse( + readFileSync(join(inspection, "release-notes-draft.json"), "utf8"), + ); + assert.equal(acceptedDraft.ok, true); + assert.ok(acceptedDraft.release_items[0].source_refs.length > 0); + assert.match( + readFileSync(join(inspection, "README.md"), "utf8"), + /inspection_kind: synthetic_contract_fixture[\s\S]*coverage_missing_required_count: 0/, + ); + const contract = JSON.parse( + readFileSync(join(inspection, "release-contract.json"), "utf8"), + ); + assert.equal(contract.release_trigger, "release.published"); + assert.equal(contract.required_webhook_event, "release"); + assert.deepEqual(contract.product_paths, ["**"]); + assert.equal(contract.live_release_policy.creates_draft_release, true); + assert.equal(contract.live_release_policy.direct_publish_allowed, false); + assert.deepEqual(contract.dry_run_side_effects, { + creates_tag: false, + creates_github_release: false, + creates_docs_pr: false, + deploys_pre: false, + deploys_gray: false, + deploys_production: false, + }); + const exportDir = String( + process.env.RELEASE_TEST_EXPORT_DIR || "", + ).trim(); + if (exportDir) { + cpSync(inspection, exportDir, { recursive: true }); + } + }, { + baselineVersion: "99.99.98", + }); +}); + +test("records a conflicting existing tag and fails closed", () => { + withFixture((root) => { + git(["tag", "v1.0.7", "v1.0.6"]); + writeVersions("1.0.7"); + write("src/memos_cli/auth.py", "def login():\n return 'improved'\n"); + commit("fix(auth): explain credential failures (#31)"); + const runnerTemp = join(root, "runner-temp"); + assert.throws(() => + execFileSync("node", [SCRIPT_PATH], { + encoding: "utf8", + env: trustedDispatchEnv({ + RELEASE_VERSION: "1.0.7", + TARGET_REF: "HEAD", + DRY_RUN: "true", + ALLOW_OFFLINE_DOCS_PREVIEW: "true", + RUNNER_TEMP: runnerTemp, + }), + stdio: ["ignore", "pipe", "pipe"], + }), + ); + const report = JSON.parse( + readFileSync( + join( + runnerTemp, + "memos-cloud-cli-release-inspection", + "quality-report.json", + ), + "utf8", + ), + ); + assert.equal(report.ok, false); + assert.equal(report.publish_blocked, true); + assert.equal(report.existing_tag_status, "conflicts_target"); + assert.match(report.publish_block_reason, /already points to/); + }); +}); + +test("writes a redacted inspection artifact when preparation fails closed", () => { + withFixture((root) => { + const runnerTemp = join(root, "runner-temp"); + let failure; + try { + execFileSync("node", [SCRIPT_PATH], { + encoding: "utf8", + env: trustedDispatchEnv({ + RELEASE_VERSION: "1.0.7", + TARGET_REF: "HEAD", + DRY_RUN: "true", + ALLOW_OFFLINE_DOCS_PREVIEW: "true", + RUNNER_TEMP: runnerTemp, + }), + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + failure = error; + } + assert.ok(failure); + assert.match( + String(failure.stderr), + /target_ref version files must all equal 1\.0\.7/, + ); + const inspection = join( + runnerTemp, + "memos-cloud-cli-release-inspection", + ); + for (const name of [ + "README.md", + "github-release-notes.md", + "release-notes.md", + "evidence.json", + "release-notes-draft.json", + "docs-preview.md", + "docs-preview.json", + "quality-report.json", + "release-contract.json", + ]) { + assert.equal(existsSync(join(inspection, name)), true, name); + } + const report = JSON.parse( + readFileSync(join(inspection, "quality-report.json"), "utf8"), + ); + assert.equal(report.ok, false); + assert.equal(report.needs_review, true); + assert.equal(report.publish_blocked, true); + assert.equal(report.phase, "resolve-release-source"); + }); +}); + +test("accepts bilingual concise items with real source refs", () => { + const result = validateDraft(validDraft, evidence); + assert.equal(result.ok, true); + assert.equal(result.coverage.required_count, 1); + assert.equal(result.coverage.missing_required_count, 0); + + const aliasResult = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + source_refs: ["abc12340"], + }, + ], + }, + evidence, + ); + assert.equal(aliasResult.ok, true); + assert.equal(aliasResult.coverage.missing_required_count, 0); +}); + +test("requests three independent candidates and deterministically selects the valid one", async () => { + const previousFetch = globalThis.fetch; + const previousUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + const previousToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + const requests = []; + const responses = [ + { + ok: true, + needs_review: false, + release_items: [ + { + ...validDraft.release_items[0], + source_refs: ["not-real"], + }, + ], + }, + validDraft, + { + ok: true, + needs_review: false, + release_items: [ + { + ...validDraft.release_items[0], + text_en: "登录失败。", + }, + ], + }, + ]; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = + "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + globalThis.fetch = async (_url, options) => { + requests.push(JSON.parse(options.body)); + return { + ok: true, + status: 200, + text: async () => JSON.stringify(responses.shift()), + }; + }; + const result = await requestDocAgentDraft(evidence); + assert.equal(result.validation_report.ok, true); + assert.equal(result.candidate_selection.requested_candidate_count, 3); + assert.equal(result.candidate_selection.received_candidate_count, 3); + assert.equal(result.candidate_selection.selected_candidate, 2); + assert.equal(result.repair_attempt_count, 0); + assert.equal(requests.length, 3); + assert.deepEqual( + requests.map( + (request) => + request.candidate_selection_context.candidate_index, + ), + [1, 2, 3], + ); + } finally { + globalThis.fetch = previousFetch; + if (previousUrl === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = previousUrl; + } + if (previousToken === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = previousToken; + } + } +}); + +test("sends deterministic validation feedback to Doc Agent for repair", async () => { + const previousFetch = globalThis.fetch; + const previousUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + const previousToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + const requests = []; + const invalidDraft = { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**登录错误提示**:修复登录失败信息。", + text_en: "**Authentication errors**: Clarified sign-in failures.", + source_refs: ["not-real"], + }, + ], + }; + const responses = [invalidDraft, invalidDraft, invalidDraft, validDraft]; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = + "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + globalThis.fetch = async (_url, options) => { + requests.push(JSON.parse(options.body)); + return { + ok: true, + status: 200, + text: async () => JSON.stringify(responses.shift()), + }; + }; + const result = await requestDocAgentDraft(evidence); + assert.equal(result.validation_report.ok, true); + assert.equal(result.validation_attempt_count, 2); + assert.equal(result.repair_attempt_count, 1); + assert.equal(requests.length, 4); + assert.ok( + requests[3].repair_context.validation_report.issues.some( + (item) => item.kind === "invalid_source_ref", + ), + ); + } finally { + globalThis.fetch = previousFetch; + if (previousUrl === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = previousUrl; + } + if (previousToken === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = previousToken; + } + } +}); + +test("fails closed and reports three exhausted semantic repairs", async () => { + const previousFetch = globalThis.fetch; + const previousDraftUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + const previousFailureUrl = process.env.DOC_AGENT_RELEASE_FAILURE_URL; + const previousToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + const invalidDraft = { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**认证**:修复了认证问题。", + text_en: "**Authentication**: Fixed an authentication issue.", + source_refs: ["not-real"], + }, + ], + }; + let draftRequests = 0; + let failurePayload; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = + "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_FAILURE_URL = + "https://example.invalid/failure"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + globalThis.fetch = async (url, options) => { + if (url === "https://example.invalid/failure") { + failurePayload = JSON.parse(options.body); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }; + } + draftRequests += 1; + return { + ok: true, + status: 200, + text: async () => JSON.stringify(invalidDraft), + }; + }; + await assert.rejects( + requestDocAgentDraft(evidence), + /failed 3 repair attempts/, + ); + assert.equal(draftRequests, 6); + assert.equal(failurePayload.phase, "release-notes-validation"); + assert.equal(failurePayload.attempts.length, 3); + assert.ok( + failurePayload.attempts.every( + (item) => item.error_code === "RELEASE_NOTES_VALIDATION", + ), + ); + } finally { + globalThis.fetch = previousFetch; + if (previousDraftUrl === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = previousDraftUrl; + } + if (previousFailureUrl === undefined) { + delete process.env.DOC_AGENT_RELEASE_FAILURE_URL; + } else { + process.env.DOC_AGENT_RELEASE_FAILURE_URL = previousFailureUrl; + } + if (previousToken === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = previousToken; + } + } +}); + +test("reports only three exhausted attempts and redacts failure details", async () => { + const previousUrl = process.env.DOC_AGENT_RELEASE_FAILURE_URL; + const previousToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + let request; + const sensitiveMessage = `${fakeBearerCredential()} at http://10.1.2.3/internal`; + try { + process.env.DOC_AGENT_RELEASE_FAILURE_URL = + "https://example.invalid/failure"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + const result = await reportFailure( + { + evidence, + attempts: [1, 2, 3].map((attempt) => ({ + error_code: "RELEASE_NOTES_VALIDATION", + message: + attempt === 3 + ? sensitiveMessage + : `validation attempt ${attempt}`, + retryable: false, + })), + finalError: sensitiveMessage, + phase: "release-notes-validation", + }, + { + fetchImpl: async (url, options) => { + request = { url, options, body: JSON.parse(options.body) }; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }; + }, + }, + ); + assert.equal(result.ok, true); + assert.equal(request.url, "https://example.invalid/failure"); + assert.equal(request.body.attempts.length, 3); + assert.equal(request.body.product_id, "memos-cloud-cli"); + assert.equal(request.body.repository, "MemTensor/MemOS-Cloud-CLI"); + assert.equal(request.body.version, "v1.0.7"); + assert.doesNotMatch(JSON.stringify(request.body), /ghp_|10\.1\.2\.3/); + } finally { + if (previousUrl === undefined) { + delete process.env.DOC_AGENT_RELEASE_FAILURE_URL; + } else { + process.env.DOC_AGENT_RELEASE_FAILURE_URL = previousUrl; + } + if (previousToken === undefined) { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + } else { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = previousToken; + } + } +}); + +test("redacts broad credential and internal address patterns", () => { + const secretValue = ["abcdef", "1234567890"].join(""); + const privateKeyBlock = [ + `-----BEGIN ${"PRIVATE"} KEY-----`, + secretValue, + `-----END ${"PRIVATE"} KEY-----`, + ].join("\n"); + const value = [ + "private ip 10.1.2.3", + `api_${"key"}=${secretValue}`, + `Authorization: Basic ${secretValue}`, + `${fakeBearerCredential()} at https://doc-agent.example.internal/internal/draft`, + privateKeyBlock, + ].join("\n"); + const result = redact(value); + assert.equal(hasSensitiveContent(value), true); + assert.doesNotMatch(result, /10\.1\.2\.3/); + assert.doesNotMatch(result, new RegExp(secretValue)); + assert.doesNotMatch(result, /Authorization: Basic/); + assert.doesNotMatch(result, /PRIVATE KEY/); + assert.doesNotMatch(result, /doc-agent\.example\.internal/); +}); + +test("rejects sensitive GitHub-generated Release notes before artifacts", async () => { + const previousFetch = globalThis.fetch; + try { + globalThis.fetch = async () => ({ + ok: true, + text: async () => + JSON.stringify({ + name: "MemOS CLI v2.0.0", + body: `## What's Changed\n\n* ${fakeBearerCredential()} at http://10.1.2.3/internal\n`, + }), + }); + await assert.rejects( + () => + generateGitHubReleaseNotes({ + repo: "MemTensor/MemOS-Cloud-CLI", + currentTag: "v2.0.0", + targetSha: "abc1234", + previousTag: "v1.0.6", + token: "test-token", + }), + /GitHub generated release notes contains credential-like or internal content/, + ); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test("rejects invalid refs, language mixing, raw commits, and missed evidence", () => { + const result = validateDraft( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Improved", + text_cn: "fix(auth): 修复登录失败。", + text_en: "修复 authentication failures.", + source_refs: ["not-real"], + }, + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((item) => item.kind === "invalid_text_en")); + assert.ok(result.issues.some((item) => item.kind === "raw_commit_subject")); + assert.ok(result.issues.some((item) => item.kind === "invalid_source_ref")); + assert.ok(result.issues.some((item) => item.kind === "missing_required_ref")); + + const sensitiveRefResult = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + source_refs: ["https://doc-agent.example.internal/source"], + }, + ], + }, + evidence, + ); + assert.ok( + sensitiveRefResult.issues.some( + (item) => item.kind === "sensitive_source_ref", + ), + ); +}); + +test("rejects a real source ref when it belongs only to release automation", () => { + const automationSha = "def5678000000000000000000000000000000000"; + const result = validateDraft( + { + ...validDraft, + release_items: [ + validDraft.release_items[0], + { + category: "Improved", + text_cn: + "**发布流程**:调整内部发布检查,使自动化运行更加稳定。", + text_en: + "**Release workflow**: Adjusted internal release checks for more stable automation.", + source_refs: ["def5678"], + }, + ], + }, + { + ...evidence, + commits: [ + ...evidence.commits, + { + sha: automationSha, + short_sha: "def5678", + subject: "fix(ci): repair release workflow", + source_refs: ["def5678", automationSha], + }, + ], + }, + ); + assert.ok( + result.issues.some( + (item) => + item.kind === "non_user_facing_source_refs" && item.index === 1, + ), + ); +}); + +test("rejects all generated items when the release has no user-facing changes", () => { + const result = validateDraft(validDraft, { + ...evidence, + has_user_facing_product_changes: false, + required_source_refs: [], + }); + assert.ok( + result.issues.some( + (item) => item.kind === "unexpected_release_items_without_user_changes", + ), + ); +}); + +test("rejects generic Plugin tab copy without concrete CLI impact", () => { + const result = validateDraft( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**认证**:修复了认证问题。", + text_en: "**Authentication**: Fixed an authentication issue.", + source_refs: ["abc1234"], + }, + ], + }, + evidence, + ); + assert.ok(result.issues.some((item) => item.kind === "generic_text_cn")); + assert.ok(result.issues.some((item) => item.kind === "generic_text_en")); +}); + +test("fault injection exercises every remote-repair quality gate", () => { + const expectedIssues = new Map([ + ["mixed_language", "invalid_text_en"], + ["missing_source_refs", "missing_source_refs"], + ["invalid_source_ref", "invalid_source_ref"], + ["missing_important_commit", "missing_required_ref"], + ["thirteen_items", "too_many_release_items"], + ["too_long", "text_cn_too_long"], + ]); + for (const [faultCase, expectedIssue] of expectedIssues) { + const injected = injectDraftFault(validDraft, evidence, faultCase); + const validation = validateDraft(injected, evidence); + assert.equal(validation.ok, false, faultCase); + assert.ok( + validation.issues.some((item) => item.kind === expectedIssue), + `${faultCase} should produce ${expectedIssue}`, + ); + } + assert.equal( + injectDraftFault(validDraft, evidence, "mixed_language", { + validationRound: 2, + }), + validDraft, + ); +}); + +test("rejects credentials and private URLs in generated website copy", () => { + const result = validateDraft( + { + ok: true, + needs_review: false, + release_items: [ + { + ...validDraft.release_items[0], + text_cn: + "**登录错误提示**:请访问 http://10.1.2.3/internal 查看认证失败原因。", + text_en: + `**Authentication errors**: Use ${fakeBearerCredential()} to inspect failures.`, + }, + ], + }, + evidence, + ); + assert.ok(result.issues.some((item) => item.kind === "sensitive_content")); + assert.equal( + redact("see https://doc-agent.example.internal/internal/draft"), + "see [REDACTED_INTERNAL_URL]", + ); +}); + +test("rejects fragmented and overlong Plugin tab content", () => { + const fragmented = { + ok: true, + needs_review: false, + release_items: Array.from({ length: 13 }, (_, index) => ({ + category: "Improved", + text_cn: `**CLI 优化 ${index}**:改善命令使用体验。`, + text_en: `**CLI improvement ${index}**: Improved command usability.`, + source_refs: ["abc1234"], + })), + }; + const fragmentedResult = validateDraft(fragmented, evidence); + assert.ok( + fragmentedResult.issues.some( + (item) => item.kind === "too_many_release_items", + ), + ); + + const longResult = validateDraft( + { + ok: true, + needs_review: false, + release_items: [ + { + ...validDraft.release_items[0], + text_cn: `**登录错误提示**:${"用于验证官网条目长度限制的中文说明。".repeat(20)}`, + text_en: `**Authentication errors**: ${"This sentence verifies the maximum length for website changelog entries. ".repeat(10)}`, + }, + ], + }, + evidence, + ); + assert.ok(longResult.issues.some((item) => item.kind === "text_cn_too_long")); + assert.ok(longResult.issues.some((item) => item.kind === "text_en_too_long")); +}); + +test("renders only the two Plugin changelog targets and exposes source refs", () => { + const preview = buildDocsPreview(validDraft, evidence); + assert.deepEqual(preview.files, [ + "content/cn/plugin-changelog.yml", + "content/en/plugin-changelog.yml", + ]); + assert.equal(preview.evidence_scope, "whole_repository"); + assert.deepEqual(preview.product_paths, ["**"]); + assert.deepEqual(preview.release_items[0].source_refs, ["abc1234", "#31"]); + assert.equal(preview.would_create_docs_pr, false); + assert.equal( + preview.cn.products.plugin["Bug Fixes"][0].type, + "MemOS CLI", + ); + const markdown = docsPreviewMarkdown(preview, validDraft, evidence); + assert.match(markdown, /refs: abc1234, #31/); +}); + +test("release workflow preserves two existing build targets and uses a draft-first release", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + const publishScript = readFileSync(PUBLISH_SCRIPT_PATH, "utf8"); + assert.doesNotThrow(() => + execFileSync("bash", ["-n", PUBLISH_SCRIPT_PATH], { + stdio: ["ignore", "pipe", "pipe"], + }), + ); + assert.match(workflow, /version:/); + assert.match(workflow, /target_ref:/); + assert.match(workflow, /dry_run:/); + assert.match(workflow, /publish_confirmation:/); + assert.match(workflow, /create_draft_release:/); + assert.match(workflow, /CREATE_DRAFT_RELEASE:/); + assert.match(workflow, /recover_existing_release:/); + assert.match(workflow, /fault_case:/); + assert.match(workflow, /mixed_language/); + assert.match(workflow, /missing_important_commit/); + assert.match(workflow, /thirteen_items/); + assert.match(workflow, /default:\s+true/); + assert.match(workflow, /PUBLISH v/); + assert.match( + workflow, + /github\.ref == format\('refs\/heads\/\{0\}', github\.event\.repository\.default_branch\)/, + ); + assert.match( + workflow, + /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/, + ); + assert.match( + workflow, + /ref: \$\{\{ needs\.prepare\.outputs\.target_sha \}\}/, + ); + assert.match( + workflow, + /prepare:\n[\s\S]*?persist-credentials: false\n[\s\S]*?\n build:/, + ); + assert.match( + workflow, + /build:\n[\s\S]*?persist-credentials: false\n[\s\S]*?\n release:/, + ); + assert.match(workflow, /release:\n[\s\S]*?persist-credentials: true/); + assert.match(workflow, /DOC_AGENT_RELEASE_FAILURE_URL/); + assert.match(workflow, /if: \$\{\{ always\(\) \}\}/); + assert.match(workflow, /permissions:\n\s+contents: read/); + assert.match(workflow, /prepare:\n[\s\S]*permissions:\n\s+contents: write/); + assert.match(workflow, /release:\n[\s\S]*permissions:\n\s+contents: write/); + assert.match(publishScript, /recover_existing_release=true/); + assert.match(workflow, /ubuntu-22\.04/); + assert.match(workflow, /windows-2022/); + assert.match(workflow, /build:\n\s+if: \$\{\{ !inputs\.dry_run \}\}/); + assert.doesNotMatch(workflow, /macos-/); + assert.doesNotMatch(workflow, /checksum|sha-256|sha256/i); + assert.match(workflow, /github-release-notes\.md/); + assert.match(workflow, /bash \.github\/scripts\/publish-cli-release\.sh/); + assert.match(publishScript, /release_flags=\(--draft\)/); + assert.match(publishScript, /git config --local user\.name/); + assert.match(publishScript, /collect_release_assets/); + assert.match(publishScript, /release_assets=\(dist\/\*\.tar\.gz\)/); + assert.match(publishScript, /gh release edit[\s\S]*--draft/); + assert.match( + publishScript, + /Publish the draft manually to emit release\.published/, + ); + assert.doesNotMatch(publishScript, /gh release create[\s\S]*--latest/); +}); + +test("publish state machine creates only a Draft Release for a new stable tag", () => { + const result = runPublishFixture(); + assert.equal(result.failure, undefined); + assert.match( + result.callLog, + new RegExp(`git tag ${result.currentTag} ${result.targetSha}`), + ); + assert.match(result.callLog, new RegExp(`git push origin refs/tags/${result.currentTag}`)); + assert.match(result.callLog, /gh release create[\s\S]*--draft/); + assert.doesNotMatch(result.callLog, /--prerelease/); + assert.match(result.output, /Draft Release created/); +}); + +test("publish state machine rechecks main before release mutation", () => { + const result = runPublishFixture({ + remoteMainSha: "def5678000000000000000000000000000000000", + }); + assert.ok(result.failure); + assert.match(result.output, /main moved to/); + assert.doesNotMatch( + result.callLog, + /git tag |git push |gh release upload|gh release edit|gh release create/, + ); +}); + +test("publish state machine requires explicit recovery for a matching orphan tag", () => { + const targetSha = "abc1234000000000000000000000000000000000"; + const blocked = runPublishFixture({ remoteTagSha: targetSha }); + assert.ok(blocked.failure); + assert.match(blocked.output, /recover_existing_release=true/); + assert.doesNotMatch(blocked.callLog, /git tag |git push |gh release create/); + + const recovered = runPublishFixture({ + remoteTagSha: targetSha, + recover: "true", + }); + assert.equal(recovered.failure, undefined); + assert.doesNotMatch(recovered.callLog, /git tag |git push /); + assert.match(recovered.callLog, /gh release create[\s\S]*--draft/); +}); + +test("publish state machine resumes a Draft and leaves a published Release unchanged", () => { + const targetSha = "abc1234000000000000000000000000000000000"; + const draft = runPublishFixture({ + remoteTagSha: targetSha, + releaseState: "draft", + }); + assert.equal(draft.failure, undefined); + assert.match(draft.callLog, /gh release upload/); + assert.match(draft.callLog, /gh release edit/); + assert.doesNotMatch(draft.callLog, /gh release create|git tag |git push /); + + const published = runPublishFixture({ + remoteTagSha: targetSha, + releaseState: "published", + }); + assert.equal(published.failure, undefined); + assert.match(published.output, /Published Release already exists/); + assert.doesNotMatch( + published.callLog, + /gh release upload|gh release edit|gh release create|git tag |git push /, + ); +}); + +test("publish state machine refuses a conflicting tag and marks prereleases", () => { + const conflict = runPublishFixture({ + remoteTagSha: "def5678000000000000000000000000000000000", + }); + assert.ok(conflict.failure); + assert.match(conflict.output, /already exists at/); + assert.doesNotMatch(conflict.callLog, /gh release view|gh release create|git push /); + + const prerelease = runPublishFixture({ version: "2.0.0-rc.1" }); + assert.equal(prerelease.failure, undefined); + assert.match(prerelease.callLog, /gh release create[\s\S]*--draft --prerelease/); +}); + +test("pre/post-merge checks lint workflows and stay isolated from live release permissions", () => { + const releaseWorkflow = readFileSync(".github/workflows/release.yml", "utf8"); + const checkWorkflow = readFileSync( + ".github/workflows/release-changelog-ci.yml", + "utf8", + ); + assert.doesNotMatch(releaseWorkflow, /\bworkflow_call\s*:/); + assert.doesNotMatch( + checkWorkflow, + /uses:\s*[.'"]*\/\.github\/workflows\/release\.yml/, + ); + assert.match(checkWorkflow, /permissions:\n\s+contents: read/); + assert.match(checkWorkflow, /docker:\/\/rhysd\/actionlint@sha256:[0-9a-f]{64}/); + assert.match(checkWorkflow, /\^\[0-9a-f\]\{40\}\(\[0-9a-f\]\{24\}\)\?\$/); + assert.match(checkWorkflow, /persist-credentials:\s+false/); + assert.doesNotMatch(checkWorkflow, /\bsecrets\./); + assert.doesNotMatch(checkWorkflow, /\bcontents:\s+write\b/); +}); + +test("the committed baseline audit records npm gitHead as the authoritative point", () => { + const runbook = readFileSync( + "docs/release-changelog-automation.md", + "utf8", + ); + assert.match( + runbook, + /c18ced54beeb817f6d3f0def1d43eca66da94817/, + ); + assert.match(runbook, /npm.*gitHead/i); + assert.match(runbook, /do not.*push.*automatically/i); +}); diff --git a/.github/scripts/publish-cli-release.sh b/.github/scripts/publish-cli-release.sh new file mode 100644 index 0000000..f9d2c4b --- /dev/null +++ b/.github/scripts/publish-cli-release.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${CURRENT_TAG:?CURRENT_TAG is required}" +: "${TARGET_SHA:?TARGET_SHA is required}" +: "${RECOVER_EXISTING_RELEASE:?RECOVER_EXISTING_RELEASE is required}" +: "${RELEASE_VERSION:?RELEASE_VERSION is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +git config --local user.name "github-actions[bot]" +git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + +declare -a release_assets + +collect_release_assets() { + shopt -s nullglob + release_assets=(dist/*.tar.gz) + shopt -u nullglob + if [[ "${#release_assets[@]}" -eq 0 ]]; then + echo "::error::No dist/*.tar.gz artifacts found; aborting release upload." + exit 1 + fi +} + +ensure_target_is_current_main() { + local remote_main_sha + remote_main_sha="$( + git ls-remote origin "refs/heads/main" | + awk '$2 == "refs/heads/main" {print $1; exit}' + )" + if [[ -z "${remote_main_sha}" ]]; then + echo "::error::Unable to resolve refs/heads/main immediately before release mutation." + exit 1 + fi + if [[ "${remote_main_sha}" != "${TARGET_SHA}" ]]; then + echo "::error::main moved to ${remote_main_sha} after release inspection; expected ${TARGET_SHA}. Rerun dry_run=true for the new main before creating or updating a Draft Release." + exit 1 + fi +} + +release_notes_file="release-inspection/github-release-notes.md" +if [[ ! -s "${release_notes_file}" ]]; then + echo "::error::github-release-notes.md is missing or empty." + exit 1 +fi +collect_release_assets + +remote_tag_sha="$( + git ls-remote --tags origin "refs/tags/${CURRENT_TAG}" "refs/tags/${CURRENT_TAG}^{}" | + awk '$2 ~ /\^\{\}$/ {sha=$1} $2 !~ /\^\{\}$/ && sha=="" {sha=$1} END {print sha}' +)" +if [[ -n "${remote_tag_sha}" && "${remote_tag_sha}" != "${TARGET_SHA}" ]]; then + echo "::error::${CURRENT_TAG} already exists at ${remote_tag_sha}, expected ${TARGET_SHA}." + exit 1 +fi + +release_exists=false +release_info="" +release_lookup_log="${RUNNER_TEMP}/memos-cli-release-lookup-$$.log" +for attempt in 1 2 3; do + set +e + release_info="$( + gh release view "${CURRENT_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease,url \ + --jq '[.isDraft, .isPrerelease, .url] | @tsv' \ + 2>"${release_lookup_log}" + )" + lookup_status=$? + set -e + if [[ "${lookup_status}" == 0 ]]; then + release_exists=true + break + fi + if grep -Eiq "release not found|HTTP 404|Not Found" "${release_lookup_log}"; then + break + fi + if [[ "${attempt}" == 3 ]]; then + sed -n '1,80p' "${release_lookup_log}" >&2 + echo "::error::Unable to determine whether ${CURRENT_TAG} already has a GitHub Release." + exit "${lookup_status}" + fi + sleep "$((attempt * 2))" +done + +if [[ "${release_exists}" == "true" && -z "${remote_tag_sha}" ]]; then + echo "::error::GitHub Release ${CURRENT_TAG} exists but its remote tag is missing." + exit 1 +fi +if [[ "${release_exists}" != "true" && + -n "${remote_tag_sha}" && + "${RECOVER_EXISTING_RELEASE}" != "true" ]]; then + echo "::error::${CURRENT_TAG} exists without a GitHub Release. Rerun only after reviewing the tag, with recover_existing_release=true." + exit 1 +fi + +release_flags=(--draft) +expected_prerelease=false +if [[ "${RELEASE_VERSION}" == *-* ]]; then + expected_prerelease=true + release_flags+=(--prerelease) +fi + +if [[ "${release_exists}" == "true" ]]; then + IFS=$'\t' read -r is_draft is_prerelease release_url <<< "${release_info}" + if [[ "${is_prerelease}" != "${expected_prerelease}" ]]; then + echo "::error::Existing Release prerelease=${is_prerelease}, expected ${expected_prerelease} for ${CURRENT_TAG}." + exit 1 + fi + if [[ "${is_draft}" != "true" ]]; then + echo "::notice::Published Release already exists at ${release_url}; leaving it unchanged." + exit 0 + fi + + ensure_target_is_current_main + gh release upload "${CURRENT_TAG}" "${release_assets[@]}" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + gh release edit "${CURRENT_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft \ + --title "MemOS CLI ${CURRENT_TAG}" \ + --notes-file "${release_notes_file}" +else + ensure_target_is_current_main + if [[ -z "${remote_tag_sha}" ]]; then + git tag "${CURRENT_TAG}" "${TARGET_SHA}" + git push origin "refs/tags/${CURRENT_TAG}" + else + echo "::notice::Explicitly recovering the missing GitHub Release for existing tag ${CURRENT_TAG}." + fi + gh release create "${CURRENT_TAG}" "${release_assets[@]}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${TARGET_SHA}" \ + --title "MemOS CLI ${CURRENT_TAG}" \ + --notes-file "${release_notes_file}" \ + "${release_flags[@]}" +fi + +echo "::notice::Draft Release created. Publish the draft manually to emit release.published." diff --git a/.github/workflows/release-changelog-ci.yml b/.github/workflows/release-changelog-ci.yml new file mode 100644 index 0000000..840e5c0 --- /dev/null +++ b/.github/workflows/release-changelog-ci.yml @@ -0,0 +1,99 @@ +name: MemOS CLI Release — Pre/Post-Merge Checks + +on: + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/**" + - "docs/release-changelog-*.md" + - "package.json" + - "pyproject.toml" + - "src/memos_cli/__init__.py" + push: + branches: + - main + paths: + - ".github/scripts/**" + - ".github/workflows/**" + - "docs/release-changelog-*.md" + - "package.json" + - "pyproject.toml" + - "src/memos_cli/__init__.py" + +concurrency: + group: release-changelog-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check GitHub Actions schema and expressions + uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + with: + args: -color -oneline + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test changelog evidence and safety guards + env: + RELEASE_TEST_EXPORT_DIR: ${{ runner.temp }}/memos-cloud-cli-release-contract-check + run: node --test .github/scripts/prepare-cli-release.test.mjs + + - name: Check workflow and script formatting + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [[ -n "${BASE_REF}" ]]; then + git diff --check "origin/${BASE_REF}...${HEAD_SHA}" + elif [[ "${BEFORE_SHA}" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]] && + [[ ! "${BEFORE_SHA}" =~ ^0+$ ]] && + git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then + git diff --check "${BEFORE_SHA}" "${HEAD_SHA}" + else + git show --check --format= "${HEAD_SHA}" + fi + node --check .github/scripts/prepare-cli-release.mjs + bash -n .github/scripts/publish-cli-release.sh + bash -n scripts/build-binary.sh + + - name: Upload offline release contract inspection + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: memos-cloud-cli-release-contract-check + path: ${{ runner.temp }}/memos-cloud-cli-release-contract-check + if-no-files-found: warn + retention-days: 7 + + - name: Summarize release contract checks + if: ${{ always() }} + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + { + echo "## MemOS CLI release contract checks" + echo + echo "- event: ${EVENT_NAME}" + echo "- scope: synthetic offline v99.99.98...v99.99.99 contract fixture" + echo "- purpose: validate artifact schema, quality gates, source refs, and zero dry-run side effects" + echo + echo "This check does not create a tag, GitHub Release, MemOS-Docs PR, or deployment." + echo "The artifact is a mechanism check and is not the next real CLI release preview." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9863db..e25ef5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,155 @@ -name: release +name: MemOS CLI — Release on: workflow_dispatch: - push: - tags: - - "v*" + inputs: + version: + description: "Owner-approved CLI SemVer without leading v. Do not invent a version." + required: true + target_ref: + description: "Git ref to inspect or release. Use main for a real release." + required: false + default: "main" + dry_run: + description: "Preview evidence and docs only; never create a tag or GitHub Release." + required: true + type: boolean + default: true + create_draft_release: + description: "For a real run, create a Draft Release first. Publish it manually to emit release.published." + required: true + type: boolean + default: true + recover_existing_release: + description: "Allow an intentional rerun to create a missing GitHub Release for an existing matching tag." + required: true + type: boolean + default: false + fault_case: + description: "Dry-run-only changelog quality fault injection." + required: true + type: choice + default: "none" + options: + - none + - mixed_language + - missing_source_refs + - invalid_source_ref + - missing_important_commit + - thirteen_items + - too_long + publish_confirmation: + description: "Required only when dry_run=false. Must exactly equal: PUBLISH v" + required: false + default: "" + +concurrency: + group: memos-cloud-cli-release + cancel-in-progress: false permissions: - contents: write + contents: read jobs: + prepare: + if: ${{ github.repository == 'MemTensor/MemOS-Cloud-CLI' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-22.04 + timeout-minutes: 20 + permissions: + contents: write + outputs: + current_tag: ${{ steps.prepare.outputs.current_tag }} + previous_tag: ${{ steps.prepare.outputs.previous_tag }} + target_sha: ${{ steps.prepare.outputs.target_sha }} + release_notes_file: ${{ steps.prepare.outputs.release_notes_file }} + inspection_dir: ${{ steps.prepare.outputs.inspection_dir }} + docs_action: ${{ steps.prepare.outputs.docs_action }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fetch branches and release tags + shell: bash + run: | + set -euo pipefail + git fetch --tags --force origin + git fetch origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Test release evidence and quality guards + run: node --test .github/scripts/prepare-cli-release.test.mjs + + - name: Prepare Release and Plugin tab inspection + id: prepare + env: + RELEASE_VERSION: ${{ inputs.version }} + TARGET_REF: ${{ inputs.target_ref || 'main' }} + DRY_RUN: ${{ inputs.dry_run }} + CREATE_DRAFT_RELEASE: ${{ inputs.create_draft_release }} + PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }} + RELEASE_FAULT_CASE: ${{ inputs.fault_case }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_TOKEN: ${{ github.token }} + 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 }} + run: node .github/scripts/prepare-cli-release.mjs + + - name: Upload release inspection + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: memos-cloud-cli-release-inspection + path: ${{ runner.temp }}/memos-cloud-cli-release-inspection + if-no-files-found: error + retention-days: 14 + + - name: Summarize inspection + if: ${{ always() }} + shell: bash + env: + PREPARE_OUTCOME: ${{ steps.prepare.outcome }} + DRY_RUN: ${{ inputs.dry_run }} + FAULT_CASE: ${{ inputs.fault_case }} + CURRENT_TAG: ${{ steps.prepare.outputs.current_tag }} + PREVIOUS_TAG: ${{ steps.prepare.outputs.previous_tag }} + TARGET_SHA: ${{ steps.prepare.outputs.target_sha }} + DOCS_ACTION: ${{ steps.prepare.outputs.docs_action }} + run: | + { + echo "## MemOS CLI release inspection" + echo + echo "- prepare_outcome: ${PREPARE_OUTCOME}" + echo "- dry_run: ${DRY_RUN}" + echo "- fault_case: ${FAULT_CASE}" + echo "- range: ${PREVIOUS_TAG}...${CURRENT_TAG}" + echo "- target: ${TARGET_SHA}" + echo "- evidence_scope: whole_repository" + echo "- docs_action: ${DOCS_ACTION}" + echo + echo "The artifact contains README.md, github-release-notes.md/release-notes.md, evidence.json, release-notes-draft.json, docs-preview.md/json, quality-report.json, and release-contract.json." + if [ "${PREPARE_OUTCOME}" != "success" ]; then + echo + echo "Preparation failed closed. Review the redacted quality-report.json in the uploaded artifact." + fi + if [ "${DRY_RUN}" = "true" ]; then + echo + echo "No tag, GitHub Release, Docs PR, or deployment was created." + fi + } >> "${GITHUB_STEP_SUMMARY}" + build: + if: ${{ !inputs.dry_run }} + needs: prepare + permissions: + contents: read + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -21,11 +160,12 @@ jobs: - os: windows-2022 build_script: ./scripts/build-binary.ps1 upload_name: windows-x64 - runs-on: ${{ matrix.os }} - steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.target_sha }} + persist-credentials: false - uses: actions/setup-python@v5 with: @@ -45,11 +185,48 @@ jobs: - name: Upload build artifact uses: actions/upload-artifact@v4 with: - name: ${{ matrix.upload_name }} + name: memos-cli-${{ matrix.upload_name }} path: dist/*.tar.gz + if-no-files-found: error + retention-days: 14 + + release: + if: ${{ !inputs.dry_run }} + needs: + - prepare + - build + runs-on: ubuntu-22.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.target_sha }} + fetch-depth: 0 + persist-credentials: true + + - name: Fetch release tags + shell: bash + run: git fetch --tags --force origin - - name: Upload release assets - if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 + - uses: actions/download-artifact@v4 with: - files: dist/*.tar.gz + pattern: memos-cli-* + path: dist + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: memos-cloud-cli-release-inspection + path: release-inspection + + - name: Create or safely resume the tag and GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_TAG: ${{ needs.prepare.outputs.current_tag }} + TARGET_SHA: ${{ needs.prepare.outputs.target_sha }} + RECOVER_EXISTING_RELEASE: ${{ inputs.recover_existing_release }} + RELEASE_VERSION: ${{ inputs.version }} + run: bash .github/scripts/publish-cli-release.sh diff --git a/docs/release-changelog-automation.md b/docs/release-changelog-automation.md new file mode 100644 index 0000000..f27f0a6 --- /dev/null +++ b/docs/release-changelog-automation.md @@ -0,0 +1,366 @@ +# MemOS Cloud CLI release-to-docs automation + +This repository uses a standalone-release variant of the MemOS local-plugin +release flow: + +> No next CLI version has been selected by this automation. As of the +> 2026-07-28 audit, `main` and npm are still `1.0.6`, and the formal repository +> has no tags or GitHub Releases. Merging this automation creates neither a +> version nor a tag. Wait for the CLI owner to choose ``, land +> its actual changes, and update all three version sources before running a +> real dry run. + +1. A release operator runs **MemOS CLI — Release** with `version`, + `target_ref`, `dry_run`, and the draft/recovery safety inputs. +2. A dry run compares the previous SemVer tag with the target commit, requests + three bilingual Plugin changelog candidates from Doc Agent, validates the + selected candidate, and uploads a review artifact. +3. A real run requires the exact confirmation `PUBLISH v`, preserves + the existing Linux and Windows builds, creates the version tag, and creates + a Draft GitHub Release whose body is GitHub-generated `What's Changed`. +4. A Draft Release is mandatory. Direct publication is rejected so a release + owner must review the title, body, tag range, and assets first. Publishing + that draft emits + `release.published`. +5. Doc Agent maps `MemTensor/MemOS-Cloud-CLI` to `memos-cloud-cli`, compares the + complete repository between the previous and current tags, and creates a + MemOS-Docs Draft PR only after its evidence and bilingual quality gates pass. +6. The docs pipeline may proceed through pre and gray. Production remains a + manual decision after the CLI owner reviews gray. + +The GitHub Release body and the website copy intentionally have different +roles. GitHub's body is the public engineering-oriented `What's Changed`. +Doc Agent creates the shorter Chinese and English Plugin tab copy from the +same Git tag range after `release.published`. + +## Verified v1.0.6 baseline + +The authoritative baseline for `v1.0.6` is: + +```text +c18ced54beeb817f6d3f0def1d43eca66da94817 +``` + +Evidence: + +- `npm view @memtensor/memos-cloud-cli@1.0.6 ...` reports + `gitHead=c18ced54beeb817f6d3f0def1d43eca66da94817`. +- npm reports the `1.0.6` publication time as + `2026-07-22T09:22:21.408Z`. +- The commit was created at `2026-07-22T16:45:57+08:00`, before the npm + publication. +- At that commit, `package.json`, `pyproject.toml`, and + `src/memos_cli/__init__.py` all declare version `1.0.6`. +- The commit is reachable from `origin/main`. +- The repository had no remote tags and no GitHub Releases when this baseline + was audited. + +The version-bump commit `b8b722f` is too early: a subsequent build cleanup is +included in the npm package's recorded `gitHead`. The merge commit `73619ff` +is too late and does not match npm's recorded source commit. + +Backfill only the verified tag; do not create a fake historical GitHub Release. +Use this approval flow: + +1. Put the evidence above in the automation PR or a dedicated maintainer issue. +2. Ask a maintainer with write access to explicitly comment: + `APPROVE BACKFILL v1.0.6 c18ced54beeb817f6d3f0def1d43eca66da94817`. +3. The maintainer runs the commands below from a clean checkout. If the remote + tag already exists or points anywhere else, stop and do not force-push. + +```bash +git clone git@github.com:MemTensor/MemOS-Cloud-CLI.git +cd MemOS-Cloud-CLI +git fetch origin main +git ls-remote --tags origin refs/tags/v1.0.6 +git rev-parse --verify c18ced54beeb817f6d3f0def1d43eca66da94817^{commit} +git show --no-patch --format='%H%n%s%n%aI' c18ced54beeb817f6d3f0def1d43eca66da94817 +git tag v1.0.6 c18ced54beeb817f6d3f0def1d43eca66da94817 +git push origin refs/tags/v1.0.6 +git ls-remote --tags origin refs/tags/v1.0.6 +``` + +Do not push this tag automatically. A CLI repository maintainer must review the +evidence above and explicitly approve the one-time remote tag backfill. The +release workflow fails closed while the previous SemVer tag is missing, so it +cannot accidentally describe the entire repository history as the next +release. + +This baseline is a Git release reference. It is unrelated to binary SHA-256 +manifests; this changelog integration does not add checksum generation. + +## Required repository secrets + +The release inspection and exhausted-failure path require these encrypted +repository secrets: + +```text +DOC_AGENT_RELEASE_NOTES_DRAFT_URL +DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN +DOC_AGENT_RELEASE_FAILURE_URL +``` + +The values must be GitHub Actions encrypted secrets. Never put the URL or token +in source, workflow defaults, artifacts, or logs. The failure endpoint is +best-effort: it receives only three exhausted, redacted attempt summaries and +can never hide the original release error. + +Endpoint URLs may use HTTP or HTTPS. The current 106 Doc Agent deployment uses +HTTP, so keep both URLs only in GitHub Actions encrypted secrets and never in +source, workflow defaults, artifacts, or logs. The failure endpoint must use +the same origin as the draft endpoint because both calls share the draft Bearer +token. + +The release continues to use GitHub's short-lived `github.token`. This +changelog-only change does not add npm, OSS, or deployment credentials. +GitHub's generate-notes API requires `contents: write`, so that permission is +limited to the `prepare` and `release` jobs; the workflow default and build job +remain read-only. + +## Required Doc Agent mapping + +The deployed `release_changelog_targets.yaml` must identify the standalone CLI +repository and collect its entire tag range: + +```yaml +sources: + - id: memos-cloud-cli + trigger: github_release + source_repo: MemTensor/MemOS-Cloud-CLI + tag_patterns: + - 'v*' + # The current 106 collector enters evidence collection through + # product_paths. '**' means the complete standalone CLI repository. + product_paths: + - '**' + format: memos_docs_plugin_changelog + renderer_options: + product_title: + zh: MemOS CLI + en: MemOS CLI + docs: + repo: MemTensor/MemOS-Docs + branch: v2 + files: + zh: content/cn/plugin-changelog.yml + en: content/en/plugin-changelog.yml +``` + +There is no CLI subdirectory filter. The `**` compatibility value means +"whole repository"; it does not distinguish MemOS from a local-plugin path. +The mapping must never point to `content/{cn,en}/changelog.yml`, which is +reserved for Highlight updates. + +The GitHub webhook must subscribe to the Release event. A read-only audit on +2026-07-28 confirmed that the formal repository has an active JSON webhook +subscribed to `release`; its creation `ping` was accepted with HTTP 200 and +webhook signature verification is configured on Doc Agent. No real +`release.published` delivery exists yet because this repository still has no +GitHub Releases. The Release-event subscription is present; the current 106 +deployment accepts HTTP webhook transport with HMAC signature verification. +The first real Release must still be held until the deployed mapping/version +preview below passes. + +The same hook currently also subscribes to `push`. That is not required for +this release-to-docs chain; limiting it to `release` is the least-noise option. +It is not a blocker because Doc Agent routes `push` to a separate, filtered +post-merge checker rather than the release pipeline. + +Doc Agent only treats a +non-draft, non-prerelease `release.published` event as the formal docs-sync +entry point. Drafts and prereleases must not create production docs PRs. + +The CLI repository can keep this change in one PR, but the deployed Doc Agent +configuration is an external prerequisite. Before the first production +Release, run a Doc Agent preview/replay with a CLI tag range and require all of +the following: + +```text +handled=true +source_id=memos-cloud-cli +previous_tag and current_tag are the expected SemVer tags +evidence_scope covers the complete repository +requested_candidate_count=3 +missing_required_count=0 +would_create_docs_pr=false during preview +only content/{cn,en}/plugin-changelog.yml would change +``` + +If the deployed service does not return three-candidate selection metadata for +the post-release extraction, deploy the already-reviewed Doc Agent +multi-candidate implementation before enabling the production webhook. Do not +assume that a successful pre-release CLI artifact proves the post-release +service version is current. + +## Dry-run procedure + +Before the first real release, after the CLI owner has selected the actual next +version: + +1. Ensure the verified `v1.0.6` tag is present remotely. +2. Prepare the intended release commit and update all three version sources: + `package.json`, `pyproject.toml`, and `src/memos_cli/__init__.py`. +3. Run **MemOS CLI — Release** and replace `` with the exact + owner-approved version. In the workflow branch selector, choose the + protected default branch `main`; use `target_ref` below to inspect another + branch or commit: + +```text +version: +target_ref: the branch or commit being inspected +dry_run: true +create_draft_release: true +recover_existing_release: false +fault_case: none +publish_confirmation: empty +``` + +The `memos-cloud-cli-release-inspection` artifact contains: + +- `README.md`: operator-facing decision summary, tag status, quality result, + and zero-side-effect statement. +- `github-release-notes.md`: preview of the public `What's Changed` body. +- `release-notes.md`: compatibility alias of the same public body preview. +- `evidence.json`: redacted whole-repository commits, changed files, PR + references, diff statistics, and bounded patch excerpts. +- `release-notes-draft.json`: accepted bilingual Plugin changelog items with + their internal `source_refs`, candidate selection, and validation result. +- `docs-preview.md` and `docs-preview.json`: the bilingual Plugin tab preview + with internal `source_refs`. +- `quality-report.json`: candidate selection, coverage, validation, and repair + attempts. +- `release-contract.json`: trigger, target files, evidence scope, and proof that + the workflow's dry-run mutation flags are disabled. It also records the + mandatory Draft-first/manual-publish policy. + +The evidence, preview, report, and contract all use +`product_paths: ["**"]`. For this standalone repository, that value means the +complete CLI tag range and keeps the workflow artifact aligned with the +deployed Doc Agent mapping. + +A dry run does not build release archives or create a tag, GitHub Release, +Docs PR, or deployment. + +For remote acceptance, also verify that `refs/tags/v` is still absent +and that the GitHub Releases API returns no matching Release. The artifact +records the contract; these read-only GitHub checks prove external state. + +After the normal dry run succeeds, the release owner can run a dry-run-only +fault matrix. Each case corrupts only the first candidate round; validation +must reject it, send the exact issue report to the repair request, and accept a +valid repaired response: + +```text +mixed_language +missing_source_refs +invalid_source_ref +missing_important_commit +thirteen_items +too_long +``` + +Every fault run must keep `dry_run=true`. A live run with any `fault_case` +other than `none` fails before build, tag, or Release mutation. + +If the preparation script starts and then fails, the workflow still uploads +the same artifact schema, including `README.md`, the public notes files, +`release-notes-draft.json`, evidence, previews, report, and contract. +`quality-report.json` has `ok=false`, the phase, and a redacted error; +`docs-preview.json` has `docs_action=blocked_by_quality_gate`. This makes a +failed remote run diagnosable without exposing endpoint or token values. + +Pull requests and pushes to `main` also run +**MemOS CLI Release — Pre/Post-Merge Checks**. It exports +`memos-cloud-cli-release-contract-check`, a synthetic offline +`v99.99.98...v99.99.99` artifact. This deliberately impossible production +version proves the artifact schema, quality gates, +source references, and zero-side-effect contract without requiring production +Doc Agent secrets. It is a mechanism check, not a preview of the next real CLI +release. The same check runs actionlint before Node tests so invalid workflow +syntax, expressions, action inputs, or job definitions fail on the PR rather +than at release time. It is read-only, does not call the live release workflow, +and has no access to Doc Agent secrets. + +## Real release procedure + +After the dry-run artifact is approved and the release commit is on `main`, +run the owner-approved version: + +```text +version: +target_ref: main +dry_run: false +create_draft_release: true +recover_existing_release: false +fault_case: none +publish_confirmation: PUBLISH v +``` + +The workflow: + +- refuses live releases from a ref other than `main`; +- refuses live dispatches whose workflow ref or target commit is not the + current protected default branch; +- refuses `create_draft_release=false`; every live run must stop at a Draft + Release for manual review; +- refuses a mismatched version file; +- refuses a missing previous SemVer tag; +- refuses to move an existing tag to another commit; +- rechecks remote `refs/heads/main` immediately before creating or updating a + Draft Release, so a main branch advance during build requires a new dry run; +- builds only the two targets already supported by this repository; +- creates or safely resumes a Draft Release; +- leaves an already-published Release unchanged. + +Evidence curation also ignores changes that are clearly release machinery +rather than CLI behavior, including `fix(ci):` / `fix(build):` scopes and +commits whose changed files are limited to workflow, test, or documentation +paths. These commits remain visible in raw evidence but do not force a Plugin +tab entry. + +If a prior run pushed the correct tag but failed before creating the GitHub +Release, the normal rerun stops. After confirming that the tag points to the +intended commit and that no Release exists, rerun once with +`recover_existing_release=true`. This explicit recovery prevents an old tag +from being backfilled as a new Release by accident. + +Review the Draft Release and its assets, then publish it manually. That manual +publication is what sends `release.published` to Doc Agent. + +## Post-release quality and recovery + +Doc Agent must fail closed before opening a Docs PR when any of these conditions +occur: + +- the previous tag cannot be determined; +- one or more of the three independent candidate requests fails to return a + candidate for local scoring; +- a referenced commit or PR does not exist in the tag-range evidence; +- an important feature, fix, performance, or refactor commit is uncovered; +- Chinese or English text is missing or mixed into the wrong language; +- content contains an internal URL or credential-like value; +- copy merely says that a feature/problem/performance was added/fixed/improved + without explaining concrete CLI impact; +- there are more than 12 entries; +- a Chinese item exceeds 180 characters or an English item exceeds 220; +- three validation/repair attempts are exhausted. + +Repeated delivery of the same release must be idempotent. If the CLI version +already exists in `plugin-changelog.yml`, Doc Agent should report an +already-present result instead of appending a duplicate. + +If gray review finds incorrect copy, fix the MemOS-Docs Draft PR or create a +corrective Docs PR, run pre and gray again, and keep production blocked until +the CLI owner approves it. + +## Explicit non-goals + +This integration does not: + +- add macOS or a four-platform build matrix; +- add SHA-256 or checksum manifests; +- change npm publishing; +- change OSS uploads or the npm postinstall download path; +- put Doc Agent's generated website copy into the GitHub Release body; +- allow GitHub Release publication to bypass the exact confirmation phrase; +- automate production deployment. diff --git a/docs/release-changelog-operator-guide-zh.md b/docs/release-changelog-operator-guide-zh.md new file mode 100644 index 0000000..ad3c77c --- /dev/null +++ b/docs/release-changelog-operator-guide-zh.md @@ -0,0 +1,589 @@ +# MemOS Cloud CLI 更新日志自动化使用说明 + +更新时间:2026-07-28 + +本文面向 CLI 发布人员、Doc Agent 维护人员和文档审核人员,说明 +`MemTensor/MemOS-Cloud-CLI` 如何把一次正常的 GitHub Release 转换为官网 +Plugin tab 的中英文更新日志。 + +这次改造只解决“从 CLI Release 提取和发布更新日志”。它不增加四平台构建、 +SHA-256 manifest、npm 发布或 OSS 上传,也不会替 CLI 负责人决定下一个版本。 + +--- + +## 1. 先看结论 + +完整链路是: + +```text +CLI 负责人确定 并完成真实代码、版本号更新 +-> Actions 手动运行 dry_run=true +-> 审核 GitHub Release Notes 和中英文 Plugin changelog 预览 +-> Actions 手动运行 dry_run=false,并输入精确确认词 +-> 在 main 当前提交创建 v Tag +-> 沿用仓库现有 Linux/Windows 构建并创建 Draft GitHub Release +-> CLI 负责人审核 Draft 的 Tag、What's Changed 和资产 +-> 人工点击 Publish +-> GitHub 发送 release.published webhook +-> Doc Agent 按相邻 Tag 收集整个 CLI 仓库的变更 +-> 生成 3 组候选,自动选择、校验,必要时最多修复 3 轮 +-> 创建 MemOS-Docs Draft PR +-> 文档 PR 审核、合并 +-> 发布 pre +-> 等待 360 秒 +-> 发布 gray +-> CLI 模块程序员检查灰度内容 +-> 人工确认后才允许 production +``` + +GitHub Actions 仍然存在,而且承担版本输入校验、dry-run、现有二平台构建、 +Tag 和 Draft Release 创建。Doc Agent 不替代 Actions;它从 +`release.published` 开始接管官网文案链路。 + +--- + +## 2. GitHub Release 里分别是谁写什么 + +有两份不同用途的文案: + +1. **GitHub Release body** + - 由 GitHub Generate Release Notes API 根据本次 Tag 和上一个 Tag 生成。 + - 内容定位是工程侧的 `What's Changed`。 + - dry-run artifact 里的 `github-release-notes.md` 就是发布前预览。 + - 发布人员在 Draft Release 阶段进行最后人工检查。 + +2. **官网 Plugin tab 更新日志** + - 由 Doc Agent 根据同一段 Git Tag range 的真实 Git 证据生成。 + - 同时生成中文和英文。 + - 先生成 3 组候选,再做确定性选择、来源覆盖校验和最多 3 轮修复。 + - 最终通过 MemOS-Docs Draft PR 进入 pre、gray 和 production。 + +不要把 Doc Agent 生成的官网短文案直接复制成 GitHub Release body,也不要把 +GitHub 自动生成的全部 commit 列表原样放进 Plugin tab。 + +--- + +## 3. 当前真实状态 + +截至 2026-07-28 的只读审核结果: + +- 正式仓库 `main` 的三个版本源均为 `1.0.6`。 +- npm 最新正式版本为 `1.0.6`。 +- 正式仓库还没有远程 Git Tag。 +- 正式仓库还没有 GitHub Release。 +- 本自动化没有选择或创造任何 ``。 +- Release webhook 已启用 Release 事件;创建时的 ping 返回成功。当前部署接受 + HTTP 的 106 Doc Agent endpoint。 +- Doc Agent 已有 `memos-cloud-cli` 的整仓 mapping 设计。 + +因此,合并自动化以后也不能立刻随意填写一个新版本。真实 dry-run 必须等待: + +- CLI 负责人明确下一个版本; +- 下一个版本的真实代码已进入目标分支; +- 三个版本源都改成同一个 ``; +- `v1.0.6` 基线 Tag 经正式仓库维护者批准并补齐。 +- webhook 和 Doc Agent Actions endpoint 已配置到可访问的 106 endpoint;当前接受 HTTP。 + +--- + +## 4. 一次性补齐 v1.0.6 基线 + +经过 npm `gitHead`、提交时间、版本文件和 main 可达性联合校验, +`v1.0.6` 的基线提交是: + +```text +c18ced54beeb817f6d3f0def1d43eca66da94817 +``` + +这里只允许补一个可信比较起点,不允许补造历史 GitHub Release。建议按下面步骤让 +维护者批准并执行: + +1. 在本 PR 或单独 Issue 里贴出上面的证据。 +2. 请拥有 `MemTensor/MemOS-Cloud-CLI` 写权限的维护者评论确认: + `APPROVE BACKFILL v1.0.6 c18ced54beeb817f6d3f0def1d43eca66da94817`。 +3. 维护者从干净 checkout 执行下面命令。第一个 `git ls-remote` 必须没有输出; + 如果已经有 `v1.0.6`,不要覆盖、不要 force-push,先回到 PR 里确认。 + +```bash +git clone git@github.com:MemTensor/MemOS-Cloud-CLI.git +cd MemOS-Cloud-CLI +git fetch origin main +git ls-remote --tags origin refs/tags/v1.0.6 +git rev-parse --verify c18ced54beeb817f6d3f0def1d43eca66da94817^{commit} +git show --no-patch --format='%H%n%s%n%aI' c18ced54beeb817f6d3f0def1d43eca66da94817 +git tag v1.0.6 c18ced54beeb817f6d3f0def1d43eca66da94817 +git push origin refs/tags/v1.0.6 +git ls-remote --tags origin refs/tags/v1.0.6 +``` + +原因是自动提取需要一个可信的比较 +起点;如果没有基线,下一次会把过多历史变化误认为一个版本的更新。 + +这个 Git Tag 基线与二进制 checksum 无关。本链路不生成 SHA-256 manifest。 + +--- + +## 5. 合并前必须通过的检查 + +PR 和合并到 `main` 的变更都会运行: + +```text +MemOS CLI Release — Pre/Post-Merge Checks +``` + +该检查具有以下特性: + +- 仓库权限只有 `contents: read`; +- 不读取 Doc Agent Secret; +- 不调用真实 Release workflow; +- 不创建 Tag、Release、Docs PR 或部署; +- 先用 actionlint 检查 workflow 语法、表达式、Action 参数和 Job 定义; +- 再运行 Node 测试和脚本语法检查; +- 导出 `v99.99.98...v99.99.99` 的合成离线 artifact。 + +`99.99.x` 只用于证明机制,绝不是下一次正式版本。看到这个 artifact 时,审核者 +只检查格式、质量门禁和零副作用,不检查它是不是一份真实 Release 文案。 + +这一设计也避免了常见的 GitHub Actions 低级错误:只看 YAML 文本没有报错, +但因为表达式、Action 输入、可复用 workflow 权限或调用接口不兼容,运行在 +创建 Job 之前就 `startup_failure`。 + +当前 CLI 检查 workflow 不使用 `workflow_call`,也不会从只读 CI 调用拥有写权限 +的 Release workflow。测试会持续禁止这两种链路被意外混合。 + +--- + +## 6. 正式仓库需要的 Secret + +在正式仓库的 **Settings -> Secrets and variables -> Actions** 中配置: + +```text +DOC_AGENT_RELEASE_NOTES_DRAFT_URL +DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN +DOC_AGENT_RELEASE_FAILURE_URL +``` + +注意: + +- 三个值都必须使用 GitHub Actions encrypted secret。 +- 两个 URL 可以使用 HTTP 或 HTTPS;当前 106 部署使用 HTTP。 +- `DOC_AGENT_RELEASE_FAILURE_URL` 必须和 `DOC_AGENT_RELEASE_NOTES_DRAFT_URL` + 使用同一个 origin,避免把同一个 Bearer token 发到错误 host。 +- 不要写进 workflow、代码、Issue、PR 描述或 artifact。 +- 不要在日志里打印 URL 或 Token。 +- dry-run 也会调用 Doc Agent 生成候选,因此同样需要前两个 Secret。 +- 失败上报只发送经过脱敏、数量受限的失败摘要,不发送完整 Secret。 + +`github.token` 由 GitHub Actions 自动提供,不需要手工创建 PAT。 + +GitHub-hosted runner 会把 Bearer Token 和发布证据发送给 Draft endpoint。当前部署 +明确接受 HTTP,因此管理员应确认 URL 只写在 GitHub Actions encrypted secrets 中, +不要写进代码、PR、日志或 artifact。GitHub webhook 依靠 HMAC 签名验证来源和完整性; +HTTP 能工作,但传输内容不是加密的。 + +--- + +## 7. Doc Agent mapping 必须满足什么 + +正式配置需要把独立 CLI 仓库映射为: + +```yaml +sources: + - id: memos-cloud-cli + trigger: github_release + source_repo: MemTensor/MemOS-Cloud-CLI + tag_patterns: + - 'v*' + product_paths: + - '**' + format: memos_docs_plugin_changelog + renderer_options: + product_title: + zh: MemOS CLI + en: MemOS CLI + docs: + repo: MemTensor/MemOS-Docs + branch: v2 + files: + zh: content/cn/plugin-changelog.yml + en: content/en/plugin-changelog.yml +``` + +这里的 `product_paths: ['**']` 表示“整个独立 CLI 仓库”,不是四平台,也不是 +MemOS 主仓中的子目录过滤。 + +必须写入: + +```text +content/cn/plugin-changelog.yml +content/en/plugin-changelog.yml +``` + +不能写入 Highlight 使用的: + +```text +content/cn/changelog.yml +content/en/changelog.yml +``` + +Doc Agent 的配置和实现还必须部署到实际运行实例。只在某台机器的未提交工作区 +修改 mapping,不等于正式链路已经持久化。第一次真实 Release 前需要由 Doc +Agent 维护者确认:配置已纳入其受控版本并部署,服务重启后不会丢失。 + +--- + +## 8. 第一次真实 dry-run 怎么操作 + +这里的“真实”表示使用正式仓库、真实候选版本和真实 Doc Agent,但仍然没有任何 +发布副作用。 + +### 8.1 前置条件 + +先确认: + +- `v1.0.6` 基线 Tag 已由维护者批准并存在于 origin。 +- CLI 负责人已经选择 ``。 +- `package.json`、`pyproject.toml`、`src/memos_cli/__init__.py` 的版本完全一致。 +- 目标分支/提交包含准备发布的真实代码。 +- 三个 Doc Agent Secret 均已配置。 +- Doc Agent 正式 mapping 已部署。 +- Doc Agent Actions endpoint 和 Release webhook 都已配置到可访问的 106 endpoint。 + +### 8.2 Actions 输入 + +打开 **Actions -> MemOS CLI — Release -> Run workflow**: + +```text +Run workflow from: main +version: +target_ref: 要检查的分支名或 commit SHA +dry_run: true +create_draft_release: true +recover_existing_release: false +fault_case: none +publish_confirmation: 留空 +``` + +版本输入不带前缀 `v`。例如负责人批准的是 `2.0.0`,填写 `2.0.0`,不要填写 +`v2.0.0`。 + +workflow 的分支选择器必须选受保护的默认分支 `main`。如果要预览尚未合并的 +候选分支,把候选分支名写入 `target_ref`;不要切换 workflow 自身的运行分支。 +这样实际读取 Secret 和执行检查的脚本始终来自受信任的默认分支。 + +### 8.3 dry-run 必须检查的 artifact + +下载: + +```text +memos-cloud-cli-release-inspection +``` + +至少检查: + +- `README.md` + - `quality_ok: true` + - `publish_blocked: false` + - previous/current Tag 正确 + - target SHA 正确 +- `github-release-notes.md` + - 比较范围正确 + - 没有把旧版本历史混入本次 + - 没有 Secret、内网 URL 或无关 CI 噪声 +- `evidence.json` + - `evidence_scope: whole_repository` + - `product_paths: ["**"]` + - 用户可感知的重要提交没有遗漏 +- `release-notes-draft.json` + - 有 3 组候选的选择记录 + - 每条官网文案都有真实 `source_refs` +- `docs-preview.md` + - 中文只出现中文正文 + - 英文只出现英文正文 + - 文案表达具体 CLI 影响,而不是“优化了功能”一类空话 +- `quality-report.json` + - `ok: true` + - `missing_required_count: 0` + - 修复轮次不超过上限 +- `release-contract.json` + - dry-run 的所有副作用均为 `false` + - `direct_publish_allowed: false` + +远端还应保持: + +- 不存在 `v`; +- 不存在对应 GitHub Release; +- 不存在由这次 dry-run 创建的 Docs PR; +- 没有 pre、gray 或 production 部署。 + +如果上述任何一点不成立,不进入真实发布。 + +--- + +## 9. 是否需要跑故障注入 + +正常 dry-run 通过后,可以在正式发布前额外运行质量门禁故障注入: + +```text +mixed_language +missing_source_refs +invalid_source_ref +missing_important_commit +thirteen_items +too_long +``` + +每次都必须保持: + +```text +dry_run: true +``` + +预期行为是第一轮候选被故意破坏,校验器指出精确问题,Doc Agent 收到修复请求, +后续合法候选通过。如果超过 3 轮仍不合法,流程必须失败关闭。 + +任何 `dry_run=false` 与非 `none` 的 fault case 组合都会在构建、Tag 和 Release +之前被拒绝。 + +--- + +## 10. 真实发布怎么操作 + +只有 dry-run artifact 审核通过、发布提交已经在受保护的默认分支 `main` 后, +才能运行: + +```text +version: +target_ref: main +dry_run: false +create_draft_release: true +recover_existing_release: false +fault_case: none +publish_confirmation: PUBLISH v +``` + +确认词必须逐字符匹配。例如版本是 `2.0.0`: + +```text +PUBLISH v2.0.0 +``` + +workflow 会: + +1. 再次检查三个版本源。 +2. 检查运行来源和目标 SHA 都是当前默认分支。 +3. 查找相邻的上一个 SemVer Tag。 +4. 生成并校验 Release/官网文案。 +5. 构建仓库原本支持的 Linux x64 和 Windows x64 资产。 +6. 创建或更新 Draft 前重新读取远端 `refs/heads/main`;如果 main 在构建期间前进, + 流程会失败并要求重新 dry-run。 +7. 在目标 SHA 创建 `v` Tag。 +8. 创建 Draft GitHub Release。 + +它不会自动 Publish。发布人员必须打开 Draft,人工检查: + +- Tag 是否指向正确 main commit; +- 标题是否正确; +- `What's Changed` 是否只覆盖预期 Tag range; +- Linux/Windows 资产是否齐全; +- 没有内部信息或明显错误。 + +检查完成后人工点击 **Publish release**。此操作才会触发正式 +`release.published` webhook。 + +如果发布的是 SemVer prerelease,workflow 会把 GitHub Release 标成 +prerelease;当前正式 Docs mapping 会忽略 prerelease,不应期待它自动创建正式 +官网更新日志。 + +--- + +## 11. Tag 已创建但 Release 失败时怎么办 + +正常重跑不会自动把一个旧 Tag 包装成新 Release。 + +如果确认: + +- Tag 指向本次预期的目标 SHA; +- GitHub 上确实不存在对应 Release; +- 这是同一次失败运行的恢复; + +才使用: + +```text +recover_existing_release: true +``` + +如果 Tag 指向错误提交,流程会失败关闭,不会移动 Tag。此时不要自行删除或强推 +Tag,应由正式仓库维护者先审计并决定恢复方式。 + +如果 Draft Release 已存在,重跑会校验 Draft/prerelease 状态,更新文案并安全地 +补齐资产。若 Release 已经发布,流程保持其不变。 + +--- + +## 12. Webhook 后应该看到什么 + +人工发布正式、非 prerelease Release 后: + +1. GitHub Webhook 页面出现 `release` delivery。 +2. delivery 的 action 为 `published`。 +3. Doc Agent 返回已识别 `memos-cloud-cli`。 +4. previous/current Tag 与本次发布一致。 +5. evidence 范围是整个 CLI 仓库。 +6. 三候选选择和来源覆盖校验通过。 +7. 创建一个 MemOS-Docs Draft PR。 +8. PR 只修改中英文 `plugin-changelog.yml`。 + +以下任一情况都应阻止 Docs PR: + +- 找不到上一个 Tag; +- 任何候选请求缺失,无法完成本地选择; +- `source_refs` 不属于真实 Tag range; +- 用户可感知的重要提交没有覆盖; +- 只有 CI、测试或文档变更,却生成了面向用户的更新条目; +- 中文、英文混写; +- 文案包含内部 URL 或凭据特征; +- 文案只有空泛的“新增/修复/优化”; +- 条目超过 12 条或单条过长; +- 3 轮修复仍然失败。 + +重复投递同一个 Release 必须幂等:已有同版本内容时,不得重复追加。 + +--- + +## 13. 文档 PR、pre、gray 和 production + +MemOS-Docs Draft PR 创建后: + +1. 核对中英文版本、分类、条目数量和产品名称 `MemOS CLI`。 +2. 核对每条文案能回溯到 evidence 中的 commit/PR。 +3. 确认没有改动 Highlight changelog 或无关文件。 +4. 通过检查后合并 PR。 +5. 发布 pre。 +6. 等待 360 秒。 +7. 发布 gray。 +8. 由对应 CLI 模块程序员在灰度页面逐条检查。 +9. 只有人工确认通过后才发布 production。 + +自动化不会代替第 8、9 步。 + +如果灰度内容有误,修正 MemOS-Docs Draft PR 或补一个纠正文档 PR,重新走 pre +和 gray;在修复确认前保持 production 阻塞。 + +--- + +## 14. 常见低级错误和处理方式 + +### 14.1 Actions 一启动就失败,没有 Job 日志 + +优先检查: + +- workflow YAML 或 `${{ }}` 表达式是否合法; +- Action 的输入名是否存在; +- 是否把有写权限/Secret 的 release workflow 错当成只读 reusable workflow; +- caller 与 `workflow_call` 的 permissions/secrets 是否兼容。 + +本实现会在 PR 上先运行 actionlint,并禁止只读检查 workflow 调用正式 Release +workflow。不要为了复用几行步骤而重新把两者耦合。 + +### 14.2 dry-run 提示找不到上一个 Tag + +确认维护者是否已批准并补齐 `v1.0.6` 基线。不要绕过门禁,也不要让脚本退化为 +“从仓库第一条提交开始比较”。 + +### 14.3 版本文件不一致 + +同时检查: + +```text +package.json +pyproject.toml +src/memos_cli/__init__.py +``` + +三处必须与 Actions 的 `version` 输入完全一致。 + +### 14.4 dry-run 意外生成 Tag 或 Release + +这是阻断级问题。立即停止真实发布,保存 run URL 和 artifact,确认实际输入 +`dry_run=true`,并检查 `release-contract.json`。当前实现的 build/release Job +都由 `!inputs.dry_run` 限制,任何修改都不能去掉该边界。 + +### 14.5 Release 发布后没有 Docs PR + +按顺序检查: + +- 是否仍是 Draft; +- 是否为 prerelease; +- webhook 是否订阅 Release; +- delivery 是否是 `release.published`; +- Doc Agent 是否识别 `source_id=memos-cloud-cli`; +- 正式 mapping 是否已经部署而非只停留在未提交工作区; +- Tag range 是否完整; +- 质量报告是否失败关闭。 + +不要通过重复 Publish 或手工重发大量 webhook 盲目重试。先确定失败阶段,再对同一 +delivery 做幂等 replay/修复。 + +### 14.6 官网把 CI 变更写成了产品能力 + +每条候选不仅要引用真实 commit,还必须至少引用一个“用户可感知”的 required +source ref。只引用 workflow、测试或发布自动化提交的条目会被拒绝。 + +--- + +## 15. 发布人员最终验收单 + +合并自动化 PR 前: + +- [ ] actionlint 和全部 Node 测试通过。 +- [ ] 检查 workflow 没有 Secret、权限或 reusable call 启动问题。 +- [ ] 合成 artifact 明确标记为 `synthetic_contract_fixture`。 +- [ ] 没有创建 Tag、Release、Docs PR 或部署。 + +第一次 dry-run 前: + +- [ ] CLI 负责人已经选择 ``。 +- [ ] 三个版本源一致。 +- [ ] `v1.0.6` 基线 Tag 经维护者批准并存在。 +- [ ] 三个 repository Secret 已配置。 +- [ ] Release webhook 可用。 +- [ ] Doc Agent mapping 已持久化并部署。 + +真实发布前: + +- [ ] dry-run 的 Tag range 和 target SHA 正确。 +- [ ] `quality-report.json` 为通过。 +- [ ] 中英文预览内容具体、准确、来源完整。 +- [ ] 远端仍没有候选 Tag 和 Release。 +- [ ] 发布提交已在当前 `main`。 +- [ ] 精确确认词由发布负责人本人输入。 + +Publish Draft 前: + +- [ ] Draft 标题、Tag、`What's Changed` 正确。 +- [ ] Linux/Windows 资产符合仓库现有发布标准。 +- [ ] 确认这次 Release 应当触发官网正式更新。 + +进入 production 前: + +- [ ] MemOS-Docs PR 只改正确的 Plugin changelog 文件。 +- [ ] pre 发布正常。 +- [ ] 已等待 360 秒。 +- [ ] gray 页面由 CLI 模块程序员检查通过。 +- [ ] production 获得人工确认。 + +--- + +## 16. 本次改造明确不做什么 + +- 不替 CLI 负责人决定版本号。 +- 不凭空创建未批准的 Tag。 +- 不增加 macOS 或四平台矩阵。 +- 不增加 SHA-256/checksum manifest。 +- 不改变 npm 发布。 +- 不改变 OSS 上传或 npm postinstall 下载逻辑。 +- 不在 dry-run 中创建 GitHub Release。 +- 不允许真实运行直接发布非 Draft Release。 +- 不允许 Doc Agent 绕过 MemOS-Docs PR 和灰度检查。 +- 不自动发布 production。