From 9ec86742c0562f760c3897c890fd42226e5ba4da Mon Sep 17 00:00:00 2001 From: MLittleprince Date: Fri, 31 Jul 2026 14:41:21 +0800 Subject: [PATCH 1/2] ci: add MemOS release publish automation --- .../draft-local-plugin-release-notes.mjs | 4 +- .github/scripts/prepare-memos-release.mjs | 1582 +++++++++++++++++ .../scripts/prepare-memos-release.test.mjs | 1028 +++++++++++ .../workflows/memos-local-plugin-publish.yml | 38 +- .../memos-release-post-merge-dry-run.yml | 139 ++ .../memos-release-pre-merge-dry-run.yml | 137 ++ .github/workflows/memos-release-publish.yml | 221 +++ 7 files changed, 3145 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/prepare-memos-release.mjs create mode 100644 .github/scripts/prepare-memos-release.test.mjs create mode 100644 .github/workflows/memos-release-post-merge-dry-run.yml create mode 100644 .github/workflows/memos-release-pre-merge-dry-run.yml create mode 100644 .github/workflows/memos-release-publish.yml diff --git a/.github/scripts/draft-local-plugin-release-notes.mjs b/.github/scripts/draft-local-plugin-release-notes.mjs index b4a593659..94046b024 100644 --- a/.github/scripts/draft-local-plugin-release-notes.mjs +++ b/.github/scripts/draft-local-plugin-release-notes.mjs @@ -8,8 +8,8 @@ import { pathToFileURL } from "node:url"; const PRODUCT_PATH = "apps/memos-local-plugin"; const PRODUCT_ID = "openclaw-local-plugin"; const PRODUCT_TITLE = { - zh: "OpenClaw 本地插件", - en: "OpenClaw Local Plugin", + zh: "MemOS 本地插件", + en: "MemOS Local Plugin", }; export const RELEASE_NOTE_GUIDANCE = { category_policy: { diff --git a/.github/scripts/prepare-memos-release.mjs b/.github/scripts/prepare-memos-release.mjs new file mode 100644 index 000000000..0ba90c5e4 --- /dev/null +++ b/.github/scripts/prepare-memos-release.mjs @@ -0,0 +1,1582 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; + +export const PRODUCT_ID = "openclaw-local-plugin"; +export const PRODUCT_PATH = "apps/memos-local-plugin"; +export const PRODUCT_PATHS = [`${PRODUCT_PATH}/**`]; +export const PRODUCT_TITLE = { + zh: "MemOS 本地插件", + en: "MemOS Local Plugin", +}; +export const RELEASE_CATEGORY_ORDER = ["Added", "Improved", "Fixed"]; +export const RELEASE_TO_DOC_CATEGORY = { + Added: "New Features", + Improved: "Improvements", + Fixed: "Bug Fixes", +}; +export const MAX_REPAIR_ATTEMPTS = 3; +export const MAX_DRAFT_ATTEMPTS = MAX_REPAIR_ATTEMPTS + 1; +const MAX_RELEASE_ITEMS = 12; +const MAX_TEXT_CN_CHARS = 180; +const MAX_TEXT_EN_CHARS = 220; +const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/; +const CJK_GLOBAL_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/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._~+/=-]+)/g; +const INTERNAL_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}|106\.15\.\d{1,3}\.\d{1,3})[^\s"'<>)]*/g; + +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 MemOS Release body as GitHub-generated whole-repo What's Changed notes.", + }, + { + source: "github-generate-notes-api", + url: "https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28", + applied_as: + "Generate preview-only MemOS Release notes with previous_tag_name before creating a tag or GitHub Release.", + }, + { + source: "keep-a-changelog", + url: "https://keepachangelog.com/en/1.1.0/", + applied_as: + "Write Plugin tab entries for humans, grouped by Added, Improved, and Fixed instead of dumping commits.", + }, + { + source: "conventional-commits", + url: "https://www.conventionalcommits.org/en/v1.0.0/", + applied_as: + "Use commit type and scope as deterministic hints while requiring real product-path evidence.", + }, + { + source: "release-please", + url: "https://github.com/googleapis/release-please", + applied_as: + "Treat feat, fix, perf, and refactor commits as releasable units and filter chore/docs/test noise.", + }, +]; + +function fail(message) { + throw new Error(String(message)); +} + +function warn(message) { + console.error(`::warning::${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 ""; + } +} + +export function redact(value) { + return String(value ?? "") + .replace(TOKEN_RE, "[REDACTED_TOKEN]") + .replace(INTERNAL_URL_RE, "[REDACTED_INTERNAL_URL]") + .replace(/([?&](?:token|access_token|secret|signature|service_id)=)[^&\s"')]+/gi, "$1[REDACTED]"); +} + +export function cleanVersion(raw) { + const value = String(raw || "").trim(); + if (!value) return ""; + if (value.startsWith("v")) fail("version input must not include a leading v."); + return value; +} + +export function displayVersion(raw) { + const value = cleanVersion(raw); + return value ? `v${value}` : ""; +} + +export function cleanLocalPluginVersion(raw, label = "local_plugin_version") { + const value = String(raw || "").trim(); + if (!value) fail(`${label} is required.`); + if (value.startsWith("v")) fail(`${label} must not include a leading v.`); + if (!parseSemver(value)) fail(`${label} must be a valid semver version, for example 2.0.12.`); + 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.-]+)?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ? match[4].split(".") : [], + }; +} + +function compareIdentifier(a, b) { + const aNum = /^\d+$/.test(a); + const bNum = /^\d+$/.test(b); + if (aNum && bNum) return Number(a) - Number(b); + if (aNum) return -1; + if (bNum) return 1; + return a.localeCompare(b); +} + +export function compareSemver(a, b) { + const av = parseSemver(a); + const bv = parseSemver(b); + if (!av || !bv) return String(a).localeCompare(String(b)); + for (const key of ["major", "minor", "patch"]) { + if (av[key] !== bv[key]) return av[key] - bv[key]; + } + if (av.prerelease.length === 0 && bv.prerelease.length === 0) return 0; + if (av.prerelease.length === 0) return 1; + if (bv.prerelease.length === 0) return -1; + const length = Math.max(av.prerelease.length, bv.prerelease.length); + for (let index = 0; index < length; index += 1) { + if (av.prerelease[index] === undefined) return -1; + if (bv.prerelease[index] === undefined) return 1; + const order = compareIdentifier(av.prerelease[index], bv.prerelease[index]); + if (order !== 0) return order; + } + return 0; +} + +export function incrementPatchVersion(raw) { + const version = cleanLocalPluginVersion(raw, "local plugin version to auto-increment"); + const parsed = parseSemver(version); + if (!parsed) fail(`Cannot auto-increment invalid local plugin version: ${version}`); + if (parsed.prerelease.length) { + fail( + `Cannot auto-increment prerelease local plugin version ${displayVersion(version)} automatically. Provide an explicit local_plugin_version guard after release-owner review.`, + ); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +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 validateReleaseTarget({ dryRun, targetRef }) { + if (String(dryRun) === "true") return; + const value = String(targetRef || "main").trim(); + if (value !== "main") { + fail("dry_run=false requires target_ref to be exactly main."); + } +} + +export function findPreviousMemOSTag(targetVersion, currentTag, tags) { + const target = cleanVersion(targetVersion); + const targetParsed = parseSemver(target); + if (!targetParsed) fail(`Invalid semver version: ${targetVersion}`); + const allowPrerelease = targetParsed.prerelease.length > 0; + return tags + .map((tag) => String(tag || "").trim()) + .filter((tag) => /^v\d+\.\d+\.\d+/.test(tag)) + .filter((tag) => tag !== currentTag) + .map((tag) => ({ tag, version: tag.slice(1), parsed: parseSemver(tag) })) + .filter((item) => item.parsed) + .filter((item) => allowPrerelease || item.parsed.prerelease.length === 0) + .filter((item) => compareSemver(item.version, target) < 0) + .sort((a, b) => compareSemver(b.version, a.version))[0]?.tag || ""; +} + +function listTags() { + return tryGit(["tag", "--list", "v*"]) + .split("\n") + .map((tag) => tag.trim()) + .filter(Boolean); +} + +function resolveRef(ref) { + const value = String(ref || "HEAD").trim() || "HEAD"; + 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}`); +} + +export function existingReleaseTagState(currentTag, targetSha) { + const tagSha = tryGit(["rev-parse", "--verify", `refs/tags/${currentTag}^{commit}`]); + const target = String(targetSha || "").trim(); + if (!tagSha) { + return { + status: "absent", + exists: false, + tag_sha: "", + target_sha: target, + publish_blocked: false, + message: `No existing ${currentTag} tag was found; the publish workflow can create it on the target commit.`, + }; + } + const matchesTarget = tagSha === target; + return { + status: matchesTarget ? "matches_target" : "conflicts_target", + exists: true, + tag_sha: tagSha, + short_tag_sha: tagSha.slice(0, 8), + target_sha: target, + short_target_sha: target.slice(0, 8), + publish_blocked: !matchesTarget, + message: matchesTarget + ? `Existing ${currentTag} tag already points to the target commit; the publish workflow can reuse it.` + : `Existing ${currentTag} tag points to ${tagSha.slice(0, 8)}, but the release target is ${target.slice(0, 8)}. Delete or recreate the manual tag after release-owner review, then rerun.`, + }; +} + +function gitShowJson(ref, path) { + try { + return JSON.parse(sh(["show", `${ref}:${path}`])); + } catch { + return {}; + } +} + +function parseLines(text) { + return String(text || "") + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); +} + +export function sourceRefsFromText(text) { + const refs = new Set(); + const value = String(text || ""); + const pattern = /\(#(\d+)\)|\b(?:PR|Fix(?:es)?|Close[sd]?|Refs?|Issue|in)\s+#(\d+)|\/(?:pull|issues)\/(\d+)\b/gi; + for (const match of value.matchAll(pattern)) refs.add(`#${match[1] || match[2] || match[3]}`); + return [...refs]; +} + +function extractPullRequests(commits, releaseAggregateItems, repo) { + const seen = new Set(); + for (const commit of commits) { + for (const ref of sourceRefsFromText(`${commit.subject || ""}\n${commit.body_excerpt || ""}`)) seen.add(ref.slice(1)); + } + for (const item of releaseAggregateItems) { + for (const ref of sourceRefsFromText(item.text)) seen.add(ref.slice(1)); + } + return [...seen].sort((a, b) => Number(a) - Number(b)).map((number) => ({ + number, + url: `https://github.com/${repo}/pull/${number}`, + })); +} + +function commitRefs(commit) { + const refs = []; + if (commit.short_sha) refs.push(commit.short_sha); + if (commit.sha) refs.push(commit.sha); + if (Array.isArray(commit.source_refs)) refs.push(...commit.source_refs); + refs.push(...sourceRefsFromText(`${commit.subject || ""}\n${commit.body_excerpt || ""}`)); + return [...new Set(refs)]; +} + +function revertedCommitKeys(commits) { + const keys = new Set(); + for (const commit of commits) { + const text = `${commit.subject || ""}\n${commit.body_excerpt || ""}`; + if (!/^revert\b/i.test(String(commit.subject || ""))) continue; + const revertedSha = text.match(/This reverts commit ([0-9a-f]{7,40})\b/i)?.[1]; + if (revertedSha) keys.add(revertedSha); + const revertedSubject = String(commit.subject || "").match(/^Revert\s+"(.+)"(?:\s+\(#\d+\))?$/i)?.[1]; + if (revertedSubject) keys.add(revertedSubject.toLowerCase()); + } + return keys; +} + +function isRevertedCommit(commit, revertedKeys, { matchSubject = false } = {}) { + if (!revertedKeys?.size) return false; + const subject = String(commit.subject || "").toLowerCase(); + return [...revertedKeys].some((key) => { + const value = String(key).toLowerCase(); + return commit.sha?.startsWith(value) || commit.short_sha?.startsWith(value) || (matchSubject && subject === value); + }); +} + +function isImportantCommit(commit, { revertedKeys = new Set() } = {}) { + if (isRevertedCommit(commit, revertedKeys)) return false; + const subject = String(commit.subject || ""); + if (isMaintenanceOnlyCommit(commit)) return false; + if (/^merge\b/i.test(subject)) return false; + if (/^(ci|chore|docs|test|style)(\([^)]+\))?:/i.test(subject)) return false; + if (/^chore:\s*update version/i.test(subject)) return false; + if (/^release:\s*merge\b/i.test(subject)) return true; + if (/^revert\b/i.test(subject)) return false; + return /^(feat|fix|perf|refactor|revert)(\([^)]+\))?:|add|improve|optimi[sz]e|compat|memory|plugin|openclaw|gateway|provider|hermes/i.test(subject); +} + +function isMaintenanceOnlyCommit(commit) { + const subject = String(commit?.subject || ""); + if (/^release:\s*merge\b/i.test(subject)) return false; + if (/^release:\s+(?:@memtensor\/memos-local-plugin|memos-local-plugin\b|openclaw local plugin\b)/i.test(subject)) { + return true; + } + if (/^(?:chore|build|ci)(\([^)]+\))?:\s*(?:release|bump|update)\b.*(?:@memtensor\/memos-local-plugin|memos-local-plugin)/i.test(subject)) { + return true; + } + if (/^chore:\s*update version/i.test(subject)) return true; + return false; +} + +function isReleaseMergeCommit(commit) { + return /^release:\s*merge\b/i.test(String(commit?.subject || "")); +} + +function commitBodyExcerpt(sha) { + const body = tryGit(["show", "--no-patch", "--format=%B", sha]); + return redact(body).slice(0, 24000); +} + +function isExplicitLocalPluginAggregate(text) { + const localPluginPattern = + /(apps\/memos-local-plugin|memos-local-plugin|local[- ]plugin|openclaw local plugin|plugin gateway|standalone bridge|viewer dashboard|hermes)/i; + return localPluginPattern.test(String(text || "")); +} + +function pathScopedPrRefs(commits) { + const refs = new Set(); + for (const commit of commits) { + if (isReleaseMergeCommit(commit) || isMaintenanceOnlyCommit(commit)) continue; + for (const ref of sourceRefsFromText(commit.subject)) refs.add(ref); + } + return refs; +} + +function isPathScopedAggregateItem(text, refs, pathRefs) { + if (refs.some((ref) => pathRefs.has(ref))) return true; + return isExplicitLocalPluginAggregate(text); +} + +function isRevertedAggregateText(text, revertedKeys) { + if (/^revert\b/i.test(String(text || ""))) return true; + return isRevertedCommit({ subject: text }, revertedKeys, { matchSubject: true }); +} + +function releaseAggregateItems( + commits, + { pathRefs = pathScopedPrRefs(commits), revertedKeys = revertedCommitKeys(commits) } = {}, +) { + const items = []; + const seenText = new Set(); + for (const commit of commits) { + if (!isReleaseMergeCommit(commit)) continue; + for (const rawLine of String(commit.body_excerpt || "").split("\n")) { + const line = rawLine.trim(); + if (!/^\*\s+/.test(line)) continue; + const text = line.replace(/^\*\s+/, "").trim(); + if (!text || text === commit.subject) continue; + if (/^#\s*/.test(text)) continue; + if (/^(co-authored-by|signed-off-by|---------|# conflicts:)/i.test(text)) continue; + if (/^(ci|chore|docs|test|style)(\([^)]+\))?:/i.test(text)) continue; + if (isMaintenanceOnlyCommit({ subject: text })) continue; + if (isRevertedAggregateText(text, revertedKeys)) continue; + const textKey = text.toLowerCase().replace(/\s+/g, " ").trim(); + if (seenText.has(textKey)) continue; + const refs = sourceRefsFromText(text); + if (!refs.length) continue; + if (!isPathScopedAggregateItem(text, refs, pathRefs)) continue; + seenText.add(textKey); + items.push({ + source_commit: commit.short_sha, + text: redact(text), + source_refs: [...new Set([commit.short_sha, ...refs])], + }); + if (items.length >= 200) break; + } + } + return items; +} + +function evidenceCommitsForRelease(commits, aggregateItems, { revertedKeys = new Set() } = {}) { + const synthetic = aggregateItems + .filter((item) => !/^revert\b/i.test(String(item.text || ""))) + .filter((item) => !isRevertedAggregateText(item.text, revertedKeys)) + .filter((item) => !isMaintenanceOnlyCommit({ subject: item.text })) + .map((item) => { + const prRefs = (item.source_refs || []).filter((ref) => String(ref).startsWith("#")); + const sourceRefs = prRefs.length ? prRefs : [item.source_commit].filter(Boolean); + return { + sha: "", + short_sha: sourceRefs[0] || item.source_commit || "", + subject: item.text, + body_excerpt: "", + source_refs: [...new Set(sourceRefs)], + evidence_source: "release_aggregate_item", + }; + }); + if (synthetic.length) return synthetic; + return commits.filter( + (commit) => + !/^revert\b/i.test(String(commit.subject || "")) && + !isRevertedCommit(commit, revertedKeys) && + !isMaintenanceOnlyCommit(commit), + ); +} + +function localPluginSkipReason({ changedFiles = [], importantCommits = [] }) { + if (!changedFiles.length) { + return "no local plugin path changes in apps/memos-local-plugin/**"; + } + if (!importantCommits.length) { + return "local plugin files changed, but no user-facing feature/fix/performance evidence was found"; + } + return ""; +} + +function packageChanges(previousTag, currentRef) { + const path = `${PRODUCT_PATH}/package.json`; + const before = gitShowJson(previousTag, path); + const after = gitShowJson(currentRef, path); + const fields = ["name", "version", "main", "types"]; + const changes = fields + .filter((field) => before[field] !== after[field]) + .map((field) => ({ field, before: before[field], after: after[field] })); + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + const beforeDeps = before[section] || {}; + const afterDeps = after[section] || {}; + const names = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]); + for (const name of [...names].sort()) { + if (beforeDeps[name] !== afterDeps[name]) { + changes.push({ field: `${section}.${name}`, before: beforeDeps[name], after: afterDeps[name] }); + } + } + } + return changes; +} + +function localPluginPackageVersions(previousTag, currentRef) { + const path = `${PRODUCT_PATH}/package.json`; + const before = gitShowJson(previousTag, path); + const after = gitShowJson(currentRef, path); + const previousVersion = cleanLocalPluginVersion(before.version, "previous local plugin package.json version"); + const currentVersion = cleanLocalPluginVersion(after.version, "local plugin package.json version"); + return { + previous_version_raw: previousVersion, + version_raw: currentVersion, + previous_version: displayVersion(previousVersion), + version: displayVersion(currentVersion), + version_changed: previousVersion !== currentVersion, + version_source: path, + }; +} + +export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = "") { + const expectedVersionRaw = String(expectedVersionInput || "").trim(); + const previousReleasedVersion = cleanLocalPluginVersion( + evidence.local_plugin_previous_version_raw || evidence.local_plugin_package_previous_version_raw, + "previous released OpenClaw local plugin version", + ); + const previousPackageVersion = cleanLocalPluginVersion( + evidence.local_plugin_package_previous_version_raw || evidence.local_plugin_previous_version_raw, + "previous local plugin package.json version", + ); + const currentPackageVersion = cleanLocalPluginVersion( + evidence.local_plugin_package_version_raw || evidence.local_plugin_version_raw, + "local plugin package.json version", + ); + const packageOrder = compareSemver(currentPackageVersion, previousPackageVersion); + const packageVsReleasedOrder = compareSemver(currentPackageVersion, previousReleasedVersion); + if (packageOrder < 0) { + fail( + `OpenClaw local plugin package version moved backwards: ${displayVersion(previousPackageVersion)} -> ${displayVersion(currentPackageVersion)}.`, + ); + } + + const hasProductChanges = Boolean(evidence.has_product_changes); + const hasUserFacingChanges = Boolean(evidence.has_user_facing_product_changes); + let expectedVersion = ""; + let resolvedVersion = previousReleasedVersion; + let versionSource = hasProductChanges ? "no_user_facing_product_changes" : "no_product_path_changes"; + let autoIncremented = false; + let inputIgnored = false; + let inputIgnoredReason = ""; + + if (hasUserFacingChanges) { + expectedVersion = expectedVersionRaw + ? cleanLocalPluginVersion(expectedVersionRaw, "local_plugin_version input") + : ""; + resolvedVersion = currentPackageVersion; + versionSource = `${PRODUCT_PATH}/package.json`; + if (packageVsReleasedOrder <= 0) { + resolvedVersion = incrementPatchVersion(previousReleasedVersion); + versionSource = "auto_patch_from_previous_released_version"; + autoIncremented = true; + } + } else if (expectedVersionRaw) { + inputIgnored = true; + inputIgnoredReason = hasProductChanges + ? "local plugin path changed, but no user-facing feature/fix/performance evidence was found" + : "no local plugin path changes in apps/memos-local-plugin/**"; + } + + if (expectedVersion && expectedVersion !== resolvedVersion) { + fail( + `local_plugin_version input ${displayVersion(expectedVersion)} does not match the resolved OpenClaw local plugin docs version ${displayVersion(resolvedVersion)}.`, + ); + } + return { + ok: true, + expected_version: expectedVersion ? displayVersion(expectedVersion) : "", + previous_version: displayVersion(previousReleasedVersion), + version: displayVersion(resolvedVersion), + version_changed: resolvedVersion !== previousReleasedVersion, + version_required: Boolean(evidence.has_user_facing_product_changes), + version_source: versionSource, + auto_incremented: autoIncremented, + input_ignored: inputIgnored, + input_ignored_reason: inputIgnoredReason, + input_raw: expectedVersionRaw, + package_previous_version: displayVersion(previousPackageVersion), + package_version: displayVersion(currentPackageVersion), + package_version_changed: previousPackageVersion !== currentPackageVersion, + }; +} + +function collectPatchSnippets(range, changedFiles) { + const interesting = changedFiles + .map((item) => item.path) + .filter((path) => /\.(ts|tsx|js|mjs|cjs|json|md|yaml|yml|sh|ps1)$/.test(path)) + .slice(0, 12); + const snippets = []; + let totalChars = 0; + for (const path of interesting) { + if (totalChars > 16000) break; + const raw = tryGit(["diff", "--unified=1", "--no-ext-diff", range, "--", path]); + if (!raw) continue; + const text = redact(raw).slice(0, 5000); + totalChars += text.length; + snippets.push({ path, patch: text, truncated: raw.length > text.length }); + } + return snippets; +} + +export function collectLocalPluginEvidence({ previousTag, currentTag, currentRef, targetVersion, repo }) { + const range = `${previousTag}..${currentRef}`; + const commitText = tryGit([ + "log", + "--format=%H%x09%h%x09%an%x09%ad%x09%s", + "--date=iso-strict", + range, + "--", + PRODUCT_PATH, + ]); + const commits = parseLines(commitText).map((line) => { + const [sha = "", shortSha = "", author = "", date = "", subject = ""] = line.split("\t"); + const bodyExcerpt = commitBodyExcerpt(sha); + const commit = { + sha, + short_sha: shortSha, + author, + date, + subject: redact(subject), + body_excerpt: bodyExcerpt, + }; + return { ...commit, source_refs: commitRefs(commit) }; + }); + + const changedFiles = parseLines(tryGit(["diff", "--name-status", range, "--", PRODUCT_PATH])).map((line) => { + const parts = line.split("\t"); + const item = { status: parts[0], path: parts[parts.length - 1] }; + if (parts.length === 3) item.old_path = parts[1]; + return item; + }); + + const numstat = parseLines(tryGit(["diff", "--numstat", range, "--", PRODUCT_PATH])).map((line) => { + const [additions = "0", deletions = "0", path = ""] = line.split("\t"); + return { + path, + additions: additions === "-" ? null : Number(additions), + deletions: deletions === "-" ? null : Number(deletions), + }; + }); + + const revertedKeys = revertedCommitKeys(commits); + const aggregateItems = releaseAggregateItems(commits, { revertedKeys }); + const evidenceCommits = evidenceCommitsForRelease(commits, aggregateItems, { revertedKeys }); + const importantCommits = evidenceCommits.filter((commit) => isImportantCommit(commit, { revertedKeys })); + const skipReason = localPluginSkipReason({ changedFiles, importantCommits }); + const localPluginVersion = localPluginPackageVersions(previousTag, currentRef); + return { + product_id: PRODUCT_ID, + product_title: PRODUCT_TITLE, + repo, + release_repo: repo, + previous_tag: previousTag, + current_tag: currentTag, + target_version: displayVersion(targetVersion), + memos_release_version: displayVersion(targetVersion), + local_plugin_previous_version: localPluginVersion.previous_version, + local_plugin_previous_version_raw: localPluginVersion.previous_version_raw, + local_plugin_version: localPluginVersion.version, + local_plugin_version_raw: localPluginVersion.version_raw, + local_plugin_version_changed: localPluginVersion.version_changed, + local_plugin_version_source: localPluginVersion.version_source, + local_plugin_package_previous_version: localPluginVersion.previous_version, + local_plugin_package_previous_version_raw: localPluginVersion.previous_version_raw, + local_plugin_package_version: localPluginVersion.version, + local_plugin_package_version_raw: localPluginVersion.version_raw, + local_plugin_package_version_changed: localPluginVersion.version_changed, + git_ref: currentRef, + product_paths: PRODUCT_PATHS, + has_product_changes: changedFiles.length > 0, + has_user_facing_product_changes: importantCommits.length > 0, + skip_reason: skipReason, + commits: evidenceCommits, + source_commits: commits, + important_commits: importantCommits, + release_aggregate_items: aggregateItems, + reverted_change_keys: [...revertedKeys], + required_source_refs: importantCommits.map((commit) => ({ + sha: commit.sha, + short_sha: commit.short_sha, + subject: commit.subject, + accepted_refs: commitRefs(commit), + })), + pull_requests: extractPullRequests(evidenceCommits, aggregateItems, repo), + changed_files: changedFiles, + diff_stat: { + text: redact(tryGit(["diff", "--stat=200,200", range, "--", PRODUCT_PATH])), + files: numstat, + }, + important_diff: { + [PRODUCT_PATHS[0]]: collectPatchSnippets(range, changedFiles), + }, + package_changes: packageChanges(previousTag, currentRef), + test_changes: changedFiles.filter((item) => /(^|\/)(test|tests|__tests__)\//.test(item.path) || /\.test\./.test(item.path)), + docs_changes: changedFiles.filter((item) => /\.(md|mdx|rst)$/i.test(item.path)), + release_note_quality_request: { + candidate_count: 3, + max_repair_attempts: MAX_REPAIR_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 should explain the user-facing impact in one sentence.", + "Avoid generic restatements such as '新增了 X 功能', '优化了 X 性能', or '修复了 X 问题'.", + "Avoid raw commit subject wording such as 'fix(plugin): ... (#123)'; rewrite it into product-facing copy.", + "A good bullet names the capability and says why it matters for OpenClaw local plugin users.", + ], + curation_policy: [ + "Use Conventional Commit type/scope as a hint, not as final copy.", + "Group related commits or PR aggregate items into user-facing topics so Plugin tab output stays readable.", + "Merge duplicate or near-duplicate bullets and preserve the combined source_refs.", + "Keep every covered source_ref in the draft and inspection artifact even when several commits become one bullet.", + "Do not surface chore/docs/test-only noise unless it changes user-visible local plugin behavior.", + ], + example_rewrites: [ + { + weak_cn: "优化了向量扫描性能。", + better_cn: "优化批量向量扫描规划,降低大数据量本地同步时的处理压力。", + weak_en: "Improved vector scan performance.", + better_en: "Improved batch vector scan planning to reduce processing pressure during large local syncs.", + }, + ], + }, + target_surface: "memos_docs_plugin_changelog", + release_context: { + release_kind: "memos_whole_repo", + public_release_body: "github_generated_whats_changed", + docs_product_extraction: "path_filtered", + }, + release_note_methodology: RELEASE_NOTE_METHODS, + }; +} + +async function fetchJsonWithRetry(url, options, { label, attempts = 3, sleepMs = 500 } = {}) { + 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) { + throw new Error(`HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`); + } + return payload; + } catch (error) { + errors.push(redact(error?.message || error)); + if (attempt === attempts) fail(`${label} failed after ${attempts} attempts: ${errors.join(" | ")}`); + warn(`${label} attempt ${attempt}/${attempts} failed; retrying: ${errors[errors.length - 1]}`); + await new Promise((resolve) => setTimeout(resolve, sleepMs * attempt)); + } + } + fail(`${label} failed.`); +} + +export async function generateGitHubReleaseNotes({ + repo, + currentTag, + targetSha, + previousTag, + token = process.env.GITHUB_TOKEN || "", + forceLocalFallback = false, + fallbackWarning = "", +}) { + const localFallback = (warning = "") => { + const subjects = parseLines(tryGit(["log", "--format=%s", `${previousTag}..${targetSha}`])); + return { + source: warning ? "local-fallback-after-github-error" : "local-fallback", + name: `Release ${currentTag}`, + body: [ + "## What's Changed", + ...subjects.map((subject) => `* ${redact(subject)}`), + "", + `**Full Changelog**: https://github.com/${repo || "MemTensor/MemOS"}/compare/${previousTag}...${currentTag}`, + "", + ].join("\n"), + warning, + }; + }; + if (forceLocalFallback) { + return localFallback(fallbackWarning || "Existing release tag conflicts with target; using local fallback release notes."); + } + if (!token || !repo.includes("/")) { + return localFallback(token ? "" : "GITHUB_TOKEN is not available; using local fallback release notes."); + } + + 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": "2026-03-10", + }, + body: JSON.stringify({ + tag_name: currentTag, + target_commitish: targetSha, + previous_tag_name: previousTag, + }), + }, + { label: "generate GitHub release notes" }, + ); + if (!String(payload.body || "").trim()) fail("GitHub generated release notes response was empty."); + return { + source: "github-generate-notes-api", + name: String(payload.name || `Release ${currentTag}`), + body: payload.body, + warning: "", + }; + } catch (error) { + const message = redact(error?.message || error); + const allowOffline = String(process.env.ALLOW_OFFLINE_DOCS_PREVIEW || "").toLowerCase() === "true"; + if (!allowOffline) throw error; + warn(`GitHub generated release notes failed; using local fallback: ${message}`); + return localFallback(message); + } +} + +const FALLBACK_TOPIC_RULES = [ + { + pattern: /openrouter/i, + category: "Added", + text_cn: "**OpenRouter 提供商路由**:新增 OpenRouter 路由与 reasoning 配置支持,便于按配置选择模型提供商。", + text_en: "**OpenRouter provider routing**: Added OpenRouter routing and reasoning configuration support for model selection.", + }, + { + pattern: /circuit breaker|terminal provider|insufficient balance|invalid api key/i, + category: "Fixed", + text_cn: "**LLM 熔断保护**:新增终端错误熔断,避免余额或密钥异常时持续触发后台 LLM 请求。", + text_en: "**LLM circuit breaker**: Added terminal-error protection to stop repeated background LLM calls after billing or credential failures.", + }, + { + pattern: /recovery replay request storm|dirty-closed reward|reward recovery/i, + category: "Fixed", + text_cn: "**恢复任务稳定性**:优化脏关闭奖励恢复与回放分页,降低恢复过程中的请求风暴风险。", + text_en: "**Recovery stability**: Improved dirty-closed reward recovery and replay pagination to reduce request storms.", + }, + { + pattern: /episode storm|foreground sessions|topic boundary|classifyTimeout|maxTurnsPerEpisode/i, + category: "Fixed", + text_cn: "**会话边界稳定性**:补强 episode 风暴保护和前台会话兜底,降低长任务阻塞风险。", + text_en: "**Session-boundary stability**: Added episode-storm safeguards and foreground-session fallbacks to reduce long-task stalls.", + }, + { + pattern: /preserve v7 session defaults|v7-full-chain|default_config\.algorithm\.session|mergeMaxGapMs|followUpMode/i, + category: "Fixed", + text_cn: "**V7 会话默认配置**:保留默认 session 参数,避免自定义 follow-up 模式时丢失会话合并窗口。", + text_en: + "**V7 session defaults**: Preserved default session parameters so custom follow-up modes keep the merge window settings.", + }, + { + pattern: /captureRunner|reflectLlm|batch reflection|reflection scoring|chunk batch/i, + category: "Improved", + text_cn: "**采集反思稳定性**:优化批量反思评分与模型路由,降低长会话和 thinking 模型导致的解析风险。", + text_en: "**Capture reflection stability**: Improved batch reflection scoring and model routing for long sessions and thinking-model setups.", + }, + { + pattern: /logging|timezone|memos\.log|logger/i, + category: "Improved", + text_cn: "**日志初始化与时区**:补齐本地桥接日志初始化和可配置时区,提升诊断一致性。", + text_en: "**Logging initialization and timezone**: Added bridge logger initialization and configurable log timezone support.", + }, + { + pattern: /bridge|shutdown|session\.close|daemon|orphaned processes|rebuild|dist\/bridge|bridge\.cjs/i, + category: "Fixed", + text_cn: "**桥接进程稳定性**:增加会话关闭、shutdown 超时和桥接构建校验,减少事件循环阻塞、孤儿进程与旧产物风险。", + text_en: "**Bridge process stability**: Added session-close, shutdown-timeout, and bridge rebuild safeguards to reduce event-loop blocking, orphaned processes, and stale artifacts.", + }, + { + pattern: /viewer|dashboard|metrics|namespace|500-row|overview/i, + category: "Fixed", + text_cn: "**Viewer 指标准确性**:修复命名空间切换和行数上限导致的概览统计偏差。", + text_en: "**Viewer metric accuracy**: Fixed overview count drift caused by namespace switching and row-limit truncation.", + }, + { + pattern: /provider|llm config|embedding|model/i, + category: "Improved", + text_cn: "**模型配置与提供商兼容性**:优化 LLM、embedding 与 provider 配置处理,提升不同模型服务的接入稳定性。", + text_en: "**Model configuration and provider compatibility**: Improved LLM, embedding, and provider configuration handling.", + }, +]; + +export function fallbackTopicForText(text, { allowGeneric = false } = {}) { + const source = String(text || ""); + const rule = FALLBACK_TOPIC_RULES.find((item) => item.pattern.test(source)); + if (rule) return rule; + if (!allowGeneric) return null; + return { + category: /^feat/i.test(source) ? "Added" : /^fix|^revert/i.test(source) ? "Fixed" : "Improved", + text_cn: `**${PRODUCT_TITLE.zh}更新**:${source.replace(CJK_GLOBAL_RE, "").trim() || "本地插件能力完成更新。"}`, + text_en: `**${PRODUCT_TITLE.en} update**: ${source.replace(CJK_GLOBAL_RE, "").trim() || "Release evidence updated."}`, + }; +} + +function dedupeFallbackItems(items) { + const byKey = new Map(); + for (const item of items) { + const key = `${item.category}:${item.text_cn}:${item.text_en}`; + if (!byKey.has(key)) { + byKey.set(key, { + ...item, + source_refs: [...new Set(item.source_refs || [])], + }); + continue; + } + const existing = byKey.get(key); + existing.source_refs = [...new Set([...(existing.source_refs || []), ...(item.source_refs || [])])]; + } + return [...byKey.values()]; +} + +function localFallbackDraft(evidence) { + const revertedKeys = new Set((evidence.reverted_change_keys || []).map((item) => String(item).toLowerCase())); + const aggregateItems = (evidence.release_aggregate_items || []) + .filter((item) => !/^revert\b/i.test(item.text)) + .filter((item) => !revertedKeys.has(String(item.text || "").toLowerCase())); + const sourceItems = aggregateItems.length + ? aggregateItems.map((item) => { + const prRefs = (item.source_refs || []).filter((ref) => String(ref).startsWith("#")); + return { + ...item, + source_refs: prRefs.length ? prRefs : [item.source_commit].filter(Boolean), + }; + }) + : evidence.important_commits.map((commit) => ({ + text: commit.subject, + source_refs: [commit.short_sha], + })); + let items = dedupeFallbackItems(sourceItems + .map((sourceItem) => { + const topic = fallbackTopicForText(sourceItem.text, { allowGeneric: aggregateItems.length === 0 }); + if (!topic) return null; + return { + category: topic.category, + text_cn: topic.text_cn, + text_en: topic.text_en, + source_refs: sourceItem.source_refs?.length ? sourceItem.source_refs : [evidence.important_commits[0]?.short_sha].filter(Boolean), + }; + }) + .filter(Boolean)).slice(0, 10); + if (!items.length && evidence.important_commits.length) { + items = dedupeFallbackItems(evidence.important_commits.map((commit) => { + const topic = fallbackTopicForText(commit.subject, { allowGeneric: true }); + return { + category: topic.category, + text_cn: topic.text_cn, + text_en: topic.text_en, + source_refs: [commit.short_sha], + }; + })).slice(0, 10); + } + return { + ok: true, + needs_review: false, + confidence: items.length ? "medium" : "high", + warnings: ["offline fallback draft; use GitHub Actions with Doc Agent secrets for production quality"], + release_items: items, + coverage: { + required_count: evidence.required_source_refs.length, + covered_required_count: Math.min(items.length, evidence.required_source_refs.length), + missing_required_count: Math.max(0, evidence.required_source_refs.length - items.length), + }, + validation_attempt_count: 1, + repair_attempt_count: 0, + }; +} + +export function normalizeDraft(draft) { + const releaseItems = Array.isArray(draft?.release_items) ? draft.release_items : []; + return { + ok: draft?.ok !== false, + needs_review: Boolean(draft?.needs_review), + confidence: draft?.confidence || "", + warnings: Array.isArray(draft?.warnings) ? draft.warnings.map(redact) : [], + coverage: draft?.coverage || {}, + release_items: releaseItems.map((item) => ({ + category: String(item.category || "").trim(), + text_cn: String(item.text_cn || item.text || "").trim(), + text_en: String(item.text_en || "").trim(), + source_refs: Array.isArray(item.source_refs) ? item.source_refs.map((ref) => String(ref).trim()).filter(Boolean) : [], + })), + validation_attempt_count: Number(draft?.validation_attempt_count || 0), + repair_attempt_count: Number(draft?.repair_attempt_count || 0), + }; +} + +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)\b/.test(body)) return false; + return /^(added|fixed|improved|updated|enhanced)\b.{1,60}\b(feature|functionality|issue|bug|problem|performance|capability)\.?$/.test(body); +} + +function hasRawCommitSubjectText(text) { + return /\b(feat|fix|perf|refactor|chore|docs|test|ci|build|style|revert)(\([^)]+\))?!?:\s+/i.test( + String(text || ""), + ); +} + +function duplicateKeyForItem(item) { + return [item.category, item.text_cn, item.text_en] + .map((value) => + stripBoldPrefix(value) + .toLowerCase() + .replace(/[#`*_()[\]{}::,,。.;;!!?\s-]+/g, " ") + .trim(), + ) + .join("|"); +} + +export function validateDraft(draft, evidence) { + const issues = []; + const validRefs = new Set(); + for (const commit of evidence.commits || []) { + for (const ref of commitRefs(commit)) validRefs.add(ref); + } + for (const pr of evidence.pull_requests || []) validRefs.add(`#${pr.number}`); + + if (!draft.ok) issues.push({ kind: "draft_not_ok", message: "draft ok=false" }); + if (draft.needs_review) issues.push({ kind: "needs_review", message: "draft needs review" }); + if (!draft.release_items.length && evidence.has_user_facing_product_changes) { + issues.push({ kind: "empty_release_items", message: "release_items is required when product files changed" }); + } + if (draft.release_items.length > MAX_RELEASE_ITEMS) { + issues.push({ + kind: "too_many_release_items", + message: `release_items must be concise for the Plugin tab; got ${draft.release_items.length}, max ${MAX_RELEASE_ITEMS}`, + }); + } + + const duplicateItems = new Map(); + + for (const [index, item] of draft.release_items.entries()) { + const duplicateKey = duplicateKeyForItem(item); + if (duplicateKey && duplicateItems.has(duplicateKey)) { + issues.push({ + kind: "duplicate_release_item", + index, + duplicate_of: duplicateItems.get(duplicateKey), + message: "duplicate release item should be merged with combined source_refs", + }); + } else if (duplicateKey) { + duplicateItems.set(duplicateKey, index); + } + if (!RELEASE_CATEGORY_ORDER.includes(item.category)) { + issues.push({ kind: "invalid_category", index, message: `invalid category ${item.category}` }); + } + if (!item.text_cn || !CJK_RE.test(item.text_cn)) { + issues.push({ kind: "invalid_text_cn", index, message: "text_cn must contain Chinese text" }); + } + if (!item.text_en || CJK_RE.test(item.text_en)) { + issues.push({ kind: "invalid_text_en", index, message: "text_en must be English without CJK characters" }); + } + if (item.text_cn && item.text_cn.length > MAX_TEXT_CN_CHARS) { + issues.push({ + kind: "text_cn_too_long", + index, + message: `text_cn is too long for docs rendering; got ${item.text_cn.length}, max ${MAX_TEXT_CN_CHARS}`, + }); + } + if (item.text_en && item.text_en.length > MAX_TEXT_EN_CHARS) { + issues.push({ + kind: "text_en_too_long", + index, + message: `text_en is too long for docs rendering; got ${item.text_en.length}, max ${MAX_TEXT_EN_CHARS}`, + }); + } + if (isGenericChineseDocsText(item.text_cn)) { + issues.push({ + kind: "generic_text_cn", + index, + message: "text_cn restates the change too generically; include concrete user-facing impact.", + }); + } + if (isGenericEnglishDocsText(item.text_en)) { + issues.push({ + kind: "generic_text_en", + index, + message: "text_en restates the change too generically; include concrete user-facing impact.", + }); + } + for (const [field, value] of [ + ["text_cn", item.text_cn], + ["text_en", item.text_en], + ]) { + if (hasRawCommitSubjectText(value)) { + issues.push({ + kind: "raw_commit_subject_text", + index, + field, + message: `${field} should not copy raw Conventional Commit subject text.`, + }); + } + } + if (!item.source_refs.length) { + issues.push({ kind: "missing_source_refs", index, message: "source_refs is required" }); + } + for (const ref of item.source_refs) { + if (!validRefs.has(ref)) { + issues.push({ kind: "invalid_source_ref", index, ref, message: `source_ref does not match evidence: ${ref}` }); + } + } + } + + const coveredRefs = new Set(draft.release_items.flatMap((item) => item.source_refs)); + const missingRequired = []; + for (const required of evidence.required_source_refs || []) { + if (!required.accepted_refs.some((ref) => coveredRefs.has(ref))) { + missingRequired.push(required.short_sha); + } + } + for (const ref of missingRequired) { + issues.push({ kind: "missing_required_ref", ref, message: `important commit is not covered: ${ref}` }); + } + + return { + ok: issues.length === 0, + needs_review: issues.length > 0, + issue_count: issues.length, + issues, + coverage: { + required_count: evidence.required_source_refs?.length || 0, + covered_required_count: (evidence.required_source_refs?.length || 0) - missingRequired.length, + missing_required_count: missingRequired.length, + missing_required_refs: missingRequired, + }, + }; +} + +export async function requestDocAgentDraft(evidence) { + if (!evidence.has_user_facing_product_changes) { + return { + ok: true, + needs_review: false, + confidence: "high", + warnings: [evidence.skip_reason || "No user-facing OpenClaw local plugin changes in this MemOS release range."], + release_items: [], + coverage: { required_count: 0, covered_required_count: 0, missing_required_count: 0 }, + 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(); + const allowOffline = String(process.env.ALLOW_OFFLINE_DOCS_PREVIEW || "").toLowerCase() === "true"; + if ((!url || !token) && allowOffline) return normalizeDraft(localFallbackDraft(evidence)); + if (!url) fail("DOC_AGENT_RELEASE_NOTES_DRAFT_URL secret is required for MemOS release docs preview."); + if (!token) fail("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN secret is required for MemOS release docs preview."); + + const attempts = []; + let repairContext = null; + for (let attempt = 1; attempt <= MAX_DRAFT_ATTEMPTS; attempt += 1) { + const payload = await fetchJsonWithRetry( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ...evidence, + workflow_retry_context: { + attempt, + previous_errors: attempts, + }, + repair_context: repairContext, + }), + }, + { label: "Doc Agent local-plugin docs draft" }, + ); + const draft = normalizeDraft(payload); + const validation = validateDraft(draft, evidence); + attempts.push({ attempt, validation }); + if (validation.ok) { + return { + ...draft, + validation_report: validation, + validation_attempt_count: attempt, + repair_attempt_count: attempt - 1, + coverage: validation.coverage, + }; + } + repairContext = { + validation_report: validation, + instructions: [ + "Repair only the validation issues.", + "Keep facts within the provided evidence.", + "Return release_items with category, text_cn, text_en, and source_refs.", + "For generic_text_cn issues, explain concrete user-facing impact without inventing facts.", + "For generic_text_en issues, explain concrete user-facing impact in English.", + "For raw_commit_subject_text issues, remove Conventional Commit prefixes and PR-number prose from the user-facing text.", + "For duplicate_release_item issues, merge duplicate bullets and combine their source_refs.", + ], + }; + } + fail(`Doc Agent draft failed validation after ${MAX_REPAIR_ATTEMPTS} repair attempts: ${JSON.stringify(attempts.at(-1)?.validation?.issues || [])}`); +} + +export function buildDocsPreview(draft, evidence) { + const localPluginVersion = evidence.local_plugin_version || evidence.current_tag; + const makeSide = (language) => { + const categories = {}; + for (const releaseCategory of RELEASE_CATEGORY_ORDER) { + const docsCategory = RELEASE_TO_DOC_CATEGORY[releaseCategory]; + const changedInfo = draft.release_items + .filter((item) => item.category === releaseCategory) + .map((item) => (language === "zh" ? item.text_cn : item.text_en)) + .filter(Boolean); + if (changedInfo.length) { + categories[docsCategory] = [ + { + type: language === "zh" ? PRODUCT_TITLE.zh : PRODUCT_TITLE.en, + changedInfo, + }, + ]; + } + } + return { + name: localPluginVersion, + source: { + repo: evidence.repo, + tag: evidence.current_tag, + memos_release_tag: evidence.current_tag, + release_url: `https://github.com/${evidence.repo}/releases/tag/${evidence.current_tag}`, + previous_tag: evidence.previous_tag, + local_plugin_version: evidence.local_plugin_version, + local_plugin_previous_version: evidence.local_plugin_previous_version, + local_plugin_version_source: evidence.local_plugin_version_source, + local_plugin_version_auto_incremented: evidence.local_plugin_version_auto_incremented, + local_plugin_version_input_ignored: evidence.local_plugin_version_input_ignored, + local_plugin_version_input_ignored_reason: evidence.local_plugin_version_input_ignored_reason, + local_plugin_package_version: evidence.local_plugin_package_version, + local_plugin_package_previous_version: evidence.local_plugin_package_previous_version, + product_paths: evidence.product_paths, + }, + products: { + plugin: categories, + }, + }; + }; + return { + source_id: PRODUCT_ID, + source_repo: evidence.repo, + source_ref: evidence.git_ref, + previous_tag: evidence.previous_tag, + current_tag: evidence.current_tag, + memos_release_tag: evidence.current_tag, + local_plugin_version: evidence.local_plugin_version, + local_plugin_previous_version: evidence.local_plugin_previous_version, + local_plugin_version_changed: evidence.local_plugin_version_changed, + local_plugin_version_source: evidence.local_plugin_version_source, + local_plugin_version_auto_incremented: evidence.local_plugin_version_auto_incremented, + local_plugin_version_input_ignored: evidence.local_plugin_version_input_ignored, + local_plugin_version_input_ignored_reason: evidence.local_plugin_version_input_ignored_reason, + local_plugin_package_version: evidence.local_plugin_package_version, + local_plugin_package_previous_version: evidence.local_plugin_package_previous_version, + product_paths: evidence.product_paths, + has_product_changes: evidence.has_product_changes, + has_user_facing_product_changes: evidence.has_user_facing_product_changes, + skip_reason: evidence.skip_reason, + docs_action: draft.release_items.length ? "preview_plugin_tab_entry" : "skip_plugin_tab_entry", + would_create_docs_pr: false, + files: ["content/cn/plugin-changelog.yml", "content/en/plugin-changelog.yml"], + cn: makeSide("zh"), + en: makeSide("en"), + }; +} + +export function docsPreviewMarkdown(preview, draft, evidence) { + const lines = [ + `# ${PRODUCT_TITLE.zh}-${evidence.local_plugin_version || evidence.current_tag}`, + "", + `- source: ${evidence.repo}`, + `- memos_release_range: ${evidence.previous_tag}...${evidence.current_tag}`, + `- local_plugin_version: ${evidence.local_plugin_version || "n/a"}`, + `- local_plugin_previous_version: ${evidence.local_plugin_previous_version || "n/a"}`, + `- local_plugin_version_source: ${evidence.local_plugin_version_source || `${PRODUCT_PATH}/package.json`}`, + `- local_plugin_version_auto_incremented: ${Boolean(evidence.local_plugin_version_auto_incremented)}`, + `- local_plugin_version_input_ignored: ${Boolean(evidence.local_plugin_version_input_ignored)}`, + `- local_plugin_version_input_ignored_reason: ${evidence.local_plugin_version_input_ignored_reason || "n/a"}`, + `- local_plugin_package_version: ${evidence.local_plugin_package_version || "n/a"}`, + `- local_plugin_package_previous_version: ${evidence.local_plugin_package_previous_version || "n/a"}`, + `- product_paths: ${evidence.product_paths.join(", ")}`, + "", + ]; + if (!draft.release_items.length) { + lines.push(evidence.skip_reason || "No OpenClaw local plugin docs entries were generated for this MemOS release range.", ""); + return lines.join("\n"); + } + for (const [language, label, field] of [ + ["cn", "中文", "text_cn"], + ["en", "English", "text_en"], + ]) { + lines.push(`## ${label}`, ""); + for (const releaseCategory of RELEASE_CATEGORY_ORDER) { + const items = draft.release_items.filter((item) => item.category === releaseCategory); + if (!items.length) continue; + lines.push(`### ${RELEASE_TO_DOC_CATEGORY[releaseCategory]}`, ""); + for (const item of items) { + lines.push(`- ${item[field]}`); + } + lines.push(""); + } + if (!Object.keys(preview[language].products.plugin).length) lines.push("No entries.", ""); + } + lines.push("## Source Refs", ""); + for (const item of draft.release_items) { + lines.push(`- ${item.category}: ${item.source_refs.join(", ")}`); + } + lines.push(""); + return lines.join("\n"); +} + +function appendOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) return; + const text = String(value ?? ""); + writeFileSync(process.env.GITHUB_OUTPUT, `${name}< ${evidence.local_plugin_version}`); + console.log(`OpenClaw local plugin changed files: ${evidence.changed_files.length}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch((error) => { + console.error(`::error::${redact(error?.stack || error?.message || error)}`); + process.exit(1); + }); +} diff --git a/.github/scripts/prepare-memos-release.test.mjs b/.github/scripts/prepare-memos-release.test.mjs new file mode 100644 index 000000000..550af23a0 --- /dev/null +++ b/.github/scripts/prepare-memos-release.test.mjs @@ -0,0 +1,1028 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { 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, + RELEASE_NOTE_METHODS, + buildDocsPreview, + collectLocalPluginEvidence, + compareSemver, + cleanLocalPluginVersion, + cleanVersion, + docsPreviewMarkdown, + existingReleaseTagState, + fallbackTopicForText, + findPreviousMemOSTag, + generateGitHubReleaseNotes, + incrementPatchVersion, + requestDocAgentDraft, + sourceRefsFromText, + validateDraft, + validateLocalPluginVersionPlan, + validatePublishConfirmation, + validateReleaseTarget, +} from "./prepare-memos-release.mjs"; + +const evidence = { + repo: "MemTensor/MemOS", + previous_tag: "v2.0.24", + current_tag: "v2.0.25", + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.11", + local_plugin_version_raw: "2.0.11", + local_plugin_version_changed: true, + local_plugin_version_source: "apps/memos-local-plugin/package.json", + local_plugin_version_auto_incremented: false, + local_plugin_package_previous_version: "v2.0.10", + local_plugin_package_previous_version_raw: "2.0.10", + local_plugin_package_version: "v2.0.11", + local_plugin_package_version_raw: "2.0.11", + local_plugin_package_version_changed: true, + product_paths: ["apps/memos-local-plugin/**"], + has_product_changes: true, + has_user_facing_product_changes: true, + commits: [ + { + sha: "9deb941e00000000000000000000000000000000", + short_sha: "9deb941e", + subject: "feat(l3): dedicated l3Llm config slot for abstraction pass (#1959)", + }, + { + sha: "59c1474600000000000000000000000000000000", + short_sha: "59c14746", + subject: "Fix #2076: local-plugin gateway CPU 100% - synchronous full-table vector scan (#2077)", + }, + ], + important_commits: [ + { + sha: "9deb941e00000000000000000000000000000000", + short_sha: "9deb941e", + subject: "feat(l3): dedicated l3Llm config slot for abstraction pass (#1959)", + }, + { + sha: "59c1474600000000000000000000000000000000", + short_sha: "59c14746", + subject: "Fix #2076: local-plugin gateway CPU 100% - synchronous full-table vector scan (#2077)", + }, + ], + required_source_refs: [ + { + short_sha: "9deb941e", + accepted_refs: ["9deb941e", "9deb941e00000000000000000000000000000000", "#1959"], + }, + { + short_sha: "59c14746", + accepted_refs: ["59c14746", "59c1474600000000000000000000000000000000", "#2076", "#2077"], + }, + ], + pull_requests: [{ number: "1959" }, { number: "2076" }, { number: "2077" }], +}; + +const validDraft = { + ok: true, + needs_review: false, + release_items: [ + { + category: "Added", + text_cn: "**L3 抽象模型配置**:新增专用 L3 LLM 配置入口,便于独立管理抽象结论阶段的模型调用。", + text_en: "**L3 abstraction model configuration**: Added a dedicated L3 LLM configuration entry for the abstraction pass.", + source_refs: ["9deb941e"], + }, + { + category: "Improved", + text_cn: "**向量扫描性能优化**:优化本地插件网关的大批量向量扫描流程,降低同步全表扫描造成的 CPU 压力。", + text_en: "**Vector scan performance**: Optimized large local-plugin vector scans to reduce CPU pressure from synchronous full-table reads.", + source_refs: ["59c14746", "#2077"], + }, + ], +}; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function writeRepoFile(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, "utf8"); +} + +function commitAll(message) { + git(["add", "."]); + git(["commit", "-q", "-m", message]); +} + +function withFixtureRepo(fn) { + const originalCwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "memos-release-evidence-")); + try { + process.chdir(root); + git(["init", "-q"]); + git(["config", "user.email", "release-test@example.invalid"]); + git(["config", "user.name", "Release Test"]); + writeRepoFile( + "apps/memos-local-plugin/package.json", + `${JSON.stringify({ name: "@memtensor/memos-local-plugin", version: "9.9.0" }, null, 2)}\n`, + ); + writeRepoFile("apps/memos-local-plugin/src/index.js", "export const baseline = true;\n"); + writeRepoFile("memos/core/session.js", "export const sessionCore = true;\n"); + writeRepoFile("packages/memos-sdk/index.js", "export const sdk = true;\n"); + commitAll("chore: baseline release"); + git(["tag", "v9.9.0"]); + return fn(root); + } finally { + process.chdir(originalCwd); + } +} + +test("compares prerelease versions with SemVer precedence", () => { + assert.ok(compareSemver("1.0.0-beta.10", "1.0.0-beta.9") > 0); + assert.ok(compareSemver("1.0.0-beta.20", "1.0.0-beta.19") > 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); +}); + +test("selects the previous MemOS stable tag for release evidence", () => { + assert.equal( + findPreviousMemOSTag("2.0.25", "v2.0.25", ["v2.0.24", "v2.0.25", "v2.0.25-beta.1", "memos-local-plugin-v2.0.10"]), + "v2.0.24", + ); + assert.equal( + findPreviousMemOSTag("2.0.26-beta.2", "v2.0.26-beta.2", ["v2.0.25", "v2.0.26-beta.1", "v2.0.24"]), + "v2.0.26-beta.1", + ); +}); + +test("extracts PR refs from GitHub release note wording", () => { + assert.deepEqual( + sourceRefsFromText( + "feat: add provider routing by @someone in #1958\nFix #2131: dashboard drift (#2132)\nhttps://github.com/MemTensor/MemOS/pull/2146", + ), + ["#1958", "#2131", "#2132", "#2146"], + ); +}); + +test("rejects leading v in manual version input", () => { + assert.equal(cleanVersion("2.0.25"), "2.0.25"); + assert.throws(() => cleanVersion("v2.0.25"), /must not include a leading v/); + assert.equal(cleanLocalPluginVersion("2.0.12"), "2.0.12"); + assert.throws(() => cleanLocalPluginVersion(""), /is required/); + assert.throws(() => cleanLocalPluginVersion("v2.0.12"), /must not include a leading v/); + assert.equal(incrementPatchVersion("2.0.12"), "2.0.13"); + assert.throws(() => incrementPatchVersion("2.0.12-beta.1"), /Cannot auto-increment prerelease/); +}); + +test("resolves the local plugin docs version from package or auto patch increment", () => { + assert.deepEqual(validateLocalPluginVersionPlan(evidence, ""), { + ok: true, + expected_version: "", + previous_version: "v2.0.10", + version: "v2.0.11", + version_changed: true, + version_required: true, + version_source: "apps/memos-local-plugin/package.json", + auto_incremented: false, + input_ignored: false, + input_ignored_reason: "", + input_raw: "", + package_previous_version: "v2.0.10", + package_version: "v2.0.11", + package_version_changed: true, + }); + assert.deepEqual(validateLocalPluginVersionPlan(evidence, "2.0.11"), { + ok: true, + expected_version: "v2.0.11", + previous_version: "v2.0.10", + version: "v2.0.11", + version_changed: true, + version_required: true, + version_source: "apps/memos-local-plugin/package.json", + auto_incremented: false, + input_ignored: false, + input_ignored_reason: "", + input_raw: "2.0.11", + package_previous_version: "v2.0.10", + package_version: "v2.0.11", + package_version_changed: true, + }); + assert.throws(() => validateLocalPluginVersionPlan(evidence, "2.0.12"), /does not match/); + + assert.deepEqual( + validateLocalPluginVersionPlan({ + ...evidence, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.10", + local_plugin_package_version_raw: "2.0.10", + local_plugin_package_version_changed: false, + }), + { + ok: true, + expected_version: "", + previous_version: "v2.0.10", + version: "v2.0.11", + version_changed: true, + version_required: true, + version_source: "auto_patch_from_previous_released_version", + auto_incremented: true, + input_ignored: false, + input_ignored_reason: "", + input_raw: "", + package_previous_version: "v2.0.10", + package_version: "v2.0.10", + package_version_changed: false, + }, + ); + assert.doesNotThrow(() => + validateLocalPluginVersionPlan( + { + ...evidence, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.10", + local_plugin_package_version_raw: "2.0.10", + local_plugin_package_version_changed: false, + }, + "2.0.11", + ), + ); + assert.throws( + () => + validateLocalPluginVersionPlan( + { + ...evidence, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.10", + local_plugin_package_version_raw: "2.0.10", + local_plugin_package_version_changed: false, + }, + "2.0.12", + ), + /does not match/, + ); + assert.deepEqual( + validateLocalPluginVersionPlan({ + ...evidence, + has_user_facing_product_changes: false, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.10", + local_plugin_package_version_raw: "2.0.10", + local_plugin_package_version_changed: false, + }), + { + ok: true, + expected_version: "", + previous_version: "v2.0.10", + version: "v2.0.10", + version_changed: false, + version_required: false, + version_source: "no_user_facing_product_changes", + auto_incremented: false, + input_ignored: false, + input_ignored_reason: "", + input_raw: "", + package_previous_version: "v2.0.10", + package_version: "v2.0.10", + package_version_changed: false, + }, + ); + assert.throws( + () => + validateLocalPluginVersionPlan({ + ...evidence, + local_plugin_package_previous_version: "v2.0.10", + local_plugin_package_previous_version_raw: "2.0.10", + local_plugin_package_version: "v2.0.9", + local_plugin_package_version_raw: "2.0.9", + }), + /moved backwards/, + ); +}); + +test("ignores local plugin version input when the release has no local-plugin path changes", () => { + assert.deepEqual( + validateLocalPluginVersionPlan( + { + ...evidence, + has_product_changes: false, + has_user_facing_product_changes: false, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.10", + local_plugin_package_version_raw: "2.0.10", + local_plugin_package_version_changed: false, + }, + "v9.9.9", + ), + { + ok: true, + expected_version: "", + previous_version: "v2.0.10", + version: "v2.0.10", + version_changed: false, + version_required: false, + version_source: "no_product_path_changes", + auto_incremented: false, + input_ignored: true, + input_ignored_reason: "no local plugin path changes in apps/memos-local-plugin/**", + input_raw: "v9.9.9", + package_previous_version: "v2.0.10", + package_version: "v2.0.10", + package_version_changed: false, + }, + ); +}); + +test("ignores local plugin version input for maintenance-only local-plugin changes", () => { + assert.deepEqual( + validateLocalPluginVersionPlan( + { + ...evidence, + has_product_changes: true, + has_user_facing_product_changes: false, + local_plugin_previous_version: "v2.0.10", + local_plugin_previous_version_raw: "2.0.10", + local_plugin_version: "v2.0.10", + local_plugin_version_raw: "2.0.10", + local_plugin_version_changed: false, + local_plugin_package_version: "v2.0.12", + local_plugin_package_version_raw: "2.0.12", + local_plugin_package_version_changed: true, + }, + "2.0.12", + ), + { + ok: true, + expected_version: "", + previous_version: "v2.0.10", + version: "v2.0.10", + version_changed: false, + version_required: false, + version_source: "no_user_facing_product_changes", + auto_incremented: false, + input_ignored: true, + input_ignored_reason: "local plugin path changed, but no user-facing feature/fix/performance evidence was found", + input_raw: "2.0.12", + package_previous_version: "v2.0.10", + package_version: "v2.0.12", + package_version_changed: true, + }, + ); +}); + +test("requires an exact publish confirmation for non-dry-run releases", () => { + assert.doesNotThrow(() => validatePublishConfirmation({ dryRun: "true", version: "2.0.25", confirmation: "" })); + assert.throws( + () => validatePublishConfirmation({ dryRun: "false", version: "2.0.25", confirmation: "" }), + /PUBLISH v2\.0\.25/, + ); + assert.doesNotThrow(() => + validatePublishConfirmation({ dryRun: "false", version: "2.0.25", confirmation: "PUBLISH v2.0.25" }), + ); +}); + +test("publish workflow defaults real releases to draft before release.published", () => { + const workflow = readFileSync(".github/workflows/memos-release-publish.yml", "utf8"); + assert.match(workflow, /create_draft_release:/); + assert.match(workflow, /default:\s+true/); + assert.match(workflow, /CREATE_DRAFT_RELEASE/); + assert.match(workflow, /flags\+=\(--draft\)/); + assert.match(workflow, /Publish manually to trigger release\.published/); +}); + +test("legacy standalone local-plugin publisher requires an extra non-dry-run confirmation", () => { + const workflow = readFileSync(".github/workflows/memos-local-plugin-publish.yml", "utf8"); + assert.match(workflow, /legacy_publish_confirmation:/); + assert.match(workflow, /guard-legacy-publish:/); + assert.match(workflow, /expected="LEGACY PUBLISH memos-local-plugin-v\$\{RELEASE_VERSION\}"/); + assert.match(workflow, /current official path is MemOS Release — Publish/); + assert.match(workflow, /needs: guard-legacy-publish/); +}); + +test("inspection artifact contract includes generic aliases and side-effect proof", () => { + const script = readFileSync(".github/scripts/prepare-memos-release.mjs", "utf8"); + assert.match(script, /"release-notes\.md"/); + assert.match(script, /"evidence\.json"/); + assert.match(script, /"docs-preview\.md"/); + assert.match(script, /"docs-preview\.json"/); + assert.match(script, /source_id:\s+PRODUCT_ID/); + assert.match(script, /release_kind:\s+"memos_whole_repo"/); + assert.match(script, /docs_product_extraction:\s+"path_filtered"/); + assert.match(script, /public_release_body:\s+"github_generated_whats_changed"/); + assert.match(script, /existing_tag:\s+existingTag/); + assert.match(script, /publish_blocked:\s+existingTag\.publish_blocked/); + assert.match(script, /local_plugin_version_plan/); + assert.match(script, /local_plugin_version_required/); + assert.match(script, /no_side_effects:\s+\{/); + assert.match(script, /npm_publish:\s+false/); + assert.match(script, /production_docs_pr:\s+false/); + assert.equal(PRODUCT_ID, "openclaw-local-plugin"); +}); + +test("allows flexible target refs only for dry runs", () => { + assert.doesNotThrow(() => validateReleaseTarget({ dryRun: "true", targetRef: "origin/main" })); + assert.doesNotThrow(() => validateReleaseTarget({ dryRun: "false", targetRef: "main" })); + assert.throws(() => validateReleaseTarget({ dryRun: "false", targetRef: "origin/main" }), /exactly main/); + assert.throws(() => validateReleaseTarget({ dryRun: "false", targetRef: "feature/test" }), /exactly main/); +}); + +test("reports absent, matching, and conflicting manual release tags", () => { + withFixtureRepo(() => { + const firstTarget = git(["rev-parse", "HEAD"]).trim(); + const absent = existingReleaseTagState("v9.9.1", firstTarget); + assert.equal(absent.status, "absent"); + assert.equal(absent.publish_blocked, false); + + git(["tag", "v9.9.1", firstTarget]); + const matching = existingReleaseTagState("v9.9.1", firstTarget); + assert.equal(matching.status, "matches_target"); + assert.equal(matching.publish_blocked, false); + assert.equal(matching.tag_sha, firstTarget); + + writeRepoFile("apps/memos-local-plugin/src/index.js", "export const newerTarget = true;\n"); + commitAll("fix(plugin): preserve release target after manual tag (#10)"); + const finalTarget = git(["rev-parse", "HEAD"]).trim(); + const conflicting = existingReleaseTagState("v9.9.1", finalTarget); + assert.equal(conflicting.status, "conflicts_target"); + assert.equal(conflicting.publish_blocked, true); + assert.equal(conflicting.tag_sha, firstTarget); + assert.match(conflicting.message, /will not|Delete or recreate|points to/i); + }); +}); + +test("validates a bilingual source-referenced plugin docs draft", () => { + const result = validateDraft(validDraft, evidence); + assert.equal(result.ok, true); + assert.equal(result.coverage.required_count, 2); + assert.equal(result.coverage.covered_required_count, 2); +}); + +test("release note methodology records the sources used for quality policy", () => { + assert.ok(RELEASE_NOTE_METHODS.some((item) => item.source === "github-auto-generated-release-notes")); + assert.ok(RELEASE_NOTE_METHODS.some((item) => item.source === "keep-a-changelog")); + assert.ok(RELEASE_NOTE_METHODS.some((item) => item.source === "conventional-commits")); + assert.ok(RELEASE_NOTE_METHODS.some((item) => item.source === "release-please")); + assert.ok(RELEASE_NOTE_METHODS.every((item) => item.url.startsWith("https://"))); +}); + +test("collects no local-plugin evidence from non-plugin-only release noise", () => { + withFixtureRepo(() => { + writeRepoFile("memos/core/session.js", "export const sessionCore = 'telemetry-only';\n"); + commitAll("feat: add core session telemetry (#10)"); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.equal(result.has_product_changes, false); + assert.deepEqual(result.changed_files, []); + assert.deepEqual(result.commits, []); + assert.deepEqual(result.important_commits, []); + assert.deepEqual(result.required_source_refs, []); + assert.deepEqual(result.product_paths, ["apps/memos-local-plugin/**"]); + }); +}); + +test("filters mixed MemOS release evidence down to local-plugin paths", () => { + withFixtureRepo(() => { + writeRepoFile("memos/core/session.js", "export const sessionCore = 'telemetry-only';\n"); + commitAll("feat: add core session telemetry (#10)"); + + writeRepoFile("apps/memos-local-plugin/src/provider-routing.js", "export const providerRouting = true;\n"); + writeRepoFile("packages/memos-sdk/index.js", "export const sdk = 'noise in the same release range';\n"); + commitAll("feat(plugin): add provider config routing (#11)"); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.equal(result.has_product_changes, true); + assert.deepEqual( + result.changed_files.map((item) => item.path), + ["apps/memos-local-plugin/src/provider-routing.js"], + ); + assert.deepEqual( + result.commits.map((commit) => commit.subject), + ["feat(plugin): add provider config routing (#11)"], + ); + assert.deepEqual(result.pull_requests.map((pr) => pr.number), ["11"]); + assert.equal(result.required_source_refs.length, 1); + assert.ok(result.required_source_refs[0].accepted_refs.includes("#11")); + assert.ok(result.important_diff["apps/memos-local-plugin/**"][0].path.endsWith("provider-routing.js")); + assert.equal(result.local_plugin_previous_version, "v9.9.0"); + assert.equal(result.local_plugin_version, "v9.9.0"); + assert.equal(result.local_plugin_version_changed, false); + }); +}); + +test("filters standalone local-plugin release metadata from docs evidence", () => { + withFixtureRepo(() => { + writeRepoFile("apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts", "export const v7Defaults = true;\n"); + commitAll("fix(plugin): preserve V7 session defaults (#11)"); + + writeRepoFile( + "apps/memos-local-plugin/package.json", + `${JSON.stringify({ name: "@memtensor/memos-local-plugin", version: "9.9.1" }, null, 2)}\n`, + ); + writeRepoFile("apps/memos-local-plugin/package-lock.json", "{\"lockfileVersion\": 3}\n"); + commitAll("release: @memtensor/memos-local-plugin v9.9.1 (#12)"); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.equal(result.has_product_changes, true); + assert.equal(result.has_user_facing_product_changes, true); + assert.deepEqual( + result.commits.map((commit) => commit.subject), + ["fix(plugin): preserve V7 session defaults (#11)"], + ); + assert.deepEqual( + result.important_commits.map((commit) => commit.subject), + ["fix(plugin): preserve V7 session defaults (#11)"], + ); + assert.deepEqual(result.pull_requests.map((pr) => pr.number), ["11"]); + assert.deepEqual(result.required_source_refs.map((item) => item.accepted_refs.includes("#11")), [true]); + }); +}); + +test("keeps release merge aggregate items tied to local-plugin path refs", () => { + withFixtureRepo(() => { + writeRepoFile("apps/memos-local-plugin/server/routes/metrics.ts", "export const viewerMetrics = 'stable';\n"); + commitAll("fix: viewer dashboard drifts after namespace flip (#11)"); + + writeRepoFile("memos/core/session.js", "export const sessionCore = 'memory-provider-noise';\n"); + commitAll("feat(memory): add workspace memory provider (#10)"); + + writeRepoFile("apps/memos-local-plugin/server/routes/metrics.ts", "export const viewerMetrics = 'release merge';\n"); + git(["add", "."]); + git([ + "commit", + "-q", + "-m", + "release: merge dev-v9.9.1 into main (#99)", + "-m", + "* fix: viewer dashboard drifts after namespace flip (#11)", + "-m", + "* feat(memory): add workspace memory provider (#10)", + "-m", + "* feat: plugin marketplace card polish (#12)", + ]); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.deepEqual( + result.release_aggregate_items.map((item) => item.text), + ["fix: viewer dashboard drifts after namespace flip (#11)"], + ); + assert.deepEqual( + result.commits.map((commit) => commit.subject), + ["fix: viewer dashboard drifts after namespace flip (#11)"], + ); + assert.deepEqual(result.required_source_refs.map((item) => item.short_sha), ["#11"]); + }); +}); + +test("drops reverted release merge aggregate items from local-plugin evidence", () => { + withFixtureRepo(() => { + writeRepoFile("apps/memos-local-plugin/src/reflection.js", "export const scoring = 'batch';\n"); + commitAll("feat: chunk batch reflection scoring (#11)"); + const featureSha = git(["rev-parse", "HEAD"]).trim(); + + writeRepoFile("apps/memos-local-plugin/src/reflection.js", "export const scoring = 'reverted';\n"); + git(["add", "."]); + git([ + "commit", + "-q", + "-m", + "Revert \"feat: chunk batch reflection scoring (#11)\" (#12)", + "-m", + `This reverts commit ${featureSha}.`, + ]); + + writeRepoFile("apps/memos-local-plugin/server/routes/metrics.ts", "export const viewerMetrics = 'fixed';\n"); + commitAll("fix: viewer dashboard drifts after namespace flip (#13)"); + + writeRepoFile("apps/memos-local-plugin/server/routes/metrics.ts", "export const viewerMetrics = 'release merge';\n"); + git(["add", "."]); + git([ + "commit", + "-q", + "-m", + "release: merge dev-v9.9.1 into main (#99)", + "-m", + "* feat: chunk batch reflection scoring (#11)", + "-m", + "* Revert \"feat: chunk batch reflection scoring (#11)\" (#12)", + "-m", + "* fix: viewer dashboard drifts after namespace flip (#13)", + ]); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.deepEqual( + result.release_aggregate_items.map((item) => item.text), + ["fix: viewer dashboard drifts after namespace flip (#13)"], + ); + assert.deepEqual( + result.commits.map((commit) => commit.subject), + ["fix: viewer dashboard drifts after namespace flip (#13)"], + ); + assert.deepEqual(result.pull_requests.map((pr) => pr.number), ["13"]); + assert.ok(result.reverted_change_keys.includes("feat: chunk batch reflection scoring (#11)")); + }); +}); + +test("keeps a reapplied local-plugin change after an earlier commit was reverted", () => { + withFixtureRepo(() => { + writeRepoFile("apps/memos-local-plugin/src/reflection.js", "export const scoring = 'batch';\n"); + commitAll("feat: chunk batch reflection scoring"); + const featureSha = git(["rev-parse", "HEAD"]).trim(); + + git(["revert", "--no-edit", featureSha]); + git([ + "commit", + "--amend", + "-q", + "-m", + "Revert \"feat: chunk batch reflection scoring\" (#12)", + "-m", + `This reverts commit ${featureSha}.`, + ]); + + writeRepoFile("apps/memos-local-plugin/src/reflection.js", "export const scoring = 'reapplied';\n"); + commitAll("feat: chunk batch reflection scoring"); + const reappliedSha = git(["rev-parse", "--short", "HEAD"]).trim(); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.deepEqual(result.commits.map((commit) => commit.short_sha), [reappliedSha]); + assert.deepEqual(result.important_commits.map((commit) => commit.short_sha), [reappliedSha]); + assert.equal(result.has_user_facing_product_changes, true); + assert.equal(result.skip_reason, ""); + }); +}); + +test("fallback topic rewrites V7 session default fixes into user-facing docs copy", () => { + const topic = fallbackTopicForText("fix(plugin): preserve V7 session defaults (#2158)", { allowGeneric: true }); + assert.equal(topic.category, "Fixed"); + assert.match(topic.text_cn, /V7 会话默认配置/); + assert.match(topic.text_cn, /会话合并窗口/); + assert.match(topic.text_en, /V7 session defaults/); + assert.doesNotMatch(topic.text_cn, /fix\(plugin\)/); +}); + +test("GitHub release notes fallback stays whole-repo when API access is unavailable", async () => { + const result = await generateGitHubReleaseNotes({ + repo: "MemTensor/MemOS", + currentTag: "v0.0.0-test", + targetSha: "HEAD", + previousTag: "HEAD", + token: "", + }); + assert.equal(result.source, "local-fallback-after-github-error"); + assert.match(result.body, /## What's Changed/); + assert.match(result.body, /Full Changelog/); + assert.doesNotMatch(result.body, /source_refs/); + assert.doesNotMatch(result.body, /doc-agent-release-notes-json/); +}); + +test("rejects English text that still contains Chinese", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [{ ...validDraft.release_items[0], text_en: "Added L3 抽象 model configuration." }], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "invalid_text_en")); +}); + +test("rejects missing or invented source refs", () => { + const missing = validateDraft( + { + ...validDraft, + release_items: [{ ...validDraft.release_items[0], source_refs: [] }, validDraft.release_items[1]], + }, + evidence, + ); + assert.equal(missing.ok, false); + assert.ok(missing.issues.some((issue) => issue.kind === "missing_source_refs")); + + const invented = validateDraft( + { + ...validDraft, + release_items: [{ ...validDraft.release_items[0], source_refs: ["deadbee"] }, validDraft.release_items[1]], + }, + evidence, + ); + assert.equal(invented.ok, false); + assert.ok(invented.issues.some((issue) => issue.kind === "invalid_source_ref")); +}); + +test("rejects drafts that drop important commits", () => { + const result = validateDraft({ ...validDraft, release_items: [validDraft.release_items[0]] }, evidence); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "missing_required_ref" && issue.ref === "59c14746")); +}); + +test("rejects plugin docs drafts that are too fragmented for the changelog page", () => { + const noisyItems = Array.from({ length: 13 }, (_item, index) => ({ + category: "Improved", + text_cn: `**本地插件优化 ${index + 1}**:整理发布说明展示效果。`, + text_en: `**Local plugin improvement ${index + 1}**: Refined release-note presentation.`, + source_refs: [index % 2 === 0 ? "9deb941e" : "59c14746"], + })); + const result = validateDraft({ ...validDraft, release_items: noisyItems }, evidence); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "too_many_release_items")); +}); + +test("rejects plugin docs bullets that are too long to render well", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + text_cn: `**L3 抽象模型配置**:${"用于发布说明质量验证的重复中文描述。".repeat(12)}`, + text_en: `**L3 abstraction model configuration**: ${"This repeated English detail is intentionally too verbose for a changelog bullet. ".repeat(6)}`, + }, + validDraft.release_items[1], + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "text_cn_too_long")); + assert.ok(result.issues.some((issue) => issue.kind === "text_en_too_long")); +}); + +test("rejects generic Chinese plugin docs copy", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + text_cn: "**本地插件能力**:新增了本地插件能力功能。", + }, + validDraft.release_items[1], + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "generic_text_cn")); +}); + +test("rejects generic English plugin docs copy", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + text_en: "**Local plugin update**: Fixed local plugin issue.", + }, + validDraft.release_items[1], + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "generic_text_en")); +}); + +test("rejects raw Conventional Commit subjects copied into docs copy", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + { + ...validDraft.release_items[0], + text_en: "**V7 defaults**: fix(plugin): preserve V7 session defaults (#2158).", + }, + validDraft.release_items[1], + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "raw_commit_subject_text" && issue.field === "text_en")); +}); + +test("rejects duplicate plugin docs bullets that should be merged", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + validDraft.release_items[0], + { + ...validDraft.release_items[0], + source_refs: ["59c14746"], + }, + ], + }, + evidence, + ); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => issue.kind === "duplicate_release_item")); +}); + +test("accepts concise impact-oriented Chinese plugin docs copy", () => { + const result = validateDraft( + { + ...validDraft, + release_items: [ + validDraft.release_items[0], + { + ...validDraft.release_items[1], + text_cn: "**向量扫描性能优化**:优化了自适应向量扫描批处理,提升了大数据量同步时的处理效率。", + }, + ], + }, + evidence, + ); + assert.equal(result.ok, true); +}); + +test("allows the draft service one initial response plus three repair attempts", async () => { + const originalFetch = globalThis.fetch; + const originalUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + const originalToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + const originalOffline = process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + const invalidDraft = { + ...validDraft, + release_items: [validDraft.release_items[0]], + }; + const userFacingEvidence = { + ...evidence, + has_user_facing_product_changes: true, + }; + + let callCount = 0; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = "https://example.invalid/internal/release-notes/draft"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + delete process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + globalThis.fetch = async () => { + callCount += 1; + return { + ok: true, + status: 200, + text: async () => JSON.stringify(callCount < 4 ? invalidDraft : validDraft), + }; + }; + + const draft = await requestDocAgentDraft(userFacingEvidence); + + assert.equal(callCount, 4); + assert.equal(draft.validation_attempt_count, 4); + assert.equal(draft.repair_attempt_count, 3); + assert.equal(draft.validation_report.ok, true); + } finally { + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + else process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = originalUrl; + if (originalToken === undefined) delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + else process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = originalToken; + if (originalOffline === undefined) delete process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + else process.env.ALLOW_OFFLINE_DOCS_PREVIEW = originalOffline; + } +}); + +test("builds Plugin tab previews without exposing source refs in page content", () => { + const preview = buildDocsPreview(validDraft, evidence); + assert.equal(preview.source_id, "openclaw-local-plugin"); + assert.equal(preview.source_repo, "MemTensor/MemOS"); + assert.equal(preview.previous_tag, "v2.0.24"); + assert.equal(preview.current_tag, "v2.0.25"); + assert.equal(preview.memos_release_tag, "v2.0.25"); + assert.equal(preview.local_plugin_version, "v2.0.11"); + assert.equal(preview.local_plugin_previous_version, "v2.0.10"); + assert.equal(preview.would_create_docs_pr, false); + assert.deepEqual(preview.files, ["content/cn/plugin-changelog.yml", "content/en/plugin-changelog.yml"]); + assert.equal(preview.cn.name, "v2.0.11"); + assert.equal(preview.cn.source.repo, "MemTensor/MemOS"); + assert.equal(preview.cn.source.memos_release_tag, "v2.0.25"); + assert.equal(preview.cn.source.local_plugin_version, "v2.0.11"); + assert.deepEqual(preview.cn.source.product_paths, ["apps/memos-local-plugin/**"]); + assert.equal(preview.cn.products.plugin["New Features"][0].type, "MemOS 本地插件"); + assert.equal(preview.en.products.plugin.Improvements[0].type, "MemOS Local Plugin"); + + const markdown = docsPreviewMarkdown(preview, validDraft, evidence); + assert.match(markdown, /MemOS 本地插件-v2\.0\.11/); + assert.match(markdown, /memos_release_range: v2\.0\.24\.\.\.v2\.0\.25/); + assert.match(markdown, /Source Refs/); + assert.match(markdown, /9deb941e/); + assert.match(markdown, /59c14746/); +}); + +test("allows an empty Plugin tab draft when a MemOS release has no local-plugin changes", () => { + const noChangeEvidence = { + ...evidence, + has_product_changes: false, + has_user_facing_product_changes: false, + skip_reason: "no local plugin path changes in apps/memos-local-plugin/**", + commits: [], + important_commits: [], + required_source_refs: [], + changed_files: [], + }; + const emptyDraft = { ok: true, needs_review: false, release_items: [] }; + const validation = validateDraft(emptyDraft, noChangeEvidence); + assert.equal(validation.ok, true); + assert.equal(validation.coverage.required_count, 0); + + const preview = buildDocsPreview(emptyDraft, noChangeEvidence); + assert.equal(preview.docs_action, "skip_plugin_tab_entry"); + assert.equal(preview.skip_reason, "no local plugin path changes in apps/memos-local-plugin/**"); + assert.deepEqual(preview.cn.products.plugin, {}); + assert.deepEqual(preview.en.products.plugin, {}); + const markdown = docsPreviewMarkdown(preview, emptyDraft, noChangeEvidence); + assert.match(markdown, /no local plugin path changes/); + assert.doesNotMatch(markdown, /Source Refs/); +}); + +test("skips Plugin tab docs when local-plugin changes are maintenance-only", () => { + withFixtureRepo(() => { + writeRepoFile("apps/memos-local-plugin/src/index.test.js", "export const coversSmokePath = true;\n"); + commitAll("test(plugin): cover standalone bridge smoke path (#10)"); + + const result = collectLocalPluginEvidence({ + previousTag: "v9.9.0", + currentTag: "v9.9.1", + currentRef: "HEAD", + targetVersion: "9.9.1", + repo: "MemTensor/MemOS", + }); + + assert.equal(result.has_product_changes, true); + assert.equal(result.has_user_facing_product_changes, false); + assert.match(result.skip_reason, /no user-facing/); + assert.deepEqual(result.important_commits, []); + + const emptyDraft = { ok: true, needs_review: false, release_items: [] }; + const validation = validateDraft(emptyDraft, result); + assert.equal(validation.ok, true); + + const preview = buildDocsPreview(emptyDraft, result); + assert.equal(preview.docs_action, "skip_plugin_tab_entry"); + assert.deepEqual(preview.cn.products.plugin, {}); + assert.deepEqual(preview.en.products.plugin, {}); + assert.match(docsPreviewMarkdown(preview, emptyDraft, result), /no user-facing/); + }); +}); diff --git a/.github/workflows/memos-local-plugin-publish.yml b/.github/workflows/memos-local-plugin-publish.yml index e5101c762..b6607a475 100644 --- a/.github/workflows/memos-local-plugin-publish.yml +++ b/.github/workflows/memos-local-plugin-publish.yml @@ -1,4 +1,4 @@ -name: MemOS Local Plugin (V2) — Build & Publish +name: MemOS Local Plugin (V2) — Legacy Standalone Publisher on: workflow_dispatch: @@ -28,6 +28,10 @@ on: required: true type: boolean default: false + legacy_publish_confirmation: + description: "Required only when dry_run=false. Must exactly equal: LEGACY PUBLISH memos-local-plugin-v" + required: false + default: "" workflow_call: inputs: version: @@ -59,6 +63,11 @@ on: required: false type: boolean default: false + legacy_publish_confirmation: + description: "Required only when dry_run=false. Must exactly equal: LEGACY PUBLISH memos-local-plugin-v" + required: false + type: string + default: "" concurrency: group: memos-local-plugin-publish @@ -73,7 +82,32 @@ permissions: pull-requests: write jobs: + guard-legacy-publish: + runs-on: ubuntu-latest + steps: + - name: Validate legacy standalone publish confirmation + shell: bash + working-directory: . + env: + DRY_RUN: ${{ inputs.dry_run }} + RELEASE_VERSION: ${{ inputs.version }} + LEGACY_PUBLISH_CONFIRMATION: ${{ inputs.legacy_publish_confirmation }} + run: | + set -euo pipefail + if [ "${DRY_RUN}" = "true" ]; then + echo "dry_run=true; legacy standalone publish confirmation is not required." + exit 0 + fi + + expected="LEGACY PUBLISH memos-local-plugin-v${RELEASE_VERSION}" + if [ "${LEGACY_PUBLISH_CONFIRMATION}" != "${expected}" ]; then + echo "::error::This workflow is the legacy standalone local-plugin publisher. The current official path is MemOS Release — Publish with v* tags and apps/memos-local-plugin/** extraction." + echo "::error::To intentionally run this legacy publisher, set legacy_publish_confirmation exactly to: ${expected}" + exit 1 + fi + build-prebuilds: + needs: guard-legacy-publish strategy: matrix: include: @@ -446,7 +480,7 @@ jobs: gh release create "${release_tag}" \ --repo "${GITHUB_REPOSITORY}" \ --target "$(git rev-parse HEAD)" \ - --title "OpenClaw Local Plugin v${RELEASE_VERSION}" \ + --title "MemOS Local Plugin v${RELEASE_VERSION}" \ --notes-file "${RELEASE_NOTES_FILE}" \ "${release_flags[@]}" status=$? diff --git a/.github/workflows/memos-release-post-merge-dry-run.yml b/.github/workflows/memos-release-post-merge-dry-run.yml new file mode 100644 index 000000000..90cc66627 --- /dev/null +++ b/.github/workflows/memos-release-post-merge-dry-run.yml @@ -0,0 +1,139 @@ +name: MemOS Release — Post-Merge Dry Run + +on: + push: + branches: + - main + paths: + - ".github/workflows/memos-release-publish.yml" + - ".github/workflows/memos-release-pre-merge-dry-run.yml" + - ".github/workflows/memos-release-post-merge-dry-run.yml" + - ".github/scripts/prepare-memos-release.mjs" + - ".github/scripts/prepare-memos-release.test.mjs" + - ".github/scripts/retry.sh" + +permissions: + contents: read + +jobs: + dry-run: + if: ${{ github.repository == 'MemTensor/MemOS' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fetch release refs + shell: bash + run: | + set -euo pipefail + git fetch --tags --force origin + git fetch origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Infer next stable release version for preview + id: version + shell: bash + run: | + set -euo pipefail + latest="$( + git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged origin/main --sort=-v:refname | + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | + head -n 1 + )" + if [ -z "${latest}" ]; then + echo "::error::Cannot infer the latest MemOS stable tag from origin/main." + exit 1 + fi + version="${latest#v}" + IFS=. read -r major minor patch <<< "${version}" + next_version="${major}.${minor}.$((patch + 1))" + echo "version=${next_version}" >> "${GITHUB_OUTPUT}" + echo "latest_tag=${latest}" >> "${GITHUB_OUTPUT}" + + - name: Run release workflow tests + shell: bash + run: node --test .github/scripts/prepare-memos-release.test.mjs + + - name: Prepare offline release inspection + id: prepare + shell: bash + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + TARGET_REF: origin/main + DRY_RUN: true + GITHUB_TOKEN: ${{ github.token }} + ALLOW_OFFLINE_DOCS_PREVIEW: true + run: | + bash .github/scripts/retry.sh --attempts 2 --label "prepare offline MemOS release inspection" -- \ + node .github/scripts/prepare-memos-release.mjs + + - name: Upload release inspection + uses: actions/upload-artifact@v4 + with: + name: memos-release-inspection + path: ${{ steps.prepare.outputs.inspection_dir }} + if-no-files-found: error + + - name: Summarize inspection + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + LATEST_TAG: ${{ steps.version.outputs.latest_tag }} + CURRENT_TAG: ${{ steps.prepare.outputs.current_tag }} + PREVIOUS_TAG: ${{ steps.prepare.outputs.previous_tag }} + LOCAL_PLUGIN_VERSION: ${{ steps.prepare.outputs.local_plugin_version }} + LOCAL_PLUGIN_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_previous_version }} + LOCAL_PLUGIN_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_version_changed }} + LOCAL_PLUGIN_VERSION_REQUIRED: ${{ steps.prepare.outputs.local_plugin_version_required }} + LOCAL_PLUGIN_VERSION_SOURCE: ${{ steps.prepare.outputs.local_plugin_version_source }} + LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED: ${{ steps.prepare.outputs.local_plugin_version_auto_incremented }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED: ${{ steps.prepare.outputs.local_plugin_version_input_ignored }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON: ${{ steps.prepare.outputs.local_plugin_version_input_ignored_reason }} + LOCAL_PLUGIN_PACKAGE_VERSION: ${{ steps.prepare.outputs.local_plugin_package_version }} + LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_package_previous_version }} + LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_package_version_changed }} + EXISTING_TAG_STATUS: ${{ steps.prepare.outputs.existing_tag_status }} + EXISTING_TAG_SHA: ${{ steps.prepare.outputs.existing_tag_sha }} + PUBLISH_BLOCKED: ${{ steps.prepare.outputs.publish_blocked }} + PUBLISH_BLOCK_REASON: ${{ steps.prepare.outputs.publish_block_reason }} + HAS_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_product_changes }} + HAS_USER_FACING_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_user_facing_product_changes }} + DOCS_ACTION: ${{ steps.prepare.outputs.docs_action }} + SKIP_REASON: ${{ steps.prepare.outputs.skip_reason }} + RELEASE_NOTES_SOURCE: ${{ steps.prepare.outputs.release_notes_source }} + run: | + { + echo "## MemOS release post-merge dry run" + echo "" + echo "- inferred_version: ${VERSION}" + echo "- latest_tag_on_main: ${LATEST_TAG}" + echo "- current_tag: ${CURRENT_TAG}" + echo "- previous_tag: ${PREVIOUS_TAG}" + echo "- local_plugin_version: ${LOCAL_PLUGIN_VERSION}" + echo "- local_plugin_previous_version: ${LOCAL_PLUGIN_PREVIOUS_VERSION}" + echo "- local_plugin_version_changed: ${LOCAL_PLUGIN_VERSION_CHANGED}" + echo "- local_plugin_version_required: ${LOCAL_PLUGIN_VERSION_REQUIRED}" + echo "- local_plugin_version_source: ${LOCAL_PLUGIN_VERSION_SOURCE}" + echo "- local_plugin_version_auto_incremented: ${LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED}" + echo "- local_plugin_version_input_ignored: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED}" + echo "- local_plugin_version_input_ignored_reason: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON:-n/a}" + echo "- local_plugin_package_version: ${LOCAL_PLUGIN_PACKAGE_VERSION}" + echo "- local_plugin_package_previous_version: ${LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION}" + echo "- local_plugin_package_version_changed: ${LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED}" + echo "- existing_tag_status: ${EXISTING_TAG_STATUS}" + echo "- existing_tag_sha: ${EXISTING_TAG_SHA:-n/a}" + echo "- publish_blocked: ${PUBLISH_BLOCKED}" + echo "- publish_block_reason: ${PUBLISH_BLOCK_REASON:-n/a}" + echo "- release_notes_source: ${RELEASE_NOTES_SOURCE}" + echo "- local_plugin_changed: ${HAS_PRODUCT_CHANGES}" + echo "- local_plugin_user_facing_changed: ${HAS_USER_FACING_PRODUCT_CHANGES}" + echo "- docs_action: ${DOCS_ACTION}" + echo "- skip_reason: ${SKIP_REASON:-n/a}" + echo "" + echo "This post-merge check never creates tags, GitHub Releases, docs PRs, or deployments." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/memos-release-pre-merge-dry-run.yml b/.github/workflows/memos-release-pre-merge-dry-run.yml new file mode 100644 index 000000000..927bd56a6 --- /dev/null +++ b/.github/workflows/memos-release-pre-merge-dry-run.yml @@ -0,0 +1,137 @@ +name: MemOS Release — Pre-Merge Dry Run + +on: + pull_request: + paths: + - ".github/workflows/memos-release-publish.yml" + - ".github/workflows/memos-release-pre-merge-dry-run.yml" + - ".github/workflows/memos-release-post-merge-dry-run.yml" + - ".github/scripts/prepare-memos-release.mjs" + - ".github/scripts/prepare-memos-release.test.mjs" + - ".github/scripts/retry.sh" + +permissions: + contents: read + +jobs: + dry-run: + if: ${{ github.repository == 'MemTensor/MemOS' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fetch release refs + shell: bash + run: | + set -euo pipefail + git fetch --tags --force origin + git fetch origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Infer next stable release version for preview + id: version + shell: bash + run: | + set -euo pipefail + latest="$( + git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged origin/main --sort=-v:refname | + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | + head -n 1 + )" + if [ -z "${latest}" ]; then + echo "::error::Cannot infer the latest MemOS stable tag from origin/main." + exit 1 + fi + version="${latest#v}" + IFS=. read -r major minor patch <<< "${version}" + next_version="${major}.${minor}.$((patch + 1))" + echo "version=${next_version}" >> "${GITHUB_OUTPUT}" + echo "latest_tag=${latest}" >> "${GITHUB_OUTPUT}" + + - name: Run release workflow tests + shell: bash + run: node --test .github/scripts/prepare-memos-release.test.mjs + + - name: Prepare offline release inspection + id: prepare + shell: bash + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + TARGET_REF: origin/main + DRY_RUN: true + GITHUB_TOKEN: ${{ github.token }} + ALLOW_OFFLINE_DOCS_PREVIEW: true + run: | + bash .github/scripts/retry.sh --attempts 2 --label "prepare offline MemOS release inspection" -- \ + node .github/scripts/prepare-memos-release.mjs + + - name: Upload release inspection + uses: actions/upload-artifact@v4 + with: + name: memos-release-inspection + path: ${{ steps.prepare.outputs.inspection_dir }} + if-no-files-found: error + + - name: Summarize inspection + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + LATEST_TAG: ${{ steps.version.outputs.latest_tag }} + CURRENT_TAG: ${{ steps.prepare.outputs.current_tag }} + PREVIOUS_TAG: ${{ steps.prepare.outputs.previous_tag }} + LOCAL_PLUGIN_VERSION: ${{ steps.prepare.outputs.local_plugin_version }} + LOCAL_PLUGIN_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_previous_version }} + LOCAL_PLUGIN_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_version_changed }} + LOCAL_PLUGIN_VERSION_REQUIRED: ${{ steps.prepare.outputs.local_plugin_version_required }} + LOCAL_PLUGIN_VERSION_SOURCE: ${{ steps.prepare.outputs.local_plugin_version_source }} + LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED: ${{ steps.prepare.outputs.local_plugin_version_auto_incremented }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED: ${{ steps.prepare.outputs.local_plugin_version_input_ignored }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON: ${{ steps.prepare.outputs.local_plugin_version_input_ignored_reason }} + LOCAL_PLUGIN_PACKAGE_VERSION: ${{ steps.prepare.outputs.local_plugin_package_version }} + LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_package_previous_version }} + LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_package_version_changed }} + EXISTING_TAG_STATUS: ${{ steps.prepare.outputs.existing_tag_status }} + EXISTING_TAG_SHA: ${{ steps.prepare.outputs.existing_tag_sha }} + PUBLISH_BLOCKED: ${{ steps.prepare.outputs.publish_blocked }} + PUBLISH_BLOCK_REASON: ${{ steps.prepare.outputs.publish_block_reason }} + HAS_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_product_changes }} + HAS_USER_FACING_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_user_facing_product_changes }} + DOCS_ACTION: ${{ steps.prepare.outputs.docs_action }} + SKIP_REASON: ${{ steps.prepare.outputs.skip_reason }} + RELEASE_NOTES_SOURCE: ${{ steps.prepare.outputs.release_notes_source }} + run: | + { + echo "## MemOS release pre-merge dry run" + echo "" + echo "- inferred_version: ${VERSION}" + echo "- latest_tag_on_main: ${LATEST_TAG}" + echo "- current_tag: ${CURRENT_TAG}" + echo "- previous_tag: ${PREVIOUS_TAG}" + echo "- local_plugin_version: ${LOCAL_PLUGIN_VERSION}" + echo "- local_plugin_previous_version: ${LOCAL_PLUGIN_PREVIOUS_VERSION}" + echo "- local_plugin_version_changed: ${LOCAL_PLUGIN_VERSION_CHANGED}" + echo "- local_plugin_version_required: ${LOCAL_PLUGIN_VERSION_REQUIRED}" + echo "- local_plugin_version_source: ${LOCAL_PLUGIN_VERSION_SOURCE}" + echo "- local_plugin_version_auto_incremented: ${LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED}" + echo "- local_plugin_version_input_ignored: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED}" + echo "- local_plugin_version_input_ignored_reason: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON:-n/a}" + echo "- local_plugin_package_version: ${LOCAL_PLUGIN_PACKAGE_VERSION}" + echo "- local_plugin_package_previous_version: ${LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION}" + echo "- local_plugin_package_version_changed: ${LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED}" + echo "- existing_tag_status: ${EXISTING_TAG_STATUS}" + echo "- existing_tag_sha: ${EXISTING_TAG_SHA:-n/a}" + echo "- publish_blocked: ${PUBLISH_BLOCKED}" + echo "- publish_block_reason: ${PUBLISH_BLOCK_REASON:-n/a}" + echo "- release_notes_source: ${RELEASE_NOTES_SOURCE}" + echo "- local_plugin_changed: ${HAS_PRODUCT_CHANGES}" + echo "- local_plugin_user_facing_changed: ${HAS_USER_FACING_PRODUCT_CHANGES}" + echo "- docs_action: ${DOCS_ACTION}" + echo "- skip_reason: ${SKIP_REASON:-n/a}" + echo "" + echo "This PR check never creates tags, GitHub Releases, docs PRs, or deployments." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/memos-release-publish.yml b/.github/workflows/memos-release-publish.yml new file mode 100644 index 000000000..baa6bb781 --- /dev/null +++ b/.github/workflows/memos-release-publish.yml @@ -0,0 +1,221 @@ +name: MemOS Release — Publish + +on: + workflow_dispatch: + inputs: + version: + description: "MemOS version to release, without leading v (for example 2.0.25)" + required: true + target_ref: + description: "Git ref to release from. Use main for normal releases." + required: false + default: "main" + local_plugin_version: + description: "Optional guard: expected resolved OpenClaw local plugin docs version, without leading v. Leave blank to auto-increment when needed." + required: false + default: "" + dry_run: + description: "Preview only. Skip tag creation, GitHub Release, docs PR, and deployment." + required: true + type: boolean + default: true + create_draft_release: + description: "When dry_run=false, create a Draft GitHub Release first. Publish manually to trigger release.published." + required: true + type: boolean + default: true + publish_confirmation: + description: "Required only when dry_run=false. Must exactly equal: PUBLISH v" + required: false + default: "" + +concurrency: + group: memos-release-publish + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + if: ${{ github.repository == 'MemTensor/MemOS' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fetch release refs + shell: bash + run: | + set -euo pipefail + git fetch --tags --force origin + git fetch origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Run release workflow tests + shell: bash + run: node --test .github/scripts/prepare-memos-release.test.mjs + + - name: Prepare MemOS release inspection + id: prepare + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + TARGET_REF: ${{ inputs.target_ref || 'main' }} + LOCAL_PLUGIN_VERSION: ${{ inputs.local_plugin_version }} + DRY_RUN: ${{ inputs.dry_run }} + PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }} + 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 }} + run: | + bash .github/scripts/retry.sh --attempts 2 --label "prepare MemOS release inspection" -- \ + node .github/scripts/prepare-memos-release.mjs + + - name: Upload release inspection + uses: actions/upload-artifact@v4 + with: + name: memos-release-inspection + path: ${{ steps.prepare.outputs.inspection_dir }} + if-no-files-found: error + + - name: Summarize dry-run inspection + shell: bash + env: + CURRENT_TAG: ${{ steps.prepare.outputs.current_tag }} + PREVIOUS_TAG: ${{ steps.prepare.outputs.previous_tag }} + LOCAL_PLUGIN_VERSION: ${{ steps.prepare.outputs.local_plugin_version }} + LOCAL_PLUGIN_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_previous_version }} + LOCAL_PLUGIN_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_version_changed }} + LOCAL_PLUGIN_VERSION_REQUIRED: ${{ steps.prepare.outputs.local_plugin_version_required }} + LOCAL_PLUGIN_VERSION_SOURCE: ${{ steps.prepare.outputs.local_plugin_version_source }} + LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED: ${{ steps.prepare.outputs.local_plugin_version_auto_incremented }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED: ${{ steps.prepare.outputs.local_plugin_version_input_ignored }} + LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON: ${{ steps.prepare.outputs.local_plugin_version_input_ignored_reason }} + LOCAL_PLUGIN_EXPECTED_VERSION: ${{ steps.prepare.outputs.local_plugin_expected_version }} + LOCAL_PLUGIN_PACKAGE_VERSION: ${{ steps.prepare.outputs.local_plugin_package_version }} + LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION: ${{ steps.prepare.outputs.local_plugin_package_previous_version }} + LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED: ${{ steps.prepare.outputs.local_plugin_package_version_changed }} + EXISTING_TAG_STATUS: ${{ steps.prepare.outputs.existing_tag_status }} + EXISTING_TAG_SHA: ${{ steps.prepare.outputs.existing_tag_sha }} + PUBLISH_BLOCKED: ${{ steps.prepare.outputs.publish_blocked }} + PUBLISH_BLOCK_REASON: ${{ steps.prepare.outputs.publish_block_reason }} + SOURCE_ID: ${{ steps.prepare.outputs.source_id }} + TARGET_REF: ${{ steps.prepare.outputs.target_ref }} + TARGET_SHA: ${{ steps.prepare.outputs.target_sha }} + HAS_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_product_changes }} + HAS_USER_FACING_PRODUCT_CHANGES: ${{ steps.prepare.outputs.has_user_facing_product_changes }} + DOCS_ACTION: ${{ steps.prepare.outputs.docs_action }} + SKIP_REASON: ${{ steps.prepare.outputs.skip_reason }} + RELEASE_NOTES_SOURCE: ${{ steps.prepare.outputs.release_notes_source }} + VALIDATION_ATTEMPT_COUNT: ${{ steps.prepare.outputs.validation_attempt_count }} + REPAIR_ATTEMPT_COUNT: ${{ steps.prepare.outputs.repair_attempt_count }} + run: | + { + echo "## MemOS release inspection" + echo "" + echo "- source_id: ${SOURCE_ID}" + echo "- current_tag: ${CURRENT_TAG}" + echo "- previous_tag: ${PREVIOUS_TAG}" + echo "- local_plugin_version: ${LOCAL_PLUGIN_VERSION}" + echo "- local_plugin_previous_version: ${LOCAL_PLUGIN_PREVIOUS_VERSION}" + echo "- local_plugin_version_changed: ${LOCAL_PLUGIN_VERSION_CHANGED}" + echo "- local_plugin_version_required: ${LOCAL_PLUGIN_VERSION_REQUIRED}" + echo "- local_plugin_version_source: ${LOCAL_PLUGIN_VERSION_SOURCE}" + echo "- local_plugin_version_auto_incremented: ${LOCAL_PLUGIN_VERSION_AUTO_INCREMENTED}" + echo "- local_plugin_version_input_ignored: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED}" + echo "- local_plugin_version_input_ignored_reason: ${LOCAL_PLUGIN_VERSION_INPUT_IGNORED_REASON:-n/a}" + echo "- local_plugin_expected_version: ${LOCAL_PLUGIN_EXPECTED_VERSION:-n/a}" + echo "- local_plugin_package_version: ${LOCAL_PLUGIN_PACKAGE_VERSION}" + echo "- local_plugin_package_previous_version: ${LOCAL_PLUGIN_PACKAGE_PREVIOUS_VERSION}" + echo "- local_plugin_package_version_changed: ${LOCAL_PLUGIN_PACKAGE_VERSION_CHANGED}" + echo "- existing_tag_status: ${EXISTING_TAG_STATUS}" + echo "- existing_tag_sha: ${EXISTING_TAG_SHA:-n/a}" + echo "- publish_blocked: ${PUBLISH_BLOCKED}" + echo "- publish_block_reason: ${PUBLISH_BLOCK_REASON:-n/a}" + echo "- target_ref: ${TARGET_REF}" + echo "- target_sha: ${TARGET_SHA}" + echo "- release_notes_source: ${RELEASE_NOTES_SOURCE}" + echo "- local_plugin_changed: ${HAS_PRODUCT_CHANGES}" + echo "- local_plugin_user_facing_changed: ${HAS_USER_FACING_PRODUCT_CHANGES}" + echo "- docs_action: ${DOCS_ACTION}" + echo "- skip_reason: ${SKIP_REASON:-n/a}" + echo "- validation_attempt_count: ${VALIDATION_ATTEMPT_COUNT}" + echo "- repair_attempt_count: ${REPAIR_ATTEMPT_COUNT}" + echo "" + echo "The uploaded artifact contains the public MemOS release notes preview and the OpenClaw local plugin docs preview." + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Stop after dry run + if: ${{ inputs.dry_run }} + shell: bash + run: | + echo "::notice::dry_run=true; skipped tag creation and GitHub Release." + + - name: Create tag and GitHub Release + if: ${{ !inputs.dry_run }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_TAG: ${{ steps.prepare.outputs.current_tag }} + TARGET_SHA: ${{ steps.prepare.outputs.target_sha }} + RELEASE_NOTES_FILE: ${{ steps.prepare.outputs.release_notes_file }} + CREATE_DRAFT_RELEASE: ${{ inputs.create_draft_release }} + PUBLISH_BLOCKED: ${{ steps.prepare.outputs.publish_blocked }} + PUBLISH_BLOCK_REASON: ${{ steps.prepare.outputs.publish_block_reason }} + run: | + set -euo pipefail + if [ "${PUBLISH_BLOCKED}" = "true" ]; then + echo "::error::${PUBLISH_BLOCK_REASON}" + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + 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}" ]; then + if [ "${remote_tag_sha}" != "${TARGET_SHA}" ]; then + echo "::error::Remote tag ${CURRENT_TAG} already exists at ${remote_tag_sha}, expected ${TARGET_SHA}. This workflow will not move an existing release tag automatically; delete or recreate the manual tag after release-owner review, then rerun." + exit 1 + fi + echo "::notice::Remote tag ${CURRENT_TAG} already exists at ${TARGET_SHA}; leaving it unchanged." + else + git tag "${CURRENT_TAG}" "${TARGET_SHA}" + git push origin "refs/tags/${CURRENT_TAG}" + fi + + if gh release view "${CURRENT_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + release_info="$( + gh release view "${CURRENT_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,url \ + --jq '[.isDraft, .url] | @tsv' + )" + IFS=$'\t' read -r is_draft release_url <<< "${release_info}" + if [ "${is_draft}" = "true" ] && [ "${CREATE_DRAFT_RELEASE}" != "true" ]; then + echo "::error::GitHub Release ${CURRENT_TAG} already exists as draft (${release_url}); publish or delete it manually before rerunning." + exit 1 + fi + echo "::notice::GitHub Release ${CURRENT_TAG} already exists (${release_url}); leaving it unchanged." + else + flags=() + if [ "${CREATE_DRAFT_RELEASE}" = "true" ]; then + flags+=(--draft) + fi + gh release create "${CURRENT_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${TARGET_SHA}" \ + --title "Release ${CURRENT_TAG}" \ + --notes-file "${RELEASE_NOTES_FILE}" \ + "${flags[@]}" + fi From b5c33cbb4327967f181ba27078375e130c20a372 Mon Sep 17 00:00:00 2001 From: MLittleprince Date: Fri, 31 Jul 2026 15:14:30 +0800 Subject: [PATCH 2/2] ci: address MemOS release workflow review --- .github/scripts/prepare-memos-release.mjs | 16 +-- .../scripts/prepare-memos-release.test.mjs | 124 +++++++++++++++--- .../workflows/memos-local-plugin-publish.yml | 2 + .../memos-release-post-merge-dry-run.yml | 8 ++ .../memos-release-pre-merge-dry-run.yml | 8 ++ .github/workflows/memos-release-publish.yml | 25 +++- 6 files changed, 155 insertions(+), 28 deletions(-) diff --git a/.github/scripts/prepare-memos-release.mjs b/.github/scripts/prepare-memos-release.mjs index 0ba90c5e4..9674eab97 100644 --- a/.github/scripts/prepare-memos-release.mjs +++ b/.github/scripts/prepare-memos-release.mjs @@ -483,7 +483,7 @@ export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = const expectedVersionRaw = String(expectedVersionInput || "").trim(); const previousReleasedVersion = cleanLocalPluginVersion( evidence.local_plugin_previous_version_raw || evidence.local_plugin_package_previous_version_raw, - "previous released OpenClaw local plugin version", + "previous released MemOS local plugin version", ); const previousPackageVersion = cleanLocalPluginVersion( evidence.local_plugin_package_previous_version_raw || evidence.local_plugin_previous_version_raw, @@ -497,7 +497,7 @@ export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = const packageVsReleasedOrder = compareSemver(currentPackageVersion, previousReleasedVersion); if (packageOrder < 0) { fail( - `OpenClaw local plugin package version moved backwards: ${displayVersion(previousPackageVersion)} -> ${displayVersion(currentPackageVersion)}.`, + `MemOS local plugin package version moved backwards: ${displayVersion(previousPackageVersion)} -> ${displayVersion(currentPackageVersion)}.`, ); } @@ -530,7 +530,7 @@ export function validateLocalPluginVersionPlan(evidence, expectedVersionInput = if (expectedVersion && expectedVersion !== resolvedVersion) { fail( - `local_plugin_version input ${displayVersion(expectedVersion)} does not match the resolved OpenClaw local plugin docs version ${displayVersion(resolvedVersion)}.`, + `local_plugin_version input ${displayVersion(expectedVersion)} does not match the resolved MemOS local plugin docs version ${displayVersion(resolvedVersion)}.`, ); } return { @@ -681,7 +681,7 @@ export function collectLocalPluginEvidence({ previousTag, currentTag, currentRef "Each bullet should explain the user-facing impact in one sentence.", "Avoid generic restatements such as '新增了 X 功能', '优化了 X 性能', or '修复了 X 问题'.", "Avoid raw commit subject wording such as 'fix(plugin): ... (#123)'; rewrite it into product-facing copy.", - "A good bullet names the capability and says why it matters for OpenClaw local plugin users.", + "A good bullet names the capability and says why it matters for MemOS local plugin users.", ], curation_policy: [ "Use Conventional Commit type/scope as a hint, not as final copy.", @@ -1135,7 +1135,7 @@ export async function requestDocAgentDraft(evidence) { ok: true, needs_review: false, confidence: "high", - warnings: [evidence.skip_reason || "No user-facing OpenClaw local plugin changes in this MemOS release range."], + warnings: [evidence.skip_reason || "No user-facing MemOS local plugin changes in this MemOS release range."], release_items: [], coverage: { required_count: 0, covered_required_count: 0, missing_required_count: 0 }, validation_attempt_count: 1, @@ -1288,7 +1288,7 @@ export function docsPreviewMarkdown(preview, draft, evidence) { "", ]; if (!draft.release_items.length) { - lines.push(evidence.skip_reason || "No OpenClaw local plugin docs entries were generated for this MemOS release range.", ""); + lines.push(evidence.skip_reason || "No MemOS local plugin docs entries were generated for this MemOS release range.", ""); return lines.join("\n"); } for (const [language, label, field] of [ @@ -1570,8 +1570,8 @@ export async function run() { console.log(`Prepared MemOS release inspection in ${outputRoot}`); console.log(`Release notes source: ${releaseNotes.source}`); console.log(`Range: ${previousTag}..${target.sha}`); - console.log(`OpenClaw local plugin version: ${evidence.local_plugin_previous_version} -> ${evidence.local_plugin_version}`); - console.log(`OpenClaw local plugin changed files: ${evidence.changed_files.length}`); + console.log(`MemOS local plugin version: ${evidence.local_plugin_previous_version} -> ${evidence.local_plugin_version}`); + console.log(`MemOS local plugin changed files: ${evidence.changed_files.length}`); } if (import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/.github/scripts/prepare-memos-release.test.mjs b/.github/scripts/prepare-memos-release.test.mjs index 550af23a0..f0c63b922 100644 --- a/.github/scripts/prepare-memos-release.test.mjs +++ b/.github/scripts/prepare-memos-release.test.mjs @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { PRODUCT_ID, @@ -27,6 +28,11 @@ import { validateReleaseTarget, } from "./prepare-memos-release.mjs"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const scriptsDir = __dirname; +const workflowsDir = join(__dirname, "../workflows"); + const evidence = { repo: "MemTensor/MemOS", previous_tag: "v2.0.24", @@ -403,25 +409,45 @@ test("requires an exact publish confirmation for non-dry-run releases", () => { }); test("publish workflow defaults real releases to draft before release.published", () => { - const workflow = readFileSync(".github/workflows/memos-release-publish.yml", "utf8"); + const workflow = readFileSync(join(workflowsDir, "memos-release-publish.yml"), "utf8"); assert.match(workflow, /create_draft_release:/); assert.match(workflow, /default:\s+true/); assert.match(workflow, /CREATE_DRAFT_RELEASE/); + assert.match(workflow, /timeout-minutes:\s+30/); + assert.match(workflow, /Validate publish confirmation/); + assert.match(workflow, /publish_confirmation must exactly equal/); assert.match(workflow, /flags\+=\(--draft\)/); assert.match(workflow, /Publish manually to trigger release\.published/); }); test("legacy standalone local-plugin publisher requires an extra non-dry-run confirmation", () => { - const workflow = readFileSync(".github/workflows/memos-local-plugin-publish.yml", "utf8"); + const workflow = readFileSync(join(workflowsDir, "memos-local-plugin-publish.yml"), "utf8"); assert.match(workflow, /legacy_publish_confirmation:/); + assert.match(workflow, /legacy_publish_confirmation:\n\s+description:.*\n\s+required: false\n\s+type: string/s); assert.match(workflow, /guard-legacy-publish:/); + assert.match(workflow, /guard-legacy-publish:\n\s+runs-on: ubuntu-latest\n\s+timeout-minutes: 5/); assert.match(workflow, /expected="LEGACY PUBLISH memos-local-plugin-v\$\{RELEASE_VERSION\}"/); assert.match(workflow, /current official path is MemOS Release — Publish/); assert.match(workflow, /needs: guard-legacy-publish/); }); +test("read-only dry-run workflows declare bounded fallback behavior", () => { + const workflows = [ + readFileSync(join(workflowsDir, "memos-release-pre-merge-dry-run.yml"), "utf8"), + readFileSync(join(workflowsDir, "memos-release-post-merge-dry-run.yml"), "utf8"), + ]; + for (const workflow of workflows) { + assert.match(workflow, /concurrency:/); + assert.match(workflow, /permissions:\n\s+contents: read/); + assert.match(workflow, /timeout-minutes:\s+15/); + assert.match(workflow, /ALLOW_OFFLINE_DOCS_PREVIEW: true/); + assert.match(workflow, /offline_docs_preview: true/); + assert.match(workflow, /production publish does not set ALLOW_OFFLINE_DOCS_PREVIEW/); + } +}); + test("inspection artifact contract includes generic aliases and side-effect proof", () => { - const script = readFileSync(".github/scripts/prepare-memos-release.mjs", "utf8"); + const script = readFileSync(join(scriptsDir, "prepare-memos-release.mjs"), "utf8"); assert.match(script, /"release-notes\.md"/); assert.match(script, /"evidence\.json"/); assert.match(script, /"docs-preview\.md"/); @@ -684,10 +710,9 @@ test("keeps a reapplied local-plugin change after an earlier commit was reverted commitAll("feat: chunk batch reflection scoring"); const featureSha = git(["rev-parse", "HEAD"]).trim(); - git(["revert", "--no-edit", featureSha]); + git(["revert", "--no-commit", featureSha]); git([ "commit", - "--amend", "-q", "-m", "Revert \"feat: chunk batch reflection scoring\" (#12)", @@ -724,18 +749,39 @@ test("fallback topic rewrites V7 session default fixes into user-facing docs cop }); test("GitHub release notes fallback stays whole-repo when API access is unavailable", async () => { - const result = await generateGitHubReleaseNotes({ - repo: "MemTensor/MemOS", - currentTag: "v0.0.0-test", - targetSha: "HEAD", - previousTag: "HEAD", - token: "", - }); - assert.equal(result.source, "local-fallback-after-github-error"); - assert.match(result.body, /## What's Changed/); - assert.match(result.body, /Full Changelog/); - assert.doesNotMatch(result.body, /source_refs/); - assert.doesNotMatch(result.body, /doc-agent-release-notes-json/); + const originalCwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "memos-release-notes-fallback-")); + try { + process.chdir(root); + git(["init", "-q"]); + git(["config", "user.email", "release-test@example.invalid"]); + git(["config", "user.name", "Release Test"]); + writeRepoFile("README.md", "baseline\n"); + commitAll("chore: baseline release"); + git(["tag", "v0.0.0"]); + writeRepoFile("README.md", "baseline\nwhole repo feature\n"); + commitAll("feat: add fallback release note source (#123)"); + writeRepoFile("apps/memos-local-plugin/src/index.js", "export const fallback = true;\n"); + commitAll("fix(plugin): preserve fallback plugin change (#124)"); + const targetSha = git(["rev-parse", "HEAD"]).trim(); + + const result = await generateGitHubReleaseNotes({ + repo: "MemTensor/MemOS", + currentTag: "v0.0.1", + targetSha, + previousTag: "v0.0.0", + token: "", + }); + assert.equal(result.source, "local-fallback-after-github-error"); + assert.match(result.body, /## What's Changed/); + assert.match(result.body, /feat: add fallback release note source/); + assert.match(result.body, /fix\(plugin\): preserve fallback plugin change/); + assert.match(result.body, /Full Changelog/); + assert.doesNotMatch(result.body, /source_refs/); + assert.doesNotMatch(result.body, /doc-agent-release-notes-json/); + } finally { + process.chdir(originalCwd); + } }); test("rejects English text that still contains Chinese", () => { @@ -944,6 +990,50 @@ test("allows the draft service one initial response plus three repair attempts", } }); +test("fails closed when the draft service exhausts all repair attempts", async () => { + const originalFetch = globalThis.fetch; + const originalUrl = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + const originalToken = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + const originalOffline = process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + const invalidDraft = { + ...validDraft, + release_items: [validDraft.release_items[0]], + }; + const userFacingEvidence = { + ...evidence, + has_user_facing_product_changes: true, + }; + + let callCount = 0; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = "https://example.invalid/internal/release-notes/draft"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + delete process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + globalThis.fetch = async () => { + callCount += 1; + return { + ok: true, + status: 200, + text: async () => JSON.stringify(invalidDraft), + }; + }; + + await assert.rejects( + () => requestDocAgentDraft(userFacingEvidence), + /Doc Agent draft failed validation after 3 repair attempts/, + ); + assert.equal(callCount, 4); + } finally { + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + else process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = originalUrl; + if (originalToken === undefined) delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN; + else process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = originalToken; + if (originalOffline === undefined) delete process.env.ALLOW_OFFLINE_DOCS_PREVIEW; + else process.env.ALLOW_OFFLINE_DOCS_PREVIEW = originalOffline; + } +}); + test("builds Plugin tab previews without exposing source refs in page content", () => { const preview = buildDocsPreview(validDraft, evidence); assert.equal(preview.source_id, "openclaw-local-plugin"); diff --git a/.github/workflows/memos-local-plugin-publish.yml b/.github/workflows/memos-local-plugin-publish.yml index b6607a475..4d466cf35 100644 --- a/.github/workflows/memos-local-plugin-publish.yml +++ b/.github/workflows/memos-local-plugin-publish.yml @@ -31,6 +31,7 @@ on: legacy_publish_confirmation: description: "Required only when dry_run=false. Must exactly equal: LEGACY PUBLISH memos-local-plugin-v" required: false + type: string default: "" workflow_call: inputs: @@ -84,6 +85,7 @@ permissions: jobs: guard-legacy-publish: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Validate legacy standalone publish confirmation shell: bash diff --git a/.github/workflows/memos-release-post-merge-dry-run.yml b/.github/workflows/memos-release-post-merge-dry-run.yml index 90cc66627..9042c40f7 100644 --- a/.github/workflows/memos-release-post-merge-dry-run.yml +++ b/.github/workflows/memos-release-post-merge-dry-run.yml @@ -12,6 +12,10 @@ on: - ".github/scripts/prepare-memos-release.test.mjs" - ".github/scripts/retry.sh" +concurrency: + group: memos-release-post-merge-dry-run-main + cancel-in-progress: true + permissions: contents: read @@ -19,6 +23,7 @@ jobs: dry-run: if: ${{ github.repository == 'MemTensor/MemOS' }} runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -67,6 +72,7 @@ jobs: TARGET_REF: origin/main DRY_RUN: true GITHUB_TOKEN: ${{ github.token }} + # Post-merge smoke check fallback. Manual publish still requires GitHub/Doc Agent validation. ALLOW_OFFLINE_DOCS_PREVIEW: true run: | bash .github/scripts/retry.sh --attempts 2 --label "prepare offline MemOS release inspection" -- \ @@ -130,6 +136,8 @@ jobs: echo "- publish_blocked: ${PUBLISH_BLOCKED}" echo "- publish_block_reason: ${PUBLISH_BLOCK_REASON:-n/a}" echo "- release_notes_source: ${RELEASE_NOTES_SOURCE}" + echo "- offline_docs_preview: true" + echo "- doc_agent_validation: skipped in post-merge smoke fallback; production publish does not set ALLOW_OFFLINE_DOCS_PREVIEW" echo "- local_plugin_changed: ${HAS_PRODUCT_CHANGES}" echo "- local_plugin_user_facing_changed: ${HAS_USER_FACING_PRODUCT_CHANGES}" echo "- docs_action: ${DOCS_ACTION}" diff --git a/.github/workflows/memos-release-pre-merge-dry-run.yml b/.github/workflows/memos-release-pre-merge-dry-run.yml index 927bd56a6..57227c021 100644 --- a/.github/workflows/memos-release-pre-merge-dry-run.yml +++ b/.github/workflows/memos-release-pre-merge-dry-run.yml @@ -10,6 +10,10 @@ on: - ".github/scripts/prepare-memos-release.test.mjs" - ".github/scripts/retry.sh" +concurrency: + group: memos-release-pre-merge-dry-run-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -17,6 +21,7 @@ jobs: dry-run: if: ${{ github.repository == 'MemTensor/MemOS' }} runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -65,6 +70,7 @@ jobs: TARGET_REF: origin/main DRY_RUN: true GITHUB_TOKEN: ${{ github.token }} + # PR-only local fallback. Production publish requires GitHub/Doc Agent validation. ALLOW_OFFLINE_DOCS_PREVIEW: true run: | bash .github/scripts/retry.sh --attempts 2 --label "prepare offline MemOS release inspection" -- \ @@ -128,6 +134,8 @@ jobs: echo "- publish_blocked: ${PUBLISH_BLOCKED}" echo "- publish_block_reason: ${PUBLISH_BLOCK_REASON:-n/a}" echo "- release_notes_source: ${RELEASE_NOTES_SOURCE}" + echo "- offline_docs_preview: true" + echo "- doc_agent_validation: skipped in PR dry-run fallback; production publish does not set ALLOW_OFFLINE_DOCS_PREVIEW" echo "- local_plugin_changed: ${HAS_PRODUCT_CHANGES}" echo "- local_plugin_user_facing_changed: ${HAS_USER_FACING_PRODUCT_CHANGES}" echo "- docs_action: ${DOCS_ACTION}" diff --git a/.github/workflows/memos-release-publish.yml b/.github/workflows/memos-release-publish.yml index baa6bb781..f26524ba7 100644 --- a/.github/workflows/memos-release-publish.yml +++ b/.github/workflows/memos-release-publish.yml @@ -6,13 +6,16 @@ on: version: description: "MemOS version to release, without leading v (for example 2.0.25)" required: true + type: string target_ref: description: "Git ref to release from. Use main for normal releases." required: false + type: string default: "main" local_plugin_version: - description: "Optional guard: expected resolved OpenClaw local plugin docs version, without leading v. Leave blank to auto-increment when needed." + description: "Optional guard: expected resolved MemOS local plugin docs version, without leading v. Leave blank to auto-increment when needed." required: false + type: string default: "" dry_run: description: "Preview only. Skip tag creation, GitHub Release, docs PR, and deployment." @@ -27,6 +30,7 @@ on: publish_confirmation: description: "Required only when dry_run=false. Must exactly equal: PUBLISH v" required: false + type: string default: "" concurrency: @@ -40,6 +44,7 @@ jobs: release: if: ${{ github.repository == 'MemTensor/MemOS' }} runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: @@ -61,6 +66,20 @@ jobs: shell: bash run: node --test .github/scripts/prepare-memos-release.test.mjs + - name: Validate publish confirmation + if: ${{ !inputs.dry_run }} + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }} + run: | + set -euo pipefail + expected="PUBLISH v${RELEASE_VERSION}" + if [ "${PUBLISH_CONFIRMATION}" != "${expected}" ]; then + echo "::error::publish_confirmation must exactly equal: ${expected}" + exit 1 + fi + - name: Prepare MemOS release inspection id: prepare shell: bash @@ -148,10 +167,10 @@ jobs: echo "- validation_attempt_count: ${VALIDATION_ATTEMPT_COUNT}" echo "- repair_attempt_count: ${REPAIR_ATTEMPT_COUNT}" echo "" - echo "The uploaded artifact contains the public MemOS release notes preview and the OpenClaw local plugin docs preview." + echo "The uploaded artifact contains the public MemOS release notes preview and the MemOS local plugin docs preview." } >> "${GITHUB_STEP_SUMMARY}" - - name: Stop after dry run + - name: Report dry-run completion if: ${{ inputs.dry_run }} shell: bash run: |