From ee8a29eec7c4c741c03be5323d7b649fe5b8ee04 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 13 Jul 2026 02:09:50 -0700 Subject: [PATCH] release: forward-port extended stable pipeline --- .../scripts/verify-release-notes.mjs | 2193 +++++++++++++++++ .agents/skills/openclaw-testing/SKILL.md | 32 +- .agents/skills/release-openclaw-ci/SKILL.md | 40 +- .../scripts/release-ci-summary.mjs | 125 - .../release-openclaw-maintainer/SKILL.md | 151 +- .../references/extended-stable-backports.md | 270 ++ .../skills/release-openclaw-nightly/SKILL.md | 10 +- .../release-openclaw-plugin-testing/SKILL.md | 64 +- .github/release/clawhub-cli/package-lock.json | 481 ++++ .github/release/clawhub-cli/package.json | 8 + .github/workflows/full-release-validation.yml | 52 +- .github/workflows/npm-telegram-beta-e2e.yml | 10 +- .github/workflows/openclaw-npm-release.yml | 130 +- .github/workflows/openclaw-performance.yml | 15 + .../workflows/openclaw-release-publish.yml | 1158 +++++++-- .github/workflows/plugin-clawhub-new.yml | 939 +++++-- .github/workflows/plugin-clawhub-release.yml | 96 +- .github/workflows/plugin-npm-release.yml | 1081 +++++++- .gitignore | 75 +- docs/.generated/config-baseline.sha256 | 4 +- docs/ci.md | 6 +- docs/cli/update.md | 36 +- docs/gateway/configuration-reference.md | 10 +- docs/help/faq-first-run.md | 2 +- docs/help/testing-updates-plugins.md | 8 +- docs/install/development-channels.md | 36 +- docs/install/updating.md | 36 +- docs/reference/RELEASING.md | 207 +- scripts/check-release-metadata-only.mjs | 4 +- scripts/e2e/bun-global-install-smoke.sh | 21 +- .../e2e/lib/plugins/npm-registry-server.mjs | 168 +- scripts/e2e/npm-telegram-live-docker.sh | 124 +- scripts/e2e/parallels/host-server.ts | 43 +- scripts/e2e/parallels/linux-smoke.ts | 18 +- scripts/e2e/parallels/macos-smoke.ts | 18 +- scripts/e2e/parallels/npm-update-scripts.ts | 18 +- scripts/e2e/parallels/npm-update-smoke.ts | 84 +- scripts/e2e/parallels/smoke-common.ts | 1 + scripts/e2e/parallels/types.ts | 11 + scripts/e2e/parallels/windows-smoke.ts | 9 + scripts/lib/actions-artifact-archive.mjs | 994 ++++++++ scripts/lib/clawhub-bootstrap-artifact.mjs | 803 ++++++ scripts/lib/npm-publish-plan.mjs | 200 ++ scripts/lib/openclaw-release-clawhub-plan.ts | 75 +- scripts/lib/plain-gh.mjs | 99 + scripts/lib/plugin-clawhub-release.ts | 349 ++- scripts/lib/plugin-npm-release.ts | 235 +- scripts/lib/release-beta-verifier.ts | 762 +++++- scripts/materialize-clawhub-cli.sh | 67 + .../openclaw-npm-extended-stable-release.mjs | 6 +- scripts/plugin-clawhub-publish.sh | 295 ++- scripts/plugin-npm-publish.sh | 89 +- scripts/plugin-publication-artifact.mjs | 1299 ++++++++++ scripts/release-candidate-checklist.mjs | 1002 ++++++-- scripts/release-ci-summary.mjs | 1805 ++++++++++++++ scripts/release-preflight.mjs | 138 +- scripts/render-github-release-notes.mjs | 431 ++++ .../resolve-openclaw-package-candidate.mjs | 4 +- scripts/test-projects.test-support.mjs | 49 + ...idate-full-release-validation-evidence.mjs | 291 +++ scripts/validate-release-publish-approval.mjs | 96 +- scripts/verify-clawhub-published-artifact.mjs | 559 +++++ src/cli/config-cli.test.ts | 8 +- src/cli/update-cli.test.ts | 442 +++- src/cli/update-cli.ts | 14 +- src/cli/update-cli/update-command.ts | 201 +- src/cli/update-cli/wizard.ts | 11 +- .../missing-configured-plugin-install.test.ts | 30 + .../missing-configured-plugin-install.ts | 27 +- .../onboarding-plugin-install.test.ts | 76 + src/commands/onboarding-plugin-install.ts | 24 +- src/commands/status.update.test.ts | 25 + src/commands/status.update.ts | 22 +- src/config/schema.help.quality.test.ts | 2 +- src/config/schema.help.ts | 9 +- src/config/types.openclaw.ts | 4 +- src/config/validation.allowed-values.test.ts | 12 +- src/config/zod-schema.ts | 9 +- src/gateway/server-methods/update.test.ts | 54 +- src/gateway/server-methods/update.ts | 11 +- src/infra/clawhub-retry.test.ts | 168 ++ src/infra/clawhub-retry.ts | 92 + src/infra/clawhub.test.ts | 66 + src/infra/clawhub.ts | 39 +- src/infra/update-channels.test.ts | 21 + src/infra/update-channels.ts | 16 +- src/infra/update-check.test.ts | 235 +- src/infra/update-check.ts | 330 ++- src/infra/update-global.test.ts | 61 + src/infra/update-global.ts | 37 +- .../update-managed-service-handoff.test.ts | 35 + src/infra/update-managed-service-handoff.ts | 7 +- src/infra/update-runner.test.ts | 48 +- src/infra/update-runner.ts | 72 +- src/infra/update-startup.integration.test.ts | 112 + src/infra/update-startup.test.ts | 569 ++++- src/infra/update-startup.ts | 139 +- src/plugins/install-channel-specs.ts | 11 + src/plugins/update.test.ts | 92 + src/plugins/update.ts | 89 +- test/helpers/temp-dir.ts | 49 +- test/npm-publish-plan.test.ts | 344 +++ test/plugin-clawhub-release.test.ts | 18 +- .../clawhub-bootstrap-artifact.test.ts | 537 ++++ test/scripts/npm-telegram-live.test.ts | 17 + ...openclaw-cross-os-release-workflow.test.ts | 310 ++- ...ed-stable-full-validation-workflow.test.ts | 27 +- ...enclaw-npm-extended-stable-release.test.ts | 10 +- ...nclaw-npm-extended-stable-workflow.test.ts | 80 +- .../openclaw-performance-workflow.test.ts | 5 + .../package-acceptance-workflow.test.ts | 138 +- .../parallels-npm-update-smoke.test.ts | 35 +- test/scripts/parallels-smoke-model.test.ts | 7 + .../plugin-clawhub-new-workflow.test.ts | 356 +++ ...lugin-npm-extended-stable-workflow.test.ts | 349 ++- test/scripts/plugin-npm-publish.test.ts | 111 +- .../plugin-publication-artifact.test.ts | 1765 +++++++++++++ test/scripts/plugins-assertions.test.ts | 398 ++- test/scripts/release-beta-verifier.test.ts | 565 ++++- .../release-candidate-checklist.test.ts | 819 +++++- test/scripts/release-ci-summary.test.ts | 1761 +++++++++++++ test/scripts/release-notes-ledger.test.ts | 221 ++ test/scripts/release-wrapper-scripts.test.ts | 140 ++ .../render-github-release-notes.test.ts | 397 +++ ...resolve-openclaw-package-candidate.test.ts | 1 + test/scripts/test-install-sh-docker.test.ts | 16 +- ...e-full-release-validation-evidence.test.ts | 312 +++ .../validate-release-publish-approval.test.ts | 297 +++ .../verify-clawhub-published-artifact.test.ts | 666 +++++ test/scripts/verify-release-notes.test.ts | 553 +++++ 130 files changed, 29370 insertions(+), 1596 deletions(-) create mode 100644 .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs delete mode 100755 .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs create mode 100644 .agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md create mode 100644 .github/release/clawhub-cli/package-lock.json create mode 100644 .github/release/clawhub-cli/package.json create mode 100644 scripts/lib/actions-artifact-archive.mjs create mode 100644 scripts/lib/clawhub-bootstrap-artifact.mjs create mode 100644 scripts/lib/plain-gh.mjs create mode 100755 scripts/materialize-clawhub-cli.sh create mode 100644 scripts/plugin-publication-artifact.mjs create mode 100755 scripts/release-ci-summary.mjs create mode 100644 scripts/render-github-release-notes.mjs create mode 100755 scripts/validate-full-release-validation-evidence.mjs create mode 100644 scripts/verify-clawhub-published-artifact.mjs create mode 100644 src/infra/clawhub-retry.test.ts create mode 100644 src/infra/clawhub-retry.ts create mode 100644 src/infra/update-startup.integration.test.ts create mode 100644 test/scripts/clawhub-bootstrap-artifact.test.ts create mode 100644 test/scripts/plugin-clawhub-new-workflow.test.ts create mode 100644 test/scripts/plugin-publication-artifact.test.ts create mode 100644 test/scripts/release-ci-summary.test.ts create mode 100644 test/scripts/release-notes-ledger.test.ts create mode 100644 test/scripts/release-wrapper-scripts.test.ts create mode 100644 test/scripts/render-github-release-notes.test.ts create mode 100644 test/scripts/validate-full-release-validation-evidence.test.ts create mode 100644 test/scripts/validate-release-publish-approval.test.ts create mode 100644 test/scripts/verify-clawhub-published-artifact.test.ts create mode 100644 test/scripts/verify-release-notes.test.ts diff --git a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs new file mode 100644 index 000000000000..d64843eb0bc2 --- /dev/null +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -0,0 +1,2193 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + extractChangelogReleaseSections, + formatShippedBaselineExclusions, + parseShippedBaselineExclusions, + releaseNotesVersionForTag, + verifyGithubReleaseNotes, +} from "../../../../scripts/render-github-release-notes.mjs"; + +const repo = "openclaw/openclaw"; +const githubSnapshotSchemaVersion = 1; +const githubSnapshotCheckpointInterval = 25; +const commitAssociationQueryBatchSize = 20; +const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]); +const nonEditorialTypes = new Set([ + "build", + "chore", + "ci", + "docs", + "qa", + "refactor", + "style", + "test", +]); +const nonEditorialTitlePattern = + /(?:^|[\s:([{\-])(docs?|documentation|tests?|testing|qa|quality assurance|refactor(?:ing)?|ci|continuous integration|build|chore|style|lint|format)(?:$|[\s:)\]}\-])/i; +const editorialTitlePattern = + /^\s*(?:\[[^\]]+\]\s*)?(?:#\d+:\s*)?(?:add|allow|block|enable|expose|fail|fix|harden|honor|improve|keep|migrate|move|persist|polish|preserve|prevent|propagate|rate[- ]?limit|restore|revert|ship|support|treat|validate)\b|^\s*#\d+:/i; +const genericDirectCommitTerms = new Set([ + "add", + "allow", + "avoid", + "build", + "change", + "fix", + "improve", + "keep", + "make", + "missing", + "move", + "omit", + "omitted", + "prevent", + "repair", + "required", + "restore", + "update", +]); +let githubSnapshotState; + +function fail(message) { + throw new Error(message); +} + +function printUsage() { + console.log(`Usage: + node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \\ + --base --target --version [options] + +Required: + --base Release range start. + --target Release range end. + --version CHANGELOG.md version heading to verify. + +Options: + --manifest Read or write the complete contribution record ledger. + --github-snapshot + Override the exact-range GitHub GraphQL snapshot path. + --no-github-snapshot Disable GitHub GraphQL snapshot reuse. + --refresh-github-snapshot + Ignore an existing exact-range snapshot and rebuild it. + --seed-ref Use an existing release section as editorial input. + --shipped-ref Exclude PRs already recorded by this shipped tag; repeatable. + --write-ledger Write the verified ledger back into CHANGELOG.md. + --release-tag GitHub release tag to compare; repeatable with --check-github. + --check-github Require each supplied GitHub release body to match. + --json Emit machine-readable verification output. + --help Show this help text.`); +} + +function parseArgs(argv) { + const options = { + releaseTags: [], + checkGithub: false, + help: false, + json: false, + manifestPath: undefined, + githubSnapshotPath: undefined, + noGithubSnapshot: false, + refreshGithubSnapshot: false, + seedRef: undefined, + shippedRefs: [], + writeLedger: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help") { + options.help = true; + continue; + } + if ( + arg === "--check-github" || + arg === "--json" || + arg === "--no-github-snapshot" || + arg === "--refresh-github-snapshot" || + arg === "--write-ledger" + ) { + options[ + arg === "--check-github" + ? "checkGithub" + : arg === "--write-ledger" + ? "writeLedger" + : arg === "--no-github-snapshot" + ? "noGithubSnapshot" + : arg === "--refresh-github-snapshot" + ? "refreshGithubSnapshot" + : "json" + ] = true; + continue; + } + if ( + arg === "--base" || + arg === "--target" || + arg === "--version" || + arg === "--release-tag" || + arg === "--shipped-ref" || + arg === "--github-snapshot" || + arg === "--manifest" || + arg === "--seed-ref" + ) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + fail(`missing value for ${arg}`); + } + if (arg === "--release-tag") { + options.releaseTags.push(value); + } else if (arg === "--shipped-ref") { + options.shippedRefs.push(value); + } else if (arg === "--manifest") { + options.manifestPath = value; + } else if (arg === "--github-snapshot") { + options.githubSnapshotPath = value; + } else if (arg === "--seed-ref") { + options.seedRef = value; + } else { + options[arg.slice(2)] = value; + } + index += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + + if (!options.help) { + for (const name of ["base", "target", "version"]) { + if (!options[name]) { + fail(`--${name} is required`); + } + } + } else if (options.checkGithub || options.releaseTags.length > 0) { + fail("--help cannot be combined with verification options"); + } + if (!options.help && options.checkGithub && options.releaseTags.length === 0) { + fail("--check-github requires at least one --release-tag"); + } + if (options.noGithubSnapshot && options.githubSnapshotPath) { + fail("--no-github-snapshot cannot be combined with --github-snapshot"); + } + if (options.noGithubSnapshot && options.refreshGithubSnapshot) { + fail("--no-github-snapshot cannot be combined with --refresh-github-snapshot"); + } + const uniqueShippedRefs = new Set(options.shippedRefs); + if (uniqueShippedRefs.size !== options.shippedRefs.length) { + fail("--shipped-ref values must be unique"); + } + options.shippedRefs = options.shippedRefs.toSorted((a, b) => (a === b ? 0 : a < b ? -1 : 1)); + return options; +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", ...options.env }, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function git(args) { + return run("git", args).trimEnd(); +} + +function gitIsAncestor(base, target) { + const result = spawnSync( + "git", + ["merge-base", "--is-ancestor", `${base}^{commit}`, `${target}^{commit}`], + { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.status === 0) { + return true; + } + if (result.status === 1) { + return false; + } + fail( + `could not validate release range ancestry for ${base}..${target}: ${ + result.stderr?.trim() || result.signal || result.status + }`, + ); +} + +function fetchGithubApi(args) { + try { + return JSON.parse(run("gh", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + } catch (error) { + if (typeof error.stdout === "string" && error.stdout.trim() !== "") { + return JSON.parse(error.stdout.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + } + throw error; + } +} + +export function createGithubSnapshotState({ + base, + checkpointEvery = githubSnapshotCheckpointInterval, + filePath, + refresh = false, + repository = repo, + target, +}) { + if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) { + fail("GitHub snapshot checkpoint interval must be a positive integer"); + } + let responses = {}; + if (!refresh && existsSync(filePath)) { + let parsed; + try { + parsed = JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + fail( + `could not read GitHub snapshot ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if ( + parsed.schemaVersion !== githubSnapshotSchemaVersion || + parsed.repository !== repository || + parsed.base !== base || + parsed.target !== target || + !parsed.responses || + typeof parsed.responses !== "object" || + Array.isArray(parsed.responses) + ) { + fail( + `GitHub snapshot ${filePath} does not match ${repository} ${base}..${target}; use --refresh-github-snapshot`, + ); + } + responses = parsed.responses; + } + return { + base, + checkpointEvery, + dirty: refresh && existsSync(filePath), + filePath, + hits: 0, + misses: 0, + repository, + responses, + target, + writesSincePersist: 0, + }; +} + +export function githubApiWithSnapshot(args, fetchApi, snapshotState) { + if (!snapshotState || args[0] !== "graphql") { + return fetchApi(args); + } + const key = JSON.stringify(args); + const cached = snapshotState.responses[key]; + if (cached !== undefined) { + snapshotState.hits += 1; + return structuredClone(cached); + } + snapshotState.misses += 1; + const response = fetchApi(args); + if ( + !response || + typeof response !== "object" || + Array.isArray(response) || + response.data === undefined || + (Array.isArray(response.errors) && response.errors.length > 0) + ) { + return response; + } + snapshotState.responses[key] = structuredClone(response); + snapshotState.dirty = true; + snapshotState.writesSincePersist += 1; + if (snapshotState.writesSincePersist >= snapshotState.checkpointEvery) { + persistGithubSnapshot(snapshotState); + } + return response; +} + +export function persistGithubSnapshot(snapshotState) { + if (!snapshotState?.dirty) { + return; + } + const output = `${JSON.stringify( + { + schemaVersion: githubSnapshotSchemaVersion, + repository: snapshotState.repository, + base: snapshotState.base, + target: snapshotState.target, + responses: snapshotState.responses, + }, + null, + 2, + )}\n`; + mkdirSync(path.dirname(snapshotState.filePath), { recursive: true }); + const tempPath = `${snapshotState.filePath}.${process.pid}.tmp`; + try { + writeFileSync(tempPath, output); + renameSync(tempPath, snapshotState.filePath); + snapshotState.dirty = false; + snapshotState.writesSincePersist = 0; + } finally { + rmSync(tempPath, { force: true }); + } +} + +function githubApi(args) { + return githubApiWithSnapshot(args, fetchGithubApi, githubSnapshotState); +} + +export function defaultGithubSnapshotPath(base, target, gitCommonDir) { + const defaultName = `verify-release-notes-${base}-${target}.json`; + return path.resolve(gitCommonDir, "openclaw-release-cache", defaultName); +} + +function initializeGithubSnapshot(options) { + if (options.noGithubSnapshot) { + return undefined; + } + const base = git(["rev-parse", `${options.base}^{commit}`]); + const target = git(["rev-parse", `${options.target}^{commit}`]); + const filePath = path.resolve( + options.githubSnapshotPath ?? + defaultGithubSnapshotPath(base, target, git(["rev-parse", "--git-common-dir"])), + ); + const state = createGithubSnapshotState({ + base, + filePath, + refresh: options.refreshGithubSnapshot, + target, + }); + process.once("exit", () => persistGithubSnapshot(state)); + return state; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isEligibleHandle(handle) { + return ( + typeof handle === "string" && + handle.toLowerCase() !== "undefined" && + !handle.endsWith("[bot]") && + !excludedHandles.has(handle.toLowerCase()) + ); +} + +function githubHandleFromNoreply(email) { + return email.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i)?.[1]; +} + +function editorialClassification(subject) { + const type = subject.match(/^\s*([a-z]+)(?:\([^)]*\))?!?:/i)?.[1]?.toLowerCase(); + return { + editorialEligible: + (Boolean(type) || editorialTitlePattern.test(subject)) && + !nonEditorialTypes.has(type) && + !nonEditorialTitlePattern.test(subject), + type: type ?? "other", + }; +} + +function mergedByTarget(mergedAt, targetTimestamp) { + const mergedTimestamp = Date.parse(mergedAt); + return Number.isFinite(mergedTimestamp) && mergedTimestamp <= targetTimestamp; +} + +function sectionFor(changelog, version) { + const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog); + if (!heading || heading.index === undefined) { + fail(`CHANGELOG.md does not contain ## ${version}`); + } + const start = heading.index; + const bodyStart = changelog.indexOf("\n", start) + 1; + const next = /^## /gm; + next.lastIndex = bodyStart; + const nextHeading = next.exec(changelog); + const end = nextHeading?.index ?? changelog.length; + return { + start, + end, + source: changelog.slice(start, end).trimEnd(), + body: changelog.slice(bodyStart, end).trim(), + }; +} + +function referencesIn(text) { + const references = []; + for (const match of text.matchAll( + /(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+))?#(?\d+)/g, + )) { + const qualifiedRepository = match.groups?.owner + ? `${match.groups.owner}/${match.groups.name}`.toLowerCase() + : undefined; + if (!qualifiedRepository || qualifiedRepository === repo) { + references.push(Number(match.groups?.number)); + } + } + return references; +} + +function referenceLabelsIn(text) { + const labels = []; + for (const match of text.matchAll( + /(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+))?#(?\d+)/g, + )) { + const qualifiedRepository = match.groups?.owner + ? `${match.groups.owner}/${match.groups.name}` + : undefined; + labels.push( + !qualifiedRepository || qualifiedRepository.toLowerCase() === repo + ? `#${match.groups?.number}` + : `${qualifiedRepository}#${match.groups?.number}`, + ); + } + return labels; +} + +export function renderContributionRecordEntry(entry) { + const references = []; + appendUnique(references, referenceLabelsIn(entry.title)); + appendUnique( + references, + (entry.priorReferences ?? []).map((number) => `#${number}`), + ); + appendUnique(references, entry.externalReferences ?? []); + for (const issue of entry.linkedIssues) { + appendUnique(references, [`#${issue.number}`]); + } + const related = references.length > 0 ? ` Related ${references.join(", ")}.` : ""; + const attribution = + entry.thanks.length > 0 + ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` + : ""; + return `- **PR #${entry.number}**${related}${attribution}`; +} + +export function releaseNoteReferences(sectionSource, shippedBaselines) { + const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines); + // The baseline inventory proves subtraction; its PR ids are not release-note references. + const referenceSource = shippedBaselineLine + ? sectionSource.replace(shippedBaselineLine, "") + : sectionSource; + return referencesIn(referenceSource); +} + +function closingReferencesIn(text) { + const references = []; + for (const match of text.matchAll( + /\b(?:fix(?:es|ed)?|closes?|closed|resolves?|resolved)\s+(?:(?:openclaw\/openclaw)?#\d+)(?:\s*(?:,|and)\s*(?:(?:openclaw\/openclaw)?#\d+))*/gi, + )) { + appendReferences(references, referencesIn(match[0])); + } + return references; +} + +export function standardRevertedHash(message) { + const paragraphs = message + .trim() + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()); + const messageIsRevert = /^(?:[a-z][a-z0-9-]*(?:\([^)]+\))?!?:\s*)?revert\b/i.test( + paragraphs[0] ?? "", + ); + for (const [index, paragraph] of paragraphs.entries()) { + const revertedHash = paragraph.match(/^This reverts commit ([0-9a-f]{7,40})\.$/i)?.[1]; + if (!revertedHash) { + continue; + } + // GitHub squash messages can embed a reverted intermediate commit. Its + // marker follows the corresponding bullet and does not revert the squash. + if (!messageIsRevert && /^\*\s+Revert\b/i.test(paragraphs[index - 1] ?? "")) { + continue; + } + return revertedHash; + } + return undefined; +} + +export function cherryPickedFromCommits(message) { + const markerLines = message + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => /^\(cherry picked from commit\b/iu.test(line)); + const hashes = []; + for (const markerLine of markerLines) { + const match = /^\(cherry picked from commit (?[a-f0-9]{40})\)$/iu.exec(markerLine); + if (!match?.groups?.hash) { + fail("commit has invalid cherry-pick provenance"); + } + hashes.push(match.groups.hash.toLowerCase()); + } + return [...new Set(hashes)]; +} + +function handlesIn(text) { + const thanksStart = text.lastIndexOf(" Thanks "); + if (thanksStart < 0) { + return []; + } + const content = text.slice(0, thanksStart); + return [...text.slice(thanksStart).matchAll(/@([A-Za-z0-9-]+)/g)] + .map((match) => match[1]) + .filter( + (handle) => + isEligibleHandle(handle) && + !new RegExp(`(? !reference.startsWith("#")); +} + +function appendUnique(values, additions) { + const seen = new Set(values.map((value) => value.toLowerCase())); + for (const value of additions) { + const key = value.toLowerCase(); + if (!seen.has(key)) { + values.push(value); + seen.add(key); + } + } +} + +function addContributionRecordEntry(entries, key, entry) { + const existing = entries.get(key); + if (!existing) { + entries.set(key, { + ...entry, + externalReferences: [...(entry.externalReferences ?? [])], + references: [...entry.references], + thanks: [...entry.thanks], + }); + return; + } + appendUnique(existing.externalReferences, entry.externalReferences ?? []); + appendReferences(existing.references, entry.references); + addHandles(existing.thanks, entry.thanks); +} + +export function contributionRecordFor(section) { + const result = { legacyIssues: new Map(), pullRequests: new Map() }; + const recordStart = section.source.search(/\n### Complete contribution (?:ledger|record)\r?$/m); + if (recordStart < 0) { + return result; + } + const record = section.source.slice(recordStart); + let subsection = ""; + for (const line of record.split("\n")) { + if (line === "#### Pull requests") { + subsection = "pull-requests"; + continue; + } + if (line === "#### Linked issues") { + subsection = "linked-issues"; + continue; + } + if (line.startsWith("#### ")) { + subsection = ""; + continue; + } + if (subsection === "pull-requests") { + const explicitRecord = line.match(/^- \*\*PR #(\d+)\*\*/); + const legacyRecord = line.match(/\(#(\d+)\)\.(?: Thanks.*)?$/); + const number = explicitRecord?.[1] ?? legacyRecord?.[1]; + if (number) { + const value = Number(number); + const metadata = explicitRecord ? line.slice(explicitRecord[0].length) : line; + addContributionRecordEntry(result.pullRequests, value, { + externalReferences: externalReferencesIn(metadata), + references: referencesIn(metadata).filter((reference) => reference !== value), + thanks: handlesIn(line), + }); + } + continue; + } + if (subsection === "linked-issues") { + const number = referencesIn(line)[0]; + if (number) { + addContributionRecordEntry(result.legacyIssues, number, { + references: [], + thanks: handlesIn(line), + }); + } + } + } + return result; +} + +function completeContributionRecord(section, label) { + const recordStart = section.source.search(/\n### Complete contribution record\r?$/m); + if (recordStart < 0) { + fail(`${label} is missing ### Complete contribution record`); + } + const recordSource = section.source.slice(recordStart); + const provenance = recordSource.match( + /^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?[0-9]+) merged PRs?\./mu, + ); + if (!provenance?.groups?.count) { + fail(`${label} is missing exact complete contribution record provenance`); + } + const record = contributionRecordFor(section); + const declaredCount = Number(provenance.groups.count); + if (record.pullRequests.size !== declaredCount) { + fail( + `${label} contribution record declares ${declaredCount} PRs but contains ${record.pullRequests.size}`, + ); + } + return { record, declaredCount }; +} + +export function cumulativeShippedPullRequests(changelog, label) { + const sections = extractChangelogReleaseSections(changelog).filter( + (section) => + section.version !== "Unreleased" && + section.source.includes("\n### Complete contribution record"), + ); + if (sections.length === 0) { + fail(`${label} is missing ### Complete contribution record`); + } + const pullRequests = new Set(); + for (const section of sections) { + const record = contributionRecordFor(section); + for (const number of record.pullRequests.keys()) { + pullRequests.add(number); + } + } + return pullRequests; +} + +function shippedBaselineFor(ref) { + const version = releaseNotesVersionForTag(ref); + const tagRef = `refs/tags/${ref}`; + git(["rev-parse", `${tagRef}^{commit}`]); + const changelog = git(["show", `${tagRef}:CHANGELOG.md`]); + completeContributionRecord(sectionFor(changelog, version), `shipped baseline ${ref}`); + return { + ref, + pullRequests: cumulativeShippedPullRequests(changelog, `shipped baseline ${ref}`), + }; +} + +export function subtractShippedPullRequests(source, baselines) { + const excluded = new Set(); + const metadata = []; + for (const baseline of baselines.toSorted((a, b) => + a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1, + )) { + const pullRequests = []; + for (const number of baseline.pullRequests) { + if ( + !excluded.has(number) && + (source.pullRequests.has(number) || source.references.includes(number)) + ) { + excluded.add(number); + pullRequests.push(number); + } + source.pullRequests.delete(number); + } + source.references = source.references.filter((number) => !baseline.pullRequests.has(number)); + const sortedPullRequests = pullRequests.toSorted((a, b) => a - b); + metadata.push({ + ref: baseline.ref, + count: sortedPullRequests.length, + pullRequests: sortedPullRequests, + }); + } + return { baselines: metadata, pullRequests: excluded }; +} + +export function withoutExcludedContributionRecords(record, excludedReferences) { + if (excludedReferences.size === 0) { + return record; + } + const filtered = { legacyIssues: new Map(), pullRequests: new Map() }; + for (const [number, entry] of record.pullRequests) { + if (excludedReferences.has(number)) { + continue; + } + addContributionRecordEntry(filtered.pullRequests, number, { + ...entry, + externalReferences: entry.externalReferences, + references: entry.references.filter((reference) => !excludedReferences.has(reference)), + }); + } + for (const [number, entry] of record.legacyIssues) { + if (!excludedReferences.has(number)) { + addContributionRecordEntry(filtered.legacyIssues, number, entry); + } + } + return filtered; +} + +function contributionRecordReferences(record) { + return [...record.pullRequests.keys()]; +} + +function contributionRecordMetadataReferences(record) { + const references = contributionRecordReferences(record); + for (const entry of record.pullRequests.values()) { + appendReferences(references, entry.references); + } + appendReferences(references, record.legacyIssues.keys()); + return references; +} + +export function contaminatingPullRequestReferences({ + noteReferences, + recordedReferences, + sourcePullRequests, + sourceReferences, + seededPullRequests, + nodes, +}) { + const allowed = new Set([...sourcePullRequests, ...seededPullRequests]); + for (const number of sourceReferences) { + if (nodes.get(number)?.__typename === "PullRequest") { + allowed.add(number); + } + } + return [...new Set([...noteReferences, ...recordedReferences])].filter( + (number) => nodes.get(number)?.__typename === "PullRequest" && !allowed.has(number), + ); +} + +function appendReferences(references, additions) { + const seen = new Set(references); + for (const number of additions) { + if (!seen.has(number)) { + references.push(number); + seen.add(number); + } + } +} + +function sourceCommits(base, target) { + const targetCommit = git(["rev-parse", `${target}^{commit}`]); + if (!gitIsAncestor(base, targetCommit)) { + fail(`release range base ${base} must be an ancestor of target ${target}`); + } + const mergeBase = git(["merge-base", base, targetCommit]); + const targetTimestamp = Date.parse(git(["show", "-s", "--format=%cI", targetCommit])); + if (!Number.isFinite(targetTimestamp)) { + fail(`could not resolve timestamp for release target ${target}`); + } + const output = git([ + "log", + "--first-parent", + "--reverse", + "--format=%H%x1f%s%x1f%an%x1f%ae%x1f%B%x1e", + `${mergeBase}..${targetCommit}`, + ]); + const commits = new Map(); + const revertsByTarget = new Map(); + for (const record of output.split("\x1e")) { + if (!record) { + continue; + } + const [rawHash, subject, authorName, authorEmail, ...bodyParts] = record.split("\x1f"); + const hash = rawHash.trim(); + const body = bodyParts.join("\x1f"); + const provenanceHashes = cherryPickedFromCommits(body); + const revertedHash = standardRevertedHash(body); + const isRevert = Boolean(revertedHash) || subject.startsWith('Revert "'); + commits.set(hash, { + authorEmail, + authorName, + body, + hash, + isRevert, + provenanceHashes, + revertedHash, + subject, + }); + } + for (const commit of commits.values()) { + if (!commit.revertedHash) { + continue; + } + const targetHash = [...commits.keys()].find((candidate) => + candidate.startsWith(commit.revertedHash), + ); + if (targetHash) { + const reverts = revertsByTarget.get(targetHash) ?? []; + reverts.push(commit.hash); + revertsByTarget.set(targetHash, reverts); + } + } + const active = new Map(); + function isActive(hash) { + if (active.has(hash)) { + return active.get(hash); + } + const cancellingReverts = revertsByTarget.get(hash) ?? []; + const value = !cancellingReverts.some((revertHash) => isActive(revertHash)); + active.set(hash, value); + return value; + } + const revertedCommitStates = new Map(); + function revertedCommitState(ref, seen = new Set()) { + let hash; + try { + hash = git(["rev-parse", `${ref}^{commit}`]); + } catch { + return undefined; + } + const cached = revertedCommitStates.get(hash); + if (cached) { + return cached; + } + if (seen.has(hash)) { + fail(`cyclic revert history at ${hash}`); + } + seen.add(hash); + const output = git(["show", "-s", "--format=%s%x1f%B", hash]); + const [subject, ...bodyParts] = output.split("\x1f"); + const body = bodyParts.join("\x1f"); + const message = `${subject}\n${body}`; + const revertedHash = standardRevertedHash(body); + const targetState = revertedHash ? revertedCommitState(revertedHash, seen) : undefined; + const state = targetState + ? { ...targetState, depth: targetState.depth + 1 } + : { depth: 0, hash, references: referencesIn(message) }; + revertedCommitStates.set(hash, state); + return state; + } + + const references = []; + const revertedReferences = new Set(); + const revertedCommitHashes = new Set(); + const coauthorsByReference = new Map(); + const activeCommits = []; + for (const commit of commits.values()) { + if (commit.isRevert && isActive(commit.hash)) { + const coauthorEmails = [...commit.body.matchAll(/^Co-authored-by:\s*.+?<([^>\s]+)>$/gim)].map( + (match) => match[1], + ); + activeCommits.push({ + authorEmail: commit.authorEmail, + authorHandle: githubHandleFromNoreply(commit.authorEmail), + authorName: commit.authorName, + body: commit.body, + closingReferences: [], + coauthors: coauthorEmails.map(githubHandleFromNoreply).filter(isEligibleHandle), + coauthorEmails, + hash: commit.hash, + isRevert: true, + pullRequests: [], + provenanceHashes: commit.provenanceHashes, + references: [], + subject: commit.subject, + }); + continue; + } + if (commit.isRevert) { + continue; + } + const uniqueReferences = [...new Set(referencesIn(`${commit.subject}\n${commit.body}`))]; + if (!isActive(commit.hash)) { + revertedCommitHashes.add(commit.hash); + for (const number of uniqueReferences) { + revertedReferences.add(number); + } + continue; + } + const coauthorEmails = [...commit.body.matchAll(/^Co-authored-by:\s*.+?<([^>\s]+)>$/gim)].map( + (match) => match[1], + ); + const coauthors = coauthorEmails.map(githubHandleFromNoreply).filter(isEligibleHandle); + activeCommits.push({ + authorEmail: commit.authorEmail, + authorHandle: githubHandleFromNoreply(commit.authorEmail), + authorName: commit.authorName, + body: commit.body, + closingReferences: closingReferencesIn(`${commit.subject}\n${commit.body}`), + coauthors, + coauthorEmails, + hash: commit.hash, + isRevert: false, + pullRequests: [], + provenanceHashes: commit.provenanceHashes, + references: uniqueReferences, + subject: commit.subject, + }); + } + for (const commit of commits.values()) { + if (!commit.isRevert || !commit.revertedHash || !isActive(commit.hash)) { + continue; + } + const targetInRange = [...commits.keys()].some((candidate) => + candidate.startsWith(commit.revertedHash), + ); + if (targetInRange) { + continue; + } + const revertedState = revertedCommitState(commit.revertedHash); + if (!revertedState) { + continue; + } + if (revertedState.depth % 2 !== 0) { + continue; + } + revertedCommitHashes.add(revertedState.hash); + for (const number of revertedState.references) { + revertedReferences.add(number); + } + } + const associationHashes = [ + ...new Set(activeCommits.flatMap((commit) => [commit.hash, ...commit.provenanceHashes])), + ]; + const requiredProvenanceHashes = new Set( + activeCommits.flatMap((commit) => commit.provenanceHashes), + ); + const activePullRequests = resolveAssociatedPullRequests( + associationHashes, + targetTimestamp, + requiredProvenanceHashes, + ); + const resolvedCoauthors = resolveCommitCoauthors(activeCommits); + const pullRequests = new Set(); + const nonRevertPullRequests = new Set(); + for (const commit of activeCommits) { + const associatedPullRequests = [ + ...new Set( + [commit.hash, ...commit.provenanceHashes].flatMap( + (hash) => activePullRequests.get(hash) ?? [], + ), + ), + ]; + commit.pullRequests = associatedPullRequests; + addHandles(commit.coauthors, resolvedCoauthors.get(commit.hash) ?? []); + appendReferences(commit.references, associatedPullRequests); + for (const number of associatedPullRequests) { + pullRequests.add(number); + if (!commit.isRevert) { + nonRevertPullRequests.add(number); + } + } + appendReferences(references, commit.references); + if (commit.coauthors.length === 0) { + continue; + } + for (const number of commit.references) { + const handles = coauthorsByReference.get(number) ?? new Set(); + for (const handle of commit.coauthors) { + handles.add(handle); + } + coauthorsByReference.set(number, handles); + } + } + const revertedPullRequests = new Set(); + for (const pullRequests of resolveAssociatedPullRequests( + [...revertedCommitHashes], + targetTimestamp, + ).values()) { + for (const number of pullRequests) { + revertedPullRequests.add(number); + } + } + // A later active implementation supersedes an earlier reverted fix, including + // direct commits that cite the same issue without having a recoverable PR. + for (const commit of activeCommits) { + if (commit.isRevert) { + continue; + } + for (const number of commit.references) { + revertedReferences.delete(number); + } + } + // A PR can span several commits. A reverted commit does not erase the PR while + // another non-revert commit from it remains active in this release range. + for (const number of revertedPullRequests) { + if (!nonRevertPullRequests.has(number)) { + pullRequests.delete(number); + revertedReferences.add(number); + } + } + for (const number of pullRequests) { + revertedReferences.delete(number); + } + + return { + activeCommits, + coauthorsByReference, + mergeBase, + pullRequests, + references, + revertedReferences, + target: targetCommit, + targetTimestamp, + }; +} + +export function githubGraphqlData(response) { + const errors = Array.isArray(response?.errors) + ? response.errors.map((error) => error?.message).filter(Boolean) + : []; + if (errors.length > 0) { + throw new Error(`GitHub GraphQL response included errors:\n${errors.join("\n")}`); + } + if (response?.data && typeof response.data === "object") { + return response.data; + } + const detail = [response?.message].filter(Boolean).join("\n"); + throw new Error( + detail + ? `GitHub GraphQL response did not include data:\n${detail}` + : "GitHub GraphQL response did not include data.", + ); +} + +function graphql(query) { + let lastError; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const response = githubApi(["graphql", "-f", `query=${query}`]); + return githubGraphqlData(response); + } catch (error) { + lastError = error; + const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n"); + // Historical ranges batch hundreds of objects; only retry transient transport failures. + if ( + !/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test( + message, + ) + ) { + throw error; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500 * 2 ** attempt); + } + } + throw lastError; +} + +function resolveAssociatedPullRequests( + commitHashes, + targetTimestamp, + requiredCommitHashes = new Set(), +) { + const pullRequestsByCommit = new Map(); + const pending = []; + function appendPullRequests(commitHash, connection) { + const pullRequests = pullRequestsByCommit.get(commitHash) ?? []; + const seen = new Set(pullRequests); + for (const pullRequest of connection?.nodes ?? []) { + // GitHub's mergedAt can trail the merge commit timestamp by a second. + // Keep an exact merge-commit association so a release ending there does not drop its PR. + const isExactMergeCommit = pullRequest.mergeCommit?.oid === commitHash; + if ( + pullRequest.mergedAt && + (isExactMergeCommit || mergedByTarget(pullRequest.mergedAt, targetTimestamp)) && + !seen.has(pullRequest.number) + ) { + pullRequests.push(pullRequest.number); + seen.add(pullRequest.number); + } + } + pullRequestsByCommit.set(commitHash, pullRequests); + if (connection?.pageInfo?.hasNextPage) { + pending.push({ commitHash, cursor: connection.pageInfo.endCursor }); + } + } + for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) { + const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize); + const fields = chunk + .map( + (hash, offset) => + `c${index + offset}: repository(owner: "openclaw", name: "openclaw") { + object(expression: ${JSON.stringify(hash)}) { + ... on Commit { + associatedPullRequests(first: 100) { + nodes { + number + mergedAt + mergeCommit { oid } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + const commitHash = chunk[offset]; + const object = data[`c${index + offset}`]?.object; + if (!object && requiredCommitHashes.has(commitHash)) { + fail(`cherry-pick provenance commit ${commitHash} is not in ${repo}`); + } + appendPullRequests(commitHash, object?.associatedPullRequests); + } + } + while (pending.length > 0) { + const chunk = pending.splice(0, 20); + const fields = chunk + .map( + (item, offset) => + `c${offset}: repository(owner: "openclaw", name: "openclaw") { + object(expression: ${JSON.stringify(item.commitHash)}) { + ... on Commit { + associatedPullRequests(first: 100, after: ${JSON.stringify(item.cursor)}) { + nodes { + number + mergedAt + mergeCommit { oid } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + appendPullRequests( + chunk[offset].commitHash, + data[`c${offset}`]?.object?.associatedPullRequests, + ); + } + } + return pullRequestsByCommit; +} + +function issueConnectionName(node) { + if (node.__typename === "Issue") { + return "closedByPullRequestsReferences"; + } + if (node.__typename === "PullRequest") { + return "closingIssuesReferences"; + } + return undefined; +} + +function resolveIssueRelationshipPages(nodes) { + const pending = []; + for (const [number, node] of nodes) { + const connectionName = issueConnectionName(node); + const pageInfo = connectionName ? node[connectionName]?.pageInfo : undefined; + if (pageInfo?.hasNextPage) { + pending.push({ connectionName, cursor: pageInfo.endCursor, number, type: node.__typename }); + } + } + while (pending.length > 0) { + const chunk = pending.splice(0, 20); + const fields = chunk + .map((item, offset) => { + const connection = `${item.connectionName}(first: 100, after: ${JSON.stringify(item.cursor)}) { + nodes { number } + pageInfo { hasNextPage endCursor } + }`; + return `n${offset}: repository(owner: "openclaw", name: "openclaw") { + issueOrPullRequest(number: ${item.number}) { + ... on ${item.type} { + ${connection} + } + } + }`; + }) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + const item = chunk[offset]; + const node = nodes.get(item.number); + const connection = data[`n${offset}`]?.issueOrPullRequest?.[item.connectionName]; + if (!node || !connection) { + continue; + } + node[item.connectionName] = { + nodes: [...(node[item.connectionName]?.nodes ?? []), ...connection.nodes], + pageInfo: connection.pageInfo, + }; + if (connection.pageInfo.hasNextPage) { + pending.push({ + connectionName: item.connectionName, + cursor: connection.pageInfo.endCursor, + number: item.number, + type: item.type, + }); + } + } + } + return nodes; +} + +function resolveReferences(numbers) { + const nodes = new Map(); + for (let index = 0; index < numbers.length; index += 40) { + const chunk = numbers.slice(index, index + 40); + const fields = chunk + .map( + (number) => `n${number}: repository(owner: "openclaw", name: "openclaw") { + issueOrPullRequest(number: ${number}) { + __typename + ... on Issue { + number + title + author { __typename login } + closedByPullRequestsReferences(first: 100) { + nodes { number } + pageInfo { hasNextPage endCursor } + } + } + ... on PullRequest { + number + title + mergedAt + author { __typename login } + closingIssuesReferences(first: 100) { + nodes { number } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (const number of chunk) { + const node = data[`n${number}`]?.issueOrPullRequest; + if (node) { + nodes.set(number, node); + } + } + } + return resolveIssueRelationshipPages(nodes); +} + +function resolveGitHubHandles(handles) { + const resolved = new Map(); + const uniqueHandles = [...new Set(handles)]; + for (let index = 0; index < uniqueHandles.length; index += 80) { + const chunk = uniqueHandles.slice(index, index + 80); + const fields = chunk + .map( + (handle, offset) => + `u${index + offset}: user(login: ${JSON.stringify(handle)}) { __typename login }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + const user = data[`u${index + offset}`]; + if (user?.__typename === "User" && isEligibleHandle(user.login)) { + resolved.set(chunk[offset].toLowerCase(), user.login); + } + } + } + return resolved; +} + +function resolveDirectCommitAuthors(commits) { + const resolved = new Map(); + const commitsWithoutGitHubHandle = commits.filter((commit) => !commit.author?.handle); + for (let index = 0; index < commitsWithoutGitHubHandle.length; index += 40) { + const chunk = commitsWithoutGitHubHandle.slice(index, index + 40); + const fields = chunk + .map( + (commit, offset) => + `c${index + offset}: repository(owner: "openclaw", name: "openclaw") { + object(expression: ${JSON.stringify(commit.hash)}) { + ... on Commit { + author { + user { + login + } + } + } + } + }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + const author = data[`c${index + offset}`]?.object?.author?.user; + if (author?.login && isEligibleHandle(author.login)) { + resolved.set(chunk[offset].hash, author.login); + } + } + } + return resolved; +} + +function resolveCommitCoauthors(commits) { + const resolved = new Map(); + const commitsWithCoauthors = commits.filter((commit) => commit.coauthorEmails.length > 0); + for (let index = 0; index < commitsWithCoauthors.length; index += 40) { + const chunk = commitsWithCoauthors.slice(index, index + 40); + const fields = chunk + .map( + (commit, offset) => + `c${index + offset}: repository(owner: "openclaw", name: "openclaw") { + object(expression: ${JSON.stringify(commit.hash)}) { + ... on Commit { + authors(first: 20) { + nodes { + email + user { login } + } + } + } + } + }`, + ) + .join("\n"); + const data = graphql(`query { ${fields} }`); + for (let offset = 0; offset < chunk.length; offset += 1) { + const coauthorEmails = new Set( + chunk[offset].coauthorEmails.map((email) => email.toLowerCase()), + ); + const handles = + data[`c${index + offset}`]?.object?.authors?.nodes + .filter((author) => coauthorEmails.has(author.email?.toLowerCase())) + .map((author) => author.user?.login) + .filter(isEligibleHandle) ?? []; + resolved.set(chunk[offset].hash, handles); + } + } + return resolved; +} + +function withDirectCommitAuthors(commits, resolvedAuthors) { + return commits.map((commit) => { + const authorHandle = resolvedAuthors.get(commit.hash) ?? commit.author?.handle; + const contributors = []; + if (authorHandle) { + contributors.push(authorHandle); + } + addHandles(contributors, commit.contributors); + return { + ...commit, + author: { + handle: authorHandle, + name: commit.author?.name ?? commit.authorName, + }, + contributors, + }; + }); +} + +function thanksFor(node, coauthorHandles) { + const handles = []; + if (node.author?.__typename === "User" && isEligibleHandle(node.author.login)) { + handles.push(node.author.login); + } + for (const handle of coauthorHandles) { + if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) { + handles.push(handle); + } + } + return handles; +} + +function addHandles(handles, additions) { + for (const handle of additions) { + if (!isEligibleHandle(handle)) { + continue; + } + if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) { + handles.push(handle); + } + } + return handles; +} + +function titleReferences(entries) { + return [...new Set(entries.flatMap((entry) => referencesIn(entry.title)))]; +} + +function releaseTitle(title) { + return title; +} + +function withSentenceEnding(value) { + return /[.!?]$/.test(value) ? value : `${value}.`; +} + +function formatThanks(handles) { + const mentions = handles.map((handle) => `@${handle}`); + if (mentions.length <= 1) { + return mentions[0] ?? ""; + } + if (mentions.length === 2) { + return mentions.join(" and "); + } + return `${mentions.slice(0, -1).join(", ")}, and ${mentions.at(-1)}`; +} + +function directCommitTitleTokens(subject) { + const title = subject.replace(/^\s*[a-z]+(?:\([^)]*\))?!?:\s*/i, ""); + return [...new Set(title.toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) ?? [])].filter( + (token) => !genericDirectCommitTerms.has(token), + ); +} + +function lineHasTerm(line, term) { + return new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(line); +} + +function directCommitMatchesLine(commit, line) { + if (!line.startsWith("- ")) { + return false; + } + if (commit.closingReferences.some((number) => referencesIn(line).includes(number))) { + return true; + } + const matchingTerms = directCommitTitleTokens(commit.subject).filter((token) => + lineHasTerm(line, token), + ); + return matchingTerms.length >= 2; +} + +function directCommitCreditsForLine(line, directCommits) { + const contributors = []; + for (const commit of directCommits) { + if ( + !editorialClassification(commit.subject).editorialEligible || + !directCommitMatchesLine(commit, line) + ) { + continue; + } + addHandles(contributors, commit.contributors); + } + return contributors; +} + +function completeEditorialCredits(prose, pullRequests, directCommits) { + const pullRequestsByNumber = new Map( + pullRequests.map((pullRequest) => [pullRequest.number, pullRequest]), + ); + return prose + .split("\n") + .map((line) => { + if (!line.startsWith("- ")) { + return line; + } + const contributors = []; + for (const number of referencesIn(line)) { + addHandles(contributors, pullRequestsByNumber.get(number)?.thanks ?? []); + } + addHandles(contributors, directCommitCreditsForLine(line, directCommits)); + if (contributors.length === 0) { + return line; + } + const existingContributors = handlesIn(line); + addHandles(existingContributors, contributors); + const thanksStart = line.lastIndexOf(" Thanks "); + const rawContent = thanksStart >= 0 ? line.slice(0, thanksStart) : line; + const content = + referencesIn(rawContent).length === 0 + ? withSentenceEnding(rawContent) + : rawContent.replace(/[.!?]$/, ""); + return `${content} Thanks ${formatThanks(existingContributors)}.`; + }) + .join("\n"); +} + +function issueEntries(numbers, nodes, priorIssues = new Map()) { + return [...new Set(numbers)] + .map((number) => { + const node = nodes.get(number); + if (node?.__typename !== "Issue") { + return undefined; + } + const thanks = thanksFor(node, []); + addHandles(thanks, priorIssues.get(number)?.thanks ?? []); + return { + number, + thanks, + title: node.title.replace(/\s+/g, " ").trim(), + }; + }) + .filter(Boolean); +} + +function legacyIssuesByPullRequest(priorRecord, nodes) { + const result = new Map(); + for (const number of priorRecord.legacyIssues.keys()) { + const issue = nodes.get(number); + if (issue?.__typename !== "Issue") { + continue; + } + const pullRequests = + issue.closedByPullRequestsReferences?.nodes.map((pullRequest) => pullRequest.number) ?? []; + for (const pullRequest of new Set(pullRequests)) { + const issues = result.get(pullRequest) ?? []; + issues.push(number); + result.set(pullRequest, issues); + } + } + return result; +} + +function contributionRelationships(source, nodes, resolvedContributors) { + const issuesByPullRequest = new Map(); + const directCommits = []; + for (const commit of source.activeCommits) { + const pullRequests = commit.pullRequests; + const issues = issueEntries(commit.closingReferences, nodes); + if (pullRequests.length === 0) { + const authorHandle = commit.authorHandle + ? resolvedContributors.get(commit.authorHandle.toLowerCase()) + : undefined; + const contributors = []; + if (authorHandle) { + contributors.push(authorHandle); + } + addHandles( + contributors, + commit.coauthors + .map((handle) => resolvedContributors.get(handle.toLowerCase())) + .filter(Boolean), + ); + directCommits.push({ + ...commit, + author: { handle: authorHandle, name: commit.authorName }, + contributors, + issues, + }); + continue; + } + if (issues.length === 0) { + continue; + } + for (const number of pullRequests) { + const existing = issuesByPullRequest.get(number) ?? []; + issuesByPullRequest.set(number, [...existing, ...issues]); + } + } + return { directCommits, issuesByPullRequest }; +} + +function mergeIssues(...groups) { + const entries = new Map(); + for (const group of groups) { + for (const issue of group) { + const existing = entries.get(issue.number); + if (existing) { + addHandles(existing.thanks, issue.thanks); + } else { + entries.set(issue.number, { ...issue, thanks: [...issue.thanks] }); + } + } + } + return [...entries.values()]; +} + +export function ledgerFor( + base, + target, + references, + nodes, + coauthorsByReference, + resolvedHandles, + relationships, + priorRecord, + sourcePullRequests, + sourceReferences, + noteReferences, + legacyIssuePullRequests, + revertedReferences, + shippedBaselines, + targetTimestamp, +) { + const entries = references.map((number) => { + const node = nodes.get(number); + const rawCoauthors = coauthorsByReference.get(number) ?? new Set(); + const coauthors = [...rawCoauthors] + .map((handle) => resolvedHandles.get(handle.toLowerCase())) + .filter(Boolean); + return { + number, + title: releaseTitle(node.title.replace(/\s+/g, " ").trim()), + type: node.__typename, + mergedAt: node.mergedAt, + closingIssuesReferences: node.closingIssuesReferences, + thanks: thanksFor(node, coauthors), + }; + }); + + const recordedPullRequests = new Set([ + ...sourcePullRequests, + ...sourceReferences, + ...noteReferences, + ...legacyIssuePullRequests, + ...priorRecord.pullRequests.keys(), + ]); + const pullRequests = entries.filter( + (entry) => + entry.type === "PullRequest" && + entry.mergedAt && + (sourcePullRequests.has(entry.number) || mergedByTarget(entry.mergedAt, targetTimestamp)) && + recordedPullRequests.has(entry.number) && + !revertedReferences.has(entry.number), + ); + const issues = entries.filter((entry) => entry.type === "Issue"); + const legacyIssues = legacyIssuesByPullRequest(priorRecord, nodes); + const records = pullRequests.map((entry) => { + const priorEntry = priorRecord.pullRequests.get(entry.number); + const priorReferences = priorEntry?.references ?? []; + const titleIssues = issueEntries(referencesIn(entry.title), nodes); + const closingIssues = issueEntries( + entry.closingIssuesReferences?.nodes.map((issue) => issue.number) ?? [], + nodes, + ); + const linkedIssues = mergeIssues( + titleIssues, + closingIssues, + relationships.issuesByPullRequest.get(entry.number) ?? [], + issueEntries(priorReferences, nodes), + issueEntries(legacyIssues.get(entry.number) ?? [], nodes, priorRecord.legacyIssues), + ); + const thanks = [...entry.thanks]; + addHandles(thanks, priorEntry?.thanks ?? []); + for (const issue of linkedIssues) { + addHandles(thanks, issue.thanks); + } + return { + ...entry, + ...editorialClassification(entry.title), + externalReferences: priorEntry?.externalReferences ?? [], + linkedIssues, + priorReferences, + thanks, + }; + }); + const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines); + const ledger = [ + "### Complete contribution record", + "", + `This audited record covers the complete ${base}..${target} history: ${records.length} merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`, + ...(shippedBaselineLine ? ["", shippedBaselineLine] : []), + "", + "#### Pull requests", + "", + ...records.map((entry) => renderContributionRecordEntry(entry)), + ].join("\n"); + return { + entries, + issues, + ledger, + pullRequests: records, + titleReferences: titleReferences(records), + }; +} + +function replaceLedger(changelog, section, ledger, pullRequests, directCommits) { + const beforeLedger = completeEditorialCredits( + section.source.replace(/\n+### Complete contribution (?:ledger|record)[\s\S]*$/m, "").trimEnd(), + pullRequests, + directCommits, + ); + const replacement = `${beforeLedger}\n\n${ledger}\n`; + return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`; +} + +export function countTopLevelSectionBullets(sectionSource, heading) { + const headingMatch = new RegExp(`^### ${escapeRegExp(heading)}\\r?$`, "mu").exec(sectionSource); + if (!headingMatch || headingMatch.index === undefined) { + return 0; + } + const headingEnd = sectionSource.indexOf("\n", headingMatch.index); + const bodyStart = headingEnd < 0 ? sectionSource.length : headingEnd + 1; + const nextHeading = /^### /gmu; + nextHeading.lastIndex = bodyStart; + const end = nextHeading.exec(sectionSource)?.index ?? sectionSource.length; + return sectionSource + .slice(bodyStart, end) + .split("\n") + .filter((line) => line.startsWith("- ")).length; +} + +export function highlightCountError(sectionSource) { + const count = countTopLevelSectionBullets(sectionSource, "Highlights"); + return count >= 5 && count <= 8 + ? undefined + : `### Highlights must contain 5-8 top-level bullets; found ${count}`; +} + +export function ledgerChecks(section, pullRequests, nodes, directCommits, shippedBaselines = []) { + const errors = []; + let sectionReferences = referencesIn(section.source); + if (/@undefined\b/i.test(section.source)) { + errors.push("release section contains invalid @undefined contributor credit"); + } + if (!section.source.includes("### Highlights")) { + errors.push("missing ### Highlights"); + } else { + const error = highlightCountError(section.source); + if (error) { + errors.push(error); + } + } + if (!section.source.includes("### Changes")) { + errors.push("missing ### Changes"); + } + if (!section.source.includes("### Fixes")) { + errors.push("missing ### Fixes"); + } + const ledgerStart = section.source.indexOf("### Complete contribution record"); + if (ledgerStart < 0) { + errors.push("missing ### Complete contribution record"); + return errors; + } + const ledger = section.source.slice(ledgerStart); + const expectedShippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines); + try { + const sectionShippedBaselineLine = formatShippedBaselineExclusions( + parseShippedBaselineExclusions(section.source), + ); + const actualShippedBaselineLine = formatShippedBaselineExclusions( + parseShippedBaselineExclusions(ledger), + ); + if (sectionShippedBaselineLine !== actualShippedBaselineLine) { + errors.push( + "shipped baseline exclusions must appear inside the complete contribution record", + ); + } else if (actualShippedBaselineLine !== expectedShippedBaselineLine) { + errors.push( + `shipped baseline exclusions mismatch: expected ${ + expectedShippedBaselineLine || "none" + }, found ${actualShippedBaselineLine || "none"}`, + ); + } else { + sectionReferences = releaseNoteReferences(section.source, shippedBaselines); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + if (ledger.includes("#### Linked issues")) { + errors.push("complete contribution record must not have a linked-issues inventory"); + } + if (ledger.includes("#### Direct commits")) { + errors.push("complete contribution record must not list direct commits"); + } + for (const number of new Set(sectionReferences)) { + if (!nodes.has(number)) { + errors.push(`unresolved release-note reference #${number}`); + } + } + for (const entry of pullRequests) { + const line = ledger + .split("\n") + .find((candidate) => candidate.startsWith(`- **PR #${entry.number}**`)); + if (!line) { + errors.push(`missing contribution record for PR #${entry.number}`); + continue; + } + for (const handle of entry.thanks) { + if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) { + errors.push(`missing Thanks @${handle} for #${entry.number}`); + } + } + const expectedReferences = []; + appendUnique(expectedReferences, referenceLabelsIn(entry.title)); + appendUnique( + expectedReferences, + entry.priorReferences.map((number) => `#${number}`), + ); + appendUnique(expectedReferences, entry.externalReferences); + appendUnique( + expectedReferences, + entry.linkedIssues.map((issue) => `#${issue.number}`), + ); + const actualReferences = new Set( + referenceLabelsIn(line).map((reference) => reference.toLowerCase()), + ); + for (const reference of expectedReferences) { + if (!actualReferences.has(reference.toLowerCase())) { + errors.push(`missing ${reference} on contribution record for PR #${entry.number}`); + } + } + } + const editorialProse = section.source.slice(0, ledgerStart); + for (const entry of pullRequests) { + if ( + !entry.editorialEligible && + new RegExp(`(? + line.includes(`#${number}`) && line.toLowerCase().includes(`@${handle.toLowerCase()}`), + ); + if (!credited) { + errors.push(`missing Thanks @${handle} for issue #${number}`); + } + } + } + return errors; +} + +function manifestFor(options, source, ledger, directCommitRecords) { + const directCommits = directCommitRecords.map((commit) => ({ + ...editorialClassification(commit.subject), + commit: commit.hash.slice(0, 12), + subject: commit.subject, + references: commit.references, + author: commit.author, + contributors: commit.contributors, + issues: commit.issues.map((issue) => ({ + number: issue.number, + title: issue.title, + reporter: issue.thanks, + })), + })); + const unlinkedCommits = directCommits.filter((commit) => commit.references.length === 0); + return { + schemaVersion: 2, + base: options.base, + target: options.target, + mergeBase: source.mergeBase, + version: options.version, + shippedBaselines: source.shippedBaselines, + source: { + references: ledger.entries.length, + pullRequests: ledger.pullRequests.length, + issues: ledger.issues.length, + directCommits: directCommits.length, + unlinkedCommits: unlinkedCommits.length, + }, + pullRequests: ledger.pullRequests.map((entry) => ({ + number: entry.number, + title: entry.title, + type: entry.type, + editorialEligible: entry.editorialEligible, + thanks: entry.thanks, + externalReferences: entry.externalReferences, + relatedReferences: [...new Set([...entry.priorReferences, ...referencesIn(entry.title)])], + linkedIssues: entry.linkedIssues.map((issue) => ({ + number: issue.number, + title: issue.title, + reporter: issue.thanks, + })), + })), + directCommits, + unlinkedCommits, + }; +} + +function releaseChecks(changelog, version, releaseTags) { + const checks = []; + for (const tag of releaseTags) { + const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]); + const verification = verifyGithubReleaseNotes({ + body: release.body ?? "", + changelog, + version, + tag, + repository: repo, + }); + checks.push({ + tag, + releaseId: release.id, + matches: verification.matches, + mode: verification.mode, + bodyLength: verification.actualSize.characters, + bodyBytes: verification.actualSize.bytes, + }); + } + return checks; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printUsage(); + return; + } + githubSnapshotState = initializeGithubSnapshot(options); + const changelog = readFileSync("CHANGELOG.md", "utf8"); + const section = sectionFor(changelog, options.version); + const source = sourceCommits(options.base, options.target); + const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor); + const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords); + source.shippedBaselines = shippedExclusions.baselines; + const preexistingNotes = section.source.replace( + /\n+### Complete contribution (?:ledger|record)[\s\S]*$/m, + "", + ); + const noteReferences = referencesIn(preexistingNotes); + const revertedNoteReferences = noteReferences.filter((number) => + source.revertedReferences.has(number), + ); + if (revertedNoteReferences.length > 0) { + fail( + `release notes reference reverted work: ${[...new Set(revertedNoteReferences)] + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const renderedRecord = options.writeLedger + ? { legacyIssues: new Map(), pullRequests: new Map() } + : contributionRecordFor(section); + const renderedRecordReferences = contributionRecordMetadataReferences(renderedRecord); + const revertedRenderedReferences = renderedRecordReferences.filter((number) => + source.revertedReferences.has(number), + ); + if (!options.writeLedger && revertedRenderedReferences.length > 0) { + fail( + `contribution record references reverted work: ${[...new Set(revertedRenderedReferences)] + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const excludedRecordedReferences = new Set([ + ...source.revertedReferences, + ...shippedExclusions.pullRequests, + ]); + const effectiveRenderedRecord = renderedRecord; + const effectiveRenderedRecordReferences = + contributionRecordMetadataReferences(effectiveRenderedRecord); + let priorRecord = { legacyIssues: new Map(), pullRequests: new Map() }; + if (options.seedRef) { + const seedChangelog = git(["show", `${options.seedRef}:CHANGELOG.md`]); + const seedSection = sectionFor(seedChangelog, options.version); + priorRecord = contributionRecordFor(seedSection); + } + priorRecord = withoutExcludedContributionRecords(priorRecord, excludedRecordedReferences); + const recordedReferences = contributionRecordMetadataReferences(priorRecord); + const revertedRecordedReferences = recordedReferences.filter((number) => + source.revertedReferences.has(number), + ); + if (revertedRecordedReferences.length > 0) { + fail( + `contribution record references reverted work: ${[...new Set(revertedRecordedReferences)] + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const references = [...source.references]; + appendReferences(references, noteReferences); + appendReferences(references, effectiveRenderedRecordReferences); + appendReferences(references, recordedReferences); + let nodes = resolveReferences(references); + const contamination = contaminatingPullRequestReferences({ + noteReferences, + recordedReferences: effectiveRenderedRecordReferences, + sourcePullRequests: source.pullRequests, + sourceReferences: source.references, + seededPullRequests: new Set(priorRecord.pullRequests.keys()), + nodes, + }); + if (contamination.length > 0) { + fail( + `release section contains PRs outside ${options.base}..${options.target}: ${contamination + .map((number) => `#${number}`) + .join(", ")}; use --seed-ref only for an intentional historical backfill`, + ); + } + const legacyIssuePullRequests = [...legacyIssuesByPullRequest(priorRecord, nodes).keys()].filter( + (number) => !shippedExclusions.pullRequests.has(number), + ); + appendReferences(references, legacyIssuePullRequests); + nodes = resolveReferences(references); + const unresolvedSourceReferences = references.filter((number) => !nodes.has(number)); + if (unresolvedSourceReferences.length > 0) { + fail( + `GitHub could not resolve source references: ${unresolvedSourceReferences + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const provisionalEntries = references + .map((number) => nodes.get(number)) + .filter((node) => node?.__typename === "PullRequest"); + const titleReferenceNumbers = titleReferences(provisionalEntries); + const closingIssueNumbers = provisionalEntries.flatMap( + (entry) => entry.closingIssuesReferences?.nodes.map((issue) => issue.number) ?? [], + ); + const resolvedReferences = [...references]; + appendReferences(resolvedReferences, titleReferenceNumbers); + appendReferences(resolvedReferences, closingIssueNumbers); + nodes = resolveReferences(resolvedReferences); + const invalidRecordedPullRequests = [...priorRecord.pullRequests.keys()].filter((number) => { + const node = nodes.get(number); + return ( + node?.__typename !== "PullRequest" || + !node.mergedAt || + (!source.pullRequests.has(number) && !mergedByTarget(node.mergedAt, source.targetTimestamp)) + ); + }); + if (!options.writeLedger && invalidRecordedPullRequests.length > 0) { + fail( + `contribution record contains unresolved or unmerged PRs: ${invalidRecordedPullRequests + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const unresolvedTitleReferences = titleReferenceNumbers.filter((number) => !nodes.has(number)); + if (unresolvedTitleReferences.length > 0) { + fail( + `GitHub could not resolve PR-title references: ${unresolvedTitleReferences + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + const contributorHandles = [ + ...source.activeCommits.flatMap((commit) => commit.coauthors), + ...source.activeCommits.map((commit) => commit.authorHandle).filter(Boolean), + ]; + const resolvedHandles = resolveGitHubHandles(contributorHandles); + const relationships = contributionRelationships(source, nodes, resolvedHandles); + const unlinkedCommits = source.activeCommits.filter((commit) => commit.references.length === 0); + const resolvedCommitAuthors = resolveDirectCommitAuthors(relationships.directCommits); + relationships.directCommits = withDirectCommitAuthors( + relationships.directCommits, + resolvedCommitAuthors, + ); + const ledger = ledgerFor( + options.base, + source.target, + references, + nodes, + source.coauthorsByReference, + resolvedHandles, + relationships, + priorRecord, + source.pullRequests, + source.references, + noteReferences, + legacyIssuePullRequests, + source.revertedReferences, + source.shippedBaselines, + source.targetTimestamp, + ); + const manifest = manifestFor( + { ...options, target: source.target }, + source, + ledger, + relationships.directCommits, + ); + + if (options.manifestPath) { + writeFileSync(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } + + const candidateChangelog = options.writeLedger + ? replaceLedger( + changelog, + section, + ledger.ledger, + ledger.pullRequests, + relationships.directCommits, + ) + : changelog; + const candidateSection = options.writeLedger + ? sectionFor(candidateChangelog, options.version) + : section; + + const errors = ledgerChecks( + candidateSection, + ledger.pullRequests, + nodes, + relationships.directCommits, + source.shippedBaselines, + ); + const github = options.checkGithub + ? releaseChecks(candidateChangelog, options.version, options.releaseTags) + : []; + for (const check of github) { + if (!check.matches) { + errors.push( + `GitHub release ${check.tag} does not match the ${options.version} CHANGELOG section`, + ); + } + } + if (options.writeLedger && errors.length === 0) { + const tempPath = `CHANGELOG.md.${process.pid}.tmp`; + try { + writeFileSync(tempPath, candidateChangelog); + renameSync(tempPath, "CHANGELOG.md"); + } finally { + rmSync(tempPath, { force: true }); + } + } + + const result = { + base: options.base, + target: source.target, + mergeBase: source.mergeBase, + version: options.version, + shippedBaselines: source.shippedBaselines, + source: { + references: references.length, + pullRequests: ledger.pullRequests.length, + issues: ledger.issues.length, + directCommits: manifest.directCommits.length, + unlinkedCommits: manifest.unlinkedCommits.length, + }, + github, + githubSnapshot: githubSnapshotState + ? { + path: githubSnapshotState.filePath, + hits: githubSnapshotState.hits, + misses: githubSnapshotState.misses, + } + : null, + errors, + }; + persistGithubSnapshot(githubSnapshotState); + if (options.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + const snapshotSummary = githubSnapshotState + ? `, GitHub snapshot ${githubSnapshotState.hits} hits/${githubSnapshotState.misses} misses` + : ""; + process.stdout.write( + `${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`, + ); + } + if (errors.length > 0) { + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index debeb23f4267..ec22c25b8436 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -292,10 +292,10 @@ the `Verify preinstalled live media dependencies` step before assuming the media tests themselves slowed down. The release Docker path intentionally shards the plugin/runtime tail. The -workflow uses `plugins-runtime-plugins`, `plugins-runtime-services`, and -`plugins-runtime-install-a` through `plugins-runtime-install-d`; aggregate -aliases such as `plugins-runtime-core`, `plugins-runtime`, and -`plugins-integrations` remain for manual reruns. +workflow uses `plugins-runtime-plugins`, `plugins-runtime-services`, +`plugins-runtime-install-a` through `plugins-runtime-install-h`, and a +dedicated `openwebui` job; aggregate aliases such as `plugins-runtime-core`, +`plugins-runtime`, and `plugins-integrations` remain for manual reruns. The release QA parity box is internally split into candidate and baseline lane jobs, followed by a report job that downloads both artifacts and runs @@ -359,8 +359,9 @@ Release-path Docker chunks are currently `core`, `package-update-openai`, `plugins-runtime-plugins`, `plugins-runtime-services`, `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, -`bundled-channels-core`, `bundled-channels-update-a`, -`bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate +`plugins-runtime-install-e`, `plugins-runtime-install-f`, +`plugins-runtime-install-g`, `plugins-runtime-install-h`, and the dedicated +`openwebui` job. The aggregate `bundled-channels`, `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` chunks remain valid for manual one-shot reruns, but release checks use the split chunks. @@ -452,13 +453,18 @@ image. Release-path normal mode fans out into smaller Docker chunk jobs: - `plugins-runtime-install-b` - `plugins-runtime-install-c` - `plugins-runtime-install-d` -- `bundled-channels` +- `plugins-runtime-install-e` +- `plugins-runtime-install-f` +- `plugins-runtime-install-g` +- `plugins-runtime-install-h` +- `openwebui` -OpenWebUI is folded into `plugins-runtime-services` for full release-path -coverage and keeps a standalone `openwebui` chunk only for OpenWebUI-only -dispatches. The legacy `package-update`, `plugins-runtime-core`, +OpenWebUI runs as a standalone `openwebui` chunk on a dedicated large-disk +runner whenever stable or full release-path coverage requests it. The legacy +`package-update`, `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` chunks still work as aggregate -aliases for manual reruns, but the release workflow uses the split chunks so +aliases for manual reruns and may still fold in OpenWebUI, but the release +workflow uses the split chunks so provider installer checks, plugin runtime checks, bundled plugin install/uninstall shards, and bundled-channel checks can run on separate machines. The bundled-channel runtime-dependency coverage @@ -468,8 +474,8 @@ than the serial `bundled-channel-deps` lane, so failures produce cheap targeted reruns for the exact channel/update scenario. The bundled plugin install/uninstall sweep is also split into `bundled-plugin-install-uninstall-0` through -`bundled-plugin-install-uninstall-7`; selecting the legacy -`bundled-plugin-install-uninstall` lane expands to all eight shards. +`bundled-plugin-install-uninstall-23`; selecting the legacy +`bundled-plugin-install-uninstall` lane expands to all 24 shards. ## Package Acceptance diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 84b1570ef09e..17b31ff51d75 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -24,6 +24,29 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele fails, the parent cancels the remaining child matrix and prints the failed job summary. Inspect that first red job instead of waiting for unrelated matrix tails. +- In a sparse worktree or Testbox source sync, first confirm `package.json`, + `pnpm-lock.yaml`, and every source path the selected check reads. If any are + absent, that checkout cannot validate a release dependency or Docker lane: + stop and use the repo remote changed gate or a full task worktree. When the + inputs are present and a release fix changes `package.json` or + `pnpm-lock.yaml`, rebuild only the task-owned disposable box with + `CI=true pnpm install --frozen-lockfile`, then run an explicit + `require.resolve()` probe before Docker or focused tests. The CI flag permits + pnpm to recreate a prewarmed modules directory without an interactive + confirmation. Do not weaken the lockfile or label sparse-checkout failures + as product/Docker failures. +- If the candidate is rebased or its base SHA changes after warmup, stop the + task-owned box and warm a fresh one before testing. Testbox source sync is + relative to the warmed source tree; continuing can mix an old base file with + a new candidate diff and produce false lockfile or Docker failures. +- Reused Testboxes are provenance-gated after their first successful run. + Source-only edits may reuse the lease; base, dependency, wrapper, or Testbox + workflow drift requires a fresh lease. Do not set + `OPENCLAW_TESTBOX_ALLOW_STALE=1` for release evidence. +- For a committed release candidate, warm the box with + `blacksmith testbox warmup ... --ref `. Do not rely + on source sync to overlay committed branch changes onto the workflow's + default ref. ## Preflight @@ -82,23 +105,30 @@ gh workflow run full-release-validation.yml \ -f rerun_group=all ``` -Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes. +For immutable workflow proof on a moving `main`, use +`pnpm ci:full-release --sha `. This retained 6.x helper pushes a +temporary `release-ci/-...` branch at the candidate commit, +dispatches validation from that ref, verifies child runs use the same SHA, and +deletes the temporary branch unless `--keep-branch` is set. It does not support +cross-run evidence reuse. + +Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Stable and full profiles force the release soak; the beta profile may opt in with `run_release_soak=true`. Use narrow `rerun_group` after focused fixes. Publish with `openclaw-release-publish.yml` using `release_profile=from-validation` unless a maintainer intentionally wants to cross-check a specific profile; the publish workflow reads the effective profile from the full-validation manifest. ## Watch -Use the summary helper instead of repeated raw polling: +Use the transition-only summary watcher instead of repeated raw polling: ```bash -node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +node scripts/release-ci-summary.mjs --watch ``` -Then watch only when useful: +For a one-shot snapshot: ```bash -gh run watch --repo openclaw/openclaw --exit-status +node scripts/release-ci-summary.mjs ``` Stop watchers before ending the turn or switching strategy. diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs deleted file mode 100755 index 18fd316c0b34..000000000000 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env node -/** - * Release CI summary helper that prints parent and child workflow status for a - * full release run. - */ -import { execFileSync } from "node:child_process"; -import process from "node:process"; - -const runId = process.argv[2]; -const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; - -if (!runId) { - console.error("usage: release-ci-summary.mjs "); - process.exit(2); -} - -function gh(args) { - return execFileSync("gh", args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); -} - -function jsonGh(args) { - return JSON.parse(gh(args)); -} - -function githubRestJson(pathSuffix) { - const result = execFileSync( - "bash", - [ - "-lc", - [ - "set -euo pipefail", - 'token="$(gh auth token)"', - 'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"', - ].join("\n"), - ], - { - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`, - }, - maxBuffer: 16 * 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - return JSON.parse(result); -} - -function rate() { - try { - return jsonGh(["api", "rate_limit"]).resources.core; - } catch { - return undefined; - } -} - -const core = rate(); -if (core) { - const reset = new Date(core.reset * 1000).toISOString(); - console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`); - if (core.remaining < 20) { - console.error("rate too low for CI summary; wait for reset before polling"); - process.exit(3); - } -} - -const parent = jsonGh([ - "run", - "view", - runId, - "--repo", - repo, - "--json", - "status,conclusion,createdAt,headSha,url,jobs", -]); - -console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`); -console.log(`sha: ${parent.headSha}`); -console.log(`url: ${parent.url}`); - -for (const job of parent.jobs ?? []) { - const marker = job.conclusion || job.status; - console.log(`parent-job: ${marker} ${job.name}`); -} - -const since = parent.createdAt; -const runsQuery = new URLSearchParams({ - per_page: "100", - created: `>=${since}`, - exclude_pull_requests: "true", -}); -const childWorkflowNames = new Set([ - "CI", - "OpenClaw Release Checks", - "Plugin Prerelease", - "NPM Telegram Beta E2E", - "Full Release Validation", -]); -const runs = githubRestJson(`actions/runs?${runsQuery.toString()}`).workflow_runs ?? []; -const runList = runs - .filter( - (run) => - run.created_at >= since && - run.head_sha === parent.headSha && - childWorkflowNames.has(run.name), - ) - .map((run) => - [run.id, run.name, run.status, run.conclusion ?? "", run.head_sha, run.html_url].join("\t"), - ) - .join("\n"); - -if (!runList) { - console.log("children: none found yet"); - process.exit(0); -} - -console.log("children:"); -for (const line of runList.split("\n")) { - const [id, name, status, conclusion, sha, url] = line.split("\t"); - console.log(`child: ${id} ${name} ${status}/${conclusion || "none"} sha=${sha}`); - console.log(`child-url: ${url}`); -} diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 43c9d9730d44..0fff22b46129 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -92,6 +92,60 @@ Use this skill for release and publish-time workflow. Load `$release-private` if - When asked to announce on X, use `~/Projects/bird/bird` and follow the release tweet style below. +## Prepare extended-stable backports + +When asked to create the initial `.33` extended-stable line or a later +maintenance patch, read +`references/extended-stable-backports.md` and follow it before version, tag, or +publication work. Treat backport discovery and preparation as an ability of +this release skill, not as a separate release workflow. + +The backport ability owns the complete mainline inventory, private-security +reconciliation, candidate decisions, maintainer approval, coordinated staging +PR, and proof handoff. After that PR lands, use the dedicated npm-only sequence +below. Never route `.33+` through the regular beta/stable release sequence. + +## Publish extended-stable releases + +Use this path only for the trailing completed month's `.33+` line. Treat +`docs/reference/RELEASING.md`, +`scripts/openclaw-npm-extended-stable-release.mjs`, and the release workflows +on pinned current `main` as the exact command and validation contract. + +1. Check out the canonical `extended-stable/YYYY.M.33` branch after the + approved backport PR lands. Require its tip, root package version, every + publishable official plugin version, and intended immutable `vYYYY.M.P` tag + to identify one exact release commit. +2. Create and push `vYYYY.M.P` at that exact branch tip only after version prep + and focused backport proof are complete. +3. Dispatch `openclaw-npm-release.yml` with `preflight_only=true` and + `npm_dist_tag=extended-stable` from the canonical branch. Save the successful + npm preflight run ID and its exact `run_attempt`. +4. Dispatch `full-release-validation.yml` from the same branch with + `ref=extended-stable/YYYY.M.33` and `release_profile=stable`. Save the + successful exact-head validation run ID and its exact `run_attempt` from + `gh api repos/openclaw/openclaw/actions/runs/ --jq .run_attempt`. +5. Dispatch `plugin-npm-release.yml` from the same branch with + `publish_scope=all-publishable`, the full release SHA as `ref`, and + `npm_dist_tag=extended-stable`. Require complete exact-version and selector + readback, then save the successful plugin run ID and its exact `run_attempt`. +6. Dispatch the real `openclaw-npm-release.yml` publish from the same branch + with the intended tag, `npm_dist_tag=extended-stable`, all three saved run + IDs, `preflight_run_attempt=`, + `full_release_validation_run_attempt=`, and + `plugin_npm_run_attempt=`. The workflow must publish the + exact prepared core tarball and prove the referenced runs match the canonical + branch and release SHA. +7. Independently verify the exact core package, every official plugin package, + and all `extended-stable` selectors. If only the core selector readback + fails, use the `openclaw` repair command generated by the core workflow. If + an official-plugin selector is missing or stale for an already-published + version, use the approved credential-isolated release tooling for manual + plugin tag repair; the OIDC source workflow cannot mutate that tag. Never + republish an immutable version. +8. Do not create a GitHub Release or publish macOS, Windows, Docker, mobile, + website, ClawHub, or private dist-tag artifacts from this path. + ## Keep release channel naming aligned - `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later @@ -362,6 +416,33 @@ pnpm test:install:smoke `npm view version dist-tags --json --prefer-online`; a 404 for a package newly added to the release is a release-prep blocker, not something to discover from the publish job. +- Bootstrap a new ClawHub package only from the trusted workflow source: + `gh workflow run plugin-clawhub-new.yml --ref main -f plugins=@openclaw/name -f ref= -f pretag_validation=true -f dry_run=true`. + The workflow source stays on `main`; `ref` is the exact release target. A + pre-tag dry run rejects tag/parent-approval inputs and requires the target to be + reachable from `main` or `release/*`. It must still resolve the live registry + plan, pack every candidate, upload and download the exact artifact ID, rehash + the inventory, reject ambiguous TAR paths locally with the pinned CLI's USTAR + canonicalization, and validate each tarball with the pinned CLI publish + dry-run. It never loads credentials or changes package/trusted-publisher + state. Approve the `clawhub-plugin-bootstrap` environment only after the + secretless pack jobs finish; the protected validation job itself has no + credentials or mutation commands. For an + existing version missing trusted-publisher configuration, pack the target + bytes too and require its tag plus exact registry byte/metadata equality + before allowing configuration-only repair. The credential-job prefilter + enforces the ClawHub 120 MiB compressed and 50 MiB total-payload limits, plus + 64 MiB expanded-TAR and 10,000-TAR-entry parser-safety limits. A mismatch + requires a new version; never bless unrelated immutable bytes. A real run + publishes the exact downloaded tarball, bounds each CLI attempt, and records + byte-identical registry readback. Final release verification must consume the + unique terminal readback artifact and bind its main-only workflow SHA/attempt, + target SHA, requested packages, package artifact ID/name/digest, and + per-package SHA-256/size/npm integrity metadata. The parent approval attests a + separate exact trusted-main child workflow SHA; the child run and protected + approval must match it. Rerun-failed recovery may reuse a prior package + artifact only when the exact producer job succeeded. Final evidence must also + preserve the locked ClawHub version, lock SHA-256, and npm integrity. - Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage. It starts a local OTLP/HTTP trace receiver, runs QA-lab's `otel-trace-smoke`, and checks span names plus content/identifier redaction @@ -539,12 +620,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - The npm workflow and the private mac publish workflow accept `preflight_only=true` to run validation/build/package steps without uploading public release assets. -- Real npm publish requires a prior successful npm preflight run id and the - successful Full Release Validation run id for the same tag/SHA so the publish - job promotes the prepared tarball instead of rebuilding it and attaches the - correct release evidence. -- Real private mac publish requires a prior successful private mac preflight - run id so the publish job promotes the prepared artifacts instead of +- Real npm publish requires a prior successful npm preflight run id plus the + successful Full Release Validation run id and exact run attempt for the same + tag/SHA so the publish job promotes the prepared tarball instead of rebuilding + it and attaches the correct release evidence. +- Real release-ops mac publish requires a prior successful release-ops mac + preflight run id so the publish job promotes the prepared artifacts instead of rebuilding or renotarizing them again. - The private mac workflow also accepts `smoke_test_only=true` for branch-safe workflow smoke tests that use ad-hoc signing, skip notarization, skip shared @@ -563,8 +644,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts and release proof manually. Never rerun the publish workflow for that already-published version. - npm validation-only preflight may still be dispatched from ordinary branches - when testing workflow changes before merge. Release checks and real publish - use only `main` or `release/YYYY.M.PATCH`. + when testing workflow changes before merge. Regular beta and stable release + checks and publish orchestration use trusted `main` against the exact target + tag; Tideclaw alpha keeps its matching alpha branch. - `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a public validation-only handoff. It validates the tag/release state and points operators to the private repo. It still rebuilds the JS outputs needed for @@ -583,17 +665,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - Private smoke-test runs upload ad-hoc, non-notarized build artifacts as workflow artifacts and intentionally skip stable `appcast.xml` generation. - For stable releases, npm preflight, Full Release Validation, public mac - validation, private mac validation, and private mac preflight must all pass - before any real publish run starts. For beta releases, npm preflight and Full - Release Validation must pass before npm publish unless the operator explicitly - waives the full gate; mac beta validation is still only required when - requested. -- Real publish runs may be dispatched from `main` or from a - `release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained - in that release branch, and the real publish must reuse a successful preflight - from the same branch. -- The release workflows stay tag-based; rely on the documented release sequence - rather than workflow-level SHA pinning. + validation, release-ops mac validation, and release-ops mac preflight must all + pass before any real publish run starts. For beta releases, npm preflight and + Full Release Validation must pass before npm publish unless the operator + explicitly waives the full gate; mac beta validation is still only required + when requested. +- Focused plugin-only repairs use `plugin_publish_scope=selected` with a nonempty + package list. `all-publishable` plugin runs require complete immutable npm + preflight and Full Release Validation evidence even when core npm publication + is disabled. +- Pin regular beta and stable `OpenClaw Release Publish` tooling by pushing the + current trusted `main` SHA to `release-publish/` and dispatching + from that exact ref. The tag still selects the release commit, including a + commit on `release/YYYY.M.PATCH`. Tideclaw alpha publish runs remain on their + matching alpha branch. Reuse the successful preflight for that exact release + SHA. +- Keep the immutable release-publish ref unchanged until every child finishes; + the parent uses it for all trusted child dispatches. - The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues. - Mac publish uses `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for @@ -685,11 +773,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts 15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait for the mock parity, live Matrix, and live Telegram credentialed-channel lanes to pass. -16. Start `.github/workflows/openclaw-npm-release.yml` from the release branch - with `preflight_only=true` +16. Resolve the current trusted `main` SHA, push it once to + `release-publish/`, and keep that ref immutable. Start + `.github/workflows/openclaw-npm-release.yml` from that exact ref with + `preflight_only=true` and choose the intended `npm_dist_tag` (`beta` default; `latest` only for an intentional direct stable publish). Wait for it to pass. Save that run id - because the real publish requires it to reuse the prepared npm tarball. + and exact attempt because the real publish requires them to reuse the + prepared npm tarball. Run `Full Release Validation` from the same immutable + workflow ref with its target `ref` set to the release tag/SHA; save its run + ID and exact attempt too. 17. Before real publish, review the early performance run if it has completed. Compare against earlier release evidence or clawgrit reports where available. Call out minor regressions in the release proof; block on major @@ -714,11 +807,15 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts where npm did not publish the beta version, delete/recreate the same beta tag and any accidental draft/incomplete prerelease at the fixed commit instead of skipping a prerelease number. -22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with - the same tag for the real publish, choose `npm_dist_tag` (`beta` default, - `latest` only when you intentionally want direct stable publish), keep it - the same as the preflight run, and pass the successful npm - `preflight_run_id` plus the successful `full_release_validation_run_id`. +22. Start `.github/workflows/openclaw-release-publish.yml` from the unchanged + immutable `release-publish/` ref that produced npm preflight + and Full Release Validation evidence, with the same tag for the real beta + or stable publish. Choose `npm_dist_tag` (`beta` default; `latest` only when + you intentionally want direct stable publish), keep it the same as the + preflight run, and pass the successful npm + `preflight_run_id` and exact `preflight_run_attempt`, plus the successful + `full_release_validation_run_id` and its exact + `full_release_validation_run_attempt`. For stable publish, also pass the exact non-prerelease `openclaw/openclaw-windows-node` tag as `windows_node_tag` and its candidate-approved installer digest map as `windows_node_installer_digests`. diff --git a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md new file mode 100644 index 000000000000..9af0698665d4 --- /dev/null +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md @@ -0,0 +1,270 @@ +# Extended-Stable Backport Preparation + +Prepare the next npm maintenance patch for the active `extended-stable` line. +Discover the complete candidate set, obtain maintainer approval, and prepare +the approved commits as one coordinated PR. Treat commits as canonical; use +PRs, issues, ClawSweeper reports, and advisories as supporting context. + +## Boundaries + +- Read `docs/reference/RELEASING.md`, + `scripts/openclaw-npm-extended-stable-release.mjs`, and the relevant release + workflows from a pinned current `origin/main` before resolving the line. +- Target npm `extended-stable` and the canonical + `extended-stable/YYYY.M.33` branch. The user-facing `extended-stable` update + channel resolves that selector; user-facing `stable` continues to resolve + npm `latest`. +- Cover the core `openclaw` package and every npm-publishable official plugin + included by the canonical `all-publishable` release inventory at the same + exact version. +- Exclude ClawHub publication, GitHub Releases, native apps, Docker images, + mobile artifacts, website downloads, and private-repository dist-tags. +- Review the complete mainline delta. Do not stop after the first obvious + fixes or consider public PRs the complete source set. +- Present the full proposed release set before changing release refs. +- Never push directly to the canonical branch, create a release tag, publish a + package, or mutate an npm dist-tag during discovery or staging. +- Never use `bypass_extended_stable_guard=true` for production. +- Reject features, broad refactors, speculative hardening, and changes that + require new config, migrations, APIs, protocols, dependencies, runtime + requirements, or operator action. +- Read `SECURITY.md` and use `$security-triage` for security candidates. Route + unpublished advisory work through `$openclaw-ghsa-maintainer`; never expose + private details before the security owner authorizes disclosure. +- Use `$openclaw-testing` for proof selection, `$autoreview` before handoff, + and `$openclaw-pr-maintainer` for GitHub operations. + +## Resolve the Active Line + +1. Run `git status -sb`. Do not overwrite unrelated work. +2. Fetch current `origin/main`, tags, and `extended-stable/*` branches. +3. Pin the fetched `origin/main` SHA. Read the release contract from that exact + commit before resolving versions, package scope, or branches. +4. Query npm dist-tags and choose exactly one mode: + - **Existing line:** `extended-stable` exists. Treat its exact final + `YYYY.M.PATCH` value as the published baseline; require `PATCH >= 33` and + no prerelease or correction suffix. + - **Bootstrap:** the selector is absent. Obtain explicit maintainer approval + for the completed `YYYY.M` month and exact final base tag. Do not infer the + base solely from `latest`, which may already have advanced. +5. Derive the only valid branch as `extended-stable/YYYY.M.33`. + - Existing line: require the branch to exist, its `package.json` version to + equal the selector, and `vYYYY.M.PATCH` to resolve to the branch tip. + - Bootstrap: use the approved base tag for discovery. If the canonical + branch exists, require its tip to equal the approved base commit and reject + unexplained unpublished changes. Do not create the remote branch during + discovery. +6. Confirm the published baseline or approved bootstrap base resolves from npm + and its Git tag resolves to the expected commit. +7. Confirm `origin/main` has an exact final version in a strictly later + calendar month with a patch below `33`, matching the production guard. +8. Choose the intended version: + - bootstrap: exact final `YYYY.M.33`; + - existing line: the next unused final patch on the same `YYYY.M` line, + normally `PATCH + 1` and always `>= 34`. +9. Verify the intended core and official-plugin versions are absent from npm. + +Use an isolated npm config for unauthenticated registry reads: + +```bash +npm_userconfig=$(mktemp) +trap 'rm -f "$npm_userconfig"' EXIT +dist_tags=$(npm view openclaw dist-tags --json --userconfig "$npm_userconfig") +published_version=$(printf '%s' "$dist_tags" | jq -r '."extended-stable" // empty') +if [[ -n "$published_version" ]]; then + npm view "openclaw@${published_version}" version \ + --userconfig "$npm_userconfig" +fi +``` + +Do not use GitHub's latest nonprerelease Release as the source of truth. The +extended-stable lane intentionally creates no GitHub Release. In bootstrap +mode, record the approving maintainer and approved base commit. Stop before +discovery or mutation if npm, the canonical branch, tags, package versions, +approved base, or protected `main` disagree. + +## Build the Complete Commit Inventory + +Freeze `scan_end` to the pinned `origin/main` SHA. Resolve `scan_start` in this +order: + +1. the prior accepted extended-stable backport evidence's recorded `scan_end`; +2. for the first run, the merge base between the canonical branch and `main`; +3. an explicitly audited maintainer-provided mainline cursor when histories are + unrelated. + +Never reuse a cursor from an open, abandoned, partially landed, or rejected PR. +Load unresolved `blocked` candidates from the accepted prior evidence before +classifying new commits. Advance the cursor only when those candidates remain +durably recorded for the next run. + +```bash +scan_end=$(git rev-parse origin/main) +scan_start=${PRIOR_ACCEPTED_SCAN_END:-} +if [[ -z "$scan_start" ]]; then + scan_start=$(git merge-base "" "$scan_end") +fi +git merge-base --is-ancestor "$scan_start" "$scan_end" +git log --reverse --format='%H%x09%ad%x09%an%x09%s' --date=short \ + "$scan_start..$scan_end" +git cherry "" "$scan_end" "$scan_start" +``` + +If no auditable start exists, stop rather than guessing from dates or titles. + +Create an uncommitted scratch ledger with one row per non-equivalent commit. +Process deterministic batches of at most 100 commits. Record each SHA, subject, +changed paths, first-pass decision, and missing evidence. + +```bash +ledger_dir=$(mktemp -d) +git rev-list --reverse "$scan_start..$scan_end" >"$ledger_dir/all-commits.txt" +git cherry "" "$scan_end" "$scan_start" \ + >"$ledger_dir/patch-equivalence.txt" +split -l 100 "$ledger_dir/all-commits.txt" "$ledger_dir/batch-" +``` + +Review every ledger entry's subject and changed-file summary. Inspect the full +diff and surrounding code for every plausible security or reliability fix. +Account for merges, squash commits, direct commits, reordered patches, +branch-specific equivalents, and companion commits that `git cherry` misses. +Do not finish while any entry remains unclassified. + +Also inspect direct maintainer/security commits, linked PRs and issues, +ClawSweeper findings, companion fixes, callers, siblings, tests, and dependency +contracts. + +## Filter by Publication Surface + +Include only fixes that affect the core package or an npm-publishable official +plugin in the exact release inventory. Prove package inclusion rather than +inferring it from the source path alone. + +- Do not exclude `extensions/**` by path. Determine whether the package appears + in the canonical `all-publishable` inventory. +- Include plugin fixes only when the canonical workflow publishes that package + at the same intended version and can verify its exact package and selector. +- Treat ClawHub-only, external, private, or otherwise unlisted plugin changes as + out of scope. +- Treat native-only, Docker-only, mobile-only, website-only, and GitHub + Release-only fixes as `skip` for this npm-only line. +- Treat cross-repository or package-topology uncertainty as `blocked` until the + shipped npm surface and release owner are proven. + +Prioritize crashes, hangs, restart loops, data/session/message loss, +auth/provider failures, serious mature-behavior regressions, +release/update/rollback failures, and bounded resource exhaustion. Do not +exclude a commit because its title lacks `fix:` or it has no PR. + +## Reconcile Private Security Work + +Before calling the release set complete, use `$security-triage` and +`$openclaw-ghsa-maintainer` to: + +1. enumerate authorized open/draft advisories and private-fork fix state; +2. determine privately whether each item affects a published npm package in the + extended-stable release inventory; +3. route applicable unpublished fixes through the approved private workflow; +4. expose only an opaque pending/cleared status publicly. + +If advisory access is unavailable, require explicit security-owner +confirmation. Never copy advisory titles, exploit details, private SHAs, or +private refs into the public ledger, branch, PR, or chat output. + +## Assess Every Plausible Fix + +For each candidate, prove: + +1. The faulty behavior exists in the published extended-stable package set or + canonical branch. +2. The public source commit is on `main` and is not already present or + behaviorally equivalent on the branch. +3. The change restores existing behavior instead of adding functionality. +4. The fix includes all required companion commits. +5. Any branch-specific adaptation is narrow and preserves the invariant. +6. Focused validation can prove the fix on the maintenance branch. +7. The complete fix ships through the canonical npm publication inventory. + +Classify each plausible fix as: + +- `backport`: applicable, material, isolated, npm-shipped, and testable; +- `already-covered`: commit or equivalent behavior is present; +- `not-affected`: the published package set does not contain the defect; +- `blocked`: useful, but adaptation, package scope, or proof is incomplete; +- `skip`: feature, low-impact change, refactor, or out-of-scope surface. + +Do not infer that a clean cherry-pick is safe. Treat config/default, persisted +state, plugin/API boundary, protocol, dependency, packaging, installer, and +cross-repository changes as high risk requiring maintainer judgment. + +## Present the Full Release Set + +Before mutation, report: + +| Source commit | Decision | Published impact | Dependencies | Adaptation | Proof | +| ------------- | -------- | ---------------- | ------------ | ---------- | ----- | + +Include the published npm selector/version, canonical branch, intended patch, +protected `main` version, scan bounds, total commits, batch count, dependency +order, complete proposed set, blocked/high-risk decisions, carry-forward items, +affected core/plugin packages, out-of-scope publication surfaces, and +confidential security status. + +Use PR links when they exist, but retain source commit identities in internal +evidence. Obtain explicit maintainer approval for the complete release set +before changing branches. + +## Prepare the Approved Patch Set + +1. Resolve the exact target commit. In existing-line mode, use the canonical + remote head. In bootstrap mode, use the approved base commit; after release + set approval, create the canonical branch from that exact commit if it is + still absent. Re-fetch and verify it before creating a separate staging + branch. +2. Apply each approved public source commit in dependency order with + `git cherry-pick -x`. Keep commits separate and avoid unrelated cleanup. +3. Compare every result with the source diff and maintenance branch. Return a + candidate to `blocked` if adaptation becomes architectural. +4. Backport or add focused regression tests where practical. Run focused proof + per fix, then combined changed-surface and release-relevant checks. Use + Crabbox/Testbox for broad, package, cross-OS, release, or E2E proof. +5. Set the intended root version and run `pnpm release:prep` on the same staging + branch. Verify every publishable official extension package has that exact + version. Do not create the tag or dispatch publication before the PR lands. +6. Run `$autoreview` until no accepted/actionable findings remain. +7. Open one coordinated PR targeting the canonical extended-stable branch. + Never target `main` and never push the target branch directly. +8. Keep unpublished security work in the approved private advisory fork until + disclosure is authorized. + +The PR body must list the intended maintenance tag, exact npm publication +inventory, every source commit and optional PR, impact, adaptations, focused +and combined proof, security status, rollback considerations, and exact scan +bounds. Record unresolved blocked candidates so the next run carries them +forward. + +## Handoff + +Report: + +- mode, published `openclaw@extended-stable` version or approved bootstrap + base, and canonical branch; +- intended maintenance tag and final staging head; +- included, skipped, blocked, not-affected, and already-covered candidates; +- affected core/plugin packages, adaptations, and commit order; +- proof commands, run IDs, and autoreview result; +- remaining security, release, or maintainer approvals; +- the coordinated PR URL or why no PR was opened; +- explicit confirmation that no non-npm publication is planned. + +After the PR lands, continue with this skill's canonical extended-stable +release flow. Require exact branch-tip/tag/package identity; run npm preflight +and Full Release Validation from the canonical branch; publish every +npm-publishable official plugin from the exact release SHA; publish the +prepared core tarball with the referenced successful run IDs; verify every +exact package and `extended-stable` selector; and preserve the generated +core `openclaw` selector-repair command. Repair missing or stale official- +plugin selectors on already-published versions with the approved credential- +isolated release tooling for manual tag repair; the OIDC source workflow cannot +mutate those tags. Never republish an immutable version when only a selector +needs repair. diff --git a/.agents/skills/release-openclaw-nightly/SKILL.md b/.agents/skills/release-openclaw-nightly/SKILL.md index e22c86b7fb24..ef2d1a8f4d83 100644 --- a/.agents/skills/release-openclaw-nightly/SKILL.md +++ b/.agents/skills/release-openclaw-nightly/SKILL.md @@ -199,13 +199,21 @@ git tag -a "$TAG" "$SHA" -m "openclaw ${TAG#v}" git push origin "$TAG" ``` -8. Dispatch the publish wrapper from the same alpha branch. Use the successful npm preflight run ID and full release validation run ID from the same head SHA: +8. Dispatch the publish wrapper from the same alpha branch. Use the successful npm preflight run ID and the full release validation run ID plus exact attempt from the same head SHA: ```bash +NPM_PREFLIGHT_RUN_ATTEMPT="$(gh api \ + "repos/openclaw/openclaw/actions/runs/${NPM_PREFLIGHT_RUN_ID}" \ + --jq .run_attempt)" +FULL_RELEASE_VALIDATION_RUN_ATTEMPT="$(gh api \ + "repos/openclaw/openclaw/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}" \ + --jq .run_attempt)" "$GH" workflow run openclaw-release-publish.yml --repo openclaw/openclaw --ref "$BRANCH" \ -f tag="$TAG" \ -f preflight_run_id="$NPM_PREFLIGHT_RUN_ID" \ + -f preflight_run_attempt="$NPM_PREFLIGHT_RUN_ATTEMPT" \ -f full_release_validation_run_id="$FULL_RELEASE_VALIDATION_RUN_ID" \ + -f full_release_validation_run_attempt="$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" \ -f npm_dist_tag=alpha \ -f plugin_publish_scope=all-publishable \ -f publish_openclaw_npm=true \ diff --git a/.agents/skills/release-openclaw-plugin-testing/SKILL.md b/.agents/skills/release-openclaw-plugin-testing/SKILL.md index d7e5d7a3bc59..7b0e40bbfb9c 100644 --- a/.agents/skills/release-openclaw-plugin-testing/SKILL.md +++ b/.agents/skills/release-openclaw-plugin-testing/SKILL.md @@ -70,12 +70,12 @@ pnpm test:docker:bundled-channel-deps:fast For full bundled install/uninstall proof, shard the packaged sweep: ```bash -OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL=8 \ -OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX=<0-7> \ +OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL=24 \ +OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX=<0-23> \ pnpm test:docker:bundled-plugin-install-uninstall ``` -Expected current packaged scope: 116 public bundled plugins over shards `0-7`. +Expected current packaged scope: 116 public bundled plugins over shards `0-23`. Private QA plugins are source-mode only unless a package explicitly includes them. @@ -84,18 +84,18 @@ them. Use this matrix for pre-release signoff. Record pass/fail, run URL/Testbox ID, package SHA/version, and skipped-live reason. -| Surface | Proof | Preferred runner | -| --- | --- | --- | -| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions | -| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker | -| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance | -| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance | -| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance | -| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness | -| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness | -| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness | -| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke | -| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes | +| Surface | Proof | Preferred runner | +| -------------------- | --------------------------------------------------------------- | ------------------------------------ | +| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions | +| Bundled lifecycle | 24-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker | +| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance | +| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance | +| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance | +| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness | +| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness | +| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness | +| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke | +| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes | ## Package Acceptance Plan @@ -117,6 +117,40 @@ Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep `workflow_ref` as trusted current harness code unless the release process says otherwise. +## Plugin npm Artifact Preflight + +Use the trusted `main` workflow to prepare and read back a selected plugin npm +artifact from an exact release SHA without entering any publish approval, +environment, secret, OIDC, npm mutation, or ClawHub mutation path: + +```bash +release_sha="$(git rev-parse origin/release/2026.7.1)" +ghx workflow run plugin-npm-release.yml \ + --repo openclaw/openclaw \ + --ref main \ + -f preflight_only=true \ + -f publish_scope=selected \ + -f plugins=@openclaw/meta-provider \ + -f ref="${release_sha}" \ + -f npm_dist_tag=default +``` + +Do not pass `release_publish_run_id`. Require the workflow to finish +`verify_plugin_npm_preflight` successfully. Record the run URL, workflow SHA, +and source SHA. The workflow first creates the staging/readback artifact +`plugin-npm-package-source--` containing +`npm-pack.json`, `preflight-manifest.json`, and the tarball. It then uploads the +final consumer artifact `plugin-npm-package--` containing +the tarball and `plugin-npm-package-evidence.json`. + +Record the final artifact name and digest separately. In the v2 evidence, +`publicationArtifact` binds the staging artifact id, name, digest, source and +packed `package.json` hashes, and tarball hash. This proof is validation-only; +it does not authorize or stage publication. For an already-published version, +require npm `dist.integrity` and `dist.shasum` to match the verified tarball. +Treat only missing or provably older dist-tags as repairable; newer or +incomparable selectors are a blocker. + ## New Testbox Harness Plan If more certainty is needed, add or run a `plugin-lifecycle-matrix` Docker lane diff --git a/.github/release/clawhub-cli/package-lock.json b/.github/release/clawhub-cli/package-lock.json new file mode 100644 index 000000000000..7b326609bb99 --- /dev/null +++ b/.github/release/clawhub-cli/package-lock.json @@ -0,0 +1,481 @@ +{ + "name": "openclaw-release-clawhub-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-release-clawhub-cli", + "version": "1.0.0", + "dependencies": { + "clawhub": "0.23.1" + } + }, + "node_modules/@ark/schema": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz", + "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.0" + } + }, + "node_modules/@ark/util": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz", + "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", + "license": "MIT" + }, + "node_modules/@clack/core": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz", + "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz", + "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@openclaw/plugin-inspector": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@openclaw/plugin-inspector/-/plugin-inspector-0.3.17.tgz", + "integrity": "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw==", + "license": "MIT", + "bin": { + "plugin-inspector": "src/cli.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/arkregex": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.6.tgz", + "integrity": "sha512-9mvuMKQuibfWhBrsNYhsKhNb6k9oEHoAJ/FvDiqe8h+E9Siwe0/cro1WVOGgpajXQ9ZHd24yCOf2k35Q/QqUQw==", + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.0" + } + }, + "node_modules/arktype": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.1.tgz", + "integrity": "sha512-CWPJxNoSxrS+NYGB3ufwc/blFonESEW5vBQyYPVS0rf4STu8VWoAWfKJSl5vVVm56h4yxpwbODeYwy6XFKvojA==", + "license": "MIT", + "dependencies": { + "@ark/schema": "0.56.0", + "@ark/util": "0.56.0", + "arkregex": "0.0.6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clawhub": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/clawhub/-/clawhub-0.23.1.tgz", + "integrity": "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "1.6.0", + "@openclaw/plugin-inspector": "0.3.17", + "arktype": "2.2.1", + "commander": "15.0.0", + "fflate": "0.8.3", + "ignore": "7.0.5", + "json5": "2.2.3", + "mime": "4.1.0", + "ora": "9.4.1", + "p-retry": "8.0.0", + "semver": "7.8.5", + "undici": "7.28.0" + }, + "bin": { + "clawdhub": "bin/clawdhub.js", + "clawhub": "bin/clawdhub.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", + "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.3.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/.github/release/clawhub-cli/package.json b/.github/release/clawhub-cli/package.json new file mode 100644 index 000000000000..8b11d4691a6f --- /dev/null +++ b/.github/release/clawhub-cli/package.json @@ -0,0 +1,8 @@ +{ + "name": "openclaw-release-clawhub-cli", + "private": true, + "version": "1.0.0", + "dependencies": { + "clawhub": "0.23.1" + } +} diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 5c8d6fb542a4..5bd393161545 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -1,4 +1,5 @@ name: Full Release Validation +run-name: ${{ inputs.dispatch_correlation_id || github.workflow }} on: workflow_dispatch: @@ -8,6 +9,16 @@ on: required: true default: main type: string + dispatch_correlation_id: + description: Optional unique dispatch correlation used by release orchestration + required: false + default: "" + type: string + target_context_ref: + description: Optional canonical release branch or tag context for an exact-SHA target + required: false + default: "" + type: string provider: description: Provider lane for cross-OS onboarding and the end-to-end agent turn required: false @@ -1082,7 +1093,8 @@ jobs: -f repeat=3 \ -f deep_profile=false \ -f live_openai_candidate=false \ - -f fail_on_regression=true + -f fail_on_regression=true \ + -f publish_reports=false run_id="" for _ in $(seq 1 60); do @@ -1593,6 +1605,14 @@ jobs: NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} PERFORMANCE_CONCLUSION: ${{ needs.performance.outputs.conclusion }} + PROVIDER: ${{ inputs.provider }} + MODE: ${{ inputs.mode }} + TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} + LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} + CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }} + RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} + CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} run: | set -euo pipefail manifest_dir="${RUNNER_TEMP}/full-release-validation" @@ -1602,6 +1622,9 @@ jobs: --arg runId "$GITHUB_RUN_ID" \ --arg runAttempt "$GITHUB_RUN_ATTEMPT" \ --arg workflowRef "$GITHUB_REF_NAME" \ + --arg workflowSha "$GITHUB_SHA" \ + --arg workflowFullRef "$GITHUB_REF" \ + --arg workflowRefType "$GITHUB_REF_TYPE" \ --arg targetRef "$TARGET_REF" \ --arg targetSha "$TARGET_SHA" \ --arg releaseProfile "$RELEASE_PROFILE" \ @@ -1613,20 +1636,42 @@ jobs: --arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \ --arg performanceRunId "$PERFORMANCE_RUN_ID" \ --arg performanceConclusion "$PERFORMANCE_CONCLUSION" \ + --arg provider "$PROVIDER" \ + --arg mode "$MODE" \ + --arg targetContextRef "$TARGET_CONTEXT_REF" \ + --arg liveSuiteFilter "$LIVE_SUITE_FILTER" \ + --arg crossOsSuiteFilter "$CROSS_OS_SUITE_FILTER" \ + --arg releasePackageSpec "$RELEASE_PACKAGE_SPEC" \ + --arg packageAcceptancePackageSpec "$PACKAGE_ACCEPTANCE_PACKAGE_SPEC" \ + --arg codexPluginSpec "$CODEX_PLUGIN_SPEC" \ '{ - version: 2, + version: 3, workflowName: $workflowName, runId: $runId, runAttempt: $runAttempt, workflowRef: $workflowRef, + workflowSha: $workflowSha, + workflowFullRef: $workflowFullRef, + workflowRefType: $workflowRefType, targetRef: $targetRef, targetSha: $targetSha, releaseProfile: $releaseProfile, rerunGroup: $rerunGroup, runReleaseSoak: $runReleaseSoak, + validationInputs: { + provider: $provider, + mode: $mode, + targetContextRef: $targetContextRef, + liveSuiteFilter: $liveSuiteFilter, + crossOsSuiteFilter: $crossOsSuiteFilter, + releasePackageSpec: $releasePackageSpec, + packageAcceptancePackageSpec: $packageAcceptancePackageSpec, + codexPluginSpec: $codexPluginSpec + }, controls: { stableSoakRequired: ($releaseProfile == "stable" or $releaseProfile == "full"), - performanceBlocking: true + performanceBlocking: true, + performanceReportPublication: "artifact-only" }, childRuns: { normalCi: $normalCiRunId, @@ -1656,3 +1701,4 @@ jobs: name: full-release-validation-${{ github.run_id }} path: ${{ runner.temp }}/full-release-validation if-no-files-found: error + overwrite: true diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index e88a128c638a..535d00b76c7c 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -1,4 +1,5 @@ name: NPM Telegram Beta E2E +run-name: ${{ inputs.dispatch_correlation_id || github.workflow }} on: workflow_dispatch: @@ -8,6 +9,11 @@ on: required: true default: openclaw@beta type: string + dispatch_correlation_id: + description: Optional unique dispatch correlation used by release orchestration + required: false + default: "" + type: string package_label: description: Optional display label for an artifact-backed package candidate required: false @@ -161,8 +167,8 @@ jobs: set -euo pipefail if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then - if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then - echo "package_spec must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2 + if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(alpha|beta|extended-stable|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then + echo "package_spec must be openclaw@alpha, openclaw@beta, openclaw@extended-stable, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2 exit 1 fi fi diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index e88134d923df..eca8e8c35349 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -1,4 +1,5 @@ name: OpenClaw NPM Release +run-name: ${{ inputs.dispatch_correlation_id || github.workflow }} on: workflow_dispatch: @@ -7,6 +8,11 @@ on: description: Release tag to publish, or any full 40-character commit SHA for validation-only preflight (for example v2026.3.22 or 0123456789abcdef0123456789abcdef01234567) required: true type: string + dispatch_correlation_id: + description: Optional unique dispatch correlation used by release orchestration + required: false + default: "" + type: string preflight_only: description: Run validation/build only and skip the gated publish job required: true @@ -184,12 +190,46 @@ jobs: OPENCLAW_LOCAL_CHECK: "0" run: pnpm check:architecture + # Same-SHA re-runs restore the built outputs so a late preflight failure + # does not redo the build and Control UI build; every validation step + # still runs against the restored artifacts. + - name: Resolve preflight build cache key + id: preflight_cache_key + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Restore preflight build outputs + id: dist_build_cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + dist/ + dist-runtime/ + packages/*/dist/ + extensions/*/src/host/**/.bundle.hash + extensions/*/src/host/**/*.bundle.js + key: ${{ runner.os }}-npm-preflight-dist-v1-${{ github.workflow_sha }}-${{ steps.preflight_cache_key.outputs.sha }}-${{ hashFiles('pnpm-lock.yaml') }} + - name: Build + if: steps.dist_build_cache.outputs.cache-hit != 'true' run: pnpm build - name: Build Control UI + if: steps.dist_build_cache.outputs.cache-hit != 'true' run: pnpm ui:build + - name: Save preflight build outputs + if: steps.dist_build_cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + dist/ + dist-runtime/ + packages/*/dist/ + extensions/*/src/host/**/.bundle.hash + extensions/*/src/host/**/*.bundle.js + key: ${{ steps.dist_build_cache.outputs.cache-primary-key }} + - name: Validate release metadata if: ${{ inputs.preflight_run_id == '' }} env: @@ -273,6 +313,7 @@ jobs: name: openclaw-plugin-npm-preflight-${{ inputs.tag }} path: ${{ steps.plugin_npm_preflight.outputs.dir }} if-no-files-found: error + overwrite: true - name: Generate dependency release evidence id: dependency_evidence @@ -392,6 +433,7 @@ jobs: packageVersion: process.env.PACKAGE_VERSION, tarballName: process.env.TARBALL_NAME, tarballSha256: process.env.TARBALL_SHA256, + dependencyTarballs: [], dependencyEvidenceDir: "dependency-evidence", dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json", }; @@ -424,6 +466,7 @@ jobs: name: openclaw-release-dependency-evidence-${{ inputs.tag }} path: ${{ steps.dependency_evidence.outputs.dir }} if-no-files-found: error + overwrite: true - name: Upload dependency release evidence tag alias if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }} @@ -432,6 +475,7 @@ jobs: name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }} path: ${{ steps.dependency_evidence.outputs.dir }} if-no-files-found: error + overwrite: true - name: Upload immutable prepared npm publish bundle uses: actions/upload-artifact@v7 @@ -446,6 +490,7 @@ jobs: name: openclaw-npm-preflight-${{ inputs.tag }} path: ${{ steps.packed_tarball.outputs.dir }} if-no-files-found: error + overwrite: true - name: Upload legacy prepared npm publish bundle tag alias if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }} @@ -454,6 +499,7 @@ jobs: name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }} path: ${{ steps.packed_tarball.outputs.dir }} if-no-files-found: error + overwrite: true validate_publish_request: if: ${{ !inputs.preflight_only }} @@ -480,18 +526,26 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail tideclaw_alpha_publish=false extended_stable_publish=false + release_publish_workflow=false if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then tideclaw_alpha_publish=true fi if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${WORKFLOW_REF}" == refs/heads/extended-stable/* ]]; then extended_stable_publish=true fi - if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]]; then - echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." + if [[ "${WORKFLOW_REF}" == "refs/heads/release-publish/${WORKFLOW_SHA}" ]]; then + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main; then + release_publish_workflow=true + fi + fi + if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]] && [[ "${release_publish_workflow}" != "true" ]]; then + echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, an immutable release-publish/ main ancestor, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." exit 1 fi @@ -531,11 +585,8 @@ jobs: echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2 exit 1 fi - elif [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then - echo "Extended-stable publish requires a positive full_release_validation_run_attempt." >&2 - exit 1 - elif [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then - echo "full_release_validation_run_attempt must be a positive integer when provided." >&2 + elif [[ ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then + echo "Real publish with full release validation requires a positive full_release_validation_run_attempt." >&2 exit 1 fi if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && -z "${PLUGIN_NPM_RUN_ID// }" ]]; then @@ -694,19 +745,21 @@ jobs: set -euo pipefail EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)" export EXPECTED_RELEASE_SHA - if [[ -n "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then - run_file="${RUNNER_TEMP}/full-release-validation-run.json" - gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" > "$run_file" - jq '{workflowName: .name, headBranch: .head_branch, headSha: .head_sha, event, status, conclusion, url: .html_url}' "$run_file" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run - run_attempt="$(jq -r '.run_attempt // ""' "$run_file")" - if [[ "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then - echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt:-}." >&2 - exit 1 - fi - else - RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,headSha,event,status,conclusion,url)" - printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run - run_attempt="" + run_file="${RUNNER_TEMP}/full-release-validation-run.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" > "$run_file" + jq '{ + workflowName: .name, + headBranch: .head_branch, + headSha: .head_sha, + event, + status, + conclusion, + url: .html_url + }' "$run_file" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run + run_attempt="$(jq -r '.run_attempt // ""' "$run_file")" + if [[ "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then + echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt:-}." >&2 + exit 1 fi echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT" @@ -784,33 +837,27 @@ jobs: FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ steps.full_run.outputs.attempt }} run: | set -euo pipefail - if [[ -n "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then - artifact_name="full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}-${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" - artifacts_file="${RUNNER_TEMP}/full-validation-artifacts.json" + artifact_name="full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}-${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" + artifacts_file="${RUNNER_TEMP}/full-release-validation-artifacts.json" gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/artifacts?per_page=100" > "$artifacts_file" - jq --arg name "$artifact_name" '[.artifacts[] | select(.expired != true and .name == $name)]' "$artifacts_file" > "${RUNNER_TEMP}/full-validation-matches.json" - artifact_count="$(jq 'length' "${RUNNER_TEMP}/full-validation-matches.json")" + jq --arg name "$artifact_name" '[.artifacts[] | select(.expired != true and .name == $name)]' "$artifacts_file" > "${RUNNER_TEMP}/full-release-validation-matches.json" + artifact_count="$(jq 'length' "${RUNNER_TEMP}/full-release-validation-matches.json")" if [[ "$artifact_count" != "1" ]]; then echo "Expected exactly one immutable artifact named ${artifact_name}; found ${artifact_count}." >&2 exit 1 fi - artifact_id="$(jq -er '.[0].id' "${RUNNER_TEMP}/full-validation-matches.json")" - artifact_digest="$(jq -er '.[0].digest | strings' "${RUNNER_TEMP}/full-validation-matches.json")" - artifact_zip="${RUNNER_TEMP}/full-validation-${artifact_id}.zip" + artifact_id="$(jq -er '.[0].id' "${RUNNER_TEMP}/full-release-validation-matches.json")" + artifact_digest="$(jq -er '.[0].digest | strings' "${RUNNER_TEMP}/full-release-validation-matches.json")" + artifact_zip="${RUNNER_TEMP}/full-release-validation-${artifact_id}.zip" gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > "$artifact_zip" actual_digest="sha256:$(sha256sum "$artifact_zip" | awk '{print $1}')" if [[ "$actual_digest" != "$artifact_digest" ]]; then - echo "Immutable full validation artifact digest mismatch: expected ${artifact_digest}, got ${actual_digest}." >&2 + echo "Immutable full release validation artifact digest mismatch: expected ${artifact_digest}, got ${actual_digest}." >&2 exit 1 fi rm -rf full-release-validation mkdir -p full-release-validation - unzip -q "$artifact_zip" -d full-release-validation - else - rm -rf full-release-validation - mkdir -p full-release-validation - gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" --dir full-release-validation - fi + unzip -q "$artifact_zip" -d full-release-validation - name: Validate release tag and package metadata if: ${{ inputs.preflight_run_id == '' }} @@ -909,6 +956,7 @@ jobs: export EXPECTED_RELEASE_SHA MANIFEST_FILE node scripts/openclaw-npm-extended-stable-release.mjs verify-manifest RERUN_GROUP="$(jq -r '.rerunGroup // ""' "$MANIFEST_FILE")" + RELEASE_PROFILE="$(jq -r '.releaseProfile // ""' "$MANIFEST_FILE")" RUN_RELEASE_SOAK="$(jq -r '.runReleaseSoak // ""' "$MANIFEST_FILE")" PERFORMANCE_BLOCKING="$(jq -r '.controls.performanceBlocking // false' "$MANIFEST_FILE")" if [[ "$RERUN_GROUP" != "all" ]]; then @@ -919,9 +967,15 @@ jobs: echo "Full release validation manifest does not record blocking product performance evidence." >&2 exit 1 fi - if [[ "$RELEASE_TAG" != *"-alpha."* && "$RELEASE_TAG" != *"-beta."* && "$RUN_RELEASE_SOAK" != "true" ]]; then - echo "Stable releases require Full Release Validation with runReleaseSoak=true." >&2 - exit 1 + if [[ "$RELEASE_TAG" != *"-alpha."* && "$RELEASE_TAG" != *"-beta."* ]]; then + if [[ "$RELEASE_PROFILE" != "stable" && "$RELEASE_PROFILE" != "full" ]]; then + echo "Stable releases require stable or full validation; got $RELEASE_PROFILE." >&2 + exit 1 + fi + if [[ "$RUN_RELEASE_SOAK" != "true" ]]; then + echo "Stable releases require Full Release Validation with runReleaseSoak=true." >&2 + exit 1 + fi fi - name: Recheck npm release request diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 593ae02bc9ad..7a96bf50f3ae 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -66,6 +66,21 @@ env: KOVA_SCENARIO_TIMEOUT_MS: "300000" jobs: + verify_artifact_only: + name: Verify artifact-only report mode + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_reports == false }} + runs-on: ubuntu-24.04 + steps: + - name: Confirm report publication is disabled + env: + PUBLISH_REPORTS: ${{ inputs.publish_reports }} + run: | + set -euo pipefail + [[ "$PUBLISH_REPORTS" == "false" ]] || { + echo "Artifact-only performance validation requires publish_reports=false." >&2 + exit 1 + } + kova: name: ${{ matrix.title }} runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 0c71afa6009c..09408ba5559a 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -8,11 +8,19 @@ on: required: true type: string preflight_run_id: - description: Successful OpenClaw NPM Release preflight run id, required when publish_openclaw_npm=true + description: Successful OpenClaw NPM Release preflight run id, required for OpenClaw npm or all-publishable plugin publication + required: false + type: string + preflight_run_attempt: + description: Exact successful OpenClaw NPM Release preflight run attempt required: false type: string full_release_validation_run_id: - description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true + description: Successful Full Release Validation run id for this tag/SHA, required for OpenClaw npm or all-publishable plugin publication + required: false + type: string + full_release_validation_run_attempt: + description: Exact Full Release Validation run attempt; when omitted, the current attempt is resolved once before publish required: false type: string windows_node_tag: @@ -89,13 +97,17 @@ jobs: outputs: sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }} preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }} + preflight_run_attempt: ${{ steps.preflight_run.outputs.attempt }} + full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }} windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }} steps: - name: Validate inputs env: RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + PREFLIGHT_RUN_ATTEMPT: ${{ inputs.preflight_run_attempt }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }} PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }} @@ -104,6 +116,8 @@ jobs: RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} RELEASE_PROFILE: ${{ inputs.release_profile }} WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then @@ -118,12 +132,38 @@ jobs: echo "Beta prerelease tags must publish OpenClaw to npm dist-tag beta." >&2 exit 1 fi - if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${PREFLIGHT_RUN_ID}" ]]; then - echo "publish_openclaw_npm=true requires preflight_run_id." >&2 + release_evidence_required=false + if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" || "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" ]]; then + release_evidence_required=true + fi + release_evidence_supplied=false + if [[ -n "${PREFLIGHT_RUN_ID//[[:space:]]/}" || -n "${PREFLIGHT_RUN_ATTEMPT//[[:space:]]/}" || -n "${FULL_RELEASE_VALIDATION_RUN_ID//[[:space:]]/}" || -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT//[[:space:]]/}" ]]; then + release_evidence_supplied=true + fi + if [[ "${release_evidence_required}" == "true" || "${release_evidence_supplied}" == "true" ]]; then + if [[ -z "${PREFLIGHT_RUN_ID//[[:space:]]/}" ]]; then + echo "OpenClaw npm and all-publishable plugin publication require preflight_run_id; supplied release evidence must also be complete." >&2 + exit 1 + fi + if [[ -z "${PREFLIGHT_RUN_ATTEMPT//[[:space:]]/}" ]]; then + echo "OpenClaw npm and all-publishable plugin publication require preflight_run_attempt; supplied release evidence must also be complete." >&2 + exit 1 + fi + if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID//[[:space:]]/}" ]]; then + echo "OpenClaw npm and all-publishable plugin publication require full_release_validation_run_id; supplied release evidence must also be complete." >&2 + exit 1 + fi + fi + if [[ -n "${PREFLIGHT_RUN_ATTEMPT}" && ! "${PREFLIGHT_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then + echo "preflight_run_attempt must be a positive integer." >&2 exit 1 fi - if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then - echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2 + if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then + echo "full_release_validation_run_attempt requires full_release_validation_run_id." >&2 + exit 1 + fi + if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then + echo "full_release_validation_run_attempt must be a positive integer." >&2 exit 1 fi stable_release=true @@ -146,19 +186,28 @@ jobs: if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then tideclaw_alpha_publish=true fi - if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then - echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2 - exit 1 + if [[ "${tideclaw_alpha_publish}" != "true" ]]; then + expected_publish_ref="refs/heads/release-publish/${WORKFLOW_SHA}" + if [[ "${WORKFLOW_REF}" != "${expected_publish_ref}" ]]; then + echo "Regular OpenClaw Release Publish runs must use immutable ${expected_publish_ref} workflow tooling." >&2 + exit 1 + fi + main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + merge_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${WORKFLOW_SHA}...${main_sha}" --jq '.merge_base_commit.sha')" + if [[ "${merge_base_sha}" != "${WORKFLOW_SHA}" ]]; then + echo "Release publish workflow SHA ${WORKFLOW_SHA} is not reachable from protected main ${main_sha}." >&2 + exit 1 + fi fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then echo "publish_openclaw_npm=true requires plugin_publish_scope=all-publishable so every publishable official plugin is released with OpenClaw." >&2 exit 1 fi - if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then + if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS//[[:space:]]/}" ]]; then echo "plugin_publish_scope=selected requires plugins." >&2 exit 1 fi - if [[ "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" && -n "${PLUGINS}" ]]; then + if [[ "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" && -n "${PLUGINS//[[:space:]]/}" ]]; then echo "plugin_publish_scope=all-publishable must not include plugins." >&2 exit 1 fi @@ -237,17 +286,36 @@ jobs: echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT" echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY" - - name: Download OpenClaw npm preflight manifest - id: preflight_artifact - if: ${{ inputs.publish_openclaw_npm }} + - name: Resolve OpenClaw npm preflight run + id: preflight_run + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.preflight_run_attempt != '' || inputs.full_release_validation_run_id != '' }} env: GH_TOKEN: ${{ github.token }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} - RELEASE_TAG: ${{ inputs.tag }} + PREFLIGHT_RUN_ATTEMPT: ${{ inputs.preflight_run_attempt }} + run: | + set -euo pipefail + run_file="${RUNNER_TEMP}/openclaw-npm-preflight-run.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/attempts/${PREFLIGHT_RUN_ATTEMPT}" > "$run_file" + run_attempt="$(jq -r '.run_attempt // ""' "$run_file")" + conclusion="$(jq -r '.conclusion // ""' "$run_file")" + if [[ "$run_attempt" != "$PREFLIGHT_RUN_ATTEMPT" || "$conclusion" != "success" ]]; then + echo "OpenClaw npm preflight run/attempt is not the requested successful attempt: ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT}." >&2 + exit 1 + fi + echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT" + + - name: Download OpenClaw npm preflight manifest + id: preflight_artifact + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.preflight_run_attempt != '' || inputs.full_release_validation_run_id != '' }} + env: + GH_TOKEN: ${{ github.token }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + PREFLIGHT_RUN_ATTEMPT: ${{ steps.preflight_run.outputs.attempt }} run: | set -euo pipefail - preferred_name="openclaw-npm-preflight-${RELEASE_TAG}" + artifact_name="openclaw-npm-preflight-${PREFLIGHT_RUN_ID}-${PREFLIGHT_RUN_ATTEMPT}" preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest" rm -rf "${preflight_dir}" mkdir -p "${preflight_dir}" @@ -269,37 +337,64 @@ jobs: return 1 } - if download_named_artifact "${preferred_name}"; then - echo "name=${preferred_name}" >> "$GITHUB_OUTPUT" - exit 0 - fi + download_named_artifact "${artifact_name}" + echo "name=${artifact_name}" >> "$GITHUB_OUTPUT" - echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact." - mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \ - --jq '.artifacts[] | select(.expired != true) | .name' | - grep '^openclaw-npm-preflight-' || true) - if [[ "${#matches[@]}" != "1" ]]; then - echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2 - printf 'Available preflight candidates:\n' >&2 - printf -- '- %s\n' "${matches[@]:-}" >&2 + - name: Resolve full release validation run + id: full_run + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} + env: + GH_TOKEN: ${{ github.token }} + FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + RELEASE_TAG: ${{ inputs.tag }} + FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} + run: | + set -euo pipefail + run_file="${RUNNER_TEMP}/full-release-validation-run.json" + run_endpoint="repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}" + if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" ]]; then + run_endpoint+="/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" + fi + gh api "$run_endpoint" > "$run_file" + run_attempt="$(jq -r '.run_attempt // ""' "$run_file")" + if [[ ! "$run_attempt" =~ ^[1-9][0-9]*$ ]]; then + echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} has invalid attempt: ${run_attempt:-}." >&2 exit 1 fi - fallback_name="${matches[0]}" - download_named_artifact "${fallback_name}" - echo "name=${fallback_name}" >> "$GITHUB_OUTPUT" + if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then + echo "Full Release Validation run attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt}." >&2 + exit 1 + fi + echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT" - name: Download full release validation manifest - if: ${{ inputs.publish_openclaw_npm }} - uses: actions/download-artifact@v8 + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: full-release-validation-${{ inputs.full_release_validation_run_id }} + name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }} path: ${{ runner.temp }}/full-release-validation-manifest repository: ${{ github.repository }} run-id: ${{ inputs.full_release_validation_run_id }} github-token: ${{ github.token }} + - name: Download trusted release validation tooling + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + tooling_dir="${RUNNER_TEMP}/release-validation-tooling" + mkdir -p "${tooling_dir}/lib" + gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/validate-full-release-validation-evidence.mjs?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode > "${tooling_dir}/validate-full-release-validation-evidence.mjs" + gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/release-ci-summary.mjs?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode > "${tooling_dir}/release-ci-summary.mjs" + gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/plain-gh.mjs?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode > "${tooling_dir}/lib/plain-gh.mjs" + - name: Checkout release tag - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 @@ -309,13 +404,50 @@ jobs: id: ref run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate OpenClaw npm preflight run provenance + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} + env: + EXPECTED_WORKFLOW_SHA: ${{ github.sha }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + PREFLIGHT_RUN_ATTEMPT: ${{ steps.preflight_run.outputs.attempt }} + run: | + set -euo pipefail + run_file="${RUNNER_TEMP}/openclaw-npm-preflight-run.json" + if [[ ! -f "$run_file" ]]; then + echo "OpenClaw npm preflight run metadata is missing." >&2 + exit 1 + fi + workflow_name="$(jq -r '.name // ""' "$run_file")" + workflow_path="$(jq -r '.path // ""' "$run_file")" + workflow_path="${workflow_path%%@*}" + event="$(jq -r '.event // ""' "$run_file")" + head_sha="$(jq -r '.head_sha // ""' "$run_file")" + if [[ "$workflow_name" != "OpenClaw NPM Release" ]]; then + echo "Preflight run ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT} has unexpected workflow name: ${workflow_name:-}." >&2 + exit 1 + fi + if [[ "$workflow_path" != ".github/workflows/openclaw-npm-release.yml" ]]; then + echo "Preflight run ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT} has unexpected workflow path: ${workflow_path:-}." >&2 + exit 1 + fi + if [[ "$event" != "workflow_dispatch" ]]; then + echo "Preflight run ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT} has unexpected event: ${event:-}." >&2 + exit 1 + fi + if [[ "$head_sha" != "$EXPECTED_WORKFLOW_SHA" ]]; then + echo "Preflight run ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT} used workflow SHA ${head_sha:-}, expected trusted workflow SHA ${EXPECTED_WORKFLOW_SHA}." >&2 + exit 1 + fi + - name: Validate OpenClaw npm preflight manifest id: manifest - if: ${{ inputs.publish_openclaw_npm }} + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} env: RELEASE_TAG: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} EXPECTED_SHA: ${{ steps.ref.outputs.sha }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + PREFLIGHT_RUN_ATTEMPT: ${{ steps.preflight_run.outputs.attempt }} run: | set -euo pipefail preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest" @@ -330,6 +462,12 @@ jobs: npm_dist_tag="$(jq -r '.npmDistTag // ""' "$manifest")" tarball_name="$(jq -r '.tarballName // ""' "$manifest")" tarball_sha256="$(jq -r '.tarballSha256 // ""' "$manifest")" + run_id="$(jq -r '.runId // ""' "$manifest")" + run_attempt="$(jq -r '.runAttempt // ""' "$manifest")" + if [[ "$run_id" != "$PREFLIGHT_RUN_ID" || "$run_attempt" != "$PREFLIGHT_RUN_ATTEMPT" ]]; then + echo "Preflight manifest run/attempt mismatch: expected ${PREFLIGHT_RUN_ID}/${PREFLIGHT_RUN_ATTEMPT}, got ${run_id}/${run_attempt}." >&2 + exit 1 + fi if [[ "$release_tag" != "$RELEASE_TAG" ]]; then echo "Preflight manifest tag mismatch: expected $RELEASE_TAG, got $release_tag" >&2 exit 1 @@ -355,28 +493,37 @@ jobs: - name: Validate full release validation manifest id: full_manifest - if: ${{ inputs.publish_openclaw_npm }} + if: ${{ inputs.publish_openclaw_npm || inputs.plugin_publish_scope == 'all-publishable' || inputs.preflight_run_id != '' || inputs.full_release_validation_run_id != '' }} env: GH_TOKEN: ${{ github.token }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + RELEASE_TAG: ${{ inputs.tag }} EXPECTED_SHA: ${{ steps.ref.outputs.sha }} EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }} EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} + EXPECTED_WORKFLOW_SHA: ${{ github.sha }} + RUN_JSON_FILE: ${{ runner.temp }}/full-release-validation-run.json + TRUSTED_MAIN_REF: refs/remotes/origin/main + VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs + STRICT_VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/release-ci-summary.mjs run: | set -euo pipefail - RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" - printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } const allowedBranches = new Set(["main", process.env.EXPECTED_WORKFLOW_BRANCH].filter(Boolean)); if (!allowedBranches.has(run.headBranch)) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have headBranch in ${[...allowedBranches].join(", ")}, got ${run.headBranch ?? ""}.`); process.exit(1); } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);' - manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json" if [[ ! -f "$manifest" ]]; then echo "Full release validation manifest is missing." >&2 ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true exit 1 fi + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main + MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE" + workflow_name="$(jq -r '.workflowName // ""' "$manifest")" target_sha="$(jq -r '.targetSha // ""' "$manifest")" release_profile="$(jq -r '.releaseProfile // ""' "$manifest")" rerun_group="$(jq -r '.rerunGroup // ""' "$manifest")" + run_release_soak="$(jq -r '.runReleaseSoak // ""' "$manifest")" + performance_blocking="$(jq -r '.controls.performanceBlocking // false' "$manifest")" if [[ "$workflow_name" != "Full Release Validation" ]]; then echo "Full release validation manifest workflow mismatch: $workflow_name" >&2 exit 1 @@ -393,6 +540,20 @@ jobs: echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2 exit 1 fi + if [[ "$performance_blocking" != "true" ]]; then + echo "Full release validation manifest does not record blocking product performance evidence." >&2 + exit 1 + fi + if [[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]; then + if [[ "$release_profile" != "stable" && "$release_profile" != "full" ]]; then + echo "Stable release publication requires stable or full validation; got $release_profile" >&2 + exit 1 + fi + if [[ "$run_release_soak" != "true" ]]; then + echo "Stable release publication requires runReleaseSoak=true; got $run_release_soak" >&2 + exit 1 + fi + fi echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT" - name: Validate release tag is reachable from a trusted release branch @@ -453,27 +614,175 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 environment: npm-release + permissions: + actions: write + artifact-metadata: write + attestations: write + contents: write + deployments: write + id-token: write steps: - - name: Checkout release SHA - uses: actions/checkout@v6 + - name: Checkout trusted release tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.resolve_release_target.outputs.sha }} + ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false - - name: Setup Node environment + - name: Setup trusted Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" + - name: Checkout release SHA as data + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.resolve_release_target.outputs.sha }} + path: .release-target + fetch-depth: 1 + persist-credentials: false + + - name: Download full release validation manifest + if: ${{ inputs.publish_openclaw_npm }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }} + path: ${{ runner.temp }}/full-release-validation-manifest + repository: ${{ github.repository }} + run-id: ${{ inputs.full_release_validation_run_id }} + github-token: ${{ github.token }} + + - name: Resolve ClawHub release plan + id: clawhub_plan + env: + TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} + CHILD_WORKFLOW_REF: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} + PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" + bootstrap_workflow_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ + --jq '.object.sha | select(test("^[a-f0-9]{40}$"))' + )" + plan_args=( + --bootstrap-workflow-sha "${bootstrap_workflow_sha}" + --release-tag "${RELEASE_TAG}" + --release-sha "${TARGET_SHA}" + --release-publish-branch "${CHILD_WORKFLOW_REF}" + --release-publish-run-attempt "${GITHUB_RUN_ATTEMPT}" + --release-publish-run-id "${GITHUB_RUN_ID}" + --plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}" + --root-dir "${GITHUB_WORKSPACE}/.release-target" + ) + if [[ -n "${PLUGINS// }" ]]; then + plan_args+=(--plugins "${PLUGINS}") + fi + + CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \ + node --import tsx \ + "${GITHUB_WORKSPACE}/scripts/openclaw-release-clawhub-plan.ts" \ + "${plan_args[@]}" > "${plan_path}" + cat "${plan_path}" + + bootstrap_should_dispatch="$( + jq -er '.bootstrap.shouldDispatch | select(type == "boolean") | tostring' "${plan_path}" + )" + echo "bootstrap_should_dispatch=${bootstrap_should_dispatch}" >> "${GITHUB_OUTPUT}" + if [[ "${bootstrap_should_dispatch}" != "true" ]]; then + exit 0 + fi + + jq -e \ + --arg branch "${CHILD_WORKFLOW_REF}" \ + --arg bootstrap_workflow_sha "${bootstrap_workflow_sha}" \ + --arg attempt "${GITHUB_RUN_ATTEMPT}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg tag "${RELEASE_TAG}" \ + --arg target "${TARGET_SHA}" \ + '.bootstrapWorkflowSha == $bootstrap_workflow_sha and + .bootstrap.ref == "main" and + .bootstrap.inputs.ref == $target and + .bootstrap.inputs.bootstrap_workflow_sha == $bootstrap_workflow_sha and + .bootstrap.inputs.release_tag == $tag and + .bootstrap.inputs.release_publish_branch == $branch and + .bootstrap.inputs.release_publish_run_attempt == $attempt and + .bootstrap.inputs.release_publish_run_id == $run_id' \ + "${plan_path}" >/dev/null + + approval_dir="${RUNNER_TEMP}/clawhub-bootstrap-approval" + mkdir -p "${approval_dir}" + PLAN_PATH="${plan_path}" APPROVAL_PATH="${approval_dir}/approval.json" \ + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from "node:fs"; + + const plan = JSON.parse(readFileSync(process.env.PLAN_PATH, "utf8")); + const packages = [...plan.bootstrap.packages].toSorted((a, b) => + a < b ? -1 : a > b ? 1 : 0, + ); + const approval = { + version: 2, + kind: "clawhub-bootstrap", + repository: process.env.GITHUB_REPOSITORY, + workflow: "OpenClaw Release Publish", + parentRunId: process.env.GITHUB_RUN_ID, + parentRunAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + workflowBranch: process.env.CHILD_WORKFLOW_REF, + parentWorkflowSha: process.env.GITHUB_SHA, + bootstrapWorkflowSha: plan.bootstrapWorkflowSha, + releaseTag: process.env.RELEASE_TAG, + targetSha: process.env.TARGET_SHA, + packages, + }; + writeFileSync(process.env.APPROVAL_PATH, `${JSON.stringify(approval, null, 2)}\n`); + NODE + + - name: Attest ClawHub bootstrap approval + if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json + + - name: Upload ClawHub bootstrap approval + if: ${{ steps.clawhub_plan.outputs.bootstrap_should_dispatch == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: clawhub-bootstrap-approval-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json + if-no-files-found: error + retention-days: 30 + + - name: Prepare GitHub release notes + if: ${{ inputs.publish_openclaw_npm }} + env: + RELEASE_TAG: ${{ inputs.tag }} + TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} + run: | + # Fail-before-mutation gate: prove the canonical release body renders + # within GitHub's limits before any publish-side dispatch runs. The + # publish sequence re-renders the same body from the same tag SHA. + changelog_file="${RUNNER_TEMP}/CHANGELOG.md" + notes_file="${RUNNER_TEMP}/release-notes.md" + git -C .release-target show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" + node scripts/render-github-release-notes.mjs \ + --changelog "${changelog_file}" \ + --tag "${RELEASE_TAG}" \ + --repository "${GITHUB_REPOSITORY}" \ + --output "${notes_file}" - name: Dispatch publish workflows env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + PREFLIGHT_RUN_ATTEMPT: ${{ needs.resolve_release_target.outputs.preflight_run_attempt }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} PLUGINS: ${{ inputs.plugins }} @@ -484,6 +793,8 @@ jobs: WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }} POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence + FULL_RELEASE_VALIDATION_MANIFEST_DIR: ${{ runner.temp }}/full-release-validation-manifest + CLAWHUB_PLAN_PATH: ${{ runner.temp }}/openclaw-release-clawhub-plan.json run: | set -euo pipefail @@ -491,55 +802,113 @@ jobs: [[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]] } + + verify_child_run_sha() { + local workflow="$1" + local run_id="$2" + local expected_sha="$3" + local run_json child_head_sha child_url attempt + + run_json="" + for attempt in $(seq 1 12); do + if run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json headSha,url 2>/dev/null)"; then + child_head_sha="$(printf '%s' "$run_json" | jq -r '.headSha // ""')" + if [[ -n "$child_head_sha" ]]; then + break + fi + fi + if [[ "$attempt" != "12" ]]; then + sleep 5 + fi + done + + child_head_sha="$(printf '%s' "$run_json" | jq -r '.headSha // ""' 2>/dev/null || true)" + child_url="$(printf '%s' "$run_json" | jq -r '.url // ""' 2>/dev/null || true)" + if [[ "$child_head_sha" != "$expected_sha" ]]; then + echo "${workflow} child run ${run_id} used workflow SHA ${child_head_sha:-}, expected ${expected_sha}: ${child_url}" >&2 + gh run cancel --repo "$GITHUB_REPOSITORY" "$run_id" >/dev/null 2>&1 || true + return 1 + fi + } + dispatch_workflow_at_ref() { local workflow_ref="$1" - shift + local expected_sha="$2" + shift 2 local workflow="$1" shift - local before_json dispatch_output run_id - before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[].id]')" - - dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)" - printf '%s\n' "$dispatch_output" >&2 - run_id="$( - printf '%s\n' "$dispatch_output" | - sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | - tail -n 1 + local dispatch_body dispatch_response encoded_workflow_ref field key resolved_workflow_sha run_id run_url value + encoded_workflow_ref="$(jq -rn --arg value "$workflow_ref" '$value | @uri')" + resolved_workflow_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" \ + --jq '.sha | select(test("^[a-f0-9]{40}$"))' )" - - if [[ -z "$run_id" ]]; then - for _ in $(seq 1 60); do - run_id="$( - BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=50 \ - --jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty' - )" - if [[ -n "$run_id" ]]; then - break - fi - sleep 5 - done - fi - - if [[ -z "${run_id:-}" ]]; then - echo "Could not find dispatched run for ${workflow}." >&2 + if [[ "$resolved_workflow_sha" != "$expected_sha" ]]; then + echo "Child workflow ref ${workflow_ref} resolved to ${resolved_workflow_sha}, expected ${expected_sha}; refusing dispatch." >&2 exit 1 fi - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2 + local inputs_json='{}' + while (( $# > 0 )); do + if [[ "$1" != "-f" && "$1" != "--raw-field" && "$1" != "-F" && "$1" != "--field" ]]; then + echo "Unsupported workflow dispatch argument for ${workflow}: $1" >&2 + exit 1 + fi + if (( $# < 2 )) || [[ "$2" != *=* ]]; then + echo "Workflow dispatch fields must use key=value syntax for ${workflow}." >&2 + exit 1 + fi + field="$2" + shift 2 + key="${field%%=*}" + value="${field#*=}" + inputs_json="$(jq -cn \ + --argjson inputs "$inputs_json" \ + --arg key "$key" \ + --arg value "$value" \ + '$inputs + {($key): $value}')" + done + + dispatch_body="$(jq -cn \ + --arg ref "$workflow_ref" \ + --argjson inputs "$inputs_json" \ + '{ref: $ref, inputs: $inputs}')" + # API 2026-03-10 always returns the exact workflow run ID and URLs; + # the earlier return_run_details request field was removed. + dispatch_response="$(printf '%s' "$dispatch_body" | gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/dispatches" \ + --input -)" + run_id="$(printf '%s' "$dispatch_response" | jq -er '.workflow_run_id')" + run_url="$(printf '%s' "$dispatch_response" | jq -er '.html_url')" + verify_child_run_sha "$workflow" "$run_id" "$expected_sha" || return 1 + + echo "Dispatched ${workflow} from ${workflow_ref} at ${expected_sha}: ${run_url}" >&2 { - echo "- ${workflow}: dispatched (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id})" + echo "- ${workflow}: dispatched from \`${workflow_ref}\` at \`${expected_sha}\` (${run_url})" } >> "$GITHUB_STEP_SUMMARY" printf '%s\n' "${run_id}" } dispatch_workflow() { - dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@" + dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$@" + } + + verify_bootstrap_workflow_sha() { + local approved_sha current_sha + approved_sha="$(jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}")" + current_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ + --jq '.object.sha | select(test("^[a-f0-9]{40}$"))' + )" + [[ "${current_sha}" == "${approved_sha}" ]] || { + echo "Trusted main moved from approved ClawHub bootstrap workflow SHA ${approved_sha} to ${current_sha}; rerun release approval." >&2 + exit 1 + } + printf '%s\n' "${approved_sha}" } print_pending_deployments() { @@ -559,12 +928,22 @@ jobs: done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv') } + # Returns 0 after an approval, 1 when no gate is ready yet, and 2 for + # identity or mutation failures that must not be retried as "pending". approve_pending_deployments() { local workflow="$1" local run_id="$2" + local expected_sha="$3" local pending_json approved - pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)" + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + echo "${workflow}: refusing environment approval because the child workflow SHA is not approved." >&2 + return 2 + fi + + if ! pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null)"; then + return 1 + fi if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then return 1 fi @@ -575,14 +954,20 @@ jobs: continue fi echo "${workflow}: approving pending environment ${env_name} (${env_id})" - gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \ + if ! gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \ -F "environment_ids[]=${env_id}" \ -f state=approved \ - -f comment="Approve child release gate after parent release approval" >/dev/null + -f comment="Approve child release gate after parent release approval" >/dev/null; then + echo "${workflow}: failed to approve pending environment ${env_name} (${env_id})." >&2 + return 2 + fi approved=1 done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv') if [[ "${approved}" == "1" ]]; then + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 2 + fi echo "${workflow}: approved available pending environment gates" return 0 fi @@ -614,7 +999,12 @@ jobs: wait_for_run() { local workflow="$1" local run_id="$2" - local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json + local expected_sha="$3" + local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json approval_status + + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 1 + fi last_state="" while true; do @@ -637,12 +1027,20 @@ jobs: if [[ "$state" != "$last_state" ]]; then echo "${workflow} still ${status} (updated ${updated_at}): ${url}" print_pending_deployments "${workflow}" "${run_id}" - approve_pending_deployments "${workflow}" "${run_id}" || true + approval_status=0 + approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || + approval_status=$? + if (( approval_status > 1 )); then + return 1 + fi last_state="$state" fi sleep 30 done + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 1 + fi run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion,url,createdAt,updatedAt)" conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion')" url="$(printf '%s' "$run_json" | jq -r '.url')" @@ -673,9 +1071,10 @@ jobs: wait_for_run_background() { local workflow="$1" local run_id="$2" - local result_file="$3" + local expected_sha="$3" + local result_file="$4" ( - if wait_for_run "${workflow}" "${run_id}"; then + if wait_for_run "${workflow}" "${run_id}" "${expected_sha}"; then printf 'success\n' > "${result_file}" else printf 'failure\n' > "${result_file}" @@ -688,8 +1087,12 @@ jobs: local workflow="$1" local run_id="$2" local job_name="$3" + local expected_sha="$4" local jobs_json job_json run_status run_conclusion status conclusion url deadline + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 1 + fi deadline=$((SECONDS + 900)) while true; do jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)" @@ -702,6 +1105,9 @@ jobs: url="$(printf '%s' "$job_json" | jq -r '.url // ""')" if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 1 + fi echo "${workflow} ${job_name} ${conclusion}: ${url}" echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY" return 0 @@ -713,6 +1119,9 @@ jobs: echo "${workflow} ${job_name} still ${status}: ${url}" elif [[ "$run_status" == "completed" ]]; then if [[ "$run_conclusion" == "success" ]]; then + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 1 + fi echo "${workflow} completed before ${job_name} was needed." echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY" return 0 @@ -734,19 +1143,35 @@ jobs: approve_child_publish_environment() { local workflow="$1" local run_id="$2" - local run_json status conclusion deadline + local expected_sha="$3" + local run_json status conclusion deadline approval_status deadline=$((SECONDS + 900)) while true; do - if approve_pending_deployments "${workflow}" "${run_id}"; then + approval_status=0 + approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || + approval_status=$? + if (( approval_status == 0 )); then echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY" return 0 fi - run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)" - status="$(printf '%s' "$run_json" | jq -r '.status')" - conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')" + if (( approval_status > 1 )); then + return "${approval_status}" + fi + if ! run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"; then + sleep 10 + continue + fi + if ! status="$(printf '%s' "$run_json" | jq -er '.status | select(type == "string" and length > 0)')" || + ! conclusion="$(printf '%s' "$run_json" | jq -er '(.conclusion // "") | select(type == "string")')"; then + echo "${workflow}: invalid run state while waiting for environment approval." >&2 + return 2 + fi if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" ]]; then + if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then + return 2 + fi echo "${workflow}: completed before child environment approval was needed" return 0 fi @@ -763,8 +1188,26 @@ jobs: done } + approve_clawhub_bootstrap_environments() { + local run_id="$1" + local expected_sha="$2" + + wait_for_job_success \ + plugin-clawhub-new.yml \ + "${run_id}" \ + "Validate release publish approval" \ + "${expected_sha}" || return 1 + approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" "${expected_sha}" || return 1 + wait_for_job_success \ + plugin-clawhub-new.yml \ + "${run_id}" \ + "Validate immutable bootstrap handoff" \ + "${expected_sha}" || return 1 + approve_child_publish_environment plugin-clawhub-new.yml "${run_id}" "${expected_sha}" || return 1 + } + guard_existing_public_release() { - local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url + local release_version asset_name release_json is_draft has_sha has_proof has_asset has_canonical_body release_url release_body release_body_file if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then return 0 @@ -785,8 +1228,28 @@ jobs: has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')" has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')" release_url="$(printf '%s' "${release_json}" | jq -r '.url')" + release_body="$(printf '%s' "${release_json}" | jq -r '.body')" + release_body_file="${RUNNER_TEMP}/existing-public-release-body.md" + printf '%s' "${release_body}" > "${release_body_file}" + has_canonical_body="false" + if canonical_release_body_matches "${release_body_file}"; then + has_canonical_body="true" + fi - if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then + if [[ "${has_asset}" == "true" && + "${has_sha}" == "true" && + "${has_proof}" == "true" && + "${has_canonical_body}" == "true" ]]; then + return 0 + fi + + # The renderer omits the verification tail when the canonical body + # already reaches GitHub's limit. A canonical proofless body with + # intact dependency evidence is retry-safe: postpublish re-attempts + # the proof append on this run. + if [[ "${has_asset}" == "true" && + "${has_canonical_body}" == "true" && + "${has_proof}" != "true" ]]; then return 0 fi @@ -798,9 +1261,10 @@ jobs: exit 1 } - guard_openclaw_npm_not_already_published() { - local release_version release_url + resolve_openclaw_npm_publish_state() { + local artifact_name manifest_dir manifest_path manifest_sha manifest_tarball_sha published_sha published_tarball_path published_tarball_url release_version + openclaw_npm_already_published="false" if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then return 0 fi @@ -810,32 +1274,46 @@ jobs: return 0 fi - release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" - { - echo "openclaw@${release_version} is already published on npm." - echo "Refusing to dispatch publish child workflows for an already-published version." - echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version." - echo "Release page, if present: ${release_url}" - } >&2 - exit 1 + # A published core package means a prior publish run already got + # that far. Resume is only safe when the registry serves the exact + # tarball this tag's preflight built; the same version from any + # other artifact is immutable on npm and needs a correction tag. + artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}" + manifest_dir="${RUNNER_TEMP}/openclaw-npm-resume-preflight" + rm -rf "${manifest_dir}" + mkdir -p "${manifest_dir}" + gh run download "${PREFLIGHT_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${manifest_dir}" + manifest_path="${manifest_dir}/preflight-manifest.json" + manifest_sha="$(jq -er '.releaseSha' "${manifest_path}")" + manifest_tarball_sha="$(jq -er '.tarballSha256' "${manifest_path}")" + if [[ "${manifest_sha}" != "${TARGET_SHA}" ]]; then + echo "openclaw@${release_version} is already on npm but preflight ${PREFLIGHT_RUN_ID} was built from ${manifest_sha}, not ${TARGET_SHA}; refusing to resume." >&2 + exit 1 + fi + published_tarball_url="$(npm view "openclaw@${release_version}" dist.tarball)" + published_tarball_path="${manifest_dir}/published.tgz" + curl -fsSL --retry 3 -o "${published_tarball_path}" "${published_tarball_url}" + published_sha="$(sha256sum "${published_tarball_path}" | awk '{print $1}')" + if [[ "${published_sha}" != "${manifest_tarball_sha}" ]]; then + { + echo "openclaw@${release_version} is already published on npm but its tarball does not match this tag's preflight artifact." + echo "Published sha256: ${published_sha}" + echo "Preflight tarballSha256: ${manifest_tarball_sha}" + echo "Cut a correction tag instead of resuming this publish." + } >&2 + exit 1 + fi + + openclaw_npm_already_published="true" + echo "openclaw@${release_version} is already published on npm with this tag's preflight tarball; skipping the core npm dispatch and resuming the remaining publish stages." } resolve_clawhub_release_plan() { - local -a plan_args - - clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" - plan_args=( - --release-tag "${RELEASE_TAG}" - --release-publish-branch "${CHILD_WORKFLOW_REF}" - --release-publish-run-id "${GITHUB_RUN_ID}" - --plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}" - ) - if [[ -n "${PLUGINS// }" ]]; then - plan_args+=(--plugins "${PLUGINS}") - fi - - CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \ - node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}" + clawhub_plan_path="${CLAWHUB_PLAN_PATH}" + test -s "${clawhub_plan_path}" echo "Resolved OpenClaw release ClawHub dispatch plan:" cat "${clawhub_plan_path}" @@ -876,7 +1354,8 @@ jobs: write_clawhub_runtime_state() { local force_skip_clawhub="$1" local output_path="$2" - node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \ + node --import tsx \ + "${GITHUB_WORKSPACE}/scripts/openclaw-release-clawhub-runtime-state.ts" \ --repository "${GITHUB_REPOSITORY}" \ --wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \ --force-skip-clawhub "${force_skip_clawhub}" \ @@ -885,65 +1364,140 @@ jobs: --bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}" } - create_or_update_github_release() { - local release_version notes_version title notes_file changelog_file latest_arg prerelease_args - release_version="${RELEASE_TAG#v}" - notes_version="${release_version}" - if [[ "${notes_version}" =~ ^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)-(alpha|beta)\.[1-9][0-9]*$ ]]; then - notes_version="${BASH_REMATCH[1]}" - fi - title="openclaw ${release_version}" - changelog_file="${RUNNER_TEMP}/CHANGELOG.md" - notes_file="${RUNNER_TEMP}/release-notes.md" + render_github_release_notes() { + local output_file="$1" + local verification_file="${2:-}" + local metadata_file="${3:-}" + local changelog_file="${RUNNER_TEMP}/CHANGELOG.md" + local -a render_args=( + node scripts/render-github-release-notes.mjs + --changelog "${changelog_file}" + --tag "${RELEASE_TAG}" + --repository "${GITHUB_REPOSITORY}" + --output "${output_file}" + ) - git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" - awk -v version="${notes_version}" ' - $0 == "## " version { in_section = 1; next } - /^## / && in_section { exit } - in_section { print } - ' "${changelog_file}" > "${notes_file}" - if [[ ! -s "${notes_file}" ]] && [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then - awk ' - $0 == "## Unreleased" { in_section = 1; next } - /^## / && in_section { exit } - in_section { print } - ' "${changelog_file}" > "${notes_file}" + git -C .release-target show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" + if [[ -n "${verification_file}" ]]; then + render_args+=(--verification-file "${verification_file}") fi - if [[ ! -s "${notes_file}" ]]; then - echo "CHANGELOG.md does not contain release notes for ${notes_version} or an Unreleased prerelease fallback." >&2 + if [[ -n "${metadata_file}" ]]; then + render_args+=(--metadata-output "${metadata_file}") + fi + "${render_args[@]}" + } + + verify_release_tag_target() { + local direct_sha peeled_sha remote_refs remote_sha + remote_refs="$(git ls-remote --tags origin \ + "refs/tags/${RELEASE_TAG}" \ + "refs/tags/${RELEASE_TAG}^{}")" + direct_sha="$(printf '%s\n' "${remote_refs}" | + awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')" + peeled_sha="$(printf '%s\n' "${remote_refs}" | + awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')" + remote_sha="${peeled_sha:-${direct_sha}}" + if [[ -z "${remote_sha}" ]]; then + echo "Release tag ${RELEASE_TAG} no longer exists on origin." >&2 exit 1 fi + if [[ "${remote_sha}" != "${TARGET_SHA}" ]]; then + echo "Release tag ${RELEASE_TAG} moved: expected ${TARGET_SHA}, found ${remote_sha}." >&2 + exit 1 + fi + } - prerelease_args=() + canonical_release_body_matches() { + local body_file="$1" + local changelog_file="${RUNNER_TEMP}/release-body-changelog.md" + git -C .release-target show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" + RELEASE_BODY_FILE="${body_file}" \ + RELEASE_CHANGELOG_FILE="${changelog_file}" \ + RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \ + RELEASE_TAG="${RELEASE_TAG}" \ + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { + releaseNotesVersionForTag, + verifyGithubReleaseNotes, + } from "./scripts/render-github-release-notes.mjs"; + + const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8"); + const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8"); + const result = verifyGithubReleaseNotes({ + body, + changelog, + version: releaseNotesVersionForTag(process.env.RELEASE_TAG), + tag: process.env.RELEASE_TAG, + repository: process.env.RELEASE_REPOSITORY, + }); + if (!result.matches) { + process.exitCode = 1; + } + NODE + } + + create_or_update_github_release() { + local existing_body_file existing_state release_version title latest_arg prerelease_arg + verify_release_tag_target + release_version="${RELEASE_TAG#v}" + title="openclaw ${release_version}" + + prerelease_arg="--prerelease=false" latest_arg="--latest=false" if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then - prerelease_args=(--prerelease) + prerelease_arg="--prerelease" elif [[ "${RELEASE_NPM_DIST_TAG}" == "latest" ]]; then latest_arg="--latest" fi - if gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + if existing_state="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,body 2>/dev/null)"; then + # A public page only reaches this call after + # guard_existing_public_release accepted it as canonical; leave + # it untouched so a failed resume cannot strip its verification + # proof before the proof append re-runs. + if [[ "$(printf '%s' "${existing_state}" | jq -r '.isDraft')" != "true" ]]; then + existing_body_file="${RUNNER_TEMP}/existing-public-release-notes.md" + printf '%s' "$(printf '%s' "${existing_state}" | jq -r '.body')" > "${existing_body_file}" + if canonical_release_body_matches "${existing_body_file}"; then + echo "- GitHub release: existing public page left untouched until proof append" >> "$GITHUB_STEP_SUMMARY" + return 0 + fi + fi gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --title "${title}" \ - --notes-file "${notes_file}" \ - "${prerelease_args[@]}" + --notes-file "${prepared_release_notes_file}" \ + "${prerelease_arg}" \ + "${latest_arg}" else gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ --title "${title}" \ - --notes-file "${notes_file}" \ - "${prerelease_args[@]}" \ + --notes-file "${prepared_release_notes_file}" \ + "${prerelease_arg}" \ "${latest_arg}" fi echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } publish_github_release() { + local expected_prerelease release_json + verify_release_tag_target if is_stable_release; then verify_windows_release_asset_contract fi gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false + release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease)" + expected_prerelease="false" + if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then + expected_prerelease="true" + fi + if [[ "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "false" ]] || + [[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" != "${expected_prerelease}" ]]; then + echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2 + exit 1 + fi echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } @@ -959,7 +1513,7 @@ jobs: "OpenClawCompanion-SHA256SUMS.txt" ) - release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)" + release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)" || return 1 expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')" actual_companion_assets="$(printf '%s' "${release_json}" | jq -c ' [.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort @@ -982,7 +1536,7 @@ jobs: gh release download "${RELEASE_TAG}" \ --repo "$GITHUB_REPOSITORY" \ --pattern "OpenClawCompanion-SHA256SUMS.txt" \ - --dir "${manifest_dir}" + --dir "${manifest_dir}" || return 1 if ! manifest_json="$(jq -Rsc ' split("\n") as $lines | (if $lines[-1] == "" then $lines[0:-1] else $lines end) | @@ -1030,13 +1584,23 @@ jobs: echo "Stable release is missing prevalidated Windows installer digests." >&2 return 1 fi + # Retry-safe: the asset contract is the done-condition, so a prior + # publish run's verified promotion is reused instead of re-running + # the Windows child workflow. + if verify_windows_release_asset_contract >/dev/null 2>&1; then + echo "- Windows Hub promotion: assets already promoted and verified; skipping dispatch" >> "$GITHUB_STEP_SUMMARY" + return 0 + fi windows_node_run_id="$(dispatch_workflow windows-node-release.yml \ -f tag="${RELEASE_TAG}" \ -f windows_node_tag="${WINDOWS_NODE_TAG}" \ -f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")" + # Promotion runs in a background subshell; hand the run id to the + # parent shell for the release proof links. + printf '%s' "${windows_node_run_id}" > "${RUNNER_TEMP}/windows-node-run-id.txt" echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY" - wait_for_run windows-node-release.yml "${windows_node_run_id}" + wait_for_run windows-node-release.yml "${windows_node_run_id}" "${PARENT_WORKFLOW_SHA}" } upload_dependency_evidence_release_asset() { @@ -1060,15 +1624,88 @@ jobs: exit 1 fi - (cd "${download_dir}" && zip -qr "${asset_path}" dependency-evidence) - gh release upload "${RELEASE_TAG}" "${asset_path}#${asset_name}" \ - --repo "${GITHUB_REPOSITORY}" \ - --clobber + ( + cd "${download_dir}" + find dependency-evidence -type f -print | LC_ALL=C sort | zip -X -q "${asset_path}" -@ + ) + attach_or_verify_release_asset "${asset_path}" "${asset_name}" echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY" } + attach_or_verify_release_asset() { + local source_path="$1" + local asset_name="$2" + local existing_dir="${RUNNER_TEMP}/openclaw-release-existing-assets/${asset_name}" + local existing_path="${existing_dir}/${asset_name}" + + if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets | + jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then + rm -rf "${existing_dir}" + mkdir -p "${existing_dir}" + gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --pattern "${asset_name}" --dir "${existing_dir}" + cmp --silent "${source_path}" "${existing_path}" || { + echo "Existing release evidence asset ${asset_name} differs from this release run." >&2 + exit 1 + } + return + fi + + gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" --repo "${GITHUB_REPOSITORY}" + } + + replace_release_asset() { + local source_path="$1" + local asset_name="$2" + # Postpublish evidence embeds this run's id, so resumable retries + # regenerate it; the newest verification replaces the asset while + # prior copies persist as workflow artifacts. + gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" \ + --repo "${GITHUB_REPOSITORY}" --clobber + } + + upload_release_evidence_assets() { + local release_version manifest_path evidence_path manifest_asset evidence_asset + release_version="${RELEASE_TAG#v}" + manifest_path="${FULL_RELEASE_VALIDATION_MANIFEST_DIR}/full-release-validation-manifest.json" + evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" + manifest_asset="openclaw-${release_version}-release-manifest.json" + evidence_asset="openclaw-${release_version}-postpublish-evidence.json" + + if [[ ! -f "${manifest_path}" ]]; then + echo "Full release validation manifest is missing from ${FULL_RELEASE_VALIDATION_MANIFEST_DIR}." >&2 + exit 1 + fi + if [[ ! -f "${evidence_path}" ]]; then + echo "Postpublish release evidence is missing from ${POSTPUBLISH_EVIDENCE_DIR}." >&2 + exit 1 + fi + + cp "${manifest_path}" "${RUNNER_TEMP}/${manifest_asset}" + cp "${evidence_path}" "${RUNNER_TEMP}/${evidence_asset}" + ( + cd "${RUNNER_TEMP}" + sha256sum "${manifest_asset}" > "${manifest_asset}.sha256" + sha256sum "${evidence_asset}" > "${evidence_asset}.sha256" + ) + + attach_or_verify_release_asset "${RUNNER_TEMP}/${manifest_asset}" "${manifest_asset}" + attach_or_verify_release_asset \ + "${RUNNER_TEMP}/${manifest_asset}.sha256" \ + "${manifest_asset}.sha256" + replace_release_asset "${RUNNER_TEMP}/${evidence_asset}" "${evidence_asset}" + replace_release_asset \ + "${RUNNER_TEMP}/${evidence_asset}.sha256" \ + "${evidence_asset}.sha256" + { + echo "- Immutable release manifest: \`${manifest_asset}\`" + echo "- Postpublish evidence (latest verification): \`${evidence_asset}\`" + } >> "$GITHUB_STEP_SUMMARY" + } verify_published_release() { - local release_version evidence_path skip_clawhub clawhub_runtime_state_path + local release_version evidence_path skip_clawhub clawhub_runtime_state_path bootstrap_run_arg_present + local validation_manifest validation_run_attempt validation_run_id + local validation_target_sha validation_url validation_workflow_ref local -a verify_args skip_clawhub="${1:-false}" @@ -1078,33 +1715,79 @@ jobs: mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}" verify_args=( - release:verify-beta - -- "${release_version}" --tag "${RELEASE_TAG}" --dist-tag "${RELEASE_NPM_DIST_TAG}" --repo "${GITHUB_REPOSITORY}" + --release-sha "${TARGET_SHA}" --workflow-ref "${CHILD_WORKFLOW_REF}" --clawhub-workflow-ref "${clawhub_workflow_ref}" - --full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}" --plugin-npm-run "${plugin_npm_run_id}" - --openclaw-npm-run "${openclaw_npm_run_id}" --evidence-out "${evidence_path}" --skip-github-release ) + # Resumed publishes have no core npm run of their own; the + # registry package check still verifies the published state. + if [[ -n "${openclaw_npm_run_id// }" ]]; then + verify_args+=(--openclaw-npm-run "${openclaw_npm_run_id}") + fi clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json" write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}" while IFS= read -r arg; do verify_args+=("${arg}") done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}") + bootstrap_run_arg_present="$( + jq -r \ + '.verifierArgs | index("--plugin-clawhub-bootstrap-run") != null' \ + "${clawhub_runtime_state_path}" + )" if [[ -n "${PLUGINS// }" ]]; then verify_args+=(--plugins "${PLUGINS}") fi + if [[ -n "${bootstrap_plugins// }" && "${bootstrap_run_arg_present}" == "true" ]]; then + verify_args+=(--clawhub-bootstrap-plugins "${bootstrap_plugins}") + fi if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}") fi - pnpm "${verify_args[@]}" + node --import tsx \ + "${GITHUB_WORKSPACE}/scripts/release-verify-beta.ts" \ + "${verify_args[@]}" + + # Resolve already validated this exact v3 run before mutation. Append + # its immutable tuple here so frozen targets need no new CLI option. + validation_manifest="${FULL_RELEASE_VALIDATION_MANIFEST_DIR}/full-release-validation-manifest.json" + validation_run_id="$(jq -er '.runId | select(type == "string" and length > 0)' "${validation_manifest}")" + validation_run_attempt="$(jq -er '.runAttempt | select(type == "string" and test("^[1-9][0-9]*$"))' "${validation_manifest}")" + validation_workflow_ref="$(jq -er '.workflowRef | select(type == "string" and length > 0)' "${validation_manifest}")" + validation_target_sha="$(jq -er '.targetSha | select(type == "string" and test("^[a-f0-9]{40}$"))' "${validation_manifest}")" + if [[ "${validation_run_id}" != "${FULL_RELEASE_VALIDATION_RUN_ID}" || + "${validation_run_attempt}" != "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" || + "${validation_target_sha}" != "${TARGET_SHA}" ]]; then + echo "Full release validation evidence changed after prepublish validation." >&2 + exit 1 + fi + validation_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${validation_run_id}" + jq \ + --arg release_publish_run_id "$GITHUB_RUN_ID" \ + --arg validation_run_id "${validation_run_id}" \ + --arg validation_run_attempt "${validation_run_attempt}" \ + --arg validation_target_sha "${validation_target_sha}" \ + --arg validation_url "${validation_url}" \ + --arg validation_workflow_ref "${validation_workflow_ref}" ' + .releasePublishRunId = $release_publish_run_id | + .workflowRuns += [{ + id: $validation_run_id, + label: "Full Release Validation", + runAttempt: $validation_run_attempt, + targetSha: $validation_target_sha, + url: $validation_url, + workflowRef: $validation_workflow_ref + }] + ' \ + "${evidence_path}" > "${evidence_path}.next" + mv "${evidence_path}.next" "${evidence_path}" { echo "- Postpublish verification: passed" echo "- Postpublish evidence: \`${evidence_path}\`" @@ -1112,15 +1795,15 @@ jobs: } append_release_proof_to_github_release() { - local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line + local release_version proof_file notes_file metadata_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line release_version="${RELEASE_TAG#v}" - body_file="${RUNNER_TEMP}/release-body.md" + proof_file="${RUNNER_TEMP}/release-verification.md" notes_file="${RUNNER_TEMP}/release-notes-with-proof.md" + metadata_file="${RUNNER_TEMP}/release-notes-with-proof.json" evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")" integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")" - gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}" if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}" @@ -1136,8 +1819,7 @@ jobs: windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}" fi - RELEASE_BODY_FILE="${body_file}" \ - RELEASE_NOTES_FILE="${notes_file}" \ + RELEASE_PROOF_FILE="${proof_file}" \ RELEASE_VERSION="${release_version}" \ RELEASE_TAG="${RELEASE_TAG}" \ RELEASE_SHA="${TARGET_SHA}" \ @@ -1154,15 +1836,13 @@ jobs: TELEGRAM_LINE="${telegram_line}" \ WINDOWS_LINE="${windows_line}" \ node --input-type=module <<'NODE' - import { readFileSync, writeFileSync } from "node:fs"; + import { writeFileSync } from "node:fs"; - const bodyFile = process.env.RELEASE_BODY_FILE; - const notesFile = process.env.RELEASE_NOTES_FILE; - if (!bodyFile || !notesFile) { - throw new Error("Missing release notes file paths."); + const proofFile = process.env.RELEASE_PROOF_FILE; + if (!proofFile) { + throw new Error("Missing release proof file path."); } - const body = readFileSync(bodyFile, "utf8").trimEnd(); const section = [ "### Release verification", "", @@ -1177,24 +1857,36 @@ jobs: `- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`, process.env.CLAWHUB_LINE, process.env.CLAWHUB_BOOTSTRAP_LINE, - `- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`, + // Resumed publishes reuse the already-published npm package and + // have no core npm run of their own to cite. + ...(process.env.OPENCLAW_NPM_RUN_ID + ? [ + `- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`, + ] + : []), process.env.TELEGRAM_LINE, ...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []), ].join("\n"); - const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, ""); - writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`); + writeFileSync(proofFile, section); NODE + render_github_release_notes "${notes_file}" "${proof_file}" "${metadata_file}" gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}" - echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY" + if jq -e '.verificationIncluded == true' "${metadata_file}" >/dev/null; then + echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::Release verification proof omitted because the canonical release notes already reach GitHub's body limit." + echo "- Release proof: omitted from body at GitHub limit; immutable evidence remains attached" >> "$GITHUB_STEP_SUMMARY" + fi } { echo "### Publish sequence" echo echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`" - echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" + echo "- Normal ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" + echo "- ClawHub bootstrap workflow ref: trusted \`main\`, target \`${TARGET_SHA}\`" echo "- Release tag: \`${RELEASE_TAG}\`" echo "- Release SHA: \`${TARGET_SHA}\`" echo "- Release approval: this workflow job" @@ -1205,7 +1897,7 @@ jobs: echo "- OpenClaw npm publish: skipped by input" fi if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then - echo "- Windows Hub promotion: required before the GitHub release can be published" + echo "- Windows Hub promotion: promoted concurrently with the OpenClaw npm publish; required before the GitHub release can be published" fi if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then echo "- Workflow completion waits for ClawHub" @@ -1214,10 +1906,24 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" - guard_existing_public_release - guard_openclaw_npm_not_already_published + prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md" + prepared_release_notes_metadata_file="${RUNNER_TEMP}/release-notes-prepublish.json" + verify_release_tag_target + if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then + render_github_release_notes \ + "${prepared_release_notes_file}" \ + "" \ + "${prepared_release_notes_metadata_file}" + guard_existing_public_release + fi + resolve_openclaw_npm_publish_state resolve_clawhub_release_plan + bootstrap_workflow_sha="" + if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then + bootstrap_workflow_sha="$(verify_bootstrap_workflow_sha)" + fi + npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") if [[ -n "${PLUGINS}" ]]; then npm_args+=(-f plugins="${PLUGINS}") @@ -1230,6 +1936,7 @@ jobs: append_clawhub_dispatch_args normal plugin_clawhub_run_id="$(dispatch_workflow_at_ref \ "$(jq -r '.normal.ref' "${clawhub_plan_path}")" \ + "${TARGET_SHA}" \ "$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \ "${clawhub_dispatch_args[@]}")" else @@ -1242,6 +1949,7 @@ jobs: append_clawhub_dispatch_args bootstrap plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \ "$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \ + "${bootstrap_workflow_sha}" \ "$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \ "${clawhub_dispatch_args[@]}")" else @@ -1253,7 +1961,7 @@ jobs: echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`" } >> "$GITHUB_STEP_SUMMARY" - if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then + if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}" "${PARENT_WORKFLOW_SHA}"; then echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2 if [[ -n "${plugin_clawhub_run_id}" ]]; then gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true @@ -1266,7 +1974,7 @@ jobs: if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish." - if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${bootstrap_workflow_sha}"; then plugin_clawhub_bootstrap_completed="true" else if [[ -n "${plugin_clawhub_run_id}" ]]; then @@ -1278,14 +1986,24 @@ jobs: openclaw_npm_run_id="" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then - openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \ - -f tag="${RELEASE_TAG}" \ - -f preflight_only=false \ - -f preflight_run_id="${PREFLIGHT_RUN_ID}" \ - -f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \ - -f release_publish_run_id="${GITHUB_RUN_ID}" \ - -f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")" - echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY" + # The draft release page is the only shared prerequisite for the + # Windows/Android promotions and the core npm publish; create it + # first so those stages can run concurrently. + create_or_update_github_release + if [[ "${openclaw_npm_already_published}" == "true" ]]; then + echo "- OpenClaw npm publish: already on npm; resuming postpublish stages" >> "$GITHUB_STEP_SUMMARY" + else + openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \ + -f tag="${RELEASE_TAG}" \ + -f preflight_only=false \ + -f preflight_run_id="${PREFLIGHT_RUN_ID}" \ + -f preflight_run_attempt="${PREFLIGHT_RUN_ATTEMPT}" \ + -f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \ + -f full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" \ + -f release_publish_run_id="${GITHUB_RUN_ID}" \ + -f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")" + echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY" + fi else echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY" fi @@ -1298,7 +2016,7 @@ jobs: if [[ -n "${plugin_clawhub_run_id}" ]]; then clawhub_result="$RUNNER_TEMP/clawhub-result.txt" wait_run_pid="" - wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" + wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${TARGET_SHA}" "${clawhub_result}" clawhub_pid="${wait_run_pid}" fi if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then @@ -1307,14 +2025,14 @@ jobs: else clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt" wait_run_pid="" - wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}" + wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${bootstrap_workflow_sha}" "${clawhub_bootstrap_result}" clawhub_bootstrap_pid="${wait_run_pid}" fi fi else if [[ -n "${plugin_clawhub_run_id}" ]]; then - wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" - if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then + wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" "${TARGET_SHA}" + if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${TARGET_SHA}"; then : else echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" @@ -1327,11 +2045,10 @@ jobs: if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" else - wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval" - if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + if approve_clawhub_bootstrap_environments "${plugin_clawhub_bootstrap_run_id}" "${bootstrap_workflow_sha}"; then : else - echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" + echo "- plugin-clawhub-new.yml: child environment gates not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" fi echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" fi @@ -1345,13 +2062,28 @@ jobs: if [[ -n "${openclaw_npm_run_id}" ]]; then openclaw_result="$RUNNER_TEMP/openclaw-npm-result.txt" wait_run_pid="" - wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${openclaw_result}" + wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${PARENT_WORKFLOW_SHA}" "${openclaw_result}" openclaw_pid="${wait_run_pid}" fi + # Windows promotion only needs the draft release page, so it + # runs concurrently with the core npm publish instead of after it. + windows_promote_result="" + windows_promote_pid="" + if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then + windows_promote_result="$RUNNER_TEMP/windows-promote-result.txt" + ( + if promote_windows_release_assets; then + echo "success" > "${windows_promote_result}" + else + echo "failed" > "${windows_promote_result}" + fi + ) & + windows_promote_pid=$! + fi + failed=0 openclaw_failed=0 - windows_node_run_id="" if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then failed=1 openclaw_failed=1 @@ -1361,30 +2093,42 @@ jobs: openclaw_failed=1 fi + clawhub_failed=0 if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then failed=1 + clawhub_failed=1 fi if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then failed=1 + clawhub_failed=1 fi if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then failed=1 + clawhub_failed=1 fi if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then failed=1 + clawhub_failed=1 fi - if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then - if [[ "${failed}" == "0" ]]; then + if [[ -n "${windows_promote_pid}" ]]; then + wait "${windows_promote_pid}" || true + if [[ ! -f "${windows_promote_result}" || "$(cat "${windows_promote_result}")" != "success" ]]; then + failed=1 + fi + fi + windows_node_run_id="$(cat "${RUNNER_TEMP}/windows-node-run-id.txt" 2>/dev/null || true)" + + if [[ ( -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ) || "${openclaw_npm_already_published}" == "true" ]]; then + # Only a ClawHub failure skips ClawHub verification; unrelated + # promotion failures must not reduce the recorded evidence. + if [[ "${clawhub_failed}" == "0" ]]; then verify_published_release else verify_published_release true fi - create_or_update_github_release upload_dependency_evidence_release_asset - if ! promote_windows_release_assets; then - failed=1 - fi + upload_release_evidence_assets append_release_proof_to_github_release if [[ "${failed}" == "0" ]]; then publish_github_release @@ -1398,7 +2142,7 @@ jobs: - name: Upload postpublish evidence if: ${{ always() }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: openclaw-release-postpublish-evidence-${{ inputs.tag }} path: ${{ runner.temp }}/openclaw-release-postpublish-evidence diff --git a/.github/workflows/plugin-clawhub-new.yml b/.github/workflows/plugin-clawhub-new.yml index 7e2e6073a078..fcc106b2882f 100644 --- a/.github/workflows/plugin-clawhub-new.yml +++ b/.github/workflows/plugin-clawhub-new.yml @@ -8,18 +8,39 @@ on: required: true type: string ref: - description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref + description: Exact 40-character target commit SHA approved by the parent release workflow + required: true + type: string + bootstrap_workflow_sha: + description: Exact trusted-main workflow SHA approved by the parent release workflow + required: false + default: "" + type: string + release_tag: + description: Exact release tag that must peel to ref; omit only for pre-tag validation required: false default: "" type: string release_publish_run_id: description: Approved OpenClaw Release Publish workflow run id required: false + default: "" + type: string + release_publish_run_attempt: + description: Exact approving OpenClaw Release Publish workflow run attempt + required: false + default: "" type: string release_publish_branch: description: Branch name of the approving OpenClaw Release Publish workflow run required: false + default: "" type: string + pretag_validation: + description: Validate an exact main/release target before its release tag exists; requires dry_run=true + required: false + default: false + type: boolean dry_run: description: Validate the token-gated ClawHub bootstrap handoff without publishing. required: false @@ -34,11 +55,11 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" CLAWHUB_REGISTRY: "https://clawhub.ai" - CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" jobs: resolve_bootstrap_plan: runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read outputs: @@ -48,57 +69,117 @@ jobs: matrix: ${{ steps.plan.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.ref }} + ref: ${{ github.sha }} fetch-depth: 0 + - name: Require trusted main workflow source + env: + APPROVED_WORKFLOW_SHA: ${{ inputs.bootstrap_workflow_sha }} + PRETAG_VALIDATION: ${{ inputs.pretag_validation }} + WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + [[ "${WORKFLOW_REF}" == "refs/heads/main" ]] || { + echo "Plugin ClawHub New must be dispatched from trusted main." >&2 + exit 1 + } + [[ "$(git rev-parse HEAD)" == "${WORKFLOW_SHA}" ]] || { + echo "Trusted workflow checkout does not match github.sha." >&2 + exit 1 + } + if [[ "${PRETAG_VALIDATION}" != "true" ]]; then + [[ "${APPROVED_WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ ]] || { + echo "Plugin ClawHub bootstrap requires an exact approved workflow SHA." >&2 + exit 1 + } + [[ "${WORKFLOW_SHA}" == "${APPROVED_WORKFLOW_SHA}" ]] || { + echo "Plugin ClawHub New workflow SHA does not match the parent-approved trusted-main SHA." >&2 + exit 1 + } + elif [[ -n "${APPROVED_WORKFLOW_SHA}" && "${WORKFLOW_SHA}" != "${APPROVED_WORKFLOW_SHA}" ]]; then + echo "Plugin ClawHub pre-tag validation workflow SHA does not match the requested trusted-main SHA." >&2 + exit 1 + fi + - name: Resolve checked-out ref id: ref env: - TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + BOOTSTRAP_WORKFLOW_SHA: ${{ inputs.bootstrap_workflow_sha }} + DRY_RUN: ${{ inputs.dry_run }} + PRETAG_VALIDATION: ${{ inputs.pretag_validation }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_PUBLISH_BRANCH: ${{ inputs.release_publish_branch }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + TARGET_REF: ${{ inputs.ref }} run: | set -euo pipefail - git fetch --no-tags origin \ - +refs/heads/main:refs/remotes/origin/main \ - '+refs/heads/release/*:refs/remotes/origin/release/*' - if [[ -n "${TARGET_REF}" ]]; then - if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then - target_sha="$(git rev-parse "${TARGET_REF}^{commit}")" - elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then - target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")" - else - echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2 + [[ "${TARGET_REF}" =~ ^[a-f0-9]{40}$ ]] || { + echo "Plugin ClawHub bootstrap ref must be a full lowercase commit SHA." >&2 + exit 1 + } + if [[ "${PRETAG_VALIDATION}" == "true" ]]; then + [[ "${DRY_RUN}" == "true" ]] || { + echo "Plugin ClawHub pre-tag validation requires dry_run=true." >&2 + exit 1 + } + if [[ -n "${RELEASE_TAG}${RELEASE_PUBLISH_RUN_ID}${RELEASE_PUBLISH_RUN_ATTEMPT}${RELEASE_PUBLISH_BRANCH}" ]]; then + echo "Plugin ClawHub pre-tag validation must not include a release tag or parent approval tuple." >&2 exit 1 fi - git checkout --detach "${target_sha}" + git fetch --force --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git cat-file -e "${TARGET_REF}^{commit}" || { + echo "Unable to resolve requested pre-tag validation ref: ${TARGET_REF}" >&2 + exit 1 + } + trusted_target=false + if git merge-base --is-ancestor "${TARGET_REF}" refs/remotes/origin/main; then + trusted_target=true + else + while IFS= read -r release_ref; do + if git merge-base --is-ancestor "${TARGET_REF}" "${release_ref}"; then + trusted_target=true + break + fi + done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) + fi + [[ "${trusted_target}" == "true" ]] || { + echo "Plugin ClawHub pre-tag validation target must be reachable from main or release/*." >&2 + exit 1 + } + else + [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]] || { + echo "Plugin ClawHub bootstrap release_tag is invalid." >&2 + exit 1 + } + [[ "${RELEASE_PUBLISH_RUN_ID}" =~ ^[1-9][0-9]*$ && + "${RELEASE_PUBLISH_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ && + -n "${RELEASE_PUBLISH_BRANCH}" && + "${BOOTSTRAP_WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ ]] || { + echo "Plugin ClawHub bootstrap requires an exact parent approval tuple." >&2 + exit 1 + } + git fetch --force --no-tags origin \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + git cat-file -e "${TARGET_REF}^{commit}" || { + echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2 + exit 1 + } + tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" + [[ "${tag_sha}" == "${TARGET_REF}" ]] || { + echo "Plugin ClawHub bootstrap target ${TARGET_REF} does not match ${RELEASE_TAG} (${tag_sha})." >&2 + exit 1 + } fi + git checkout --detach "${TARGET_REF}" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Validate ref is on a trusted publish branch - env: - TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} - run: | - set -euo pipefail - if git merge-base --is-ancestor HEAD origin/main; then - exit 0 - fi - while IFS= read -r release_ref; do - if git merge-base --is-ancestor HEAD "${release_ref}"; then - exit 0 - fi - done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) - if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - alpha_branch="${TRUSTED_PUBLISH_BRANCH}" - git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" - if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then - exit 0 - fi - fi - echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2 - exit 1 - - name: Setup Node environment uses: ./.github/actions/setup-node-env with: @@ -200,7 +281,7 @@ jobs: - name: Validate Tideclaw alpha plugin channels env: - TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch }} run: | set -euo pipefail if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then @@ -222,103 +303,176 @@ jobs: validate_release_publish_approval: name: Validate release publish approval needs: resolve_bootstrap_plan - if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' + if: github.event_name == 'workflow_dispatch' && inputs.pretag_validation != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' runs-on: ubuntu-latest + timeout-minutes: 20 permissions: actions: read + attestations: read contents: read steps: + - name: Download parent ClawHub bootstrap approval + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: clawhub-bootstrap-approval-${{ inputs.release_publish_run_id }}-${{ inputs.release_publish_run_attempt }} + path: ${{ runner.temp }}/clawhub-bootstrap-approval + repository: ${{ github.repository }} + run-id: ${{ inputs.release_publish_run_id }} + github-token: ${{ github.token }} + - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false + ref: ${{ github.sha }} - name: Validate release publish approval run env: + APPROVAL_PATH: ${{ runner.temp }}/clawhub-bootstrap-approval/approval.json + CHILD_WORKFLOW_SHA: ${{ github.sha }} GH_TOKEN: ${{ github.token }} + RELEASE_APPROVAL_KIND: clawhub-bootstrap + RELEASE_PACKAGES: ${{ inputs.plugins }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} - EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + EXPECTED_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} run: | set -euo pipefail - if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then - if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then - echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2 - exit 1 - fi - echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval." - exit 0 - fi direct_recovery=false if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then direct_recovery=true echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval." fi - RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" + RUN_JSON="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_PUBLISH_RUN_ID}/attempts/${EXPECTED_RUN_ATTEMPT}" | + jq '{ + workflowName: .name, + headBranch: .head_branch, + headSha: .head_sha, + event, + status, + conclusion, + url: .html_url, + runAttempt: .run_attempt + }' + )" + EXPECTED_WORKFLOW_SHA="$( + jq -er '.headSha | select(test("^[a-f0-9]{40}$"))' <<< "${RUN_JSON}" + )" + gh attestation verify "${APPROVAL_PATH}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/openclaw-release-publish.yml" \ + --source-ref "refs/heads/${EXPECTED_WORKFLOW_BRANCH}" \ + --source-digest "${EXPECTED_WORKFLOW_SHA}" \ + --deny-self-hosted-runners printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs validate_bootstrap_trusted_publisher_cli: needs: [resolve_bootstrap_plan, validate_release_publish_approval] - if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' + if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && (inputs.pretag_validation == true || needs.validate_release_publish_approval.result == 'success') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: + - name: Checkout trusted workflow tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup trusted Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Materialize locked ClawHub CLI + id: clawhub_cli + run: | + set -euo pipefail + bash scripts/materialize-clawhub-cli.sh \ + .github/release/clawhub-cli \ + "${RUNNER_TEMP}/clawhub-cli" \ + "${GITHUB_OUTPUT}" + - name: Validate pinned ClawHub trusted publisher CLI support env: - CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} + CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }} run: | set -euo pipefail help_output="$( - npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ - clawhub package trusted-publisher set --help 2>&1 || true + "${CLAWHUB_CLI}" package trusted-publisher set --help 2>&1 || true )" printf '%s\n' "${help_output}" if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then - echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row." + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires locked clawhub@0.23.1 to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row." exit 1 fi for required_flag in --repository --workflow-filename; do if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then - echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}." + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires locked clawhub@0.23.1 trusted-publisher set help to include ${required_flag}." exit 1 fi done - publish_bootstrap_plugins: - needs: - [ - resolve_bootstrap_plan, - validate_release_publish_approval, - validate_bootstrap_trusted_publisher_cli, - ] - if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success') + pack_bootstrap_plugins: + name: Pack immutable ClawHub bootstrap artifacts + needs: [resolve_bootstrap_plan, validate_release_publish_approval] + if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && (inputs.pretag_validation == true || needs.validate_release_publish_approval.result == 'success') runs-on: ubuntu-latest - environment: clawhub-plugin-bootstrap + timeout-minutes: 60 permissions: + actions: read contents: read - strategy: - fail-fast: false - max-parallel: 8 - matrix: - plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} + outputs: + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + artifact_name: ${{ steps.artifact.outputs.name }} + artifact_run_attempt: ${{ github.run_attempt }} + artifact_run_id: ${{ github.run_id }} + artifact_size: ${{ steps.upload_binding.outputs.size }} + clawhub_toolchain_sha256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.ref }} + ref: ${{ github.sha }} fetch-depth: 0 - name: Checkout target revision env: + PRETAG_VALIDATION: ${{ inputs.pretag_validation }} + RELEASE_TAG: ${{ inputs.release_tag }} TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} run: | set -euo pipefail - git fetch --no-tags origin \ - +refs/heads/main:refs/remotes/origin/main \ - '+refs/heads/release/*:refs/remotes/origin/release/*' + if [[ "${PRETAG_VALIDATION}" == "true" ]]; then + git fetch --force --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git cat-file -e "${TARGET_SHA}^{commit}" + else + git fetch --force --no-tags origin \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" + [[ "${tag_sha}" == "${TARGET_SHA}" ]] || { + echo "Plugin ClawHub bootstrap target ${TARGET_SHA} does not match ${RELEASE_TAG} (${tag_sha})." >&2 + exit 1 + } + fi git checkout --detach "${TARGET_SHA}" + - name: Checkout trusted workflow tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: .release-harness + - name: Setup Node environment uses: ./.github/actions/setup-node-env with: @@ -326,27 +480,440 @@ jobs: install-bun: "true" install-deps: "true" - - name: Verify package-local runtime build - run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - - - name: Install pinned ClawHub CLI wrapper + - name: Materialize locked ClawHub CLI + id: clawhub_cli run: | set -euo pipefail - cat > "${RUNNER_TEMP}/clawhub" <<'EOF' - #!/usr/bin/env bash + bash .release-harness/scripts/materialize-clawhub-cli.sh \ + .release-harness/.github/release/clawhub-cli \ + "${RUNNER_TEMP}/clawhub-cli" \ + "${GITHUB_OUTPUT}" + + - name: Pack immutable ClawHub bootstrap artifacts + id: artifact + env: + BOOTSTRAP_MATRIX: ${{ needs.resolve_bootstrap_plan.outputs.matrix }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + RELEASE_PLUGINS: ${{ inputs.plugins }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + WORKFLOW_SHA: ${{ github.sha }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }} + run: | set -euo pipefail - exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@" - EOF - chmod +x "${RUNNER_TEMP}/clawhub" - echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + artifact_root="${RUNNER_TEMP}/clawhub-bootstrap-artifact" + matrix_path="${RUNNER_TEMP}/clawhub-bootstrap-matrix.json" + artifact_name="clawhub-bootstrap-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "${artifact_root}/packages" + printf '%s\n' "${BOOTSTRAP_MATRIX}" > "${matrix_path}" + + while IFS= read -r plugin; do + package_name="$(jq -r '.packageName' <<< "${plugin}")" + package_dir="$(jq -r '.packageDir' <<< "${plugin}")" + package_version="$(jq -r '.version' <<< "${plugin}")" + package_tag="$(jq -r '.publishTag' <<< "${plugin}")" + package_slug="${package_name#@openclaw/}" + output_dir="${artifact_root}/packages/${package_slug}" + mkdir -p "${output_dir}" + node scripts/check-plugin-npm-runtime-builds.mjs --package "${package_dir}" + OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR="${output_dir}" \ + PACKAGE_TAG="${package_tag}" \ + bash .release-harness/scripts/plugin-clawhub-publish.sh --pack "${package_dir}" + tgz="$(find "${output_dir}" -maxdepth 1 -type f -name '*.tgz' -print -quit)" + artifact_sha256="$(sha256sum "${tgz}" | awk '{print $1}')" + artifact_size="$(wc -c < "${tgz}" | tr -d '[:space:]')" + PACKAGE_DIR="${package_dir}" \ + PACKAGE_TAG="${package_tag}" \ + EXPECTED_CLAWHUB_PACKAGE_NAME="${package_name}" \ + EXPECTED_CLAWHUB_PACKAGE_VERSION="${package_version}" \ + EXPECTED_CLAWHUB_ARTIFACT_SHA256="${artifact_sha256}" \ + EXPECTED_CLAWHUB_ARTIFACT_SIZE="${artifact_size}" \ + SOURCE_COMMIT="${TARGET_SHA}" \ + SOURCE_REF="${TARGET_SHA}" \ + SOURCE_REPO="${GITHUB_REPOSITORY}" \ + bash .release-harness/scripts/plugin-clawhub-publish.sh \ + --validate-packed "${tgz}" + done < <(jq -c '.[]' "${matrix_path}") + + node "${GITHUB_WORKSPACE}/.release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs" create \ + --artifact-root "${artifact_root}" \ + --artifact-name "${artifact_name}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --matrix "${matrix_path}" \ + --output "${artifact_root}/manifest.json" \ + --plugins "${RELEASE_PLUGINS}" \ + --repository "${GITHUB_REPOSITORY}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --run-id "${GITHUB_RUN_ID}" \ + --target-sha "${TARGET_SHA}" \ + --workflow-sha "${WORKFLOW_SHA}" + echo "name=${artifact_name}" >> "${GITHUB_OUTPUT}" + + - name: Upload immutable ClawHub bootstrap artifact + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + archive: true + name: ${{ steps.artifact.outputs.name }} + path: ${{ runner.temp }}/clawhub-bootstrap-artifact + if-no-files-found: error + compression-level: 0 + + - name: Bind uploaded artifact size + id: upload_binding + env: + ARTIFACT_DIGEST: ${{ steps.upload.outputs.artifact-digest }} + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + ARTIFACT_NAME: ${{ steps.artifact.outputs.name }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_json="" + for attempt in 1 2 3 4 5; do + if artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"; then + break + fi + [[ "${attempt}" -lt 5 ]] || exit 1 + sleep 2 + done + size="$( + jq -er \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "${ARTIFACT_ID}" \ + --arg name "${ARTIFACT_NAME}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg workflow_sha "${GITHUB_SHA}" \ + 'select( + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id and + .workflow_run.head_sha == $workflow_sha + ) | .size_in_bytes' \ + <<< "${artifact_json}" + )" + [[ "${size}" =~ ^[1-9][0-9]*$ ]] || exit 1 + echo "size=${size}" >> "${GITHUB_OUTPUT}" + + validate_bootstrap_artifact: + name: Validate immutable bootstrap handoff + needs: + [ + resolve_bootstrap_plan, + validate_release_publish_approval, + validate_bootstrap_trusted_publisher_cli, + pack_bootstrap_plugins, + ] + if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && (inputs.pretag_validation == true || needs.validate_release_publish_approval.result == 'success') && needs.validate_bootstrap_trusted_publisher_cli.result == 'success' && needs.pack_bootstrap_plugins.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: clawhub-plugin-bootstrap + permissions: + actions: read + contents: read + steps: + - name: Checkout trusted workflow tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: .release-harness + + - name: Setup trusted Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Materialize locked ClawHub CLI + id: clawhub_cli + run: | + set -euo pipefail + bash .release-harness/scripts/materialize-clawhub-cli.sh \ + .release-harness/.github/release/clawhub-cli \ + "${RUNNER_TEMP}/clawhub-cli" \ + "${GITHUB_OUTPUT}" + + - name: Download and verify immutable ClawHub bootstrap artifact + env: + ARTIFACT_DIGEST: ${{ needs.pack_bootstrap_plugins.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_id }} + ARTIFACT_NAME: ${{ needs.pack_bootstrap_plugins.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_id }} + ARTIFACT_SIZE: ${{ needs.pack_bootstrap_plugins.outputs.artifact_size }} + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + WORKFLOW_SHA: ${{ github.sha }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs download \ + --artifact-digest "${ARTIFACT_DIGEST}" \ + --artifact-id "${ARTIFACT_ID}" \ + --artifact-name "${ARTIFACT_NAME}" \ + --artifact-size "${ARTIFACT_SIZE}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --consumer-run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --output-root "${RUNNER_TEMP}/clawhub-bootstrap-artifact" \ + --producer-job-name "Pack immutable ClawHub bootstrap artifacts" \ + --repository "${GITHUB_REPOSITORY}" \ + --run-attempt "${ARTIFACT_RUN_ATTEMPT}" \ + --run-id "${ARTIFACT_RUN_ID}" \ + --target-sha "${TARGET_SHA}" \ + --workflow-sha "${WORKFLOW_SHA}" + + - name: Rehash immutable ClawHub bootstrap artifacts + env: + ARTIFACT_NAME: ${{ needs.pack_bootstrap_plugins.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_id }} + RELEASE_PLUGINS: ${{ inputs.plugins }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + WORKFLOW_SHA: ${{ github.sha }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs verify \ + --artifact-root "${RUNNER_TEMP}/clawhub-bootstrap-artifact" \ + --artifact-name "${ARTIFACT_NAME}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --manifest "${RUNNER_TEMP}/clawhub-bootstrap-artifact/manifest.json" \ + --output "${RUNNER_TEMP}/clawhub-bootstrap-validated.json" \ + --plugins "${RELEASE_PLUGINS}" \ + --repository "${GITHUB_REPOSITORY}" \ + --run-attempt "${ARTIFACT_RUN_ATTEMPT}" \ + --run-id "${ARTIFACT_RUN_ID}" \ + --target-sha "${TARGET_SHA}" \ + --workflow-sha "${WORKFLOW_SHA}" + + - name: Validate packed ClawHub package identities + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REF: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REPO: ${{ github.repository }} + OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }} + run: | + set -euo pipefail + while IFS= read -r plugin; do + artifact_path="$(jq -r '.artifactPath' <<< "${plugin}")" + PACKAGE_DIR="$(jq -r '.packageDir' <<< "${plugin}")" \ + PACKAGE_TAG="$(jq -r '.publishTag' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_PACKAGE_NAME="$(jq -r '.packageName' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_PACKAGE_VERSION="$(jq -r '.version' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SHA256="$(jq -r '.sha256' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SIZE="$(jq -r '.size' <<< "${plugin}")" \ + OPENCLAW_CLAWHUB_AUTH_LABEL="secretless immutable handoff validation" \ + bash .release-harness/scripts/plugin-clawhub-publish.sh \ + --validate-packed "${RUNNER_TEMP}/clawhub-bootstrap-artifact/${artifact_path}" + done < <(jq -c '.entries[]' "${RUNNER_TEMP}/clawhub-bootstrap-validated.json") + + - name: Require configure-only registry bytes to match target + env: + ARTIFACT_DIGEST: ${{ needs.pack_bootstrap_plugins.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_id }} + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/verify-clawhub-published-artifact.mjs \ + --artifact-digest "${ARTIFACT_DIGEST}" \ + --artifact-id "${ARTIFACT_ID}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --manifest "${RUNNER_TEMP}/clawhub-bootstrap-validated.json" \ + --mode configure-only-preflight \ + --output "${RUNNER_TEMP}/clawhub-bootstrap-configure-only-preflight.json" \ + --registry "${CLAWHUB_REGISTRY}" \ + --terminal-run-attempt "${GITHUB_RUN_ATTEMPT}" + + - name: Upload immutable bootstrap validation evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: clawhub-bootstrap-validation-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/clawhub-bootstrap-validated.json + ${{ runner.temp }}/clawhub-bootstrap-configure-only-preflight.json + if-no-files-found: error + + publish_bootstrap_plugins: + needs: + [ + resolve_bootstrap_plan, + validate_release_publish_approval, + validate_bootstrap_trusted_publisher_cli, + pack_bootstrap_plugins, + validate_bootstrap_artifact, + ] + if: always() && github.event_name == 'workflow_dispatch' && inputs.pretag_validation != true && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && needs.validate_bootstrap_trusted_publisher_cli.result == 'success' && needs.pack_bootstrap_plugins.result == 'success' && needs.validate_bootstrap_artifact.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 120 + environment: clawhub-plugin-bootstrap + permissions: + actions: read + contents: read + steps: + - name: Checkout trusted workflow tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: .release-harness + + - name: Setup trusted Node runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Materialize locked ClawHub CLI + id: clawhub_cli + run: | + set -euo pipefail + bash .release-harness/scripts/materialize-clawhub-cli.sh \ + .release-harness/.github/release/clawhub-cli \ + "${RUNNER_TEMP}/clawhub-cli" \ + "${GITHUB_OUTPUT}" + + - name: Download and verify immutable ClawHub bootstrap artifact + env: + ARTIFACT_DIGEST: ${{ needs.pack_bootstrap_plugins.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_id }} + ARTIFACT_NAME: ${{ needs.pack_bootstrap_plugins.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_id }} + ARTIFACT_SIZE: ${{ needs.pack_bootstrap_plugins.outputs.artifact_size }} + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + WORKFLOW_SHA: ${{ github.sha }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs download \ + --artifact-digest "${ARTIFACT_DIGEST}" \ + --artifact-id "${ARTIFACT_ID}" \ + --artifact-name "${ARTIFACT_NAME}" \ + --artifact-size "${ARTIFACT_SIZE}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --consumer-run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --output-root "${RUNNER_TEMP}/clawhub-bootstrap-artifact" \ + --producer-job-name "Pack immutable ClawHub bootstrap artifacts" \ + --repository "${GITHUB_REPOSITORY}" \ + --run-attempt "${ARTIFACT_RUN_ATTEMPT}" \ + --run-id "${ARTIFACT_RUN_ID}" \ + --target-sha "${TARGET_SHA}" \ + --workflow-sha "${WORKFLOW_SHA}" + + - name: Rehash immutable ClawHub bootstrap artifacts + env: + ARTIFACT_NAME: ${{ needs.pack_bootstrap_plugins.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_run_id }} + RELEASE_PLUGINS: ${{ inputs.plugins }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + WORKFLOW_SHA: ${{ github.sha }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs verify \ + --artifact-root "${RUNNER_TEMP}/clawhub-bootstrap-artifact" \ + --artifact-name "${ARTIFACT_NAME}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --manifest "${RUNNER_TEMP}/clawhub-bootstrap-artifact/manifest.json" \ + --output "${RUNNER_TEMP}/clawhub-bootstrap-validated.json" \ + --plugins "${RELEASE_PLUGINS}" \ + --repository "${GITHUB_REPOSITORY}" \ + --run-attempt "${ARTIFACT_RUN_ATTEMPT}" \ + --run-id "${ARTIFACT_RUN_ID}" \ + --target-sha "${TARGET_SHA}" \ + --workflow-sha "${WORKFLOW_SHA}" + + - name: Validate packed ClawHub package identities before credentials + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REF: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REPO: ${{ github.repository }} + OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }} + run: | + set -euo pipefail + while IFS= read -r plugin; do + artifact_path="$(jq -r '.artifactPath' <<< "${plugin}")" + PACKAGE_DIR="$(jq -r '.packageDir' <<< "${plugin}")" \ + PACKAGE_TAG="$(jq -r '.publishTag' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_PACKAGE_NAME="$(jq -r '.packageName' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_PACKAGE_VERSION="$(jq -r '.version' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SHA256="$(jq -r '.sha256' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SIZE="$(jq -r '.size' <<< "${plugin}")" \ + OPENCLAW_CLAWHUB_AUTH_LABEL="token-gated bootstrap (credential not loaded)" \ + bash .release-harness/scripts/plugin-clawhub-publish.sh \ + --validate-packed "${RUNNER_TEMP}/clawhub-bootstrap-artifact/${artifact_path}" + done < <(jq -c '.entries[]' "${RUNNER_TEMP}/clawhub-bootstrap-validated.json") + + - name: Reconfirm configure-only registry bytes before credentials + env: + ARTIFACT_DIGEST: ${{ needs.pack_bootstrap_plugins.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_id }} + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/verify-clawhub-published-artifact.mjs \ + --artifact-digest "${ARTIFACT_DIGEST}" \ + --artifact-id "${ARTIFACT_ID}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --manifest "${RUNNER_TEMP}/clawhub-bootstrap-validated.json" \ + --mode configure-only-preflight \ + --registry "${CLAWHUB_REGISTRY}" \ + --terminal-run-attempt "${GITHUB_RUN_ATTEMPT}" + + - name: Reconfirm release tag before credentials + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + run: | + set -euo pipefail + git -C .release-harness fetch --force --no-tags origin \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_sha="$(git -C .release-harness rev-parse "${RELEASE_TAG}^{commit}")" + [[ "${tag_sha}" == "${TARGET_SHA}" ]] || { + echo "Plugin ClawHub bootstrap target ${TARGET_SHA} no longer matches ${RELEASE_TAG} (${tag_sha})." >&2 + exit 1 + } - name: Write ClawHub token config - if: inputs.dry_run != true env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} run: | set -euo pipefail + umask 077 config_path="${RUNNER_TEMP}/clawhub-config.json" CONFIG_PATH="${config_path}" node --input-type=module <<'NODE' import { writeFileSync } from "node:fs"; @@ -363,7 +930,6 @@ jobs: if (!configPath) { throw new Error("CONFIG_PATH is required."); } - writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, @@ -371,134 +937,87 @@ jobs: NODE echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}" - - name: Publish ClawHub bootstrap package + - name: Publish exact ClawHub bootstrap artifacts env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - SOURCE_REPO: ${{ github.repository }} SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} - SOURCE_REF: ${{ github.ref }} - PACKAGE_TAG: ${{ matrix.plugin.publishTag }} - PACKAGE_DIR: ${{ matrix.plugin.packageDir }} - BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }} - REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }} - DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} - OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" + SOURCE_REF: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REPO: ${{ github.repository }} + OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }} + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} run: | set -euo pipefail - if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then - echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only." - elif [[ "${DRY_RUN}" == "true" ]]; then - bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" - else - if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then - export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration" - fi - bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" - fi - - - name: Configure trusted publisher for normal OIDC releases - if: inputs.dry_run != true - env: - CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} - PACKAGE_NAME: ${{ matrix.plugin.packageName }} - run: | - set -euo pipefail - npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ - clawhub package trusted-publisher set "${PACKAGE_NAME}" \ - --repository openclaw/openclaw \ - --workflow-filename plugin-clawhub-release.yml - - verify_bootstrap_clawhub_package: - needs: [resolve_bootstrap_plan, publish_bootstrap_plugins] - if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 8 - matrix: - plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} - steps: - - name: Verify bootstrap ClawHub package and trusted publisher - env: - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - PACKAGE_NAME: ${{ matrix.plugin.packageName }} - PACKAGE_VERSION: ${{ matrix.plugin.version }} - PACKAGE_TAG: ${{ matrix.plugin.publishTag }} - run: | - set -euo pipefail - node --input-type=module <<'EOF' - const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, ""); - const packageName = process.env.PACKAGE_NAME; - const packageVersion = process.env.PACKAGE_VERSION; - const packageTag = process.env.PACKAGE_TAG; - if (!packageName || !packageVersion || !packageTag) { - throw new Error("Missing ClawHub bootstrap verification env."); - } - const encodedName = encodeURIComponent(packageName); - const encodedVersion = encodeURIComponent(packageVersion); - const detailUrl = `${registry}/api/v1/packages/${encodedName}`; - const trustedPublisherUrl = `${detailUrl}/trusted-publisher`; - const versionUrl = `${detailUrl}/versions/${encodedVersion}`; - const artifactUrl = `${versionUrl}/artifact/download`; - - async function fetchWithRetry(url, options = {}) { - let lastStatus = "unknown"; - for (let attempt = 1; attempt <= 12; attempt += 1) { - try { - const response = await fetch(url, { redirect: "manual", ...options }); - lastStatus = response.status; - if (response.status !== 429 && response.status < 500) { - return response; - } - } catch (error) { - lastStatus = error instanceof Error ? error.message : String(error); - } - await new Promise((resolve) => setTimeout(resolve, attempt * 5000)); + verify_release_tag_target() { + git -C .release-harness fetch --force --no-tags origin \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + local tag_sha + tag_sha="$(git -C .release-harness rev-parse "${RELEASE_TAG}^{commit}")" + [[ "${tag_sha}" == "${TARGET_SHA}" ]] || { + echo "Plugin ClawHub bootstrap target ${TARGET_SHA} no longer matches ${RELEASE_TAG} (${tag_sha})." >&2 + exit 1 } - throw new Error(`${url} did not stabilize; last status ${lastStatus}.`); } - const detailResponse = await fetchWithRetry(detailUrl, { - headers: { accept: "application/json" }, - }); - if (!detailResponse.ok) { - throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`); - } - const detail = await detailResponse.json(); - const tags = detail?.package?.tags ?? {}; - if (tags[packageTag] !== packageVersion) { - throw new Error( - `${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? ""}, expected ${packageVersion}.`, - ); - } + while IFS= read -r plugin; do + verify_release_tag_target + mode="$(jq -r '.bootstrapMode' <<< "${plugin}")" + package_name="$(jq -r '.packageName' <<< "${plugin}")" + if [[ "${mode}" == "publish" ]]; then + artifact_path="$(jq -r '.artifactPath' <<< "${plugin}")" + # The prefix lets ClawHub reuse only an existing same-integrity + # release after an ambiguous token-publish timeout. + manual_override="GitHub Actions immutable bootstrap retry" + if [[ "$(jq -r '.requiresManualOverride' <<< "${plugin}")" == "true" ]]; then + manual_override="GitHub Actions trusted publisher repair before OIDC migration" + fi + PACKAGE_DIR="$(jq -r '.packageDir' <<< "${plugin}")" \ + PACKAGE_TAG="$(jq -r '.publishTag' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_PACKAGE_NAME="${package_name}" \ + EXPECTED_CLAWHUB_PACKAGE_VERSION="$(jq -r '.version' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SHA256="$(jq -r '.sha256' <<< "${plugin}")" \ + EXPECTED_CLAWHUB_ARTIFACT_SIZE="$(jq -r '.size' <<< "${plugin}")" \ + OPENCLAW_CLAWHUB_AUTH_LABEL="token-gated bootstrap" \ + OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="${manual_override}" \ + OPENCLAW_CLAWHUB_RELEASE_GIT_DIR="${GITHUB_WORKSPACE}/.release-harness" \ + OPENCLAW_CLAWHUB_RELEASE_TAG="${RELEASE_TAG}" \ + OPENCLAW_CLAWHUB_TARGET_SHA="${TARGET_SHA}" \ + bash .release-harness/scripts/plugin-clawhub-publish.sh \ + --publish-packed "${RUNNER_TEMP}/clawhub-bootstrap-artifact/${artifact_path}" + else + echo "Skipping bootstrap publish because the exact version is already present; configuring trusted publisher only." + fi + verify_release_tag_target + timeout --signal=TERM --kill-after=10s 300s \ + "${OPENCLAW_CLAWHUB_CLI}" package trusted-publisher set "${package_name}" \ + --repository openclaw/openclaw \ + --workflow-filename plugin-clawhub-release.yml + done < <(jq -c '.entries[]' "${RUNNER_TEMP}/clawhub-bootstrap-validated.json") - const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, { - headers: { accept: "application/json" }, - }); - if (!trustedPublisherResponse.ok) { - throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`); - } - const trustedPublisherDetail = await trustedPublisherResponse.json(); - const trustedPublisher = trustedPublisherDetail?.trustedPublisher; - if ( - trustedPublisher?.repository !== "openclaw/openclaw" || - trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" || - trustedPublisher?.environment != null - ) { - throw new Error( - `${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`, - ); - } + - name: Verify exact ClawHub registry artifact bytes + env: + ARTIFACT_DIGEST: ${{ needs.pack_bootstrap_plugins.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.pack_bootstrap_plugins.outputs.artifact_id }} + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + CLAWHUB_TOOLCHAIN_INTEGRITY: ${{ steps.clawhub_cli.outputs.integrity }} + CLAWHUB_TOOLCHAIN_SHA256: ${{ steps.clawhub_cli.outputs.lock_sha256 }} + CLAWHUB_TOOLCHAIN_VERSION: ${{ steps.clawhub_cli.outputs.version }} + run: | + set -euo pipefail + node .release-harness/scripts/verify-clawhub-published-artifact.mjs \ + --artifact-digest "${ARTIFACT_DIGEST}" \ + --artifact-id "${ARTIFACT_ID}" \ + --clawhub-toolchain-integrity "${CLAWHUB_TOOLCHAIN_INTEGRITY}" \ + --clawhub-toolchain-sha256 "${CLAWHUB_TOOLCHAIN_SHA256}" \ + --clawhub-toolchain-version "${CLAWHUB_TOOLCHAIN_VERSION}" \ + --manifest "${RUNNER_TEMP}/clawhub-bootstrap-validated.json" \ + --output "${RUNNER_TEMP}/clawhub-bootstrap-readback.json" \ + --registry "${CLAWHUB_REGISTRY}" \ + --terminal-run-attempt "${GITHUB_RUN_ATTEMPT}" - const versionResponse = await fetchWithRetry(versionUrl); - if (!versionResponse.ok) { - throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`); - } - const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" }); - if (artifactResponse.status < 200 || artifactResponse.status >= 400) { - throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`); - } - console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`); - EOF + - name: Upload ClawHub bootstrap readback evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: clawhub-bootstrap-readback-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/clawhub-bootstrap-readback.json + if-no-files-found: error diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index da9645a2cdd3..13f5dd21f171 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -42,7 +42,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" CLAWHUB_REGISTRY: "https://clawhub.ai" - CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" + CLAWHUB_CLI_PACKAGE: "clawhub@0.23.1" jobs: preview_plugins_clawhub: @@ -63,7 +63,7 @@ jobs: missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false ref: ${{ github.ref }} @@ -275,7 +275,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false @@ -315,7 +315,7 @@ jobs: plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false ref: ${{ github.ref }} @@ -364,7 +364,7 @@ jobs: run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}" - name: Upload ClawHub package artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.plugin.artifactName }} path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz @@ -387,7 +387,7 @@ jobs: needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release] if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success') - uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721 + uses: openclaw/clawhub/.github/workflows/package-publish.yml@d8096dfc039e86ab942ddf9ef117d04849fd84c1 permissions: actions: read contents: read @@ -402,6 +402,7 @@ jobs: dry_run: ${{ inputs.dry_run }} registry: https://clawhub.ai site: https://clawhub.ai + family: ${{ contains(fromJson('["@openclaw/acpx","@openclaw/diffs","@openclaw/feishu","@openclaw/qqbot"]'), matrix.plugin.packageName) && 'bundle-plugin' || '' }} tags: ${{ matrix.plugin.publishTag }} source_repo: ${{ github.repository }} source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} @@ -414,7 +415,9 @@ jobs: needs: [preview_plugins_clawhub, publish_plugins_clawhub] if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' runs-on: ubuntu-latest + timeout-minutes: 45 permissions: + actions: read contents: read strategy: fail-fast: false @@ -422,6 +425,27 @@ jobs: matrix: plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} steps: + - name: Checkout verification tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + install-deps: "false" + use-actions-cache: "false" + + - name: Download published package input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ matrix.plugin.artifactName }} + path: ${{ runner.temp }}/clawhub-package-artifact + - name: Verify published ClawHub package env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} @@ -430,57 +454,9 @@ jobs: PACKAGE_TAG: ${{ matrix.plugin.publishTag }} run: | set -euo pipefail - node --input-type=module <<'EOF' - const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, ""); - const packageName = process.env.PACKAGE_NAME; - const packageVersion = process.env.PACKAGE_VERSION; - const packageTag = process.env.PACKAGE_TAG; - if (!packageName || !packageVersion || !packageTag) { - throw new Error("Missing ClawHub package verification env."); - } - const encodedName = encodeURIComponent(packageName); - const encodedVersion = encodeURIComponent(packageVersion); - const detailUrl = `${registry}/api/v1/packages/${encodedName}`; - const versionUrl = `${detailUrl}/versions/${encodedVersion}`; - const artifactUrl = `${versionUrl}/artifact/download`; - - async function fetchWithRetry(url, options = {}) { - let lastStatus = "unknown"; - for (let attempt = 1; attempt <= 12; attempt += 1) { - try { - const response = await fetch(url, { redirect: "manual", ...options }); - lastStatus = response.status; - if (response.status !== 429 && response.status < 500) { - return response; - } - } catch (error) { - lastStatus = error instanceof Error ? error.message : String(error); - } - await new Promise((resolve) => setTimeout(resolve, attempt * 5000)); - } - throw new Error(`${url} did not stabilize; last status ${lastStatus}.`); - } - - const detailResponse = await fetchWithRetry(detailUrl, { - headers: { accept: "application/json" }, - }); - if (!detailResponse.ok) { - throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`); - } - const detail = await detailResponse.json(); - const tags = detail?.package?.tags ?? {}; - if (tags[packageTag] !== packageVersion) { - throw new Error( - `${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? ""}, expected ${packageVersion}.`, - ); - } - const versionResponse = await fetchWithRetry(versionUrl); - if (!versionResponse.ok) { - throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`); - } - const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" }); - if (artifactResponse.status < 200 || artifactResponse.status >= 400) { - throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`); - } - console.log(`${packageName}@${packageVersion} verified on ClawHub.`); - EOF + node scripts/verify-clawhub-published-artifact.mjs \ + --expected-artifact-dir "${RUNNER_TEMP}/clawhub-package-artifact" \ + --package-name "${PACKAGE_NAME}" \ + --package-version "${PACKAGE_VERSION}" \ + --publish-tag "${PACKAGE_TAG}" \ + --registry "${CLAWHUB_REGISTRY}" diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index bba2b8f2352f..a22137565e62 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -1,5 +1,5 @@ name: Plugin NPM Release -run-name: ${{ github.event_name == 'workflow_dispatch' && format('Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref) || format('Plugin NPM Release [default] {0}', github.sha) }} +run-name: ${{ github.event_name == 'workflow_dispatch' && (inputs.preflight_only && format('Plugin NPM Preflight [{0}] {1}', inputs.npm_dist_tag, inputs.ref) || format('Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref)) || format('Plugin NPM Release [default] {0}', github.sha) }} on: push: @@ -12,7 +12,9 @@ on: - "scripts/lib/npm-publish-plan.mjs" - "scripts/lib/plugin-npm-package-manifest.mjs" - "scripts/lib/plugin-npm-release.ts" + - "scripts/lib/actions-artifact-archive.mjs" - "scripts/plugin-npm-publish.sh" + - "scripts/plugin-publication-artifact.mjs" - "scripts/plugin-npm-release-check.ts" - "scripts/plugin-npm-release-plan.ts" - "scripts/verify-plugin-npm-published-runtime.mjs" @@ -27,7 +29,7 @@ on: - selected - all-publishable ref: - description: Commit SHA on main, a release branch, the canonical extended-stable branch, or the matching Tideclaw alpha branch to publish from + description: Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches required: true type: string plugins: @@ -38,6 +40,11 @@ on: description: Approved OpenClaw Release Publish workflow run id required: false type: string + preflight_only: + description: Prepare and verify immutable plugin npm artifacts without publishing + required: true + default: false + type: boolean npm_dist_tag: description: Optional npm dist-tag override required: true @@ -64,6 +71,8 @@ jobs: ref_revision: ${{ steps.ref.outputs.sha }} has_candidates: ${{ steps.plan.outputs.has_candidates }} candidate_count: ${{ steps.plan.outputs.candidate_count }} + has_selection: ${{ steps.plan.outputs.has_selection }} + selection_count: ${{ steps.plan.outputs.selection_count }} matrix: ${{ steps.plan.outputs.matrix }} all_matrix: ${{ steps.plan.outputs.all_matrix }} steps: @@ -74,12 +83,6 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} fetch-depth: 0 - - name: Setup Node environment - uses: ./.github/actions/setup-node-env - with: - node-version: ${{ env.NODE_VERSION }} - install-bun: "false" - - name: Resolve checked-out ref id: ref run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -87,12 +90,29 @@ jobs: - name: Validate ref is on a trusted publish branch env: NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }} + PREFLIGHT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }} PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + RELEASE_PUBLISH_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail + if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then + if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] || [[ ! "${WORKFLOW_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Plugin npm preflight must run from a trusted main workflow revision." >&2 + exit 1 + fi + if [[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]; then + echo "Plugin npm preflight requires ref to be the exact 40-character source SHA." >&2 + exit 1 + fi + if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" ]]; then + echo "Plugin npm preflight must not include release_publish_run_id." >&2 + exit 1 + fi + fi if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then if [[ "${PUBLISH_SCOPE}" != "all-publishable" || -n "${RELEASE_PLUGINS// }" ]]; then echo "Extended-stable plugin publication requires publish_scope=all-publishable without an explicit plugin list." >&2 @@ -102,7 +122,7 @@ jobs: echo "Extended-stable plugin publication requires ref to be the exact 40-character source SHA." >&2 exit 1 fi - package_version="$(node -p "require('./package.json').version")" + package_version="$(jq -er '.version | strings' package.json)" if [[ ! "${package_version}" =~ ^([0-9]{4})\.([1-9]|1[0-2])\.([1-9][0-9]*)$ ]] || (( 10#${BASH_REMATCH[3]:-0} < 33 )); then echo "Extended-stable plugin publication requires a final YYYY.M.PATCH version with PATCH >= 33." >&2 exit 1 @@ -120,6 +140,10 @@ jobs: git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main \ '+refs/heads/release/*:refs/remotes/origin/release/*' + if [[ "${PREFLIGHT_ONLY}" == "true" ]] && ! git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main; then + echo "Plugin npm preflight workflow revision is not reachable from main." >&2 + exit 1 + fi if git merge-base --is-ancestor HEAD origin/main; then exit 0 fi @@ -128,6 +152,10 @@ jobs: exit 0 fi done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) + if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then + echo "Plugin npm preflight target must be reachable from main or release/*." >&2 + exit 1 + fi if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then alpha_branch="${WORKFLOW_REF#refs/heads/}" git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" @@ -138,6 +166,12 @@ jobs: echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2 exit 1 + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + - name: Validate publishable plugin metadata env: PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} @@ -170,6 +204,7 @@ jobs: BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }} HEAD_REF: ${{ steps.ref.outputs.sha }} NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }} + PREFLIGHT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }} run: | set -euo pipefail mkdir -p .local @@ -191,20 +226,32 @@ jobs: cat .local/plugin-npm-release-plan.json candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)" + selection_count="$(jq -r '.all | length' .local/plugin-npm-release-plan.json)" has_candidates="false" + has_selection="false" if [[ "${candidate_count}" != "0" ]]; then has_candidates="true" fi + if [[ "${selection_count}" != "0" ]]; then + has_selection="true" + fi matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)" all_matrix_json="$(jq -c '.all' .local/plugin-npm-release-plan.json)" { echo "candidate_count=${candidate_count}" echo "has_candidates=${has_candidates}" + echo "selection_count=${selection_count}" + echo "has_selection=${has_selection}" echo "matrix=${matrix_json}" echo "all_matrix=${all_matrix_json}" } >> "$GITHUB_OUTPUT" + if [[ "${PREFLIGHT_ONLY}" == "true" && "${has_selection}" != "true" ]]; then + echo "Plugin npm preflight resolved no selected publishable packages." >&2 + exit 1 + fi + echo "Plugin release candidates:" jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json @@ -227,7 +274,7 @@ jobs: validate_release_publish_approval: name: Validate release publish approval needs: preview_plugins_npm - if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true' + if: github.event_name == 'workflow_dispatch' && !inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true' runs-on: ubuntu-latest permissions: actions: read @@ -263,14 +310,14 @@ jobs: preview_plugin_pack: needs: preview_plugins_npm - if: needs.preview_plugins_npm.outputs.has_candidates == 'true' + if: needs.preview_plugins_npm.outputs.has_candidates == 'true' || (github.event_name == 'workflow_dispatch' && inputs.preflight_only && needs.preview_plugins_npm.outputs.has_selection == 'true') runs-on: ubuntu-latest permissions: contents: read strategy: fail-fast: false matrix: - plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} + plugin: ${{ fromJson(github.event_name == 'workflow_dispatch' && inputs.preflight_only && needs.preview_plugins_npm.outputs.all_matrix || needs.preview_plugins_npm.outputs.matrix) }} steps: - name: Checkout uses: actions/checkout@v6 @@ -295,9 +342,658 @@ jobs: OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }} run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}" + - name: Prepare immutable npm preflight artifact + id: preflight_artifact + env: + ARTIFACT_NAME: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }} + EXTENSION_ID: ${{ matrix.plugin.extensionId }} + INSTALL_NPM_SPEC: ${{ matrix.plugin.installNpmSpec }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + REPOSITORY: ${{ github.repository }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + WORKFLOW_PATH: .github/workflows/plugin-npm-release.yml + WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }} + run: | + set -euo pipefail + artifact_dir="${RUNNER_TEMP}/${ARTIFACT_NAME}" + rm -rf "${artifact_dir}" + mkdir -p "${artifact_dir}" + pack_output="${RUNNER_TEMP}/${EXTENSION_ID}-npm-pack-output.txt" + pack_json="${artifact_dir}/npm-pack.json" + + OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD=0 \ + OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR="${artifact_dir}" \ + bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}" > "${pack_output}" + + node - "${pack_output}" "${pack_json}" <<'NODE' + const fs = require("node:fs"); + const raw = fs.readFileSync(process.argv[2], "utf8").trim(); + let pack; + for (let index = raw.lastIndexOf("["); index >= 0; index = raw.lastIndexOf("[", index - 1)) { + try { + const candidate = JSON.parse(raw.slice(index)); + if (Array.isArray(candidate)) { + pack = candidate; + break; + } + } catch { + // npm can print bundled-dependency summaries before its JSON result. + } + if (index === 0) { + break; + } + } + if (!pack) { + throw new Error("npm pack did not emit a JSON array."); + } + fs.writeFileSync(process.argv[3], `${JSON.stringify(pack, null, 2)}\n`); + NODE + + tarball_name="$( + node - "${pack_json}" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const pack = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + if (!Array.isArray(pack) || pack.length !== 1) { + throw new Error(`Expected one npm pack result, found ${Array.isArray(pack) ? pack.length : "non-array"}.`); + } + const filename = pack[0]?.filename; + if ( + typeof filename !== "string" || + filename.length === 0 || + filename.includes("\0") || + filename !== path.basename(filename) || + filename !== path.win32.basename(filename) || + !filename.endsWith(".tgz") + ) { + throw new Error(`Unsafe npm pack filename: ${JSON.stringify(filename)}.`); + } + process.stdout.write(filename); + NODE + )" + tarball_path="${artifact_dir}/${tarball_name}" + if [[ ! -f "${tarball_path}" ]]; then + echo "npm pack did not produce ${tarball_name}." >&2 + exit 1 + fi + + packed_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-packed-package.json" + packed_plugin_json="${RUNNER_TEMP}/${EXTENSION_ID}-packed-plugin.json" + tar -xOf "${tarball_path}" package/package.json > "${packed_package_json}" + tar -xOf "${tarball_path}" package/openclaw.plugin.json > "${packed_plugin_json}" + source_package_json="${PACKAGE_DIR}/package.json" + [[ -f "${source_package_json}" ]] || { + echo "Source package.json is missing: ${source_package_json}." >&2 + exit 1 + } + source_package_json_sha256="$(sha256sum "${source_package_json}" | awk '{print $1}')" + packed_package_json_sha256="$(sha256sum "${packed_package_json}" | awk '{print $1}')" + tarball_sha256="$(sha256sum "${tarball_path}" | awk '{print $1}')" + + ARTIFACT_DIR="${artifact_dir}" \ + PACK_JSON="${pack_json}" \ + PACKED_PACKAGE_JSON="${packed_package_json}" \ + PACKED_PLUGIN_JSON="${packed_plugin_json}" \ + PACKED_PACKAGE_JSON_SHA256="${packed_package_json_sha256}" \ + SOURCE_PACKAGE_JSON_SHA256="${source_package_json_sha256}" \ + TARBALL_NAME="${tarball_name}" \ + TARBALL_SHA256="${tarball_sha256}" \ + node <<'NODE' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const path = require("node:path"); + + function fail(message) { + throw new Error(message); + } + + const pack = JSON.parse(fs.readFileSync(process.env.PACK_JSON, "utf8")); + const packEntry = pack[0]; + const packageJson = JSON.parse(fs.readFileSync(process.env.PACKED_PACKAGE_JSON, "utf8")); + const pluginManifest = JSON.parse(fs.readFileSync(process.env.PACKED_PLUGIN_JSON, "utf8")); + const tarballPath = path.join(process.env.ARTIFACT_DIR, process.env.TARBALL_NAME); + const tarball = fs.readFileSync(tarballPath); + const repositoryUrl = + typeof packageJson.repository === "string" + ? packageJson.repository + : packageJson.repository?.url; + const actualIntegrity = `sha512-${crypto.createHash("sha512").update(tarball).digest("base64")}`; + const actualShasum = crypto.createHash("sha1").update(tarball).digest("hex"); + + if (packEntry.name !== process.env.PACKAGE_NAME || packageJson.name !== process.env.PACKAGE_NAME) { + fail(`Packed package name mismatch: expected ${process.env.PACKAGE_NAME}.`); + } + if ( + packEntry.version !== process.env.PACKAGE_VERSION || + packageJson.version !== process.env.PACKAGE_VERSION + ) { + fail(`Packed package version mismatch: expected ${process.env.PACKAGE_VERSION}.`); + } + if (packageJson.openclaw?.install?.npmSpec !== process.env.INSTALL_NPM_SPEC) { + fail(`Packed npm install route mismatch: expected ${process.env.INSTALL_NPM_SPEC}.`); + } + if (repositoryUrl !== "https://github.com/openclaw/openclaw") { + fail(`Packed repository route mismatch: ${JSON.stringify(repositoryUrl)}.`); + } + if (typeof pluginManifest.id !== "string" || pluginManifest.id.length === 0) { + fail("Packed plugin id is missing."); + } + if (packEntry.integrity !== actualIntegrity || packEntry.shasum !== actualShasum) { + fail("npm pack integrity metadata does not match the prepared tarball."); + } + + const manifest = { + schemaVersion: 1, + kind: "openclaw-plugin-npm-preflight", + mode: "preflight-only", + repository: process.env.REPOSITORY, + workflow: { + path: process.env.WORKFLOW_PATH, + ref: process.env.WORKFLOW_REF, + sha: process.env.WORKFLOW_SHA, + runId: process.env.RUN_ID, + runAttempt: Number(process.env.RUN_ATTEMPT), + }, + source: { + inputRef: process.env.SOURCE_REF, + sha: process.env.SOURCE_SHA, + trustPolicy: "workflow-main-and-target-main-or-release-ancestor", + }, + package: { + extensionId: process.env.EXTENSION_ID, + packageDir: process.env.PACKAGE_DIR, + name: process.env.PACKAGE_NAME, + version: process.env.PACKAGE_VERSION, + installNpmSpec: process.env.INSTALL_NPM_SPEC, + packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256, + publishTag: process.env.PUBLISH_TAG, + pluginId: pluginManifest.id, + repositoryUrl, + sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256, + }, + artifact: { + name: process.env.ARTIFACT_NAME, + tarballName: process.env.TARBALL_NAME, + sha256: process.env.TARBALL_SHA256, + npmIntegrity: actualIntegrity, + npmShasum: actualShasum, + }, + mutationCapabilities: { + npmPublish: false, + npmDistTag: false, + clawHub: false, + environmentApproval: false, + oidcWrite: false, + secretRead: false, + }, + }; + fs.writeFileSync( + path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + NODE + + echo "dir=${artifact_dir}" >> "$GITHUB_OUTPUT" + echo "name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT" + echo "package_json_sha256=${packed_package_json_sha256}" >> "$GITHUB_OUTPUT" + echo "sha256=${tarball_sha256}" >> "$GITHUB_OUTPUT" + echo "source_package_json_sha256=${source_package_json_sha256}" >> "$GITHUB_OUTPUT" + + - name: Upload immutable npm preflight artifact + id: upload_preflight_artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ steps.preflight_artifact.outputs.name }} + path: ${{ steps.preflight_artifact.outputs.dir }} + compression-level: 0 + if-no-files-found: error + overwrite: true + retention-days: 30 + + - name: Record npm preflight artifact attestation + env: + ARTIFACT_DIGEST: ${{ steps.upload_preflight_artifact.outputs.artifact-digest }} + ARTIFACT_NAME: ${{ steps.preflight_artifact.outputs.name }} + TARBALL_SHA256: ${{ steps.preflight_artifact.outputs.sha256 }} + run: | + { + echo "- Plugin npm preflight artifact: \`${ARTIFACT_NAME}\`" + echo "- Actions artifact digest: \`${ARTIFACT_DIGEST}\`" + echo "- Packed tarball SHA-256: \`${TARBALL_SHA256}\`" + } >> "$GITHUB_STEP_SUMMARY" + + verify_plugin_npm_preflight: + name: Preflight plugin npm package (${{ matrix.plugin.packageName }}) + needs: [preview_plugins_npm, preview_plugin_pack] + if: ${{ github.event_name == 'workflow_dispatch' && ((inputs.preflight_only && needs.preview_plugins_npm.outputs.has_selection == 'true') || (!inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true')) }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJson(inputs.preflight_only && needs.preview_plugins_npm.outputs.all_matrix || needs.preview_plugins_npm.outputs.matrix) }} + steps: + - name: Checkout trusted npm preflight tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + + - name: Download immutable npm preflight artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }} + path: ${{ runner.temp }}/plugin-npm-preflight-readback + + - name: Validate npm preflight artifact readback + id: publication_artifact + env: + ARTIFACT_DIR: ${{ runner.temp }}/plugin-npm-preflight-readback + ARTIFACT_NAME: plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }} + EXTENSION_ID: ${{ matrix.plugin.extensionId }} + GH_TOKEN: ${{ github.token }} + INSTALL_NPM_SPEC: ${{ matrix.plugin.installNpmSpec }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + REPOSITORY: ${{ github.repository }} + SOURCE_REF: ${{ inputs.ref }} + SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + WORKFLOW_PATH: .github/workflows/plugin-npm-release.yml + WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "${SOURCE_SHA}" + source_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-source-package.json" + git show "${SOURCE_SHA}:${PACKAGE_DIR}/package.json" > "${source_package_json}" + + artifacts_json="${RUNNER_TEMP}/${EXTENSION_ID}-artifacts.json" + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?per_page=100" | + jq -s '{artifacts: [.[].artifacts[]]}' > "${artifacts_json}" + artifact_count="$( + jq --arg name "${ARTIFACT_NAME}" \ + '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ + "${artifacts_json}" + )" + [[ "${artifact_count}" == "1" ]] || { + echo "Expected exactly one live package artifact named ${ARTIFACT_NAME}; found ${artifact_count}." >&2 + exit 1 + } + artifact_id="$( + jq -r --arg name "${ARTIFACT_NAME}" \ + '.artifacts[] | select(.name == $name and .expired == false) | .id' \ + "${artifacts_json}" + )" + artifact_digest="$( + jq -r --arg name "${ARTIFACT_NAME}" \ + '.artifacts[] | select(.name == $name and .expired == false) | .digest' \ + "${artifacts_json}" + )" + [[ "${artifact_id}" =~ ^[1-9][0-9]*$ && "${artifact_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "Package artifact identity is invalid." >&2 + exit 1 + } + + SOURCE_PACKAGE_JSON="${source_package_json}" \ + node <<'NODE' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const path = require("node:path"); + const { execFileSync } = require("node:child_process"); + + function fail(message) { + throw new Error(message); + } + + const artifactDir = process.env.ARTIFACT_DIR; + const manifestPath = path.join(artifactDir, "preflight-manifest.json"); + const packPath = path.join(artifactDir, "npm-pack.json"); + if (!fs.existsSync(manifestPath) || !fs.existsSync(packPath)) { + fail("Plugin npm preflight artifact is missing its manifest or npm pack metadata."); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const pack = JSON.parse(fs.readFileSync(packPath, "utf8")); + if (!Array.isArray(pack) || pack.length !== 1) { + fail("Plugin npm preflight artifact must contain exactly one npm pack result."); + } + const packEntry = pack[0]; + const tarballName = manifest.artifact?.tarballName; + if ( + typeof tarballName !== "string" || + tarballName.length === 0 || + tarballName.includes("\0") || + tarballName !== path.basename(tarballName) || + tarballName !== path.win32.basename(tarballName) || + !tarballName.endsWith(".tgz") + ) { + fail(`Unsafe preflight tarball name: ${JSON.stringify(tarballName)}.`); + } + const artifactFiles = fs.readdirSync(artifactDir).toSorted(); + const expectedFiles = ["npm-pack.json", "preflight-manifest.json", tarballName].toSorted(); + if (JSON.stringify(artifactFiles) !== JSON.stringify(expectedFiles)) { + fail(`Unexpected preflight artifact files: ${artifactFiles.join(", ")}.`); + } + + const expectedManifest = { + schemaVersion: 1, + kind: "openclaw-plugin-npm-preflight", + mode: "preflight-only", + repository: process.env.REPOSITORY, + workflow: { + path: process.env.WORKFLOW_PATH, + ref: process.env.WORKFLOW_REF, + sha: process.env.WORKFLOW_SHA, + runId: process.env.RUN_ID, + runAttempt: Number(process.env.RUN_ATTEMPT), + }, + source: { + inputRef: process.env.SOURCE_REF, + sha: process.env.SOURCE_SHA, + trustPolicy: "workflow-main-and-target-main-or-release-ancestor", + }, + mutationCapabilities: { + npmPublish: false, + npmDistTag: false, + clawHub: false, + environmentApproval: false, + oidcWrite: false, + secretRead: false, + }, + }; + for (const [key, value] of Object.entries(expectedManifest)) { + if (JSON.stringify(manifest[key]) !== JSON.stringify(value)) { + fail(`Preflight manifest ${key} mismatch.`); + } + } + if (manifest.artifact?.name !== process.env.ARTIFACT_NAME) { + fail("Preflight artifact name mismatch."); + } + + const tarballPath = path.join(artifactDir, tarballName); + if (!fs.existsSync(tarballPath)) { + fail(`Preflight tarball is missing: ${tarballName}.`); + } + const tarball = fs.readFileSync(tarballPath); + const sha256 = crypto.createHash("sha256").update(tarball).digest("hex"); + const npmIntegrity = `sha512-${crypto.createHash("sha512").update(tarball).digest("base64")}`; + const npmShasum = crypto.createHash("sha1").update(tarball).digest("hex"); + if ( + manifest.artifact.sha256 !== sha256 || + manifest.artifact.npmIntegrity !== npmIntegrity || + manifest.artifact.npmShasum !== npmShasum || + packEntry.integrity !== npmIntegrity || + packEntry.shasum !== npmShasum || + packEntry.filename !== tarballName || + packEntry.name !== process.env.PACKAGE_NAME || + packEntry.version !== process.env.PACKAGE_VERSION + ) { + fail("Preflight tarball digest or npm integrity readback mismatch."); + } + + const packedPackageJson = execFileSync("tar", [ + "-xOf", + tarballPath, + "package/package.json", + ]); + const sourcePackageJson = fs.readFileSync(process.env.SOURCE_PACKAGE_JSON); + const packedPackageJsonSha256 = crypto + .createHash("sha256") + .update(packedPackageJson) + .digest("hex"); + const sourcePackageJsonSha256 = crypto + .createHash("sha256") + .update(sourcePackageJson) + .digest("hex"); + const packageJson = JSON.parse(packedPackageJson.toString("utf8")); + const pluginManifest = JSON.parse( + execFileSync("tar", ["-xOf", tarballPath, "package/openclaw.plugin.json"], { + encoding: "utf8", + }), + ); + const repositoryUrl = + typeof packageJson.repository === "string" + ? packageJson.repository + : packageJson.repository?.url; + if ( + manifest.package.extensionId !== process.env.EXTENSION_ID || + manifest.package.packageDir !== process.env.PACKAGE_DIR || + packageJson.name !== process.env.PACKAGE_NAME || + packageJson.version !== process.env.PACKAGE_VERSION || + manifest.package.name !== process.env.PACKAGE_NAME || + manifest.package.version !== process.env.PACKAGE_VERSION || + packageJson.openclaw?.install?.npmSpec !== process.env.INSTALL_NPM_SPEC || + manifest.package.installNpmSpec !== process.env.INSTALL_NPM_SPEC || + manifest.package.publishTag !== process.env.PUBLISH_TAG || + repositoryUrl !== "https://github.com/openclaw/openclaw" || + typeof pluginManifest.id !== "string" || + pluginManifest.id.length === 0 || + manifest.package.pluginId !== pluginManifest.id || + manifest.package.repositoryUrl !== repositoryUrl || + manifest.package.packageJsonSha256 !== packedPackageJsonSha256 || + manifest.package.sourcePackageJsonSha256 !== sourcePackageJsonSha256 + ) { + fail("Packed plugin identity, package hashes, or install route changed during artifact readback."); + } + console.log( + `Verified immutable plugin npm preflight artifact ${process.env.ARTIFACT_NAME} (${sha256}).`, + ); + NODE + + tarball_name="$(jq -er '.artifact.tarballName' "${ARTIFACT_DIR}/preflight-manifest.json")" + npm_integrity="$(jq -er '.artifact.npmIntegrity | strings' "${ARTIFACT_DIR}/preflight-manifest.json")" + npm_shasum="$(jq -er '.artifact.npmShasum | strings' "${ARTIFACT_DIR}/preflight-manifest.json")" + packed_package_json_sha256="$(jq -er '.package.packageJsonSha256' "${ARTIFACT_DIR}/preflight-manifest.json")" + source_package_json_sha256="$(jq -er '.package.sourcePackageJsonSha256' "${ARTIFACT_DIR}/preflight-manifest.json")" + tarball_sha256="$(jq -er '.artifact.sha256' "${ARTIFACT_DIR}/preflight-manifest.json")" + { + echo "artifact_digest=${artifact_digest}" + echo "artifact_id=${artifact_id}" + echo "artifact_name=${ARTIFACT_NAME}" + echo "npm_integrity=${npm_integrity}" + echo "npm_shasum=${npm_shasum}" + echo "package_json_sha256=${packed_package_json_sha256}" + echo "source_package_json_sha256=${source_package_json_sha256}" + echo "tarball_name=${tarball_name}" + echo "tarball_path=${ARTIFACT_DIR}/${tarball_name}" + echo "tarball_sha256=${tarball_sha256}" + } >> "$GITHUB_OUTPUT" + + - name: Verify npm publication route readiness + id: publication_route + env: + EXPECTED_NPM_INTEGRITY: ${{ steps.publication_artifact.outputs.npm_integrity }} + EXPECTED_NPM_SHASUM: ${{ steps.publication_artifact.outputs.npm_shasum }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + run: | + set -euo pipefail + node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT" + import { + fetchNpmRegistryPackumentWithRetry, + resolveNpmPublishPlan, + resolvePublishedNpmVersionRoute, + } from "./scripts/lib/npm-publish-plan.mjs"; + + const packageName = process.env.PACKAGE_NAME; + const packageVersion = process.env.PACKAGE_VERSION; + const publishTag = process.env.PUBLISH_TAG; + const expectedIntegrity = process.env.EXPECTED_NPM_INTEGRITY; + const expectedShasum = process.env.EXPECTED_NPM_SHASUM; + if ( + !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(expectedIntegrity ?? "") || + !/^[0-9a-f]{40}$/u.test(expectedShasum ?? "") + ) { + throw new Error(`${packageName}: verified preflight npm identity is invalid.`); + } + const packageUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`; + const requestAttempts = 3; + const requestTimeoutMs = 20_000; + + const observations = []; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const registryFetch = await fetchNpmRegistryPackumentWithRetry({ + packageName, + packageUrl, + attempts: requestAttempts, + timeoutMs: requestTimeoutMs, + }); + if (registryFetch.status === 404) { + observations.push("npm-token-bootstrap"); + } else if (registryFetch.ok) { + const packument = registryFetch.packument; + if (!packument || typeof packument !== "object" || Array.isArray(packument)) { + throw new Error(`${packageName}: npm registry packument is not an object.`); + } + const versions = Object.keys(packument.versions ?? {}); + const targetPublished = versions.includes(packageVersion); + const priorVersions = versions.filter((version) => version !== packageVersion); + if (!targetPublished && priorVersions.length > 0) { + observations.push("npm-oidc"); + } else if (targetPublished) { + const targetDist = packument.versions?.[packageVersion]?.dist; + if ( + targetDist?.integrity !== expectedIntegrity || + targetDist?.shasum !== expectedShasum + ) { + throw new Error( + `${packageName}@${packageVersion}: npm registry tarball identity does not match the verified preflight artifact; refusing published-version route.`, + ); + } + const publishTagOverride = + publishTag === "extended-stable" ? "extended-stable" : undefined; + const publishPlan = resolveNpmPublishPlan( + packageVersion, + packument["dist-tags"]?.beta, + publishTagOverride, + ); + if (publishPlan.publishTag !== publishTag) { + throw new Error( + `${packageName}: package release plan resolved ${publishPlan.publishTag}, expected ${publishTag}.`, + ); + } + observations.push( + resolvePublishedNpmVersionRoute({ + packageVersion, + publishPlan, + distTags: packument["dist-tags"] ?? {}, + }), + ); + } else { + throw new Error( + `${packageName}: npm registry publication history is inconsistent.`, + ); + } + } else { + throw new Error( + `${packageName}: npm publication-route probe returned HTTP ${registryFetch.status}.`, + ); + } + if (attempt !== 3) { + await new Promise((resolve) => setTimeout(resolve, attempt * 1000)); + } + } + if (new Set(observations).size !== 1) { + throw new Error( + `${packageName}: npm publication route changed during preflight: ${observations.join(", ")}.`, + ); + } + console.log(`route=${observations[0]}`); + NODE + + - name: Create immutable plugin npm publication evidence + id: preflight_evidence + env: + ARTIFACT_NAME_PREFIX: plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }} + EXTENSION_ID: ${{ matrix.plugin.extensionId }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + PUBLISH_ROUTE: ${{ steps.publication_route.outputs.route }} + SOURCE_PACKAGE_JSON_SHA256: ${{ steps.publication_artifact.outputs.source_package_json_sha256 }} + TARBALL_NAME: ${{ steps.publication_artifact.outputs.tarball_name }} + TARBALL_PATH: ${{ steps.publication_artifact.outputs.tarball_path }} + TARGET_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + run: | + set -euo pipefail + output_dir="${RUNNER_TEMP}/plugin-npm-preflight/evidence" + rm -rf "$output_dir" + install -d -m 0700 "$output_dir" + install -m 0600 "$TARBALL_PATH" "$output_dir/$TARBALL_NAME" + artifact_name="${ARTIFACT_NAME_PREFIX}-${PUBLISH_ROUTE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + policy_sha256="$(sha256sum .github/workflows/plugin-npm-release.yml | awk '{print $1}')" + node scripts/plugin-publication-artifact.mjs create \ + --artifact-dir "$output_dir" \ + --artifact-name "$artifact_name" \ + --package-dir "$PACKAGE_DIR" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --publication-reason "Stable npm registry preflight selected ${PUBLISH_ROUTE}." \ + --publish-tag "$PUBLISH_TAG" \ + --publisher-policy-id plugin-npm-release-workflow \ + --publisher-policy-schema openclaw.plugin-npm-publisher-policy/v1 \ + --publisher-policy-sha256 "$policy_sha256" \ + --route "$PUBLISH_ROUTE" \ + --source-package-json-sha256 "$SOURCE_PACKAGE_JSON_SHA256" \ + --target-sha "$TARGET_SHA" + evidence_count="$(find "$output_dir" -maxdepth 1 -type f | wc -l | tr -d ' ')" + [[ "$evidence_count" == "2" ]] || { + echo "Plugin npm preflight evidence must contain exactly two files." >&2 + exit 1 + } + echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" + echo "artifact_path=$output_dir" >> "$GITHUB_OUTPUT" + + - name: Upload immutable plugin npm preflight evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ steps.preflight_evidence.outputs.artifact_name }} + path: ${{ steps.preflight_evidence.outputs.artifact_path }}/* + if-no-files-found: error + retention-days: 30 + + - name: Record npm preflight summary + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_ROUTE: ${{ steps.publication_route.outputs.route }} + TARBALL_SHA256: ${{ steps.publication_artifact.outputs.tarball_sha256 }} + run: | + { + echo "## Plugin npm validation-only proof" + echo + echo "- Package: \`${PACKAGE_NAME}@${PACKAGE_VERSION}\`" + echo "- Route: \`${PUBLISH_ROUTE}\`" + echo "- Tarball SHA-256: \`${TARBALL_SHA256}\`" + echo "- Publication: **not attempted**" + } >> "$GITHUB_STEP_SUMMARY" + publish_plugins_npm: - needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval] - if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true' + needs: + [ + preview_plugins_npm, + preview_plugin_pack, + verify_plugin_npm_preflight, + validate_release_publish_approval, + ] + if: github.event_name == 'workflow_dispatch' && !inputs.preflight_only && needs.preview_plugins_npm.outputs.has_candidates == 'true' runs-on: ubuntu-latest environment: npm-release permissions: @@ -309,64 +1005,389 @@ jobs: matrix: plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout trusted publication tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + ref: ${{ github.workflow_sha }} fetch-depth: 1 - - name: Setup Node environment + - name: Setup trusted Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Consume immutable npm publication evidence + id: publication_evidence + env: + ARTIFACT_NAME_PREFIX: plugin-npm-package-${{ matrix.plugin.extensionId }}-${{ matrix.plugin.version }} + EXTENSION_ID: ${{ matrix.plugin.extensionId }} + GH_TOKEN: ${{ github.token }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + TARGET_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + WORKFLOW_HEAD_BRANCH: ${{ github.ref_name }} + WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + artifacts_json="${RUNNER_TEMP}/${EXTENSION_ID}-publication-artifacts.json" + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" | + jq -s '{artifacts: [.[].artifacts[]]}' > "$artifacts_json" + jq \ + --arg prefix "${ARTIFACT_NAME_PREFIX}-" \ + --arg run_id "$GITHUB_RUN_ID" \ + --argjson consumer_attempt "$GITHUB_RUN_ATTEMPT" \ + '[ + .artifacts[] + | select(.expired == false and (.name | startswith($prefix))) + | ( + try ( + .name + | ltrimstr($prefix) + | capture("^(?npm-(?:oidc|token-bootstrap|mirror|tag-repair|readback))-(?[1-9][0-9]*)-(?[1-9][0-9]*)$") + ) catch null + ) as $binding + | select( + $binding != null and + $binding.run_id == $run_id and + ($binding.attempt | tonumber) <= $consumer_attempt + ) + | . + { + producer_attempt: ($binding.attempt | tonumber), + publish_route: $binding.route + } + ] + | sort_by(.producer_attempt) + | if length == 0 then + [] + else + (last.producer_attempt) as $latest_attempt + | map(select(.producer_attempt == $latest_attempt)) + end' \ + "$artifacts_json" > "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json" + artifact_count="$(jq 'length' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + [[ "$artifact_count" == "1" ]] || { + echo "Expected one current or prior immutable publication artifact for ${PACKAGE_NAME}@${PACKAGE_VERSION}; found ${artifact_count}." >&2 + exit 1 + } + artifact_metadata="${RUNNER_TEMP}/${EXTENSION_ID}-publication-artifact.json" + jq '.[0] | del(.producer_attempt, .publish_route)' \ + "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json" > "$artifact_metadata" + artifact_name="$(jq -er '.name' "$artifact_metadata")" + publish_route="$(jq -er '.[0].publish_route' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + producer_attempt="$(jq -er '.[0].producer_attempt' "${RUNNER_TEMP}/${EXTENSION_ID}-publication-matches.json")" + [[ "$publish_route" =~ ^npm-(oidc|token-bootstrap|mirror|tag-repair|readback)$ ]] || { + echo "Unsupported immutable npm publication route: ${publish_route}." >&2 + exit 1 + } + if [[ "$publish_route" == "npm-token-bootstrap" ]]; then + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$WORKFLOW_REF" != "refs/heads/main" && "$WORKFLOW_REF" != "refs/heads/release-publish/${WORKFLOW_SHA}" ]]; then + echo "npm token bootstrap requires trusted main or immutable release-publish/ tooling." >&2 + exit 1 + fi + git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main || { + echo "npm token bootstrap workflow revision is not reachable from main." >&2 + exit 1 + } + fi + artifact_id="$(jq -er '.id' "$artifact_metadata")" + artifact_digest="$(jq -er '.digest' "$artifact_metadata")" + artifact_size="$(jq -er '.size_in_bytes' "$artifact_metadata")" + [[ "$artifact_id" =~ ^[1-9][0-9]*$ && "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ && "$artifact_size" =~ ^[1-9][0-9]*$ ]] || { + echo "Immutable npm publication artifact metadata is invalid." >&2 + exit 1 + } + (( artifact_size <= 268435456 )) || { + echo "Immutable npm publication artifact exceeds 256 MiB." >&2 + exit 1 + } + + workflow_run="${RUNNER_TEMP}/${EXTENSION_ID}-publication-run.json" + workflow_jobs="${RUNNER_TEMP}/${EXTENSION_ID}-publication-jobs.json" + artifact_zip="${RUNNER_TEMP}/${EXTENSION_ID}-publication.zip" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${producer_attempt}" \ + > "$workflow_run" + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${producer_attempt}/jobs?per_page=100" | + jq -s '{total_count: ([.[].jobs[]] | length), jobs: [.[].jobs[]]}' > "$workflow_jobs" + curl --fail --location --silent --show-error \ + --retry 2 \ + --retry-all-errors \ + --max-time 120 \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" \ + -o "$artifact_zip" + + git fetch --no-tags --depth=1 origin "$TARGET_SHA" + source_package_json="${RUNNER_TEMP}/${EXTENSION_ID}-source-package.json" + git show "${TARGET_SHA}:${PACKAGE_DIR}/package.json" > "$source_package_json" + source_package_json_sha256="$(sha256sum "$source_package_json" | awk '{print $1}')" + policy_sha256="$(sha256sum .github/workflows/plugin-npm-release.yml | awk '{print $1}')" + output_dir="${RUNNER_TEMP}/${EXTENSION_ID}-verified-publication" + node scripts/plugin-publication-artifact.mjs verify \ + --artifact-digest "$artifact_digest" \ + --artifact-id "$artifact_id" \ + --artifact-metadata "$artifact_metadata" \ + --artifact-name "$artifact_name" \ + --artifact-size-bytes "$artifact_size" \ + --artifact-zip "$artifact_zip" \ + --consumer-run-attempt "$GITHUB_RUN_ATTEMPT" \ + --github-output "$GITHUB_OUTPUT" \ + --output-dir "$output_dir" \ + --package-dir "$PACKAGE_DIR" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --producer-job-name "Preflight plugin npm package (${PACKAGE_NAME})" \ + --producer-run-attempt "$producer_attempt" \ + --producer-run-id "$GITHUB_RUN_ID" \ + --publication-reason "Stable npm registry preflight selected ${publish_route}." \ + --publish-tag "$PUBLISH_TAG" \ + --publisher-policy-id plugin-npm-release-workflow \ + --publisher-policy-schema openclaw.plugin-npm-publisher-policy/v1 \ + --publisher-policy-sha256 "$policy_sha256" \ + --repository "$GITHUB_REPOSITORY" \ + --route "$publish_route" \ + --run-state-policy same-run-producer-success \ + --source-package-json-sha256 "$source_package_json_sha256" \ + --target-sha "$TARGET_SHA" \ + --workflow-event workflow_dispatch \ + --workflow-head-branch "$WORKFLOW_HEAD_BRANCH" \ + --workflow-jobs-metadata "$workflow_jobs" \ + --workflow-path .github/workflows/plugin-npm-release.yml \ + --workflow-run-metadata "$workflow_run" \ + --workflow-sha "$WORKFLOW_SHA" + source_package_dir="${RUNNER_TEMP}/${EXTENSION_ID}-verified-source-package" + rm -rf "$source_package_dir" + mkdir -p "$source_package_dir" + cp "$source_package_json" "$source_package_dir/package.json" + chmod 0444 "$source_package_dir/package.json" + echo "source_package_dir=$source_package_dir" >> "$GITHUB_OUTPUT" + + - name: Authorize immutable publication route + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PUBLISH_ROUTE: ${{ steps.publication_evidence.outputs.publish_route }} + run: | + set -euo pipefail + case "$PUBLISH_ROUTE" in + npm-oidc | npm-token-bootstrap | npm-readback) + ;; + *) + echo "${PACKAGE_NAME}: route ${PUBLISH_ROUTE} requires credential-isolated repair tooling, not this publish path." >&2 + exit 1 + ;; + esac + + - name: Setup trusted publication dependencies + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' || steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' || steps.publication_evidence.outputs.publish_route == 'npm-readback' uses: ./.github/actions/setup-node-env with: node-version: ${{ env.NODE_VERSION }} install-bun: "false" - - name: Check npm package version + - name: Check OIDC npm package version id: npm_package_version + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' env: + EXPECTED_NPM_INTEGRITY: ${{ steps.publication_evidence.outputs.npm_integrity }} + EXPECTED_NPM_SHASUM: ${{ steps.publication_evidence.outputs.npm_shasum }} PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} run: | set -euo pipefail - if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + if package_json="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" --json 2>/dev/null)"; then + actual_integrity="$(printf '%s' "$package_json" | jq -er '.dist.integrity | strings')" + actual_shasum="$(printf '%s' "$package_json" | jq -er '.dist.shasum | strings')" + if [[ "$actual_integrity" != "$EXPECTED_NPM_INTEGRITY" || "$actual_shasum" != "$EXPECTED_NPM_SHASUM" ]]; then + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} exists with registry bytes that do not match immutable publication evidence." >&2 + exit 1 + fi echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm." echo "already_published=true" >> "$GITHUB_OUTPUT" else echo "already_published=false" >> "$GITHUB_OUTPUT" fi - - name: Publish - if: steps.npm_package_version.outputs.already_published != 'true' + - name: Publish with trusted publisher + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' && steps.npm_package_version.outputs.already_published != 'true' env: - NODE_AUTH_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }} - NPM_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }} OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher + OPENCLAW_PLUGIN_NPM_DEFER_DIST_TAG_MIRRORS: "1" OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }} SOURCE_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} + SOURCE_PACKAGE_DIR: ${{ steps.publication_evidence.outputs.source_package_dir }} + TARBALL_PATH: ${{ steps.publication_evidence.outputs.tarball_path }} + TARBALL_SHA256: ${{ steps.publication_evidence.outputs.tarball_sha256 }} WORKFLOW_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail if [[ "${OPENCLAW_PLUGIN_NPM_PUBLISH_TAG}" == "extended-stable" ]]; then git fetch --no-tags origin "+refs/heads/${WORKFLOW_BRANCH}:refs/remotes/origin/${WORKFLOW_BRANCH}" current_tip="$(git rev-parse "refs/remotes/origin/${WORKFLOW_BRANCH}^{commit}")" - checkout_sha="$(git rev-parse HEAD)" - if [[ "$checkout_sha" != "$SOURCE_SHA" || "$current_tip" != "$SOURCE_SHA" ]]; then - echo "Refusing stale extended-stable plugin publish: checkout=${checkout_sha}, current branch tip=${current_tip}, authorized=${SOURCE_SHA}." >&2 + if [[ "$current_tip" != "$SOURCE_SHA" ]]; then + echo "Refusing stale extended-stable plugin publish: current branch tip=${current_tip}, authorized=${SOURCE_SHA}." >&2 exit 1 fi fi - bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" + actual_tarball_sha256="$(sha256sum "$TARBALL_PATH" | awk '{print $1}')" + if [[ "$actual_tarball_sha256" != "$TARBALL_SHA256" ]]; then + echo "Verified plugin npm tarball changed before publish: expected ${TARBALL_SHA256}, got ${actual_tarball_sha256}." >&2 + exit 1 + fi + bash scripts/plugin-npm-publish.sh --publish "$SOURCE_PACKAGE_DIR" "$TARBALL_PATH" - - name: Verify published runtime + - name: Verify OIDC published runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' env: PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + - name: Repair required npm mirrors + if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PUBLISH_TAG: ${{ steps.publication_evidence.outputs.publish_tag }} + run: | + set -euo pipefail + current_beta_version="$(npm view "${PACKAGE_NAME}" dist-tags.beta 2>/dev/null || true)" + mirror_dist_tags="$({ + PACKAGE_VERSION="$PACKAGE_VERSION" \ + CURRENT_BETA_VERSION="$current_beta_version" \ + PUBLISH_TAG="$PUBLISH_TAG" \ + node --input-type=module <<'NODE' + import { resolveNpmPublishPlan } from "./scripts/lib/npm-publish-plan.mjs"; + + const override = process.env.PUBLISH_TAG === "extended-stable" ? "extended-stable" : undefined; + const plan = resolveNpmPublishPlan( + process.env.PACKAGE_VERSION ?? "", + process.env.CURRENT_BETA_VERSION, + override, + ); + if (plan.publishTag !== process.env.PUBLISH_TAG) { + throw new Error(`Resolved publish tag ${plan.publishTag}, expected ${process.env.PUBLISH_TAG}.`); + } + process.stdout.write(plan.mirrorDistTags.join("\n")); + NODE + })" + if [[ -z "$mirror_dist_tags" ]]; then + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} requires no npm dist-tag mirror repair." + exit 0 + fi + if [[ "$mirror_dist_tags" != "beta" ]]; then + echo "Unexpected npm dist-tag mirror plan for ${PACKAGE_NAME}@${PACKAGE_VERSION}: ${mirror_dist_tags}." >&2 + exit 1 + fi + [[ -n "${NPM_TOKEN// }" ]] || { + echo "Stable npm beta mirror repair requires the protected npm release token." >&2 + exit 1 + } + repair_home="$(mktemp -d "${RUNNER_TEMP}/plugin-npm-mirror.XXXXXX")" + cleanup() { + rm -rf "$repair_home" + } + trap cleanup EXIT + umask 077 + npmrc="$repair_home/npmrc" + { + echo "registry=https://registry.npmjs.org/" + printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" + } > "$npmrc" + chmod 0600 "$npmrc" + unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS + HOME="$repair_home" \ + NPM_CONFIG_GLOBALCONFIG=/dev/null \ + NPM_CONFIG_IGNORE_SCRIPTS=true \ + NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ \ + NPM_CONFIG_USERCONFIG="$npmrc" \ + npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" beta + + - name: Publish approved Meta bootstrap tarball + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + PUBLISH_TAG: ${{ steps.publication_evidence.outputs.publish_tag }} + TARBALL_PATH: ${{ steps.publication_evidence.outputs.tarball_path }} + run: | + set -euo pipefail + [[ "$PACKAGE_NAME" == "@openclaw/meta-provider" && "$PACKAGE_DIR" == "extensions/meta" ]] || { + echo "npm token bootstrap is restricted to the approved Meta provider package." >&2 + exit 1 + } + [[ "$PACKAGE_VERSION" == *"-beta."* && "$PUBLISH_TAG" == "beta" ]] || { + echo "Meta npm token bootstrap requires an approved beta package and beta tag." >&2 + exit 1 + } + [[ -n "${NPM_TOKEN// }" ]] || { + echo "Meta npm token bootstrap requires the protected npm release token." >&2 + exit 1 + } + publish_home="$(mktemp -d "${RUNNER_TEMP}/meta-npm-bootstrap.XXXXXX")" + cleanup() { + rm -rf "$publish_home" + } + trap cleanup EXIT + umask 077 + npmrc="$publish_home/npmrc" + { + echo "registry=https://registry.npmjs.org/" + printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" + } > "$npmrc" + chmod 0600 "$npmrc" + unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS + HOME="$publish_home" \ + NPM_CONFIG_GLOBALCONFIG=/dev/null \ + NPM_CONFIG_IGNORE_SCRIPTS=true \ + NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ \ + NPM_CONFIG_USERCONFIG="$npmrc" \ + npm publish "$TARBALL_PATH" \ + --access public \ + --ignore-scripts \ + --provenance \ + --tag "$PUBLISH_TAG" + + - name: Verify Meta bootstrap published runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + + - name: Verify immutable npm readback runtime + if: steps.publication_evidence.outputs.publish_route == 'npm-readback' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} + run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}" + + - name: Record Meta trusted publisher checkpoint + if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' + env: + PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} + run: | + { + echo "## Meta npm bootstrap follow-up" + echo + echo "- Published \`${PACKAGE_NAME}\` from the verified immutable tarball." + echo "- Configure the GitHub trusted publisher for \`plugin-npm-release.yml\` and environment \`npm-release\` before the next OIDC publish." + } >> "$GITHUB_STEP_SUMMARY" + verify_plugins_npm: needs: [preview_plugins_npm, publish_plugins_npm] - if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag == 'extended-stable' && needs.preview_plugins_npm.result == 'success' && (needs.publish_plugins_npm.result == 'success' || (needs.preview_plugins_npm.outputs.has_candidates == 'false' && needs.publish_plugins_npm.result == 'skipped')) }} + if: ${{ always() && github.event_name == 'workflow_dispatch' && !inputs.preflight_only && inputs.npm_dist_tag == 'extended-stable' && needs.preview_plugins_npm.result == 'success' && (needs.publish_plugins_npm.result == 'success' || (needs.preview_plugins_npm.outputs.has_candidates == 'false' && needs.publish_plugins_npm.result == 'skipped')) }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index 5f5a3d35eaa1..0877d42fd1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -114,27 +114,49 @@ USER.md .crabbox/ # local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts -mantis/ +/mantis/ # Local project-agent skill installs. Only repo-owned skills are visible by -# default; promoting a new repo skill should require an intentional `git add -f`. +# default; keep every tracked repo skill allowlisted for Git-aware sync tools. .agents/skills/* !.agents/skills/blacksmith-testbox/ !.agents/skills/blacksmith-testbox/** -!.agents/skills/crabbox/ -!.agents/skills/crabbox/** +!.agents/skills/agent-transcript/ +!.agents/skills/agent-transcript/** +!.agents/skills/autoreview/ +!.agents/skills/autoreview/** +!.agents/skills/channel-message-flows/ +!.agents/skills/channel-message-flows/** +!.agents/skills/claw-score/ +!.agents/skills/claw-score/** !.agents/skills/clawdtributor/ !.agents/skills/clawdtributor/** +!.agents/skills/clawsweeper/ +!.agents/skills/clawsweeper/** !.agents/skills/control-ui-e2e/ !.agents/skills/control-ui-e2e/** +!.agents/skills/crabbox/ +!.agents/skills/crabbox/** +!.agents/skills/discord-clawd/ +!.agents/skills/discord-clawd/** +!.agents/skills/discord-user-post/ +!.agents/skills/discord-user-post/** +!.agents/skills/discrawl/ +!.agents/skills/discrawl/** !.agents/skills/gitcrawl/ !.agents/skills/gitcrawl/** -!.agents/skills/technical-documentation/ -!.agents/skills/technical-documentation/** -!.agents/skills/openclaw-refactor-docs/ -!.agents/skills/openclaw-refactor-docs/** +!.agents/skills/graincrawl/ +!.agents/skills/graincrawl/** +!.agents/skills/notcrawl/ +!.agents/skills/notcrawl/** +!.agents/skills/openclaw-changelog-update/ +!.agents/skills/openclaw-changelog-update/** +!.agents/skills/openclaw-ci-limits/ +!.agents/skills/openclaw-ci-limits/** !.agents/skills/openclaw-debugging/ !.agents/skills/openclaw-debugging/** +!.agents/skills/openclaw-docker-e2e-authoring/ +!.agents/skills/openclaw-docker-e2e-authoring/** !.agents/skills/openclaw-ghsa-maintainer/ !.agents/skills/openclaw-ghsa-maintainer/** !.agents/skills/openclaw-landable-bug-sweep/ @@ -143,32 +165,47 @@ mantis/ !.agents/skills/openclaw-parallels-smoke/** !.agents/skills/openclaw-pr-maintainer/ !.agents/skills/openclaw-pr-maintainer/** -!.agents/skills/openclaw-refactor-docs/ -!.agents/skills/openclaw-refactor-docs/** !.agents/skills/openclaw-qa-testing/ !.agents/skills/openclaw-qa-testing/** -!.agents/skills/openclaw-release-ci/ -!.agents/skills/openclaw-release-ci/** -!.agents/skills/openclaw-release-maintainer/ -!.agents/skills/openclaw-release-maintainer/** +!.agents/skills/openclaw-refactor-docs/ +!.agents/skills/openclaw-refactor-docs/** !.agents/skills/openclaw-secret-scanning-maintainer/ !.agents/skills/openclaw-secret-scanning-maintainer/** +!.agents/skills/openclaw-small-bugfix-sweep/ +!.agents/skills/openclaw-small-bugfix-sweep/** !.agents/skills/openclaw-test-heap-leaks/ !.agents/skills/openclaw-test-heap-leaks/** !.agents/skills/openclaw-test-performance/ !.agents/skills/openclaw-test-performance/** !.agents/skills/openclaw-testing/ !.agents/skills/openclaw-testing/** -!.agents/skills/optimizetests/ -!.agents/skills/optimizetests/** !.agents/skills/parallels-discord-roundtrip/ !.agents/skills/parallels-discord-roundtrip/** +!.agents/skills/release-openclaw-announcement/ +!.agents/skills/release-openclaw-announcement/** +!.agents/skills/release-openclaw-ci/ +!.agents/skills/release-openclaw-ci/** +!.agents/skills/release-openclaw-mac/ +!.agents/skills/release-openclaw-mac/** +!.agents/skills/release-openclaw-maintainer/ +!.agents/skills/release-openclaw-maintainer/** +!.agents/skills/release-openclaw-nightly/ +!.agents/skills/release-openclaw-nightly/** +!.agents/skills/release-openclaw-plugin-testing/ +!.agents/skills/release-openclaw-plugin-testing/** !.agents/skills/security-triage/ !.agents/skills/security-triage/** +!.agents/skills/slacrawl/ +!.agents/skills/slacrawl/** !.agents/skills/tag-duplicate-prs-issues/ !.agents/skills/tag-duplicate-prs-issues/** -!.agents/skills/autoreview/ -!.agents/skills/autoreview/** +!.agents/skills/technical-documentation/ +!.agents/skills/technical-documentation/** +!.agents/skills/telegram-crabbox-e2e-proof/ +!.agents/skills/telegram-crabbox-e2e-proof/** +!.agents/skills/verify-release/ +!.agents/skills/verify-release/** +.agents/skills/**/*.orig .agents/skills/**/__pycache__/ .agents/skills/**/*.py[cod] @@ -179,6 +216,7 @@ mantis/ /local/ /client_secret_*.json package-lock.json +!.github/release/clawhub-cli/package-lock.json !src/commands/copilot-sdk-install-manifest/package-lock.json .claude/ .agent/ @@ -210,7 +248,6 @@ dist/protocol.schema.json .dev-state docs/superpowers .superpowers/ -.gitignore test/config-form.analyze.telegram.test.ts ui/src/ui/theme-variants.browser.test.ts ui/src/ui/__screenshots__ diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index cab5508644ff..6e1c4dda02dc 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -023ae47864a8ece491aa3f3d6ea9860c825e169b5ce98abdc2733d1f5c6154c8 config-baseline.json -47db85eae8797328e4061b382d8b360032ffd523f13dd361a7ff3138dc419f34 config-baseline.core.json +3937d8f4668f098ae851404450a674909f0a7ded13d3838d89b30f2ed01abdd0 config-baseline.json +836bbb91a51de6ca6e02a936ff70df21dd8c6c658452307e5488fd5816002371 config-baseline.core.json 1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json diff --git a/docs/ci.md b/docs/ci.md index 35dc62a3fc38..2ffa67643a38 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -203,7 +203,7 @@ stage matrix, exact workflow job names, profile differences, artifacts, and focused rerun handles. `OpenClaw Release Publish` is the manual mutating release workflow. Dispatch it -from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the +from `main` after the release tag exists and after the OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`, dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches `Plugin ClawHub Release` for the same release SHA, and only then dispatches @@ -216,10 +216,12 @@ and checksum contract before publishing the GitHub release draft. ```bash gh workflow run openclaw-release-publish.yml \ - --ref release/YYYY.M.PATCH \ + --ref main \ -f tag=vYYYY.M.PATCH-beta.N \ -f preflight_run_id= \ + -f preflight_run_attempt= \ -f full_release_validation_run_id= \ + -f full_release_validation_run_attempt= \ -f npm_dist_tag=beta ``` diff --git a/docs/cli/update.md b/docs/cli/update.md index fb2ce17f2df5..a8b5ad1b07b1 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -9,7 +9,7 @@ title: "Update" # `openclaw update` -Safely update OpenClaw and switch between stable/beta/dev channels. +Safely update OpenClaw and switch between stable/extended-stable/beta/dev channels. If you installed via **npm/pnpm/bun** (global install, no git metadata), updates happen via the package-manager flow in [Updating](/install/updating). @@ -21,6 +21,7 @@ openclaw update openclaw update status openclaw update repair openclaw update wizard +openclaw update --channel extended-stable openclaw update --channel beta openclaw update --channel dev openclaw update --tag beta @@ -35,8 +36,8 @@ openclaw --update ## Options - `--no-restart`: skip restarting the Gateway service after a successful update. Package-manager updates that do restart the Gateway verify the restarted service reports the expected updated version before the command succeeds. -- `--channel `: set the update channel (git + npm; persisted in config). -- `--tag `: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`; GitHub/git source specs are packed into a temporary tarball before the staged global npm install. +- `--channel `: set the update channel and persist it after core update success. Extended-stable is package-only. +- `--tag `: override the package target for this update only. It cannot be combined with an effective `extended-stable` channel, whose verified exact target is mandatory. For other package installs, `main` maps to `github:openclaw/openclaw#main`; GitHub/git source specs are packed into a temporary tarball before the staged global npm install. - `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting. - `--json`: print machine-readable `UpdateRunResult` JSON, including `postUpdate.plugins.warnings` when corrupt or unloadable managed plugins need @@ -66,6 +67,12 @@ Downgrades require confirmation because older versions can break configuration. Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability. +For extended-stable package installs, status performs the same public selector +and exact-package verification as foreground update. It can report +`ahead of extended-stable` when the installed version is newer. JSON failures +include `registry.reason` (`selector_missing`, `selector_query_failed`, +`exact_package_mismatch`, or `unsupported_git_channel`). + ```bash openclaw update status openclaw update status --json @@ -93,8 +100,10 @@ openclaw update repair --json Options: -- `--channel `: persist the update channel before repair and - run plugin convergence against that channel. +- `--channel `: persist the core update channel + before repair. For extended-stable, plugin convergence pins npm-publishable + official plugins to the exact installed core version. Extended-stable repair + is rejected on Git checkouts without changing config. - `--json`: print machine-readable finalization JSON. - `--timeout `: timeout for repair steps (default `1800`). - `--yes`: skip confirmation prompts. @@ -126,6 +135,9 @@ install method aligned: `OPENCLAW_HOME` is set; override with `OPENCLAW_GIT_DIR`), updates it, and installs the global CLI from that checkout. - `stable` → installs from npm using `latest`. +- `extended-stable` → resolves the public npm `extended-stable` selector, + verifies the exact selected package, and installs that exact version. It does + not fall back to another selector and is rejected for Git checkouts. - `beta` → prefers npm dist-tag `beta`, but falls back to `latest` when beta is missing or older than the current stable release. @@ -139,6 +151,13 @@ from outside the Gateway process tree. If that handoff is unavailable, `update.run` returns a structured response with the safe shell command to run manually. +Stored extended-stable selections receive read-only startup and 24-hour update +hints when `update.checkOnStart` is enabled. These checks never apply an update, +start a handoff, restart the Gateway, use stable delay/jitter, or use beta +polling cadence. Explicit foreground updates, bare foreground updates with +stored `update.channel: "extended-stable"`, on-demand status, and their managed +Gateway handoff remain supported. + For package-manager installs, `openclaw update` resolves the target package version before invoking the package manager. npm global installs use a staged install: OpenClaw installs the new package into a temporary npm prefix, verifies @@ -151,6 +170,12 @@ keeps packaged sidecars and channel-owned plugin records aligned with the installed OpenClaw build while leaving full plugin-command completion rebuilds to explicit `openclaw completion --write-state` runs. +After an extended-stable core update succeeds, post-core plugin integrity and +convergence still run. Npm-publishable official plugins use the exact installed +core version; OpenClaw does not query plugin `@extended-stable` selectors. This +overrides official npm pins/tags and converts trusted official ClawHub installs +to the exact npm version. Third-party plugin selections remain unchanged. + When a local managed Gateway service is installed and restart is enabled, package-manager and git-checkout updates stop the running service before replacing the package tree or mutating the checkout/build output. The updater @@ -212,6 +237,7 @@ returns the latest sentinel. - `stable`: checkout the latest non-beta tag, then build and doctor. - `beta`: prefer the latest `-beta` tag, but fall back to the latest stable tag when beta is missing or older. - `dev`: checkout `main`, then fetch and rebase. +- `extended-stable`: unsupported for Git checkouts; no checkout mutation occurs. ### Update steps diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index e5db8bebbf7e..820788f3f78d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1149,7 +1149,7 @@ Notes: ```json5 { update: { - channel: "stable", // stable | beta | dev + channel: "stable", // stable | extended-stable | beta | dev checkOnStart: true, auto: { @@ -1162,12 +1162,12 @@ Notes: } ``` -- `channel`: release channel for npm/git installs - `"stable"`, `"beta"`, or `"dev"`. -- `checkOnStart`: check for npm updates when the gateway starts (default: `true`). -- `auto.enabled`: enable background auto-update for package installs (default: `false`). +- `channel`: release channel - `"stable"`, `"extended-stable"`, `"beta"`, or `"dev"`. Extended-stable is package-only: foreground commands own installation, while the Gateway may emit read-only update hints. +- `checkOnStart`: check for npm updates when the gateway starts (default: `true`). Stored extended-stable selections use the same read-only hint and 24-hour hint schedule. +- `auto.enabled`: enable background auto-update for stable and beta package installs (default: `false`). Extended-stable never applies automatically. - `auto.stableDelayHours`: minimum delay in hours before stable-channel auto-apply (default: `6`; max: `168`). - `auto.stableJitterHours`: extra stable-channel rollout spread window in hours (default: `12`; max: `168`). -- `auto.betaCheckIntervalHours`: how often beta-channel checks run in hours (default: `1`; max: `24`). +- `auto.betaCheckIntervalHours`: how often beta-channel checks run in hours (default: `1`; max: `24`). Stable delay/jitter and beta polling settings do not apply to extended-stable. --- diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 51e8066c5366..efe77c94540c 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -482,7 +482,7 @@ and troubleshooting see the main [FAQ](/help/faq). ```bash openclaw update openclaw update status - openclaw update --channel stable|beta|dev + openclaw update --channel stable|extended-stable|beta|dev openclaw update --tag openclaw update --no-restart ``` diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index f75566e85d19..25536e407c11 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -158,8 +158,8 @@ older trusted releases. Candidate sources: -- `source=npm`: validate `openclaw@beta`, `openclaw@latest`, or an exact - published version. +- `source=npm`: validate `openclaw@extended-stable`, `openclaw@beta`, + `openclaw@latest`, or an exact published version. - `source=ref`: pack a trusted branch, tag, or commit with the selected current harness. - `source=url`: validate a public HTTPS tarball with required `package_sha256`. @@ -227,6 +227,10 @@ gh workflow run package-acceptance.yml \ -f telegram_mode=mock-openai ``` +For a published extended-stable canary, set +`package_spec=openclaw@extended-stable`. Package Acceptance resolves that +selector into an exact tarball before the Docker lanes run. + Use `suite_profile=product` when the release question includes MCP channels, cron/subagent cleanup, OpenAI web search, or OpenWebUI. Use `suite_profile=full` only when you need full Docker release-path coverage. diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index 0208d548531d..08692d15cda6 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -1,21 +1,25 @@ --- -summary: "Stable, beta, and dev channels: semantics, switching, pinning, and tagging" +summary: "Stable, extended-stable, beta, and dev channels: semantics, switching, pinning, and tagging" read_when: - - You want to switch between stable/beta/dev + - You want to switch between stable/extended-stable/beta/dev - You want to pin a specific version, tag, or SHA - You are tagging or publishing prereleases title: "Release channels" sidebarTitle: "Release Channels" --- -OpenClaw ships three update channels: +OpenClaw ships four update channels: - **stable**: npm dist-tag `latest`. Recommended for most users. -- **beta**: npm dist-tag `beta` when it is current; if beta is missing or older than - the latest stable release, the update flow falls back to `latest`. -- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published). - The `main` branch is for experimentation and active development. It may contain - incomplete features or breaking changes. Do not use it for production gateways. +- **extended-stable**: npm dist-tag `extended-stable`. A net-new, trailing + supported-month package channel. It is package-only, and installation is + foreground-only. A stored selection receives read-only update hints when + `update.checkOnStart` is enabled, but never applies automatically. +- **beta**: npm dist-tag `beta`. Falls back to `latest` when `beta` is missing + or older than the current stable release. +- **dev**: moving head of `main` (git). npm dist-tag `dev` when published. `main` + is for experimentation and active development; it may contain incomplete + features or breaking changes. Do not run it for production gateways. We usually ship stable builds to **beta** first, test them there, then run an explicit promotion step that moves the vetted build to `latest` without @@ -27,6 +31,7 @@ installs. ```bash openclaw update --channel stable +openclaw update --channel extended-stable openclaw update --channel beta openclaw update --channel dev ``` @@ -35,6 +40,10 @@ openclaw update --channel dev install method: - **`stable`** (package installs): updates via npm dist-tag `latest`. +- **`extended-stable`** (package installs only): resolves the public npm + `extended-stable` selector, verifies the exact selected package version, and + installs that exact version. Resolution fails closed with no fallback to + `latest`, `beta`, or `dev`. - **`beta`** (package installs): prefers npm dist-tag `beta`, but falls back to `latest` when `beta` is missing or older than the current stable tag. - **`stable`** (git installs): checks out the latest stable git tag, excluding @@ -43,6 +52,8 @@ install method: suffixes. - **`beta`** (git installs): prefers the latest beta git tag, but falls back to the latest stable git tag when beta is missing or older. +- **`extended-stable`** (git installs): unsupported. OpenClaw leaves the + checkout unchanged and asks you to use a package installation. - **`dev`**: ensures a git checkout (default `~/openclaw`, or `$OPENCLAW_HOME/openclaw` when `OPENCLAW_HOME` is set; override with `OPENCLAW_GIT_DIR`), switches to `main`, rebases on upstream, builds, and @@ -85,6 +96,9 @@ Notes: checkout as your persistent install. - Downgrade protection: if the target version is older than your current version, OpenClaw prompts for confirmation (skip with `--yes`). +- Extended-stable always uses its verified exact package target. It is not a + one-off alias for `--tag extended-stable`, and `--tag` cannot be combined + with an effective extended-stable channel. - `--channel beta` is different from `--tag beta`: the channel flow can fall back to stable/latest when beta is missing or older, while `--tag beta` targets the raw `beta` dist-tag for that one run. @@ -110,6 +124,11 @@ sources: - `dev` prefers bundled plugins from the git checkout. - `stable` and `beta` restore npm-installed plugin packages. +- `extended-stable` pins npm-publishable official plugins to the exact installed + core version after the core package succeeds. Official plugin + `@extended-stable` selectors are not queried. This overrides official npm + pins/tags and converts trusted official ClawHub installs to the exact npm + version; third-party selections are preserved. - npm-installed plugins are updated after the core update completes. ## Checking current status @@ -132,6 +151,7 @@ source (config, git tag, git branch, or default). - Keep tags immutable: never move or reuse a tag. - npm dist-tags remain the source of truth for npm installs: - `latest` -> stable + - `extended-stable` -> trailing supported-month package release - `beta` -> candidate build or beta-first stable build - `dev` -> main snapshot (optional) diff --git a/docs/install/updating.md b/docs/install/updating.md index 9aea637b9d93..982b006be374 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -20,6 +20,7 @@ To switch channels or target a specific version: ```bash openclaw update --channel beta +openclaw update --channel extended-stable openclaw update --channel dev openclaw update --dry-run # preview without applying ``` @@ -34,6 +35,21 @@ installer has its own `--verbose` flag, but that flag is not part of the beta tag is missing or older than the latest stable release. Use `--tag beta` if you want the raw npm beta dist-tag for a one-off package update. +`--channel extended-stable` is package-only, and installation remains +foreground-only. OpenClaw reads the public npm `extended-stable` selector, +verifies the selected exact package, and installs that exact version. Missing +or inconsistent registry data fails closed; it never falls back to `latest`. +If the selected version is older than the installed version, the normal +downgrade confirmation still applies. The CLI persists the channel after a +successful core update; a direct `npm install -g openclaw@extended-stable` +does not update `update.channel`. +After the core swap, normal plugin integrity and convergence checks still run. +Npm-publishable official plugins use the exact installed core version; OpenClaw +does not query plugin `@extended-stable` selectors. This compatibility rule +overrides an official plugin's explicit npm pin/tag and converts a trusted +official ClawHub install to the exact npm package version. Third-party plugin +pins/tags and other non-npm sources remain unchanged. + Use `--channel dev` for a persistent moving GitHub `main` checkout. For package updates, `--tag main` maps to `github:openclaw/openclaw#main` for one run, and GitHub/git source specs are packed into a temporary tarball before the staged @@ -67,7 +83,9 @@ openclaw update --channel stable --dry-run ``` The `dev` channel ensures a git checkout, builds it, and installs the global CLI -from that checkout. The `stable` and `beta` channels use package installs. If the +from that checkout. The `stable`, `extended-stable`, and `beta` channels use +package installs. Extended-stable is rejected on a Git checkout without +mutating or converting it. If the gateway is already installed, `openclaw update` refreshes the service metadata and restarts it unless you pass `--no-restart`. @@ -201,13 +219,17 @@ The auto-updater is off by default. Enable it in `~/.openclaw/openclaw.json`: } ``` -| Channel | Behavior | -| -------- | ------------------------------------------------------------------------------------------------------------- | -| `stable` | Waits `stableDelayHours`, then applies with deterministic jitter across `stableJitterHours` (spread rollout). | -| `beta` | Checks every `betaCheckIntervalHours` (default: hourly) and applies immediately. | -| `dev` | No automatic apply. Use `openclaw update` manually. | +| Channel | Behavior | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `stable` | Waits `stableDelayHours` (default: 6), then applies with deterministic jitter across `stableJitterHours` (default: 12) for a spread rollout. | +| `extended-stable` | Checks for a read-only update hint on startup and every 24 hours when `checkOnStart` is enabled. Never applies automatically. | +| `beta` | Checks every `betaCheckIntervalHours` (default: 1) and applies immediately. | +| `dev` | No automatic apply. Use `openclaw update` manually. | -The gateway also logs an update hint on startup (disable with `update.checkOnStart: false`). +The gateway also logs an update hint on startup (disable with +`update.checkOnStart: false`). Stored extended-stable selections use this +read-only hint path and the existing 24-hour hint interval, but never invoke +automatic installation, handoff, restart, stable delay/jitter, or beta polling. For downgrade or incident recovery, set `OPENCLAW_NO_AUTO_UPDATE=1` in the gateway environment to block automatic applies even when `update.auto.enabled` is configured. Startup update hints can still run unless `update.checkOnStart` is also disabled. Package-manager updates requested through the live Gateway control-plane handler diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index e65e527b16e3..480ea852f98d 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -7,17 +7,17 @@ read_when: - Looking for version naming and cadence --- -OpenClaw currently exposes three user-facing update channels: +OpenClaw exposes four user-facing update channels: -- stable: the existing promoted release channel, which still resolves through - npm `latest` until the separate CLI/channel milestone lands +- stable: the promoted release channel, which resolves through npm `latest` +- extended-stable: the trailing supported-month npm package channel; package + installs resolve npm `extended-stable` exactly, while git installs are unsupported - beta: prerelease tags that publish to npm `beta` - dev: the moving head of `main` -Separately, release operators can publish the trailing completed month's core -package to npm `extended-stable`, beginning at patch `33`. The current-month regular -final line continues on npm `latest`; this operator-side publication split does -not by itself change CLI update-channel resolution. +Release operators publish the trailing completed month's core package and official +npm-publishable plugins to `extended-stable`, beginning at patch `33`. The +current-month regular final line continues on npm `latest`. ## Version naming @@ -177,7 +177,10 @@ the maintainer-only release runbook. 2. Generate the top `CHANGELOG.md` section from merged PRs and all direct commits since the last reachable release tag. Keep entries user-facing, dedupe overlapping PR/direct-commit entries, commit the rewrite, push it, - and rebase/pull once more before branching. + and rebase/pull once more before branching. When a divergent shipped tag or + later forward-port re-associates already-released PRs, pass that tag as an + explicit `--shipped-ref`; the verifier uses its numbered release sections as + the cumulative shipped boundary and records the exact excluded PR inventory. 3. Review release compatibility records in `src/plugins/compat/registry.ts` and `src/commands/doctor/shared/deprecation-compat.ts`. Remove expired @@ -192,24 +195,37 @@ the maintainer-only release runbook. drift before tagging. Then run the local deterministic preflight: `pnpm check:test-types`, `pnpm check:architecture`, `pnpm build && pnpm ui:build`, and `pnpm release:check`. -6. Run `OpenClaw NPM Release` with `preflight_only=true`. Before a tag exists, - a full 40-character release-branch SHA is allowed for validation-only - preflight. The preflight generates dependency release evidence for the - exact checked-out dependency graph and stores it in the npm preflight - artifact. Save the successful `preflight_run_id`. -7. Kick off all pre-release tests with `Full Release Validation` for the - release branch, tag, or full commit SHA. This is the one manual entrypoint - for the four big release test boxes: Vitest, Docker, QA Lab, and Package. +6. Resolve the current trusted `main` SHA, push it once to the immutable + `release-publish/` branch, and use that exact workflow ref for + all release evidence and the later publish run. Run `OpenClaw NPM Release` + from that immutable ref with `preflight_only=true` and the release tag or + full 40-character release SHA as its `tag` input. The preflight generates + dependency release evidence for the exact release dependency graph and + stores it in the npm preflight artifact. Save the successful + `preflight_run_id` and exact attempt. +7. Kick off all pre-release tests by running `Full Release Validation` from the + same immutable `release-publish/` workflow ref. Set its `ref` + input to the release branch, tag, or full commit SHA and set + `target_context_ref` when an exact SHA needs its canonical release branch or + tag context. This is the one manual entrypoint for the four big release test + boxes: Vitest, Docker, QA Lab, and Package. 8. If validation fails, fix on the release branch and rerun the smallest failed file, lane, workflow job, package profile, provider, or model allowlist that proves the fix. Rerun the full umbrella only when the changed surface makes - prior evidence stale. + prior evidence stale. If trusted workflow tooling changes, create a new + immutable ref for the new trusted `main` SHA and regenerate both npm + preflight and Full Release Validation evidence; never move or mix evidence + across immutable release-publish refs. 9. For a tagged beta candidate, run `pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. For stable, pass the required Windows source release too: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. - The helper runs the local generated-release checks, dispatches or verifies + Before dispatch, the helper deterministically renders the tag's GitHub + release body and rejects missing headings, invalid compact over-limit + bodies, or contribution-record provenance that is not reachable from the + tag. It also validates explicit shipped-baseline exclusions. The helper then + runs the local generated-release checks, dispatches or verifies the full release validation and npm preflight evidence, runs Parallels fresh/update proof against the exact prepared tarball plus Telegram package proof, records plugin npm and ClawHub plans, and prints the exact @@ -218,17 +234,25 @@ the maintainer-only release runbook. packages to npm and the same set to ClawHub in parallel, and then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag as soon as plugin npm publish succeeds. - After the OpenClaw npm publish child succeeds, it creates or updates the - matching GitHub release/prerelease page from the complete matching - `CHANGELOG.md` section. Stable releases published to npm `latest` become the + Before any publish child starts, it renders and caches the canonical GitHub + release body. A matching `CHANGELOG.md` section that fits GitHub's + 125,000-character and 125,000-byte safety limits is published in full. If it + exceeds either limit, the renderer preserves the grouped editorial notes and + replaces the oversized contribution record with a tag-pinned `CHANGELOG.md` + link; it never truncates a bullet or publishes a partial record. The workflow + chooses this body before appending optional release verification, and relies + on immutable evidence when that proof tail would exceed the limit. Stable + releases published to npm `latest` become the GitHub latest release; stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence to the GitHub release as `openclaw--dependency-evidence.zip` for post-release incident response. The publish workflow prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes - failed child jobs with log tails, closes out the GitHub release and dependency - evidence as soon as OpenClaw npm publish succeeds, waits for ClawHub whenever + failed child jobs with log tails, creates the draft GitHub release page up + front, promotes Windows assets concurrently with the OpenClaw npm publish, + closes out the release page and dependency evidence when those stages + succeed, waits for ClawHub whenever OpenClaw npm is being published, then runs `pnpm release:verify-beta` and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and @@ -364,13 +388,20 @@ the maintainer-only release runbook. publish path reuses that preflight artifact, then attaches the same evidence to the GitHub release as `openclaw--dependency-evidence.zip`. - Run `OpenClaw Release Publish` for the mutating publish sequence after the - tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a - main-reachable tag), pass the release tag, successful OpenClaw npm - `preflight_run_id`, and successful `full_release_validation_run_id`, and keep + tag exists. Dispatch it from the immutable + `release-publish/` ref used to produce the evidence, pass + the release tag, successful OpenClaw npm `preflight_run_id`, successful + `full_release_validation_run_id`, + and its exact `full_release_validation_run_attempt`, and keep the default plugin publish scope `all-publishable` unless you are deliberately running a focused repair. The workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw npm publish so the core package is not published - before its externalized plugins. + before its externalized plugins; Windows promotion runs concurrently with the + core npm publish against the draft release page. Publish reruns are resumable: + an existing core npm version skips the core dispatch only after the workflow + proves the registry tarball matches the tag's preflight artifact, and Windows + promotion is skipped when the release already carries the verified asset + contract. - Stable `OpenClaw Release Publish` requires an exact `windows_node_tag` after the matching non-prerelease `openclaw/openclaw-windows-node` release exists. It also requires the candidate-approved `windows_node_installer_digests` map. @@ -496,17 +527,18 @@ Validation` or from the `main`/release workflow ref so workflow logic and `Full Release Validation` is how operators kick off all pre-release tests from one entrypoint. For a pinned commit proof on a fast-moving branch, use the -helper so every child workflow runs from a temporary branch fixed at the target -SHA: +retained 6.x helper so the parent and every child run from a temporary branch +at the requested candidate commit: ```bash pnpm ci:full-release --sha ``` -The helper pushes `release-ci/-...`, dispatches `Full Release Validation` -from that branch with `ref=`, verifies every child workflow `headSha` -matches the target, then deletes the temporary branch. This avoids proving a -newer `main` child run by accident. +The helper pushes `release-ci/-...` at the target commit, +dispatches `Full Release Validation` with `ref=`, verifies every +child workflow `headSha` matches that commit, then deletes the temporary remote +branch. Use `--branch` to choose its name, `--keep-branch` to retain it, or +`--dry-run` to print the ref mutations and dispatch without executing them. For release branch or tag validation, run it from the trusted `main` workflow ref and pass the release branch or tag as `ref`: @@ -538,13 +570,22 @@ unless a published `release_package_spec` or `npm_telegram_package_spec` was provided. The final verifier summary includes slowest-job tables for each child run, so the release manager can see the current critical path without downloading logs. + +The product-performance child is artifact-only in this release path. The +umbrella dispatches it with `publish_reports=false`, and validation is rejected +unless its artifact-only guard proves that report publishing stayed skipped. + See [Full release validation](/reference/full-release-validation) for the complete stage matrix, exact workflow job names, stable versus full profile differences, artifacts, and focused rerun handles. Child workflows are dispatched from the trusted ref that runs `Full Release -Validation`, normally `--ref main`, even when the target `ref` points at an -older release branch or tag. There is no separate Full Release Validation -workflow-ref input; choose the trusted harness by choosing the workflow run ref. +Validation`: normally `--ref main` for standalone validation, or the immutable +`release-publish/` ref when the result will authorize regular +publish. The target `ref` may still point at an older release branch or tag. +Every child must use the exact parent workflow SHA; +if `main` advances before dispatch resolves, the umbrella fails closed. There is +no separate Full Release Validation workflow-ref input; choose the trusted +harness by choosing the workflow run ref. Do not use `--ref main -f ref=` for exact commit proof on moving `main`; raw commit SHAs cannot be workflow dispatch refs, so use `pnpm ci:full-release --sha ` to create the pinned temporary branch. @@ -615,19 +656,28 @@ stale. The umbrella's final verifier re-checks the recorded child workflow run ids, so after a child workflow is rerun successfully, rerun only the failed `Verify full validation` parent job. +This 6.x workflow does not reuse evidence across separate Full Release +Validation runs. A new `rerun_group=all` dispatch starts fresh child proof for +the candidate. A changed candidate, including a changelog- or version-only +commit, requires fresh proof for every affected package, artifact, install, +Docker, or provider gate. Newer umbrella runs for the same `release/*` ref and +rerun group supersede in-progress runs automatically. + For bounded recovery, pass `rerun_group` to the umbrella. `all` is the real release-candidate run, `ci` runs only the normal CI child, `plugin-prerelease` runs only the release-only plugin child, `release-checks` runs every release box, and the narrower release groups are `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`. Focused `npm-telegram` reruns require `release_package_spec` or -`npm_telegram_package_spec`; full/all runs with `release_profile=full` use the -release-checks package artifact. Focused +`npm_telegram_package_spec`; full/all runs use the canonical package Telegram +E2E inside Package Acceptance. Focused cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or another OS/suite filter. QA release-check failures block normal release validation, including required OpenClaw dynamic tool drift in the standard tier. Tideclaw alpha runs may still treat non-package-safety release-check lanes as -advisory. When `live_suite_filter` explicitly requests a gated QA live lane such +advisory. With `release_profile=beta`, the `Run repo/live E2E validation` +live-provider suites are advisory warnings; stable and full profiles keep them +blocking. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane. @@ -682,8 +732,8 @@ Release Docker coverage includes: `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, `plugins-runtime-install-e`, `plugins-runtime-install-f`, - `plugins-runtime-install-g`, and `plugins-runtime-install-h` -- OpenWebUI coverage inside the `plugins-runtime-services` chunk when requested + `plugins-runtime-install-g`, `plugins-runtime-install-h`, and `openwebui` +- OpenWebUI coverage on a dedicated large-disk runner when requested - split bundled plugin install/uninstall lanes `bundled-plugin-install-uninstall-0` through `bundled-plugin-install-uninstall-23` @@ -827,7 +877,7 @@ orchestrates the trusted-publisher workflows in the order the release needs: 5. Dispatch `Plugin ClawHub Release` with the same scope and SHA. 6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved - `full_release_validation_run_id`. + `full_release_validation_run_id` and exact run attempt. 7. For stable releases, create or update the GitHub release as a draft, dispatch `Windows Node Release` with the explicit `windows_node_tag` and candidate-approved `windows_node_installer_digests`, and verify the canonical @@ -836,11 +886,31 @@ orchestrates the trusted-publisher workflows in the order the release needs: Beta publish example: ```bash +release_publish_sha=$(git rev-parse origin/main) +release_publish_ref="release-publish/${release_publish_sha}" +git push origin "${release_publish_sha}:refs/heads/${release_publish_ref}" + +# Both evidence workflows must use this exact workflow ref. Their target input +# remains the release tag/branch/SHA being validated. +gh workflow run openclaw-npm-release.yml \ + --ref "${release_publish_ref}" \ + -f tag=vYYYY.M.PATCH-beta.N \ + -f preflight_only=true \ + -f npm_dist_tag=beta +gh workflow run full-release-validation.yml \ + --ref "${release_publish_ref}" \ + -f ref=vYYYY.M.PATCH-beta.N \ + -f target_context_ref=release/YYYY.M.PATCH \ + -f release_profile=beta + +# After both exact attempts succeed, publish from the unchanged ref. gh workflow run openclaw-release-publish.yml \ - --ref release/YYYY.M.PATCH \ + --ref "${release_publish_ref}" \ -f tag=vYYYY.M.PATCH-beta.N \ -f preflight_run_id= \ + -f preflight_run_attempt= \ -f full_release_validation_run_id= \ + -f full_release_validation_run_attempt= \ -f npm_dist_tag=beta ``` @@ -848,12 +918,14 @@ Stable publish to the default beta dist-tag: ```bash gh workflow run openclaw-release-publish.yml \ - --ref release/YYYY.M.PATCH \ + --ref "${release_publish_ref}" \ -f tag=vYYYY.M.PATCH \ -f windows_node_tag=vX.Y.Z \ -f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:","OpenClawCompanion-Setup-arm64.exe":"sha256:"}' \ -f preflight_run_id= \ + -f preflight_run_attempt= \ -f full_release_validation_run_id= \ + -f full_release_validation_run_attempt= \ -f npm_dist_tag=beta ``` @@ -861,12 +933,14 @@ Stable promotion directly to `latest` is explicit: ```bash gh workflow run openclaw-release-publish.yml \ - --ref release/YYYY.M.PATCH \ + --ref "${release_publish_ref}" \ -f tag=vYYYY.M.PATCH \ -f windows_node_tag=vX.Y.Z \ -f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:","OpenClawCompanion-Setup-arm64.exe":"sha256:"}' \ -f preflight_run_id= \ + -f preflight_run_attempt= \ -f full_release_validation_run_id= \ + -f full_release_validation_run_attempt= \ -f npm_dist_tag=latest ``` @@ -878,6 +952,18 @@ package cannot ship without every publishable official plugin, including `publish_openclaw_npm=false` with `plugin_publish_scope=selected` and `plugins=@openclaw/name`, or dispatch the child workflow directly. +First-publish ClawHub bootstrap is the exception: dispatch `Plugin ClawHub New` +from the trusted workflow source and pass the full target release SHA through +`ref`. Pre-tag validation requires `dry_run=true`, does not load ClawHub +credentials, and validates the locked CLI plus immutable package artifact +identity before a release tag exists. A tagged bootstrap additionally binds the +parent publish run and attempt, trusted workflow SHA, target release SHA, +Actions artifact ID and digest, and per-package tarball SHA-256 and size. The +protected job downloads by exact artifact ID and rehashes every tarball before +any registry mutation; post-publish verification requires the same immutable +artifact identity and registry readback. A mismatch requires a new package +version. + ## Extended-stable npm workflow inputs `OpenClaw NPM Release` accepts the following extended-stable inputs: @@ -905,8 +991,13 @@ permission only in its gated publish job. - `tag`: required release tag; must already exist - `preflight_run_id`: successful `OpenClaw NPM Release` preflight run id; required when `publish_openclaw_npm=true` +- `preflight_run_attempt`: exact successful attempt of `preflight_run_id`; + required with release evidence so the prepared tarball cannot move on rerun - `full_release_validation_run_id`: successful `Full Release Validation` run id; required when `publish_openclaw_npm=true` +- `full_release_validation_run_attempt`: exact successful attempt of + `full_release_validation_run_id`; record it with the run id for immutable + evidence selection - `windows_node_tag`: exact non-prerelease `openclaw/openclaw-windows-node` release tag; required for stable OpenClaw publish - `windows_node_installer_digests`: candidate-approved compact JSON map of the @@ -953,14 +1044,17 @@ monthly `.33+` npm-only extended-stable path documented at the top of this page. When cutting a regular orchestrated stable release: -1. Run `OpenClaw NPM Release` with `preflight_only=true` - - Before a tag exists, you may use the current full workflow-branch commit - SHA for a validation-only dry run of the preflight workflow +1. Resolve the current trusted `main` SHA and push it once to + `release-publish/`. Run `OpenClaw NPM Release` from that exact + immutable workflow ref with `preflight_only=true` + - Before a tag exists, you may use the current full release-branch commit + SHA as the `tag` input for a validation-only dry run 2. Choose `npm_dist_tag=beta` for the normal beta-first flow, or `latest` only when you intentionally want a direct stable publish -3. Run `Full Release Validation` on the release branch, release tag, or full - commit SHA when you want normal CI plus live prompt cache, Docker, QA Lab, - Matrix, and Telegram coverage from one manual workflow +3. Run `Full Release Validation` from the same immutable workflow ref, with its + target `ref` set to the release branch, release tag, or full commit SHA, when + you want normal CI plus live prompt cache, Docker, QA Lab, Matrix, and + Telegram coverage from one manual workflow 4. If you intentionally only need the deterministic normal test graph, run the manual `CI` workflow on the release ref instead 5. Select the exact non-prerelease `openclaw/openclaw-windows-node` release tag @@ -968,10 +1062,15 @@ When cutting a regular orchestrated stable release: `windows_node_tag`, and save their validated digest map as `windows_node_installer_digests`. The release-candidate helper records both and includes them in its generated publish command. -6. Save the successful `preflight_run_id` and `full_release_validation_run_id` -7. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`, +6. Save the successful `preflight_run_id` and exact `preflight_run_attempt`, + plus `full_release_validation_run_id` and exact + `full_release_validation_run_attempt` +7. Run `OpenClaw Release Publish` from the unchanged immutable workflow ref + with the same `tag`, the same `npm_dist_tag`, the selected `windows_node_tag`, its saved `windows_node_installer_digests`, - the saved `preflight_run_id`, and the saved `full_release_validation_run_id`; + the saved `preflight_run_id` and `preflight_run_attempt`, saved + `full_release_validation_run_id`, and its exact + `full_release_validation_run_attempt`; it publishes externalized plugins to npm and ClawHub before promoting the OpenClaw npm package 8. If the release landed on `beta`, use the diff --git a/scripts/check-release-metadata-only.mjs b/scripts/check-release-metadata-only.mjs index a78c7637e55a..49e3ffff4878 100644 --- a/scripts/check-release-metadata-only.mjs +++ b/scripts/check-release-metadata-only.mjs @@ -87,7 +87,9 @@ function readBeforeAfter(args, filePath) { const refs = refsFor(args); const before = readBlob(refs.before, filePath); let after = readBlob(refs.after, filePath); - if (!args.staged && existsSync(filePath)) { + // The worktree overlay covers uncommitted edits; an explicit --head SHA is + // a request for SHA-exact comparison and must not read the checkout. + if (!args.staged && args.head === "HEAD" && existsSync(filePath)) { const worktree = readBlob("WORKTREE", filePath); if (worktree !== after) { after = worktree; diff --git a/scripts/e2e/bun-global-install-smoke.sh b/scripts/e2e/bun-global-install-smoke.sh index da79863e201d..653e294ab1f3 100755 --- a/scripts/e2e/bun-global-install-smoke.sh +++ b/scripts/e2e/bun-global-install-smoke.sh @@ -21,6 +21,7 @@ cleanup() { fi } + trap cleanup EXIT run_with_timeout() { @@ -113,25 +114,14 @@ resolve_package_tgz() { exit 1 fi - echo "==> Write package inventory" - node --import tsx scripts/write-package-dist-inventory.ts - - local pack_json_file PACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-bun-pack.XXXXXX")" - pack_json_file="$PACK_DIR/pack.json" echo "==> Pack OpenClaw tarball" - npm pack --ignore-scripts --json --pack-destination "$PACK_DIR" >"$pack_json_file" PACKAGE_TGZ="$( - node -e ' -const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; -const parsed = JSON.parse(raw); -const last = Array.isArray(parsed) ? parsed.at(-1) : null; -if (!last || typeof last.filename !== "string" || last.filename.length === 0) { - process.exit(1); -} -process.stdout.write(require("node:path").resolve(process.argv[2], last.filename)); -' "$pack_json_file" "$PACK_DIR" + node scripts/package-openclaw-for-docker.mjs \ + --skip-build \ + --output-dir "$PACK_DIR" \ + --output-name openclaw-current.tgz )" if [ -z "$PACKAGE_TGZ" ] || [ ! -f "$PACKAGE_TGZ" ]; then echo "missing packed OpenClaw tarball" >&2 @@ -162,7 +152,6 @@ main() { export NO_COLOR=1 mkdir -p "$HOME" "$BUN_INSTALL/bin" "$XDG_CACHE_HOME" export PATH="$BUN_INSTALL/bin:$(dirname "$(command -v node)"):$PATH" - echo "==> Bun version" "$bun_path" --version diff --git a/scripts/e2e/lib/plugins/npm-registry-server.mjs b/scripts/e2e/lib/plugins/npm-registry-server.mjs index c908212955c4..dc8760486320 100644 --- a/scripts/e2e/lib/plugins/npm-registry-server.mjs +++ b/scripts/e2e/lib/plugins/npm-registry-server.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; // Fixture npm registry server for plugin E2E scenarios. import crypto from "node:crypto"; import fs from "node:fs"; @@ -5,6 +6,49 @@ import http from "node:http"; import path from "node:path"; const [portFile, ...packageArgs] = process.argv.slice(2); +function normalizeUpstreamRegistry(raw) { + if (!raw) { + return undefined; + } + const url = new URL(raw); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error("OPENCLAW_NPM_REGISTRY_UPSTREAM must be an HTTP(S) origin"); + } + return url.origin; +} + +const upstreamRegistry = normalizeUpstreamRegistry(process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM); +const upstreamMaxBytes = parsePositiveInteger( + process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM_MAX_BYTES, + 64 * 1024 * 1024, + "OPENCLAW_NPM_REGISTRY_UPSTREAM_MAX_BYTES", +); +const upstreamTimeoutMs = parsePositiveInteger( + process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM_TIMEOUT_MS, + 120_000, + "OPENCLAW_NPM_REGISTRY_UPSTREAM_TIMEOUT_MS", +); + +function parsePositiveInteger(raw, fallback, label) { + if (raw === undefined || raw === "") { + return fallback; + } + if (!/^[1-9][0-9]*$/u.test(raw)) { + throw new Error(`${label} must be a positive integer`); + } + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new Error(`${label} exceeds the safe integer range`); + } + return value; +} if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { console.error( @@ -14,6 +58,25 @@ if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { } const packages = new Map(); + +function readPackageManifest(tarballPath, packageName) { + try { + const packageJson = JSON.parse( + execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }), + ); + return packageJson && typeof packageJson === "object" && !Array.isArray(packageJson) + ? packageJson + : {}; + } catch { + return packageName === "@openclaw/demo-plugin-npm" + ? { dependencies: { "is-number": "7.0.0" } } + : {}; + } +} + for (let index = 0; index < packageArgs.length; index += 3) { const packageName = packageArgs[index]; const version = packageArgs[index + 1]; @@ -28,8 +91,8 @@ for (let index = 0; index < packageArgs.length; index += 3) { existing.latestVersion = version; existing.versions.set(version, { archive, - dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {}, integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + manifest: readPackageManifest(tarballPath, packageName), shasum: crypto.createHash("sha1").update(archive).digest("hex"), tarballName: path.basename(tarballPath), version, @@ -44,7 +107,7 @@ const metadataFor = (entry, baseUrl) => ({ [...entry.versions.entries()].map(([version, versionEntry]) => [ version, { - dependencies: versionEntry.dependencies, + ...versionEntry.manifest, name: entry.packageName, version, dist: { @@ -76,9 +139,85 @@ function findTarballForPath(pathname) { return undefined; } -const server = http.createServer((request, response) => { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - const baseUrl = `http://127.0.0.1:${server.address().port}`; +function resolveUpstreamRequestUrl(rawRequestUrl) { + const raw = rawRequestUrl || "/"; + if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) { + throw new Error(`refusing non-origin registry request URL: ${JSON.stringify(raw)}`); + } + const requestUrl = new URL(raw, "http://127.0.0.1"); + return `${upstreamRegistry}${requestUrl.pathname}${requestUrl.search}`; +} + +async function proxyUpstream(rawRequestUrl, response) { + if (!upstreamRegistry) { + return false; + } + try { + const upstreamUrl = resolveUpstreamRequestUrl(rawRequestUrl); + const abort = new AbortController(); + const timeout = setTimeout(() => { + abort.abort(new Error(`upstream registry request exceeded ${upstreamTimeoutMs}ms`)); + }, upstreamTimeoutMs); + let upstreamResponse; + let body; + try { + upstreamResponse = await fetch(upstreamUrl, { + redirect: "manual", + signal: abort.signal, + }); + const declaredLength = Number(upstreamResponse.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > upstreamMaxBytes) { + throw new Error(`upstream registry response exceeds ${upstreamMaxBytes} bytes`); + } + const reader = upstreamResponse.body?.getReader(); + const chunks = []; + let total = 0; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value?.byteLength) { + continue; + } + total += value.byteLength; + if (total > upstreamMaxBytes) { + abort.abort(new Error(`upstream registry response exceeds ${upstreamMaxBytes} bytes`)); + await reader.cancel().catch(() => undefined); + throw new Error(`upstream registry response exceeds ${upstreamMaxBytes} bytes`); + } + chunks.push(Buffer.from(value)); + } + } + body = Buffer.concat(chunks, total); + } finally { + clearTimeout(timeout); + abort.abort(); + } + // Fetch decodes compressed bodies but preserves upstream length metadata. + // Emit the decoded size so npm clients do not truncate proxied responses. + const headers = { "content-length": String(body.length) }; + for (const name of ["content-type", "location"]) { + const value = upstreamResponse.headers.get(name); + if (value) { + headers[name] = value; + } + } + response.writeHead(upstreamResponse.status, headers); + response.end(body); + } catch (error) { + response.writeHead(502, { "content-type": "text/plain" }); + response.end(`upstream registry request failed: ${String(error)}`); + } + return true; +} + +async function handleRequest(request, response) { + const fallbackHost = `127.0.0.1:${server.address().port}`; + const requestHost = request.headers.host || fallbackHost; + const url = new URL(request.url ?? "/", `http://${requestHost}`); + const baseUrl = url.origin; if (request.method !== "GET") { response.writeHead(405, { "content-type": "text/plain" }); response.end("method not allowed"); @@ -102,10 +241,27 @@ const server = http.createServer((request, response) => { return; } + if (await proxyUpstream(request.url, response)) { + return; + } + response.writeHead(404, { "content-type": "text/plain" }); response.end(`not found: ${url.pathname}`); +} + +const server = http.createServer((request, response) => { + void handleRequest(request, response).catch((/** @type {unknown} */ error) => { + if (!response.headersSent) { + response.writeHead(500, { "content-type": "text/plain" }); + response.end(`registry request failed: ${String(error)}`); + return; + } + response.destroy(error instanceof Error ? error : new Error(String(error))); + }); }); -server.listen(0, "127.0.0.1", () => { +const bindHost = process.env.OPENCLAW_NPM_REGISTRY_BIND_HOST || "127.0.0.1"; +const requestedPort = Number(process.env.OPENCLAW_NPM_REGISTRY_PORT || 0); +server.listen(requestedPort, bindHost, () => { fs.writeFileSync(portFile, String(server.address().port)); }); diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index df2c09850c69..b1fa1641df68 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -10,6 +10,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-telegram-live-e2e" OPENCLAW DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}" PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}" PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}" +PACKAGE_DIR="${OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR:-}" PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}" RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}" @@ -42,10 +43,10 @@ resolve_credential_role() { validate_openclaw_package_spec() { local spec="$1" - if [[ "$spec" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then + if [[ "$spec" =~ ^openclaw@(alpha|beta|extended-stable|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then return 0 fi - echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2 + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC must be openclaw@alpha, openclaw@beta, openclaw@extended-stable, openclaw@latest, or an exact OpenClaw release version; got: $spec" >&2 exit 1 } @@ -72,11 +73,58 @@ resolve_package_tgz() { printf "%s/%s" "$dir" "$base" } +resolve_package_dir() { + local candidate="$1" + if [ -z "$candidate" ]; then + return 0 + fi + if [ ! -d "$candidate" ]; then + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR must point to an existing directory; got: $candidate" >&2 + exit 1 + fi + (cd "$candidate" && pwd) +} + +read_package_version() { + tar -xOf "$1" package/package.json | + node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + const version = JSON.parse(raw).version; + if (typeof version !== "string" || !version) { + throw new Error("package tarball is missing a version"); + } + process.stdout.write(version); +}); +' +} + package_mount_args=() +registry_helper_mount_args=() package_install_source="$PACKAGE_SPEC" package_source_kind="npm-package" resolved_package_tgz="$(resolve_package_tgz "$PACKAGE_TGZ")" -if [ -n "$resolved_package_tgz" ]; then +resolved_package_dir="$(resolve_package_dir "$PACKAGE_DIR")" +if [ -n "$resolved_package_dir" ]; then + if [ -z "$resolved_package_tgz" ]; then + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR requires OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ" >&2 + exit 1 + fi + case "$resolved_package_tgz" in + "$resolved_package_dir"/*) ;; + *) + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ must be inside OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR" >&2 + exit 1 + ;; + esac + package_install_source="openclaw@$(read_package_version "$resolved_package_tgz")" + package_source_kind="prepared-package-set" + package_mount_args=(-v "$resolved_package_dir:/package-under-test:ro") + registry_helper_mount_args=( + -v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro" + ) +elif [ -n "$resolved_package_tgz" ]; then package_install_source="/package-under-test/$(basename "$resolved_package_tgz")" package_source_kind="packed-tarball" package_mount_args=(-v "$resolved_package_tgz:$package_install_source:ro") @@ -241,7 +289,9 @@ run_logged docker_e2e_docker_run_cmd run --rm \ -e OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" \ -e OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE="$package_install_source" \ -e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL" \ + -e OPENCLAW_NPM_TELEGRAM_PACKAGE_SET="$([ -n "$resolved_package_dir" ] && printf 1 || printf 0)" \ ${package_mount_args[@]+"${package_mount_args[@]}"} \ + ${registry_helper_mount_args[@]+"${registry_helper_mount_args[@]}"} \ -v "$npm_prefix_host:/npm-global" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail @@ -254,6 +304,74 @@ install_source="${OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE:?missing OPENCLAW_NPM_TEL package_label="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-$install_source}" echo "Installing ${package_label} from ${install_source}..." +registry_pid="" +registry_log="" +cleanup_registry() { + if [ -n "$registry_pid" ]; then + kill "$registry_pid" >/dev/null 2>&1 || true + wait "$registry_pid" >/dev/null 2>&1 || true + fi + if [ -n "$registry_log" ]; then + rm -f "$registry_log" + fi +} +trap cleanup_registry EXIT + +if [ "${OPENCLAW_NPM_TELEGRAM_PACKAGE_SET:-0}" = "1" ]; then + shopt -s nullglob + package_tgzs=(/package-under-test/*.tgz) + shopt -u nullglob + if [ "${#package_tgzs[@]}" -eq 0 ]; then + echo "prepared package set contains no tgz files" >&2 + exit 1 + fi + registry_args=() + for package_tgz in "${package_tgzs[@]}"; do + package_metadata="$( + tar -xOf "$package_tgz" package/package.json | + node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + const pkg = JSON.parse(raw); + if (typeof pkg.name !== "string" || !pkg.name || typeof pkg.version !== "string" || !pkg.version) { + throw new Error("package tarball is missing name or version"); + } + process.stdout.write(`${pkg.name}\n${pkg.version}\n`); +}); +' + )" + mapfile -t package_fields <<<"$package_metadata" + registry_args+=("${package_fields[0]}" "${package_fields[1]}" "$package_tgz") + done + registry_port_file="$(mktemp)" + registry_log="$(mktemp)" + OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \ + node /tmp/openclaw-npm-registry-server.mjs \ + "$registry_port_file" \ + "${registry_args[@]}" >"$registry_log" 2>&1 & + registry_pid=$! + for _ in $(seq 1 100); do + if [ -s "$registry_port_file" ]; then + break + fi + if ! kill -0 "$registry_pid" >/dev/null 2>&1; then + cat "$registry_log" >&2 + exit 1 + fi + sleep 0.1 + done + if [ ! -s "$registry_port_file" ]; then + cat "$registry_log" >&2 + echo "prepared package registry did not start" >&2 + exit 1 + fi + registry_url="http://127.0.0.1:$(cat "$registry_port_file")" + rm -f "$registry_port_file" + export NPM_CONFIG_REGISTRY="$registry_url" + export npm_config_registry="$registry_url" +fi + npm_install_timeout="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" run_npm_install() { if [ -z "$npm_install_timeout" ] || [ "$npm_install_timeout" = "0" ]; then diff --git a/scripts/e2e/parallels/host-server.ts b/scripts/e2e/parallels/host-server.ts index c4e14ddf4626..e2161cf43a2f 100644 --- a/scripts/e2e/parallels/host-server.ts +++ b/scripts/e2e/parallels/host-server.ts @@ -1,10 +1,13 @@ // Host Server script supports OpenClaw repository automation. import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; import { createServer } from "node:http"; import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; import path from "node:path"; import { die, run, say, sh, warn } from "./host-command.ts"; -import type { HostServer } from "./types.ts"; +import type { HostServer, NpmRegistryPackage, NpmRegistryServer } from "./types.ts"; const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024; const HOST_SERVER_STDERR_DRAIN_MS = 5_000; @@ -89,6 +92,44 @@ export async function startHostServer(input: { }; } +export async function startNpmRegistryServer(input: { + hostIp: string; + packages: NpmRegistryPackage[]; +}): Promise { + if (input.packages.length === 0) { + die("npm registry server requires at least one package"); + } + const port = allocateHostPort(); + const portFile = path.join(tmpdir(), `openclaw-npm-registry-${randomUUID()}.port`); + const packageArgs = input.packages.flatMap((pkg) => [pkg.name, pkg.version, pkg.tarballPath]); + const child = spawn( + process.execPath, + ["scripts/e2e/lib/plugins/npm-registry-server.mjs", portFile, ...packageArgs], + { + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_BIND_HOST: "0.0.0.0", + OPENCLAW_NPM_REGISTRY_PORT: String(port), + OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + await waitForHostServer(child, port); + const url = `http://${input.hostIp}:${port}`; + say(`Serve prepared npm package set on ${url}`); + return { + url, + stop: async () => { + try { + await stopHostServerChild(child); + } finally { + await rm(portFile, { force: true }); + } + }, + }; +} + async function stopHostServerChild( child: ChildProcessWithoutNullStreams, terminateTimeoutMs = 2_000, diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index e9e0cc88fa90..a05b9e3e7da5 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -129,6 +129,7 @@ const defaultOptions = (): LinuxOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", snapshotHint: "fresh", targetPackageSpec: "", @@ -156,6 +157,7 @@ Options: --install-version Pin site-installer version/dist-tag for the baseline lane. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. -h, --help Show help. @@ -221,6 +223,10 @@ export function parseArgs(argv: string[]): LinuxOptions { options.targetPackageSpec = ensureValue(args, i, arg); i++; break; + case "--npm-registry": + options.npmRegistry = ensureValue(args, i, arg); + i++; + break; case "--keep-server": options.keepServer = true; break; @@ -521,7 +527,17 @@ fi`); } const tgzUrl = this.server.urlFor(this.artifact.path); this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`); - this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]); + const npmArgs = ["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]; + this.guestExec( + this.options.npmRegistry + ? [ + "/usr/bin/env", + `NPM_CONFIG_REGISTRY=${this.options.npmRegistry}`, + `npm_config_registry=${this.options.npmRegistry}`, + ...npmArgs, + ] + : npmArgs, + ); this.guestExec(["openclaw", "--version"]); } diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 72513b7ce335..a1ae225f526d 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -63,6 +63,7 @@ interface MacosOptions { hostIp?: string; latestVersion?: string; installVersion?: string; + npmRegistry?: string; targetPackageSpec?: string; skipLatestRefCheck: boolean; keepServer: boolean; @@ -126,6 +127,7 @@ const defaultOptions = (): MacosOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", skipLatestRefCheck: false, snapshotHint: "macOS 26.5 latest", @@ -153,6 +155,7 @@ Options: --install-version Pin site-installer version/dist-tag for the baseline lane. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --keep-server Leave temp host HTTP server running. --discord-token-env Host env var name for Discord bot token. @@ -222,6 +225,10 @@ export function parseArgs(argv: string[]): MacosOptions { options.targetPackageSpec = ensureValue(args, i, arg); i++; break; + case "--npm-registry": + options.npmRegistry = ensureValue(args, i, arg); + i++; + break; case "--skip-latest-ref-check": options.skipLatestRefCheck = true; break; @@ -813,11 +820,14 @@ ${guestOpenClaw} --version`, } private installMain(tempName: string): void { + const npmRegistryEnv = this.options.npmRegistry + ? `NPM_CONFIG_REGISTRY=${shellQuote(this.options.npmRegistry)} npm_config_registry=${shellQuote(this.options.npmRegistry)} ` + : ""; if (this.targetInstallsDirectly()) { this .guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")} for attempt in 1 2; do - if ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then + if ${npmRegistryEnv}${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then break fi if [ "$attempt" -eq 2 ]; then @@ -834,8 +844,10 @@ ${guestOpenClaw} --version`); } const tgzUrl = this.server.urlFor(this.artifact.path); this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)} -curl -fsSL ${shellQuote(tgzUrl)} -o /tmp/${tempName} -${guestNpm} install -g /tmp/${tempName} +curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote( + tgzUrl, + )} -o /tmp/${tempName} +${npmRegistryEnv}${guestNpm} install -g /tmp/${tempName} ${guestOpenClaw} --version`); } diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index 60e67ab7b39f..3b0816657be6 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -21,6 +21,7 @@ import type { Platform, ProviderAuth } from "./types.ts"; export interface NpmUpdateScriptInput { auth: ProviderAuth; expectedNeedle: string; + npmRegistry?: string; updateTarget: string; } @@ -29,6 +30,14 @@ const macosGuestPath = "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; const macosOpenClawCommand = '"$OPENCLAW_BIN"'; +function posixNpmRegistryEnv(registry: string | undefined): string { + if (!registry) { + return ""; + } + const quoted = shellQuote(registry); + return `NPM_CONFIG_REGISTRY=${quoted} npm_config_registry=${quoted} `; +} + function posixModelProviderConfigCommands( command: string, modelId: string, @@ -115,8 +124,11 @@ fi`; } function windowsUpdateWithBundledPluginsDisabled(input: NpmUpdateScriptInput): string { + const registryEntry = input.npmRegistry + ? `; NPM_CONFIG_REGISTRY = ${psSingleQuote(input.npmRegistry)}` + : ""; return `$script:OpenClawUpdateExit = 0 -$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1' } { +$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'${registryEntry} } { Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json --no-restart 2>&1 $script:OpenClawUpdateExit = $LASTEXITCODE } @@ -250,7 +262,7 @@ wait_for_gateway() { } scrub_future_plugin_entries stop_openclaw_gateway_processes -OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart +${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixVersionCheck(macosOpenClawCommand, input.expectedNeedle)} start_openclaw_gateway wait_for_gateway @@ -394,7 +406,7 @@ wait_for_gateway() { } scrub_future_plugin_entries stop_openclaw_gateway_processes -OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart +${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixVersionCheck("openclaw", input.expectedNeedle)} start_openclaw_gateway wait_for_gateway diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 8af7a5038c5a..2149ee80a004 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -8,11 +8,11 @@ import { pathToFileURL } from "node:url"; import { die, ensureValue, + extractPackageJsonFromTgz, extractLastOpenClawVersionFromLog, makeTempDir, packOpenClaw, packageBuildCommitFromTgz, - packageVersionFromTgz, parsePlatformList, parseProvider, readPositiveIntEnv, @@ -26,10 +26,13 @@ import { say, shellQuote, startHostServer, + startNpmRegistryServer, withProgressOnStderr, writeSummaryMarkdown, writeJson, type HostServer, + type NpmRegistryPackage, + type NpmRegistryServer, type PackageArtifact, type Platform, type Provider, @@ -42,6 +45,7 @@ import { runTimedUpdateJob } from "./update-job-timeout.ts"; interface NpmUpdateOptions { betaValidation?: string; + dependencyTarballs: string[]; freshTargetSpec?: string; hostIp?: string; macosVm?: string; @@ -293,6 +297,7 @@ Options: --update-target Target passed to guest 'openclaw update --tag'. Default: host-served tgz packed from current checkout. --target-tarball Host-serve this prepared tgz for update and fresh install. + --dependency-tarball Companion package tgz required by the target. Repeatable. --fresh-target Also run fresh install smoke for this package after update lanes. --beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update plus fresh target install. Default target when flag is bare: beta. @@ -315,6 +320,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { const options: NpmUpdateOptions = { apiKeyEnv: undefined, betaValidation: undefined, + dependencyTarballs: [], freshTargetSpec: undefined, json: false, macosVm: undefined, @@ -342,6 +348,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { options.targetTarball = ensureValue(args, i, arg); i++; break; + case "--dependency-tarball": + options.dependencyTarballs.push(ensureValue(args, i, arg)); + i++; + break; case "--fresh-target": options.freshTargetSpec = ensureValue(args, i, arg); i++; @@ -401,6 +411,9 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { "--target-tarball cannot be combined with --beta-validation, --update-target, or --fresh-target", ); } + if (options.dependencyTarballs.length > 0 && !options.targetTarball) { + throw new Error("--dependency-tarball requires --target-tarball"); + } return options; } @@ -451,6 +464,7 @@ export class NpmUpdateSmoke { private harnessTargetFamily = ""; private hostIp = ""; protected server: HostServer | null = null; + private registryServer: NpmRegistryServer | null = null; private artifact: PackageArtifact | null = null; private freshTargetSpec = ""; private startedAt = Date.now(); @@ -461,7 +475,9 @@ export class NpmUpdateSmoke { private updateTargetTarball = ""; private targetTarballPath = ""; private targetTarballBuildCommit = ""; + private targetDependencyPackages: NpmRegistryPackage[] = []; private targetTarballVersion = ""; + private targetRegistryUrl = ""; private macosVm = macosVmDefault; private linuxVm = linuxVmDefault; @@ -492,6 +508,7 @@ export class NpmUpdateSmoke { await this.runSteps(); } finally { await this.server?.stop().catch(() => undefined); + await this.registryServer?.stop().catch(() => undefined); await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined); } } @@ -643,6 +660,9 @@ export class NpmUpdateSmoke { auth.apiKeyEnv, "--target-package-spec", packageSpec, + ...(phase === "fresh-target" && this.targetRegistryUrl + ? ["--npm-registry", this.targetRegistryUrl] + : []), "--json", ...extraArgs, ]; @@ -686,6 +706,31 @@ export class NpmUpdateSmoke { path: hostedTarballPath, version: this.targetTarballVersion, }; + if (this.targetDependencyPackages.length > 0) { + // Prepared sibling packages publish before core, so pre-publish VM installs need + // a local registry that serves the exact package set without touching public npm. + this.registryServer = await startNpmRegistryServer({ + hostIp: this.hostIp, + packages: [ + { + name: "openclaw", + version: this.targetTarballVersion, + tarballPath: hostedTarballPath, + }, + ...this.targetDependencyPackages, + ], + }); + this.targetRegistryUrl = this.registryServer.url; + this.updateTargetTarball = `${this.registryServer.url}/openclaw/-/${path.basename( + hostedTarballPath, + )}`; + this.updateTargetEffective = this.targetTarballVersion; + this.freshTargetSpec = this.updateTargetTarball; + this.updateExpectedNeedle = this.targetTarballVersion; + this.updateTargetPackageVersion = this.targetTarballVersion; + this.updateTargetBuildCommit = this.artifact.buildCommitShort ?? ""; + return; + } this.server = await startHostServer({ artifactPath: this.artifact.path, dir: this.tgzDir, @@ -873,6 +918,7 @@ export class NpmUpdateSmoke { const input = { auth: this.authForPlatform(platform), expectedNeedle: this.updateExpectedNeedle, + npmRegistry: this.targetRegistryUrl, updateTarget: this.updateTargetEffective, }; switch (platform) { @@ -1262,10 +1308,42 @@ export class NpmUpdateSmoke { throw new Error(`target tarball does not exist: ${targetTarballPath}`); } this.targetTarballPath = targetTarballPath; - [this.targetTarballVersion, this.targetTarballBuildCommit] = await Promise.all([ - packageVersionFromTgz(targetTarballPath), + const [targetPackageJson, targetBuildCommit] = await Promise.all([ + extractPackageJsonFromTgz<{ + dependencies?: Record; + version?: string; + }>(targetTarballPath, "package/package.json"), packageBuildCommitFromTgz(targetTarballPath), ]); + this.targetTarballVersion = targetPackageJson.version ?? ""; + this.targetTarballBuildCommit = targetBuildCommit; + this.targetDependencyPackages = await Promise.all( + this.options.dependencyTarballs.map(async (dependencyTarball) => { + const tarballPath = path.resolve(dependencyTarball); + if (!existsSync(tarballPath)) { + throw new Error(`dependency tarball does not exist: ${tarballPath}`); + } + const dependencyPackage = await extractPackageJsonFromTgz<{ + name?: string; + version?: string; + }>(tarballPath, "package/package.json"); + const name = dependencyPackage.name ?? ""; + const version = dependencyPackage.version ?? ""; + if (!name || !version || name === "openclaw") { + throw new Error(`dependency tarball has invalid package metadata: ${tarballPath}`); + } + if (targetPackageJson.dependencies?.[name] !== version) { + throw new Error( + `target tarball requires ${name}@${targetPackageJson.dependencies?.[name] ?? ""}, but companion tarball provides ${version}`, + ); + } + return { name, version, tarballPath }; + }), + ); + const dependencyNames = new Set(this.targetDependencyPackages.map((pkg) => pkg.name)); + if (dependencyNames.size !== this.targetDependencyPackages.length) { + throw new Error("dependency tarballs must have unique package names"); + } if (!this.targetTarballVersion || !this.targetTarballBuildCommit) { throw new Error( `target tarball is missing package or build metadata: ${targetTarballPath}`, diff --git a/scripts/e2e/parallels/smoke-common.ts b/scripts/e2e/parallels/smoke-common.ts index 3b4eec00f0dd..da8c34890ad2 100644 --- a/scripts/e2e/parallels/smoke-common.ts +++ b/scripts/e2e/parallels/smoke-common.ts @@ -24,6 +24,7 @@ export interface SmokeRunOptions { json: boolean; keepServer: boolean; mode: Mode; + npmRegistry?: string; provider: Provider; snapshotHint: string; targetPackageSpec?: string; diff --git a/scripts/e2e/parallels/types.ts b/scripts/e2e/parallels/types.ts index 19f54bb4a9b9..f4b357356d1a 100644 --- a/scripts/e2e/parallels/types.ts +++ b/scripts/e2e/parallels/types.ts @@ -45,3 +45,14 @@ export interface HostServer { urlFor(filePath: string): string; stop(): Promise; } + +export interface NpmRegistryPackage { + name: string; + version: string; + tarballPath: string; +} + +export interface NpmRegistryServer { + url: string; + stop(): Promise; +} diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index a5baa34379b4..bb5480e372a4 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -104,6 +104,7 @@ const defaultOptions = (): WindowsOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", skipLatestRefCheck: false, snapshotHint: "pre-openclaw-native-e2e-2026-03-12", @@ -138,6 +139,7 @@ Options: then run openclaw update --channel dev. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --skip-latest-ref-check Skip latest-release ref-mode precheck. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -171,6 +173,9 @@ export function parseArgs(argv: string[]): WindowsOptions { "--model": (value) => { options.modelId = value; }, + "--npm-registry": (value) => { + options.npmRegistry = value; + }, "--openai-api-key-env": (value) => { options.apiKeyEnv = value; }, @@ -560,10 +565,14 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST die("package artifact/server missing"); } const tgzUrl = this.server.urlFor(this.artifact.path); + const registryScript = this.options.npmRegistry + ? `$env:NPM_CONFIG_REGISTRY = ${psSingleQuote(this.options.npmRegistry)}` + : ""; this.guestPowerShell( `$ErrorActionPreference = 'Stop' $tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)} curl.exe -fsSL ${psSingleQuote(tgzUrl)} -o $tgz +${registryScript} npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } Invoke-OpenClaw --version diff --git a/scripts/lib/actions-artifact-archive.mjs b/scripts/lib/actions-artifact-archive.mjs new file mode 100644 index 000000000000..d1b569d365f7 --- /dev/null +++ b/scripts/lib/actions-artifact-archive.mjs @@ -0,0 +1,994 @@ +import { createHash } from "node:crypto"; +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { basename } from "node:path"; +import { inflateRawSync } from "node:zlib"; + +export const ACTIONS_ARTIFACT_API_VERSION = "2026-03-10"; +export const DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES = 256 * 1024 * 1024; +export const DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES = 512 * 1024 * 1024; + +const DEFAULT_MAX_JSON_BYTES = 2 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 60_000; +const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u; +const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u; +const COMMIT_SHA_RE = /^[0-9a-f]{40}$/u; +const REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const SUPPORTED_ZIP_FLAGS = 0x0808; +const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008; +const ZIP_UTF8_FLAG = 0x0800; +const ZIP_LOCAL_HEADER_SIGNATURE = 0x04034b50; +const ZIP_CENTRAL_HEADER_SIGNATURE = 0x02014b50; +const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50; +const ZIP_EOCD_SIGNATURE = 0x06054b50; +const ZIP_EOCD_BYTES = 22; +const ZIP_DATA_DESCRIPTOR_BYTES = 16; +const ZIP_MAX_COMMENT_BYTES = 65_535; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); + +function assertPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive safe integer.`); + } + return value; +} + +function assertTrimmedString(value, label) { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error(`${label} must be a non-empty trimmed string.`); + } + return value; +} + +function hasControlCharacters(value) { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint <= 0x1f || codePoint === 0x7f) { + return true; + } + } + return false; +} + +function boundedLimit(value, fallback, label) { + if (value === undefined) { + return fallback; + } + if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) { + throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`); + } + return value; +} + +function asBuffer(bytes, label) { + if (!(bytes instanceof Uint8Array)) { + throw new Error(`${label} must be a Uint8Array.`); + } + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function decodeUtf8Exact(bytes, label) { + let value; + try { + value = UTF8_DECODER.decode(bytes); + } catch { + throw new Error(`${label} is not valid UTF-8.`); + } + if (!Buffer.from(value, "utf8").equals(bytes)) { + throw new Error(`${label} is not canonically encoded UTF-8.`); + } + return value; +} + +function assertSafeArchivePath(value, label) { + if ( + typeof value !== "string" || + value.length === 0 || + value.startsWith("/") || + value.endsWith("/") || + value.includes("\\") || + value.includes("\0") || + value.normalize("NFC") !== value || + hasControlCharacters(value) + ) { + throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`); + } + const parts = value.split("/"); + if (parts.some((part) => part.length === 0 || part === "." || part === "..")) { + throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`); + } + return value; +} + +function assertArtifactName(value) { + const name = assertTrimmedString(value, "Actions artifact name"); + if (!ARTIFACT_NAME_RE.test(name)) { + throw new Error(`Invalid Actions artifact name: ${name}`); + } + return name; +} + +function assertArtifactDigest(value) { + const digest = assertTrimmedString(value, "Actions artifact digest"); + if (!ARTIFACT_DIGEST_RE.test(digest)) { + throw new Error("Actions artifact digest must be sha256:<64 lowercase hex>."); + } + return digest; +} + +function assertCommitSha(value, label) { + const sha = assertTrimmedString(value, label); + if (!COMMIT_SHA_RE.test(sha)) { + throw new Error(`${label} must be a full lowercase commit SHA.`); + } + return sha; +} + +function assertWorkflowPath(value) { + const workflowPath = assertTrimmedString(value, "workflow path"); + if ( + !/^\.github\/workflows\/[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml$/u.test(workflowPath) || + hasControlCharacters(workflowPath) + ) { + throw new Error(`Invalid workflow path: ${workflowPath}`); + } + return workflowPath; +} + +function assertRepository(value) { + const repository = assertTrimmedString(value, "GitHub repository"); + if (!REPOSITORY_RE.test(repository)) { + throw new Error("GitHub repository must be owner/name."); + } + return repository; +} + +export function sha256Digest(bytes) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function describeActionsArtifactFiles(files) { + if (!(files instanceof Map)) { + throw new Error("Actions artifact files must be a Map."); + } + return [...files.entries()] + .map(([path, bytes]) => { + const safePath = assertSafeArchivePath(path, "Actions artifact file path"); + const content = asBuffer(bytes, `Actions artifact file ${safePath}`); + return { + path: safePath, + sha256: sha256Digest(content).slice("sha256:".length), + sizeBytes: content.byteLength, + }; + }) + .toSorted((left, right) => compareCodeUnits(left.path, right.path)); +} + +export function readBoundedRegularFile(path, params) { + if (!Number.isSafeInteger(params.maxBytes) || params.maxBytes <= 0) { + throw new Error(`${params.label} byte limit must be a positive safe integer.`); + } + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile()) { + throw new Error(`${params.label} must be a regular file.`); + } + if (before.size === 0 || before.size > params.maxBytes) { + throw new Error(`${params.label} size is outside the allowed range: ${before.size}.`); + } + + const bytes = Buffer.allocUnsafe(before.size); + let offset = 0; + while (offset < bytes.length) { + const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) { + throw new Error(`${params.label} changed while it was being read.`); + } + offset += bytesRead; + } + const extra = Buffer.allocUnsafe(1); + const extraBytes = readSync(descriptor, extra, 0, 1, before.size); + const after = fstatSync(descriptor); + if (extraBytes !== 0 || after.size !== before.size) { + throw new Error(`${params.label} changed while it was being read.`); + } + return bytes; + } catch (error) { + if (error && typeof error === "object" && error.code === "ELOOP") { + throw new Error(`${params.label} must be a regular file.`, { cause: error }); + } + throw error; + } finally { + if (descriptor !== undefined) { + closeSync(descriptor); + } + } +} + +function findEndOfCentralDirectory(bytes) { + const minOffset = Math.max(0, bytes.length - ZIP_EOCD_BYTES - ZIP_MAX_COMMENT_BYTES); + for (let offset = bytes.length - ZIP_EOCD_BYTES; offset >= minOffset; offset -= 1) { + if (bytes.readUInt32LE(offset) !== ZIP_EOCD_SIGNATURE) { + continue; + } + const commentLength = bytes.readUInt16LE(offset + 20); + if (offset + ZIP_EOCD_BYTES + commentLength === bytes.length) { + if (commentLength !== 0) { + throw new Error("Actions artifact ZIP comments are not supported."); + } + return offset; + } + } + throw new Error( + "Actions artifact ZIP is missing an exact terminal end-of-central-directory record.", + ); +} + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function normalizeArchivePolicy(policy) { + if (!policy || typeof policy !== "object") { + throw new Error("Actions artifact ZIP policy is required."); + } + const maxArchiveBytes = boundedLimit( + policy.maxArchiveBytes, + DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES, + "Actions artifact ZIP byte limit", + ); + const maxExpandedBytes = boundedLimit( + policy.maxExpandedBytes, + DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES, + "Actions artifact ZIP expanded-byte limit", + ); + let expectedEntries; + if (policy.expectedEntries !== undefined) { + if ( + !Array.isArray(policy.expectedEntries) || + policy.expectedEntries.length === 0 || + policy.expectedEntries.length > 1024 + ) { + throw new Error("Actions artifact ZIP expected inventory is invalid."); + } + expectedEntries = policy.expectedEntries.map((name) => + assertSafeArchivePath(name, "expected ZIP entry path"), + ); + if (new Set(expectedEntries).size !== expectedEntries.length) { + throw new Error("Actions artifact ZIP expected inventory contains duplicates."); + } + } + const minEntries = expectedEntries?.length ?? policy.minEntries; + const maxEntries = expectedEntries?.length ?? policy.maxEntries; + if ( + !Number.isSafeInteger(minEntries) || + !Number.isSafeInteger(maxEntries) || + minEntries <= 0 || + maxEntries < minEntries || + maxEntries > 1024 + ) { + throw new Error("Actions artifact ZIP entry-count policy is invalid."); + } + if (!expectedEntries && typeof policy.allowPath !== "function") { + throw new Error("Actions artifact ZIP path policy is required."); + } + if (typeof policy.maxEntryBytes !== "function") { + throw new Error("Actions artifact ZIP per-entry expanded-byte policy is required."); + } + if ( + policy.maxCompressedEntryBytes !== undefined && + typeof policy.maxCompressedEntryBytes !== "function" + ) { + throw new Error("Actions artifact ZIP per-entry compressed-byte policy is invalid."); + } + return { + expectedEntries, + expectedEntrySet: expectedEntries ? new Set(expectedEntries) : undefined, + maxArchiveBytes, + maxEntries, + maxExpandedBytes, + minEntries, + rejectCaseFoldAliases: policy.rejectCaseFoldAliases !== false, + allowPath: expectedEntries + ? (name) => new Set(expectedEntries).has(name) + : (name) => policy.allowPath(name), + maxCompressedEntryBytes: (name) => + boundedLimit( + policy.maxCompressedEntryBytes?.(name), + maxArchiveBytes, + `Actions artifact ZIP compressed entry limit for ${name}`, + ), + maxEntryBytes: (name) => + boundedLimit( + policy.maxEntryBytes(name), + maxExpandedBytes, + `Actions artifact ZIP expanded entry limit for ${name}`, + ), + }; +} + +function inspectCentralDirectory(bytes, eocd, policy) { + const disk = bytes.readUInt16LE(eocd + 4); + const centralDisk = bytes.readUInt16LE(eocd + 6); + const diskEntries = bytes.readUInt16LE(eocd + 8); + const totalEntries = bytes.readUInt16LE(eocd + 10); + const centralSize = bytes.readUInt32LE(eocd + 12); + const centralOffset = bytes.readUInt32LE(eocd + 16); + if ( + disk !== 0 || + centralDisk !== 0 || + diskEntries !== totalEntries || + totalEntries === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff + ) { + throw new Error("Multi-disk and ZIP64 Actions artifacts are not supported."); + } + if ( + totalEntries < policy.minEntries || + totalEntries > policy.maxEntries || + centralOffset + centralSize !== eocd + ) { + throw new Error( + `Actions artifact ZIP must contain between ${policy.minEntries} and ${policy.maxEntries} exact files with a contiguous central directory.`, + ); + } + + const records = []; + const names = new Set(); + const aliases = new Set(); + let offset = centralOffset; + let declaredExpandedBytes = 0; + for (let index = 0; index < totalEntries; index += 1) { + if (offset + 46 > eocd || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIGNATURE) { + throw new Error("Invalid Actions artifact ZIP central-directory entry."); + } + const versionNeeded = bytes.readUInt16LE(offset + 6); + const flags = bytes.readUInt16LE(offset + 8); + const compression = bytes.readUInt16LE(offset + 10); + const expectedCrc = bytes.readUInt32LE(offset + 16); + const compressedSize = bytes.readUInt32LE(offset + 20); + const expandedSize = bytes.readUInt32LE(offset + 24); + const nameLength = bytes.readUInt16LE(offset + 28); + const extraLength = bytes.readUInt16LE(offset + 30); + const commentLength = bytes.readUInt16LE(offset + 32); + const startDisk = bytes.readUInt16LE(offset + 34); + const externalAttributes = bytes.readUInt32LE(offset + 38); + const localOffset = bytes.readUInt32LE(offset + 42); + const nameStart = offset + 46; + const nameEnd = nameStart + nameLength; + const nextOffset = nameEnd + extraLength + commentLength; + if (nextOffset > eocd) { + throw new Error("Actions artifact ZIP central directory exceeds its declared boundary."); + } + if ( + versionNeeded > 20 || + startDisk !== 0 || + nameLength === 0 || + extraLength !== 0 || + commentLength !== 0 + ) { + throw new Error("Actions artifact ZIP central-directory encoding is not canonical."); + } + if ((flags & ~SUPPORTED_ZIP_FLAGS) !== 0 || (flags & 0x0001) !== 0) { + throw new Error(`Unsupported Actions artifact ZIP flags 0x${flags.toString(16)}.`); + } + if (compression !== 0 && compression !== 8) { + throw new Error(`Unsupported Actions artifact ZIP compression method ${compression}.`); + } + if ( + compressedSize === 0xffffffff || + expandedSize === 0xffffffff || + localOffset === 0xffffffff + ) { + throw new Error("ZIP64 Actions artifact entries are not supported."); + } + + const rawName = Buffer.from(bytes.subarray(nameStart, nameEnd)); + if ((flags & ZIP_UTF8_FLAG) === 0 && rawName.some((byte) => byte >= 0x80)) { + throw new Error( + "Actions artifact ZIP non-ASCII entry names must set the UTF-8 language flag.", + ); + } + const name = assertSafeArchivePath( + decodeUtf8Exact(rawName, "Actions artifact ZIP entry path"), + "ZIP entry path", + ); + const alias = name.toLocaleLowerCase("en-US"); + if ( + names.has(name) || + (policy.rejectCaseFoldAliases && aliases.has(alias)) || + !policy.allowPath(name) + ) { + throw new Error(`Unexpected, duplicate, or aliased Actions artifact ZIP entry: ${name}`); + } + names.add(name); + aliases.add(alias); + + const unixMode = (externalAttributes >>> 16) & 0xffff; + const unixType = unixMode & 0o170000; + const dosAttributes = externalAttributes & 0xffff; + if ((unixType !== 0 && unixType !== 0o100000) || (dosAttributes & 0x10) !== 0) { + throw new Error(`Actions artifact ZIP entry is not a regular file: ${name}`); + } + const maxCompressedBytes = policy.maxCompressedEntryBytes(name); + const maxEntryBytes = policy.maxEntryBytes(name); + if ( + compressedSize > maxCompressedBytes || + expandedSize > maxEntryBytes || + declaredExpandedBytes + expandedSize > policy.maxExpandedBytes + ) { + throw new Error(`Actions artifact ZIP entry is too large: ${name}`); + } + if (compression === 0 && compressedSize !== expandedSize) { + throw new Error(`Stored Actions artifact ZIP entry size mismatch for ${name}.`); + } + declaredExpandedBytes += expandedSize; + records.push({ + compressedSize, + compression, + expandedSize, + expectedCrc, + flags, + localOffset, + maxEntryBytes, + name, + rawName, + versionNeeded, + }); + offset = nextOffset; + } + if (offset !== eocd) { + throw new Error("Actions artifact ZIP central-directory size mismatch."); + } + return { centralOffset, records }; +} + +function expandZipEntry(compressed, record, remainingExpandedBytes) { + const outputLimit = Math.min(record.maxEntryBytes, remainingExpandedBytes); + try { + if (record.compression === 0) { + return compressed; + } + const result = inflateRawSync(compressed, { + info: true, + maxOutputLength: Math.max(1, outputLimit), + }); + if (result.engine.bytesWritten !== compressed.length) { + throw new Error("deflate stream does not consume its full compressed member"); + } + return result.buffer; + } catch (error) { + throw new Error( + `Actions artifact ZIP entry expansion exceeds its allowed range: ${record.name}`, + { cause: error }, + ); + } +} + +function inspectLocalRecords(bytes, centralOffset, records, policy) { + const files = new Map(); + const ordered = records.toSorted((left, right) => left.localOffset - right.localOffset); + let expectedOffset = 0; + let totalExpandedBytes = 0; + + for (const [index, record] of ordered.entries()) { + if ( + record.localOffset !== expectedOffset || + record.localOffset + 30 > centralOffset || + bytes.readUInt32LE(record.localOffset) !== ZIP_LOCAL_HEADER_SIGNATURE + ) { + throw new Error( + `Non-contiguous or invalid Actions artifact ZIP local record: ${record.name}`, + ); + } + const localVersionNeeded = bytes.readUInt16LE(record.localOffset + 4); + const localFlags = bytes.readUInt16LE(record.localOffset + 6); + const localCompression = bytes.readUInt16LE(record.localOffset + 8); + const localCrc = bytes.readUInt32LE(record.localOffset + 14); + const localCompressedSize = bytes.readUInt32LE(record.localOffset + 18); + const localExpandedSize = bytes.readUInt32LE(record.localOffset + 22); + const localNameLength = bytes.readUInt16LE(record.localOffset + 26); + const localExtraLength = bytes.readUInt16LE(record.localOffset + 28); + const localNameStart = record.localOffset + 30; + const localNameEnd = localNameStart + localNameLength; + if ( + localNameEnd > centralOffset || + localVersionNeeded !== record.versionNeeded || + localFlags !== record.flags || + localCompression !== record.compression || + localExtraLength !== 0 + ) { + throw new Error(`Actions artifact ZIP local header mismatch for ${record.name}.`); + } + const rawLocalName = bytes.subarray(localNameStart, localNameEnd); + if (!record.rawName.equals(rawLocalName)) { + throw new Error(`Actions artifact ZIP local and central names differ for ${record.name}.`); + } + decodeUtf8Exact(rawLocalName, `Actions artifact ZIP local entry path for ${record.name}`); + + const dataStart = localNameEnd; + const dataEnd = dataStart + record.compressedSize; + if (dataEnd > centralOffset) { + throw new Error(`Actions artifact ZIP data exceeds its boundary: ${record.name}`); + } + + let recordEnd = dataEnd; + if ((record.flags & ZIP_DATA_DESCRIPTOR_FLAG) !== 0) { + if (localCrc !== 0 || localCompressedSize !== 0 || localExpandedSize !== 0) { + throw new Error( + `Actions artifact ZIP descriptor-backed local sizes must be zero: ${record.name}`, + ); + } + if ( + dataEnd + ZIP_DATA_DESCRIPTOR_BYTES > centralOffset || + bytes.readUInt32LE(dataEnd) !== ZIP_DATA_DESCRIPTOR_SIGNATURE || + bytes.readUInt32LE(dataEnd + 4) !== record.expectedCrc || + bytes.readUInt32LE(dataEnd + 8) !== record.compressedSize || + bytes.readUInt32LE(dataEnd + 12) !== record.expandedSize + ) { + throw new Error(`Invalid Actions artifact ZIP data descriptor for ${record.name}.`); + } + recordEnd += ZIP_DATA_DESCRIPTOR_BYTES; + } else if ( + localCrc !== record.expectedCrc || + localCompressedSize !== record.compressedSize || + localExpandedSize !== record.expandedSize + ) { + throw new Error(`Actions artifact ZIP local sizes or CRC differ for ${record.name}.`); + } + + const nextOffset = ordered[index + 1]?.localOffset ?? centralOffset; + if (recordEnd !== nextOffset) { + throw new Error(`Actions artifact ZIP contains a gap or overlap after ${record.name}.`); + } + + const compressed = bytes.subarray(dataStart, dataEnd); + const expanded = expandZipEntry( + compressed, + record, + policy.maxExpandedBytes - totalExpandedBytes, + ); + if ( + expanded.length !== record.expandedSize || + totalExpandedBytes + expanded.length > policy.maxExpandedBytes || + crc32(expanded) !== record.expectedCrc + ) { + throw new Error(`Actions artifact ZIP checksum mismatch for ${record.name}.`); + } + totalExpandedBytes += expanded.length; + files.set(record.name, Buffer.from(expanded)); + expectedOffset = recordEnd; + } + if (expectedOffset !== centralOffset) { + throw new Error("Actions artifact ZIP local records do not end at the central directory."); + } + return files; +} + +export function inspectActionsArtifactZipWithPolicy(inputBytes, inputPolicy) { + const bytes = asBuffer(inputBytes, "Actions artifact ZIP"); + const policy = normalizeArchivePolicy(inputPolicy); + if (bytes.length === 0 || bytes.length > policy.maxArchiveBytes) { + throw new Error(`Actions artifact ZIP size is outside the allowed range: ${bytes.length}.`); + } + const eocd = findEndOfCentralDirectory(bytes); + const { centralOffset, records } = inspectCentralDirectory(bytes, eocd, policy); + const files = inspectLocalRecords(bytes, centralOffset, records, policy); + if (policy.expectedEntries) { + const actual = [...files.keys()].toSorted(compareCodeUnits); + const expected = [...policy.expectedEntries].toSorted(compareCodeUnits); + if ( + actual.length !== expected.length || + actual.some((name, index) => name !== expected[index]) + ) { + throw new Error( + `Actions artifact ZIP inventory mismatch: expected ${expected.join(", ")}, found ${actual.join(", ")}.`, + ); + } + } + return files; +} + +export function inspectActionsArtifactZip(bytes, expectedEntries = 2, limits = {}) { + let expectedInventory; + let expectedCount; + if (Array.isArray(expectedEntries)) { + expectedInventory = expectedEntries; + expectedCount = expectedEntries.length; + } else { + expectedCount = assertPositiveInteger(expectedEntries, "Expected Actions artifact entry count"); + } + const maxArchiveBytes = boundedLimit( + limits.maxArchiveBytes, + DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES, + "Actions artifact ZIP byte limit", + ); + const maxExpandedBytes = boundedLimit( + limits.maxExpandedBytes, + DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES, + "Actions artifact ZIP expanded-byte limit", + ); + const maxEntryBytes = boundedLimit( + limits.maxEntryBytes, + maxExpandedBytes, + "Actions artifact ZIP expanded entry limit", + ); + const maxCompressedEntryBytes = boundedLimit( + limits.maxCompressedEntryBytes, + maxArchiveBytes, + "Actions artifact ZIP compressed entry limit", + ); + return inspectActionsArtifactZipWithPolicy(bytes, { + expectedEntries: expectedInventory, + minEntries: expectedCount, + maxEntries: expectedCount, + maxArchiveBytes, + maxExpandedBytes, + allowPath: expectedInventory ? undefined : (name) => basename(name) === name, + maxCompressedEntryBytes: () => maxCompressedEntryBytes, + maxEntryBytes: () => maxEntryBytes, + }); +} + +function requireExpectedBinding(params) { + const expected = params.expected; + if (!expected || typeof expected !== "object") { + throw new Error("Expected Actions artifact binding is required."); + } + const repository = assertRepository(expected.repository); + const artifactId = assertPositiveInteger(expected.artifactId, "Actions artifact ID"); + const artifactName = assertArtifactName(expected.artifactName); + const artifactDigest = assertArtifactDigest(expected.artifactDigest); + const artifactSizeBytes = assertPositiveInteger( + expected.artifactSizeBytes, + "Actions artifact size", + ); + const runId = assertPositiveInteger(expected.runId, "workflow run ID"); + const runAttempt = assertPositiveInteger(expected.runAttempt, "workflow run attempt"); + const workflowSha = assertCommitSha(expected.workflowSha, "workflow SHA"); + const workflowPath = assertWorkflowPath(expected.workflowPath); + const workflowEvent = assertTrimmedString(expected.workflowEvent, "workflow event"); + const workflowHeadBranch = assertTrimmedString( + expected.workflowHeadBranch, + "workflow head branch", + ); + const runStatePolicy = assertTrimmedString(expected.runStatePolicy, "workflow run-state policy"); + if (runStatePolicy !== "completed-success" && runStatePolicy !== "same-run-producer-success") { + throw new Error(`Unsupported workflow run-state policy: ${runStatePolicy}`); + } + const consumerRunAttempt = + runStatePolicy === "same-run-producer-success" + ? assertPositiveInteger(expected.consumerRunAttempt, "consumer workflow run attempt") + : undefined; + const producerJobName = + runStatePolicy === "same-run-producer-success" + ? assertTrimmedString(expected.producerJobName, "producer job name") + : undefined; + if (consumerRunAttempt !== undefined && runAttempt > consumerRunAttempt) { + throw new Error("Producer workflow run attempt must not be newer than the consumer attempt."); + } + return { + artifactDigest, + artifactId, + artifactName, + artifactSizeBytes, + consumerRunAttempt, + producerJobName, + repository, + runStatePolicy, + runAttempt, + runId, + workflowEvent, + workflowHeadBranch, + workflowPath, + workflowSha, + }; +} + +export function validateActionsArtifactBinding(params) { + const expected = requireExpectedBinding(params); + const artifact = params.artifactMetadata; + const run = params.workflowRun; + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { + throw new Error("Actions artifact metadata must be an object."); + } + if (!run || typeof run !== "object" || Array.isArray(run)) { + throw new Error("Actions workflow run metadata must be an object."); + } + if ( + artifact.id !== expected.artifactId || + artifact.name !== expected.artifactName || + artifact.size_in_bytes !== expected.artifactSizeBytes || + artifact.expired !== false || + artifact.digest !== expected.artifactDigest || + artifact.workflow_run?.id !== expected.runId || + artifact.workflow_run?.head_sha !== expected.workflowSha + ) { + throw new Error("Actions artifact metadata does not match the immutable publication tuple."); + } + if ( + run.id !== expected.runId || + run.run_attempt !== expected.runAttempt || + run.head_sha !== expected.workflowSha || + run.head_branch !== expected.workflowHeadBranch || + run.event !== expected.workflowEvent || + run.path !== expected.workflowPath || + run.repository?.full_name !== expected.repository || + run.head_repository?.full_name !== expected.repository + ) { + throw new Error("Actions workflow run does not match the immutable publication tuple."); + } + if (expected.runStatePolicy === "completed-success") { + if (run.status !== "completed" || run.conclusion !== "success") { + throw new Error("Actions workflow run does not match the immutable publication tuple."); + } + } else if (expected.runAttempt === expected.consumerRunAttempt) { + if (run.status !== "in_progress" || run.conclusion !== null) { + throw new Error("Current producer workflow attempt must still be in progress."); + } + } else if ( + run.status !== "completed" || + typeof run.conclusion !== "string" || + run.conclusion.length === 0 + ) { + throw new Error("Prior producer workflow attempt must be completed."); + } + return expected; +} + +export function validateActionsArtifactProducerJob(params) { + const expected = requireExpectedBinding(params); + if (expected.runStatePolicy !== "same-run-producer-success") { + return expected; + } + const response = params.workflowJobs; + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw new Error("Actions workflow jobs response must be an object."); + } + if ( + !Number.isSafeInteger(response.total_count) || + response.total_count < 0 || + !Array.isArray(response.jobs) || + response.total_count !== response.jobs.length + ) { + throw new Error("Actions workflow jobs inventory is incomplete."); + } + const matches = response.jobs.filter((job) => job?.name === expected.producerJobName); + if (matches.length !== 1) { + throw new Error("Actions artifact producer job must be unique."); + } + const [producerJob] = matches; + if ( + producerJob.run_id !== expected.runId || + producerJob.run_attempt !== expected.runAttempt || + producerJob.head_sha !== expected.workflowSha || + producerJob.status !== "completed" || + producerJob.conclusion !== "success" + ) { + throw new Error("Actions artifact producer job did not complete successfully."); + } + return expected; +} + +async function readBoundedResponseBody(response, params) { + if (!response.ok || !response.body) { + await response.body?.cancel(); + throw new Error(`${params.label} returned HTTP ${response.status}.`); + } + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLength)) { + await response.body?.cancel(); + throw new Error(`${params.label} returned an invalid Content-Length.`); + } + const declaredBytes = Number(contentLength); + if ( + !Number.isSafeInteger(declaredBytes) || + declaredBytes > params.maxBytes || + (params.expectedBytes !== undefined && declaredBytes !== params.expectedBytes) + ) { + await response.body?.cancel(); + throw new Error(`${params.label} Content-Length is outside the approved range.`); + } + } + + const chunks = []; + let totalBytes = 0; + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if ( + totalBytes > params.maxBytes || + (params.expectedBytes !== undefined && totalBytes > params.expectedBytes) + ) { + await reader.cancel(); + throw new Error(`${params.label} exceeded its approved byte count.`); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } finally { + reader.releaseLock(); + } + if (totalBytes === 0) { + throw new Error(`${params.label} returned an empty body.`); + } + if (params.expectedBytes !== undefined && totalBytes !== params.expectedBytes) { + throw new Error(`${params.label} size does not match metadata.`); + } + return Buffer.concat(chunks, totalBytes); +} + +async function runBoundedRetry(label, operation, params) { + let lastError; + for (let attempt = 1; attempt <= params.attempts; attempt += 1) { + try { + return await operation(attempt); + } catch (error) { + lastError = error; + if (attempt === params.attempts) { + break; + } + await new Promise((resolvePromise) => { + setTimeout(resolvePromise, params.delayMs); + }); + } + } + throw new Error( + `${label} failed after ${params.attempts} attempts: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + { cause: lastError }, + ); +} + +async function fetchBoundedJson(url, request, params) { + const response = await request.fetchImpl(url, { + headers: request.headers, + redirect: "follow", + signal: AbortSignal.timeout(request.timeoutMs), + }); + const bytes = await readBoundedResponseBody(response, { + label: params.label, + maxBytes: params.maxBytes, + }); + let value; + try { + value = JSON.parse(decodeUtf8Exact(bytes, `${params.label} body`)); + } catch (error) { + throw new Error( + `${params.label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${params.label} JSON must be an object.`); + } + return value; +} + +export async function downloadActionsArtifactArchive(params) { + const expected = requireExpectedBinding(params); + const token = assertTrimmedString(params.token, "GitHub token"); + const timeoutMs = boundedLimit(params.timeoutMs, DEFAULT_TIMEOUT_MS, "GitHub request timeout"); + const retryAttempts = + params.retryAttempts === undefined + ? 3 + : boundedLimit(params.retryAttempts, 5, "GitHub request retry count"); + const retryDelayMs = + params.retryDelayMs === undefined + ? 250 + : boundedLimit(params.retryDelayMs, 5_000, "GitHub retry delay"); + const maxArchiveBytes = boundedLimit( + params.maxArchiveBytes, + DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES, + "Actions artifact ZIP byte limit", + ); + if (expected.artifactSizeBytes > maxArchiveBytes) { + throw new Error("Actions artifact size exceeds the configured archive limit."); + } + const fetchImpl = params.fetchImpl ?? fetch; + const headers = { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "openclaw-publication-artifact", + "x-github-api-version": ACTIONS_ARTIFACT_API_VERSION, + }; + const apiRoot = `https://api.github.com/repos/${expected.repository}`; + const request = { fetchImpl, headers, timeoutMs }; + const retry = { + attempts: retryAttempts, + delayMs: retryDelayMs, + }; + const artifactMetadata = await runBoundedRetry( + "GitHub Actions artifact metadata", + () => + fetchBoundedJson(`${apiRoot}/actions/artifacts/${expected.artifactId}`, request, { + label: "GitHub Actions artifact metadata", + maxBytes: DEFAULT_MAX_JSON_BYTES, + }), + retry, + ); + const workflowRun = await runBoundedRetry( + "GitHub Actions workflow attempt", + () => + fetchBoundedJson( + `${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}`, + request, + { + label: "GitHub Actions workflow attempt", + maxBytes: DEFAULT_MAX_JSON_BYTES, + }, + ), + retry, + ); + validateActionsArtifactBinding({ artifactMetadata, expected, workflowRun }); + let workflowJobs; + if (expected.runStatePolicy === "same-run-producer-success") { + workflowJobs = await runBoundedRetry( + "GitHub Actions producer jobs", + () => + fetchBoundedJson( + `${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}/jobs?per_page=100`, + request, + { + label: "GitHub Actions producer jobs", + maxBytes: DEFAULT_MAX_JSON_BYTES, + }, + ), + retry, + ); + validateActionsArtifactProducerJob({ expected, workflowJobs }); + } + + const archiveBytes = await runBoundedRetry( + "GitHub Actions artifact download", + async () => { + const response = await fetchImpl(`${apiRoot}/actions/artifacts/${expected.artifactId}/zip`, { + headers, + redirect: "follow", + signal: AbortSignal.timeout(timeoutMs), + }); + const bytes = await readBoundedResponseBody(response, { + expectedBytes: expected.artifactSizeBytes, + label: "GitHub Actions artifact download", + maxBytes: maxArchiveBytes, + }); + const actualDigest = sha256Digest(bytes); + if (actualDigest !== expected.artifactDigest) { + throw new Error( + `GitHub Actions artifact digest ${actualDigest} does not match ${expected.artifactDigest}.`, + ); + } + return bytes; + }, + retry, + ); + return { archiveBytes, artifactMetadata, binding: expected, workflowJobs, workflowRun }; +} + +export async function readPublicationArtifactArchive(params) { + const downloaded = await downloadActionsArtifactArchive(params); + const files = inspectActionsArtifactZipWithPolicy(downloaded.archiveBytes, params.archivePolicy); + return { ...downloaded, files }; +} diff --git a/scripts/lib/clawhub-bootstrap-artifact.mjs b/scripts/lib/clawhub-bootstrap-artifact.mjs new file mode 100644 index 000000000000..d2b61aec0d95 --- /dev/null +++ b/scripts/lib/clawhub-bootstrap-artifact.mjs @@ -0,0 +1,803 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { lstat, mkdir, readdir, realpath, writeFile } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + CLAWHUB_PUBLICATION_TAR_LIMITS, + inspectPackageTarballBytes, + validatePluginPackageManifest, +} from "../plugin-publication-artifact.mjs"; +import { + describeActionsArtifactFiles, + readBoundedRegularFile, + readPublicationArtifactArchive, +} from "./actions-artifact-archive.mjs"; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u; +const COMMIT_PATTERN = /^[a-f0-9]{40}$/u; +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const PACKAGE_NAME_PATTERN = /^@openclaw\/[a-z0-9][a-z0-9._-]*$/u; +const PACKAGE_DIR_PATTERN = /^extensions\/[a-z0-9][a-z0-9._-]*$/u; +const TAG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u; +const VERSION_PATTERN = + /^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u; +const TOOLCHAIN_VERSION_PATTERN = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u; +const MAX_BOOTSTRAP_ARCHIVE_BYTES = 256 * 1024 * 1024; +const MAX_BOOTSTRAP_ARCHIVE_FILES = 128; +const MAX_BOOTSTRAP_PACKAGES = MAX_BOOTSTRAP_ARCHIVE_FILES - 1; +const MAX_BOOTSTRAP_MANIFEST_BYTES = 2 * 1024 * 1024; +// The compressed and total-payload limits match ClawHub's ClawPack contract. +// The expanded TAR and entry-count ceilings bound this credential-job parser. +const MAX_CLAWPACK_BYTES = CLAWHUB_PUBLICATION_TAR_LIMITS.maxArchiveBytes; + +function fail(message) { + throw new Error(message); +} + +function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function requireString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + fail(`${label} is required.`); + } + return value.trim(); +} + +function requirePattern(value, pattern, label) { + const result = requireString(value, label); + if (!pattern.test(result)) { + fail(`${label} is invalid.`); + } + return result; +} + +function requireBoolean(value, label) { + if (typeof value !== "boolean") { + fail(`${label} must be a boolean.`); + } + return value; +} + +function parsePlugins(value) { + const plugins = requireString(value, "plugins") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + const unique = [...new Set(plugins)].toSorted(compareCodeUnits); + if (unique.length !== plugins.length) { + fail("plugins must not contain duplicates."); + } + for (const plugin of unique) { + requirePattern(plugin, PACKAGE_NAME_PATTERN, `plugin ${plugin}`); + } + return unique; +} + +function packageSlug(packageName) { + return packageName.slice("@openclaw/".length); +} + +function normalizePlanEntry(value, index) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`matrix[${index}] must be an object.`); + } + const packageName = requirePattern( + value.packageName, + PACKAGE_NAME_PATTERN, + `matrix[${index}].packageName`, + ); + const packageDir = requirePattern( + value.packageDir, + PACKAGE_DIR_PATTERN, + `matrix[${index}].packageDir`, + ); + const publishTag = requirePattern(value.publishTag, TAG_PATTERN, `matrix[${index}].publishTag`); + const version = requirePattern(value.version, VERSION_PATTERN, `matrix[${index}].version`); + const bootstrapMode = requireString(value.bootstrapMode, `matrix[${index}].bootstrapMode`); + if (bootstrapMode !== "publish" && bootstrapMode !== "configure-only") { + fail(`matrix[${index}].bootstrapMode is invalid.`); + } + const requiresManualOverride = requireBoolean( + value.requiresManualOverride, + `matrix[${index}].requiresManualOverride`, + ); + if (bootstrapMode === "configure-only" && !requiresManualOverride) { + fail(`matrix[${index}] configure-only entries must require the manual override.`); + } + return { + packageName, + version, + packageDir, + publishTag, + bootstrapMode, + requiresManualOverride, + }; +} + +function hashBytes(bytes) { + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + }; +} + +async function hashFile(path) { + return hashBytes( + readBoundedRegularFile(path, { + label: "Packed ClawHub artifact", + maxBytes: MAX_CLAWPACK_BYTES, + }), + ); +} + +export async function verifyClawHubPackedArtifactIdentity(options) { + const artifactPath = resolve(requireString(options.artifactPath, "artifactPath")); + const expectedSha256 = requirePattern(options.expectedSha256, SHA256_PATTERN, "expectedSha256"); + const expectedSize = requirePattern( + options.expectedSize, + POSITIVE_INTEGER_PATTERN, + "expectedSize", + ); + const expectedName = requirePattern(options.expectedName, PACKAGE_NAME_PATTERN, "expectedName"); + const expectedVersion = requireString(options.expectedVersion, "expectedVersion"); + const expectedDir = requirePattern(options.expectedDir, PACKAGE_DIR_PATTERN, "expectedDir"); + + const artifactStat = await lstat(artifactPath); + if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) { + fail("Packed ClawHub artifact must be a regular file."); + } + if (artifactStat.size > MAX_CLAWPACK_BYTES) { + fail(`Packed ClawHub artifact exceeds ${MAX_CLAWPACK_BYTES} bytes.`); + } + if (String(artifactStat.size) !== expectedSize) { + fail("Packed ClawHub artifact hash or size mismatch."); + } + const bytes = readBoundedRegularFile(artifactPath, { + label: "Packed ClawHub artifact", + maxBytes: MAX_CLAWPACK_BYTES, + }); + const identity = hashBytes(bytes); + if (identity.sha256 !== expectedSha256 || String(identity.size) !== expectedSize) { + fail("Packed ClawHub artifact hash or size mismatch."); + } + + const inspection = inspectPackageTarballBytes(bytes, CLAWHUB_PUBLICATION_TAR_LIMITS); + validatePluginPackageManifest( + { + packageDir: expectedDir, + packageName: expectedName, + route: "clawhub-token-bootstrap", + version: expectedVersion, + }, + inspection.packageManifest, + ); + const packageName = inspection.packageManifest.name; + const packageVersion = inspection.packageManifest.version; + return { + ...identity, + inventory: inspection.inventory, + packageJsonSha256: inspection.packageManifestSha256, + packageName, + packageVersion, + pluginManifestSha256: inspection.pluginManifestSha256, + }; +} + +async function listFiles(root) { + const result = []; + let visitedEntries = 0; + let totalPathBytes = 0; + async function visit(directory, depth) { + if (depth > 4) { + fail("Artifact inventory exceeds the supported directory depth."); + } + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + visitedEntries += 1; + if (visitedEntries > MAX_BOOTSTRAP_ARCHIVE_FILES * 4) { + fail("Artifact inventory contains too many filesystem entries."); + } + if (entry.isSymbolicLink()) { + fail(`Artifact inventory contains a symlink: ${relative(root, path)}`); + } + if (entry.isDirectory()) { + await visit(path, depth + 1); + } else if (entry.isFile()) { + const artifactPath = relative(root, path).split(sep).join("/"); + totalPathBytes += Buffer.byteLength(artifactPath, "utf8"); + if ( + result.length >= MAX_BOOTSTRAP_ARCHIVE_FILES || + totalPathBytes > MAX_BOOTSTRAP_MANIFEST_BYTES + ) { + fail("Artifact inventory exceeds its file or path-byte limit."); + } + result.push(artifactPath); + } else { + fail(`Artifact inventory contains a non-regular entry: ${relative(root, path)}`); + } + } + } + await visit(root, 0); + return result.toSorted(compareCodeUnits); +} + +function readPositiveInteger(value, label) { + const raw = requirePattern(value, POSITIVE_INTEGER_PATTERN, label); + const result = Number(raw); + if (!Number.isSafeInteger(result)) { + fail(`${label} is outside the supported range.`); + } + return result; +} + +function requireExactKeys(value, expected, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${label} must be an object.`); + } + const actual = Object.keys(value).toSorted(compareCodeUnits); + if (JSON.stringify(actual) !== JSON.stringify([...expected].toSorted(compareCodeUnits))) { + fail(`${label} keys are invalid: ${actual.join(",")}.`); + } +} + +function normalizeBootstrapManifestEntry(value, index) { + requireExactKeys( + value, + [ + "artifactPath", + "bootstrapMode", + "packageDir", + "packageName", + "publishTag", + "requiresManualOverride", + "sha256", + "size", + "version", + ], + `manifest.entries[${index}]`, + ); + const entry = normalizePlanEntry(value, index); + const expectedPrefix = `packages/${packageSlug(entry.packageName)}/`; + const artifactPath = requireString(value.artifactPath, `manifest.entries[${index}].artifactPath`); + if ( + artifactPath !== `${expectedPrefix}${basename(artifactPath)}` || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(basename(artifactPath)) + ) { + fail(`Bootstrap Actions artifact path is invalid: ${artifactPath}`); + } + const sha256 = requirePattern(value.sha256, SHA256_PATTERN, `manifest.entries[${index}].sha256`); + if (!Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_CLAWPACK_BYTES) { + fail(`manifest.entries[${index}].size is invalid.`); + } + return { ...entry, artifactPath, sha256, size: value.size }; +} + +export function parseClawHubBootstrapManifestBytes(inputBytes) { + const manifestBytes = Buffer.isBuffer(inputBytes) ? inputBytes : Buffer.from(inputBytes); + if (manifestBytes.byteLength === 0 || manifestBytes.byteLength > MAX_BOOTSTRAP_MANIFEST_BYTES) { + fail(`ClawHub bootstrap manifest must be 1-${MAX_BOOTSTRAP_MANIFEST_BYTES} bytes.`); + } + let manifest; + try { + manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes)); + } catch { + fail("ClawHub bootstrap manifest is not valid UTF-8 JSON."); + } + requireExactKeys( + manifest, + [ + "artifactName", + "clawhubToolchainIntegrity", + "clawhubToolchainSha256", + "clawhubToolchainVersion", + "entries", + "repository", + "requestedPlugins", + "runAttempt", + "runId", + "schemaVersion", + "targetSha", + "workflowSha", + ], + "ClawHub bootstrap manifest", + ); + if (manifest.schemaVersion !== 1) { + fail(`Unsupported ClawHub bootstrap manifest schema: ${String(manifest.schemaVersion)}.`); + } + const repository = requirePattern(manifest.repository, REPOSITORY_PATTERN, "manifest.repository"); + const targetSha = requirePattern(manifest.targetSha, COMMIT_PATTERN, "manifest.targetSha"); + const workflowSha = requirePattern(manifest.workflowSha, COMMIT_PATTERN, "manifest.workflowSha"); + const runId = requirePattern(manifest.runId, POSITIVE_INTEGER_PATTERN, "manifest.runId"); + const runAttempt = requirePattern( + manifest.runAttempt, + POSITIVE_INTEGER_PATTERN, + "manifest.runAttempt", + ); + const artifactName = requireString(manifest.artifactName, "manifest.artifactName"); + const clawhubToolchainSha256 = requirePattern( + manifest.clawhubToolchainSha256, + SHA256_PATTERN, + "manifest.clawhubToolchainSha256", + ); + const clawhubToolchainVersion = requirePattern( + manifest.clawhubToolchainVersion, + TOOLCHAIN_VERSION_PATTERN, + "manifest.clawhubToolchainVersion", + ); + const clawhubToolchainIntegrity = requirePattern( + manifest.clawhubToolchainIntegrity, + SHA512_INTEGRITY_PATTERN, + "manifest.clawhubToolchainIntegrity", + ); + if ( + !Array.isArray(manifest.requestedPlugins) || + manifest.requestedPlugins.length === 0 || + manifest.requestedPlugins.length > MAX_BOOTSTRAP_PACKAGES || + manifest.requestedPlugins.some((entry) => typeof entry !== "string") + ) { + fail("ClawHub bootstrap manifest requestedPlugins is invalid."); + } + const requestedPlugins = parsePlugins(manifest.requestedPlugins.join(",")); + if (JSON.stringify(requestedPlugins) !== JSON.stringify(manifest.requestedPlugins)) { + fail("ClawHub bootstrap manifest requestedPlugins is not canonical."); + } + if ( + !Array.isArray(manifest.entries) || + manifest.entries.length === 0 || + manifest.entries.length > MAX_BOOTSTRAP_PACKAGES + ) { + fail("ClawHub bootstrap manifest entries are invalid."); + } + const entries = manifest.entries.map(normalizeBootstrapManifestEntry); + if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) { + fail("ClawHub bootstrap manifest contains duplicate package names."); + } + assertExactPackageSet(entries, requestedPlugins); + const entryNames = entries.map((entry) => entry.packageName); + if (JSON.stringify(entryNames) !== JSON.stringify(entryNames.toSorted(compareCodeUnits))) { + fail("ClawHub bootstrap manifest entries are not canonical."); + } + return { + artifactName, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + entries, + repository, + requestedPlugins, + runAttempt, + runId, + schemaVersion: 1, + targetSha, + workflowSha, + }; +} + +export function readClawHubBootstrapManifest(path) { + return parseClawHubBootstrapManifestBytes( + readBoundedRegularFile(path, { + label: "ClawHub bootstrap manifest", + maxBytes: MAX_BOOTSTRAP_MANIFEST_BYTES, + }), + ); +} + +function validateBootstrapArchiveInventory(files, expectedBinding) { + const manifestBytes = files.get("manifest.json"); + if (!manifestBytes) { + fail("Bootstrap Actions artifact must contain manifest.json."); + } + const manifest = parseClawHubBootstrapManifestBytes(manifestBytes); + for (const [key, expected] of Object.entries(expectedBinding)) { + if (manifest[key] !== String(expected)) { + fail(`Bootstrap Actions artifact manifest ${key} mismatch.`); + } + } + const { entries, requestedPlugins } = manifest; + + const expected = new Set(["manifest.json"]); + for (const entry of entries) { + const artifactPath = entry.artifactPath; + if (expected.has(artifactPath)) { + fail(`Bootstrap Actions artifact path is duplicated: ${artifactPath}`); + } + const bytes = files.get(artifactPath); + if (!bytes || bytes.byteLength !== entry.size || hashBytes(bytes).sha256 !== entry.sha256) { + fail(`Bootstrap Actions artifact bytes do not match manifest: ${artifactPath}`); + } + expected.add(artifactPath); + } + const actual = new Set(files.keys()); + if (actual.size !== expected.size || [...actual].some((path) => !expected.has(path))) { + fail( + `Bootstrap Actions artifact inventory mismatch: expected ${[...expected].toSorted(compareCodeUnits).join(",")}, found ${[...actual].toSorted(compareCodeUnits).join(",")}.`, + ); + } + return { entries, manifest, requestedPlugins }; +} + +export async function downloadClawHubBootstrapArtifact(options) { + const artifactId = readPositiveInteger(options.artifactId, "artifactId"); + const artifactSizeBytes = readPositiveInteger(options.artifactSize, "artifactSize"); + const runId = readPositiveInteger(options.runId, "runId"); + const runAttempt = readPositiveInteger(options.runAttempt, "runAttempt"); + const consumerRunAttempt = readPositiveInteger(options.consumerRunAttempt, "consumerRunAttempt"); + const producerJobName = requireString(options.producerJobName, "producerJobName"); + const targetSha = requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha"); + const workflowSha = requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha"); + const artifactDigest = requirePattern(options.artifactDigest, SHA256_PATTERN, "artifactDigest"); + const artifactName = requireString(options.artifactName, "artifactName"); + const repository = requirePattern(options.repository, REPOSITORY_PATTERN, "repository"); + const clawhubToolchainSha256 = requirePattern( + options.clawhubToolchainSha256, + SHA256_PATTERN, + "clawhubToolchainSha256", + ); + const clawhubToolchainVersion = requirePattern( + options.clawhubToolchainVersion, + TOOLCHAIN_VERSION_PATTERN, + "clawhubToolchainVersion", + ); + const clawhubToolchainIntegrity = requirePattern( + options.clawhubToolchainIntegrity, + SHA512_INTEGRITY_PATTERN, + "clawhubToolchainIntegrity", + ); + const expectedName = `clawhub-bootstrap-${targetSha.slice(0, 12)}-${runId}-${runAttempt}`; + if (artifactName !== expectedName) { + fail("ClawHub bootstrap artifact name does not bind the target and producer attempt."); + } + const outputRoot = resolve(requireString(options.outputRoot, "outputRoot")); + try { + await lstat(outputRoot); + fail("ClawHub bootstrap artifact output directory must not already exist."); + } catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") { + throw error; + } + } + const result = await readPublicationArtifactArchive({ + archivePolicy: { + minEntries: 2, + maxEntries: MAX_BOOTSTRAP_ARCHIVE_FILES, + maxArchiveBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES, + maxExpandedBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES, + allowPath: (path) => + path === "manifest.json" || + /^packages\/[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(path), + maxCompressedEntryBytes: (path) => + path === "manifest.json" ? MAX_BOOTSTRAP_MANIFEST_BYTES : MAX_CLAWPACK_BYTES, + maxEntryBytes: (path) => + path === "manifest.json" ? MAX_BOOTSTRAP_MANIFEST_BYTES : MAX_CLAWPACK_BYTES, + }, + expected: { + artifactDigest: `sha256:${artifactDigest}`, + artifactId, + artifactName, + artifactSizeBytes, + consumerRunAttempt, + producerJobName, + repository, + runStatePolicy: "same-run-producer-success", + runAttempt, + runId, + workflowEvent: "workflow_dispatch", + workflowHeadBranch: "main", + workflowPath: ".github/workflows/plugin-clawhub-new.yml", + workflowSha, + }, + maxArchiveBytes: MAX_BOOTSTRAP_ARCHIVE_BYTES, + fetchImpl: options.fetchImpl, + retryAttempts: options.retryAttempts, + retryDelayMs: options.retryDelayMs, + token: requireString(options.token, "token"), + }); + const validated = validateBootstrapArchiveInventory(result.files, { + artifactName, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + repository, + runAttempt, + runId, + targetSha, + workflowSha, + }); + await mkdir(dirname(outputRoot), { mode: 0o700, recursive: true }); + try { + await mkdir(outputRoot, { mode: 0o700 }); + } catch (error) { + if (error && typeof error === "object" && error.code === "EEXIST") { + fail("ClawHub bootstrap artifact output directory must not already exist."); + } + throw error; + } + const outputRootStat = await lstat(outputRoot); + if (!outputRootStat.isDirectory() || outputRootStat.isSymbolicLink()) { + fail("ClawHub bootstrap artifact output directory must be newly created."); + } + for (const [path, bytes] of result.files) { + const destination = join(outputRoot, path); + await mkdir(dirname(destination), { mode: 0o700, recursive: true }); + await writeFile(destination, bytes, { flag: "wx", mode: 0o600 }); + } + return { + artifactDigest, + artifactId, + artifactName, + artifactSizeBytes, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + inventory: describeActionsArtifactFiles(result.files), + packages: validated.entries, + runAttempt, + runId, + }; +} + +async function resolveRegularArtifactFile(root, artifactPath) { + if ( + typeof artifactPath !== "string" || + artifactPath.startsWith("/") || + artifactPath.includes("\\") || + artifactPath.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + fail(`Unsafe artifact path: ${String(artifactPath)}`); + } + const rootReal = await realpath(root); + const candidate = resolve(root, artifactPath); + const candidateReal = await realpath(candidate); + if (candidateReal !== rootReal && !candidateReal.startsWith(`${rootReal}${sep}`)) { + fail(`Artifact path escapes the artifact root: ${artifactPath}`); + } + const fileStat = await lstat(candidate); + if (!fileStat.isFile() || fileStat.isSymbolicLink()) { + fail(`Artifact path is not a regular file: ${artifactPath}`); + } + return candidate; +} + +function assertExactPackageSet(entries, expectedPlugins) { + const actual = entries.map((entry) => entry.packageName).toSorted(compareCodeUnits); + if (JSON.stringify(actual) !== JSON.stringify(expectedPlugins)) { + fail( + `Artifact package set does not match requested plugins: expected ${expectedPlugins.join(",")}, found ${actual.join(",")}.`, + ); + } +} + +export async function createClawHubBootstrapArtifactManifest(options) { + const artifactRoot = resolve(options.artifactRoot); + const matrix = JSON.parse( + readBoundedRegularFile(options.matrixPath, { + label: "ClawHub bootstrap matrix", + maxBytes: MAX_BOOTSTRAP_MANIFEST_BYTES, + }).toString("utf8"), + ); + if (!Array.isArray(matrix) || matrix.length === 0 || matrix.length > MAX_BOOTSTRAP_PACKAGES) { + fail("matrix must be a non-empty array."); + } + const entries = matrix.map(normalizePlanEntry); + const expectedPlugins = parsePlugins(options.plugins); + if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) { + fail("matrix must not contain duplicate package names."); + } + assertExactPackageSet(entries, expectedPlugins); + + const manifestEntries = []; + for (const entry of entries.toSorted((a, b) => compareCodeUnits(a.packageName, b.packageName))) { + const packageDirectory = join(artifactRoot, "packages", packageSlug(entry.packageName)); + const files = (await readdir(packageDirectory)).filter((name) => name.endsWith(".tgz")); + if (files.length !== 1) { + fail(`${entry.packageName} must have exactly one packed .tgz artifact.`); + } + const artifactPath = `packages/${packageSlug(entry.packageName)}/${files[0]}`; + const filePath = await resolveRegularArtifactFile(artifactRoot, artifactPath); + const identity = await hashFile(filePath); + manifestEntries.push({ ...entry, artifactPath, ...identity }); + } + + const manifest = { + schemaVersion: 1, + repository: requirePattern(options.repository, REPOSITORY_PATTERN, "repository"), + targetSha: requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha"), + workflowSha: requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha"), + runId: requirePattern(options.runId, POSITIVE_INTEGER_PATTERN, "runId"), + runAttempt: requirePattern(options.runAttempt, POSITIVE_INTEGER_PATTERN, "runAttempt"), + artifactName: requireString(options.artifactName, "artifactName"), + clawhubToolchainIntegrity: requirePattern( + options.clawhubToolchainIntegrity, + SHA512_INTEGRITY_PATTERN, + "clawhubToolchainIntegrity", + ), + clawhubToolchainSha256: requirePattern( + options.clawhubToolchainSha256, + SHA256_PATTERN, + "clawhubToolchainSha256", + ), + clawhubToolchainVersion: requirePattern( + options.clawhubToolchainVersion, + TOOLCHAIN_VERSION_PATTERN, + "clawhubToolchainVersion", + ), + requestedPlugins: expectedPlugins, + entries: manifestEntries, + }; + await mkdir(dirname(options.outputPath), { recursive: true }); + await writeFile(options.outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + return manifest; +} + +export async function verifyClawHubBootstrapArtifactManifest(options) { + const artifactRoot = resolve(options.artifactRoot); + const manifest = readClawHubBootstrapManifest(options.manifestPath); + const expected = { + repository: requirePattern(options.repository, REPOSITORY_PATTERN, "repository"), + targetSha: requirePattern(options.targetSha, COMMIT_PATTERN, "targetSha"), + workflowSha: requirePattern(options.workflowSha, COMMIT_PATTERN, "workflowSha"), + runId: requirePattern(options.runId, POSITIVE_INTEGER_PATTERN, "runId"), + runAttempt: requirePattern(options.runAttempt, POSITIVE_INTEGER_PATTERN, "runAttempt"), + artifactName: requireString(options.artifactName, "artifactName"), + clawhubToolchainIntegrity: requirePattern( + options.clawhubToolchainIntegrity, + SHA512_INTEGRITY_PATTERN, + "clawhubToolchainIntegrity", + ), + clawhubToolchainSha256: requirePattern( + options.clawhubToolchainSha256, + SHA256_PATTERN, + "clawhubToolchainSha256", + ), + clawhubToolchainVersion: requirePattern( + options.clawhubToolchainVersion, + TOOLCHAIN_VERSION_PATTERN, + "clawhubToolchainVersion", + ), + }; + for (const [key, value] of Object.entries(expected)) { + if (manifest[key] !== value) { + fail(`Bootstrap artifact manifest ${key} mismatch.`); + } + } + + const expectedPlugins = parsePlugins(options.plugins); + if (!Array.isArray(manifest.requestedPlugins)) { + fail("Bootstrap artifact manifest requestedPlugins must be an array."); + } + if (JSON.stringify(manifest.requestedPlugins) !== JSON.stringify(expectedPlugins)) { + fail("Bootstrap artifact manifest requestedPlugins mismatch."); + } + if ( + !Array.isArray(manifest.entries) || + manifest.entries.length === 0 || + manifest.entries.length > MAX_BOOTSTRAP_PACKAGES + ) { + fail("Bootstrap artifact manifest entries must be a non-empty array."); + } + + const entries = []; + const allowedFiles = new Set([relative(artifactRoot, options.manifestPath).split(sep).join("/")]); + for (const [index, rawEntry] of manifest.entries.entries()) { + const entry = normalizeBootstrapManifestEntry(rawEntry, index); + const { artifactPath } = entry; + const filePath = await resolveRegularArtifactFile(artifactRoot, artifactPath); + const identity = await hashFile(filePath); + if (identity.sha256 !== entry.sha256 || identity.size !== entry.size) { + fail(`${entry.packageName} packed artifact hash or size mismatch.`); + } + allowedFiles.add(artifactPath); + entries.push({ ...entry, artifactPath, ...identity }); + } + if (new Set(entries.map((entry) => entry.packageName)).size !== entries.length) { + fail("Bootstrap artifact manifest must not contain duplicate package names."); + } + assertExactPackageSet(entries, expectedPlugins); + + const inventory = await listFiles(artifactRoot); + const expectedInventory = [...allowedFiles].toSorted(compareCodeUnits); + if (JSON.stringify(inventory) !== JSON.stringify(expectedInventory)) { + fail( + `Bootstrap artifact inventory mismatch: expected ${expectedInventory.join(",")}, found ${inventory.join(",")}.`, + ); + } + return { ...manifest, entries }; +} + +function parseArgs(argv) { + const values = [...argv]; + const command = values.shift(); + const result = { command }; + while (values.length > 0) { + const key = values.shift(); + const value = values.shift(); + if (!key?.startsWith("--") || value === undefined) { + fail(`Invalid argument: ${String(key)}`); + } + result[key.slice(2).replaceAll("-", "_")] = value; + } + return result; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.command === "download") { + const result = await downloadClawHubBootstrapArtifact({ + artifactDigest: args.artifact_digest, + artifactId: args.artifact_id, + artifactName: args.artifact_name, + artifactSize: args.artifact_size, + consumerRunAttempt: args.consumer_run_attempt, + clawhubToolchainIntegrity: args.clawhub_toolchain_integrity, + clawhubToolchainSha256: args.clawhub_toolchain_sha256, + clawhubToolchainVersion: args.clawhub_toolchain_version, + outputRoot: args.output_root, + producerJobName: args.producer_job_name, + repository: args.repository, + runAttempt: args.run_attempt, + runId: args.run_id, + targetSha: args.target_sha, + token: process.env.GH_TOKEN, + workflowSha: args.workflow_sha, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (args.command === "verify-packed") { + const identity = await verifyClawHubPackedArtifactIdentity({ + artifactPath: args.path, + expectedDir: args.expected_dir, + expectedSha256: args.expected_sha256, + expectedSize: args.expected_size, + expectedName: args.expected_name, + expectedVersion: args.expected_version, + }); + process.stdout.write(`${JSON.stringify(identity)}\n`); + return; + } + const common = { + artifactRoot: args.artifact_root, + artifactName: args.artifact_name, + clawhubToolchainIntegrity: args.clawhub_toolchain_integrity, + clawhubToolchainSha256: args.clawhub_toolchain_sha256, + clawhubToolchainVersion: args.clawhub_toolchain_version, + repository: args.repository, + targetSha: args.target_sha, + workflowSha: args.workflow_sha, + runId: args.run_id, + runAttempt: args.run_attempt, + plugins: args.plugins, + }; + if (args.command === "create") { + await createClawHubBootstrapArtifactManifest({ + ...common, + matrixPath: args.matrix, + outputPath: args.output, + }); + return; + } + if (args.command === "verify") { + const manifest = await verifyClawHubBootstrapArtifactManifest({ + ...common, + manifestPath: args.manifest, + }); + if (args.output) { + await mkdir(dirname(args.output), { recursive: true }); + await writeFile(args.output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + } else { + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + } + return; + } + fail("Usage: clawhub-bootstrap-artifact.mjs [options]"); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/scripts/lib/npm-publish-plan.mjs b/scripts/lib/npm-publish-plan.mjs index d2a59fa4e78c..4c67463e36a6 100644 --- a/scripts/lib/npm-publish-plan.mjs +++ b/scripts/lib/npm-publish-plan.mjs @@ -1,4 +1,6 @@ // Parses OpenClaw monthly patch release versions and npm dist-tag publish plans. +import { readBoundedResponseText } from "./bounded-response.mjs"; + const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)$/; const ALPHA_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-alpha\.(?[1-9]\d*)$/; @@ -7,6 +9,7 @@ const BETA_VERSION_REGEX = const CORRECTION_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-(?[1-9]\d*)$/; export const JUNE_2026_PATCH_FLOOR = 5; +const NPM_REGISTRY_PACKUMENT_MAX_BYTES = 16 * 1024 * 1024; /** * @typedef {object} ParsedReleaseVersion @@ -28,6 +31,14 @@ export const JUNE_2026_PATCH_FLOOR = 5; * @property {("latest" | "alpha" | "beta")[]} mirrorDistTags */ +/** + * @typedef {"npm-readback" | "npm-mirror" | "npm-tag-repair"} PublishedNpmVersionRoute + */ + +/** + * @typedef {"match" | "missing" | "lagging" | "ahead" | "incomparable" | "conflict"} NpmDistTagVersionState + */ + /** * @typedef {object} NpmDistTagMirrorAuth * @property {boolean} hasAuth @@ -38,6 +49,114 @@ export const JUNE_2026_PATCH_FLOOR = 5; * @typedef {"--dry-run" | "--publish"} NpmPublishMode */ +/** + * @typedef {object} NpmRegistryPackumentResult + * @property {number} status + * @property {boolean} ok + * @property {unknown} packument + */ + +/** + * @param {Response} response + * @returns {Promise} + */ +async function cancelNpmRegistryResponseBody(response) { + await response.body?.cancel().catch(() => undefined); +} + +/** + * Fetches and consumes an npm packument within one timeout per attempt. Keeping + * body transfer inside the retry loop prevents a headers-only success from + * bypassing the retry budget when the registry stream stalls or truncates. + * + * @param {{ + * packageName: string; + * packageUrl: string; + * attempts?: number; + * timeoutMs?: number; + * maxBytes?: number; + * fetchImpl?: (input: string, init: RequestInit) => Promise; + * sleep?: (delayMs: number) => Promise; + * createSignal?: (timeoutMs: number) => AbortSignal; + * }} params + * @returns {Promise} + */ +export async function fetchNpmRegistryPackumentWithRetry(params) { + const attempts = params.attempts ?? 3; + const timeoutMs = params.timeoutMs ?? 20_000; + const maxBytes = params.maxBytes ?? NPM_REGISTRY_PACKUMENT_MAX_BYTES; + const fetchImpl = params.fetchImpl ?? globalThis.fetch; + const sleep = + params.sleep ?? + ((delayMs) => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + })); + const createSignal = params.createSignal ?? ((delayMs) => AbortSignal.timeout(delayMs)); + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let response; + const signal = createSignal(timeoutMs); + try { + response = await fetchImpl(params.packageUrl, { + headers: { accept: "application/vnd.npm.install-v1+json" }, + signal, + }); + } catch (error) { + lastError = error; + } + + if (response) { + if (response.status === 429 || response.status >= 500) { + await cancelNpmRegistryResponseBody(response); + lastError = new Error(`HTTP ${response.status}`); + } else if (!response.ok) { + await cancelNpmRegistryResponseBody(response); + return { status: response.status, ok: false, packument: null }; + } else { + let body; + try { + body = await readBoundedResponseText( + response, + `${params.packageName} npm packument`, + maxBytes, + { signal }, + ); + } catch (error) { + await cancelNpmRegistryResponseBody(response); + lastError = error; + body = undefined; + } + if (body !== undefined) { + try { + return { + status: response.status, + ok: true, + packument: JSON.parse(body), + }; + } catch (error) { + await cancelNpmRegistryResponseBody(response); + const message = error instanceof Error ? error.message : String(error); + lastError = new Error( + `${params.packageName}: npm publication-route probe returned invalid JSON: ${message}.`, + ); + } + } + } + } + + if (attempt < attempts) { + await sleep(attempt * 1000); + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `${params.packageName}: npm publication-route probe did not return a stable response: ${message}.`, + ); +} + /** * @param {string} version * @param {Record} groups @@ -252,6 +371,87 @@ export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOve }; } +/** + * @param {{ + * packageVersion: string; + * publishPlan: NpmPublishPlan; + * distTags: Record; + * }} params + * @returns {PublishedNpmVersionRoute} + */ +export function resolvePublishedNpmVersionRoute(params) { + const primaryState = classifyNpmDistTagVersion( + params.distTags[params.publishPlan.publishTag], + params.packageVersion, + ); + const needsPrimaryRepair = primaryState === "missing" || primaryState === "lagging"; + if (!needsPrimaryRepair && primaryState !== "match") { + throwUnsafeNpmDistTag( + params.publishPlan.publishTag, + params.distTags[params.publishPlan.publishTag], + params.packageVersion, + primaryState, + ); + } + + let needsMirrorRepair = false; + for (const distTag of params.publishPlan.mirrorDistTags) { + const mirrorState = classifyNpmDistTagVersion(params.distTags[distTag], params.packageVersion); + if (mirrorState === "missing" || mirrorState === "lagging") { + needsMirrorRepair = true; + continue; + } + if (mirrorState !== "match") { + throwUnsafeNpmDistTag(distTag, params.distTags[distTag], params.packageVersion, mirrorState); + } + } + if (needsPrimaryRepair) { + return "npm-tag-repair"; + } + return needsMirrorRepair ? "npm-mirror" : "npm-readback"; +} + +/** + * @param {unknown} currentVersion + * @param {string} targetVersion + * @returns {NpmDistTagVersionState} + */ +function classifyNpmDistTagVersion(currentVersion, targetVersion) { + if (currentVersion === undefined) { + return "missing"; + } + if (typeof currentVersion !== "string") { + return "incomparable"; + } + if (currentVersion === targetVersion) { + return "match"; + } + const comparison = compareReleaseVersions(currentVersion, targetVersion); + if (comparison === null) { + return "incomparable"; + } + if (comparison < 0) { + return "lagging"; + } + if (comparison > 0) { + return "ahead"; + } + return "conflict"; +} + +/** + * @param {string} distTag + * @param {unknown} currentVersion + * @param {string} targetVersion + * @param {NpmDistTagVersionState} state + * @returns {never} + */ +function throwUnsafeNpmDistTag(distTag, currentVersion, targetVersion, state) { + throw new Error( + `npm dist-tag "${distTag}" points to ${JSON.stringify(currentVersion)} and cannot be safely moved to "${targetVersion}" (${state}).`, + ); +} + /** * @param {{ * nodeAuthToken?: string | null | undefined; diff --git a/scripts/lib/openclaw-release-clawhub-plan.ts b/scripts/lib/openclaw-release-clawhub-plan.ts index dbb06756ff7d..d8518d0416a9 100644 --- a/scripts/lib/openclaw-release-clawhub-plan.ts +++ b/scripts/lib/openclaw-release-clawhub-plan.ts @@ -22,15 +22,20 @@ type ClawHubDispatchTarget = { inputs: ClawHubDispatchInputs; }; -export type OpenClawReleaseClawHubPlanArgs = { +type OpenClawReleaseClawHubPlanArgs = { + bootstrapWorkflowSha: string; releaseTag: string; + releaseSha: string; releasePublishBranch: string; + releasePublishRunAttempt: string; releasePublishRunId: string; pluginPublishScope: PluginReleaseSelectionMode; plugins: string[]; + rootDir?: string; }; -export type OpenClawReleaseClawHubPlan = { +type OpenClawReleaseClawHubPlan = { + bootstrapWorkflowSha: string; clawHubWorkflowRef: string; releasePublishBranch: string; normal: ClawHubDispatchTarget; @@ -86,6 +91,22 @@ function optionalArg(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +function requireCommitSha(value: string | undefined, label: string): string { + const sha = requireArg(value, label); + if (!/^[a-f0-9]{40}$/u.test(sha)) { + throw new Error(`${label} must be a full 40-character lowercase commit SHA.`); + } + return sha; +} + +function requirePositiveInteger(value: string | undefined, label: string): string { + const result = requireArg(value, label); + if (!/^[1-9][0-9]*$/u.test(result)) { + throw new Error(`${label} must be a positive integer.`); + } + return result; +} + function runUrl(repository: string, runId: string): string { return `https://github.com/${repository}/actions/runs/${runId}`; } @@ -110,6 +131,10 @@ function createDispatchTarget(params: { releasePublishRunId: string; releasePublishBranch: string; includePublishScope: boolean; + bootstrapWorkflowSha?: string; + releaseTag?: string; + releasePublishRunAttempt?: string; + targetRef?: string; }): ClawHubDispatchTarget { if (params.packages.length === 0) { return { @@ -129,6 +154,14 @@ function createDispatchTarget(params: { packages: [...params.packages], inputs: { ...(params.includePublishScope ? { publish_scope: "selected" } : {}), + ...(params.targetRef ? { ref: params.targetRef } : {}), + ...(params.bootstrapWorkflowSha + ? { bootstrap_workflow_sha: params.bootstrapWorkflowSha } + : {}), + ...(params.releaseTag ? { release_tag: params.releaseTag } : {}), + ...(params.releasePublishRunAttempt + ? { release_publish_run_attempt: params.releasePublishRunAttempt } + : {}), plugins, release_publish_run_id: params.releasePublishRunId, release_publish_branch: params.releasePublishBranch, @@ -194,10 +227,14 @@ export function parseOpenClawReleaseClawHubPlanArgs( } let releaseTag: string | undefined; + let releaseSha: string | undefined; + let bootstrapWorkflowSha: string | undefined; let releasePublishBranch: string | undefined; + let releasePublishRunAttempt: string | undefined; let releasePublishRunId: string | undefined; let pluginPublishScope: PluginReleaseSelectionMode | undefined; let plugins: string[] = []; + let rootDir: string | undefined; let pluginsFlagProvided = false; for (let index = 0; index < values.length; index += 1) { @@ -212,12 +249,21 @@ export function parseOpenClawReleaseClawHubPlanArgs( }; switch (arg) { + case "--bootstrap-workflow-sha": + bootstrapWorkflowSha = next(); + break; case "--release-tag": releaseTag = next(); break; + case "--release-sha": + releaseSha = next(); + break; case "--release-publish-branch": releasePublishBranch = next(); break; + case "--release-publish-run-attempt": + releasePublishRunAttempt = next(); + break; case "--release-publish-run-id": releasePublishRunId = next(); break; @@ -228,6 +274,9 @@ export function parseOpenClawReleaseClawHubPlanArgs( plugins = parsePluginReleaseSelection(next()); pluginsFlagProvided = true; break; + case "--root-dir": + rootDir = resolve(next()); + break; default: throw new Error(`Unknown argument: ${arg}`); } @@ -245,11 +294,18 @@ export function parseOpenClawReleaseClawHubPlanArgs( } return { + bootstrapWorkflowSha: requireCommitSha(bootstrapWorkflowSha, "--bootstrap-workflow-sha"), releaseTag: requireArg(releaseTag, "--release-tag"), + releaseSha: requireCommitSha(releaseSha, "--release-sha"), releasePublishBranch: requireArg(releasePublishBranch, "--release-publish-branch"), + releasePublishRunAttempt: requirePositiveInteger( + releasePublishRunAttempt, + "--release-publish-run-attempt", + ), releasePublishRunId: requireArg(releasePublishRunId, "--release-publish-run-id"), pluginPublishScope: resolvedPluginPublishScope, plugins, + rootDir, }; } @@ -261,11 +317,17 @@ export async function buildOpenClawReleaseClawHubPlan( registryBaseUrl?: string; } = {}, ): Promise { + const bootstrapWorkflowSha = requireCommitSha(args.bootstrapWorkflowSha, "bootstrapWorkflowSha"); const releaseTag = requireArg(args.releaseTag, "releaseTag"); + const releaseSha = requireCommitSha(args.releaseSha, "releaseSha"); const releasePublishBranch = requireArg(args.releasePublishBranch, "releasePublishBranch"); + const releasePublishRunAttempt = requirePositiveInteger( + args.releasePublishRunAttempt, + "releasePublishRunAttempt", + ); const releasePublishRunId = requireArg(args.releasePublishRunId, "releasePublishRunId"); const plan = await collectPluginClawHubReleasePlan({ - rootDir: options.rootDir ?? resolve("."), + rootDir: options.rootDir ?? args.rootDir ?? resolve("."), selection: args.plugins, selectionMode: args.pluginPublishScope, fetchImpl: options.fetchImpl, @@ -281,6 +343,7 @@ export async function buildOpenClawReleaseClawHubPlan( assertNoPackageOverlap(normalPackages, bootstrapPackages); return { + bootstrapWorkflowSha, clawHubWorkflowRef: releaseTag, releasePublishBranch, normal: createDispatchTarget({ @@ -293,11 +356,15 @@ export async function buildOpenClawReleaseClawHubPlan( }), bootstrap: createDispatchTarget({ workflow: "plugin-clawhub-new.yml", - ref: releaseTag, + ref: "main", packages: bootstrapPackages, releasePublishRunId, releasePublishBranch, includePublishScope: false, + bootstrapWorkflowSha, + releaseTag, + releasePublishRunAttempt, + targetRef: releaseSha, }), summary: { normalCount: normalPackages.length, diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs new file mode 100644 index 000000000000..873bf45cefd2 --- /dev/null +++ b/scripts/lib/plain-gh.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; +export const PLAIN_GH_SYSTEM_CANDIDATES = [ + // Prefer package-manager opt paths: bin/gh may intentionally be an Octopool shim. + "/opt/homebrew/opt/gh/bin/gh", + "/usr/local/opt/gh/bin/gh", + "/home/linuxbrew/.linuxbrew/opt/gh/bin/gh", + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", +]; + +function isExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function pathEntries(env) { + return String(env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean); +} + +export function plainGhEnv(env = process.env) { + const next = { ...env }; + delete next.CLICOLOR; + delete next.CLICOLOR_FORCE; + delete next.COLORTERM; + delete next.GH_FORCE_TTY; + next.NO_COLOR = "1"; + next.FORCE_COLOR = "0"; + next.CLICOLOR = "0"; + next.CLICOLOR_FORCE = "0"; + return next; +} + +export function resolvePlainGhBin( + env = process.env, + systemCandidates = PLAIN_GH_SYSTEM_CANDIDATES, +) { + if (env.OPENCLAW_GH_BIN) { + if (isExecutable(env.OPENCLAW_GH_BIN)) { + return env.OPENCLAW_GH_BIN; + } + throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`); + } + + for (const candidate of systemCandidates) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const homeBin = env.HOME ? path.join(env.HOME, "bin") : ""; + for (const entry of pathEntries(env)) { + if (homeBin && entry === homeBin) { + continue; + } + const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh"); + if (isExecutable(candidate)) { + return candidate; + } + } + + for (const entry of pathEntries(env)) { + const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh"); + if (isExecutable(candidate)) { + return candidate; + } + } + + throw new Error("missing required command: gh"); +} + +export function execPlainGh(args, options = {}) { + const env = plainGhEnv(options.env ?? process.env); + const ghBin = resolvePlainGhBin(env); + return execFileSync(ghBin, args, { + ...options, + env, + maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, + }); +} + +export function spawnPlainGh(args, options = {}) { + const env = plainGhEnv(options.env ?? process.env); + const ghBin = resolvePlainGhBin(env); + return spawnSync(ghBin, args, { + ...options, + env, + maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, + }); +} diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index d46e40c10465..40eaf7ccefe0 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -2,11 +2,15 @@ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; +import { retryClawHubRead } from "../../src/infra/clawhub-retry.js"; +import { readBoundedResponseText } from "./bounded-response.ts"; import { + assertPluginReleaseDependencyFreshness, collectExtensionPackageJsonCandidates, collectChangedPathsFromGitRange, collectChangedExtensionIdsFromPaths, collectPublishablePluginPackageErrors, + collectRequiredLatestDependencies, assertPluginReleaseVersionFloors, parsePluginReleaseArgs, resolvePublishablePluginVersion, @@ -14,10 +18,16 @@ import { resolveChangedPublishablePluginPackages, resolveSelectedPublishablePluginPackages, type GitRangeSelection, + type NpmLatestVersionResolver, type PluginReleaseSelectionMode, + type RequiredLatestDependency, } from "./plugin-npm-release.ts"; -export { assertPluginReleaseVersionFloors, parsePluginReleaseArgs }; +export { + assertPluginReleaseDependencyFreshness, + assertPluginReleaseVersionFloors, + parsePluginReleaseArgs, +}; type PluginPackageJson = { name?: string; @@ -49,7 +59,8 @@ export type PublishablePluginPackage = { packageName: string; version: string; channel: "stable" | "alpha" | "beta"; - publishTag: "latest" | "alpha" | "beta"; + publishTag: "latest" | "alpha" | "beta" | "extended-stable"; + requiredLatestDependencies?: RequiredLatestDependency[]; }; type PluginReleasePlanItem = PublishablePluginPackage & { @@ -86,6 +97,10 @@ type ClawHubPublishablePluginPackageFilters = { }; const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai"; +const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000; +const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024; +const CLAWHUB_ERROR_BODY_MAX_BYTES = 8 * 1024; +const CLAWHUB_ERROR_BODY_MAX_CHARS = 400; const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw"; const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml"; const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; @@ -114,6 +129,123 @@ function getRegistryBaseUrl(explicit?: string) { ); } +type ClawHubRequestOptions = { + fetchImpl?: typeof fetch; + requestTimeoutMs?: number; +}; + +type ClawHubRetryOptions = ClawHubRequestOptions & { + sleep?: (ms: number) => Promise; +}; + +async function fetchClawHubRequest( + url: URL, + options: ClawHubRequestOptions = {}, +): Promise<{ + clearTimeout: () => void; + response: Response; + signal: AbortSignal; + timeoutPromise: Promise; +}> { + const timeoutMs = options.requestTimeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutError = Object.assign( + new Error(`ClawHub request timed out after ${timeoutMs}ms: ${url.href}`), + { code: "ETIMEDOUT" }, + ); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + timeout.unref?.(); + }); + + try { + const response = await Promise.race([ + (options.fetchImpl ?? fetch)(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: controller.signal, + }), + timeoutPromise, + ]); + return { + clearTimeout: () => clearTimeout(timeout), + response, + signal: controller.signal, + timeoutPromise, + }; + } catch (error) { + clearTimeout(timeout); + throw error; + } +} + +async function cancelClawHubResponseBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +async function fetchClawHubRead( + url: URL, + options: ClawHubRetryOptions = {}, +): Promise>> { + return await retryClawHubRead( + () => + fetchClawHubRequest(url, { + fetchImpl: options.fetchImpl, + requestTimeoutMs: options.requestTimeoutMs, + }), + { + disposeRetry: async (request) => { + await cancelClawHubResponseBody(request.response); + request.clearTimeout(); + }, + retryRateLimit: true, + sleep: options.sleep, + }, + ); +} + +async function buildClawHubQueryError( + message: string, + request: Awaited>, +): Promise { + const { response } = request; + let body: string; + try { + body = ( + await readBoundedResponseText(response, message, CLAWHUB_ERROR_BODY_MAX_BYTES, { + signal: request.signal, + timeoutPromise: request.timeoutPromise, + }) + ) + .replace(/\s+/gu, " ") + .trim(); + } catch { + body = ""; + } + if (body.length > CLAWHUB_ERROR_BODY_MAX_CHARS) { + body = `${body.slice(0, CLAWHUB_ERROR_BODY_MAX_CHARS)}...`; + } + const diagnosticHeaders = ["retry-after", "x-request-id", "x-vercel-id", "cf-ray"] + .map((name) => { + const value = response.headers.get(name)?.trim(); + return value ? `${name}=${value}` : undefined; + }) + .filter((value): value is string => Boolean(value)); + const detail = [ + body || response.statusText || `HTTP ${response.status}`, + diagnosticHeaders.length > 0 ? `[${diagnosticHeaders.join("; ")}]` : undefined, + ] + .filter((value): value is string => Boolean(value)) + .join(" "); + return new Error(`${message}: ${response.status} ${detail}`); +} + function formatClawHubPackageArtifactName( plugin: Pick, ) { @@ -176,6 +308,7 @@ export function collectClawHubPublishablePluginPackages( continue; } const { version, parsedVersion } = resolvedVersion; + const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson).dependencies; publishable.push({ extensionId, @@ -189,6 +322,7 @@ export function collectClawHubPublishablePluginPackages( : parsedVersion.channel === "beta" ? "beta" : "latest", + ...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}), }); } @@ -343,101 +477,109 @@ export function collectClawHubVersionGateErrors(params: { async function isPluginVersionPublishedOnClawHub( packageName: string, version: string, - options: { - fetchImpl?: typeof fetch; - registryBaseUrl?: string; - } = {}, + options: ClawHubRetryOptions & { registryBaseUrl?: string } = {}, ): Promise { - const fetchImpl = options.fetchImpl ?? fetch; const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(version)}`, getRegistryBaseUrl(options.registryBaseUrl), ); - const response = await fetchImpl(url, { - method: "GET", - headers: { - Accept: "application/json", - }, + const request = await fetchClawHubRead(url, { + fetchImpl: options.fetchImpl, + requestTimeoutMs: options.requestTimeoutMs, + sleep: options.sleep, }); + const { response } = request; - if (response.status === 404) { - return false; - } - if (response.ok) { - return true; - } + try { + if (response.status === 404) { + return false; + } + if (response.ok) { + return true; + } - throw new Error( - `Failed to query ClawHub for ${packageName}@${version}: ${response.status} ${response.statusText}`, - ); + throw await buildClawHubQueryError( + `Failed to query ClawHub for ${packageName}@${version}`, + request, + ); + } finally { + await cancelClawHubResponseBody(response); + request.clearTimeout(); + } } async function doesClawHubPackageExist( packageName: string, - options: { - fetchImpl?: typeof fetch; - registryBaseUrl?: string; - } = {}, + options: ClawHubRetryOptions & { registryBaseUrl?: string } = {}, ): Promise { - const fetchImpl = options.fetchImpl ?? fetch; const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}`, getRegistryBaseUrl(options.registryBaseUrl), ); - const response = await fetchImpl(url, { - method: "GET", - headers: { - Accept: "application/json", - }, + const request = await fetchClawHubRead(url, { + fetchImpl: options.fetchImpl, + requestTimeoutMs: options.requestTimeoutMs, + sleep: options.sleep, }); + const { response } = request; - if (response.status === 404) { - return false; - } - if (!response.ok) { - throw new Error( - `Failed to query ClawHub package ${packageName}: ${response.status} ${response.statusText}`, - ); - } + try { + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw await buildClawHubQueryError(`Failed to query ClawHub package ${packageName}`, request); + } - return true; + return true; + } finally { + await cancelClawHubResponseBody(response); + request.clearTimeout(); + } } async function hasClawHubTrustedPublisher( packageName: string, - options: { - fetchImpl?: typeof fetch; + options: ClawHubRetryOptions & { registryBaseUrl?: string; } = {}, ): Promise { - const fetchImpl = options.fetchImpl ?? fetch; const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`, getRegistryBaseUrl(options.registryBaseUrl), ); - const response = await fetchImpl(url, { - method: "GET", - headers: { - Accept: "application/json", - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, - ); - } - - let trustedPublisherDetail: ClawHubTrustedPublisherDetail; + const request = await fetchClawHubRead(url, options); + const { response } = request; try { - trustedPublisherDetail = (await response.json()) as ClawHubTrustedPublisherDetail; - } catch (error) { - throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { - cause: error, - }); - } + if (!response.ok) { + throw await buildClawHubQueryError( + `Failed to query ClawHub trusted publisher for ${packageName}`, + request, + ); + } - return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); + let trustedPublisherDetail: ClawHubTrustedPublisherDetail; + const text = await readBoundedResponseText( + response, + `ClawHub trusted publisher ${packageName}`, + CLAWHUB_RESPONSE_BODY_MAX_BYTES, + { + signal: request.signal, + timeoutPromise: request.timeoutPromise, + }, + ); + try { + trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; + } catch (error) { + throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { + cause: error, + }); + } + + return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); + } finally { + request.clearTimeout(); + } } function isOpenClawPluginTrustedPublisher(value: unknown): boolean { @@ -470,6 +612,9 @@ export async function collectPluginClawHubReleasePlan(params?: { gitRange?: GitRangeSelection; registryBaseUrl?: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; + resolveLatestVersion?: NpmLatestVersionResolver; + sleep?: (ms: number) => Promise; }): Promise { const rootDir = params?.rootDir; const selection = params?.selection ?? []; @@ -500,40 +645,50 @@ export async function collectPluginClawHubReleasePlan(params?: { if (explicitPublishSelection) { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan"); } - - const planned = await Promise.all( - selectedPublishable.map(async (plugin): Promise => { - const packageExists = await doesClawHubPackageExist(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - }); - const hasTrustedPublisher = packageExists - ? await hasClawHubTrustedPublisher(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - }) - : false; - const alreadyPublished = packageExists - ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - }) - : false; - - return { - extensionId: plugin.extensionId, - packageDir: plugin.packageDir, - packageName: plugin.packageName, - version: plugin.version, - channel: plugin.channel, - publishTag: plugin.publishTag, - packageExists, - hasTrustedPublisher, - alreadyPublished, - artifactName: formatClawHubPackageArtifactName(plugin), - }; - }), + assertPluginReleaseDependencyFreshness( + selectedPublishable, + "Plugin ClawHub release plan", + params?.resolveLatestVersion, ); + + const planned: PluginReleasePlanItemWithPackageState[] = []; + for (const plugin of selectedPublishable) { + const packageExists = await doesClawHubPackageExist(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + sleep: params?.sleep, + }); + const hasTrustedPublisher = packageExists + ? await hasClawHubTrustedPublisher(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + sleep: params?.sleep, + }) + : false; + const alreadyPublished = packageExists + ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + sleep: params?.sleep, + }) + : false; + + planned.push({ + extensionId: plugin.extensionId, + packageDir: plugin.packageDir, + packageName: plugin.packageName, + version: plugin.version, + channel: plugin.channel, + publishTag: plugin.publishTag, + packageExists, + hasTrustedPublisher, + alreadyPublished, + artifactName: formatClawHubPackageArtifactName(plugin), + }); + } const all = planned.map(stripPackageReleaseState); return { diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index f58964105041..48a2c152edf5 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -5,14 +5,26 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; -import { parseReleaseVersion } from "../openclaw-npm-release-check.ts"; -import { collectReleaseVersionFloorErrors, resolveNpmPublishPlan } from "./npm-publish-plan.mjs"; +import { + collectReleaseVersionFloorErrors, + parseReleaseVersion, + resolveNpmPublishPlan, +} from "./npm-publish-plan.mjs"; + +function expectDefined(value: T | null | undefined, context: string): T { + if (value === null || value === undefined) { + throw new Error(`expected ${context} to be defined`); + } + return value; +} export type PluginPackageJson = { name?: string; version?: string; type?: string; private?: boolean; + dependencies?: Record; + optionalDependencies?: Record; repository?: | string | { @@ -35,11 +47,18 @@ export type PluginPackageJson = { pluginSdkVersion?: string; }; release?: { + publishToClawHub?: boolean; publishToNpm?: boolean; + requireLatestDependencies?: unknown; }; }; }; +export type RequiredLatestDependency = { + packageName: string; + version: string; +}; + export type PublishablePluginPackage = { extensionId: string; packageDir: string; @@ -48,6 +67,7 @@ export type PublishablePluginPackage = { channel: "stable" | "alpha" | "beta"; publishTag: "latest" | "alpha" | "beta" | "extended-stable"; installNpmSpec?: string; + requiredLatestDependencies?: RequiredLatestDependency[]; }; export type PluginReleasePlanItem = PublishablePluginPackage & { @@ -67,7 +87,7 @@ export type GitRangeSelection = { headRef: string; }; -export type ParsedPluginReleaseArgs = { +type ParsedPluginReleaseArgs = { selection: string[]; selectionMode?: PluginReleaseSelectionMode; pluginsFlagProvided: boolean; @@ -75,21 +95,82 @@ export type ParsedPluginReleaseArgs = { headRef?: string; }; -export type ParsedPluginNpmReleaseArgs = ParsedPluginReleaseArgs & { +type ParsedPluginNpmReleaseArgs = ParsedPluginReleaseArgs & { npmDistTag?: "extended-stable"; }; -export type PublishablePluginPackageCandidate< - TPackageJson extends PluginPackageJson = PluginPackageJson, -> = { - extensionId: string; - packageDir: string; - packageJson: TPackageJson; - readmeText?: string; -}; +function parsePluginNpmDistTagOverride(value: string | undefined): "extended-stable" | undefined { + if (value === undefined || value.trim() === "") { + return undefined; + } + if (value === "extended-stable") { + return value; + } + throw new Error(`Unknown npm dist-tag override: ${value}. Expected "extended-stable".`); +} + +type PublishablePluginPackageCandidate = + { + extensionId: string; + packageDir: string; + packageJson: TPackageJson; + readmeText?: string; + }; export const OPENCLAW_PLUGIN_NPM_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; +export function collectRequiredLatestDependencies(packageJson: PluginPackageJson): { + dependencies: RequiredLatestDependency[]; + errors: string[]; +} { + const configured = packageJson.openclaw?.release?.requireLatestDependencies; + if (configured === undefined) { + return { dependencies: [], errors: [] }; + } + if (!Array.isArray(configured)) { + return { + dependencies: [], + errors: ["openclaw.release.requireLatestDependencies must be an array of package names."], + }; + } + + const runtimeDependencies = { + ...packageJson.dependencies, + ...packageJson.optionalDependencies, + }; + const dependencies: RequiredLatestDependency[] = []; + const errors: string[] = []; + const seen = new Set(); + + for (const value of configured) { + if (typeof value !== "string" || !value.trim()) { + errors.push( + "openclaw.release.requireLatestDependencies must contain only non-empty package names.", + ); + continue; + } + const packageName = value.trim(); + if (seen.has(packageName)) { + errors.push( + `openclaw.release.requireLatestDependencies must not contain duplicate package names; found "${packageName}".`, + ); + continue; + } + seen.add(packageName); + + const version = runtimeDependencies[packageName]; + if (typeof version !== "string" || !version.trim()) { + errors.push( + `openclaw.release.requireLatestDependencies must reference package.json dependencies or optionalDependencies; "${packageName}" is not a runtime dependency.`, + ); + continue; + } + dependencies.push({ packageName, version: version.trim() }); + } + + return { dependencies, errors }; +} + function readPluginPackageJson(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")); } @@ -185,28 +266,28 @@ export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs let headRef: string | undefined; for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; + const arg = expectDefined(argv[index], `plugin release argument at index ${index}`); if (arg === "--") { continue; } if (arg === "--plugins") { - selection = parsePluginReleaseSelection(argv[index + 1]); + selection = parsePluginReleaseSelection(readRequiredArgValue(argv, index, arg, true)); pluginsFlagProvided = true; index += 1; continue; } if (arg === "--selection-mode") { - selectionMode = parsePluginReleaseSelectionMode(argv[index + 1]); + selectionMode = parsePluginReleaseSelectionMode(readRequiredArgValue(argv, index, arg)); index += 1; continue; } if (arg === "--base-ref") { - baseRef = argv[index + 1]; + baseRef = readRequiredArgValue(argv, index, arg); index += 1; continue; } if (arg === "--head-ref") { - headRef = argv[index + 1]; + headRef = readRequiredArgValue(argv, index, arg); index += 1; continue; } @@ -231,16 +312,14 @@ export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs if ((baseRef && !headRef) || (!baseRef && headRef)) { throw new Error("Both --base-ref and --head-ref are required together."); } - return { selection, selectionMode, pluginsFlagProvided, baseRef, headRef }; } export function parsePluginNpmReleaseArgs(argv: string[]): ParsedPluginNpmReleaseArgs { const baseArgs: string[] = []; let npmDistTag: "extended-stable" | undefined; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; + const arg = expectDefined(argv[index], `plugin npm release argument at index ${index}`); if (arg !== "--npm-dist-tag") { baseArgs.push(arg); continue; @@ -248,16 +327,9 @@ export function parsePluginNpmReleaseArgs(argv: string[]): ParsedPluginNpmReleas if (npmDistTag !== undefined) { throw new Error("--npm-dist-tag must not be provided more than once."); } - const value = argv[index + 1]; - if (value !== "extended-stable") { - throw new Error( - `Unknown npm dist-tag override: ${value ?? ""}. Expected "extended-stable".`, - ); - } - npmDistTag = value; + npmDistTag = parsePluginNpmDistTagOverride(readRequiredArgValue(argv, index, arg)); index += 1; } - const parsed = parsePluginReleaseArgs(baseArgs); if (npmDistTag === "extended-stable" && parsed.selectionMode !== "all-publishable") { throw new Error( @@ -267,6 +339,21 @@ export function parsePluginNpmReleaseArgs(argv: string[]): ParsedPluginNpmReleas return { ...parsed, npmDistTag }; } +function readRequiredArgValue( + argv: string[], + index: number, + flag: string, + allowBlank = false, +): string { + const value = argv[index + 1]; + const missingValue = + value === undefined || value.startsWith("--") || (!allowBlank && value.trim() === ""); + if (missingValue) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + export function collectPublishablePluginPackageErrors( candidate: PublishablePluginPackageCandidate, ): string[] { @@ -280,6 +367,7 @@ export function collectPublishablePluginPackageErrors( ? packageJson.repository.trim() : (packageJson.repository?.url?.trim() ?? ""); const extensions = packageJson.openclaw?.extensions ?? []; + const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson); if (!packageName.startsWith("@openclaw/")) { errors.push( @@ -316,6 +404,7 @@ export function collectPublishablePluginPackageErrors( if (!installNpmSpec) { errors.push("openclaw.install.npmSpec must be a non-empty string for publishable plugins."); } + errors.push(...requiredLatestDependencies.errors); errors.push( ...validateExternalCodePluginPackageJson(packageJson).issues.map((issue) => issue.message), ); @@ -323,7 +412,7 @@ export function collectPublishablePluginPackageErrors( return errors; } -export type PublishablePluginPackageFilters = { +type PublishablePluginPackageFilters = { extensionIds?: readonly string[]; packageNames?: readonly string[]; npmDistTag?: "extended-stable"; @@ -368,6 +457,7 @@ export function collectPublishablePluginPackages( continue; } const { version, parsedVersion } = resolvedVersion; + const requiredLatestDependencies = collectRequiredLatestDependencies(packageJson).dependencies; publishable.push({ extensionId, @@ -377,6 +467,7 @@ export function collectPublishablePluginPackages( channel: parsedVersion.channel, publishTag: resolveNpmPublishPlan(version, undefined, filters.npmDistTag).publishTag, installNpmSpec: normalizeOptionalString(packageJson.openclaw?.install?.npmSpec), + ...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}), }); } @@ -556,25 +647,88 @@ export function assertPluginReleaseVersionFloors( ); } -function isPluginVersionPublished(packageName: string, version: string): boolean { +export type NpmLatestVersionResolver = (packageName: string) => string; + +function runNpmView(args: string[]): string { const tempDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-view-")); const userconfigPath = join(tempDir, "npmrc"); writeFileSync(userconfigPath, ""); try { - execFileSync( - "npm", - ["view", `${packageName}@${version}`, "version", "--userconfig", userconfigPath], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); + return execFileSync("npm", ["view", ...args, "--userconfig", userconfigPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function resolveNpmLatestVersion(packageName: string): string { + const raw = runNpmView([packageName, "dist-tags.latest", "--json"]); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "string" || !parsed.trim()) { + throw new Error(`npm returned an invalid latest dist-tag for ${packageName}.`); + } + return parsed.trim(); +} + +export function collectPluginReleaseDependencyFreshnessErrors( + plugins: readonly PublishablePluginPackage[], + resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, +): string[] { + // Only plugin-owned opt-ins use this strict gate. It prevents release branches + // from silently carrying old executable pins while leaving normal dependencies alone. + const latestVersions = new Map(); + const errors: string[] = []; + + for (const plugin of plugins) { + for (const dependency of plugin.requiredLatestDependencies ?? []) { + let latestVersion = latestVersions.get(dependency.packageName); + if (!latestVersion) { + try { + latestVersion = resolveLatestVersion(dependency.packageName); + latestVersions.set(dependency.packageName, latestVersion); + } catch (error) { + errors.push( + `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + } + if (dependency.version !== latestVersion) { + errors.push( + `${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`, + ); + } + } + } + + return errors; +} + +export function assertPluginReleaseDependencyFreshness( + plugins: readonly PublishablePluginPackage[], + label: string, + resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, +): void { + const errors = collectPluginReleaseDependencyFreshnessErrors(plugins, resolveLatestVersion); + if (errors.length === 0) { + return; + } + throw new Error( + `${label} rejected stale required release dependencies:\n${errors + .map((error) => `- ${error}`) + .join("\n")}`, + ); +} + +function isPluginVersionPublished(packageName: string, version: string): boolean { + try { + runNpmView([`${packageName}@${version}`, "version"]); return true; } catch { return false; - } finally { - rmSync(tempDir, { recursive: true, force: true }); } } @@ -619,6 +773,7 @@ export function collectPluginReleasePlan(params?: { if (explicitPublishSelection) { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin NPM release plan"); } + assertPluginReleaseDependencyFreshness(selectedPublishable, "Plugin NPM release plan"); const all = selectedPublishable.map((plugin) => Object.assign({}, plugin, { diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 847f1e254bf5..126036921a97 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -1,7 +1,10 @@ -// Release Beta Verifier script supports OpenClaw repository automation. import { execFileSync } from "node:child_process"; +// Release Beta Verifier script supports OpenClaw repository automation. +import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs"; import { readBoundedResponseText } from "./bounded-response.ts"; import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts"; import { @@ -17,9 +20,11 @@ export type ReleaseVerifyBetaArgs = { distTag: string; repo: string; registry: string; + releaseSha?: string; workflowRef?: string; clawHubWorkflowRef?: string; pluginSelection: string[]; + clawHubBootstrapPlugins: string[]; evidenceOut?: string; skipPostpublish: boolean; skipGitHubRelease: boolean; @@ -42,22 +47,54 @@ export type NpmViewFields = { tarball?: string; }; +type FetchWithRetryResult = { + response: Response; + signal: AbortSignal; +}; + type WorkflowRunSummary = { id: string; label: string; url?: string; durationSeconds?: number; + bootstrapEvidence?: { + targetSha: string; + workflowSha: string; + workflowPath: string; + producerRunAttempt: string; + terminalRunAttempt: string; + readbackArtifactId: string; + readbackArtifactDigest: string; + packageArtifactId: string; + packageArtifactDigest: string; + packageCount: number; + clawhubToolchainIntegrity: string; + clawhubToolchainSha256: string; + clawhubToolchainVersion: string; + }; }; const DEFAULT_REPO = "openclaw/openclaw"; const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai"; +const CLAWHUB_BOOTSTRAP_WORKFLOW_PATH = ".github/workflows/plugin-clawhub-new.yml"; +const CLAWHUB_BOOTSTRAP_READBACK_FILE = "clawhub-bootstrap-readback.json"; const CLAWHUB_REQUEST_TIMEOUT_MS = 20_000; const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 1024 * 1024; +const CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES = 2 * 1024 * 1024; +const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u; +const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u; +const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); // Trusted publish can finish before npm registry metadata converges. Keep the // verifier on the same release train instead of forcing a republish/correction. const NPM_VIEW_ATTEMPTS = 30; const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000; +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -74,6 +111,35 @@ function requireString(value: unknown, label: string): string { return stringValue; } +function readTrustedClawHubToolchainIdentity(): { + clawhubToolchainIntegrity: string; + clawhubToolchainSha256: string; + clawhubToolchainVersion: string; +} { + const lockPath = resolve(TRUSTED_TOOLING_ROOT, ".github/release/clawhub-cli/package-lock.json"); + const lockBytes = readFileSync(lockPath); + const lock = parseJson(lockBytes.toString("utf8"), "trusted ClawHub CLI package-lock.json"); + if (!isRecord(lock) || !isRecord(lock.packages)) { + throw new Error("Trusted ClawHub CLI package-lock.json is invalid."); + } + const clawhub = lock.packages["node_modules/clawhub"]; + if (!isRecord(clawhub)) { + throw new Error("Trusted ClawHub CLI package-lock.json is missing clawhub."); + } + const clawhubToolchainIntegrity = requireString( + clawhub.integrity, + "trusted ClawHub CLI integrity", + ); + if (!SHA512_INTEGRITY_PATTERN.test(clawhubToolchainIntegrity)) { + throw new Error("Trusted ClawHub CLI integrity is invalid."); + } + return { + clawhubToolchainIntegrity, + clawhubToolchainSha256: createHash("sha256").update(lockBytes).digest("hex"), + clawhubToolchainVersion: requireString(clawhub.version, "trusted ClawHub CLI version"), + }; +} + function runCommand(command: string, args: string[], options: { cwd?: string } = {}): string { return execFileSync(command, args, { cwd: options.cwd, @@ -160,7 +226,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg const version = values.shift(); if (!version || version.startsWith("-")) { throw new Error( - "Usage: pnpm release:verify-beta -- [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]", + "Usage: pnpm release:verify-beta -- [--release-sha SHA] [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID --clawhub-bootstrap-plugins NAMES] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]", ); } @@ -170,9 +236,11 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg distTag: "beta", repo: DEFAULT_REPO, registry: DEFAULT_CLAWHUB_REGISTRY, + releaseSha: undefined, workflowRef: undefined, clawHubWorkflowRef: undefined, pluginSelection: [], + clawHubBootstrapPlugins: [], evidenceOut: undefined, skipPostpublish: false, skipGitHubRelease: false, @@ -205,6 +273,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg case "--registry": parsed.registry = next(); break; + case "--release-sha": + parsed.releaseSha = next(); + if (!COMMIT_SHA_PATTERN.test(parsed.releaseSha)) { + throw new Error("--release-sha must be a full 40-character lowercase commit SHA."); + } + break; case "--workflow-ref": parsed.workflowRef = next(); break; @@ -217,6 +291,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg throw new Error("--plugins requires at least one plugin package name."); } break; + case "--clawhub-bootstrap-plugins": + parsed.clawHubBootstrapPlugins = parsePluginReleaseSelection(next()); + if (parsed.clawHubBootstrapPlugins.length === 0) { + throw new Error("--clawhub-bootstrap-plugins requires at least one package name."); + } + break; case "--evidence-out": parsed.evidenceOut = next(); break; @@ -255,6 +335,17 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg } } + if (parsed.workflowRuns.pluginClawHubBootstrap !== undefined) { + if (parsed.releaseSha === undefined) { + throw new Error("--plugin-clawhub-bootstrap-run requires --release-sha."); + } + if (parsed.clawHubBootstrapPlugins.length === 0) { + throw new Error("--plugin-clawhub-bootstrap-run requires --clawhub-bootstrap-plugins."); + } + } else if (parsed.clawHubBootstrapPlugins.length > 0) { + throw new Error("--clawhub-bootstrap-plugins requires --plugin-clawhub-bootstrap-run."); + } + return parsed; } @@ -262,17 +353,19 @@ async function fetchWithRetry( url: string, options: RequestInit, attempts: number, -): Promise { +): Promise { let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { + const signal = AbortSignal.timeout(CLAWHUB_REQUEST_TIMEOUT_MS); const response = await fetch(url, { ...options, - signal: AbortSignal.timeout(CLAWHUB_REQUEST_TIMEOUT_MS), + signal, }); if (response.status !== 429 && response.status < 500) { - return response; + return { response, signal }; } + await cancelResponseBody(response); lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; @@ -287,25 +380,88 @@ async function fetchWithRetry( throw new Error(`${url} did not return a stable response: ${message}`); } -async function fetchJsonWithRetry(url: string): Promise { - const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, 5); - if (!response.ok) { - throw new Error(`${url} returned HTTP ${response.status}.`); +async function cancelResponseBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +export async function fetchJsonWithRetry( + url: string, + options: { + attempts?: number; + delay?: (delayMs: number) => Promise; + fetchImpl?: typeof fetch; + timeoutMs?: number; + } = {}, +): Promise { + const attempts = options.attempts ?? 5; + const delay = + options.delay ?? + ((delayMs: number) => + new Promise((resolveDelay) => { + setTimeout(resolveDelay, delayMs); + })); + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS; + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let response: Response | undefined; + let attemptError: unknown; + try { + const signal = AbortSignal.timeout(timeoutMs); + response = await fetchImpl(url, { + headers: { accept: "application/json" }, + signal, + }); + if (response.status !== 429 && response.status < 500) { + if (!response.ok) { + await cancelResponseBody(response); + throw new Error(`${url} returned HTTP ${response.status}.`); + } + return await readBoundedJsonResponse(response, url, undefined, { signal }); + } + attemptError = new Error(`HTTP ${response.status}`); + lastError = attemptError; + } catch (error) { + if ( + response !== undefined && + response.status !== 429 && + response.status < 500 && + !response.ok + ) { + throw error; + } + attemptError = error; + lastError = error; + } finally { + if (response !== undefined && attemptError !== undefined) { + await cancelResponseBody(response); + } + } + if (attempt < attempts) { + await delay(attempt * 1000); + } } - return await readBoundedJsonResponse(response, url); + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`${url} did not return stable JSON: ${message}`); } export async function readBoundedJsonResponse( response: Response, label: string, maxBytes = CLAWHUB_RESPONSE_BODY_MAX_BYTES, + options: { signal?: AbortSignal } = {}, ): Promise { - return parseJson(await readBoundedResponseText(response, label, maxBytes), label); + return parseJson(await readBoundedResponseText(response, label, maxBytes, options), label); } -async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise { - const response = await fetchWithRetry(url, { method, redirect: "manual" }, 5); - return response.status; +export async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise { + const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5); + try { + return response.status; + } finally { + await cancelResponseBody(response); + } } async function verifyNpmPackage( @@ -494,6 +650,562 @@ function verifyWorkflowRun(params: { }; } +function requirePositiveIntegerString(value: unknown, label: string): string { + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + return String(value); + } + const stringValue = readString(value); + if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) { + throw new Error(`${label} must be a positive integer.`); + } + return stringValue; +} + +function requirePositiveSafeInteger(value: unknown, label: string): number { + const stringValue = requirePositiveIntegerString(value, label); + const numberValue = Number(stringValue); + if (!Number.isSafeInteger(numberValue)) { + throw new Error(`${label} must be a positive safe integer.`); + } + return numberValue; +} + +function requireCommitSha(value: unknown, label: string): string { + const sha = requireString(value, label); + if (!COMMIT_SHA_PATTERN.test(sha)) { + throw new Error(`${label} must be a full 40-character lowercase commit SHA.`); + } + return sha; +} + +function requireSha256(value: unknown, label: string): string { + const sha = requireString(value, label); + if (!SHA256_PATTERN.test(sha)) { + throw new Error(`${label} must be a lowercase SHA-256 digest.`); + } + return sha; +} + +function requireArtifactDigest(value: unknown, label: string): string { + const digest = requireString(value, label); + const match = /^sha256:([a-f0-9]{64})$/u.exec(digest); + if (!match?.[1]) { + throw new Error(`${label} must be a sha256 artifact digest.`); + } + return match[1]; +} + +function requireStringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new Error(`${label} must be a string array.`); + } + return value + .map((entry) => entry.trim()) + .filter(Boolean) + .toSorted(compareCodeUnits); +} + +function requireArtifactWorkflowRun( + artifact: JsonRecord, + params: { label: string; runId: string; headSha: string }, +): void { + if (artifact.expired !== false) { + throw new Error(`${params.label} is expired or missing its immutable state.`); + } + const workflowRun = artifact.workflow_run; + if (!isRecord(workflowRun)) { + throw new Error(`${params.label} is missing workflow_run metadata.`); + } + if ( + requirePositiveIntegerString(workflowRun.id, `${params.label} workflow run id`) !== params.runId + ) { + throw new Error(`${params.label} belongs to a different workflow run.`); + } + if ( + requireCommitSha(workflowRun.head_sha, `${params.label} workflow head SHA`) !== params.headSha + ) { + throw new Error(`${params.label} belongs to a different workflow head SHA.`); + } + if (requireString(workflowRun.head_branch, `${params.label} workflow head branch`) !== "main") { + throw new Error(`${params.label} was not produced by trusted main.`); + } +} + +function requireClawHubBootstrapRunBinding( + run: unknown, + expectedRunId: string, +): { + headSha: string; + run: JsonRecord; + runAttempt: number; + runId: number; + terminalRunAttempt: string; + workflowPath: string; +} { + if (!isRecord(run)) { + throw new Error("Plugin ClawHub New run metadata is invalid."); + } + const runId = requirePositiveSafeInteger(run.id, "Plugin ClawHub New run id"); + if (String(runId) !== expectedRunId) { + throw new Error(`Plugin ClawHub New run id is ${runId}, expected ${expectedRunId}.`); + } + if (run.name !== "Plugin ClawHub New") { + throw new Error("Plugin ClawHub New run has an unexpected workflow name."); + } + if (run.event !== "workflow_dispatch") { + throw new Error("Plugin ClawHub New run was not workflow_dispatch."); + } + if (run.head_branch !== "main") { + throw new Error("Plugin ClawHub New run was not dispatched from trusted main."); + } + const headSha = requireCommitSha(run.head_sha, "Plugin ClawHub New head SHA"); + const runAttempt = requirePositiveSafeInteger(run.run_attempt, "Plugin ClawHub New run attempt"); + const workflowPath = requireString(run.path, "Plugin ClawHub New workflow path").replace( + /@.*$/u, + "", + ); + if (workflowPath !== CLAWHUB_BOOTSTRAP_WORKFLOW_PATH) { + throw new Error("Plugin ClawHub New run has an unexpected workflow path."); + } + if (run.status !== "completed" || run.conclusion !== "success") { + throw new Error("Plugin ClawHub New run is not completed/success."); + } + return { + headSha, + run, + runAttempt, + runId, + terminalRunAttempt: String(runAttempt), + workflowPath, + }; +} + +function requireClawHubReadbackArtifactBinding( + artifact: unknown, + run: ReturnType, +): { + artifactDigest: string; + artifactId: number; + artifactName: string; + artifactSizeBytes: number; +} { + if (!isRecord(artifact)) { + throw new Error("Plugin ClawHub New readback artifact metadata is invalid."); + } + const artifactName = `clawhub-bootstrap-readback-${run.runId}-${run.terminalRunAttempt}`; + if (artifact.name !== artifactName) { + throw new Error("Plugin ClawHub New readback artifact name does not bind the run attempt."); + } + requireArtifactWorkflowRun(artifact, { + label: "Plugin ClawHub New readback artifact", + runId: String(run.runId), + headSha: run.headSha, + }); + return { + artifactDigest: `sha256:${requireArtifactDigest( + artifact.digest, + "Plugin ClawHub New readback artifact digest", + )}`, + artifactId: requirePositiveSafeInteger(artifact.id, "Plugin ClawHub New readback artifact id"), + artifactName, + artifactSizeBytes: requirePositiveSafeInteger( + artifact.size_in_bytes, + "Plugin ClawHub New readback artifact size", + ), + }; +} + +function validateBootstrapPackageEvidence( + value: unknown, + params: { packageName: string; version: string }, +): void { + if (!isRecord(value)) { + throw new Error(`${params.packageName} bootstrap evidence is invalid.`); + } + if ( + requireString(value.packageName, `${params.packageName} package name`) !== params.packageName + ) { + throw new Error(`${params.packageName} bootstrap evidence package mismatch.`); + } + if (requireString(value.version, `${params.packageName} version`) !== params.version) { + throw new Error(`${params.packageName} bootstrap evidence version mismatch.`); + } + const expectedSha256 = requireSha256( + value.expectedSha256, + `${params.packageName} expected sha256`, + ); + if ( + requireSha256(value.registrySha256, `${params.packageName} registry sha256`) !== expectedSha256 + ) { + throw new Error( + `${params.packageName} registry artifact digest differs from the packed artifact.`, + ); + } + const expectedSize = value.expectedSize; + const registrySize = value.registrySize; + if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0 || registrySize !== expectedSize) { + throw new Error( + `${params.packageName} registry artifact size differs from the packed artifact.`, + ); + } + const npmIntegrity = requireString(value.npmIntegrity, `${params.packageName} npm integrity`); + const npmShasum = requireString(value.npmShasum, `${params.packageName} npm shasum`); + const metadata = value.artifactMetadata; + if (!isRecord(metadata)) { + throw new Error(`${params.packageName} artifact metadata evidence is invalid.`); + } + if (metadata.kind !== "npm-pack") { + throw new Error(`${params.packageName} artifact metadata is not npm-pack.`); + } + if ( + requireString(metadata.packageName, `${params.packageName} metadata package name`) !== + params.packageName || + requireString(metadata.version, `${params.packageName} metadata version`) !== params.version + ) { + throw new Error(`${params.packageName} artifact metadata identity mismatch.`); + } + if ( + requireSha256(metadata.sha256, `${params.packageName} metadata sha256`) !== expectedSha256 || + metadata.size !== expectedSize || + metadata.npmIntegrity !== npmIntegrity || + metadata.npmShasum !== npmShasum + ) { + throw new Error(`${params.packageName} artifact metadata does not match downloaded bytes.`); + } +} + +export function validateClawHubBootstrapEvidence(params: { + repo: string; + runId: string; + releaseSha: string; + expectedVersion: string; + expectedPackages: string[]; + run: unknown; + readbackArtifact: unknown; + readbackArchiveSha256: string; + packageArtifact: unknown; + evidence: unknown; +}): WorkflowRunSummary { + const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId); + const { headSha, terminalRunAttempt, workflowPath } = runBinding; + const runId = String(runBinding.runId); + const readbackBinding = requireClawHubReadbackArtifactBinding( + params.readbackArtifact, + runBinding, + ); + const readbackArtifactId = String(readbackBinding.artifactId); + const readbackArtifactDigest = requireArtifactDigest( + readbackBinding.artifactDigest, + "Plugin ClawHub New readback artifact digest", + ); + if ( + requireSha256(params.readbackArchiveSha256, "Downloaded readback artifact sha256") !== + readbackArtifactDigest + ) { + throw new Error("Downloaded Plugin ClawHub New readback artifact digest mismatch."); + } + + if (!isRecord(params.evidence)) { + throw new Error("Plugin ClawHub New readback evidence is invalid."); + } + if (params.evidence.schemaVersion !== 2 || params.evidence.verificationMode !== "postpublish") { + throw new Error("Plugin ClawHub New readback evidence schema or mode is invalid."); + } + if (params.evidence.repository !== params.repo) { + throw new Error("Plugin ClawHub New readback evidence repository mismatch."); + } + if ( + requireCommitSha(params.evidence.targetSha, "Plugin ClawHub New target SHA") !== + params.releaseSha + ) { + throw new Error("Plugin ClawHub New readback evidence target SHA mismatch."); + } + if ( + requireCommitSha(params.evidence.workflowSha, "Plugin ClawHub New workflow SHA") !== headSha + ) { + throw new Error("Plugin ClawHub New readback evidence workflow SHA mismatch."); + } + const evidenceRunId = requirePositiveIntegerString( + params.evidence.runId, + "Plugin ClawHub New evidence run id", + ); + const producerRunAttempt = requirePositiveIntegerString( + params.evidence.producerRunAttempt, + "Plugin ClawHub New evidence producer run attempt", + ); + const evidenceTerminalRunAttempt = requirePositiveIntegerString( + params.evidence.terminalRunAttempt, + "Plugin ClawHub New evidence terminal run attempt", + ); + if (evidenceRunId !== runId || evidenceTerminalRunAttempt !== terminalRunAttempt) { + throw new Error("Plugin ClawHub New readback evidence run tuple mismatch."); + } + if (BigInt(producerRunAttempt) > BigInt(terminalRunAttempt)) { + throw new Error("Plugin ClawHub New producer attempt is newer than its terminal attempt."); + } + const expectedToolchain = readTrustedClawHubToolchainIdentity(); + for (const [key, expected] of Object.entries(expectedToolchain)) { + if (params.evidence[key] !== expected) { + throw new Error(`Plugin ClawHub New ${key} mismatch.`); + } + } + + const expectedPackages = [...new Set(params.expectedPackages)].toSorted(compareCodeUnits); + if (expectedPackages.length === 0) { + throw new Error("Plugin ClawHub New expected package set is empty."); + } + if ( + JSON.stringify( + requireStringArray(params.evidence.requestedPlugins, "requested bootstrap plugins"), + ) !== JSON.stringify(expectedPackages) + ) { + throw new Error("Plugin ClawHub New requested package set mismatch."); + } + if (!Array.isArray(params.evidence.packages)) { + throw new Error("Plugin ClawHub New package evidence is invalid."); + } + const evidencePackages = params.evidence.packages + .map((entry) => + isRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "", + ) + .toSorted(compareCodeUnits); + if (JSON.stringify(evidencePackages) !== JSON.stringify(expectedPackages)) { + throw new Error("Plugin ClawHub New terminal package set mismatch."); + } + for (const packageName of expectedPackages) { + validateBootstrapPackageEvidence( + params.evidence.packages.find( + (entry) => isRecord(entry) && entry.packageName === packageName, + ), + { packageName, version: params.expectedVersion }, + ); + } + + if (!isRecord(params.packageArtifact)) { + throw new Error("Plugin ClawHub New package artifact metadata is invalid."); + } + const packageArtifactId = requirePositiveIntegerString( + params.packageArtifact.id, + "Plugin ClawHub New package artifact id", + ); + if ( + packageArtifactId !== + requirePositiveIntegerString( + params.evidence.artifactId, + "Plugin ClawHub New evidence package artifact id", + ) + ) { + throw new Error("Plugin ClawHub New package artifact id mismatch."); + } + if ( + params.packageArtifact.name !== + requireString(params.evidence.artifactName, "Plugin ClawHub New package artifact name") + ) { + throw new Error("Plugin ClawHub New package artifact name mismatch."); + } + requireArtifactWorkflowRun(params.packageArtifact, { + label: "Plugin ClawHub New package artifact", + runId, + headSha, + }); + const packageArtifactDigest = requireArtifactDigest( + params.packageArtifact.digest, + "Plugin ClawHub New package artifact digest", + ); + if ( + packageArtifactDigest !== + requireSha256( + params.evidence.artifactDigest, + "Plugin ClawHub New evidence package artifact digest", + ) + ) { + throw new Error("Plugin ClawHub New package artifact digest mismatch."); + } + const expectedPackageArtifactName = `clawhub-bootstrap-${params.releaseSha.slice(0, 12)}-${runId}-${producerRunAttempt}`; + if (params.packageArtifact.name !== expectedPackageArtifactName) { + throw new Error( + "Plugin ClawHub New package artifact name does not bind the target and attempt.", + ); + } + + const createdAt = readString(runBinding.run.created_at); + const updatedAt = readString(runBinding.run.updated_at); + const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt); + const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt); + return { + id: runId, + label: "Plugin ClawHub New", + url: readString(runBinding.run.html_url), + durationSeconds: + Number.isFinite(createdMs) && Number.isFinite(updatedMs) + ? Math.max(0, Math.round((updatedMs - createdMs) / 1000)) + : undefined, + bootstrapEvidence: { + targetSha: params.releaseSha, + workflowSha: headSha, + workflowPath, + producerRunAttempt, + terminalRunAttempt, + readbackArtifactId, + readbackArtifactDigest, + packageArtifactId, + packageArtifactDigest, + packageCount: expectedPackages.length, + ...expectedToolchain, + }, + }; +} + +function readGitHubApiJson(repo: string, endpoint: string, label: string): unknown { + return parseJson(runCommand("gh", ["api", `repos/${repo}/${endpoint}`]), label); +} + +function readGitHubToken(): string { + return requireString( + process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? runCommand("gh", ["auth", "token"]), + "GitHub token", + ); +} + +function decodeUtf8Exact(bytes: Uint8Array, label: string): string { + let value: string; + try { + value = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new Error(`${label} is not valid UTF-8.`, { cause: error }); + } + if (!Buffer.from(value, "utf8").equals(bytes)) { + throw new Error(`${label} is not canonically encoded UTF-8.`); + } + return value; +} + +export async function downloadClawHubBootstrapReadback(params: { + repo: string; + runId: string; + run: unknown; + readbackArtifact: unknown; + token: string; + fetchImpl?: typeof fetch; + retryAttempts?: number; + retryDelayMs?: number; + timeoutMs?: number; +}): Promise<{ value: unknown; archiveSha256: string }> { + const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId); + const artifactBinding = requireClawHubReadbackArtifactBinding( + params.readbackArtifact, + runBinding, + ); + const downloaded = await readPublicationArtifactArchive({ + archivePolicy: { + expectedEntries: [CLAWHUB_BOOTSTRAP_READBACK_FILE], + maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES, + maxExpandedBytes: CLAWHUB_RESPONSE_BODY_MAX_BYTES, + maxCompressedEntryBytes: () => CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES, + maxEntryBytes: () => CLAWHUB_RESPONSE_BODY_MAX_BYTES, + }, + expected: { + artifactDigest: artifactBinding.artifactDigest, + artifactId: artifactBinding.artifactId, + artifactName: artifactBinding.artifactName, + artifactSizeBytes: artifactBinding.artifactSizeBytes, + repository: params.repo, + runStatePolicy: "completed-success", + runAttempt: runBinding.runAttempt, + runId: runBinding.runId, + workflowEvent: "workflow_dispatch", + workflowHeadBranch: "main", + workflowPath: runBinding.workflowPath, + workflowSha: runBinding.headSha, + }, + fetchImpl: params.fetchImpl, + maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES, + retryAttempts: params.retryAttempts, + retryDelayMs: params.retryDelayMs, + timeoutMs: params.timeoutMs, + token: params.token, + }); + const bytes = downloaded.files.get(CLAWHUB_BOOTSTRAP_READBACK_FILE); + if (!bytes) { + throw new Error("Plugin ClawHub New readback artifact is missing its evidence file."); + } + return { + value: parseJson( + decodeUtf8Exact(bytes, "Plugin ClawHub New readback artifact"), + "Plugin ClawHub New readback artifact", + ), + archiveSha256: requireArtifactDigest( + sha256Digest(downloaded.archiveBytes), + "Downloaded Plugin ClawHub New readback artifact digest", + ), + }; +} + +async function verifyClawHubBootstrapRun(params: { + repo: string; + runId: string; + releaseSha: string; + version: string; + expectedPackages: string[]; +}): Promise { + const run = readGitHubApiJson( + params.repo, + `actions/runs/${params.runId}`, + "Plugin ClawHub New run", + ); + const runBinding = requireClawHubBootstrapRunBinding(run, params.runId); + const terminalRunAttempt = runBinding.terminalRunAttempt; + const readbackName = `clawhub-bootstrap-readback-${params.runId}-${terminalRunAttempt}`; + const artifactList = readGitHubApiJson( + params.repo, + `actions/runs/${params.runId}/artifacts?per_page=100&name=${encodeURIComponent(readbackName)}`, + "Plugin ClawHub New readback artifact list", + ); + if (!isRecord(artifactList) || !Array.isArray(artifactList.artifacts)) { + throw new Error("Plugin ClawHub New readback artifact list is invalid."); + } + const readbackArtifacts = artifactList.artifacts.filter( + (artifact) => isRecord(artifact) && artifact.name === readbackName, + ); + if (readbackArtifacts.length !== 1 || !isRecord(readbackArtifacts[0])) { + throw new Error( + `Plugin ClawHub New run must have exactly one ${readbackName} artifact; found ${readbackArtifacts.length}.`, + ); + } + const readbackArtifact = readbackArtifacts[0]; + const downloaded = await downloadClawHubBootstrapReadback({ + repo: params.repo, + runId: params.runId, + run, + readbackArtifact, + token: readGitHubToken(), + }); + if (!isRecord(downloaded.value)) { + throw new Error("Plugin ClawHub New readback evidence is invalid."); + } + const packageArtifactId = requirePositiveIntegerString( + downloaded.value.artifactId, + "Plugin ClawHub New package artifact id", + ); + const packageArtifact = readGitHubApiJson( + params.repo, + `actions/artifacts/${packageArtifactId}`, + "Plugin ClawHub New package artifact", + ); + return validateClawHubBootstrapEvidence({ + repo: params.repo, + runId: params.runId, + releaseSha: params.releaseSha, + expectedVersion: params.version, + expectedPackages: params.expectedPackages, + run, + readbackArtifact, + readbackArchiveSha256: downloaded.archiveSha256, + packageArtifact, + evidence: downloaded.value, + }); +} + function readRootPackageVersion(rootDir: string): string { const packageJson = parseJson( readFileSync(resolve(rootDir, "package.json"), "utf8"), @@ -538,6 +1250,12 @@ export async function verifyBetaRelease( if (rootVersion !== args.version) { throw new Error(`package.json version is ${rootVersion}; expected ${args.version}.`); } + if (args.releaseSha !== undefined) { + const checkedOutSha = runCommand("git", ["rev-parse", "HEAD"], { cwd: rootDir }); + if (checkedOutSha !== args.releaseSha) { + throw new Error(`release checkout SHA is ${checkedOutSha}; expected ${args.releaseSha}.`); + } + } const lines: string[] = []; const releaseUrl = args.skipGitHubRelease ? undefined : verifyGitHubRelease(args); @@ -636,15 +1354,13 @@ export async function verifyBetaRelease( ); } if (args.workflowRuns.pluginClawHubBootstrap !== undefined) { - const clawHubWorkflowRef = args.clawHubWorkflowRef ?? args.workflowRef; workflowRuns.push( - verifyWorkflowRun({ - id: args.workflowRuns.pluginClawHubBootstrap, - label: "Plugin ClawHub New", + await verifyClawHubBootstrapRun({ repo: args.repo, - expectedWorkflowName: "Plugin ClawHub New", - expectedHeadBranch: clawHubWorkflowRef, - rerunFailed: false, + runId: args.workflowRuns.pluginClawHubBootstrap, + releaseSha: requireCommitSha(args.releaseSha, "release SHA"), + version: args.version, + expectedPackages: args.clawHubBootstrapPlugins, }), ); } @@ -667,7 +1383,7 @@ export async function verifyBetaRelease( label: "NPM Telegram Beta E2E", repo: args.repo, expectedWorkflowName: "NPM Telegram Beta E2E", - expectedHeadBranch: args.workflowRef, + allowedHeadBranches: ["main", args.workflowRef], rerunFailed: false, }), ); @@ -696,6 +1412,8 @@ export async function verifyBetaRelease( pluginNpmPackageCount: npmPlugins.length, clawHubPackageCount: clawHubPlugins.length, workflowRuns, + clawHubBootstrapEvidence: + workflowRuns.find((run) => run.bootstrapEvidence)?.bootstrapEvidence ?? null, }, null, 2, diff --git a/scripts/materialize-clawhub-cli.sh b/scripts/materialize-clawhub-cli.sh new file mode 100755 index 000000000000..5c51dcdcf563 --- /dev/null +++ b/scripts/materialize-clawhub-cli.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source_root="${1:?trusted ClawHub CLI source root is required}" +destination="${2:?ClawHub CLI destination is required}" +github_output="${3:-}" + +package_json="${source_root}/package.json" +package_lock="${source_root}/package-lock.json" +expected_lock_sha256="f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816" +expected_clawhub_integrity="sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==" +test -f "${package_json}" +test -f "${package_lock}" +if [[ -e "${destination}" || -L "${destination}" ]]; then + echo "ClawHub CLI destination must not already exist: ${destination}" >&2 + exit 1 +fi + +install -d -m 0700 "${destination}" +install -m 0600 "${package_json}" "${destination}/package.json" +install -m 0600 "${package_lock}" "${destination}/package-lock.json" + +lock_sha256="$( + CLAWHUB_CLI_LOCK="${package_lock}" \ + node -e "const { createHash } = require('node:crypto'); const { readFileSync } = require('node:fs'); process.stdout.write(createHash('sha256').update(readFileSync(process.env.CLAWHUB_CLI_LOCK)).digest('hex'));" +)" +[[ "${lock_sha256}" == "${expected_lock_sha256}" ]] || { + echo "Pinned ClawHub CLI lock SHA-256 mismatch." >&2 + exit 1 +} +clawhub_integrity="$( + CLAWHUB_CLI_LOCK="${package_lock}" \ + node -p "require(require('node:path').resolve(process.env.CLAWHUB_CLI_LOCK)).packages['node_modules/clawhub'].integrity" +)" +[[ "${clawhub_integrity}" == "${expected_clawhub_integrity}" ]] || { + echo "Pinned ClawHub CLI integrity mismatch." >&2 + exit 1 +} + +npm ci \ + --prefix "${destination}" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --omit=dev + +clawhub_version="$( + CLAWHUB_CLI_ROOT="${destination}" \ + node -p "require(require('node:path').join(process.env.CLAWHUB_CLI_ROOT, 'node_modules/clawhub/package.json')).version" +)" +[[ "${clawhub_version}" == "0.23.1" ]] || { + echo "Pinned ClawHub CLI version mismatch: ${clawhub_version}" >&2 + exit 1 +} +test -x "${destination}/node_modules/.bin/clawhub" +clawhub_cli="${destination}/node_modules/.bin/clawhub" + +echo "Materialized clawhub@${clawhub_version} from lock ${lock_sha256}." +if [[ -n "${github_output}" ]]; then + { + echo "cli=${clawhub_cli}" + echo "integrity=${clawhub_integrity}" + echo "lock_sha256=${lock_sha256}" + echo "version=${clawhub_version}" + } >> "${github_output}" +fi diff --git a/scripts/openclaw-npm-extended-stable-release.mjs b/scripts/openclaw-npm-extended-stable-release.mjs index 9be88340c76d..fdedfbf204b2 100644 --- a/scripts/openclaw-npm-extended-stable-release.mjs +++ b/scripts/openclaw-npm-extended-stable-release.mjs @@ -499,10 +499,8 @@ async function main() { npmDistTag: process.env.RELEASE_NPM_DIST_TAG, expectedWorkflowRef: process.env.EXPECTED_WORKFLOW_REF, expectedSha: process.env.EXPECTED_RELEASE_SHA, - expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT - ? process.env.FULL_RELEASE_VALIDATION_RUN_ID - : undefined, - expectedRunAttempt: process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT || undefined, + expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID, + expectedRunAttempt: process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT, }); return; } diff --git a/scripts/plugin-clawhub-publish.sh b/scripts/plugin-clawhub-publish.sh index 99b1479c84ff..ffaa10dc854e 100644 --- a/scripts/plugin-clawhub-publish.sh +++ b/scripts/plugin-clawhub-publish.sh @@ -2,20 +2,58 @@ set -euo pipefail +usage() { + echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] " + echo " bash scripts/plugin-clawhub-publish.sh [--validate-packed|--publish-packed] " +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + mode="${1:-}" -package_dir="${2:-}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" invocation_root="$(pwd)" -if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" ]]; then - echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish|--pack] " >&2 +if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" && "${mode}" != "--pack" && + "${mode}" != "--validate-packed" && "${mode}" != "--publish-packed" ]]; then + usage >&2 + exit 2 +fi +shift + +if [[ "${1:-}" == "--" ]]; then + shift +fi +input_path="" +if [[ "$#" -gt 0 ]]; then + case "$1" in + -*) echo "unexpected plugin ClawHub package-dir option: $1" >&2; exit 2 ;; + *) input_path="$1"; shift ;; + esac +fi +if [[ -z "${input_path}" ]]; then + echo "missing package dir or ClawPack path" >&2 + exit 2 +fi +if [[ "$#" -gt 0 ]]; then + echo "unexpected plugin ClawHub publish argument: $1" >&2 exit 2 fi -if [[ -z "${package_dir}" ]]; then - echo "missing package dir" >&2 - exit 2 +packed_mode=false +if [[ "${mode}" == "--validate-packed" || "${mode}" == "--publish-packed" ]]; then + packed_mode=true +fi + +package_dir="${PACKAGE_DIR:-}" +clawpack_path="" +if [[ "${packed_mode}" == "true" ]]; then + clawpack_path="$(cd "$(dirname "${input_path}")" && pwd)/$(basename "${input_path}")" +else + package_dir="${input_path}" fi if [[ ! "${package_dir}" =~ ^extensions/[a-z0-9][a-z0-9._-]*$ ]]; then @@ -25,24 +63,69 @@ fi package_source="${invocation_root}/${package_dir}" -if [[ ! -f "${package_source}/package.json" ]]; then +if [[ "${packed_mode}" == "false" && ! -f "${package_source}/package.json" ]]; then echo "package.json not found under ${package_dir}" >&2 exit 2 fi - -if ! command -v clawhub >/dev/null 2>&1; then - echo "clawhub CLI is required on PATH" >&2 - exit 1 +if [[ "${packed_mode}" == "true" && ! -f "${clawpack_path}" ]]; then + echo "ClawPack tarball not found: ${clawpack_path}" >&2 + exit 2 fi -package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_source}")" -package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_source}")" +clawhub_cli="${OPENCLAW_CLAWHUB_CLI:-}" +if [[ -n "${clawhub_cli}" ]]; then + if [[ "${clawhub_cli}" != /* || ! -x "${clawhub_cli}" ]]; then + echo "OPENCLAW_CLAWHUB_CLI must be an absolute executable path" >&2 + exit 1 + fi +else + clawhub_cli="$(command -v clawhub 2>/dev/null || true)" + if [[ -z "${clawhub_cli}" ]]; then + echo "clawhub CLI is required on PATH" >&2 + exit 1 + fi +fi + +if [[ "${packed_mode}" == "true" ]]; then + package_name="${EXPECTED_CLAWHUB_PACKAGE_NAME:-}" + package_version="${EXPECTED_CLAWHUB_PACKAGE_VERSION:-}" + if [[ ! "${package_name}" =~ ^@openclaw/[a-z0-9][a-z0-9._-]*$ ]]; then + echo "EXPECTED_CLAWHUB_PACKAGE_NAME is invalid." >&2 + exit 2 + fi + if [[ -z "${package_version}" ]]; then + echo "EXPECTED_CLAWHUB_PACKAGE_VERSION is required." >&2 + exit 2 + fi +else + package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_source}")" + package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_source}")" +fi publish_tag="${PACKAGE_TAG:-latest}" source_repo="${SOURCE_REPO:-${GITHUB_REPOSITORY:-openclaw/openclaw}}" source_commit="${SOURCE_COMMIT:-$(git -C "${invocation_root}" rev-parse HEAD)}" source_ref="${SOURCE_REF:-$(git -C "${invocation_root}" symbolic-ref -q HEAD || true)}" clawhub_workdir="${CLAWDHUB_WORKDIR:-${CLAWHUB_WORKDIR:-${invocation_root}}}" manual_override_reason="${OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON:-}" +release_git_dir="${OPENCLAW_CLAWHUB_RELEASE_GIT_DIR:-}" +release_tag="${OPENCLAW_CLAWHUB_RELEASE_TAG:-}" +release_target_sha="${OPENCLAW_CLAWHUB_TARGET_SHA:-}" +release_binding_count=0 +for release_binding_value in "${release_git_dir}" "${release_tag}" "${release_target_sha}"; do + if [[ -n "${release_binding_value}" ]]; then + release_binding_count=$((release_binding_count + 1)) + fi +done +if [[ "${release_binding_count}" != "0" && "${release_binding_count}" != "3" ]]; then + echo "OPENCLAW_CLAWHUB_RELEASE_GIT_DIR, OPENCLAW_CLAWHUB_RELEASE_TAG, and OPENCLAW_CLAWHUB_TARGET_SHA must be provided together." >&2 + exit 2 +fi +if [[ "${release_binding_count}" == "3" ]]; then + if [[ ! -d "${release_git_dir}" || ! "${release_target_sha}" =~ ^[a-f0-9]{40}$ ]]; then + echo "ClawHub release tag binding is invalid." >&2 + exit 2 + fi +fi pack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-clawhub-pack.XXXXXX")" cleanup() { @@ -51,7 +134,7 @@ cleanup() { trap cleanup EXIT pack_cmd=( - clawhub + "${clawhub_cli}" --workdir "${clawhub_workdir}" package @@ -80,23 +163,24 @@ echo "Resolved source repo: ${source_repo}" echo "Resolved source commit: ${source_commit}" echo "Resolved source ref: ${source_ref:-}" echo "Resolved ClawHub workdir: ${clawhub_workdir}" -echo "Publish auth: GitHub Actions OIDC via ClawHub short-lived token" +echo "Publish auth: ${OPENCLAW_CLAWHUB_AUTH_LABEL:-GitHub Actions OIDC via ClawHub short-lived token}" -printf 'Pack command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}" -printf ' %q' "${pack_cmd[@]}" -printf '\n' +if [[ "${packed_mode}" == "false" ]]; then + printf 'Pack command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}" + printf ' %q' "${pack_cmd[@]}" + printf '\n' -build_package_runtime + build_package_runtime -pack_json="${pack_dir}/pack.json" -CLAWHUB_WORKDIR="${clawhub_workdir}" \ - node "${repo_root}/scripts/lib/plugin-npm-package-manifest.mjs" --run "${package_dir}" -- \ - "${pack_cmd[@]}" > "${pack_json}" -pack_output="$(cat "${pack_json}")" -printf '%s\n' "${pack_output}" + pack_json="${pack_dir}/pack.json" + CLAWHUB_WORKDIR="${clawhub_workdir}" \ + node "${repo_root}/scripts/lib/plugin-npm-package-manifest.mjs" --run "${package_dir}" -- \ + "${pack_cmd[@]}" > "${pack_json}" + pack_output="$(cat "${pack_json}")" + printf '%s\n' "${pack_output}" -pack_path="$( - PACK_OUTPUT="${pack_output}" node --input-type=module <<'EOF' + pack_path="$( + PACK_OUTPUT="${pack_output}" node --input-type=module <<'EOF' import { resolve } from "node:path"; const raw = process.env.PACK_OUTPUT ?? ""; @@ -113,14 +197,17 @@ if (!parsed || typeof parsed.path !== "string" || parsed.path.trim() === "") { } console.log(resolve(parsed.path)); EOF -)" + )" -if [[ ! -f "${pack_path}" ]]; then - echo "ClawPack tarball not found: ${pack_path}" >&2 - exit 1 + if [[ ! -f "${pack_path}" ]]; then + echo "ClawPack tarball not found: ${pack_path}" >&2 + exit 1 + fi + + clawpack_path="${pack_path}" fi -echo "Resolved ClawPack: ${pack_path}" +echo "Resolved ClawPack: ${clawpack_path}" if [[ "${mode}" == "--pack" ]]; then output_dir="${OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR:-}" @@ -129,19 +216,101 @@ if [[ "${mode}" == "--pack" ]]; then exit 2 fi mkdir -p "${output_dir}" - output_path="${output_dir}/$(basename "${pack_path}")" - cp "${pack_path}" "${output_path}" + output_path="${output_dir}/$(basename "${clawpack_path}")" + cp "${clawpack_path}" "${output_path}" echo "Packed ClawPack: ${output_path}" exit 0 fi +verify_packed_identity() { + local expected_sha="${EXPECTED_CLAWHUB_ARTIFACT_SHA256:-}" + local expected_size="${EXPECTED_CLAWHUB_ARTIFACT_SIZE:-}" + if [[ ! "${expected_sha}" =~ ^[a-f0-9]{64}$ ]]; then + echo "EXPECTED_CLAWHUB_ARTIFACT_SHA256 is invalid." >&2 + exit 2 + fi + if [[ ! "${expected_size}" =~ ^[1-9][0-9]*$ ]]; then + echo "EXPECTED_CLAWHUB_ARTIFACT_SIZE is invalid." >&2 + exit 2 + fi + + node "${repo_root}/scripts/lib/clawhub-bootstrap-artifact.mjs" verify-packed \ + --path "${clawpack_path}" \ + --expected-sha256 "${expected_sha}" \ + --expected-size "${expected_size}" \ + --expected-dir "${package_dir}" \ + --expected-name "${package_name}" \ + --expected-version "${package_version}" +} + +if [[ "${packed_mode}" == "true" ]]; then + verify_packed_identity +fi + +clawhub_timeout_seconds="${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPT_TIMEOUT_SECONDS:-300}" +if [[ ! "${clawhub_timeout_seconds}" =~ ^[1-9][0-9]*$ || "${clawhub_timeout_seconds}" -gt 900 ]]; then + echo "OPENCLAW_CLAWHUB_PUBLISH_ATTEMPT_TIMEOUT_SECONDS must be an integer from 1 through 900." >&2 + exit 2 +fi +timeout_bin="" +for timeout_candidate in timeout gtimeout; do + timeout_candidate_path="$(command -v "${timeout_candidate}" 2>/dev/null || true)" + if [[ -n "${timeout_candidate_path}" ]] && + "${timeout_candidate_path}" --signal=TERM --kill-after=1s 1s true >/dev/null 2>&1; then + timeout_bin="${timeout_candidate_path}" + break + fi +done +if [[ -z "${timeout_bin}" ]]; then + echo "GNU timeout or gtimeout with --signal and --kill-after support is required for bounded ClawHub CLI calls." >&2 + exit 1 +fi +clawhub_timeout=( + "${timeout_bin}" + --signal=TERM + --kill-after=10s + "${clawhub_timeout_seconds}s" +) + +validate_packed_publish() { + local dry_run_json + dry_run_json="$( + CLAWHUB_WORKDIR="${clawhub_workdir}" "${clawhub_timeout[@]}" "${clawhub_cli}" \ + --workdir "${clawhub_workdir}" \ + package publish "${clawpack_path}" \ + --tags "${publish_tag}" \ + --source-repo "${source_repo}" \ + --source-commit "${source_commit}" \ + --source-path "${package_dir}" \ + --dry-run \ + --json + )" + printf '%s\n' "${dry_run_json}" + DRY_RUN_JSON="${dry_run_json}" EXPECTED_NAME="${package_name}" EXPECTED_VERSION="${package_version}" \ + node --input-type=module <<'NODE' +const output = JSON.parse(process.env.DRY_RUN_JSON ?? ""); +if (output.name !== process.env.EXPECTED_NAME || output.version !== process.env.EXPECTED_VERSION) { + throw new Error( + `Packed ClawHub identity mismatch: expected ${process.env.EXPECTED_NAME}@${process.env.EXPECTED_VERSION}, found ${String(output.name)}@${String(output.version)}.`, + ); +} +NODE +} + +if [[ "${packed_mode}" == "true" ]]; then + validate_packed_publish + if [[ "${mode}" == "--validate-packed" ]]; then + exit 0 + fi +fi + publish_cmd=( - clawhub + "${clawhub_cli}" --workdir "${clawhub_workdir}" package publish - "${pack_path}" + "${clawpack_path}" --tags "${publish_tag}" --source-repo @@ -171,21 +340,61 @@ printf ' %q' "${publish_cmd[@]}" printf '\n' if [[ "${mode}" == "--dry-run" ]]; then - CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" --dry-run + CLAWHUB_WORKDIR="${clawhub_workdir}" "${clawhub_timeout[@]}" "${publish_cmd[@]}" --dry-run exit 0 fi +publish_attempts="${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}" +publish_retry_delay="${OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS:-60}" +if [[ ! "${publish_attempts}" =~ ^[1-9][0-9]*$ || "${publish_attempts}" -gt 12 ]]; then + echo "OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS must be an integer from 1 through 12." >&2 + exit 2 +fi +if [[ ! "${publish_retry_delay}" =~ ^[1-9][0-9]*$ || "${publish_retry_delay}" -gt 300 ]]; then + echo "OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS must be an integer from 1 through 300." >&2 + exit 2 +fi + publish_log="${pack_dir}/publish.log" -for attempt in $(seq 1 "${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8}"); do - if CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" > >(tee "${publish_log}") 2>&1; then +verify_release_tag_target() { + if [[ "${release_binding_count}" == "0" ]]; then + return 0 + fi + git -C "${release_git_dir}" fetch --force --no-tags origin \ + "+refs/tags/${release_tag}:refs/tags/${release_tag}" + local tag_sha + tag_sha="$(git -C "${release_git_dir}" rev-parse "${release_tag}^{commit}")" + [[ "${tag_sha}" == "${release_target_sha}" ]] || { + echo "ClawHub publish target ${release_target_sha} no longer matches ${release_tag} (${tag_sha})." >&2 + exit 1 + } +} + +for attempt in $(seq 1 "${publish_attempts}"); do + verify_release_tag_target + set +e + CLAWHUB_WORKDIR="${clawhub_workdir}" \ + "${clawhub_timeout[@]}" "${publish_cmd[@]}" 2>&1 | tee "${publish_log}" + pipeline_status=("${PIPESTATUS[@]}") + set -e + publish_status="${pipeline_status[0]}" + tee_status="${pipeline_status[1]}" + if [[ "${tee_status}" != "0" ]]; then + echo "Failed to capture ClawHub publish output." >&2 + exit "${tee_status}" + fi + if [[ "${publish_status}" == "0" ]]; then exit 0 fi - if ! grep -Eqi "rate limit|too many requests|\\b429\\b" "${publish_log}"; then + if [[ "${publish_status}" != "124" && "${publish_status}" != "137" ]] && + ! grep -Eqi "rate limit|too many requests|\\b(408|425|429|5[0-9]{2})\\b|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up|network error|temporarily unavailable" "${publish_log}"; then exit 1 fi - echo "ClawHub publish hit a rate limit; retrying (${attempt}/${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8})." >&2 - sleep "${OPENCLAW_CLAWHUB_PUBLISH_RETRY_DELAY_SECONDS:-60}" + if [[ "${attempt}" -lt "${publish_attempts}" ]]; then + echo "ClawHub publish hit a transient failure; retrying (${attempt}/${publish_attempts})." >&2 + sleep "${publish_retry_delay}" + fi done -echo "ClawHub publish failed after ${OPENCLAW_CLAWHUB_PUBLISH_ATTEMPTS:-8} attempts." >&2 +echo "ClawHub publish failed after ${publish_attempts} attempts." >&2 exit 1 diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 4505cc7a2203..9412a1efb552 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { - echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] " + echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] [verified-package.tgz]" } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then @@ -33,6 +33,13 @@ if [[ -z "${package_dir}" ]]; then echo "missing package dir" >&2 exit 2 fi +publish_target="" +if [[ "$#" -gt 0 && "${mode}" == "--publish" ]]; then + case "$1" in + -*) echo "unexpected plugin npm tarball option: $1" >&2; exit 2 ;; + *) publish_target="$1"; shift ;; + esac +fi if [[ "$#" -gt 0 ]]; then echo "unexpected plugin npm publish argument: $1" >&2 exit 2 @@ -44,6 +51,40 @@ fi package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")" package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")" +if [[ -n "${publish_target}" ]]; then + if [[ ! -f "${publish_target}" ]]; then + echo "verified plugin npm tarball not found: ${publish_target}" >&2 + exit 2 + fi + case "${publish_target}" in + /*|./*|../*) ;; + *) publish_target="./${publish_target}" ;; + esac + if ! tarball_package_json="$(tar -xOf "${publish_target}" package/package.json)"; then + echo "verified plugin npm tarball is missing package/package.json: ${publish_target}" >&2 + exit 2 + fi + tarball_identity="$(printf '%s' "${tarball_package_json}" | node -e ' + let input = ""; + process.stdin.on("data", (chunk) => { input += chunk; }); + process.stdin.on("end", () => { + const pkg = JSON.parse(input); + if (!pkg || typeof pkg !== "object" || Array.isArray(pkg) || typeof pkg.name !== "string" || typeof pkg.version !== "string") { + throw new Error("package/package.json must contain string name and version"); + } + process.stdout.write(`${pkg.name.trim()}\n${pkg.version.trim()}\n`); + }); + ')" || { + echo "verified plugin npm tarball has invalid package identity: ${publish_target}" >&2 + exit 2 + } + tarball_package_name="$(printf '%s\n' "${tarball_identity}" | sed -n '1p')" + tarball_package_version="$(printf '%s\n' "${tarball_identity}" | sed -n '2p')" + if [[ "${tarball_package_name}" != "${package_name}" || "${tarball_package_version}" != "${package_version}" ]]; then + echo "verified plugin npm tarball identity mismatch: expected ${package_name}@${package_version}, got ${tarball_package_name}@${tarball_package_version}" >&2 + exit 2 + fi +fi current_beta_version="$(npm view "${package_name}" dist-tags.beta 2>/dev/null || true)" log() { if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then @@ -88,7 +129,19 @@ mirror_auth_source="$(printf '%s\n' "${publish_plan_output}" | sed -n '4p')" mirror_auth_requirement="$(printf '%s\n' "${publish_plan_output}" | sed -n '5p')" mirror_auth_source="${mirror_auth_source:-none}" mirror_auth_requirement="${mirror_auth_requirement:-optional}" -publish_cmd=(npm publish --access public --tag "${publish_tag}") +defer_dist_tag_mirrors="${OPENCLAW_PLUGIN_NPM_DEFER_DIST_TAG_MIRRORS:-0}" +if [[ "${defer_dist_tag_mirrors}" == "1" || "${defer_dist_tag_mirrors}" == "true" ]]; then + if [[ "${mode}" != "--publish" || "${OPENCLAW_NPM_PUBLISH_AUTH_MODE:-}" != "trusted-publisher" ]]; then + echo "Deferring npm dist-tag mirrors is restricted to trusted-publisher publication." >&2 + exit 1 + fi + mirror_auth_requirement="optional" +fi +publish_cmd=(npm publish) +if [[ -n "${publish_target}" ]]; then + publish_cmd+=("${publish_target}") +fi +publish_cmd+=(--access public --tag "${publish_tag}") if [[ "${OPENCLAW_NPM_PUBLISH_PROVENANCE:-1}" != "0" && "${OPENCLAW_NPM_PUBLISH_PROVENANCE:-1}" != "false" ]]; then publish_cmd+=(--provenance) fi @@ -96,12 +149,18 @@ fi log "Resolved package dir: ${package_dir}" log "Resolved package name: ${package_name}" log "Resolved package version: ${package_version}" +if [[ -n "${publish_target}" ]]; then + log "Resolved verified publish target: ${publish_target}" +fi log "Current beta dist-tag: ${current_beta_version:-}" log "Resolved release channel: ${release_channel}" log "Resolved publish tag: ${publish_tag}" log "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-}" log "Mirror dist-tag auth source: ${mirror_auth_source}" log "Mirror dist-tag auth requirement: ${mirror_auth_requirement}" +if [[ "${defer_dist_tag_mirrors}" == "1" || "${defer_dist_tag_mirrors}" == "true" ]]; then + log "Mirror dist-tag execution: deferred to credential-isolated release tooling" +fi build_package_runtime() { if [[ "${OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD:-1}" == "0" || "${OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD:-1}" == "false" ]]; then @@ -164,8 +223,10 @@ if [[ "${mode}" == "--dry-run" ]]; then exit 0 fi -build_package_runtime -check_package_shrinkwrap +if [[ -z "${publish_target}" ]]; then + build_package_runtime + check_package_shrinkwrap +fi if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then pack_args=(npm pack --json --ignore-scripts) @@ -184,23 +245,35 @@ fi ( cleanup_files=() - trap 'rm -f "${cleanup_files[@]}"' EXIT + cleanup() { + if (( ${#cleanup_files[@]} > 0 )); then + rm -f "${cleanup_files[@]}" + fi + } + trap cleanup EXIT run_with_manifest_overlay() { OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES=1 \ node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- "$@" } + run_publish() { + if [[ -n "${publish_target}" ]]; then + "$@" + return + fi + run_with_manifest_overlay "$@" + } publish_userconfig="" if [[ -n "${publish_auth_token}" ]]; then publish_userconfig="$(mktemp)" cleanup_files+=("${publish_userconfig}") chmod 0600 "${publish_userconfig}" printf '%s\n' "//registry.npmjs.org/:_authToken=${publish_auth_token}" > "${publish_userconfig}" - NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_with_manifest_overlay "${publish_cmd[@]}" + NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_publish "${publish_cmd[@]}" else - run_with_manifest_overlay "${publish_cmd[@]}" + run_publish "${publish_cmd[@]}" fi - if [[ -n "${mirror_dist_tags_csv}" ]]; then + if [[ -n "${mirror_dist_tags_csv}" && "${defer_dist_tag_mirrors}" != "1" && "${defer_dist_tag_mirrors}" != "true" ]]; then mirror_userconfig="$(mktemp)" cleanup_files+=("${mirror_userconfig}") chmod 0600 "${mirror_userconfig}" diff --git a/scripts/plugin-publication-artifact.mjs b/scripts/plugin-publication-artifact.mjs new file mode 100644 index 000000000000..ce3e5a20faac --- /dev/null +++ b/scripts/plugin-publication-artifact.mjs @@ -0,0 +1,1299 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { lstatSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { gunzipSync, inflateRawSync } from "node:zlib"; +import { + downloadActionsArtifactArchive, + describeActionsArtifactFiles, + inspectActionsArtifactZip, + inspectActionsArtifactZipWithPolicy, + readBoundedRegularFile, + readPublicationArtifactArchive, + sha256Digest, + validateActionsArtifactBinding, + validateActionsArtifactProducerJob, +} from "./lib/actions-artifact-archive.mjs"; +import { resolveNpmPublishPlan } from "./lib/npm-publish-plan.mjs"; + +export { + downloadActionsArtifactArchive, + describeActionsArtifactFiles, + inspectActionsArtifactZip, + inspectActionsArtifactZipWithPolicy, + readBoundedRegularFile, + readPublicationArtifactArchive, + validateActionsArtifactBinding, + validateActionsArtifactProducerJob, +}; + +const MANIFEST_FILENAME = "plugin-publication-manifest.json"; +const MANIFEST_SCHEMA = "openclaw.plugin-publication-artifact/v1"; +const TAR_BLOCK_BYTES = 512; +const TAR_END_MARKER_BYTES = TAR_BLOCK_BYTES * 2; +const TAR_USTAR_MAGIC = Buffer.from("ustar\0", "ascii"); +const TAR_USTAR_VERSION = Buffer.from("00", "ascii"); +const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024; +const MAX_EXPANDED_BYTES = 512 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; +const MAX_PLUGIN_MANIFEST_BYTES = 2 * 1024 * 1024; +const MAX_TAR_ENTRIES = 10_000; +const MAX_TAR_PATH_BYTES = 4 * 1024 * 1024; +const MAX_TAR_TOTAL_FILE_BYTES = 512 * 1024 * 1024; +export const CLAWHUB_PUBLICATION_TAR_LIMITS = Object.freeze({ + maxArchiveBytes: 120 * 1024 * 1024, + maxEntries: 10_000, + maxEntryBytes: 50 * 1024 * 1024, + maxExpandedBytes: 64 * 1024 * 1024, + maxPathBytes: 4 * 1024 * 1024, + maxTotalFileBytes: 50 * 1024 * 1024, +}); +const SHA_RE = /^[0-9a-f]{40}$/u; +const SHA256_RE = /^[0-9a-f]{64}$/u; +const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u; +const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u; +const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u; +const VERSION_RE = + /^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u; +const NPM_ROUTE_POLICIES = new Map([ + ["npm-oidc", { authMode: "trusted-publisher", capability: "trusted-publisher" }], + ["npm-token-bootstrap", { authMode: "token-bootstrap", capability: "first-publication" }], + [ + "npm-token-placeholder-recovery", + { authMode: "token-bootstrap", capability: "placeholder-recovery" }, + ], + ["npm-mirror", { authMode: "release-token", capability: "dist-tag-mirror" }], + ["npm-tag-repair", { authMode: "release-token", capability: "dist-tag-repair" }], + ["npm-readback", { authMode: "none", capability: "registry-readback" }], +]); +const ROUTES = new Set([ + ...NPM_ROUTE_POLICIES.keys(), + "clawhub-token-release", + "clawhub-token-bootstrap", + "clawhub-readback", +]); +const NPM_TAGS = new Set(["latest", "alpha", "beta", "extended-stable"]); +const CLAWHUB_TAGS = new Set(["latest", "alpha", "beta"]); +const META_PACKAGE = "@openclaw/meta-provider"; +const META_PACKAGE_DIR = "extensions/meta"; + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function npmIntegrity(bytes) { + return `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +} + +function npmShasum(bytes) { + return createHash("sha1").update(bytes).digest("hex"); +} + +function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function assertString(value, label) { + if (typeof value !== "string" || value.trim() !== value || value.length === 0) { + throw new Error(`${label} must be a non-empty trimmed string.`); + } + return value; +} + +function assertPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a safe positive integer.`); + } + return value; +} + +function assertBooleanString(value, label) { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + throw new Error(`${label} must be true or false.`); +} + +function hasControlCharacters(value) { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint <= 0x1f || codePoint === 0x7f) { + return true; + } + } + return false; +} + +function normalizeManualOverrideReason(value) { + if (value === undefined || value === null || value === "") { + return null; + } + const reason = assertString(value, "manual override reason"); + if (reason.length > 500 || hasControlCharacters(reason)) { + throw new Error( + "Manual override reason must be at most 500 characters and contain no control characters.", + ); + } + return reason; +} + +function assertSafePackageDir(value) { + const packageDir = assertString(value, "package dir"); + if (!/^extensions\/[a-z0-9][a-z0-9._-]*$/u.test(packageDir) || packageDir.includes("\\")) { + throw new Error(`Unsafe plugin package dir: ${packageDir}`); + } + return packageDir; +} + +function assertSafeArtifactName(value) { + const artifactName = assertString(value, "artifact name"); + if (!ARTIFACT_NAME_RE.test(artifactName)) { + throw new Error(`Unsafe plugin publication artifact name: ${artifactName}`); + } + return artifactName; +} + +function assertSafeArchivePath(value, label) { + const raw = assertString(value, label); + if ( + raw.startsWith("/") || + raw.includes("\\") || + raw.includes("\0") || + raw.normalize("NFC") !== raw || + hasControlCharacters(raw) + ) { + throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`); + } + const withoutTrailingSlash = raw.endsWith("/") ? raw.slice(0, -1) : raw; + const parts = withoutTrailingSlash.split("/"); + if ( + withoutTrailingSlash.length === 0 || + parts.some((part) => part.length === 0 || part === "." || part === "..") + ) { + throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`); + } + return withoutTrailingSlash; +} + +function normalizePublicationReason(value) { + const reason = assertString(value, "publication reason"); + if (reason.length > 500 || hasControlCharacters(reason)) { + throw new Error( + "Publication reason must be at most 500 characters and contain no control characters.", + ); + } + return reason; +} + +function normalizePublisherPolicy(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Publisher policy must be an object."); + } + const keys = Object.keys(value).toSorted(); + if (JSON.stringify(keys) !== JSON.stringify(["policyId", "schema", "sha256"])) { + throw new Error("Publisher policy must contain exactly schema, policyId, and sha256."); + } + const schema = assertString(value.schema, "publisher policy schema"); + const policyId = assertString(value.policyId, "publisher policy id"); + const policySha256 = assertString(value.sha256, "publisher policy SHA-256"); + if ( + schema.length > 200 || + policyId.length > 200 || + hasControlCharacters(schema) || + hasControlCharacters(policyId) || + !SHA256_RE.test(policySha256) + ) { + throw new Error("Publisher policy identity is invalid."); + } + return { policyId, schema, sha256: policySha256 }; +} + +function boundedTarLimit(value, fallback, label) { + if (value === undefined) { + return fallback; + } + if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) { + throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`); + } + return value; +} + +function normalizeTarInspectionOptions(options = {}) { + const maxArchiveBytes = boundedTarLimit( + options.maxArchiveBytes, + MAX_ARCHIVE_BYTES, + "Plugin tarball byte limit", + ); + const maxExpandedBytes = boundedTarLimit( + options.maxExpandedBytes, + MAX_EXPANDED_BYTES, + "Plugin tarball expanded-byte limit", + ); + const maxEntryBytes = boundedTarLimit( + options.maxEntryBytes, + maxExpandedBytes, + "Plugin tarball per-entry byte limit", + ); + const maxTotalFileBytes = boundedTarLimit( + options.maxTotalFileBytes, + Math.min(MAX_TAR_TOTAL_FILE_BYTES, maxExpandedBytes), + "Plugin tarball total-file byte limit", + ); + const maxEntries = boundedTarLimit( + options.maxEntries, + MAX_TAR_ENTRIES, + "Plugin tarball entry-count limit", + ); + const maxPathBytes = boundedTarLimit( + options.maxPathBytes, + MAX_TAR_PATH_BYTES, + "Plugin tarball path-byte limit", + ); + return { + maxArchiveBytes, + maxEntries, + maxEntryBytes, + maxExpandedBytes, + maxPathBytes, + maxTotalFileBytes, + }; +} + +function decodeTarString(bytes, label = "tar string field") { + const nul = bytes.indexOf(0); + if (nul !== -1 && bytes.subarray(nul + 1).some((byte) => byte !== 0)) { + throw new Error(`${label} has non-zero bytes after its NUL terminator.`); + } + const value = bytes.subarray(0, nul === -1 ? bytes.length : nul); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(value); + } catch { + throw new Error(`${label} is not valid UTF-8.`); + } +} + +function decodeConsumerTarPathField(bytes, label) { + const raw = decodeTarString(bytes, label); + const consumerValue = raw.trim(); + if (raw !== consumerValue) { + throw new Error(`${label} changes under the pinned ClawHub path normalization.`); + } + return consumerValue; +} + +function normalizeConsumerTarPath(value, options = {}) { + const rawPath = options.directory === true && value.endsWith("/") ? value.slice(0, -1) : value; + const normalized = rawPath.replaceAll("\\", "/").replace(/^\.\/+/u, ""); + const segments = normalized.split("/").filter(Boolean); + const consumerPath = segments.join("/"); + if ( + consumerPath !== rawPath || + segments.length === 0 || + segments.some((segment) => segment === "." || segment === "..") + ) { + throw new Error( + `Tar entry path changes under the pinned ClawHub normalization: ${JSON.stringify(value)}.`, + ); + } + return assertSafeArchivePath(consumerPath, "tar entry path"); +} + +function parseCanonicalTarNumber(bytes, label, options = {}) { + const { allowEmpty = false } = options; + if ((bytes[0] & 0x80) !== 0) { + throw new Error(`${label} must not use base-256 encoding.`); + } + if (bytes.every((byte) => byte === 0)) { + if (allowEmpty) { + return 0; + } + throw new Error(`${label} must not be empty.`); + } + const raw = bytes.toString("ascii"); + const canonicalDigits = new RegExp(`^[0-7]{${bytes.length - 2}}$`, "u"); + const canonical = canonicalDigits.test(raw.slice(0, -2)) && raw.endsWith(" \0"); + if (!canonical) { + throw new Error(`${label} is not canonically encoded.`); + } + const octal = raw.slice(0, -2); + const value = Number.parseInt(octal, 8); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid ${label}: ${JSON.stringify(octal)}`); + } + return value; +} + +function verifyTarChecksum(header) { + const expected = parseCanonicalTarNumber(header.subarray(148, 156), "tar checksum"); + let actual = 0; + for (let index = 0; index < header.length; index += 1) { + actual += index >= 148 && index < 156 ? 0x20 : header[index]; + } + if (actual !== expected) { + throw new Error(`Tar header checksum mismatch: expected ${expected}, got ${actual}.`); + } +} + +function verifyCanonicalTarHeader(header) { + if ( + !header.subarray(257, 263).equals(TAR_USTAR_MAGIC) || + !header.subarray(263, 265).equals(TAR_USTAR_VERSION) + ) { + throw new Error("Plugin tarball entries must use canonical POSIX USTAR headers."); + } + decodeTarString(header.subarray(0, 100), "tar entry name"); + parseCanonicalTarNumber(header.subarray(100, 108), "tar entry mode"); + parseCanonicalTarNumber(header.subarray(108, 116), "tar entry uid", { allowEmpty: true }); + parseCanonicalTarNumber(header.subarray(116, 124), "tar entry gid", { allowEmpty: true }); + parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size"); + parseCanonicalTarNumber(header.subarray(136, 148), "tar entry mtime"); + decodeTarString(header.subarray(157, 257), "tar entry link path"); + decodeTarString(header.subarray(265, 297), "tar entry user name"); + decodeTarString(header.subarray(297, 329), "tar entry group name"); + parseCanonicalTarNumber(header.subarray(329, 337), "tar entry device major"); + parseCanonicalTarNumber(header.subarray(337, 345), "tar entry device minor"); + if (header[475] === 0) { + decodeTarString(header.subarray(345, 475), "tar entry prefix"); + parseCanonicalTarNumber(header.subarray(476, 488), "tar entry access time", { + allowEmpty: true, + }); + parseCanonicalTarNumber(header.subarray(488, 500), "tar entry change time", { + allowEmpty: true, + }); + } else { + decodeTarString(header.subarray(345, 500), "tar entry prefix"); + } +} + +function isZeroTarBlock(bytes) { + return bytes.length === TAR_BLOCK_BYTES && bytes.every((byte) => byte === 0); +} + +function parsePackedJson(bytes, label) { + let value; + try { + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (error) { + throw new Error( + `${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value; +} + +function firstGzipMemberEnd(bytes, maxOutputLength) { + if ( + bytes.length < 18 || + bytes[0] !== 0x1f || + bytes[1] !== 0x8b || + bytes[2] !== 0x08 || + (bytes[3] & 0xe0) !== 0 + ) { + throw new Error("Invalid gzip header."); + } + + const flags = bytes[3]; + let offset = 10; + if ((flags & 0x04) !== 0) { + if (offset + 2 > bytes.length) { + throw new Error("Truncated gzip extra-field length."); + } + const extraLength = bytes.readUInt16LE(offset); + offset += 2 + extraLength; + if (offset > bytes.length) { + throw new Error("Truncated gzip extra field."); + } + } + for (const flag of [0x08, 0x10]) { + if ((flags & flag) === 0) { + continue; + } + const terminator = bytes.indexOf(0, offset); + if (terminator === -1) { + throw new Error("Unterminated gzip header string."); + } + offset = terminator + 1; + } + if ((flags & 0x02) !== 0) { + offset += 2; + } + if (offset + 8 > bytes.length) { + throw new Error("Truncated gzip member."); + } + + const expanded = inflateRawSync(bytes.subarray(offset), { + info: true, + maxOutputLength, + }); + return offset + expanded.engine.bytesWritten + 8; +} + +export function inspectPackageTarballBytes(inputBytes, options = {}) { + if (!(inputBytes instanceof Uint8Array)) { + throw new Error("Plugin tarball bytes must be a Uint8Array."); + } + const tarballBytes = Buffer.from(inputBytes.buffer, inputBytes.byteOffset, inputBytes.byteLength); + const limits = normalizeTarInspectionOptions(options); + if (tarballBytes.length === 0 || tarballBytes.length > limits.maxArchiveBytes) { + throw new Error(`Plugin tarball size is outside the allowed range: ${tarballBytes.length}.`); + } + let memberEnd; + try { + memberEnd = firstGzipMemberEnd(tarballBytes, limits.maxExpandedBytes); + } catch (error) { + throw new Error( + `Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`, + { cause: error }, + ); + } + if (memberEnd !== tarballBytes.length) { + throw new Error("Plugin tarball must contain exactly one gzip member."); + } + let tarBytes; + try { + const expanded = gunzipSync(tarballBytes, { + info: true, + maxOutputLength: limits.maxExpandedBytes, + }); + if (expanded.engine.bytesWritten !== tarballBytes.length) { + throw new Error("gzip stream does not consume the full plugin tarball"); + } + tarBytes = expanded.buffer; + } catch (error) { + throw new Error( + `Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`, + { cause: error }, + ); + } + + const inventory = []; + const seenPaths = new Set(); + const seenAliases = new Set(); + let packageManifestBytes; + let pluginManifestBytes; + let offset = 0; + let sawEndMarker = false; + let entryCount = 0; + let totalFileBytes = 0; + let totalPathBytes = 0; + + while (offset + TAR_BLOCK_BYTES <= tarBytes.length) { + const header = tarBytes.subarray(offset, offset + TAR_BLOCK_BYTES); + if (isZeroTarBlock(header)) { + const secondEndBlock = tarBytes.subarray( + offset + TAR_BLOCK_BYTES, + offset + TAR_END_MARKER_BYTES, + ); + if ( + !isZeroTarBlock(secondEndBlock) || + !tarBytes.subarray(offset + TAR_END_MARKER_BYTES).every((byte) => byte === 0) + ) { + throw new Error( + "Plugin tarball must end with two zero blocks and contain no trailing entries.", + ); + } + sawEndMarker = true; + break; + } + entryCount += 1; + if (entryCount > limits.maxEntries) { + throw new Error(`Plugin tarball exceeds the ${limits.maxEntries} entry limit.`); + } + verifyTarChecksum(header); + verifyCanonicalTarHeader(header); + const headerName = decodeConsumerTarPathField(header.subarray(0, 100), "tar entry name"); + const headerPrefix = + header[475] === 0 + ? decodeConsumerTarPathField(header.subarray(345, 475), "tar entry prefix") + : decodeConsumerTarPathField(header.subarray(345, 500), "tar entry prefix"); + const headerPath = headerPrefix ? `${headerPrefix}/${headerName}` : headerName; + const headerSize = parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size"); + const typeFlag = String.fromCharCode(header[156] || 0x30); + const linkPath = decodeTarString(header.subarray(157, 257), "tar entry link path"); + if (typeFlag === "x" || typeFlag === "g" || typeFlag === "L" || typeFlag === "K") { + throw new Error("PAX and GNU tar metadata are not supported for plugin artifacts."); + } + if (typeFlag !== "0" && typeFlag !== "5") { + const suffix = linkPath ? ` -> ${linkPath}` : ""; + throw new Error( + `Unsupported plugin tar entry type ${JSON.stringify(typeFlag)}: ${headerPath}${suffix}`, + ); + } + if (typeFlag === "5" && headerSize !== 0) { + throw new Error(`Directory tar entry ${JSON.stringify(headerPath)} must have size zero.`); + } + if (linkPath) { + throw new Error( + `Plugin tar entries must not carry link targets: ${headerPath} -> ${linkPath}`, + ); + } + if (typeFlag !== "5" && headerPath.endsWith("/")) { + throw new Error( + `Non-directory tar entry must not end with a slash: ${JSON.stringify(headerPath)}.`, + ); + } + + const safePath = normalizeConsumerTarPath(headerPath, { directory: typeFlag === "5" }); + if (safePath !== "package" && !safePath.startsWith("package/")) { + throw new Error(`Plugin tar entry must stay under package/: ${safePath}`); + } + const alias = safePath.toLocaleLowerCase("en-US"); + if (seenPaths.has(safePath) || seenAliases.has(alias)) { + throw new Error(`Duplicate or aliased plugin tar entry: ${safePath}`); + } + seenPaths.add(safePath); + seenAliases.add(alias); + totalPathBytes += Buffer.byteLength(safePath, "utf8"); + if (totalPathBytes > limits.maxPathBytes) { + throw new Error(`Plugin tarball paths exceed the ${limits.maxPathBytes} byte limit.`); + } + + if (headerSize > limits.maxEntryBytes) { + throw new Error(`Plugin tar entry ${safePath} exceeds ${limits.maxEntryBytes} bytes.`); + } + const dataOffset = offset + TAR_BLOCK_BYTES; + const dataEnd = dataOffset + headerSize; + const nextOffset = dataOffset + Math.ceil(headerSize / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES; + if (dataEnd > tarBytes.length || nextOffset > tarBytes.length) { + throw new Error(`Tar entry ${JSON.stringify(safePath)} exceeds the archive boundary.`); + } + const content = tarBytes.subarray(dataOffset, dataEnd); + offset = nextOffset; + + if (typeFlag === "5") { + inventory.push({ path: safePath, sizeBytes: 0, type: "directory" }); + continue; + } + totalFileBytes += content.length; + if (totalFileBytes > limits.maxTotalFileBytes) { + throw new Error(`Plugin tarball file payload exceeds ${limits.maxTotalFileBytes} bytes.`); + } + const entry = { + path: safePath, + sha256: sha256(content), + sizeBytes: content.length, + type: "file", + }; + inventory.push(entry); + if (safePath === "package/package.json") { + if (content.length === 0 || content.length > MAX_MANIFEST_BYTES) { + throw new Error( + `Packed package.json size is outside the allowed range: ${content.length}.`, + ); + } + packageManifestBytes = Buffer.from(content); + } else if (safePath === "package/openclaw.plugin.json") { + if (content.length === 0 || content.length > MAX_PLUGIN_MANIFEST_BYTES) { + throw new Error( + `Packed openclaw.plugin.json size is outside the allowed range: ${content.length}.`, + ); + } + pluginManifestBytes = Buffer.from(content); + } + } + + if (!sawEndMarker) { + throw new Error("Plugin tarball is missing its end marker."); + } + if (!packageManifestBytes) { + throw new Error("Plugin tarball must contain exactly one package/package.json."); + } + if (!pluginManifestBytes) { + throw new Error("Plugin tarball must contain exactly one package/openclaw.plugin.json."); + } + inventory.sort((left, right) => compareCodeUnits(left.path, right.path)); + const packageManifest = parsePackedJson(packageManifestBytes, "Packed package.json"); + const pluginManifest = parsePackedJson(pluginManifestBytes, "Packed openclaw.plugin.json"); + return { + inventory, + packageManifest, + packageManifestSha256: sha256(packageManifestBytes), + pluginManifest, + pluginManifestSha256: sha256(pluginManifestBytes), + tarballSizeBytes: tarballBytes.byteLength, + tarballSha256: sha256(tarballBytes), + totalFileBytes, + }; +} + +export function validatePluginPackageManifest(params, packageManifest) { + if (packageManifest.name !== params.packageName || packageManifest.version !== params.version) { + throw new Error( + `Packed plugin identity ${String(packageManifest.name)}@${String(packageManifest.version)} does not match ${params.packageName}@${params.version}.`, + ); + } + if (Object.hasOwn(packageManifest, "tag")) { + throw new Error( + `${params.packageName}: packed package.json must not override the approved publication tag.`, + ); + } + const release = packageManifest.openclaw?.release; + const referencesMetaIdentity = + params.packageName === META_PACKAGE || params.packageDir === META_PACKAGE_DIR; + if ( + referencesMetaIdentity && + (params.packageName !== META_PACKAGE || + params.packageDir !== META_PACKAGE_DIR || + release?.publishToNpm !== true || + release?.publishToClawHub !== true) + ) { + throw new Error( + `${META_PACKAGE}: Meta publication requires ${META_PACKAGE_DIR} with npm and ClawHub enabled.`, + ); + } + if (params.route.startsWith("npm-") && release?.publishToNpm !== true) { + throw new Error(`${params.packageName}: packed plugin is not marked publishToNpm.`); + } + if (params.route.startsWith("clawhub-") && release?.publishToClawHub !== true) { + throw new Error(`${params.packageName}: packed plugin is not marked publishToClawHub.`); + } + if (packageManifest.publishConfig !== undefined) { + throw new Error( + `${params.packageName}: packed package.json must not override publication through publishConfig.`, + ); + } +} + +function normalizePublicationParams(params) { + const targetSha = assertString(params.targetSha, "target SHA"); + if (!SHA_RE.test(targetSha)) { + throw new Error(`Target SHA must be a full lowercase commit SHA: ${targetSha}`); + } + const packageDir = assertSafePackageDir(params.packageDir); + const packageName = assertString(params.packageName, "package name"); + if (!PACKAGE_NAME_RE.test(packageName)) { + throw new Error(`Invalid plugin package name: ${packageName}`); + } + const version = assertString(params.version, "package version"); + if (!VERSION_RE.test(version)) { + throw new Error(`Invalid plugin package version: ${version}`); + } + const route = assertString(params.route, "publication route"); + if (!ROUTES.has(route)) { + throw new Error(`Unsupported plugin publication route: ${route}`); + } + const npmRoutePolicy = NPM_ROUTE_POLICIES.get(route); + let publicationReason = null; + let publisherPolicy = null; + if (npmRoutePolicy) { + if (params.authMode !== undefined && params.authMode !== npmRoutePolicy.authMode) { + throw new Error(`${route} auth mode must be ${npmRoutePolicy.authMode}.`); + } + if (params.capability !== undefined && params.capability !== npmRoutePolicy.capability) { + throw new Error(`${route} capability must be ${npmRoutePolicy.capability}.`); + } + publicationReason = normalizePublicationReason(params.publicationReason); + publisherPolicy = normalizePublisherPolicy(params.publisherPolicy); + } else if ( + params.authMode !== undefined || + params.capability !== undefined || + params.publicationReason !== undefined || + params.publisherPolicy !== undefined + ) { + throw new Error(`${route} must not carry npm publisher-policy controls.`); + } + const publishTag = assertString(params.publishTag, "publish tag"); + const allowedTags = route.startsWith("npm-") ? NPM_TAGS : CLAWHUB_TAGS; + if (!allowedTags.has(publishTag)) { + throw new Error(`Unsupported ${route} publish tag: ${publishTag}`); + } + if (route.startsWith("npm-")) { + const override = publishTag === "extended-stable" ? publishTag : undefined; + const publishPlan = resolveNpmPublishPlan(version, undefined, override); + if (publishPlan.publishTag !== publishTag) { + throw new Error( + `${packageName}@${version}: npm publish tag ${publishTag} does not match release channel ${publishPlan.channel}.`, + ); + } + } else { + const expectedTag = version.includes("-alpha.") + ? "alpha" + : version.includes("-beta.") + ? "beta" + : "latest"; + if (publishTag !== expectedTag) { + throw new Error( + `${packageName}@${version}: ClawHub publish tag ${publishTag} must be ${expectedTag}.`, + ); + } + } + const artifactName = assertSafeArtifactName(params.artifactName); + let bootstrapMode = null; + let requiresManualOverride = false; + const manualOverrideReason = normalizeManualOverrideReason(params.manualOverrideReason); + if (route === "clawhub-token-bootstrap") { + bootstrapMode = assertString(params.bootstrapMode, "bootstrap mode"); + if (bootstrapMode !== "publish" && bootstrapMode !== "configure-only") { + throw new Error(`Unsupported ClawHub bootstrap mode: ${bootstrapMode}`); + } + requiresManualOverride = params.requiresManualOverride === true; + } else if (route === "clawhub-token-release") { + requiresManualOverride = params.requiresManualOverride === true; + } else if ( + params.bootstrapMode !== undefined || + params.requiresManualOverride === true || + manualOverrideReason !== null + ) { + throw new Error(`${route} must not carry ClawHub bootstrap controls.`); + } + if (requiresManualOverride !== (manualOverrideReason !== null)) { + throw new Error( + `${route} must bind a manual override reason exactly when a manual override is required.`, + ); + } + let sourcePackageJsonSha256; + if (params.sourcePackageJsonSha256 !== undefined) { + sourcePackageJsonSha256 = assertString( + params.sourcePackageJsonSha256, + "source package.json SHA-256", + ); + if (!SHA256_RE.test(sourcePackageJsonSha256)) { + throw new Error( + `Source package.json SHA-256 must be 64 lowercase hex characters: ${sourcePackageJsonSha256}`, + ); + } + } + return { + artifactName, + authMode: npmRoutePolicy?.authMode ?? null, + bootstrapMode, + capability: npmRoutePolicy?.capability ?? null, + manualOverrideReason, + packageDir, + packageName, + publicationReason, + publishTag, + publisherPolicy, + requiresManualOverride, + route, + sourcePackageJsonSha256, + targetSha, + version, + }; +} + +function buildManifest(params, tarballName, tarballBytes, inspection) { + validatePluginPackageManifest(params, inspection.packageManifest); + const publication = params.authMode + ? { + route: params.route, + authMode: params.authMode, + capability: params.capability, + reason: params.publicationReason, + tag: params.publishTag, + publisherPolicy: params.publisherPolicy, + } + : { + route: params.route, + tag: params.publishTag, + bootstrapMode: params.bootstrapMode, + manualOverrideReason: params.manualOverrideReason, + requiresManualOverride: params.requiresManualOverride, + }; + return { + schema: MANIFEST_SCHEMA, + schemaVersion: 1, + targetSha: params.targetSha, + package: { + dir: params.packageDir, + name: params.packageName, + version: params.version, + author: inspection.packageManifest.author ?? null, + contributors: inspection.packageManifest.contributors ?? null, + repository: inspection.packageManifest.repository ?? null, + packageJsonSha256: inspection.packageManifestSha256, + pluginManifestSha256: inspection.pluginManifestSha256, + sourcePackageJsonSha256: params.sourcePackageJsonSha256, + }, + publication, + artifact: { + name: params.artifactName, + tarball: tarballName, + npmIntegrity: npmIntegrity(tarballBytes), + npmShasum: npmShasum(tarballBytes), + sha256: inspection.tarballSha256, + sizeBytes: tarballBytes.length, + inventory: inspection.inventory, + }, + }; +} + +function tarInspectionOptionsForRoute(route) { + return route.startsWith("clawhub-") ? CLAWHUB_PUBLICATION_TAR_LIMITS : undefined; +} + +function normalizeExpectedInventory(value) { + if (!Array.isArray(value)) { + throw new Error("Expected plugin tarball inventory must be an array."); + } + const paths = new Set(); + const aliases = new Set(); + const inventory = value.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("Expected plugin tarball inventory entries must be objects."); + } + const expectedKeys = + entry.type === "directory" + ? ["path", "sizeBytes", "type"] + : ["path", "sha256", "sizeBytes", "type"]; + if (JSON.stringify(Object.keys(entry).toSorted()) !== JSON.stringify(expectedKeys)) { + throw new Error("Expected plugin tarball inventory entry shape is invalid."); + } + const path = assertSafeArchivePath(entry.path, "expected tar entry path"); + if (path !== entry.path) { + throw new Error(`Expected plugin tarball path is not canonical: ${entry.path}`); + } + const alias = path.toLocaleLowerCase("en-US"); + if (paths.has(path) || aliases.has(alias)) { + throw new Error(`Expected plugin tarball inventory contains an alias: ${path}`); + } + paths.add(path); + aliases.add(alias); + if ( + !Number.isSafeInteger(entry.sizeBytes) || + entry.sizeBytes < 0 || + (entry.type === "directory" && entry.sizeBytes !== 0) + ) { + throw new Error(`Expected plugin tarball size is invalid for ${path}.`); + } + if (entry.type === "directory") { + return { path, sizeBytes: 0, type: "directory" }; + } + if ( + entry.type !== "file" || + typeof entry.sha256 !== "string" || + !SHA256_RE.test(entry.sha256) + ) { + throw new Error(`Expected plugin tarball file identity is invalid for ${path}.`); + } + return { + path, + sha256: entry.sha256, + sizeBytes: entry.sizeBytes, + type: "file", + }; + }); + return inventory.toSorted((left, right) => compareCodeUnits(left.path, right.path)); +} + +function canonicalManifestText(manifest) { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +function createFreshOutputDirectory(path, label) { + try { + lstatSync(path); + throw new Error(`${label} must not already exist.`); + } catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") { + throw error; + } + } + mkdirSync(path, { recursive: true, mode: 0o700 }); + const created = lstatSync(path); + if (!created.isDirectory() || created.isSymbolicLink()) { + throw new Error(`${label} must be a newly created directory.`); + } +} + +export function createPluginPublicationArtifact(params) { + const normalized = normalizePublicationParams(params); + if (!normalized.sourcePackageJsonSha256) { + throw new Error("Plugin publication creation requires source package.json SHA-256."); + } + const artifactDir = resolve(params.artifactDir); + const entries = readdirSync(artifactDir, { withFileTypes: true }); + const tarballs = entries.filter( + (entry) => entry.isFile() && entry.name.endsWith(".tgz") && entry.name !== MANIFEST_FILENAME, + ); + const unexpected = entries.filter((entry) => !(entry.isFile() && entry.name.endsWith(".tgz"))); + if (tarballs.length !== 1 || unexpected.length !== 0) { + throw new Error( + `Plugin publication staging dir must contain exactly one .tgz and no other entries; found ${entries.map((entry) => entry.name).join(", ") || ""}.`, + ); + } + const tarballName = assertSafeArchivePath(tarballs[0].name, "tarball filename"); + if (basename(tarballName) !== tarballName) { + throw new Error(`Plugin tarball must be at the artifact root: ${tarballName}`); + } + const tarballPath = join(artifactDir, tarballName); + const tarballBytes = readBoundedRegularFile(tarballPath, { + label: "Plugin tarball", + maxBytes: MAX_ARCHIVE_BYTES, + }); + const inspection = inspectPackageTarballBytes( + tarballBytes, + tarInspectionOptionsForRoute(normalized.route), + ); + const manifest = buildManifest(normalized, tarballName, tarballBytes, inspection); + const manifestPath = join(artifactDir, MANIFEST_FILENAME); + const manifestText = canonicalManifestText(manifest); + if (Buffer.byteLength(manifestText) > MAX_MANIFEST_BYTES) { + throw new Error(`Plugin publication manifest exceeds ${MAX_MANIFEST_BYTES} bytes.`); + } + writeFileSync(manifestPath, manifestText, { mode: 0o600 }); + return { manifest, manifestPath, tarballPath }; +} + +function parseBoundedJsonFile(path, label, maxBytes = MAX_MANIFEST_BYTES) { + let value; + try { + value = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + readBoundedRegularFile(path, { + label, + maxBytes, + }), + ), + ); + } catch (error) { + throw new Error( + `${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value; +} + +function inspectPluginPublicationArtifactZip(zipBytes) { + return inspectActionsArtifactZipWithPolicy(zipBytes, { + minEntries: 2, + maxEntries: 2, + maxArchiveBytes: MAX_ARCHIVE_BYTES, + maxExpandedBytes: MAX_ARCHIVE_BYTES + MAX_MANIFEST_BYTES, + allowPath: (name) => + name === MANIFEST_FILENAME || (basename(name) === name && name.endsWith(".tgz")), + maxEntryBytes: (name) => (name === MANIFEST_FILENAME ? MAX_MANIFEST_BYTES : MAX_ARCHIVE_BYTES), + }); +} + +export function verifyPluginPublicationArtifact(params) { + const normalized = normalizePublicationParams(params); + let expectedTarballSha256; + if (params.expectedTarballSha256 !== undefined) { + expectedTarballSha256 = assertString(params.expectedTarballSha256, "expected tarball SHA-256"); + if (!SHA256_RE.test(expectedTarballSha256)) { + throw new Error("Expected tarball SHA-256 must be 64 lowercase hex characters."); + } + } + const expectedTarballSizeBytes = + params.expectedTarballSizeBytes === undefined + ? undefined + : assertPositiveInteger(params.expectedTarballSizeBytes, "expected tarball size"); + const expectedInventory = + params.expectedInventory === undefined + ? undefined + : normalizeExpectedInventory(params.expectedInventory); + const artifactId = assertPositiveInteger(params.artifactId, "artifact ID"); + const artifactSizeBytes = assertPositiveInteger(params.artifactSizeBytes, "artifact size"); + const runId = assertPositiveInteger( + params.producerRunId ?? params.runId, + "producer workflow run ID", + ); + const runAttempt = assertPositiveInteger( + params.producerRunAttempt ?? params.runAttempt, + "producer workflow run attempt", + ); + const workflowSha = assertString(params.workflowSha, "workflow SHA"); + if (!SHA_RE.test(workflowSha)) { + throw new Error(`Workflow SHA must be a full lowercase commit SHA: ${workflowSha}`); + } + const expectedArtifactDigest = assertString(params.artifactDigest, "artifact digest"); + if (!ARTIFACT_DIGEST_RE.test(expectedArtifactDigest)) { + throw new Error(`Invalid Actions artifact digest: ${expectedArtifactDigest}`); + } + const metadata = parseBoundedJsonFile(params.artifactMetadataPath, "Actions artifact metadata"); + const workflowRun = parseBoundedJsonFile( + params.workflowRunMetadataPath, + "Actions workflow run metadata", + ); + const expectedBinding = { + artifactDigest: expectedArtifactDigest, + artifactId, + artifactName: normalized.artifactName, + artifactSizeBytes, + consumerRunAttempt: params.consumerRunAttempt, + producerJobName: params.producerJobName, + repository: params.repository, + runStatePolicy: params.runStatePolicy ?? "completed-success", + runAttempt, + runId, + workflowEvent: params.workflowEvent, + workflowHeadBranch: params.workflowHeadBranch, + workflowPath: params.workflowPath, + workflowSha, + }; + validateActionsArtifactBinding({ + artifactMetadata: metadata, + expected: expectedBinding, + workflowRun, + }); + if (expectedBinding.runStatePolicy === "same-run-producer-success") { + const workflowJobs = parseBoundedJsonFile( + params.workflowJobsMetadataPath, + "Actions workflow jobs metadata", + ); + validateActionsArtifactProducerJob({ expected: expectedBinding, workflowJobs }); + } + + const zipBytes = readBoundedRegularFile(params.artifactZipPath, { + label: "Actions artifact ZIP", + maxBytes: MAX_ARCHIVE_BYTES, + }); + if (zipBytes.length !== artifactSizeBytes) { + throw new Error("Actions artifact ZIP size does not match the immutable publish tuple."); + } + const actualArtifactDigest = sha256Digest(zipBytes); + if (actualArtifactDigest !== expectedArtifactDigest) { + throw new Error( + `Actions artifact ZIP digest ${actualArtifactDigest} does not match ${expectedArtifactDigest}.`, + ); + } + const files = inspectPluginPublicationArtifactZip(zipBytes); + const manifestBytes = files.get(MANIFEST_FILENAME); + const tarballNames = [...files.keys()].filter((name) => name.endsWith(".tgz")); + if (!manifestBytes || tarballNames.length !== 1) { + throw new Error( + `Plugin publication artifact must contain ${MANIFEST_FILENAME} and exactly one .tgz.`, + ); + } + if (manifestBytes.length === 0 || manifestBytes.length > MAX_MANIFEST_BYTES) { + throw new Error(`Plugin publication manifest size is outside the allowed range.`); + } + let manifest; + try { + manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes)); + } catch (error) { + throw new Error( + `Plugin publication manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + const manifestSourcePackageJsonSha256 = manifest?.package?.sourcePackageJsonSha256; + if ( + typeof manifestSourcePackageJsonSha256 !== "string" || + !SHA256_RE.test(manifestSourcePackageJsonSha256) + ) { + throw new Error("Plugin publication manifest source package.json SHA-256 is invalid."); + } + if ( + normalized.sourcePackageJsonSha256 !== undefined && + normalized.sourcePackageJsonSha256 !== manifestSourcePackageJsonSha256 + ) { + throw new Error( + "Plugin publication source package.json SHA-256 does not match the approved target source.", + ); + } + const tarballName = tarballNames[0]; + const tarballBytes = files.get(tarballName); + const inspection = inspectPackageTarballBytes( + tarballBytes, + tarInspectionOptionsForRoute(normalized.route), + ); + if ( + expectedTarballSizeBytes !== undefined && + inspection.tarballSizeBytes !== expectedTarballSizeBytes + ) { + throw new Error("Plugin tarball size does not match the approved publication tuple."); + } + if (expectedTarballSha256 !== undefined && inspection.tarballSha256 !== expectedTarballSha256) { + throw new Error("Plugin tarball SHA-256 does not match the approved publication tuple."); + } + if ( + expectedInventory !== undefined && + JSON.stringify(inspection.inventory) !== JSON.stringify(expectedInventory) + ) { + throw new Error("Plugin tarball inventory does not match the approved publication tuple."); + } + const expectedManifest = buildManifest( + { + ...normalized, + sourcePackageJsonSha256: manifestSourcePackageJsonSha256, + }, + tarballName, + tarballBytes, + inspection, + ); + const expectedManifestBytes = Buffer.from(canonicalManifestText(expectedManifest), "utf8"); + if (!manifestBytes.equals(expectedManifestBytes)) { + throw new Error( + "Plugin publication manifest does not canonically bind the approved package and tarball.", + ); + } + + const outputDir = resolve(params.outputDir); + createFreshOutputDirectory(outputDir, "Plugin publication output directory"); + const outputPath = join(outputDir, tarballName); + writeFileSync(outputPath, tarballBytes, { flag: "wx", mode: 0o600 }); + if (!statSync(outputPath).isFile()) { + throw new Error(`Verified plugin tarball was not written: ${outputPath}`); + } + return { + artifactDigest: expectedArtifactDigest, + artifactId, + artifactName: normalized.artifactName, + artifactSizeBytes, + artifactZipSha256: actualArtifactDigest.slice("sha256:".length), + manifest: expectedManifest, + npmIntegrity: expectedManifest.artifact.npmIntegrity, + npmShasum: expectedManifest.artifact.npmShasum, + packageJsonSha256: expectedManifest.package.packageJsonSha256, + pluginManifestSha256: expectedManifest.package.pluginManifestSha256, + producerRunAttempt: runAttempt, + producerRunId: runId, + sourcePackageJsonSha256: expectedManifest.package.sourcePackageJsonSha256, + tarballInventory: inspection.inventory, + tarballName, + tarballPath: outputPath, + tarballSizeBytes: inspection.tarballSizeBytes, + tarballSha256: inspection.tarballSha256, + }; +} + +function parseCliArgs(argv) { + const [command, ...rest] = argv; + if (command !== "create" && command !== "verify") { + throw new Error( + "Usage: plugin-publication-artifact.mjs --artifact-name ...", + ); + } + const values = {}; + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index]; + const value = rest[index + 1]; + if (!key?.startsWith("--") || value === undefined || value.startsWith("--")) { + throw new Error(`Invalid ${command} argument near ${key ?? ""}.`); + } + const name = key.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase()); + if (values[name] !== undefined) { + throw new Error(`Duplicate ${command} option: ${key}`); + } + values[name] = value; + } + return { command, values }; +} + +function commonCliParams(values) { + return { + artifactName: values.artifactName, + bootstrapMode: values.bootstrapMode, + manualOverrideReason: values.manualOverrideReason, + packageDir: values.packageDir, + packageName: values.packageName, + publishTag: values.publishTag, + requiresManualOverride: + values.requiresManualOverride === undefined + ? false + : assertBooleanString(values.requiresManualOverride, "requires-manual-override"), + route: values.route, + publicationReason: values.publicationReason, + publisherPolicy: + values.publisherPolicySchema === undefined && + values.publisherPolicyId === undefined && + values.publisherPolicySha256 === undefined + ? undefined + : { + policyId: values.publisherPolicyId, + schema: values.publisherPolicySchema, + sha256: values.publisherPolicySha256, + }, + sourcePackageJsonSha256: values.sourcePackageJsonSha256, + targetSha: values.targetSha, + version: values.packageVersion, + }; +} + +function appendGithubOutput(path, values) { + const lines = Object.entries(values).map(([name, value]) => `${name}=${String(value)}`); + writeFileSync(path, `${lines.join("\n")}\n`, { flag: "a" }); +} + +export function main(argv = process.argv.slice(2)) { + const { command, values } = parseCliArgs(argv); + const common = commonCliParams(values); + if (command === "create") { + const result = createPluginPublicationArtifact({ + ...common, + artifactDir: values.artifactDir, + }); + console.log(`Created canonical plugin publication manifest: ${result.manifestPath}`); + console.log(`Prepared plugin tarball: ${result.tarballPath}`); + return; + } + const result = verifyPluginPublicationArtifact({ + ...common, + artifactDigest: values.artifactDigest, + artifactId: Number(values.artifactId), + artifactMetadataPath: values.artifactMetadata, + artifactSizeBytes: Number(values.artifactSizeBytes), + artifactZipPath: values.artifactZip, + expectedTarballSizeBytes: + values.expectedTarballSizeBytes === undefined + ? undefined + : Number(values.expectedTarballSizeBytes), + expectedTarballSha256: values.expectedTarballSha256, + outputDir: values.outputDir, + consumerRunAttempt: + values.consumerRunAttempt === undefined ? undefined : Number(values.consumerRunAttempt), + producerJobName: values.producerJobName, + producerRunAttempt: Number(values.producerRunAttempt), + producerRunId: Number(values.producerRunId), + repository: values.repository, + workflowEvent: values.workflowEvent, + workflowHeadBranch: values.workflowHeadBranch, + workflowPath: values.workflowPath, + workflowJobsMetadataPath: values.workflowJobsMetadata, + workflowRunMetadataPath: values.workflowRunMetadata, + runStatePolicy: values.runStatePolicy, + workflowSha: values.workflowSha, + }); + if (values.githubOutput) { + appendGithubOutput(values.githubOutput, { + artifact_digest: result.artifactDigest, + artifact_id: result.artifactId, + artifact_name: result.artifactName, + artifact_size_bytes: result.artifactSizeBytes, + artifact_zip_sha256: result.artifactZipSha256, + bootstrap_mode: result.manifest.publication.bootstrapMode ?? "", + manual_override_reason: result.manifest.publication.manualOverrideReason ?? "", + package_name: result.manifest.package.name, + package_json_sha256: result.manifest.package.packageJsonSha256, + package_version: result.manifest.package.version, + npm_integrity: result.npmIntegrity, + npm_shasum: result.npmShasum, + publish_route: result.manifest.publication.route, + publish_tag: result.manifest.publication.tag, + producer_run_attempt: result.producerRunAttempt, + producer_run_id: result.producerRunId, + requires_manual_override: result.manifest.publication.requiresManualOverride, + source_package_json_sha256: result.manifest.package.sourcePackageJsonSha256, + tarball_path: result.tarballPath, + tarball_sha256: result.tarballSha256, + }); + } + console.log( + `Verified ${result.manifest.package.name}@${result.manifest.package.version} plugin artifact ${result.artifactId} (${result.artifactDigest}).`, + ); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined; +if (entrypoint === import.meta.url) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 759cd29ece64..0ae4e7af3cc1 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -2,10 +2,37 @@ // Coordinates release-candidate validation runs and emits the publish command // only after required local, CI, npm, plugin, and E2E evidence is green. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs"; +import { readBoundedResponseText } from "./lib/bounded-response.mjs"; +import { + dedicatedSectionVersionForTag, + extractChangelogReleaseSections, + extractChangelogSection, + formatShippedBaselineExclusions, + parseShippedBaselineExclusions, + releaseNotesSectionForTag, + releaseNotesVersionForTag, + renderGithubReleaseNotes, +} from "./render-github-release-notes.mjs"; +import { + isShaPinnedReleaseValidationBranch, + runStrictReleaseEvidenceValidation, + validateFullReleaseValidationEvidence, +} from "./validate-full-release-validation-evidence.mjs"; const DEFAULT_REPO = "openclaw/openclaw"; const DEFAULT_PROVIDER = "openai"; @@ -14,6 +41,11 @@ const DEFAULT_NPM_DIST_TAG = "beta"; const DEFAULT_PLUGIN_SCOPE = "all-publishable"; const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai"; const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000; +const DEFAULT_GITHUB_API_RESPONSE_BODY_MAX_BYTES = 16 * 1024 * 1024; +const COMMAND_CAPTURE_MAX_BUFFER_BYTES = 16 * 1024 * 1024; +const TOOLING_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN = + /^tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$/u; const WINDOWS_NODE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u; const WINDOWS_NODE_REPO = "openclaw/openclaw-windows-node"; const WINDOWS_NODE_REQUIRED_ASSETS = [ @@ -21,6 +53,25 @@ const WINDOWS_NODE_REQUIRED_ASSETS = [ "OpenClawCompanion-Setup-arm64.exe", ]; const SHA256_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const RELEASE_CANDIDATE_STATE_VERSION = 1; +const RELEASE_CANDIDATE_STATE_FILE = "release-candidate-state.json"; +const RELEASE_CANDIDATE_STATE_KEYS = [ + "repo", + "tag", + "targetSha", + "toolingSha", + "workflowRef", + "provider", + "mode", + "releaseProfile", + "npmDistTag", + "pluginPublishScope", + "plugins", + "windowsNodeTag", + "skipParallels", + "skipTelegram", + "telegramProviderMode", +]; function usage() { return `Usage: pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N [options] @@ -31,7 +82,7 @@ OpenClaw Release Publish command only after everything is green. Options: --tag Release tag to validate. - --workflow-ref Workflow branch/ref. Default: current branch. + --workflow-ref Trusted workflow ref. Default: main; matching Tideclaw branch required for alpha. --repo GitHub repo. Default: ${DEFAULT_REPO} --full-release-run Reuse successful Full Release Validation run. --npm-preflight-run Reuse successful OpenClaw NPM Release preflight run. @@ -85,64 +136,72 @@ export function parseArgs(argv) { windowsNodeInstallerDigests: "", outputDir: "", }; + const seen = new Set(); + const setOnce = (flag, key, value) => { + if (seen.has(flag)) { + throw new Error(`${flag} was provided more than once`); + } + seen.add(flag); + options[key] = value; + }; parseArgv: for (let index = 0; index < args.length; index += 1) { const arg = args[index]; switch (arg) { case "--": break parseArgv; case "--tag": - options.tag = requireValue(args, ++index, arg); + setOnce(arg, "tag", requireValue(args, ++index, arg)); break; case "--workflow-ref": - options.workflowRef = requireValue(args, ++index, arg); + setOnce(arg, "workflowRef", requireValue(args, ++index, arg)); break; case "--repo": - options.repo = requireValue(args, ++index, arg); + setOnce(arg, "repo", requireValue(args, ++index, arg)); break; case "--full-release-run": - options.fullReleaseRunId = requireValue(args, ++index, arg); + setOnce(arg, "fullReleaseRunId", requireValue(args, ++index, arg)); break; case "--npm-preflight-run": - options.npmPreflightRunId = requireValue(args, ++index, arg); + setOnce(arg, "npmPreflightRunId", requireValue(args, ++index, arg)); break; case "--windows-node-tag": - options.windowsNodeTag = requireValue(args, ++index, arg); + setOnce(arg, "windowsNodeTag", requireValue(args, ++index, arg)); break; case "--skip-dispatch": - options.skipDispatch = true; + setOnce(arg, "skipDispatch", true); break; case "--skip-local-generated-check": - options.skipLocalGeneratedCheck = true; + setOnce(arg, "skipLocalGeneratedCheck", true); break; case "--skip-parallels": - options.skipParallels = true; + setOnce(arg, "skipParallels", true); break; case "--skip-telegram": - options.skipTelegram = true; + setOnce(arg, "skipTelegram", true); break; case "--telegram-provider-mode": - options.telegramProviderMode = requireValue(args, ++index, arg); + setOnce(arg, "telegramProviderMode", requireValue(args, ++index, arg)); break; case "--provider": - options.provider = requireValue(args, ++index, arg); + setOnce(arg, "provider", requireValue(args, ++index, arg)); break; case "--mode": - options.mode = requireValue(args, ++index, arg); + setOnce(arg, "mode", requireValue(args, ++index, arg)); break; case "--release-profile": - options.releaseProfile = requireValue(args, ++index, arg); + setOnce(arg, "releaseProfile", requireValue(args, ++index, arg)); break; case "--npm-dist-tag": - options.npmDistTag = requireValue(args, ++index, arg); + setOnce(arg, "npmDistTag", requireValue(args, ++index, arg)); break; case "--plugin-publish-scope": - options.pluginPublishScope = requireValue(args, ++index, arg); + setOnce(arg, "pluginPublishScope", requireValue(args, ++index, arg)); break; case "--plugins": - options.plugins = requireValue(args, ++index, arg); + setOnce(arg, "plugins", requireValue(args, ++index, arg)); break; case "--output-dir": - options.outputDir = requireValue(args, ++index, arg); + setOnce(arg, "outputDir", requireValue(args, ++index, arg)); break; case "-h": case "--help": @@ -155,6 +214,18 @@ export function parseArgs(argv) { if (!options.tag) { throw new Error("--tag is required"); } + if (options.tag.includes("-alpha.")) { + if (!TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(options.workflowRef)) { + throw new Error( + "--workflow-ref must be the matching tideclaw/alpha/YYYY-MM-DD-HHMMZ branch for alpha release candidates", + ); + } + } else { + options.workflowRef ||= "main"; + } + if (!options.tag.includes("-alpha.") && options.workflowRef !== "main") { + throw new Error("--workflow-ref must be main for regular beta and stable release candidates"); + } options.releaseProfile ||= options.tag.includes("-alpha.") || options.tag.includes("-beta.") ? "beta" : "stable"; if (!["beta", "stable", "full"].includes(options.releaseProfile)) { @@ -190,10 +261,11 @@ export function parseArgs(argv) { return options; } -function run(command, args, options = {}) { +export function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, encoding: "utf8", + maxBuffer: COMMAND_CAPTURE_MAX_BUFFER_BYTES, stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", }); if (result.status !== 0) { @@ -215,16 +287,87 @@ function readJson(path, label) { } } +export function buildReleaseCandidateState(options, { targetSha, toolingSha }) { + return { + version: RELEASE_CANDIDATE_STATE_VERSION, + phase: "validated", + repo: options.repo, + tag: options.tag, + targetSha, + toolingSha, + workflowRef: options.workflowRef, + provider: options.provider, + mode: options.mode, + releaseProfile: options.releaseProfile, + npmDistTag: options.npmDistTag, + pluginPublishScope: options.pluginPublishScope, + plugins: options.plugins, + windowsNodeTag: options.windowsNodeTag, + skipParallels: options.skipParallels, + skipTelegram: options.skipTelegram, + telegramProviderMode: options.telegramProviderMode, + fullReleaseRunId: options.fullReleaseRunId, + npmPreflightRunId: options.npmPreflightRunId, + }; +} + +export function reconcileReleaseCandidateState(saved, expected) { + if (!saved) { + return expected; + } + if ( + typeof saved !== "object" || + Array.isArray(saved) || + saved.version !== RELEASE_CANDIDATE_STATE_VERSION + ) { + throw new Error("release candidate state has an unsupported schema"); + } + for (const key of RELEASE_CANDIDATE_STATE_KEYS) { + if (!isDeepStrictEqual(saved[key], expected[key])) { + throw new Error( + `release candidate state mismatch for ${key}: saved=${JSON.stringify(saved[key])} current=${JSON.stringify(expected[key])}`, + ); + } + } + for (const key of ["fullReleaseRunId", "npmPreflightRunId"]) { + if (saved[key] && expected[key] && saved[key] !== expected[key]) { + throw new Error(`release candidate state mismatch for ${key}`); + } + } + return { + ...expected, + phase: typeof saved.phase === "string" ? saved.phase : expected.phase, + fullReleaseRunId: expected.fullReleaseRunId || saved.fullReleaseRunId || "", + npmPreflightRunId: expected.npmPreflightRunId || saved.npmPreflightRunId || "", + }; +} + +function writeReleaseCandidateState(path, state) { + mkdirSync(join(path, ".."), { recursive: true }); + const temporaryPath = `${path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`); + renameSync(temporaryPath, path); +} + +function updateReleaseCandidateState(path, state, phase, runIds = {}) { + const next = { ...state, ...runIds, phase }; + writeReleaseCandidateState(path, next); + return next; +} + function githubApiTimeoutMs() { const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS; if (!raw) { return DEFAULT_GITHUB_API_TIMEOUT_MS; } - const value = Number(raw); - if (!Number.isFinite(value) || value <= 0) { - throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive number"); + if (!/^[1-9]\d*$/u.test(raw)) { + throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer"); } - return Math.trunc(value); + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer"); + } + return value; } function githubApiTimedOut(error) { @@ -239,26 +382,44 @@ function githubApiTimedOut(error) { export async function githubApi(path, options = {}) { const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim(); const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs(); - let response; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_GITHUB_API_RESPONSE_BODY_MAX_BYTES; + const controller = new AbortController(); + let timeout; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(new DOMException("request timed out", "TimeoutError")); + reject(new DOMException("request timed out", "TimeoutError")); + }, timeoutMs); + timeout.unref?.(); + }); try { - response = await (options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, { - signal: AbortSignal.timeout(timeoutMs), - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, + const response = await Promise.race([ + (options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, { + signal: controller.signal, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }), + timeoutPromise, + ]); + const text = await readBoundedResponseText(response, `GitHub API ${path}`, maxBodyBytes, { + signal: controller.signal, + timeoutPromise, }); + if (!response.ok) { + throw new Error(`GitHub API ${path} failed with ${response.status}: ${text}`); + } + return JSON.parse(text); } catch (error) { if (githubApiTimedOut(error)) { throw new Error(`GitHub API ${path} timed out after ${timeoutMs}ms`, { cause: error }); } throw error; + } finally { + clearTimeout(timeout); } - if (!response.ok) { - throw new Error(`GitHub API ${path} failed with ${response.status}: ${await response.text()}`); - } - return response.json(); } /** @@ -301,32 +462,336 @@ export async function validateWindowsSourceRelease(tag, options = {}) { }; } -function currentBranch() { - return run("git", ["branch", "--show-current"], { capture: true }).trim(); +function gitRevParse(ref, cwd) { + return run("git", ["rev-parse", ref], { capture: true, cwd }).trim(); } -function gitRevParse(ref) { - return run("git", ["rev-parse", ref], { capture: true }).trim(); +function gitTopLevel(cwd) { + return run("git", ["rev-parse", "--show-toplevel"], { capture: true, cwd }).trim(); } -async function workflowRuns(repo, workflowFile) { - const data = await githubApi( - `repos/${repo}/actions/workflows/${workflowFile}/runs?event=workflow_dispatch&per_page=100`, +function gitTrackedStatus(cwd) { + return run("git", ["status", "--porcelain=v1", "--untracked-files=no"], { + capture: true, + cwd, + }); +} + +function fetchTrustedWorkflowSha(workflowRef, toolingRoot) { + const remoteRef = `refs/remotes/origin/${workflowRef}`; + run("git", ["fetch", "--no-tags", "origin", `+refs/heads/${workflowRef}:${remoteRef}`], { + cwd: toolingRoot, + }); + return gitRevParse(`${remoteRef}^{commit}`, toolingRoot); +} + +function runFromTrustedTooling(argv, { targetRoot, workflowRef }) { + const trustedToolingSha = fetchTrustedWorkflowSha(workflowRef, targetRoot); + const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-release-tooling-")); + const toolingRoot = join(tempRoot, "checkout"); + let worktreeAdded; + try { + run("git", ["worktree", "add", "--detach", toolingRoot, trustedToolingSha], { + cwd: targetRoot, + }); + worktreeAdded = true; + const result = spawnSync( + process.execPath, + [join(toolingRoot, "scripts/release-candidate-checklist.mjs"), ...argv], + { + cwd: targetRoot, + env: process.env, + stdio: "inherit", + }, + ); + if (result.status !== 0) { + throw new Error( + `trusted release candidate tooling failed with ${result.status ?? result.signal}`, + ); + } + } finally { + if (worktreeAdded) { + const cleanup = spawnSync("git", ["worktree", "remove", "--force", toolingRoot], { + cwd: targetRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (cleanup.status !== 0) { + console.warn( + `could not remove temporary trusted tooling worktree: ${cleanup.stderr?.trim() || cleanup.signal || cleanup.status}`, + ); + } + } + rmSync(tempRoot, { force: true, recursive: true }); + } +} + +export function validateCandidateCheckout({ + targetSha, + targetHeadSha, + targetTrackedStatus, + toolingSha, + trustedToolingSha, + toolingTrackedStatus, + workflowRef, +}) { + if (targetHeadSha !== targetSha) { + throw new Error( + `release candidate tag resolves to ${targetSha}, but target worktree HEAD is ${targetHeadSha}`, + ); + } + if (targetTrackedStatus.trim()) { + throw new Error( + "release candidate validation requires a clean tracked target worktree at the release tag", + ); + } + if (toolingSha !== trustedToolingSha) { + throw new Error( + `release candidate tooling HEAD ${toolingSha} does not match trusted ${workflowRef} ${trustedToolingSha}`, + ); + } + if (toolingTrackedStatus.trim()) { + throw new Error( + "release candidate validation requires a clean tracked tooling checkout at the trusted workflow ref", + ); + } + return { status: "passed", targetSha, toolingSha, workflowRef }; +} + +function gitIsAncestor(ancestor, target) { + const result = spawnSync( + "git", + ["merge-base", "--is-ancestor", `${ancestor}^{commit}`, `${target}^{commit}`], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.status === 0) { + return true; + } + if (result.status === 1) { + return false; + } + throw new Error( + `could not validate changelog provenance ${ancestor}..${target}: ${ + result.stderr?.trim() || result.signal || result.status + }`, ); - return (data.workflow_runs ?? []).map((runEntry) => ({ - databaseId: runEntry.id, - workflowName: runEntry.name, - event: runEntry.event, - createdAt: runEntry.created_at, - })); } -async function runArtifacts(repo, runId) { - const data = await githubApi(`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`); - return (data.artifacts ?? []).map((artifact) => ({ - name: artifact.name, - expired: artifact.expired, - })); +function candidateContributionRecordPullRequests( + section, + label, + { requireExactProvenance = true } = {}, +) { + const recordStart = section.search(/\n### Complete contribution record\r?$/m); + if (recordStart < 0) { + throw new Error(`${label} is missing ### Complete contribution record`); + } + const record = section.slice(recordStart); + const rowNumbers = [...record.matchAll(/^- \*\*PR #(?[0-9]+)\*\*/gmu)].map((match) => + Number(match.groups.number), + ); + const rows = new Set(rowNumbers); + if (rows.size !== rowNumbers.length) { + const seen = new Set(); + const duplicates = rowNumbers.filter((number) => { + if (seen.has(number)) { + return true; + } + seen.add(number); + return false; + }); + throw new Error( + `${label} contains duplicate contribution record PR rows: ${[...new Set(duplicates)] + .map((number) => `#${number}`) + .join(", ")}`, + ); + } + if (!requireExactProvenance) { + return rows; + } + const provenance = record.match( + /^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?[0-9]+) merged PRs?\./mu, + ); + if (!provenance?.groups?.count) { + throw new Error(`${label} is missing exact complete contribution record provenance`); + } + const declaredCount = Number(provenance.groups.count); + if (rows.size !== declaredCount) { + throw new Error( + `${label} contribution record declares ${declaredCount} PRs but contains ${rows.size}`, + ); + } + return rows; +} + +export function candidateCumulativeShippedPullRequests(changelog, label) { + const pullRequests = new Set(); + for (const section of extractChangelogReleaseSections(changelog)) { + if ( + section.version === "Unreleased" || + !section.source.includes("\n### Complete contribution record") + ) { + continue; + } + for (const number of candidateContributionRecordPullRequests( + section.source, + `${label} section ${section.version}`, + { requireExactProvenance: false }, + )) { + pullRequests.add(number); + } + } + return pullRequests; +} + +function loadCandidateShippedBaseline(ref) { + const tagRef = `refs/tags/${ref}`; + gitRevParse(`${tagRef}^{commit}`); + const changelog = run("git", ["show", `${tagRef}:CHANGELOG.md`], { capture: true }); + const version = releaseNotesVersionForTag(ref); + candidateContributionRecordPullRequests( + extractChangelogSection(changelog, version), + `shipped baseline ${ref}`, + ); + const pullRequests = candidateCumulativeShippedPullRequests(changelog, `shipped baseline ${ref}`); + return { ref, pullRequests }; +} + +export function validateCandidateReleaseNotes({ changelog, repository, tag }) { + const rendered = renderGithubReleaseNotes({ + changelog, + version: releaseNotesVersionForTag(tag), + tag, + repository, + }); + return { + status: "passed", + mode: rendered.mode, + characters: rendered.size.characters, + bytes: rendered.size.bytes, + }; +} + +export function validateCandidateChangelogProvenance({ + changelog, + version, + tag, + targetSha, + isAncestor = gitIsAncestor, + loadShippedBaseline = loadCandidateShippedBaseline, +}) { + // Validate the same section the renderer publishes: alpha and correction + // tags may carry their own heading, and alpha tags may fall back to + // Unreleased. + let section; + let sectionVersion = version; + let usesAlphaUnreleasedFallback = false; + const dedicatedVersion = dedicatedSectionVersionForTag(tag); + if (dedicatedVersion && dedicatedVersion !== version) { + try { + section = extractChangelogSection(changelog, dedicatedVersion); + sectionVersion = dedicatedVersion; + } catch { + // No dedicated section; validate the base section. + } + } + if (section === undefined) { + try { + section = extractChangelogSection(changelog, version); + } catch (error) { + if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) { + throw error; + } + section = releaseNotesSectionForTag(changelog, version, tag); + usesAlphaUnreleasedFallback = true; + } + } + const recordStart = section.search(/\n### Complete contribution record\r?$/m); + if (recordStart < 0) { + if (usesAlphaUnreleasedFallback) { + return { + status: "skipped", + reason: "alpha release uses the explicit Unreleased fallback", + shippedBaselines: [], + }; + } + throw new Error( + `CHANGELOG.md ## ${sectionVersion} is missing ### Complete contribution record`, + ); + } + const record = section.slice(recordStart); + const recordedPullRequests = candidateContributionRecordPullRequests( + section, + `CHANGELOG.md ## ${sectionVersion}`, + ); + const provenance = record.match( + /^This audited record covers the complete (?\S+)\.\.(?[0-9a-f]{40}) history:/mu, + ); + const base = provenance?.groups?.base; + const recordedTarget = provenance?.groups?.target; + if (!base || !recordedTarget) { + throw new Error( + `CHANGELOG.md ## ${sectionVersion} is missing exact complete contribution record provenance`, + ); + } + const shippedBaselines = parseShippedBaselineExclusions(record); + const sectionShippedBaselines = parseShippedBaselineExclusions(section); + if ( + formatShippedBaselineExclusions(sectionShippedBaselines) !== + formatShippedBaselineExclusions(shippedBaselines) + ) { + throw new Error( + "shipped baseline exclusions must appear inside the complete contribution record", + ); + } + if (!isAncestor(base, recordedTarget)) { + throw new Error( + `CHANGELOG.md contribution record base ${base} is not an ancestor of recorded target ${recordedTarget}`, + ); + } + // The record is generated before its own changelog/finalization commit. Require + // reachability so the tag can contain that bounded release-only follow-up. + if (!isAncestor(recordedTarget, targetSha)) { + throw new Error( + `CHANGELOG.md contribution record target ${recordedTarget} is not reachable from release tag ${targetSha}`, + ); + } + // The verifier persists associated and text-linked PR exclusions together. + // Revalidate that exact inventory here instead of rediscovering a narrower set from git text. + const excludedPullRequests = new Set(); + for (const baseline of shippedBaselines) { + const loaded = loadShippedBaseline(baseline.ref); + if (!(loaded.pullRequests instanceof Set)) { + throw new Error(`shipped baseline ${baseline.ref} did not provide a PR inventory`); + } + const duplicateExclusions = baseline.pullRequests.filter((number) => + excludedPullRequests.has(number), + ); + if (duplicateExclusions.length > 0) { + throw new Error( + `release contribution record repeats shipped PR exclusions across baselines: ${duplicateExclusions.map((number) => `#${number}`).join(", ")}`, + ); + } + const absent = baseline.pullRequests.filter((number) => !loaded.pullRequests.has(number)); + if (absent.length > 0) { + throw new Error( + `release contribution record lists PRs absent from shipped baseline ${baseline.ref}: ${absent.map((number) => `#${number}`).join(", ")}`, + ); + } + const retained = [...recordedPullRequests].filter((number) => loaded.pullRequests.has(number)); + if (retained.length > 0) { + throw new Error( + `release contribution record still contains shipped PRs from ${baseline.ref}: ${retained.map((number) => `#${number}`).join(", ")}`, + ); + } + for (const number of baseline.pullRequests) { + excludedPullRequests.add(number); + } + } + return { status: "passed", base, target: recordedTarget, shippedBaselines }; } /** @@ -351,16 +816,6 @@ export function resolveArtifactName(artifacts, preferredName, prefix) { ); } -async function resolveRunArtifactName(repo, runId, preferredName, prefix) { - return resolveArtifactName(await runArtifacts(repo, runId), preferredName, prefix); -} - -async function beforeRunIds(repo, workflowFile) { - return new Set( - (await workflowRuns(repo, workflowFile)).map((runResult) => String(runResult.databaseId)), - ); -} - function runAndEcho(command, args) { const result = spawnSync(command, args, { encoding: "utf8", @@ -397,36 +852,72 @@ export function parseRunIdFromDispatchOutput(output) { return output.match(/actions\/runs\/([0-9]+)/u)?.[1] ?? ""; } +export function selectDispatchedWorkflowRunId(runs, expected) { + const expectedPath = `.github/workflows/${expected.workflowFile}`; + const matches = runs.filter( + (workflowRun) => + workflowRun.display_title === expected.correlationId && + workflowRun.event === "workflow_dispatch" && + workflowRun.head_branch === expected.workflowRef && + workflowRun.head_sha === expected.headSha && + String(workflowRun.path ?? "").split("@", 1)[0] === expectedPath && + /^[1-9][0-9]*$/u.test(String(workflowRun.id ?? "")), + ); + if (matches.length > 1) { + throw new Error( + `workflow dispatch correlation ${expected.correlationId} matched multiple runs`, + ); + } + return matches.length === 1 ? String(matches[0].id) : ""; +} + async function wait(ms) { await new Promise((resolve) => { setTimeout(resolve, ms); }); } -async function findNewRunId(repo, workflowFile, workflowName, beforeIds) { - for (let attempt = 0; attempt < 60; attempt += 1) { - const match = (await workflowRuns(repo, workflowFile)) - .filter( - (runValue) => - runValue.workflowName === workflowName && - runValue.event === "workflow_dispatch" && - !beforeIds.has(String(runValue.databaseId)), - ) - .toSorted((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))[0]; - if (match?.databaseId) { - return String(match.databaseId); - } - await wait(5_000); - } - throw new Error(`could not find dispatched ${workflowName} run`); -} - -function dispatchWorkflow(repo, workflowFile, workflowRef, fields) { +async function dispatchWorkflow(repo, workflowFile, workflowRef, headSha, fields) { + const correlationId = randomUUID(); const args = ["workflow", "run", workflowFile, "--repo", repo, "--ref", workflowRef]; - for (const [key, value] of Object.entries(fields)) { + for (const [key, value] of Object.entries({ + ...fields, + dispatch_correlation_id: correlationId, + })) { args.push("-f", `${key}=${String(value)}`); } - return parseRunIdFromDispatchOutput(runAndEcho("gh", args)); + const output = runAndEcho("gh", args); + const directRunId = parseRunIdFromDispatchOutput(output); + if (directRunId) { + return directRunId; + } + + const encodedWorkflow = encodeURIComponent(workflowFile); + const query = new URLSearchParams({ + branch: workflowRef, + event: "workflow_dispatch", + per_page: "100", + }); + for (let attempt = 1; attempt <= 20; attempt += 1) { + const response = await githubApi( + `repos/${repo}/actions/workflows/${encodedWorkflow}/runs?${query.toString()}`, + ); + const runId = selectDispatchedWorkflowRunId(response.workflow_runs ?? [], { + correlationId, + headSha, + workflowFile, + workflowRef, + }); + if (runId) { + return runId; + } + if (attempt < 20) { + await wait(3_000); + } + } + throw new Error( + `workflow ${workflowFile} was dispatched with correlation ${correlationId}, but its run ID did not become visible; recover that exact run before retrying`, + ); } async function runInfo(repo, runId) { @@ -436,7 +927,10 @@ async function runInfo(repo, runId) { ]); return { databaseId: runData.id, + runAttempt: runData.run_attempt, workflowName: runData.name, + workflowPath: runData.path, + repository: runData.repository?.full_name, headBranch: runData.head_branch, headSha: runData.head_sha, event: runData.event, @@ -513,7 +1007,9 @@ async function waitForSuccessfulRun(repo, runId, expected) { `run ${runId} workflow mismatch: expected ${expected.workflowName}, got ${info.workflowName}`, ); } - if (info.headBranch !== expected.workflowRef) { + const acceptsPinnedWorkflow = + expected.allowShaPinnedWorkflowRef && isShaPinnedReleaseValidationBranch(info.headBranch); + if (info.headBranch !== expected.workflowRef && !acceptsPinnedWorkflow) { throw new Error( `run ${runId} branch mismatch: expected ${expected.workflowRef}, got ${info.headBranch}`, ); @@ -530,12 +1026,6 @@ function downloadArtifact(repo, runId, name, dir) { run("gh", ["run", "download", runId, "--repo", repo, "--name", name, "--dir", dir]); } -async function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) { - const name = await resolveRunArtifactName(repo, runId, preferredName, prefix); - downloadArtifact(repo, runId, name, dir); - return name; -} - function sha256(path) { return run("shasum", ["-a", "256", path], { capture: true }).trim().split(/\s+/u)[0] ?? ""; } @@ -583,10 +1073,31 @@ function shellQuote(value) { * Builds the final release publish workflow command once validation evidence is ready. */ export function buildPublishCommand(options) { + for (const [label, value] of [ + ["npm preflight run ID", options.npmPreflightRunId], + ["npm preflight run attempt", options.npmPreflightRunAttempt], + ["Full Release Validation run ID", options.fullReleaseRunId], + ["Full Release Validation run attempt", options.fullReleaseRunAttempt], + ]) { + if (!/^[1-9][0-9]*$/u.test(String(value ?? ""))) { + throw new Error(`${label} must be a positive integer before building the publish command`); + } + } + const alphaRelease = options.tag.includes("-alpha."); + const workflowRef = alphaRelease + ? options.workflowRef + : `release-publish/${options.trustedToolingSha}`; + if (alphaRelease && !TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(workflowRef)) { + throw new Error( + "alpha release publish requires a matching tideclaw/alpha/YYYY-MM-DD-HHMMZ workflow ref", + ); + } const fields = [ ["tag", options.tag], ["preflight_run_id", options.npmPreflightRunId], + ["preflight_run_attempt", options.npmPreflightRunAttempt], ["full_release_validation_run_id", options.fullReleaseRunId], + ["full_release_validation_run_attempt", options.fullReleaseRunAttempt], ["npm_dist_tag", options.npmDistTag], ["plugin_publish_scope", options.pluginPublishScope], ["publish_openclaw_npm", "true"], @@ -605,7 +1116,10 @@ export function buildPublishCommand(options) { if (options.plugins.trim()) { fields.push(["plugins", options.plugins]); } - return [ + if (!alphaRelease && !/^[a-f0-9]{40}$/u.test(options.trustedToolingSha ?? "")) { + throw new Error("regular release publish requires an exact trusted tooling SHA"); + } + const workflowCommand = [ "gh", "workflow", "run", @@ -613,14 +1127,26 @@ export function buildPublishCommand(options) { "--repo", options.repo, "--ref", - options.workflowRef, + workflowRef, ...fields.flatMap(([key, value]) => ["-f", `${key}=${value}`]), ] .map(shellQuote) .join(" "); + if (alphaRelease) { + return workflowCommand; + } + const pushCommand = [ + "git", + "push", + "origin", + `${options.trustedToolingSha}:refs/heads/${workflowRef}`, + ] + .map(shellQuote) + .join(" "); + return `${pushCommand}\n${workflowCommand}`; } -function validatePreflightManifest(manifest, params) { +export function validatePreflightManifest(manifest, params) { if (manifest.releaseTag !== params.tag) { throw new Error( `npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`, @@ -639,9 +1165,23 @@ function validatePreflightManifest(manifest, params) { if (!manifest.tarballName || !manifest.tarballSha256) { throw new Error("npm preflight manifest missing tarball metadata"); } + if (!Array.isArray(manifest.dependencyTarballs)) { + throw new Error("npm preflight manifest missing dependency tarball metadata"); + } + for (const dependency of manifest.dependencyTarballs) { + if ( + !dependency?.packageName || + !dependency.packageVersion || + !dependency.tarballName || + !dependency.tarballSha256 || + dependency.tarballName !== basename(dependency.tarballName) + ) { + throw new Error("npm preflight manifest contains invalid dependency tarball metadata"); + } + } } -function validateFullManifest(manifest, params) { +export function validateFullManifest(manifest, params) { if (manifest.workflowName !== "Full Release Validation") { throw new Error(`full validation workflow mismatch: ${manifest.workflowName}`); } @@ -658,13 +1198,35 @@ function validateFullManifest(manifest, params) { if (manifest.rerunGroup !== "all") { throw new Error(`full validation must use rerun_group=all, got ${manifest.rerunGroup}`); } + if ( + (params.releaseProfile === "stable" || params.releaseProfile === "full") && + manifest.runReleaseSoak !== "true" + ) { + throw new Error( + `full validation must record runReleaseSoak=true for ${params.releaseProfile} release candidates`, + ); + } + if (manifest.controls?.performanceBlocking !== true) { + throw new Error("full validation manifest must record blocking product performance evidence"); + } } -export function candidateParallelsArgs(tarballPath) { - return ["test:parallels:npm-update", "--", "--target-tarball", tarballPath, "--json"]; +export function candidateParallelsArgs(tarballPath, dependencyTarballPaths = []) { + return [ + "test:parallels:npm-update", + "--", + "--target-tarball", + tarballPath, + ...dependencyTarballPaths.flatMap((dependency) => ["--dependency-tarball", dependency]), + "--json", + ]; } -export function candidateParallelsShellCommand(tarballPath, timeoutBin) { +export function candidateParallelsShellCommand( + tarballPath, + timeoutBin, + dependencyTarballPaths = [], +) { return [ 'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;', "exec", @@ -672,18 +1234,18 @@ export function candidateParallelsShellCommand(tarballPath, timeoutBin) { "--foreground", "150m", "pnpm", - ...candidateParallelsArgs(tarballPath).map(shellQuote), + ...candidateParallelsArgs(tarballPath, dependencyTarballPaths).map(shellQuote), ].join(" "); } -async function runParallelsIfNeeded(options, tarballPath) { +async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths) { if (options.skipParallels) { return { status: "skipped", reason: "operator skipped --skip-parallels" }; } const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], { capture: true, }).trim(); - const command = candidateParallelsShellCommand(tarballPath, timeoutBin); + const command = candidateParallelsShellCommand(tarballPath, timeoutBin, dependencyTarballPaths); run("bash", ["-lc", command]); return { status: "passed", @@ -691,23 +1253,25 @@ async function runParallelsIfNeeded(options, tarballPath) { }; } -async function runTelegramIfNeeded(options, artifactName) { +async function runTelegramIfNeeded(options, artifactName, trustedToolingSha) { if (options.skipTelegram) { return { status: "skipped" }; } const workflowFile = "npm-telegram-beta-e2e.yml"; - const before = await beforeRunIds(options.repo, workflowFile); - const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, { - package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`, - package_label: options.tag, - package_artifact_name: artifactName, - package_artifact_run_id: options.npmPreflightRunId, - harness_ref: options.workflowRef, - provider_mode: options.telegramProviderMode, - }); - const runId = - dispatchedRunId || - (await findNewRunId(options.repo, workflowFile, "NPM Telegram Beta E2E", before)); + const runId = await dispatchWorkflow( + options.repo, + workflowFile, + options.workflowRef, + trustedToolingSha, + { + package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`, + package_label: options.tag, + package_artifact_name: artifactName, + package_artifact_run_id: options.npmPreflightRunId, + harness_ref: options.workflowRef, + provider_mode: options.telegramProviderMode, + }, + ); const runLocal = await waitForSuccessfulRun(options.repo, runId, { workflowName: "NPM Telegram Beta E2E", workflowRef: options.workflowRef, @@ -721,11 +1285,63 @@ async function runTelegramIfNeeded(options, artifactName) { }; } +export function validateTrustedWorkflowRunHead(workflowRun, params) { + const shaPinnedRun = + params.allowShaPinnedWorkflowRef && isShaPinnedReleaseValidationBranch(workflowRun.headBranch); + const expectedSha = shaPinnedRun ? params.targetSha : params.trustedToolingSha; + if (workflowRun.headSha !== expectedSha) { + throw new Error( + `${params.label} workflow SHA mismatch: expected=${expectedSha} run=${workflowRun.headSha}`, + ); + } +} + async function main() { const options = parseArgs(process.argv.slice(2)); - options.workflowRef ||= currentBranch(); + const targetRoot = gitTopLevel(process.cwd()); + const toolingRoot = gitTopLevel(TOOLING_ROOT); + if (targetRoot === toolingRoot) { + runFromTrustedTooling(process.argv.slice(2), { + targetRoot, + workflowRef: options.workflowRef, + }); + return; + } options.outputDir ||= join(".artifacts", "release-candidate", options.tag); - const targetSha = gitRevParse(`${options.tag}^{}`); + const targetSha = gitRevParse(`${options.tag}^{}`, targetRoot); + const toolingSha = gitRevParse("HEAD", TOOLING_ROOT); + const trustedToolingSha = fetchTrustedWorkflowSha(options.workflowRef, TOOLING_ROOT); + validateCandidateCheckout({ + targetSha, + targetHeadSha: gitRevParse("HEAD", targetRoot), + targetTrackedStatus: gitTrackedStatus(targetRoot), + toolingSha, + trustedToolingSha, + toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT), + workflowRef: options.workflowRef, + }); + const statePath = join(options.outputDir, RELEASE_CANDIDATE_STATE_FILE); + const expectedState = buildReleaseCandidateState(options, { targetSha, toolingSha }); + let candidateState = reconcileReleaseCandidateState( + existsSync(statePath) ? readJson(statePath, "release candidate state") : undefined, + expectedState, + ); + options.fullReleaseRunId = candidateState.fullReleaseRunId; + options.npmPreflightRunId = candidateState.npmPreflightRunId; + writeReleaseCandidateState(statePath, candidateState); + const releaseChangelog = run("git", ["show", `${targetSha}:CHANGELOG.md`], { capture: true }); + const releaseNotesVersion = releaseNotesVersionForTag(options.tag); + const releaseNotesCheck = validateCandidateReleaseNotes({ + changelog: releaseChangelog, + repository: options.repo, + tag: options.tag, + }); + const releaseNotesProvenance = validateCandidateChangelogProvenance({ + changelog: releaseChangelog, + version: releaseNotesVersion, + tag: options.tag, + targetSha, + }); const windowsNodeSourceRelease = options.windowsNodeTag ? await validateWindowsSourceRelease(options.windowsNodeTag) : undefined; @@ -740,69 +1356,103 @@ async function main() { if (!options.fullReleaseRunId && !options.skipDispatch) { const workflowFile = "full-release-validation.yml"; - const before = await beforeRunIds(options.repo, workflowFile); - const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, { - ref: options.tag, - provider: options.provider, - mode: options.mode, - release_profile: options.releaseProfile, - run_release_soak: options.releaseProfile === "full" ? "true" : "false", - rerun_group: "all", + options.fullReleaseRunId = await dispatchWorkflow( + options.repo, + workflowFile, + options.workflowRef, + trustedToolingSha, + { + ref: options.tag, + provider: options.provider, + mode: options.mode, + release_profile: options.releaseProfile, + run_release_soak: + options.releaseProfile === "stable" || options.releaseProfile === "full" + ? "true" + : "false", + rerun_group: "all", + }, + ); + candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", { + fullReleaseRunId: options.fullReleaseRunId, }); - options.fullReleaseRunId = - dispatchedRunId || - (await findNewRunId(options.repo, workflowFile, "Full Release Validation", before)); } if (!options.npmPreflightRunId && !options.skipDispatch) { const workflowFile = "openclaw-npm-release.yml"; - const before = await beforeRunIds(options.repo, workflowFile); - const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, { - tag: options.tag, - preflight_only: "true", - npm_dist_tag: options.npmDistTag, + options.npmPreflightRunId = await dispatchWorkflow( + options.repo, + workflowFile, + options.workflowRef, + trustedToolingSha, + { + tag: options.tag, + preflight_only: "true", + npm_dist_tag: options.npmDistTag, + }, + ); + candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", { + npmPreflightRunId: options.npmPreflightRunId, }); - options.npmPreflightRunId = - dispatchedRunId || - (await findNewRunId(options.repo, workflowFile, "OpenClaw NPM Release", before)); } + candidateState = updateReleaseCandidateState(statePath, candidateState, "waiting", { + fullReleaseRunId: options.fullReleaseRunId, + npmPreflightRunId: options.npmPreflightRunId, + }); const fullRun = await waitForSuccessfulRun(options.repo, options.fullReleaseRunId, { workflowName: "Full Release Validation", workflowRef: options.workflowRef, + allowShaPinnedWorkflowRef: true, }); const npmRun = await waitForSuccessfulRun(options.repo, options.npmPreflightRunId, { workflowName: "OpenClaw NPM Release", workflowRef: options.workflowRef, }); - if (fullRun.headSha !== targetSha || npmRun.headSha !== targetSha) { - throw new Error( - `run SHA mismatch: tag=${targetSha} full=${fullRun.headSha} npm=${npmRun.headSha}`, - ); - } + validateTrustedWorkflowRunHead(npmRun, { + label: "npm preflight", + trustedToolingSha, + }); + validateTrustedWorkflowRunHead(fullRun, { + allowShaPinnedWorkflowRef: true, + label: "Full Release Validation", + targetSha, + trustedToolingSha, + }); const npmDir = join(options.outputDir, "npm-preflight"); const fullDir = join(options.outputDir, "full-release-validation"); - const npmArtifactName = await downloadResolvedArtifact( - options.repo, - options.npmPreflightRunId, - `openclaw-npm-preflight-${options.tag}`, - "openclaw-npm-preflight-", - npmDir, - ); - const fullArtifactName = await downloadResolvedArtifact( - options.repo, - options.fullReleaseRunId, - `full-release-validation-${options.fullReleaseRunId}`, - "full-release-validation-", - fullDir, - ); + if (!Number.isInteger(npmRun.runAttempt) || npmRun.runAttempt < 1) { + throw new Error(`OpenClaw NPM Release run ${options.npmPreflightRunId} has invalid attempt.`); + } + const npmArtifactName = `openclaw-npm-preflight-${options.npmPreflightRunId}-${npmRun.runAttempt}`; + downloadArtifact(options.repo, options.npmPreflightRunId, npmArtifactName, npmDir); + if (!Number.isInteger(fullRun.runAttempt) || fullRun.runAttempt < 1) { + throw new Error(`Full Release Validation run ${options.fullReleaseRunId} has invalid attempt.`); + } + const fullArtifactName = `full-release-validation-${options.fullReleaseRunId}-${fullRun.runAttempt}`; + downloadArtifact(options.repo, options.fullReleaseRunId, fullArtifactName, fullDir); const npmManifest = readJson(join(npmDir, "preflight-manifest.json"), "npm preflight manifest"); const fullManifest = readJson( join(fullDir, "full-release-validation-manifest.json"), "full validation manifest", ); + run("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"], { + capture: true, + }); + validateFullReleaseValidationEvidence({ + run: fullRun, + manifest: fullManifest, + expectedRepository: options.repo, + expectedRunId: options.fullReleaseRunId, + expectedTargetSha: targetSha, + expectedWorkflowBranch: options.workflowRef, + expectedWorkflowSha: trustedToolingSha, + isTrustedMainAncestor: (sha) => gitIsAncestor(sha, "refs/remotes/origin/main"), + validateEvidenceReuseStrictly: ({ repository, runId }) => + runStrictReleaseEvidenceValidation({ repository, runId }), + }); validatePreflightManifest(npmManifest, { tag: options.tag, targetSha, @@ -822,9 +1472,22 @@ async function main() { `prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`, ); } + const dependencyTarballPaths = npmManifest.dependencyTarballs.map((dependency) => { + const dependencyPath = join(npmDir, dependency.tarballName); + if (!existsSync(dependencyPath)) { + throw new Error(`prepared dependency tarball missing: ${dependencyPath}`); + } + const actualDependencySha = sha256(dependencyPath); + if (actualDependencySha !== dependency.tarballSha256) { + throw new Error( + `prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`, + ); + } + return dependencyPath; + }); - const parallels = await runParallelsIfNeeded(options, tarballPath); - const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName); + const parallels = await runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths); + const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName, trustedToolingSha); options.npmTelegramRunId = npmTelegram.runId ?? ""; const pluginNpmPlan = await collectPluginPlanWithRetry( "scripts/plugin-npm-release-plan.ts", @@ -834,7 +1497,12 @@ async function main() { "scripts/plugin-clawhub-release-plan.ts", options, ); - const publishCommand = buildPublishCommand(options); + const publishCommand = buildPublishCommand({ + ...options, + fullReleaseRunAttempt: fullRun.runAttempt, + npmPreflightRunAttempt: npmRun.runAttempt, + trustedToolingSha, + }); const evidence = { version: 1, tag: options.tag, @@ -842,15 +1510,20 @@ async function main() { workflowRef: options.workflowRef, npmDistTag: options.npmDistTag, fullReleaseValidationRunId: options.fullReleaseRunId, + fullReleaseValidationRunAttempt: fullRun.runAttempt, npmPreflightRunId: options.npmPreflightRunId, + npmPreflightRunAttempt: npmRun.runAttempt, windowsNodeTag: options.windowsNodeTag || undefined, windowsNodeSourceRelease, fullReleaseValidationUrl: fullRun.url, + fullReleaseValidationControls: fullManifest.controls, npmPreflightUrl: npmRun.url, artifacts: { npmPreflight: npmArtifactName, fullReleaseValidation: fullArtifactName, }, + releaseNotesCheck, + releaseNotesProvenance, localGeneratedCheck, tarball: { name: basename(tarballPath), @@ -885,6 +1558,14 @@ async function main() { : []), `- npm preflight artifact: ${npmArtifactName}`, `- full release artifact: ${fullArtifactName}`, + `- GitHub release notes: ${releaseNotesCheck.status} (${releaseNotesCheck.mode}, ${releaseNotesCheck.characters} characters, ${releaseNotesCheck.bytes} bytes)`, + releaseNotesProvenance.status === "passed" + ? `- changelog provenance: passed (${releaseNotesProvenance.base}..${releaseNotesProvenance.target})` + : `- changelog provenance: skipped (${releaseNotesProvenance.reason})`, + `- ${ + formatShippedBaselineExclusions(releaseNotesProvenance.shippedBaselines) || + "Shipped baseline exclusions: none" + }`, `- local generated release checks: ${localGeneratedCheck.status}${ localGeneratedCheck.reason ? ` (${localGeneratedCheck.reason})` : "" }`, @@ -906,6 +1587,7 @@ async function main() { "", ].join("\n"), ); + updateReleaseCandidateState(statePath, candidateState, "completed"); console.log(`release candidate evidence: ${evidencePath}`); console.log(`release candidate summary: ${evidenceMarkdownPath}`); diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs new file mode 100755 index 000000000000..df51a3aa825b --- /dev/null +++ b/scripts/release-ci-summary.mjs @@ -0,0 +1,1805 @@ +#!/usr/bin/env node +/** + * Release CI summary helper that prints parent and child workflow status for a + * full release run. + */ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { plainGhEnv, resolvePlainGhBin } from "./lib/plain-gh.mjs"; + +const DEFAULT_REPO = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; +const RELEASE_EVIDENCE_SCHEMA = "openclaw.release-validation-evidence/v3"; +const SHA_PINNED_BRANCH_PATTERN = /^release-ci\/[a-f0-9]{12}-[1-9][0-9]*$/u; +const RELEASE_EVIDENCE_SCRIPT = "scripts/release-ci-summary.mjs"; +const RELEASE_EVIDENCE_FILE = fileURLToPath(import.meta.url); +const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), ".."); +const MANIFEST_ARTIFACT_ENTRY = "full-release-validation-manifest.json"; +const MAX_MANIFEST_ARTIFACT_ZIP_BYTES = 256 * 1024; +const MAX_MANIFEST_JSON_BYTES = 128 * 1024; +const MAX_MANIFEST_ENTRY_LIST_BYTES = 8 * 1024; + +const CHILD_DISPATCHES = [ + { + manifestKey: "normalCi", + name: "CI", + parentJobName: "Run normal full CI", + suffix: "-ci", + trustedRef: "parent", + workflow: "ci.yml", + }, + { + manifestKey: "releaseChecks", + name: "OpenClaw Release Checks", + parentJobName: "Run release/live/Docker/QA validation", + suffix: "-release-checks", + trustedRef: "parent", + workflow: "openclaw-release-checks.yml", + }, + { + manifestKey: "pluginPrerelease", + name: "Plugin Prerelease", + parentJobName: "Run plugin prerelease validation", + suffix: "-plugin-prerelease", + trustedRef: "parent", + workflow: "plugin-prerelease.yml", + }, + { + manifestKey: "npmTelegram", + name: "NPM Telegram Beta E2E", + parentJobName: "Run package Telegram E2E", + suffix: "-npm-telegram", + trustedRef: "parent", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + manifestKey: "productPerformance", + name: "OpenClaw Performance", + parentJobName: "Run product performance evidence", + suffix: "", + trustedRef: "parent", + workflow: "openclaw-performance.yml", + }, +]; + +const EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1"; + +const RERUN_GROUP_CHILD_KEYS = new Map([ + ["all", ["normalCi", "releaseChecks", "pluginPrerelease", "productPerformance"]], + ["ci", ["normalCi"]], + ["plugin-prerelease", ["pluginPrerelease"]], + ["release-checks", ["releaseChecks"]], + ["install-smoke", ["releaseChecks"]], + ["cross-os", ["releaseChecks"]], + ["live-e2e", ["releaseChecks"]], + ["package", ["releaseChecks"]], + ["qa", ["releaseChecks"]], + ["qa-parity", ["releaseChecks"]], + ["qa-live", ["releaseChecks"]], + ["npm-telegram", ["npmTelegram"]], + ["performance", ["productPerformance"]], +]); + +function gh(args) { + return execFileSync(resolvePlainGhBin(), args, { + encoding: "utf8", + env: plainGhEnv(), + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function jsonGh(args) { + return JSON.parse(gh(args)); +} + +function githubRestJson(pathSuffix, repository = DEFAULT_REPO) { + const result = execFileSync( + "bash", + [ + "-lc", + [ + "set -euo pipefail", + 'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"', + 'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"', + ].join("\n"), + ], + { + encoding: "utf8", + env: { + ...plainGhEnv(), + OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(), + OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repository}/${pathSuffix}`, + }, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return JSON.parse(result); +} + +function downloadArtifactZip(artifactId, destination, repository = DEFAULT_REPO) { + execFileSync( + "bash", + [ + "-lc", + [ + "set -euo pipefail", + 'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"', + 'curl -fsSL -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" --output "$OPENCLAW_GITHUB_ARTIFACT_DESTINATION" "$OPENCLAW_GITHUB_ARTIFACT_URL"', + ].join("\n"), + ], + { + env: { + ...plainGhEnv(), + OPENCLAW_GITHUB_ARTIFACT_DESTINATION: destination, + OPENCLAW_GITHUB_ARTIFACT_URL: `https://api.github.com/repos/${repository}/actions/artifacts/${artifactId}/zip`, + OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(), + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); +} + +function rate() { + try { + return jsonGh(["api", "rate_limit"]).resources.core; + } catch { + return undefined; + } +} + +export function validateParentRunBinding(parentView, parentRest, expectedRunId) { + const boundWorkflowPath = String(parentRest.path ?? "").split("@", 1)[0]; + if ( + String(parentRest.id) !== String(expectedRunId) || + parentRest.event !== "workflow_dispatch" || + boundWorkflowPath !== ".github/workflows/full-release-validation.yml" || + Number(parentRest.run_attempt) !== Number(parentView.attempt) || + parentRest.head_branch !== parentView.headBranch || + parentRest.head_sha !== parentView.headSha + ) { + throw new Error(`full release parent run binding mismatch: ${expectedRunId}`); + } + return parentRest; +} + +export function expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef) { + if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) { + throw new Error("parent run ID must be a positive decimal"); + } + if (!Number.isSafeInteger(parentRunAttempt) || parentRunAttempt < 1) { + throw new Error("parent run attempt must be a positive integer"); + } + if (typeof parentWorkflowRef !== "string" || parentWorkflowRef.length === 0) { + throw new Error("parent workflow ref is required"); + } + const dispatchPrefix = `full-release-validation-${parentRunId}-${parentRunAttempt}`; + return CHILD_DISPATCHES.map((child) => ({ + ...child, + displayTitle: `${child.name} ${dispatchPrefix}${child.suffix}`, + headBranch: child.trustedRef === "main" ? "main" : parentWorkflowRef, + })); +} + +export function requiredChildKeysForRerunGroup(rerunGroup) { + const childKeys = RERUN_GROUP_CHILD_KEYS.get(rerunGroup); + if (!childKeys) { + throw new Error(`release validation manifest rerun group is invalid: ${rerunGroup}`); + } + return new Set(childKeys); +} + +export function expectedSelectedChildDispatches( + parentRunId, + parentRunAttempt, + parentWorkflowRef, + selectedKeys, +) { + return expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef).filter((child) => + selectedKeys.has(child.manifestKey), + ); +} + +export function selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch) { + const matches = runs.filter( + (run) => + run.event === "workflow_dispatch" && + run.display_title === expectedDisplayTitle && + run.head_branch === expectedHeadBranch, + ); + if (matches.length > 1) { + throw new Error( + `multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`, + ); + } + return matches[0]; +} + +export function selectExactChildRunFromPages(runPages, expectedDisplayTitle, expectedHeadBranch) { + let exactMatch; + for (const runs of runPages) { + const match = selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch); + if (match) { + if (exactMatch) { + throw new Error( + `multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`, + ); + } + exactMatch = match; + } + if (runs.length < 100) { + break; + } + } + return exactMatch; +} + +function findExactChildRun(child, repository = DEFAULT_REPO) { + const runPages = []; + for (let page = 1; page <= 10; page += 1) { + const query = new URLSearchParams({ + event: "workflow_dispatch", + branch: child.headBranch, + page: String(page), + per_page: "100", + }); + const runs = + githubRestJson(`actions/workflows/${child.workflow}/runs?${query.toString()}`, repository) + .workflow_runs ?? []; + runPages.push(runs); + if (runs.length < 100) { + break; + } + } + return selectExactChildRunFromPages(runPages, child.displayTitle, child.headBranch); +} + +function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { + const jobs = []; + for (let page = 1; page <= 10; page += 1) { + const query = new URLSearchParams({ + filter: "all", + page: String(page), + per_page: "100", + }); + const pageJobs = + githubRestJson(`actions/runs/${parentRunId}/jobs?${query.toString()}`, repository).jobs ?? []; + jobs.push(...pageJobs); + if (pageJobs.length < 100) { + break; + } + } + return jobs; +} + +function parentJobLog(jobId, repository = DEFAULT_REPO) { + return gh(["api", `repos/${repository}/actions/jobs/${jobId}/logs`]); +} + +function normalizeOptionalRunId(value, label) { + if (value === "") { + return ""; + } + if (!/^[1-9][0-9]*$/u.test(String(value))) { + throw new Error(`${label} must be empty or a positive decimal run ID`); + } + return String(value); +} + +function normalizeRequiredRunId(value, label) { + const runId = normalizeOptionalRunId(value, label); + if (!runId) { + throw new Error(`${label} is required`); + } + return runId; +} + +function normalizeRepository(value) { + const repository = String(value ?? ""); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error("repository must use the owner/name form"); + } + return repository; +} + +function normalizeWorkflowRef(value, label) { + const workflowRef = String(value ?? ""); + const hasForbiddenCharacter = Array.from(workflowRef).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return ( + codePoint <= 0x1f || + codePoint === 0x7f || + character.trim() === "" || + "~^:?*[\\".includes(character) + ); + }); + if (workflowRef.length === 0 || workflowRef.length > 255 || hasForbiddenCharacter) { + throw new Error(`${label} is invalid`); + } + return workflowRef; +} + +function normalizeSha(value, label) { + const sha = String(value ?? ""); + if (!/^[a-f0-9]{40}$/u.test(sha)) { + throw new Error(`${label} is invalid`); + } + return sha; +} + +function normalizePositiveInteger(value, label) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number < 1) { + throw new Error(`${label} must be a positive integer`); + } + return number; +} + +function normalizeJsonObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function canonicalJson(value) { + if (Array.isArray(value)) { + return value.map(canonicalJson); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalJson(entry)]), + ); + } + return value; +} + +function manifestEvidenceIdentity(manifest) { + return canonicalJson({ + childRunIds: manifest.childRunIds, + controls: manifest.controls, + releaseProfile: manifest.releaseProfile, + rerunGroup: manifest.rerunGroup, + runReleaseSoak: manifest.runReleaseSoak, + validationInputs: manifest.validationInputs, + }); +} + +export function validateParentManifest(value, expected) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("release validation manifest must be an object"); + } + if (![2, 3].includes(value.version) || value.workflowName !== "Full Release Validation") { + throw new Error("release validation manifest schema is unsupported"); + } + if (String(value.runId) !== String(expected.runId)) { + throw new Error("release validation manifest run ID mismatch"); + } + if ( + !/^[1-9][0-9]*$/u.test(String(value.runAttempt)) || + (expected.runAttempt !== undefined && Number(value.runAttempt) !== Number(expected.runAttempt)) + ) { + throw new Error("release validation manifest run attempt mismatch"); + } + const targetSha = normalizeSha(value.targetSha, "release validation manifest target SHA"); + if (typeof value.workflowRef !== "string" || value.workflowRef.length === 0) { + throw new Error("release validation manifest workflow ref is invalid"); + } + if (expected.workflowRef !== undefined && value.workflowRef !== expected.workflowRef) { + throw new Error("release validation manifest workflow ref mismatch"); + } + let workflowSha; + let workflowFullRef; + let workflowRefType; + if (value.version === 3) { + workflowSha = normalizeSha(value.workflowSha, "release validation manifest workflow SHA"); + if (expected.workflowSha !== undefined && workflowSha !== expected.workflowSha) { + throw new Error("release validation manifest workflow SHA mismatch"); + } + workflowFullRef = String(value.workflowFullRef ?? ""); + workflowRefType = String(value.workflowRefType ?? ""); + if ( + !["branch", "tag"].includes(workflowRefType) || + workflowFullRef !== + `refs/${workflowRefType === "branch" ? "heads" : "tags"}/${value.workflowRef}` + ) { + throw new Error("release validation manifest workflow full ref is invalid"); + } + } else if (expected.workflowSha !== undefined) { + workflowSha = normalizeSha(expected.workflowSha, "release validation workflow SHA"); + } + const rerunGroup = String(value.rerunGroup ?? ""); + requiredChildKeysForRerunGroup(rerunGroup); + const releaseProfile = String(value.releaseProfile ?? ""); + if (!["beta", "stable", "full"].includes(releaseProfile)) { + throw new Error("release validation manifest release profile is invalid"); + } + const runReleaseSoak = String(value.runReleaseSoak ?? ""); + if (!["true", "false"].includes(runReleaseSoak)) { + throw new Error("release validation manifest release soak value is invalid"); + } + const controls = normalizeJsonObject(value.controls, "release validation manifest controls"); + if (value.version === 3 && controls.performanceBlocking !== true) { + throw new Error("release validation manifest performance blocking control is invalid"); + } + if (value.version === 3 && controls.performanceReportPublication !== "artifact-only") { + throw new Error("release validation manifest performance report publication mode is invalid"); + } + const validationInputs = + value.validationInputs === undefined + ? undefined + : normalizeJsonObject( + value.validationInputs, + "release validation manifest validation inputs", + ); + const childRuns = value.childRuns; + if (!childRuns || typeof childRuns !== "object" || Array.isArray(childRuns)) { + throw new Error("release validation manifest childRuns is invalid"); + } + const childRunIds = { + normalCi: normalizeOptionalRunId(childRuns.normalCi, "normal CI run ID"), + npmTelegram: normalizeOptionalRunId(childRuns.npmTelegram, "npm Telegram run ID"), + pluginPrerelease: normalizeOptionalRunId( + childRuns.pluginPrerelease, + "plugin prerelease run ID", + ), + productPerformance: normalizeOptionalRunId( + childRuns.productPerformance?.runId ?? "", + "performance run ID", + ), + releaseChecks: normalizeOptionalRunId(childRuns.releaseChecks, "release checks run ID"), + }; + let evidenceReuse; + if (value.evidenceReuse !== undefined) { + const reuse = normalizeJsonObject( + value.evidenceReuse, + "release validation manifest evidence reuse", + ); + if (reuse.policy !== EVIDENCE_REUSE_POLICY) { + throw new Error("release validation manifest evidence reuse policy is invalid"); + } + if (!/^[a-f0-9]{40}$/u.test(String(reuse.evidenceSha))) { + throw new Error("release validation manifest evidence SHA is invalid"); + } + if ( + !Array.isArray(reuse.changedPaths) || + reuse.changedPaths.some( + (changedPath) => typeof changedPath !== "string" || changedPath.length === 0, + ) || + new Set(reuse.changedPaths).size !== reuse.changedPaths.length + ) { + throw new Error("release validation manifest evidence changed paths are invalid"); + } + evidenceReuse = { + changedPaths: reuse.changedPaths, + evidenceSha: String(reuse.evidenceSha), + policy: reuse.policy, + runId: normalizeRequiredRunId(reuse.runId, "evidence reuse root run ID"), + selectedRunId: normalizeRequiredRunId(reuse.selectedRunId, "evidence reuse selected run ID"), + }; + } + return { + childRunIds, + controls, + evidenceReuse, + releaseProfile, + rerunGroup, + runAttempt: Number(value.runAttempt), + runId: String(value.runId), + runReleaseSoak, + targetRef: String(value.targetRef ?? ""), + targetSha, + validationInputs, + version: value.version, + workflowFullRef, + workflowSha, + workflowRef: value.workflowRef, + workflowRefType, + }; +} + +export function validateEvidenceReuseChain(currentManifest, selectedManifest, rootManifest) { + const reuse = currentManifest.evidenceReuse; + if (!reuse) { + throw new Error("release validation manifest does not authorize evidence reuse"); + } + if (reuse.changedPaths.length !== 0) { + throw new Error("full release evidence reuse requires an exact target with no changed paths"); + } + if (rootManifest.evidenceReuse || selectedManifest.evidenceReuse) { + throw new Error("evidence reuse must select a root execution manifest"); + } + if ( + !currentManifest.validationInputs || + !selectedManifest.validationInputs || + !rootManifest.validationInputs + ) { + throw new Error("evidence reuse manifests must record validation inputs"); + } + if (rootManifest.runId !== reuse.runId) { + throw new Error("evidence reuse root manifest run ID mismatch"); + } + if (selectedManifest.runId !== reuse.selectedRunId) { + throw new Error("evidence reuse selected manifest run ID mismatch"); + } + if (selectedManifest.targetSha !== reuse.evidenceSha) { + throw new Error("evidence reuse selected manifest SHA mismatch"); + } + if ( + currentManifest.targetSha !== reuse.evidenceSha || + rootManifest.targetSha !== reuse.evidenceSha + ) { + throw new Error("full release evidence reuse target SHA mismatch"); + } + if (selectedManifest.runId !== rootManifest.runId) { + throw new Error("evidence reuse selected manifest is not the chain root"); + } + + const rootIdentity = JSON.stringify(manifestEvidenceIdentity(rootManifest)); + for (const [label, manifest] of [ + ["selected", selectedManifest], + ["current", currentManifest], + ]) { + if (JSON.stringify(manifestEvidenceIdentity(manifest)) !== rootIdentity) { + throw new Error(`evidence reuse ${label} manifest policy differs from the chain root`); + } + } + return rootManifest.targetSha; +} + +export function selectedChildKeys(parentJobs) { + return new Set( + CHILD_DISPATCHES.filter((child) => { + const parentJob = parentJobs.find((job) => job.name === child.parentJobName); + return parentJob && parentJob.conclusion !== "skipped"; + }).map((child) => child.manifestKey), + ); +} + +export function manifestChildEntries(manifest, children, selectedKeys) { + return children.flatMap((child) => { + const runId = manifest.childRunIds[child.manifestKey]; + if (!runId) { + if (selectedKeys.has(child.manifestKey)) { + throw new Error(`selected child is missing from manifest: ${child.name}`); + } + return []; + } + return [{ child, runId }]; + }); +} + +function childDispatchAttempt(displayTitle, child, parentRunId, parentRunAttempt) { + const prefix = `${child.name} full-release-validation-${parentRunId}-`; + if (!displayTitle.startsWith(prefix) || !displayTitle.endsWith(child.suffix)) { + return undefined; + } + const attemptEnd = child.suffix ? -child.suffix.length : undefined; + const attemptText = displayTitle.slice(prefix.length, attemptEnd); + if (!/^[1-9][0-9]*$/u.test(attemptText)) { + return undefined; + } + const attempt = Number(attemptText); + if (!Number.isSafeInteger(attempt) || attempt > parentRunAttempt) { + return undefined; + } + return attempt; +} + +function parentJobExecutionFingerprint(job) { + return canonicalJson({ + completedAt: job.completed_at, + conclusion: job.conclusion, + name: job.name, + startedAt: job.started_at, + status: job.status, + steps: (job.steps ?? []).map((step) => ({ + completedAt: step.completed_at, + conclusion: step.conclusion, + name: step.name, + number: step.number, + startedAt: step.started_at, + status: step.status, + })), + }); +} + +function selectedAttemptParentJob(parentJobs, child, parentManifest) { + const slotJobs = parentJobs.filter((job) => job.name === child.parentJobName); + if (slotJobs.length === 0) { + throw new Error(`manifest parent job is missing: ${child.name}`); + } + const latestAttempt = Math.max(...slotJobs.map((job) => Number(job.run_attempt))); + if (latestAttempt !== parentManifest.runAttempt) { + throw new Error(`manifest parent job latest attempt mismatch: ${child.name}`); + } + const currentJobs = slotJobs.filter( + (job) => Number(job.run_attempt) === parentManifest.runAttempt, + ); + if (currentJobs.length !== 1) { + throw new Error(`manifest parent job is not unique at the selected attempt: ${child.name}`); + } + const currentJob = currentJobs[0]; + if (currentJob.status !== "completed" || currentJob.conclusion !== "success") { + throw new Error(`manifest parent job is not completed/success: ${child.name}`); + } + return { currentJob, slotJobs }; +} + +export function resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs) { + const correlatedAttempt = childDispatchAttempt( + String(run.display_title ?? ""), + child, + parentManifest.runId, + parentManifest.runAttempt, + ); + if (correlatedAttempt !== undefined) { + return correlatedAttempt; + } + if (run.display_title !== child.name) { + return undefined; + } + + const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest); + const currentFingerprint = JSON.stringify(parentJobExecutionFingerprint(currentJob)); + const carriedOriginAttempts = slotJobs + .filter( + (job) => + Number(job.run_attempt) < parentManifest.runAttempt && + job.status === "completed" && + job.conclusion === "success" && + JSON.stringify(parentJobExecutionFingerprint(job)) === currentFingerprint, + ) + .map((job) => Number(job.run_attempt)); + return carriedOriginAttempts.length > 0 + ? Math.min(...carriedOriginAttempts) + : parentManifest.runAttempt; +} + +export function selectManifestParentJob(parentJobs, child, parentManifest, originAttempt) { + const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest); + if (originAttempt === parentManifest.runAttempt) { + return currentJob; + } + + const originJobs = slotJobs.filter((job) => Number(job.run_attempt) === originAttempt); + if (originJobs.length !== 1) { + throw new Error(`manifest parent job origin is not unique: ${child.name}`); + } + const originJob = originJobs[0]; + if (originJob.status !== "completed" || originJob.conclusion !== "success") { + throw new Error(`manifest parent job origin is not completed/success: ${child.name}`); + } + if ( + JSON.stringify(parentJobExecutionFingerprint(currentJob)) !== + JSON.stringify(parentJobExecutionFingerprint(originJob)) + ) { + throw new Error(`manifest parent job carry-forward fingerprint mismatch: ${child.name}`); + } + return currentJob; +} + +function childRunIdsFromParentLog(log, repository = DEFAULT_REPO) { + const escapedRepo = repository.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp( + `https://github\\.com/${escapedRepo}/actions/runs/([1-9][0-9]*)`, + "gu", + ); + return new Set(Array.from(log.matchAll(pattern), (match) => match[1])); +} + +export function validateManifestChildRun( + run, + child, + runId, + parentManifest, + parentJobs, + selectedParentJobLog, + parentActorLogin, + repository = DEFAULT_REPO, +) { + if (String(run.id) !== String(runId)) { + throw new Error(`manifest child run ID mismatch: ${child.name}`); + } + const originAttempt = resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs); + // GitHub preserves the bot as `actor` on a rerun and records the requester + // separately. Only the original parent operator may retry that exact child. + const allowedTriggeringActors = new Set( + ["github-actions[bot]", parentActorLogin].filter( + (login) => typeof login === "string" && login.length > 0, + ), + ); + if ( + run.event !== "workflow_dispatch" || + run.head_branch !== child.headBranch || + (child.trustedRef === "parent" && run.head_sha !== parentManifest.workflowSha) || + !/^[a-f0-9]{40}$/u.test(String(run.head_sha)) || + run.actor?.login !== "github-actions[bot]" || + !allowedTriggeringActors.has(run.triggering_actor?.login) || + !Number.isSafeInteger(Number(run.run_attempt)) || + Number(run.run_attempt) < 1 || + originAttempt === undefined + ) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const childWorkflowPath = String(run.path ?? "").split("@", 1)[0]; + if (childWorkflowPath !== `.github/workflows/${child.workflow}`) { + throw new Error(`manifest child workflow mismatch: ${child.name}`); + } + selectManifestParentJob(parentJobs, child, parentManifest, originAttempt); + const emittedChildRunIds = childRunIdsFromParentLog(selectedParentJobLog, repository); + if (emittedChildRunIds.size !== 1 || !emittedChildRunIds.has(String(runId))) { + throw new Error(`manifest child run is not uniquely emitted by its parent job: ${child.name}`); + } + if ( + child.manifestKey !== "npmTelegram" && + !selectedParentJobLog.includes(`TARGET_SHA: ${parentManifest.targetSha}`) + ) { + throw new Error(`manifest parent job target SHA mismatch: ${child.name}`); + } + if ( + child.manifestKey === "productPerformance" && + !selectedParentJobLog.includes("-f publish_reports=false") + ) { + throw new Error("manifest performance child is not dispatched in artifact-only mode"); + } + return run; +} + +export function validatePerformanceArtifactOnlyJobs(jobs, runAttempt) { + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "performance run attempt"); + const currentJobs = jobs.filter((job) => Number(job.run_attempt) === normalizedRunAttempt); + const guards = currentJobs.filter((job) => job.name === "Verify artifact-only report mode"); + if ( + guards.length !== 1 || + guards[0].status !== "completed" || + guards[0].conclusion !== "success" + ) { + throw new Error("performance artifact-only guard is missing or unsuccessful"); + } + const unsafePublisher = currentJobs.find( + (job) => + String(job.name ?? "").startsWith("Publish ") && + String(job.name ?? "").endsWith(" report") && + job.conclusion !== "skipped", + ); + if (unsafePublisher) { + throw new Error(`performance report publisher was not skipped: ${unsafePublisher.name}`); + } + return guards[0]; +} + +function manifestArtifactName(runId, runAttempt) { + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt"); + return `full-release-validation-${normalizedRunId}-${normalizedRunAttempt}`; +} + +function legacyManifestArtifactName(runId) { + return `full-release-validation-${normalizeRequiredRunId(runId, "full release run ID")}`; +} + +export function validateManifestArtifactIdentity( + artifact, + { artifactDigest, artifactId, runAttempt, runId }, +) { + const normalizedArtifactId = normalizeRequiredRunId(artifactId, "manifest artifact ID"); + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt"); + const normalizedDigest = String(artifactDigest ?? ""); + if (!/^sha256:[a-f0-9]{64}$/u.test(normalizedDigest)) { + throw new Error(`release validation manifest artifact digest is invalid: ${normalizedRunId}`); + } + const canonicalName = manifestArtifactName(normalizedRunId, normalizedRunAttempt); + const legacyName = legacyManifestArtifactName(normalizedRunId); + const validName = + artifact.name === canonicalName || (normalizedRunAttempt === 1 && artifact.name === legacyName); + if ( + String(artifact.id) !== normalizedArtifactId || + !validName || + artifact.digest !== normalizedDigest || + artifact.expired !== false || + String(artifact.workflow_run?.id) !== normalizedRunId || + !Number.isSafeInteger(Number(artifact.size_in_bytes)) || + Number(artifact.size_in_bytes) < 1 + ) { + throw new Error(`release validation manifest artifact identity mismatch: ${normalizedRunId}`); + } + return artifact; +} + +export function selectManifestArtifact(artifacts, runId, runAttempt) { + const expectedName = manifestArtifactName(runId, runAttempt); + const canonicalMatches = artifacts.filter( + (artifact) => + artifact.name === expectedName && + artifact.expired === false && + String(artifact.workflow_run?.id) === String(runId), + ); + if (canonicalMatches.length > 1) { + throw new Error(`multiple release validation manifest artifacts found: ${runId}`); + } + const canonicalArtifact = canonicalMatches[0]; + if (canonicalArtifact) { + return validateManifestArtifactIdentity(canonicalArtifact, { + artifactDigest: canonicalArtifact.digest, + artifactId: canonicalArtifact.id, + runAttempt, + runId, + }); + } + + const legacyName = legacyManifestArtifactName(runId); + const legacyMatches = artifacts.filter( + (artifact) => + artifact.name === legacyName && + artifact.expired === false && + String(artifact.workflow_run?.id) === String(runId), + ); + if (legacyMatches.length > 1) { + throw new Error(`multiple legacy release validation manifest artifacts found: ${runId}`); + } + const legacyArtifact = legacyMatches[0]; + if (!legacyArtifact) { + return undefined; + } + if (Number(runAttempt) !== 1) { + throw new Error(`legacy release validation manifest requires run attempt 1: ${runId}`); + } + return validateManifestArtifactIdentity(legacyArtifact, { + artifactDigest: legacyArtifact.digest, + artifactId: legacyArtifact.id, + runAttempt, + runId, + }); +} + +export function validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt) { + if (artifact.name === manifestArtifactName(runId, runAttempt)) { + return artifact; + } + if ( + Number(runAttempt) === 1 && + artifact.name === legacyManifestArtifactName(runId) && + manifest?.version === 2 + ) { + return artifact; + } + throw new Error(`legacy release validation manifest artifact is not compatible: ${runId}`); +} + +export function readManifestArtifactArchive(archivePath, expectedDigest) { + const archiveSize = statSync(archivePath).size; + if ( + !Number.isSafeInteger(archiveSize) || + archiveSize < 1 || + archiveSize > MAX_MANIFEST_ARTIFACT_ZIP_BYTES + ) { + throw new Error("release validation manifest artifact compressed size is invalid"); + } + const archiveBytes = readFileSync(archivePath); + if (archiveBytes.byteLength !== archiveSize) { + throw new Error("release validation manifest artifact changed while being verified"); + } + const actualDigest = `sha256:${createHash("sha256").update(archiveBytes).digest("hex")}`; + if (actualDigest !== expectedDigest) { + throw new Error("release validation manifest artifact digest mismatch"); + } + + let entryList; + try { + entryList = execFileSync("unzip", ["-Z", "-1", archivePath], { + encoding: "utf8", + maxBuffer: MAX_MANIFEST_ENTRY_LIST_BYTES, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new Error("release validation manifest artifact entry list is invalid"); + } + const entries = entryList.split(/\r?\n/u).filter((entry) => entry.length > 0); + if (entries.length !== 1 || entries[0] !== MANIFEST_ARTIFACT_ENTRY) { + throw new Error( + `release validation manifest artifact must contain only ${MANIFEST_ARTIFACT_ENTRY}`, + ); + } + + let manifestBytes; + try { + manifestBytes = execFileSync("unzip", ["-p", archivePath, MANIFEST_ARTIFACT_ENTRY], { + maxBuffer: MAX_MANIFEST_JSON_BYTES + 1, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new Error("release validation manifest artifact entry could not be read safely"); + } + if (manifestBytes.byteLength < 1 || manifestBytes.byteLength > MAX_MANIFEST_JSON_BYTES) { + throw new Error("release validation manifest artifact entry size is invalid"); + } + return JSON.parse(manifestBytes.toString("utf8")); +} + +function downloadParentManifestEvidence(runId, runAttempt, repository, manifestPath) { + const targetRepository = repository ?? DEFAULT_REPO; + const artifacts = []; + for (let page = 1; page <= 10; page += 1) { + const pageArtifacts = + githubRestJson(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`, targetRepository) + .artifacts ?? []; + artifacts.push(...pageArtifacts); + if (pageArtifacts.length < 100) { + break; + } + } + const listedArtifact = selectManifestArtifact(artifacts, runId, runAttempt); + if (!listedArtifact) { + return undefined; + } + const artifact = validateManifestArtifactIdentity( + githubRestJson(`actions/artifacts/${listedArtifact.id}`, targetRepository), + { + artifactDigest: listedArtifact.digest, + artifactId: listedArtifact.id, + runAttempt, + runId, + }, + ); + const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-release-ci-summary-")); + try { + const archivePath = join(downloadDir, "manifest.zip"); + downloadArtifactZip(String(artifact.id), archivePath, targetRepository); + const manifest = readManifestArtifactArchive(archivePath, artifact.digest); + validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt); + if (manifestPath) { + const providedManifest = JSON.parse(readFileSync(resolve(manifestPath), "utf8")); + if ( + JSON.stringify(canonicalJson(providedManifest)) !== JSON.stringify(canonicalJson(manifest)) + ) { + throw new Error("provided release validation manifest differs from the run artifact"); + } + } + return { artifact, manifest }; + } finally { + rmSync(downloadDir, { force: true, recursive: true }); + } +} + +function tryDownloadParentManifest(runId, runAttempt, repository = DEFAULT_REPO) { + return downloadParentManifestEvidence(runId, runAttempt, repository)?.manifest; +} + +function workflowPath(run) { + return String(run.path ?? "").split("@", 1)[0]; +} + +function normalizedManifestArtifact(artifact, runAttempt) { + return { + digest: artifact.digest, + id: String(artifact.id), + name: artifact.name, + runAttempt, + sizeInBytes: Number(artifact.size_in_bytes), + }; +} + +function validateManifestArtifactBinding(artifact, manifest, parentRun, runId) { + validateManifestArtifactCompatibility(artifact, manifest, runId, parentRun.run_attempt); + if ( + String(artifact.workflow_run?.id) !== String(runId) || + artifact.workflow_run?.head_branch !== parentRun.head_branch || + artifact.workflow_run?.head_sha !== parentRun.head_sha + ) { + throw new Error(`release validation manifest artifact binding mismatch: ${runId}`); + } +} + +function validateCompletedParentRun(parentView, parentRest, repository, runId) { + validateParentRunBinding(parentView, parentRest, runId); + if ( + parentView.status !== "completed" || + parentView.conclusion !== "success" || + parentRest.status !== "completed" || + parentRest.conclusion !== "success" || + parentRest.repository?.full_name !== repository + ) { + throw new Error(`full release parent run is not completed/success: ${runId}`); + } +} + +export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { + const normalizedRepository = normalizeRepository(repository); + return { + compareCommits(base, head) { + return githubRestJson(`compare/${base}...${head}`, normalizedRepository); + }, + getJobLog(jobId) { + return parentJobLog(jobId, normalizedRepository); + }, + getParentJobs(runId) { + return findParentJobsAll(runId, normalizedRepository); + }, + getRun(runId) { + return githubRestJson(`actions/runs/${runId}`, normalizedRepository); + }, + getRunView(runId) { + return jsonGh([ + "run", + "view", + String(runId), + "--repo", + normalizedRepository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + }, + loadManifest(runId, runAttempt, manifestPath) { + return downloadParentManifestEvidence(runId, runAttempt, normalizedRepository, manifestPath); + }, + }; +} + +function loadValidatedParentEvidence({ client, manifestPath, repository, runId }) { + const parentView = client.getRunView(runId); + const parentRun = client.getRun(runId); + validateCompletedParentRun(parentView, parentRun, repository, runId); + + const manifestEvidence = client.loadManifest(runId, parentRun.run_attempt, manifestPath); + if (!manifestEvidence) { + throw new Error(`successful parent run is missing its release validation manifest: ${runId}`); + } + const manifest = validateParentManifest(manifestEvidence.manifest, { + runAttempt: parentRun.run_attempt, + runId, + workflowRef: parentRun.head_branch, + workflowSha: parentRun.head_sha, + }); + validateManifestArtifactBinding(manifestEvidence.artifact, manifest, parentRun, runId); + + return { + artifact: manifestEvidence.artifact, + manifest, + manifestJson: canonicalJson(manifestEvidence.manifest), + parentRun, + parentView, + }; +} + +function trustedWorkflowFullRef(workflowRef) { + return `refs/heads/${workflowRef}`; +} + +function normalizeWorkflowPathRef(ref) { + if (!ref || ref.startsWith("refs/")) { + return ref; + } + return `refs/heads/${ref}`; +} + +export function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) { + const { manifest, parentRun } = evidence; + // Keep this predicate local: verifier source identity covers this file only. + const shaPinned = SHA_PINNED_BRANCH_PATTERN.test(manifest.workflowRef ?? ""); + if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) { + throw new Error( + `release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`, + ); + } + if (shaPinned) { + if (manifest.version !== 3) { + throw new Error("SHA-pinned release evidence requires a v3 manifest"); + } + if (!manifest.workflowRef.startsWith(`release-ci/${manifest.workflowSha.slice(0, 12)}-`)) { + throw new Error("SHA-pinned release evidence branch does not match its workflow SHA"); + } + if (manifest.targetRef !== manifest.targetSha) { + throw new Error("SHA-pinned release evidence target ref must equal its target SHA"); + } + } + const expectedFullRef = trustedWorkflowFullRef(manifest.workflowRef); + const runPath = String(parentRun.path ?? ""); + const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2); + if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") { + throw new Error("release evidence producer workflow path is not trusted"); + } + if (runWorkflowFullRef && normalizeWorkflowPathRef(runWorkflowFullRef) !== expectedFullRef) { + throw new Error("release evidence producer workflow full ref is not trusted"); + } + + let workflowRefProof = "legacy-v2-main-ancestry"; + if (manifest.version === 3) { + if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) { + throw new Error("release evidence producer workflow full ref is not trusted"); + } + workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch"; + } + + const comparison = client.compareCommits(manifest.workflowSha, verifier.sourceSha); + if ( + !["ahead", "identical"].includes(String(comparison.status)) || + comparison.merge_base_commit?.sha !== manifest.workflowSha + ) { + throw new Error("release evidence producer is not on the trusted main verifier lineage"); + } + + return { + producerOnTrustedMainLineage: true, + workflowFullRef: expectedFullRef, + workflowQualifiedPath: `${runWorkflowPath}@${expectedFullRef}`, + workflowRefProof, + workflowRefType: "branch", + workflowRunPath: runPath, + }; +} + +function normalizedParentTuple(evidence, identity) { + const { manifest, parentRun } = evidence; + return { + artifact: normalizedManifestArtifact(evidence.artifact, manifest.runAttempt), + conclusion: parentRun.conclusion, + manifest: evidence.manifestJson, + manifestVersion: manifest.version, + runAttempt: manifest.runAttempt, + runId: manifest.runId, + status: parentRun.status, + targetSha: manifest.targetSha, + url: parentRun.html_url ?? evidence.parentView.url, + ...identity, + workflowPath: workflowPath(parentRun), + workflowRef: manifest.workflowRef, + workflowSha: manifest.workflowSha, + }; +} + +export function resolveVerifierIdentity( + sourceSha, + verifierSourceContent, + repositoryRoot = RELEASE_EVIDENCE_REPO_ROOT, +) { + let normalizedSourceSha = sourceSha ?? process.env.GITHUB_SHA; + if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) { + try { + normalizedSourceSha = execFileSync("git", ["-C", repositoryRoot, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + normalizedSourceSha = null; + } + } + if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) { + throw new Error("release evidence verifier source SHA is unavailable"); + } + const script = readFileSync(RELEASE_EVIDENCE_FILE); + const scriptSha256 = createHash("sha256").update(script).digest("hex"); + let sourceScript; + if (verifierSourceContent !== undefined) { + sourceScript = Buffer.from(verifierSourceContent); + } else { + try { + sourceScript = execFileSync( + "git", + ["-C", repositoryRoot, "show", `${normalizedSourceSha}:${RELEASE_EVIDENCE_SCRIPT}`], + { + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch { + throw new Error("release evidence verifier source blob is unavailable"); + } + } + const sourceScriptSha256 = createHash("sha256").update(sourceScript).digest("hex"); + if (scriptSha256 !== sourceScriptSha256) { + throw new Error("release evidence verifier script differs from its source SHA"); + } + return { + schemaVersion: 3, + script: RELEASE_EVIDENCE_SCRIPT, + scriptSha256, + sourceSha: normalizedSourceSha, + }; +} + +function validateStrictChildRun({ child, client, parentEvidence, parentJobs, repository, runId }) { + const run = client.getRun(runId); + const originAttempt = resolveManifestChildOriginAttempt( + run, + child, + parentEvidence.manifest, + parentJobs, + ); + if (originAttempt === undefined) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const parentJob = selectManifestParentJob( + parentJobs, + child, + parentEvidence.manifest, + originAttempt, + ); + validateManifestChildRun( + run, + child, + runId, + parentEvidence.manifest, + parentJobs, + client.getJobLog(parentJob.id), + parentEvidence.parentRun.actor?.login, + repository, + ); + if ( + run.repository?.full_name !== repository || + run.status !== "completed" || + run.conclusion !== "success" || + run.head_sha !== parentEvidence.manifest.workflowSha + ) { + throw new Error(`manifest child run is not exact completed/success evidence: ${child.name}`); + } + if (child.manifestKey === "productPerformance") { + validatePerformanceArtifactOnlyJobs(client.getParentJobs(runId), run.run_attempt); + } + + return { + conclusion: run.conclusion, + dispatchNonce: `full-release-validation-${parentEvidence.manifest.runId}-${originAttempt}${child.suffix}`, + displayTitle: run.display_title, + event: run.event, + headBranch: run.head_branch, + parentJobId: String(parentJob.id), + path: workflowPath(run), + role: child.manifestKey, + runAttempt: normalizePositiveInteger(run.run_attempt, `${child.name} run attempt`), + runId: String(run.id), + sourceParentAttempt: originAttempt, + sourceParentRunId: parentEvidence.manifest.runId, + status: run.status, + url: run.html_url, + workflowSha: run.head_sha, + ...(child.manifestKey === "productPerformance" ? { reportPublication: "artifact-only" } : {}), + }; +} + +export function validateReleaseRunEvidence( + { + manifestPath, + repository = DEFAULT_REPO, + runId, + trustedWorkflowRef = "main", + verifierSourceContent, + verifierSourceSha, + }, + client, +) { + const normalizedRepository = normalizeRepository(repository); + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedTrustedWorkflowRef = normalizeWorkflowRef( + trustedWorkflowRef, + "trusted workflow ref", + ); + const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository); + const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent); + const currentEvidence = loadValidatedParentEvidence({ + client: evidenceClient, + manifestPath, + repository: normalizedRepository, + runId: normalizedRunId, + }); + const producerIdentities = new Map([ + [ + currentEvidence.manifest.runId, + validateTrustedProducerIdentity( + currentEvidence, + evidenceClient, + verifier, + normalizedTrustedWorkflowRef, + ), + ], + ]); + + let rootEvidence = currentEvidence; + let selectedEvidence = currentEvidence; + const reuse = currentEvidence.manifest.evidenceReuse; + if (reuse) { + rootEvidence = loadValidatedParentEvidence({ + client: evidenceClient, + repository: normalizedRepository, + runId: reuse.runId, + }); + selectedEvidence = + reuse.selectedRunId === reuse.runId + ? rootEvidence + : loadValidatedParentEvidence({ + client: evidenceClient, + repository: normalizedRepository, + runId: reuse.selectedRunId, + }); + validateEvidenceReuseChain( + currentEvidence.manifest, + selectedEvidence.manifest, + rootEvidence.manifest, + ); + } + + for (const evidence of [currentEvidence, selectedEvidence, rootEvidence]) { + if (!producerIdentities.has(evidence.manifest.runId)) { + producerIdentities.set( + evidence.manifest.runId, + validateTrustedProducerIdentity( + evidence, + evidenceClient, + verifier, + normalizedTrustedWorkflowRef, + ), + ); + } + } + const selectedKeys = requiredChildKeysForRerunGroup(rootEvidence.manifest.rerunGroup); + const expectedChildren = expectedSelectedChildDispatches( + rootEvidence.manifest.runId, + rootEvidence.manifest.runAttempt, + rootEvidence.manifest.workflowRef, + selectedKeys, + ); + const parentJobs = evidenceClient.getParentJobs(rootEvidence.manifest.runId); + const children = manifestChildEntries(rootEvidence.manifest, expectedChildren, selectedKeys).map( + ({ child, runId: childRunId }) => + validateStrictChildRun({ + child, + client: evidenceClient, + parentEvidence: rootEvidence, + parentJobs, + repository: normalizedRepository, + runId: childRunId, + }), + ); + + const current = normalizedParentTuple( + currentEvidence, + producerIdentities.get(currentEvidence.manifest.runId), + ); + const root = normalizedParentTuple( + rootEvidence, + producerIdentities.get(rootEvidence.manifest.runId), + ); + const childConclusions = Object.fromEntries( + children.map((child) => [child.role, child.conclusion]), + ); + return canonicalJson({ + children, + conclusions: { + allRequiredSucceeded: children.every((child) => child.conclusion === "success"), + children: childConclusions, + current: current.conclusion, + root: root.conclusion, + }, + controls: rootEvidence.manifest.controls, + current, + directRoot: !reuse, + evidenceReuse: reuse + ? { + changedPaths: reuse.changedPaths, + evidenceSha: reuse.evidenceSha, + policy: reuse.policy, + rootRunId: reuse.runId, + selectedRunId: reuse.selectedRunId, + } + : null, + manifest: rootEvidence.manifestJson, + releaseProfile: rootEvidence.manifest.releaseProfile, + repository: normalizedRepository, + rerunGroup: rootEvidence.manifest.rerunGroup, + root, + runReleaseSoak: rootEvidence.manifest.runReleaseSoak === "true", + schema: RELEASE_EVIDENCE_SCHEMA, + producerOnTrustedMainLineage: true, + trustedWorkflowFullRef: trustedWorkflowFullRef(normalizedTrustedWorkflowRef), + trustedWorkflowRef: normalizedTrustedWorkflowRef, + valid: true, + validationInputs: rootEvidence.manifest.validationInputs ?? null, + verifier, + }); +} + +export function parseReleaseCiSummaryArgs(argv) { + const options = { + intervalMs: 30_000, + json: false, + manifestPath: undefined, + repository: DEFAULT_REPO, + runId: undefined, + trustedWorkflowRef: "main", + validate: false, + verifierSourceFile: undefined, + verifierSourceSha: undefined, + watch: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--validate-run") { + options.validate = true; + options.runId = argv[++index]; + } else if (argument === "--repo") { + options.repository = argv[++index]; + } else if (argument === "--manifest") { + options.manifestPath = argv[++index]; + } else if (argument === "--trusted-workflow-ref") { + options.trustedWorkflowRef = argv[++index]; + } else if (argument === "--verifier-source-sha") { + options.verifierSourceSha = argv[++index]; + } else if (argument === "--verifier-source-file") { + options.verifierSourceFile = argv[++index]; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--watch") { + options.watch = true; + } else if (argument === "--interval") { + const seconds = argv[++index]; + if (!/^[1-9][0-9]*$/u.test(seconds ?? "")) { + throw new Error("--interval requires a positive number of seconds"); + } + options.intervalMs = Number(seconds) * 1000; + } else if (!argument.startsWith("-") && !options.runId && !options.validate) { + options.runId = argument; + } else { + throw new Error(`unknown or incomplete argument: ${argument}`); + } + } + if (!options.validate && options.manifestPath) { + throw new Error("--manifest requires --validate-run"); + } + if (options.validate && options.watch) { + throw new Error("--watch cannot be combined with --validate-run"); + } + if (options.verifierSourceFile && !options.verifierSourceSha) { + throw new Error("--verifier-source-file requires --verifier-source-sha"); + } + if (!options.runId) { + throw new Error("full release run ID is required"); + } + return options; +} + +function printUsage() { + console.error( + [ + "usage: release-ci-summary.mjs ", + " release-ci-summary.mjs --watch [--interval seconds]", + " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json", + ].join("\n"), + ); +} + +export function releaseCiWatchFingerprint(parent) { + return JSON.stringify({ + attempt: parent.attempt, + conclusion: parent.conclusion ?? "", + jobs: (parent.jobs ?? []) + .map((job) => ({ + conclusion: job.conclusion ?? "", + name: job.name, + status: job.status, + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + status: parent.status, + }); +} + +function summarizeReleaseCiRun(options) { + execFileSync( + process.execPath, + [ + RELEASE_EVIDENCE_FILE, + options.runId, + "--repo", + options.repository, + "--trusted-workflow-ref", + options.trustedWorkflowRef, + ], + { stdio: "inherit" }, + ); +} + +export async function watchReleaseCiRun(options, overrides = {}) { + const fetchParent = + overrides.fetchParent ?? + (() => + jsonGh([ + "run", + "view", + options.runId, + "--repo", + options.repository, + "--json", + "status,conclusion,attempt,jobs", + ])); + const summarize = overrides.summarize ?? (() => summarizeReleaseCiRun(options)); + const sleep = + overrides.sleep ?? + ((milliseconds) => + new Promise((complete) => { + setTimeout(complete, milliseconds); + })); + let previousFingerprint; + while (true) { + const parent = fetchParent(); + const fingerprint = releaseCiWatchFingerprint(parent); + if (fingerprint !== previousFingerprint) { + summarize(); + previousFingerprint = fingerprint; + } + if (parent.status === "completed") { + if (parent.conclusion !== "success") { + throw new Error( + `full release run ${options.runId} completed with ${parent.conclusion || "no conclusion"}`, + ); + } + return; + } + await sleep(options.intervalMs); + } +} + +async function main() { + let options; + try { + options = parseReleaseCiSummaryArgs(process.argv.slice(2)); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } + const { repository, runId } = options; + + if (options.validate) { + try { + const evidence = validateReleaseRunEvidence({ + manifestPath: options.manifestPath, + repository, + runId, + trustedWorkflowRef: options.trustedWorkflowRef, + verifierSourceContent: options.verifierSourceFile + ? readFileSync(options.verifierSourceFile) + : undefined, + verifierSourceSha: options.verifierSourceSha, + }); + console.log(JSON.stringify(evidence, null, options.json ? 2 : 0)); + } catch (error) { + const failure = { + error: error instanceof Error ? error.message : String(error), + schema: RELEASE_EVIDENCE_SCHEMA, + valid: false, + }; + if (options.json) { + console.log(JSON.stringify(failure, null, 2)); + } else { + console.error(failure.error); + } + process.exit(1); + } + return; + } + if (options.watch) { + await watchReleaseCiRun(options); + return; + } + + const core = rate(); + if (core) { + const reset = new Date(core.reset * 1000).toISOString(); + console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`); + if (core.remaining < 20) { + console.error("rate too low for CI summary; wait for reset before polling"); + process.exit(3); + } + } + + const parent = jsonGh([ + "run", + "view", + runId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding(parent, githubRestJson(`actions/runs/${runId}`, repository), runId); + + console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`); + console.log(`workflow-ref: ${parent.headBranch}`); + console.log(`workflow-sha: ${parent.headSha}`); + console.log(`url: ${parent.url}`); + + for (const job of parent.jobs ?? []) { + const marker = job.conclusion || job.status; + console.log(`parent-job: ${marker} ${job.name}`); + } + + const currentManifestRaw = tryDownloadParentManifest(runId, parent.attempt, repository); + let children; + if (currentManifestRaw) { + const currentManifest = validateParentManifest(currentManifestRaw, { + runAttempt: parent.attempt, + runId, + workflowRef: parent.headBranch, + workflowSha: parent.headSha, + }); + console.log(`candidate-sha: ${currentManifest.targetSha}`); + console.log(`manifest-run: ${currentManifest.runId}/${currentManifest.runAttempt}`); + + let sourceManifest = currentManifest; + let sourceParent = parent; + if (currentManifest.evidenceReuse) { + const rootRunId = currentManifest.evidenceReuse.runId; + const rootParent = jsonGh([ + "run", + "view", + rootRunId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding( + rootParent, + githubRestJson(`actions/runs/${rootRunId}`, repository), + rootRunId, + ); + if (rootParent.status !== "completed" || rootParent.conclusion !== "success") { + throw new Error(`evidence root run is not completed/success: ${rootRunId}`); + } + const rootManifestRaw = tryDownloadParentManifest(rootRunId, rootParent.attempt, repository); + if (!rootManifestRaw) { + throw new Error(`evidence root manifest is unavailable: ${rootRunId}`); + } + const rootManifest = validateParentManifest(rootManifestRaw, { + runAttempt: rootParent.attempt, + runId: rootRunId, + workflowRef: rootParent.headBranch, + workflowSha: rootParent.headSha, + }); + + const selectedRunId = currentManifest.evidenceReuse.selectedRunId; + let selectedManifest = rootManifest; + if (selectedRunId !== rootRunId) { + const selectedParent = jsonGh([ + "run", + "view", + selectedRunId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding( + selectedParent, + githubRestJson(`actions/runs/${selectedRunId}`, repository), + selectedRunId, + ); + if (selectedParent.status !== "completed" || selectedParent.conclusion !== "success") { + throw new Error(`selected evidence run is not completed/success: ${selectedRunId}`); + } + const selectedManifestRaw = tryDownloadParentManifest( + selectedRunId, + selectedParent.attempt, + repository, + ); + if (!selectedManifestRaw) { + throw new Error(`selected evidence manifest is unavailable: ${selectedRunId}`); + } + selectedManifest = validateParentManifest(selectedManifestRaw, { + runAttempt: selectedParent.attempt, + runId: selectedRunId, + workflowRef: selectedParent.headBranch, + workflowSha: selectedParent.headSha, + }); + } + + const evidenceSha = validateEvidenceReuseChain( + currentManifest, + selectedManifest, + rootManifest, + ); + sourceManifest = rootManifest; + sourceParent = rootParent; + console.log(`evidence-selected-run: ${selectedRunId}`); + console.log(`evidence-root-run: ${rootRunId}`); + console.log(`evidence-sha: ${evidenceSha}`); + } + + const expectedChildren = expectedSelectedChildDispatches( + sourceManifest.runId, + sourceManifest.runAttempt, + sourceManifest.workflowRef, + requiredChildKeysForRerunGroup(sourceManifest.rerunGroup), + ); + const sourceParentJobs = findParentJobsAll(sourceManifest.runId, repository); + children = manifestChildEntries( + sourceManifest, + expectedChildren, + requiredChildKeysForRerunGroup(sourceManifest.rerunGroup), + ).map(({ child, runId: childRunId }) => { + const run = githubRestJson(`actions/runs/${childRunId}`, repository); + const originAttempt = resolveManifestChildOriginAttempt( + run, + child, + sourceManifest, + sourceParentJobs, + ); + if (originAttempt === undefined) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const parentJob = selectManifestParentJob( + sourceParentJobs, + child, + sourceManifest, + originAttempt, + ); + const validatedRun = validateManifestChildRun( + run, + child, + childRunId, + { ...sourceManifest, workflowSha: sourceParent.headSha }, + sourceParentJobs, + parentJobLog(parentJob.id, repository), + sourceParent.actor?.login, + repository, + ); + if (child.manifestKey === "productPerformance") { + validatePerformanceArtifactOnlyJobs( + findParentJobsAll(childRunId, repository), + run.run_attempt, + ); + } + return { child, run: validatedRun }; + }); + } else { + console.log("candidate-sha: unavailable (release validation manifest not uploaded)"); + if (parent.status === "completed" && parent.conclusion === "success") { + throw new Error("successful parent run is missing its release validation manifest"); + } + const selectedKeys = selectedChildKeys(parent.jobs ?? []); + children = expectedSelectedChildDispatches( + runId, + parent.attempt, + parent.headBranch, + selectedKeys, + ) + .map((child) => { + const run = findExactChildRun(child, repository); + if (!run) { + console.log( + `child-missing: ${child.name} title=${child.displayTitle} branch=${child.headBranch}`, + ); + } + return { child, run }; + }) + .filter((entry) => entry.run); + } + if (children.length === 0) { + console.log("children: none found yet"); + return; + } + + console.log("children:"); + for (const { child, run } of children) { + console.log( + `child: ${run.id} ${child.name} ${run.status}/${run.conclusion || "none"} branch=${run.head_branch} workflow_sha=${run.head_sha}`, + ); + console.log(`child-url: ${run.html_url}`); + } +} + +if (process.argv[1]?.endsWith("release-ci-summary.mjs")) { + await main().catch( + /** @param {unknown} error */ (error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }, + ); +} diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index d1dbf86e44ba..ad1e25d54a19 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,13 +1,26 @@ #!/usr/bin/env node // Checks or refreshes generated release artifacts before a release publish. +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; +import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; -const args = new Set(process.argv.slice(2)); -const fix = args.has("--fix"); +const parsedArgs = parseArgs(process.argv.slice(2)); +const fix = parsedArgs.fix; +const macosInfoPlistPath = "apps/macos/Sources/OpenClaw/Resources/Info.plist"; -if (fix && args.has("--check")) { - console.error("Use either --fix or --check, not both."); - process.exit(1); +// Release-evidence reuse validates version-stamp targets without running any +// package-manager commands; keep this mode dependency-free file reads only. +if (parsedArgs.macosVersionsOnly) { + const errors = collectMacosVersionErrors(); + if (errors.length !== 0) { + for (const error of errors) { + console.error(`[release-preflight] macOS app version metadata: ${error}`); + } + process.exit(1); + } + console.log("[release-preflight] macOS app version metadata OK"); + process.exit(0); } const fixCommands = [ @@ -51,6 +64,78 @@ if (failed.length !== 0) { } console.log("[release-preflight] OK"); +function collectMacosVersionErrors(rootDir = resolve(".")) { + const packageJsonPath = resolve(rootDir, "package.json"); + const infoPlistPath = resolve(rootDir, macosInfoPlistPath); + let packageVersion; + let infoPlist; + + try { + const parsedPackage = JSON.parse(readFileSync(packageJsonPath, "utf8")); + packageVersion = typeof parsedPackage.version === "string" ? parsedPackage.version.trim() : ""; + } catch (error) { + return [`unable to read package.json: ${formatError(error)}`]; + } + + const releaseVersion = parseReleaseVersion(packageVersion); + if (!releaseVersion) { + return [`package.json has invalid release version ${JSON.stringify(packageVersion)}`]; + } + + try { + infoPlist = readFileSync(infoPlistPath, "utf8"); + } catch (error) { + return [`unable to read ${macosInfoPlistPath}: ${formatError(error)}`]; + } + + const errors = []; + // The source plist tracks native base metadata. Packaging stamps the exact + // prerelease version and canonical Sparkle build into the copied app bundle. + const expectedShortVersion = releaseVersion.baseVersion; + const expectedBuildVersion = [ + String(releaseVersion.year), + String(releaseVersion.month).padStart(2, "0"), + String(releaseVersion.patch).padStart(2, "0"), + "00", + ].join(""); + const shortVersion = readPlistString(infoPlist, "CFBundleShortVersionString"); + const buildVersion = readPlistString(infoPlist, "CFBundleVersion"); + + if (shortVersion.error) { + errors.push(shortVersion.error); + } else if (shortVersion.value !== expectedShortVersion) { + errors.push( + `${macosInfoPlistPath} CFBundleShortVersionString is ${JSON.stringify(shortVersion.value)}; expected ${JSON.stringify(expectedShortVersion)} from package.json base version`, + ); + } + + if (buildVersion.error) { + errors.push(buildVersion.error); + } else if (buildVersion.value !== expectedBuildVersion) { + errors.push( + `${macosInfoPlistPath} CFBundleVersion is ${JSON.stringify(buildVersion.value)}; expected ${JSON.stringify(expectedBuildVersion)} for ${expectedShortVersion}`, + ); + } + + return errors; +} + +function readPlistString(infoPlist, key) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`\\s*${escapedKey}\\s*\\s*([^<]*)`, "gu"); + const matches = [...infoPlist.matchAll(pattern)]; + if (matches.length !== 1) { + return { + error: `${macosInfoPlistPath} must contain exactly one string value for ${key}; found ${matches.length}`, + }; + } + return { value: matches[0][1]?.trim() ?? "" }; +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} + async function runSerial(commands) { const failedValue = []; for (const command of commands) { @@ -93,3 +178,46 @@ function printFailures(title, failures) { console.error(`- ${failure.name}: exit ${failure.status} (pnpm ${failure.args.join(" ")})`); } } +function parseArgs(argv) { + let check = false; + let wantsFix = false; + let macosVersionsOnly = false; + for (const arg of argv) { + if (arg === "--help") { + printUsage(console.log); + process.exit(0); + } + if (arg === "--check") { + check = true; + continue; + } + if (arg === "--fix") { + wantsFix = true; + continue; + } + if (arg === "--macos-versions-only") { + macosVersionsOnly = true; + continue; + } + console.error(`Unknown release preflight argument: ${arg}`); + printUsage(console.error); + process.exit(1); + } + if (wantsFix && check) { + console.error("Use either --fix or --check, not both."); + process.exit(1); + } + if (macosVersionsOnly && (wantsFix || check)) { + console.error("Use --macos-versions-only without --fix or --check."); + process.exit(1); + } + return { fix: wantsFix, macosVersionsOnly }; +} + +function printUsage(writeLine) { + writeLine("Usage: node scripts/release-preflight.mjs [--check|--fix|--macos-versions-only]"); + writeLine(""); + writeLine(" --check verify generated release artifacts without writing changes (default)"); + writeLine(" --fix refresh generated release artifacts, then verify them"); + writeLine(" --macos-versions-only verify macOS source version metadata only, no commands"); +} diff --git a/scripts/render-github-release-notes.mjs b/scripts/render-github-release-notes.mjs new file mode 100644 index 000000000000..71736f035063 --- /dev/null +++ b/scripts/render-github-release-notes.mjs @@ -0,0 +1,431 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const GITHUB_RELEASE_BODY_MAX_CHARACTERS = 125_000; +export const GITHUB_RELEASE_BODY_MAX_BYTES = 125_000; + +const CONTRIBUTION_RECORD_HEADING = "### Complete contribution record"; +const RELEASE_VERIFICATION_HEADING = "### Release verification"; +const SHIPPED_BASELINE_EXCLUSIONS_PREFIX = "Shipped baseline exclusions:"; +const OPENCLAW_RELEASE_TAG_PATTERN = + /^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?$/u; +const RELEASE_HEADING_PATTERN = + /^## (?Unreleased|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?)\r?$/u; + +function fail(message) { + throw new Error(message); +} + +function normalizeTail(value) { + return value?.trim() ?? ""; +} + +function joinBody(notes, tail) { + const normalizedNotes = notes.trimEnd(); + const normalizedTail = normalizeTail(tail); + return normalizedTail ? `${normalizedNotes}\n\n${normalizedTail}` : normalizedNotes; +} + +function validateRepository(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + fail(`invalid GitHub repository: ${repository}`); + } +} + +function validateTag(tag) { + if (!OPENCLAW_RELEASE_TAG_PATTERN.test(tag)) { + fail(`invalid release tag: ${tag}`); + } +} + +export function githubReleaseBodySize(body) { + return { + characters: [...body].length, + bytes: Buffer.byteLength(body, "utf8"), + }; +} + +export function fitsGithubReleaseBody(body) { + const size = githubReleaseBodySize(body); + return ( + size.characters <= GITHUB_RELEASE_BODY_MAX_CHARACTERS && + size.bytes <= GITHUB_RELEASE_BODY_MAX_BYTES + ); +} + +function releaseSections(changelog) { + const headings = []; + let offset = 0; + let fence; + for (const segment of changelog.split(/(?<=\n)/u)) { + const line = segment.replace(/\n$/u, ""); + const fenceMatch = line.match(/^\s*(?`{3,}|~{3,})/u); + if (fenceMatch?.groups?.marker) { + const marker = fenceMatch.groups.marker; + if (!fence) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length) { + fence = undefined; + } + offset += segment.length; + continue; + } + if (!fence) { + if (line.startsWith("## ")) { + const releaseHeading = line.match(RELEASE_HEADING_PATTERN); + headings.push({ version: releaseHeading?.groups?.version, start: offset }); + } + } + offset += segment.length; + } + return headings + .map((heading, index) => ({ + version: heading.version, + start: heading.start, + end: headings[index + 1]?.start ?? changelog.length, + })) + .filter((heading) => heading.version); +} + +export function extractChangelogReleaseSections(changelog) { + return releaseSections(changelog).map(({ version, start, end }) => ({ + version, + source: changelog.slice(start, end).trimEnd(), + })); +} + +export function extractChangelogSection(changelog, version) { + const section = releaseSections(changelog).find((candidate) => candidate.version === version); + if (!section) { + fail(`CHANGELOG.md does not contain ## ${version}`); + } + return changelog.slice(section.start, section.end).trimEnd(); +} + +export function releaseNotesVersionForTag(tag) { + validateTag(tag); + return tag.replace(/^v/u, "").replace(/-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*)$/u, ""); +} + +function validateShippedBaselineRef(ref) { + if (!OPENCLAW_RELEASE_TAG_PATTERN.test(ref)) { + fail(`invalid shipped release tag: ${ref}`); + } +} + +export function formatShippedBaselineExclusions(baselines) { + if (baselines.length === 0) { + return ""; + } + const normalized = baselines + .map(({ ref, count, pullRequests }) => { + validateShippedBaselineRef(ref); + if (!Array.isArray(pullRequests)) { + fail(`missing shipped baseline PR inventory for ${ref}`); + } + const normalizedPullRequests = pullRequests.toSorted((a, b) => a - b); + if ( + normalizedPullRequests.some((number) => !Number.isSafeInteger(number) || number < 1) || + new Set(normalizedPullRequests).size !== normalizedPullRequests.length + ) { + fail(`invalid shipped baseline PR inventory for ${ref}`); + } + if (!Number.isSafeInteger(count) || count < 0 || count !== normalizedPullRequests.length) { + fail(`invalid shipped baseline exclusion count for ${ref}: ${count}`); + } + return { ref, count, pullRequests: normalizedPullRequests }; + }) + .toSorted((a, b) => (a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1)); + const seen = new Set(); + for (const baseline of normalized) { + if (seen.has(baseline.ref)) { + fail(`duplicate shipped baseline exclusion: ${baseline.ref}`); + } + seen.add(baseline.ref); + } + return `${SHIPPED_BASELINE_EXCLUSIONS_PREFIX} ${normalized + .map(({ ref, count, pullRequests }) => + count === 0 + ? `${ref} (0 PRs)` + : `${ref} (${count} PRs: ${pullRequests.map((number) => `#${number}`).join(", ")})`, + ) + .join("; ")}.`; +} + +export function parseShippedBaselineExclusions(section) { + const lines = section.split(/\r?\n/u).filter((line) => line.startsWith("Shipped baseline")); + if (lines.length === 0) { + return []; + } + if (lines.length > 1) { + fail("release contribution record contains multiple shipped baseline exclusion lines"); + } + const match = lines[0].match(/^Shipped baseline exclusions: (?.+)\.$/u); + if (!match?.groups?.entries) { + fail("release contribution record contains malformed shipped baseline exclusions"); + } + const baselines = match.groups.entries.split("; ").map((entry) => { + const item = entry.match( + /^(?\S+) \((?0|[1-9][0-9]*) PRs(?:: (?#[1-9][0-9]*(?:, #[1-9][0-9]*)*))?\)$/u, + ); + if (!item?.groups?.ref || item.groups.count === undefined) { + fail(`release contribution record contains malformed shipped baseline exclusion: ${entry}`); + } + const count = Number(item.groups.count); + const pullRequests = item.groups.pullRequests + ? item.groups.pullRequests.split(", ").map((number) => Number(number.slice(1))) + : []; + return { ref: item.groups.ref, count, pullRequests }; + }); + if (formatShippedBaselineExclusions(baselines) !== lines[0]) { + fail("release contribution record shipped baseline exclusions are not canonical"); + } + return baselines; +} + +export function tagPinnedContributionRecordUrl(repository, tag) { + validateRepository(repository); + validateTag(tag); + return `https://github.com/${repository}/blob/${tag}/CHANGELOG.md#complete-contribution-record`; +} + +function headingIndexOutsideFences(markdown, heading) { + let offset = 0; + let fence; + for (const segment of markdown.split(/(?<=\n)/u)) { + const line = segment.replace(/\n$/u, ""); + const fenceMatch = line.match(/^\s*(?`{3,}|~{3,})/u); + if (fenceMatch?.groups?.marker) { + const marker = fenceMatch.groups.marker; + if (!fence) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length) { + fence = undefined; + } + } else if (!fence && line === heading) { + return offset; + } + offset += segment.length; + } + return -1; +} + +function compactReleaseNotes(section, repository, tag) { + const recordIndex = headingIndexOutsideFences(section, CONTRIBUTION_RECORD_HEADING); + if (recordIndex < 0) { + fail( + "release notes exceed GitHub's body limit and cannot be compacted without a complete contribution record", + ); + } + const editorialNotes = section.slice(0, recordIndex).trimEnd(); + const contributionRecordUrl = tagPinnedContributionRecordUrl(repository, tag); + return [ + editorialNotes, + "", + CONTRIBUTION_RECORD_HEADING, + "", + `The full contribution record is available in the tag-pinned [CHANGELOG.md](${contributionRecordUrl}).`, + ].join("\n"); +} + +export function dedicatedSectionVersionForTag(tag) { + // Correction (vX-N) and alpha tags may carry their own exact changelog + // heading; beta and stable bodies must come from the stable base section. + const taggedVersion = tag.replace(/^v/u, ""); + if (/-beta\.[1-9][0-9]*$/u.test(taggedVersion)) { + return undefined; + } + return /-(?:alpha\.)?[1-9][0-9]*$/u.test(taggedVersion) ? taggedVersion : undefined; +} + +export function releaseNotesSectionForTag(changelog, version, tag) { + // Alpha and correction tags prefer their own exact heading when the + // changelog carries one; otherwise they fall back to the base version. + const dedicatedVersion = dedicatedSectionVersionForTag(tag); + if (dedicatedVersion && dedicatedVersion !== version) { + try { + return extractChangelogSection(changelog, dedicatedVersion); + } catch { + // No dedicated section; use the base version below. + } + } + try { + return extractChangelogSection(changelog, version); + } catch (error) { + if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) { + throw error; + } + const unreleased = extractChangelogSection(changelog, "Unreleased"); + return unreleased.replace(/^## Unreleased\r?$/mu, `## ${version}`); + } +} + +export function renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification = "", +}) { + validateRepository(repository); + validateTag(tag); + const tagVersion = releaseNotesVersionForTag(tag); + if (tagVersion !== version) { + fail(`release tag ${tag} requires CHANGELOG.md version ${tagVersion}, got ${version}`); + } + const section = releaseNotesSectionForTag(changelog, version, tag); + const mode = fitsGithubReleaseBody(section) ? "full" : "compact"; + const baseBody = mode === "full" ? section : compactReleaseNotes(section, repository, tag); + if (!fitsGithubReleaseBody(baseBody)) { + const size = githubReleaseBodySize(baseBody); + fail( + `compacted release notes are still too large for GitHub: ${size.characters} characters, ${size.bytes} bytes`, + ); + } + const normalizedVerification = normalizeTail(verification); + const bodyWithVerification = joinBody(baseBody, normalizedVerification); + const verificationIncluded = + normalizedVerification !== "" && fitsGithubReleaseBody(bodyWithVerification); + const body = verificationIncluded ? bodyWithVerification : baseBody; + return { + body, + mode, + size: githubReleaseBodySize(body), + verificationIncluded, + verificationOmitted: normalizedVerification !== "" && !verificationIncluded, + }; +} + +export function verifyGithubReleaseNotes({ body, changelog, version, tag, repository }) { + const normalizedBody = body.trimEnd(); + const base = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }); + if (normalizedBody === base.body) { + return { + ...base, + matches: true, + actualSize: githubReleaseBodySize(normalizedBody), + }; + } + const verificationPrefix = `${base.body}\n\n${RELEASE_VERIFICATION_HEADING}`; + const verification = normalizedBody.startsWith(verificationPrefix) + ? normalizedBody.slice(base.body.length + 2) + : ""; + const expected = verification + ? renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification, + }) + : base; + return { + ...expected, + matches: normalizedBody === expected.body, + actualSize: githubReleaseBodySize(normalizedBody), + }; +} + +function usage() { + return `Usage: + node scripts/render-github-release-notes.mjs \\ + --changelog --tag --repository \\ + [--version ] [--verification-file ] [--output ] \\ + [--metadata-output ] +`; +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if ( + arg === "--changelog" || + arg === "--version" || + arg === "--tag" || + arg === "--repository" || + arg === "--verification-file" || + arg === "--output" || + arg === "--metadata-output" + ) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + fail(`${arg} requires a value`); + } + const key = arg.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase()); + options[key] = value; + index += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + if (!options.help) { + for (const name of ["changelog", "tag", "repository"]) { + if (!options[name]) { + fail(`--${name} is required`); + } + } + if (options.metadataOutput && !options.output) { + fail("--metadata-output requires --output"); + } + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage()); + return; + } + const changelog = readFileSync(options.changelog, "utf8"); + const verification = options.verificationFile + ? readFileSync(options.verificationFile, "utf8") + : ""; + const rendered = renderGithubReleaseNotes({ + changelog, + version: options.version ?? releaseNotesVersionForTag(options.tag), + tag: options.tag, + repository: options.repository, + verification, + }); + if (options.output) { + writeFileSync(options.output, rendered.body); + if (options.metadataOutput) { + const metadata = { + mode: rendered.mode, + size: rendered.size, + verificationIncluded: rendered.verificationIncluded, + verificationOmitted: rendered.verificationOmitted, + }; + writeFileSync(options.metadataOutput, `${JSON.stringify(metadata, null, 2)}\n`); + } + process.stderr.write( + `release-notes: ${rendered.mode} body, ${rendered.size.characters} characters, ${rendered.size.bytes} bytes${ + rendered.verificationOmitted ? ", verification omitted at GitHub limit" : "" + }\n`, + ); + return; + } + process.stdout.write(rendered.body); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index c38b1bba8432..ef17353c1122 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -57,7 +57,7 @@ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { }); } export const OPENCLAW_PACKAGE_SPEC_RE = - /^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$/u; + /^openclaw@(alpha|beta|extended-stable|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$/u; function usage() { return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source --output-dir [options] @@ -136,7 +136,7 @@ export function parseArgs(argv) { export function validateOpenClawPackageSpec(spec) { if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) { throw new Error( - `package_spec must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`, + `package_spec must be openclaw@alpha, openclaw@beta, openclaw@extended-stable, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`, ); } } diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index cbd758c17081..be206aeb28bc 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -399,6 +399,10 @@ const PRECISE_SOURCE_TEST_TARGETS = new Map([ ]); const BROAD_ONLY_TEST_HELPERS = new Set(["test/helpers/poll.ts"]); const TOOLING_SOURCE_TEST_TARGETS = new Map([ + [ + ".agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs", + ["test/scripts/release-notes-ledger.test.ts", "test/scripts/verify-release-notes.test.ts"], + ], [".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]], [ ".github/workflows/ci-check-testbox.yml", @@ -535,6 +539,50 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/lib/managed-child-process.mjs", ["test/scripts/managed-child-process.test.ts"]], ["scripts/lib/npm-verify-exec.ts", ["test/scripts/npm-verify-exec.test.ts"]], ["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]], + ["scripts/lib/openclaw-release-clawhub-plan.ts", ["test/plugin-clawhub-release.test.ts"]], + [ + "scripts/lib/actions-artifact-archive.mjs", + ["test/scripts/plugin-publication-artifact.test.ts"], + ], + [ + "scripts/lib/clawhub-bootstrap-artifact.mjs", + [ + "test/scripts/clawhub-bootstrap-artifact.test.ts", + "test/scripts/verify-clawhub-published-artifact.test.ts", + ], + ], + ["scripts/plugin-publication-artifact.mjs", ["test/scripts/plugin-publication-artifact.test.ts"]], + [ + "scripts/materialize-clawhub-cli.sh", + [ + "test/scripts/package-acceptance-workflow.test.ts", + "test/scripts/plugin-clawhub-new-workflow.test.ts", + ], + ], + [ + ".github/release/clawhub-cli/package.json", + [ + "test/scripts/package-acceptance-workflow.test.ts", + "test/scripts/plugin-clawhub-new-workflow.test.ts", + ], + ], + [ + ".github/release/clawhub-cli/package-lock.json", + [ + "test/scripts/package-acceptance-workflow.test.ts", + "test/scripts/plugin-clawhub-new-workflow.test.ts", + ], + ], + [ + "scripts/verify-clawhub-published-artifact.mjs", + ["test/scripts/verify-clawhub-published-artifact.test.ts"], + ], + ["scripts/plugin-clawhub-publish.sh", ["test/plugin-clawhub-release.test.ts"]], + ["scripts/lib/release-beta-verifier.ts", ["test/scripts/release-beta-verifier.test.ts"]], + [ + "scripts/lib/plugin-npm-release.ts", + ["test/plugin-npm-release.test.ts", "test/plugin-clawhub-release.test.ts"], + ], ["scripts/lib/source-file-scan-cache.mjs", ["test/scripts/source-file-scan-cache.test.ts"]], ["scripts/lib/test-group-report.mjs", ["test/scripts/test-group-report.test.ts"]], ["scripts/lib/ts-guard-utils.mjs", ["test/scripts/ts-guard-utils.test.ts"]], @@ -571,6 +619,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/openclaw-npm-release-check.ts", ["test/openclaw-npm-release-check.test.ts"]], ["scripts/openclaw-prepack.ts", ["test/openclaw-prepack.test.ts"]], ["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]], + ["scripts/render-github-release-notes.mjs", ["test/scripts/render-github-release-notes.test.ts"]], ["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]], ["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]], ["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]], diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs new file mode 100755 index 000000000000..6857bf44e4d7 --- /dev/null +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +// Binds Full Release Validation run metadata to its v3 evidence manifest. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const FULL_RELEASE_WORKFLOW = "Full Release Validation"; +const FULL_RELEASE_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml"; +const SHA_PATTERN = /^[a-f0-9]{40}$/u; +const PINNED_BRANCH_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u; + +function normalizeWorkflowPathRef(ref) { + if (!ref || ref.startsWith("refs/")) { + return ref; + } + return `refs/heads/${ref}`; +} + +export function normalizeFullReleaseValidationRun(run) { + const [workflowPath, workflowQualifiedRef] = String(run.path ?? run.workflowPath ?? "").split( + "@", + 2, + ); + return { + databaseId: String(run.id ?? run.databaseId ?? ""), + runAttempt: Number(run.run_attempt ?? run.runAttempt ?? run.attempt), + workflowName: run.name ?? run.workflowName, + workflowPath, + workflowQualifiedRef, + repository: run.repository?.full_name ?? run.repository, + headBranch: run.head_branch ?? run.headBranch, + headSha: run.head_sha ?? run.headSha, + event: run.event, + status: run.status, + conclusion: run.conclusion, + url: run.html_url ?? run.url, + }; +} + +export function isShaPinnedReleaseValidationBranch(branch) { + return PINNED_BRANCH_PATTERN.test(branch ?? ""); +} + +export function validateFullReleaseValidationEvidence({ + run: rawRun, + manifest, + expectedRepository, + expectedRunId, + expectedTargetSha, + expectedWorkflowBranch, + expectedWorkflowSha, + isTrustedMainAncestor, + validateEvidenceReuseStrictly, +}) { + const run = normalizeFullReleaseValidationRun(rawRun); + const checks = [ + ["databaseId", String(expectedRunId)], + ["workflowName", FULL_RELEASE_WORKFLOW], + ["workflowPath", FULL_RELEASE_WORKFLOW_PATH], + ["repository", expectedRepository], + ["event", "workflow_dispatch"], + ["status", "completed"], + ["conclusion", "success"], + ]; + for (const [key, expected] of checks) { + if (run[key] !== expected) { + throw new Error( + `Referenced full release validation run ${expectedRunId} must have ${key}=${expected}, got ${run[key] ?? ""}.`, + ); + } + } + if (!Number.isInteger(run.runAttempt) || run.runAttempt < 1) { + throw new Error(`Referenced full release validation run ${expectedRunId} has invalid attempt.`); + } + if (!SHA_PATTERN.test(run.headSha ?? "")) { + throw new Error( + `Referenced full release validation run ${expectedRunId} has invalid head SHA.`, + ); + } + const expectedQualifiedRef = `refs/heads/${run.headBranch}`; + const workflowQualifiedRef = normalizeWorkflowPathRef(run.workflowQualifiedRef); + if (workflowQualifiedRef && workflowQualifiedRef !== expectedQualifiedRef) { + throw new Error( + `Referenced full release validation run ${expectedRunId} has workflow path ref ${run.workflowQualifiedRef}, expected ${expectedQualifiedRef}.`, + ); + } + + if (manifest.version !== 3) { + throw new Error( + `Full release validation manifest must use version 3, got ${manifest.version}.`, + ); + } + if (manifest.controls?.performanceBlocking !== true) { + throw new Error( + "Full release validation manifest does not record blocking product performance evidence.", + ); + } + const manifestChecks = [ + ["workflowName", FULL_RELEASE_WORKFLOW], + ["runId", String(expectedRunId)], + ["runAttempt", String(run.runAttempt)], + ["workflowRef", run.headBranch], + ["workflowSha", run.headSha], + ["workflowFullRef", expectedQualifiedRef], + ["workflowRefType", "branch"], + ["targetSha", expectedTargetSha], + ]; + for (const [key, expected] of manifestChecks) { + if (String(manifest[key] ?? "") !== expected) { + throw new Error( + `Full release validation manifest ${key} mismatch: expected ${expected}, got ${manifest[key] ?? ""}.`, + ); + } + } + + const pinnedMatch = PINNED_BRANCH_PATTERN.exec(run.headBranch ?? ""); + if (!pinnedMatch) { + if (run.headBranch?.startsWith("release-ci/")) { + throw new Error( + `Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch}.`, + ); + } + const directBranches = new Set(["main", expectedWorkflowBranch].filter(Boolean)); + if (directBranches.has(run.headBranch)) { + if (run.headBranch === "main" && !isTrustedMainAncestor?.(run.headSha)) { + throw new Error( + `Direct main validation workflow ${run.headSha} is not reachable from current main.`, + ); + } + if ( + run.headBranch !== "main" && + (!SHA_PATTERN.test(expectedWorkflowSha ?? "") || run.headSha !== expectedWorkflowSha) + ) { + throw new Error( + `Direct ${run.headBranch} validation workflow SHA ${run.headSha} does not match trusted ${expectedWorkflowSha ?? ""}.`, + ); + } + return { run, source: "direct" }; + } + throw new Error( + `Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch ?? ""}.`, + ); + } + if (pinnedMatch[1] !== run.headSha.slice(0, 12)) { + throw new Error( + `SHA-pinned validation branch ${run.headBranch} does not match workflow SHA ${run.headSha}.`, + ); + } + if (manifest.targetRef !== expectedTargetSha) { + throw new Error( + `SHA-pinned validation target ref mismatch: expected ${expectedTargetSha}, got ${manifest.targetRef ?? ""}.`, + ); + } + if (!isTrustedMainAncestor?.(run.headSha)) { + throw new Error( + `SHA-pinned validation workflow ${run.headSha} is not reachable from current main.`, + ); + } + if (Object.hasOwn(manifest, "evidenceReuse")) { + const reuse = manifest.evidenceReuse; + if ( + !reuse || + typeof reuse !== "object" || + Array.isArray(reuse) || + reuse.policy !== "exact-target-full-validation-v1" || + reuse.evidenceSha !== expectedTargetSha || + !Array.isArray(reuse.changedPaths) || + reuse.changedPaths.length !== 0 || + !/^[1-9][0-9]*$/u.test(String(reuse.runId ?? "")) || + !/^[1-9][0-9]*$/u.test(String(reuse.selectedRunId ?? "")) + ) { + throw new Error("SHA-pinned validation evidence reuse is invalid."); + } + if (typeof validateEvidenceReuseStrictly !== "function") { + throw new Error("SHA-pinned validation evidence reuse requires strict chain validation."); + } + const strictEvidence = validateEvidenceReuseStrictly({ + repository: expectedRepository, + runId: String(expectedRunId), + targetSha: expectedTargetSha, + }); + if ( + strictEvidence?.schema !== "openclaw.release-validation-evidence/v3" || + strictEvidence.valid !== true || + String(strictEvidence.current?.runId ?? "") !== String(expectedRunId) || + strictEvidence.current?.targetSha !== expectedTargetSha || + strictEvidence.root?.targetSha !== expectedTargetSha || + strictEvidence.evidenceReuse?.evidenceSha !== expectedTargetSha || + String(strictEvidence.evidenceReuse?.rootRunId ?? "") !== String(reuse.runId) || + String(strictEvidence.evidenceReuse?.selectedRunId ?? "") !== String(reuse.selectedRunId) || + strictEvidence.conclusions?.allRequiredSucceeded !== true + ) { + throw new Error("SHA-pinned validation evidence reuse failed strict chain validation."); + } + } + return { run, source: "sha-pinned-main" }; +} + +export function runStrictReleaseEvidenceValidation({ + repository, + runId, + validatorFile = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)), + verifierSourceSha, +}) { + const verifierSourceArgs = verifierSourceSha + ? ["--verifier-source-sha", verifierSourceSha, "--verifier-source-file", validatorFile] + : []; + const result = spawnSync( + process.execPath, + [ + validatorFile, + "--validate-run", + String(runId), + "--repo", + repository, + "--trusted-workflow-ref", + "main", + "--json", + ...verifierSourceArgs, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status !== 0) { + throw new Error( + `Strict release evidence validation failed: ${result.stderr?.trim() || result.signal || result.status}.`, + ); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error("Strict release evidence validator returned invalid JSON."); + } +} + +function gitIsAncestor(ancestor, target) { + const result = spawnSync( + "git", + ["merge-base", "--is-ancestor", `${ancestor}^{commit}`, `${target}^{commit}`], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status === 0) { + return true; + } + if (result.status === 1) { + return false; + } + throw new Error( + `Could not validate trusted workflow ancestry: ${result.stderr?.trim() || result.signal || result.status}.`, + ); +} + +function main() { + const manifestPath = process.env.MANIFEST_FILE ?? ""; + if (!manifestPath) { + throw new Error("MANIFEST_FILE is required."); + } + const run = JSON.parse(readFileSync(0, "utf8")); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const trustedMainRef = process.env.TRUSTED_MAIN_REF ?? "refs/remotes/origin/main"; + const result = validateFullReleaseValidationEvidence({ + run, + manifest, + expectedRepository: process.env.GITHUB_REPOSITORY, + expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID, + expectedTargetSha: process.env.EXPECTED_SHA, + expectedWorkflowBranch: process.env.EXPECTED_WORKFLOW_BRANCH, + expectedWorkflowSha: process.env.EXPECTED_WORKFLOW_SHA, + isTrustedMainAncestor: (sha) => gitIsAncestor(sha, trustedMainRef), + validateEvidenceReuseStrictly: ({ repository, runId }) => + runStrictReleaseEvidenceValidation({ + repository, + runId, + validatorFile: + process.env.STRICT_VALIDATOR_FILE ?? + fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)), + verifierSourceSha: process.env.GITHUB_SHA, + }), + }); + console.log( + `Using full release validation run ${result.run.databaseId} (${result.source}): ${result.run.url}`, + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/validate-release-publish-approval.mjs b/scripts/validate-release-publish-approval.mjs index 49015367e473..dd0ceeef51e0 100644 --- a/scripts/validate-release-publish-approval.mjs +++ b/scripts/validate-release-publish-approval.mjs @@ -7,6 +7,84 @@ const run = JSON.parse(fs.readFileSync(0, "utf8")); const releasePublishRunId = process.env.RELEASE_PUBLISH_RUN_ID ?? ""; const expectedBranch = process.env.EXPECTED_WORKFLOW_BRANCH ?? ""; const directRecovery = process.env.DIRECT_RELEASE_RECOVERY === "true"; +const approvalPath = process.env.APPROVAL_PATH ?? ""; +const approvalKind = process.env.RELEASE_APPROVAL_KIND ?? "android"; +const expectedRunAttempt = process.env.EXPECTED_RUN_ATTEMPT ?? ""; +const childWorkflowSha = process.env.CHILD_WORKFLOW_SHA ?? ""; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function canonicalPackages(value) { + const packages = value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if ( + packages.length === 0 || + new Set(packages).size !== packages.length || + packages.some((entry) => !/^@openclaw\/[a-z0-9][a-z0-9._-]*$/u.test(entry)) + ) { + fail("ClawHub bootstrap approval requires a unique @openclaw/* package set."); + } + return packages.toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +function positiveRunAttempt(value) { + if (!/^[1-9][0-9]*$/u.test(value)) { + fail("Expected release publish run attempt must be a positive integer."); + } + return Number(value); +} + +if (approvalKind === "clawhub-bootstrap" && !approvalPath) { + fail("ClawHub bootstrap approval requires an attested approval artifact."); +} + +if (approvalPath) { + const approval = JSON.parse(fs.readFileSync(approvalPath, "utf8")); + let expectedApproval; + let mismatchMessage; + if (approvalKind === "android") { + expectedApproval = { + version: 1, + repository: process.env.GITHUB_REPOSITORY, + workflow: "OpenClaw Release Publish", + parentRunId: releasePublishRunId, + workflowBranch: expectedBranch, + releaseTag: process.env.RELEASE_TAG, + targetSha: process.env.RELEASE_TARGET_SHA, + }; + mismatchMessage = "Attested Android release approval does not match this run request."; + } else if (approvalKind === "clawhub-bootstrap") { + if (!/^[a-f0-9]{40}$/u.test(childWorkflowSha)) { + fail("Plugin ClawHub New workflow SHA must be a full lowercase commit SHA."); + } + expectedApproval = { + version: 2, + kind: "clawhub-bootstrap", + repository: process.env.GITHUB_REPOSITORY, + workflow: "OpenClaw Release Publish", + parentRunId: releasePublishRunId, + parentRunAttempt: positiveRunAttempt(expectedRunAttempt), + workflowBranch: expectedBranch, + parentWorkflowSha: run.headSha, + bootstrapWorkflowSha: childWorkflowSha, + releaseTag: process.env.RELEASE_TAG, + targetSha: process.env.RELEASE_TARGET_SHA, + packages: canonicalPackages(process.env.RELEASE_PACKAGES ?? ""), + }; + mismatchMessage = + "Attested ClawHub bootstrap approval does not match this release target and package set."; + } else { + fail(`Unsupported release approval kind: ${approvalKind}`); + } + if (JSON.stringify(approval) !== JSON.stringify(expectedApproval)) { + fail(mismatchMessage); + } +} const checks = [ ["workflowName", "OpenClaw Release Publish"], @@ -16,25 +94,28 @@ const checks = [ for (const [key, expected] of checks) { if (run[key] !== expected) { - console.error( + fail( `Referenced release publish run ${releasePublishRunId} must have ${key}=${expected}, got ${run[key] ?? ""}.`, ); - process.exit(1); } } +if (expectedRunAttempt && run.runAttempt !== positiveRunAttempt(expectedRunAttempt)) { + fail( + `Referenced release publish run ${releasePublishRunId} must use attempt ${expectedRunAttempt}, got ${run.runAttempt ?? ""}.`, + ); +} + if (!directRecovery) { if (run.status !== "in_progress") { - console.error( + fail( `Referenced release publish run ${releasePublishRunId} must still be in_progress, got ${run.status ?? ""}.`, ); - process.exit(1); } if (run.conclusion) { - console.error( + fail( `Referenced release publish run ${releasePublishRunId} already concluded ${run.conclusion}.`, ); - process.exit(1); } console.log(`Using release publish approval run ${releasePublishRunId}: ${run.url}`); process.exit(0); @@ -52,7 +133,6 @@ if (run.status === "completed" && ["success", "failure"].includes(run.conclusion process.exit(0); } -console.error( +fail( `Direct release recovery run ${releasePublishRunId} must be in_progress or completed with success/failure, got status=${run.status ?? ""} conclusion=${run.conclusion ?? ""}.`, ); -process.exit(1); diff --git a/scripts/verify-clawhub-published-artifact.mjs b/scripts/verify-clawhub-published-artifact.mjs new file mode 100644 index 000000000000..83385a037426 --- /dev/null +++ b/scripts/verify-clawhub-published-artifact.mjs @@ -0,0 +1,559 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readdirSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readClawHubBootstrapManifest } from "./lib/clawhub-bootstrap-artifact.mjs"; +import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs"; + +const DEFAULT_ATTEMPTS = 12; +const DEFAULT_DELAY_MS = 5_000; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 120_000; +const MAX_ATTEMPTS = 12; +const MAX_DELAY_MS = 60_000; +const MAX_ARTIFACT_BYTES = 130 * 1024 * 1024; +const MAX_JSON_BYTES = 1024 * 1024; +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u; +const PACKAGE_VERSION_PATTERN = + /^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u; +const PUBLISH_TAG_PATTERN = /^(?:alpha|beta|latest)$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u; +const TOOLCHAIN_VERSION_PATTERN = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u; + +class PermanentReadbackError extends Error {} + +class RetryableReadbackError extends Error { + constructor(message, requestedDelayMs) { + super(message); + this.retryAfterMs = requestedDelayMs; + } +} + +function fail(message) { + throw new Error(message); +} + +function positiveInteger(value, fallback, label, maximum = Number.MAX_SAFE_INTEGER) { + const raw = value === undefined ? fallback : value; + const text = raw === undefined ? "" : String(raw); + if (!/^[1-9][0-9]*$/u.test(text)) { + fail(`${label} must be an integer from 1 through ${maximum}.`); + } + const parsed = Number(text); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > maximum) { + fail(`${label} must be an integer from 1 through ${maximum}.`); + } + return parsed; +} + +function requiredPattern(value, pattern, label) { + if (typeof value !== "string" || !pattern.test(value)) { + fail(`${label} is invalid.`); + } + return value; +} + +function requiredString(value, label) { + if (typeof value !== "string" || value.trim() !== value || value.length === 0) { + fail(`${label} is invalid.`); + } + return value; +} + +function retryAfterMs(headers) { + const retryAfter = headers?.get("retry-after")?.trim(); + if (!retryAfter) { + return undefined; + } + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(MAX_DELAY_MS, Math.max(1, Math.round(seconds * 1_000))); + } + const dateMs = Date.parse(retryAfter); + if (Number.isFinite(dateMs)) { + return Math.min(MAX_DELAY_MS, Math.max(1, dateMs - Date.now())); + } + return undefined; +} + +function retryableStatus(status) { + return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500; +} + +async function cancelResponse(response) { + await response.body?.cancel().catch(() => undefined); +} + +async function readBoundedBytes(response, label, maximumBytes) { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const parsedLength = Number(contentLength); + if (Number.isFinite(parsedLength) && parsedLength > maximumBytes) { + await cancelResponse(response); + throw new PermanentReadbackError(`${label} exceeded ${maximumBytes} bytes.`); + } + } + if (!response.body) { + throw new RetryableReadbackError(`${label} returned no response body.`); + } + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > maximumBytes) { + await reader.cancel(); + throw new PermanentReadbackError(`${label} exceeded ${maximumBytes} bytes.`); + } + chunks.push(value); + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +async function fetchResponse(url, options, context) { + let response; + try { + response = await context.fetchImpl(url, { + ...options, + redirect: "follow", + signal: context.signal, + }); + } catch (error) { + throw new RetryableReadbackError( + `${url} request failed: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + if (retryableStatus(response.status)) { + const delay = retryAfterMs(response.headers); + await cancelResponse(response); + throw new RetryableReadbackError(`${url} returned HTTP ${response.status}.`, delay); + } + if (!response.ok) { + await cancelResponse(response); + throw new PermanentReadbackError(`${url} returned HTTP ${response.status}.`); + } + return response; +} + +async function fetchJson(url, context) { + const response = await fetchResponse(url, { headers: { accept: "application/json" } }, context); + let bytes; + try { + bytes = await readBoundedBytes(response, url, MAX_JSON_BYTES); + } catch (error) { + if (error instanceof PermanentReadbackError) { + throw error; + } + throw new RetryableReadbackError( + `${url} body read failed: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (error) { + throw new RetryableReadbackError( + `${url} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}.`, + ); + } +} + +async function fetchArtifact(url, context) { + const response = await fetchResponse(url, {}, context); + let bytes; + try { + bytes = await readBoundedBytes(response, url, MAX_ARTIFACT_BYTES); + } catch (error) { + if (error instanceof PermanentReadbackError) { + throw error; + } + throw new RetryableReadbackError( + `${url} body read failed: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + return { bytes, headers: response.headers }; +} + +function requireObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new RetryableReadbackError(`${label} is missing or invalid.`); + } + return value; +} + +function requireExact(value, expected, label) { + if (value !== expected) { + throw new RetryableReadbackError( + `${label} mismatch: expected ${String(expected)}, found ${String(value)}.`, + ); + } +} + +function artifactIdentity(bytes) { + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + npmIntegrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`, + npmShasum: createHash("sha1").update(bytes).digest("hex"), + }; +} + +function readExpectedPackageArtifact(directory) { + const artifactDirectory = requiredString(directory, "expectedArtifactDir"); + const entries = readdirSync(artifactDirectory, { withFileTypes: true }); + if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith(".tgz")) { + fail("Expected artifact directory must contain exactly one root .tgz regular file."); + } + const fileName = entries[0].name; + const bytes = readBoundedRegularFile(join(artifactDirectory, fileName), { + label: "Expected ClawHub package artifact", + maxBytes: MAX_ARTIFACT_BYTES, + }); + return { bytes, fileName }; +} + +function validateArtifactMetadata(entry, metadata, identity, headers) { + const packageDetail = requireObject(metadata.package, `${entry.packageName} artifact package`); + const artifact = requireObject(metadata.artifact, `${entry.packageName} artifact metadata`); + requireExact(packageDetail.name, entry.packageName, `${entry.packageName} artifact package name`); + requireExact(metadata.version, entry.version, `${entry.packageName} artifact version`); + requireExact(artifact.kind, "npm-pack", `${entry.packageName} artifact kind`); + requireExact(artifact.sha256, identity.sha256, `${entry.packageName} artifact sha256`); + requireExact(artifact.size, identity.size, `${entry.packageName} artifact size`); + requireExact( + artifact.npmIntegrity, + identity.npmIntegrity, + `${entry.packageName} artifact npmIntegrity`, + ); + requireExact(artifact.npmShasum, identity.npmShasum, `${entry.packageName} artifact npmShasum`); + + const headerSha256 = headers.get("x-clawhub-artifact-sha256"); + const headerIntegrity = headers.get("x-clawhub-npm-integrity"); + const headerShasum = headers.get("x-clawhub-npm-shasum"); + requireExact(headerSha256, identity.sha256, `${entry.packageName} download sha256 header`); + requireExact( + headerIntegrity, + identity.npmIntegrity, + `${entry.packageName} download npm integrity header`, + ); + requireExact(headerShasum, identity.npmShasum, `${entry.packageName} download shasum header`); + return { + kind: artifact.kind, + sha256: artifact.sha256, + size: artifact.size, + npmIntegrity: artifact.npmIntegrity, + npmShasum: artifact.npmShasum, + packageName: packageDetail.name, + version: metadata.version, + }; +} + +async function verifyEntryOnce(entry, options, context) { + const registry = options.registry; + const encodedName = encodeURIComponent(entry.packageName); + const encodedVersion = encodeURIComponent(entry.version); + const detailUrl = `${registry}/api/v1/packages/${encodedName}`; + const versionUrl = `${detailUrl}/versions/${encodedVersion}`; + const metadataUrl = `${versionUrl}/artifact`; + const artifactUrl = `${metadataUrl}/download`; + + const detail = await fetchJson(detailUrl, context); + requireExact( + detail?.package?.tags?.[entry.publishTag], + entry.version, + `${entry.packageName} ClawHub tag ${entry.publishTag}`, + ); + if (options.mode === "postpublish") { + const trustedPublisher = (await fetchJson(`${detailUrl}/trusted-publisher`, context)) + ?.trustedPublisher; + requireExact( + trustedPublisher?.provider, + "github-actions", + `${entry.packageName} trusted publisher provider`, + ); + requireExact( + trustedPublisher?.repository, + "openclaw/openclaw", + `${entry.packageName} trusted publisher repository`, + ); + requireExact( + trustedPublisher?.workflowFilename, + "plugin-clawhub-release.yml", + `${entry.packageName} trusted publisher workflow`, + ); + requireExact( + trustedPublisher?.environment ?? null, + null, + `${entry.packageName} trusted publisher environment`, + ); + } + + const metadata = await fetchJson(metadataUrl, context); + const { bytes, headers } = await fetchArtifact(artifactUrl, context); + const identity = artifactIdentity(bytes); + requireExact(identity.sha256, entry.sha256, `${entry.packageName} registry artifact sha256`); + requireExact(identity.size, entry.size, `${entry.packageName} registry artifact size`); + const artifactMetadata = validateArtifactMetadata(entry, metadata, identity, headers); + + return { + packageName: entry.packageName, + version: entry.version, + publishTag: entry.publishTag, + bootstrapMode: entry.bootstrapMode, + expectedSha256: entry.sha256, + expectedSize: entry.size, + registrySha256: identity.sha256, + registrySize: identity.size, + npmIntegrity: identity.npmIntegrity, + npmShasum: identity.npmShasum, + artifactMetadata, + }; +} + +async function runBoundedRetry(label, operation, retryOptions = {}) { + const attempts = positiveInteger( + retryOptions.attempts, + DEFAULT_ATTEMPTS, + "attempts", + MAX_ATTEMPTS, + ); + const delayMs = positiveInteger(retryOptions.delayMs, DEFAULT_DELAY_MS, "delayMs", MAX_DELAY_MS); + const timeoutMs = positiveInteger( + retryOptions.timeoutMs, + DEFAULT_ATTEMPT_TIMEOUT_MS, + "timeoutMs", + ); + const sleep = + retryOptions.sleep ?? + ((milliseconds) => + new Promise((resolveDelay) => { + setTimeout(resolveDelay, milliseconds); + })); + const fetchImpl = retryOptions.fetchImpl ?? fetch; + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const signal = AbortSignal.timeout(timeoutMs); + try { + return await operation({ fetchImpl, signal }); + } catch (error) { + if (error instanceof PermanentReadbackError) { + throw error; + } + lastError = error; + if (attempt < attempts) { + const requestedDelay = + error instanceof RetryableReadbackError ? error.retryAfterMs : undefined; + await sleep(requestedDelay ?? Math.min(MAX_DELAY_MS, delayMs * attempt)); + } + } + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`${label} did not stabilize after ${attempts} attempts; last failure ${detail}`); +} + +export async function verifyPublishedClawHubArtifacts(options) { + const registry = String(options.registry ?? "https://clawhub.ai").replace(/\/+$/u, ""); + const manifest = readClawHubBootstrapManifest(options.manifestPath); + const expectedToolchain = { + clawhubToolchainIntegrity: requiredPattern( + options.clawhubToolchainIntegrity, + SHA512_INTEGRITY_PATTERN, + "clawhubToolchainIntegrity", + ), + clawhubToolchainSha256: requiredPattern( + options.clawhubToolchainSha256, + SHA256_PATTERN, + "clawhubToolchainSha256", + ), + clawhubToolchainVersion: requiredPattern( + options.clawhubToolchainVersion, + TOOLCHAIN_VERSION_PATTERN, + "clawhubToolchainVersion", + ), + }; + for (const [key, expected] of Object.entries(expectedToolchain)) { + if (manifest[key] !== expected) { + fail(`Validated ClawHub bootstrap manifest ${key} mismatch.`); + } + } + const mode = options.mode ?? "postpublish"; + if (mode !== "postpublish" && mode !== "configure-only-preflight") { + fail(`Unsupported ClawHub artifact verification mode: ${String(mode)}.`); + } + const producerRunAttempt = positiveInteger(manifest.runAttempt, undefined, "manifest runAttempt"); + const terminalRunAttempt = positiveInteger( + options.terminalRunAttempt, + undefined, + "terminalRunAttempt", + ); + if (terminalRunAttempt < producerRunAttempt) { + fail("terminalRunAttempt must be greater than or equal to the producer run attempt."); + } + const artifactId = String(positiveInteger(options.artifactId, undefined, "artifactId")); + const artifactDigest = requiredPattern(options.artifactDigest, SHA256_PATTERN, "artifactDigest"); + + const entries = + mode === "configure-only-preflight" + ? manifest.entries.filter((entry) => entry.bootstrapMode === "configure-only") + : manifest.entries; + const results = []; + for (const entry of entries) { + results.push( + await runBoundedRetry( + `${entry.packageName}@${entry.version} ClawHub artifact`, + (context) => verifyEntryOnce(entry, { registry, mode }, context), + options.retryOptions, + ), + ); + } + return { + schemaVersion: 2, + repository: manifest.repository, + targetSha: manifest.targetSha, + workflowSha: manifest.workflowSha, + runId: manifest.runId, + producerRunAttempt: String(producerRunAttempt), + terminalRunAttempt: String(terminalRunAttempt), + artifactName: manifest.artifactName, + artifactId, + artifactDigest, + clawhubToolchainIntegrity: manifest.clawhubToolchainIntegrity, + clawhubToolchainSha256: manifest.clawhubToolchainSha256, + clawhubToolchainVersion: manifest.clawhubToolchainVersion, + requestedPlugins: manifest.requestedPlugins, + verificationMode: mode, + packages: results, + }; +} + +export async function verifyPublishedClawHubPackage(options) { + const registry = String(options.registry ?? "https://clawhub.ai").replace(/\/+$/u, ""); + const packageName = requiredPattern(options.packageName, PACKAGE_NAME_PATTERN, "packageName"); + const version = requiredPattern( + options.packageVersion, + PACKAGE_VERSION_PATTERN, + "packageVersion", + ); + const publishTag = requiredPattern(options.publishTag, PUBLISH_TAG_PATTERN, "publishTag"); + const { bytes, fileName } = readExpectedPackageArtifact(options.expectedArtifactDir); + const expected = artifactIdentity(bytes); + const entry = { + bootstrapMode: null, + packageName, + publishTag, + sha256: expected.sha256, + size: expected.size, + version, + }; + const result = await runBoundedRetry( + `${packageName}@${version} ClawHub artifact`, + (context) => verifyEntryOnce(entry, { registry, mode: "postpublish" }, context), + options.retryOptions, + ); + return { + schemaVersion: 1, + verificationMode: "oidc-postpublish", + expectedArtifact: { + fileName, + ...expected, + }, + package: result, + }; +} + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + fail(`Invalid argument: ${String(key)}`); + } + result[key.slice(2).replaceAll("-", "_")] = value; + } + return result; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const retryOptions = { + attempts: positiveInteger( + process.env.OPENCLAW_CLAWHUB_VERIFY_ATTEMPTS, + DEFAULT_ATTEMPTS, + "OPENCLAW_CLAWHUB_VERIFY_ATTEMPTS", + MAX_ATTEMPTS, + ), + delayMs: positiveInteger( + process.env.OPENCLAW_CLAWHUB_VERIFY_DELAY_MS, + DEFAULT_DELAY_MS, + "OPENCLAW_CLAWHUB_VERIFY_DELAY_MS", + MAX_DELAY_MS, + ), + timeoutMs: positiveInteger( + process.env.OPENCLAW_CLAWHUB_VERIFY_ATTEMPT_TIMEOUT_MS, + DEFAULT_ATTEMPT_TIMEOUT_MS, + "OPENCLAW_CLAWHUB_VERIFY_ATTEMPT_TIMEOUT_MS", + ), + }; + const directMode = [ + args.expected_artifact_dir, + args.package_name, + args.package_version, + args.publish_tag, + ].some((value) => value !== undefined); + if (directMode && args.manifest !== undefined) { + fail("Direct package verification and bootstrap manifest verification are mutually exclusive."); + } + if (!directMode && args.manifest === undefined) { + fail("Expected --manifest or --expected-artifact-dir."); + } + const evidence = directMode + ? await verifyPublishedClawHubPackage({ + expectedArtifactDir: args.expected_artifact_dir, + packageName: args.package_name, + packageVersion: args.package_version, + publishTag: args.publish_tag, + registry: args.registry, + retryOptions, + }) + : await verifyPublishedClawHubArtifacts({ + registry: args.registry, + manifestPath: args.manifest, + artifactId: args.artifact_id, + artifactDigest: args.artifact_digest, + clawhubToolchainIntegrity: args.clawhub_toolchain_integrity, + clawhubToolchainSha256: args.clawhub_toolchain_sha256, + clawhubToolchainVersion: args.clawhub_toolchain_version, + mode: args.mode, + terminalRunAttempt: args.terminal_run_attempt, + retryOptions, + }); + if (args.output) { + await mkdir(dirname(args.output), { recursive: true }); + await writeFile(args.output, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); + } + process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 24582a27e785..cbb0234f46c0 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1123,8 +1123,8 @@ describe("config cli", () => { issues: [ { path: "update.channel", - message: 'Invalid input (allowed: "stable", "beta", "dev")', - allowedValues: ["stable", "beta", "dev"], + message: 'Invalid input (allowed: "stable", "extended-stable", "beta", "dev")', + allowedValues: ["stable", "extended-stable", "beta", "dev"], allowedValuesHiddenCount: 0, }, ], @@ -1137,8 +1137,8 @@ describe("config cli", () => { expect(payload.issues).toEqual([ { path: "update.channel", - message: 'Invalid input (allowed: "stable", "beta", "dev")', - allowedValues: ["stable", "beta", "dev"], + message: 'Invalid input (allowed: "stable", "extended-stable", "beta", "dev")', + allowedValues: ["stable", "extended-stable", "beta", "dev"], }, ]); expect(mockError).not.toHaveBeenCalled(); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9d58b46e7708..b964766723aa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -11,7 +11,7 @@ import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.opencla import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; -import { isBetaTag } from "../infra/update-channels.js"; +import { isBetaTag, type UpdateChannel } from "../infra/update-channels.js"; import { createDeferredConfiguredPluginRepairDoctorResult, UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, @@ -60,6 +60,14 @@ const legacyConfigRepairMocks = vi.hoisted(() => ({ const launchdUpdateCleanupMocks = vi.hoisted(() => ({ disableCurrentOpenClawUpdateLaunchdJob: vi.fn(async () => false), })); +const officialInstallRecordMocks = vi.hoisted(() => ({ + resolveTrustedSourceLinkedOfficialClawHubSpec: vi.fn( + (_params: { pluginId: string }): string | undefined => undefined, + ), + resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn( + (_params: { pluginId: string }): string | undefined => undefined, + ), +})); const nodeVersionSatisfiesEngine = vi.fn(); const execFile = vi.fn((...args: unknown[]) => { const callback = args.at(-1); @@ -156,6 +164,7 @@ vi.mock("../infra/update-check.js", () => ({ }), fetchNpmPackageTargetStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), + resolveExtendedStablePackage: vi.fn(), resolveNpmChannelTag: vi.fn(), })); @@ -218,10 +227,7 @@ vi.mock("../utils.js", async (importOriginal) => { }; }); -vi.mock("../plugins/official-external-install-records.js", () => ({ - resolveTrustedSourceLinkedOfficialClawHubSpec: vi.fn(() => undefined), - resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined), -})); +vi.mock("../plugins/official-external-install-records.js", () => officialInstallRecordMocks); vi.mock("../plugins/update.js", () => ({ syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), @@ -353,19 +359,31 @@ const { readSourceConfigBestEffort, replaceConfigFile, } = await import("../config/config.js"); -const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } = - await import("../infra/update-check.js"); +const { + checkUpdateStatus, + fetchNpmPackageTargetStatus, + fetchNpmTagVersion, + resolveExtendedStablePackage, + resolveNpmChannelTag, +} = await import("../infra/update-check.js"); const { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } = await import("../infra/update-control-plane-sentinel.js"); const { runCommandWithTimeout } = await import("../process/exec.js"); const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); +const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js"); +const runPostCorePluginConvergenceSpy = vi.spyOn( + postCorePluginConvergence, + "runPostCorePluginConvergence", +); const { updateCommand, updateFinalizeCommand, updateStatusCommand, updateWizardCommand } = await import("./update-cli.js"); +const { updatePluginsAfterCoreUpdate } = await import("./update-cli/update-command.js"); const updateCliShared = await import("./update-cli/shared.js"); const { ensureGitCheckout, resolveGitInstallDir } = updateCliShared; const { spawnSync } = await import("node:child_process"); +const { readRestartSentinel } = await import("../infra/restart-sentinel.js"); function requireValue(value: T | undefined, label: string): T { if (value === undefined) { @@ -523,14 +541,21 @@ describe("update-cli", () => { const syncPluginCall = (index = 0) => { const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array< - [{ channel?: string; config?: OpenClawConfig }] + [{ channel?: string; config?: OpenClawConfig; exactOfficialPluginVersion?: string }] >; return calls[index]?.[0]; }; const npmPluginUpdateCall = (index = 0) => { const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array< - [{ config?: OpenClawConfig; timeoutMs?: number }] + [ + { + config?: OpenClawConfig; + timeoutMs?: number; + updateChannel?: UpdateChannel; + specOverrides?: Record; + }, + ] >; return calls[index]?.[0]; }; @@ -716,6 +741,10 @@ describe("update-cli", () => { beforeEach(() => { vi.clearAllMocks(); + officialInstallRecordMocks.resolveTrustedSourceLinkedOfficialClawHubSpec.mockReturnValue( + undefined, + ); + officialInstallRecordMocks.resolveTrustedSourceLinkedOfficialNpmSpec.mockReturnValue(undefined); resetRuntimeCapture(); spawn.mockImplementation(() => { const child = new EventEmitter() as EventEmitter & { @@ -740,6 +769,13 @@ describe("update-cli", () => { version: "9999.0.0", nodeEngine: ">=22.19.0", }); + vi.mocked(resolveExtendedStablePackage).mockResolvedValue({ + status: "resolved", + selector: "extended-stable", + version: "2026.6.33", + packageSpec: "openclaw@2026.6.33", + registryUrl: "https://registry.npmjs.org/", + }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "9999.0.0", @@ -2142,6 +2178,251 @@ describe("update-cli", () => { expectPackageInstallSpec("openclaw@latest"); }); + it("installs the verified exact package and persists an explicit extended-stable channel", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + readPackageVersion.mockResolvedValue("2026.6.33"); + + await updateCommand({ channel: "extended-stable", yes: true, restart: false }); + + expect(resolveExtendedStablePackage).toHaveBeenCalledWith({ + installKind: "package", + timeoutMs: undefined, + packageName: "openclaw", + }); + expectPackageInstallSpec("openclaw@2026.6.33"); + const installOptions = packageInstallCommandCall()?.[1] as + | { env?: NodeJS.ProcessEnv } + | undefined; + expect(installOptions?.env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org/"); + expect(installOptions?.env?.npm_config_registry).toBe("https://registry.npmjs.org/"); + expect(lastReplaceConfigCall()?.nextConfig?.update?.channel).toBe("extended-stable"); + expect(syncPluginCall()?.channel).toBe("extended-stable"); + expect(syncPluginCall()?.exactOfficialPluginVersion).toBe("2026.6.33"); + expect(lastNpmPluginUpdateCall()?.updateChannel).toBe("extended-stable"); + }); + + it("pins official npm plugins to the exact extended-stable core version", async () => { + const tempDir = createCaseDir("openclaw-update"); + readPackageVersion.mockResolvedValue("2026.6.33"); + const config = { + update: { channel: "extended-stable" }, + plugins: { + installs: { + demo: { + source: "npm", + spec: "@openclaw/demo", + version: "2026.6.8", + installPath: tempDir, + }, + }, + }, + } as OpenClawConfig; + const pluginInstallRecords = config.plugins?.installs ?? {}; + pathExists.mockResolvedValue(true); + officialInstallRecordMocks.resolveTrustedSourceLinkedOfficialNpmSpec.mockImplementation( + ({ pluginId }: { pluginId: string }) => (pluginId === "demo" ? "@openclaw/demo" : undefined), + ); + syncPluginsForUpdateChannel.mockImplementationOnce(async ({ config: nextConfig }) => ({ + changed: false, + config: nextConfig, + summary: { + switchedToBundled: [], + switchedToClawHub: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + })); + updateNpmInstalledPlugins.mockImplementationOnce(async ({ config: nextConfig }) => ({ + changed: false, + config: nextConfig, + outcomes: [], + })); + + await updatePluginsAfterCoreUpdate({ + root: tempDir, + channel: "extended-stable", + configSnapshot: { + ...baseSnapshot, + parsed: config, + sourceConfig: config, + resolved: config, + runtimeConfig: config, + config, + }, + opts: { yes: true, restart: false }, + timeoutMs: 30_000, + pluginInstallRecords, + }); + + expect(lastNpmPluginUpdateCall()?.specOverrides).toEqual({ + demo: "@openclaw/demo@2026.6.33", + }); + }); + + it("uses the same exact resolver for a bare update with stored extended-stable", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + const config = { update: { channel: "extended-stable" } } as OpenClawConfig; + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: config, + sourceConfig: config, + resolved: config, + runtimeConfig: config, + config, + }); + + await updateCommand({ yes: true, restart: false }); + + expect(resolveExtendedStablePackage).toHaveBeenCalledWith({ + installKind: "package", + timeoutMs: undefined, + packageName: "openclaw", + }); + expectPackageInstallSpec("openclaw@2026.6.33"); + expect(syncPluginCall()?.channel).toBe("extended-stable"); + }); + + it("fails closed without config or package mutation when extended-stable resolution fails", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + vi.mocked(resolveExtendedStablePackage).mockResolvedValueOnce({ + status: "failed", + reason: "selector_missing", + }); + + await updateCommand({ channel: "extended-stable", yes: true }); + + expect(packageInstallCommandCall()).toBeUndefined(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled(); + expect(lastWriteJsonCall()).toBeUndefined(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("fails a stored extended-stable update before launchd cleanup when resolution fails", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + const config = { update: { channel: "extended-stable" } } as OpenClawConfig; + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: config, + sourceConfig: config, + resolved: config, + runtimeConfig: config, + config, + }); + vi.mocked(resolveExtendedStablePackage).mockResolvedValueOnce({ + status: "failed", + reason: "selector_query_failed", + }); + + await updateCommand({ yes: true }); + + expect(packageInstallCommandCall()).toBeUndefined(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it.each([ + { name: "explicit", explicit: true }, + { name: "stored", explicit: false }, + ])("rejects --tag for an $name extended-stable channel", async ({ explicit }) => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + if (!explicit) { + const config = { update: { channel: "extended-stable" } } as OpenClawConfig; + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: config, + sourceConfig: config, + resolved: config, + runtimeConfig: config, + config, + }); + } + + await updateCommand({ + ...(explicit ? { channel: "extended-stable" as const } : {}), + tag: "latest", + yes: true, + }); + + expect(resolveExtendedStablePackage).not.toHaveBeenCalled(); + expect(packageInstallCommandCall()).toBeUndefined(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("rejects extended-stable Git updates before handoff, conversion, or config mutation", async () => { + await updateCommand({ channel: "extended-stable", yes: true }); + + expect(resolveExtendedStablePackage).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it.each([ + { name: "refuses", yes: false, installs: false }, + { name: "allows with --yes", yes: true, installs: true }, + ])("$name an extended-stable downgrade in non-interactive mode", async ({ yes, installs }) => { + const tempDir = createCaseDir("openclaw-update"); + setTty(false); + mockPackageInstallStatus(tempDir); + readPackageVersion.mockResolvedValue("2026.7.10"); + vi.mocked(resolveExtendedStablePackage).mockResolvedValueOnce({ + status: "resolved", + selector: "extended-stable", + version: "2026.6.33", + packageSpec: "openclaw@2026.6.33", + registryUrl: "https://registry.npmjs.org/", + }); + + await updateCommand({ channel: "extended-stable", yes, restart: false }); + + expect(packageInstallCommandCall() !== undefined).toBe(installs); + if (installs) { + expect(lastReplaceConfigCall()?.nextConfig?.update?.channel).toBe("extended-stable"); + } else { + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + } + }); + + it("retains extended-stable after a post-commit plugin convergence failure", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + runPostCorePluginConvergenceSpy.mockResolvedValueOnce({ + changes: [], + warnings: [ + { + pluginId: "demo", + reason: "plugin smoke failed", + message: "plugin smoke failed", + guidance: ["Run openclaw update repair."], + }, + ], + errored: true, + smokeFailures: [], + installRecords: {}, + }); + + await updateCommand({ channel: "extended-stable", yes: true, json: true, restart: false }); + + expect(lastReplaceConfigCall()?.nextConfig?.update?.channel).toBe("extended-stable"); + const output = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(output?.status).toBe("error"); + expect(output?.reason).toBe("post-update-plugins"); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + it("refreshes package-manager updates when the installed version already matches the target", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); @@ -4528,6 +4809,32 @@ describe("update-cli", () => { expect(lastWrite?.nextConfig?.update?.channel).toBe("beta"); }); + it("does not immediately update plugins just switched through ClawHub", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({ + changed: true, + config, + summary: { + switchedToBundled: [], + switchedToClawHub: ["demo"], + switchedToNpm: [], + warnings: [], + errors: [], + }, + })); + updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({ + changed: false, + config, + outcomes: [], + })); + + await updateCommand({ channel: "extended-stable", yes: true }); + + const updateCall = lastNpmPluginUpdateCall() as { skipIds?: Set } | undefined; + expect(updateCall?.skipIds?.has("demo")).toBe(true); + }); + it("refreshes post-doctor config before post-update plugin sync", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); @@ -5895,6 +6202,47 @@ describe("update-cli", () => { expect(sentinel.payload?.stats?.after?.version).toBe("2026.4.24"); }); + it("writes an extended-stable selector failure to the control-plane sentinel", async () => { + const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-"); + const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-"); + const metaPath = path.join(metaDir, "meta.json"); + await fs.writeFile( + metaPath, + JSON.stringify({ + version: 1, + meta: { + sessionKey: "agent:main:webchat:dm:user-123", + handoffId: "extended-stable-handoff", + note: "Update requested from the agent.", + }, + }), + ); + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + vi.mocked(resolveExtendedStablePackage).mockResolvedValueOnce({ + status: "failed", + reason: "selector_missing", + }); + + await withEnvAsync( + { + [CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath, + OPENCLAW_STATE_DIR: stateDir, + }, + async () => { + await updateCommand({ channel: "extended-stable", yes: true, json: true }); + }, + ); + + const sentinel = await readRestartSentinel({ + OPENCLAW_STATE_DIR: stateDir, + } as NodeJS.ProcessEnv); + expect(sentinel?.payload.status).toBe("error"); + expect(sentinel?.payload.stats?.reason).toBe("selector_missing"); + expect(sentinel?.payload.stats?.handoffId).toBe("extended-stable-handoff"); + expect(sentinel?.payload.continuation).toBeUndefined(); + }); + it("marks the control-plane update sentinel failed when restart health verification fails", async () => { const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-"); const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-"); @@ -6213,6 +6561,48 @@ describe("update-cli", () => { ); }); + it("updateFinalizeCommand rejects extended-stable on Git before persistence", async () => { + await updateFinalizeCommand({ + channel: "extended-stable", + json: true, + restart: false, + }); + + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(doctorCommand).not.toHaveBeenCalled(); + expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); + expect(lastWriteJsonCall()).toMatchObject({ + status: "error", + mode: "git", + reason: "unsupported_git_channel", + }); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("updateFinalizeCommand rejects a stored extended-stable channel on Git before mutation", async () => { + const storedConfig = { update: { channel: "extended-stable" } } as OpenClawConfig; + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: storedConfig, + resolved: storedConfig, + sourceConfig: storedConfig, + config: storedConfig, + runtimeConfig: storedConfig, + }); + + await updateFinalizeCommand({ json: true, restart: false }); + + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(doctorCommand).not.toHaveBeenCalled(); + expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); + expect(lastWriteJsonCall()).toMatchObject({ + status: "error", + mode: "git", + reason: "unsupported_git_channel", + }); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => { const preDoctorConfig = { update: { channel: "stable" }, @@ -6342,7 +6732,7 @@ describe("update-cli", () => { run: async () => await updateWizardCommand({}), requireTty: false, expectedError: - "Update wizard requires a TTY. Use `openclaw update --channel ` instead.", + "Update wizard requires a TTY. Use `openclaw update --channel ` instead.", }, ] as const)( "validates update command invocation errors: $name", @@ -6424,6 +6814,38 @@ describe("update-cli", () => { }); }); + it("updateWizardCommand hides extended-stable for git checkouts", async () => { + setTty(true); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/test/path", + installKind: "git", + packageManager: "pnpm", + git: { + root: "/test/path", + sha: "abcdef1234567890", + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + }, + deps: { + manager: "pnpm", + status: "ok", + lockfilePath: "/test/path/pnpm-lock.yaml", + markerPath: "/test/path/node_modules", + }, + }); + select.mockResolvedValue("cancel"); + + await updateWizardCommand({}); + + const options = (select.mock.calls[0]?.[0] as { options?: Array<{ value?: string }> })?.options; + expect(options?.map((option) => option.value)).not.toContain("extended-stable"); + }); + it("uses ~/openclaw as the default dev checkout directory", async () => { const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home"); try { diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 58d1c90e1a63..7fb130013545 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -43,7 +43,7 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden command .description("Repair post-update doctor and plugin convergence") .option("--json", "Output result as JSON", false) - .option("--channel ", "Persist update channel before repair") + .option("--channel ", "Persist update channel before repair") .option("--timeout ", "Timeout for update repair steps in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) .option("--no-restart", "Accepted for update command parity; repair never restarts") @@ -85,7 +85,7 @@ export function registerUpdateCli(program: Command) { .option("--json", "Output result as JSON", false) .option("--no-restart", "Skip restarting the gateway service after a successful update") .option("--dry-run", "Preview update actions without making changes", false) - .option("--channel ", "Persist update channel (git + npm)") + .option("--channel ", "Persist update channel (git + npm)") .option( "--tag ", "Override the package target for this update (dist-tag, version, or package spec)", @@ -95,6 +95,10 @@ export function registerUpdateCli(program: Command) { .addHelpText("after", () => { const examples = [ ["openclaw update", "Update a source checkout (git)"], + [ + "openclaw update --channel extended-stable", + "Switch to the monthly supported npm channel", + ], ["openclaw update --channel beta", "Switch to beta channel (git + npm)"], ["openclaw update --channel dev", "Switch to dev channel (git + npm)"], ["openclaw update --tag beta", "One-off update to a dist-tag or version"], @@ -116,7 +120,7 @@ ${theme.heading("What this does:")} - npm installs: updates via detected package manager ${theme.heading("Switch channels:")} - - Use --channel stable|beta|dev to persist the update channel in config + - Use --channel stable|extended-stable|beta|dev to persist the update channel in config - Run openclaw update status to see the active channel and source - Use --tag for a one-off package update without persisting - Use --tag main for a one-off package update from GitHub main @@ -130,7 +134,7 @@ ${theme.heading("Examples:")} ${fmtExamples} ${theme.heading("Notes:")} - - Switch channels with --channel stable|beta|dev + - Switch channels with --channel stable|extended-stable|beta|dev - For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md) - Downgrades require confirmation (can break configuration) - Skips update if the working directory has uncommitted changes @@ -189,7 +193,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up ["openclaw update status --json", "JSON output."], ["openclaw update status --timeout 10", "Custom timeout."], ])}\n\n${theme.heading("Notes:")}\n${theme.muted( - "- Shows current update channel (stable/beta/dev) and source", + "- Shows current update channel (stable/extended-stable/beta/dev) and source", )}\n${theme.muted("- Includes git tag/branch/SHA for source checkouts")}\n\n${theme.muted( "Docs:", )} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 2416e1693cc9..01241b7b5c0a 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -52,6 +52,7 @@ import { import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { pathExists } from "../../infra/fs-safe.js"; import { readJsonIfExists, writeJson } from "../../infra/json-files.js"; +import { parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { markPackagePostInstallDoctorAdvisory, runGlobalPackageUpdateSteps, @@ -63,13 +64,17 @@ import { channelToNpmTag, DEFAULT_GIT_CHANNEL, DEFAULT_PACKAGE_CHANNEL, + EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, normalizeUpdateChannel, + type UpdateChannel, } from "../../infra/update-channels.js"; import { compareSemverStrings, fetchNpmPackageTargetStatus, + resolveExtendedStablePackage, resolveNpmChannelTag, checkUpdateStatus, + type ExtendedStableFailureReason, } from "../../infra/update-check.js"; import { buildControlPlaneUpdateRestartHealthPendingResult, @@ -1198,9 +1203,9 @@ type UpdateDryRunPreview = { switchToGit: boolean; switchToPackage: boolean; restart: boolean; - requestedChannel: "stable" | "beta" | "dev" | null; - storedChannel: "stable" | "beta" | "dev" | null; - effectiveChannel: "stable" | "beta" | "dev"; + requestedChannel: UpdateChannel | null; + storedChannel: UpdateChannel | null; + effectiveChannel: UpdateChannel; tag: string; currentVersion: string | null; targetVersion: string | null; @@ -1248,6 +1253,30 @@ function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): vo } } +async function reportPreMutationUpdateFailure(params: { + root: string; + installKind: "git" | "package" | "unknown"; + reason: ExtendedStableFailureReason | typeof EXTENDED_STABLE_TAG_UNSUPPORTED_REASON; + opts: UpdateCommandOptions; + controlPlaneUpdateSentinelMeta: ControlPlaneUpdateSentinelMetaFile["meta"] | null; +}): Promise { + const result: UpdateRunResult = { + status: "error", + mode: params.installKind === "git" ? "git" : "unknown", + root: params.root, + reason: params.reason, + steps: [], + durationMs: 0, + }; + await writeControlPlaneUpdateRestartSentinelBestEffort({ + meta: params.controlPlaneUpdateSentinelMeta, + result, + jsonMode: Boolean(params.opts.json), + }); + printResult(result, params.opts); + defaultRuntime.exit(1); +} + async function refreshGatewayServiceEnv(params: { result: UpdateRunResult; jsonMode: boolean; @@ -1495,6 +1524,8 @@ async function runPackageInstallUpdate(params: { root: string; installKind: "git" | "package" | "unknown"; tag: string; + installSpec?: string; + registryUrl?: string; timeoutMs: number; startedAt: number; progress: ReturnType["progress"]; @@ -1509,7 +1540,9 @@ async function runPackageInstallUpdate(params: { installKind: params.installKind, timeoutMs: params.timeoutMs, }); - const installEnv = await createGlobalInstallEnv(); + const installEnv = await createGlobalInstallEnv(undefined, { + registryUrl: params.registryUrl, + }); const runCommand = createGlobalCommandRunner(); const installTarget = await resolveGlobalInstallTarget({ manager, @@ -1522,11 +1555,13 @@ async function runPackageInstallUpdate(params: { const packageName = (pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(params.root)) ?? DEFAULT_PACKAGE_NAME; - const installSpec = resolveGlobalInstallSpec({ - packageName, - tag: params.tag, - env: installEnv, - }); + const installSpec = + params.installSpec ?? + resolveGlobalInstallSpec({ + packageName, + tag: params.tag, + env: installEnv, + }); const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null; if (pkgRoot) { @@ -1637,7 +1672,7 @@ async function runGitUpdate(params: { timeoutMs: number | undefined; startedAt: number; progress: ReturnType["progress"]; - channel: "stable" | "beta" | "dev"; + channel: UpdateChannel; tag: string; showProgress: boolean; opts: UpdateCommandOptions; @@ -1731,7 +1766,7 @@ async function runGitUpdate(params: { export async function updatePluginsAfterCoreUpdate(params: { root: string; - channel: "stable" | "beta" | "dev"; + channel: UpdateChannel; configSnapshot: Awaited>; configChanged?: boolean; restoredAuthoredChannels?: unknown; @@ -1766,23 +1801,40 @@ export async function updatePluginsAfterCoreUpdate(params: { const warnings: PostUpdatePluginWarning[] = []; const pluginInstallRecords = params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); + const pluginUpdateChannel = params.channel; + const exactOfficialPluginVersion = + params.channel === "extended-stable" ? await readPackageVersion(params.root) : null; + if (params.channel === "extended-stable" && !exactOfficialPluginVersion) { + throw new Error("Extended-stable plugin convergence requires the installed core version."); + } const syncConfig = withPluginInstallRecords( params.configSnapshot.sourceConfig, pluginInstallRecords, ); const syncResult = await syncPluginsForUpdateChannel({ config: syncConfig, - channel: params.channel, + channel: pluginUpdateChannel, workspaceDir: params.root, externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({ workspaceDir: params.root, }), + ...(exactOfficialPluginVersion ? { exactOfficialPluginVersion } : {}), logger: pluginLogger, }); for (const error of syncResult.summary.errors) { warnings.push(createPostUpdatePluginWarning({ reason: error })); } let pluginConfig = syncResult.config; + const exactOfficialSpecOverrides: Record = {}; + if (exactOfficialPluginVersion) { + for (const [pluginId, record] of Object.entries(pluginConfig.plugins?.installs ?? {})) { + const officialSpec = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record }); + const parsed = officialSpec ? parseRegistryNpmSpec(officialSpec) : null; + if (parsed) { + exactOfficialSpecOverrides[pluginId] = `${parsed.name}@${exactOfficialPluginVersion}`; + } + } + } const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = []; const pluginUpdateOutcomes: PluginUpdateOutcome[] = []; let pluginsChanged = syncResult.changed || params.configChanged === true; @@ -1844,7 +1896,8 @@ export async function updatePluginsAfterCoreUpdate(params: { config: pluginConfig, pluginIds: missingIds, timeoutMs: params.timeoutMs, - updateChannel: params.channel, + updateChannel: pluginUpdateChannel, + specOverrides: exactOfficialSpecOverrides, skipDisabledPlugins: true, syncOfficialPluginInstalls: true, disableOnFailure: true, @@ -1863,8 +1916,13 @@ export async function updatePluginsAfterCoreUpdate(params: { const npmResult = await updateNpmInstalledPlugins({ config: pluginConfig, timeoutMs: params.timeoutMs, - updateChannel: params.channel, - skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]), + updateChannel: pluginUpdateChannel, + specOverrides: exactOfficialSpecOverrides, + skipIds: new Set([ + ...syncResult.summary.switchedToNpm, + ...(syncResult.summary.switchedToClawHub ?? []), + ...missingPayloadIds, + ]), skipDisabledPlugins: true, syncOfficialPluginInstalls: true, disableOnFailure: true, @@ -2378,7 +2436,7 @@ async function maybeRestartService(params: { async function runPostCorePluginUpdate(params: { root: string; - channel: "stable" | "beta" | "dev"; + channel: UpdateChannel; configSnapshot: Awaited>; configChanged?: boolean; restoredAuthoredChannels?: unknown; @@ -2402,7 +2460,7 @@ type UpdateFinalizeResult = { status: "ok" | "warning" | "error"; mode: "finalize"; root: string; - channel: "stable" | "beta" | "dev"; + channel: UpdateChannel; restart: false; postUpdate: { doctor: { @@ -2462,7 +2520,9 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis : undefined; const requestedChannel = normalizeUpdateChannel(opts.channel); if (opts.channel && !requestedChannel) { - defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`); + defaultRuntime.error( + `--channel must be "stable", "extended-stable", "beta", or "dev" (got "${opts.channel}")`, + ); defaultRuntime.exit(1); return; } @@ -2470,6 +2530,24 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis ? normalizeUpdateChannel(configSnapshot.config.update?.channel) : null; const channel = requestedChannel ?? storedChannel ?? DEFAULT_PACKAGE_CHANNEL; + if (channel === "extended-stable") { + const updateStatus = await checkUpdateStatus({ + root, + timeoutMs: timeoutMs ?? 3500, + fetchGit: false, + includeRegistry: false, + }); + if (updateStatus.installKind === "git") { + await reportPreMutationUpdateFailure({ + root, + installKind: updateStatus.installKind, + reason: "unsupported_git_channel", + opts, + controlPlaneUpdateSentinelMeta: null, + }); + return; + } + } if (requestedChannel) { configSnapshot = await persistRequestedUpdateChannel({ configSnapshot, @@ -2553,7 +2631,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis async function persistRequestedUpdateChannel(params: { configSnapshot: Awaited>; - requestedChannel: "stable" | "beta" | "dev" | null; + requestedChannel: UpdateChannel | null; }): Promise>> { if (!params.requestedChannel || !params.configSnapshot.valid) { return params.configSnapshot; @@ -2908,8 +2986,8 @@ function preparePostCorePluginInstallRecordsForFreshProcess(params: { async function continuePostCoreUpdateInFreshProcess(params: { root: string; - channel: "stable" | "beta" | "dev"; - requestedChannel: "stable" | "beta" | "dev" | null; + channel: UpdateChannel; + requestedChannel: UpdateChannel | null; opts: UpdateCommandOptions; pluginInstallRecords: Record; preUpdateConfig?: PreUpdateConfigRestoreInput; @@ -3147,8 +3225,12 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise return; } if (opts.dryRun !== true) { - await disableCurrentOpenClawUpdateLaunchdJob().catch(() => undefined); - assertConfigWriteAllowedInCurrentMode(); + try { + assertConfigWriteAllowedInCurrentMode(); + } catch (err) { + await disableCurrentOpenClawUpdateLaunchdJob().catch(() => undefined); + throw err; + } } const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; @@ -3156,6 +3238,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise if (postCoreUpdateResume) { if ( postCoreUpdateChannel !== "stable" && + postCoreUpdateChannel !== "extended-stable" && postCoreUpdateChannel !== "beta" && postCoreUpdateChannel !== "dev" ) { @@ -3245,11 +3328,24 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise const requestedChannel = normalizeUpdateChannel(opts.channel); if (opts.channel && !requestedChannel) { - defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`); + defaultRuntime.error( + `--channel must be "stable", "extended-stable", "beta", or "dev" (got "${opts.channel}")`, + ); defaultRuntime.exit(1); return; } + if (requestedChannel === "extended-stable" && updateStatus.installKind === "git") { + await reportPreMutationUpdateFailure({ + root, + installKind: updateStatus.installKind, + reason: "unsupported_git_channel", + opts, + controlPlaneUpdateSentinelMeta, + }); + return; + } + let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); if (opts.channel && !opts.dryRun && !configSnapshot.valid) { configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({ @@ -3269,6 +3365,20 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise } const installKind = updateStatus.installKind; + const selectedChannel = + requestedChannel ?? + storedChannel ?? + (installKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL); + if (selectedChannel === "extended-stable" && installKind === "git") { + await reportPreMutationUpdateFailure({ + root, + installKind, + reason: "unsupported_git_channel", + opts, + controlPlaneUpdateSentinelMeta, + }); + return; + } const switchToGit = requestedChannel === "dev" && installKind !== "git"; const switchToPackage = requestedChannel !== null && requestedChannel !== "dev" && installKind === "git"; @@ -3280,12 +3390,24 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined; const explicitTag = normalizeTag(opts.tag); + if (channel === "extended-stable" && explicitTag) { + await reportPreMutationUpdateFailure({ + root, + installKind: updateInstallKind, + reason: EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, + opts, + controlPlaneUpdateSentinelMeta, + }); + return; + } let tag = explicitTag ?? channelToNpmTag(channel); let currentVersion: string | null = null; let targetVersion: string | null = null; let downgradeRisk = false; let fallbackToLatest = false; let packageInstallSpec: string | null = null; + let packageInstallRegistryUrl: string | null = null; + let installedPackageName = DEFAULT_PACKAGE_NAME; let packageAlreadyCurrent = false; let managedServiceRootRedirect: ManagedServiceRootRedirect | null = null; // Resolved independently of the root redirect so it covers the common case @@ -3340,8 +3462,31 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise } if (updateInstallKind !== "git") { + if (updateInstallKind === "package") { + installedPackageName = (await readPackageName(root)) ?? DEFAULT_PACKAGE_NAME; + } currentVersion = switchToPackage ? null : await readPackageVersion(root); - if (explicitTag) { + if (channel === "extended-stable") { + const extendedStable = await resolveExtendedStablePackage({ + installKind: updateInstallKind, + timeoutMs, + packageName: installedPackageName, + }); + if (extendedStable.status === "failed") { + await reportPreMutationUpdateFailure({ + root, + installKind: updateInstallKind, + reason: extendedStable.reason, + opts, + controlPlaneUpdateSentinelMeta, + }); + return; + } + targetVersion = extendedStable.version; + tag = extendedStable.version; + packageInstallSpec = extendedStable.packageSpec; + packageInstallRegistryUrl = extendedStable.registryUrl; + } else if (explicitTag) { targetVersion = await resolveTargetVersion(tag, timeoutMs); } else { targetVersion = await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => { @@ -3364,7 +3509,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise !fallbackToLatest && currentVersion != null && (targetVersion == null ? tag !== "latest" : cmp != null && cmp > 0); - packageInstallSpec = resolveGlobalInstallSpec({ + packageInstallSpec ??= resolveGlobalInstallSpec({ packageName: DEFAULT_PACKAGE_NAME, tag, env: process.env, @@ -3495,6 +3640,8 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise } } + await disableCurrentOpenClawUpdateLaunchdJob().catch(() => undefined); + const showProgress = !opts.json && process.stdout.isTTY; if (!opts.json) { defaultRuntime.log(theme.heading("Updating OpenClaw...")); @@ -3582,6 +3729,8 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise root, installKind, tag, + installSpec: packageInstallSpec ?? undefined, + registryUrl: packageInstallRegistryUrl ?? undefined, timeoutMs: updateStepTimeoutMs, startedAt, progress, diff --git a/src/cli/update-cli/wizard.ts b/src/cli/update-cli/wizard.ts index 5470239170dd..626dc855a684 100644 --- a/src/cli/update-cli/wizard.ts +++ b/src/cli/update-cli/wizard.ts @@ -27,7 +27,7 @@ import { updateCommand } from "./update-command.js"; export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise { if (!process.stdin.isTTY) { defaultRuntime.error( - "Update wizard requires a TTY. Use `openclaw update --channel ` instead.", + "Update wizard requires a TTY. Use `openclaw update --channel ` instead.", ); defaultRuntime.exit(1); return; @@ -79,6 +79,15 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi label: "Stable", hint: "Tagged releases (npm latest)", }, + ...(updateStatus.installKind === "git" + ? [] + : [ + { + value: "extended-stable" as const, + label: "Extended Stable", + hint: "Monthly supported release (npm extended-stable)", + }, + ]), { value: "beta", label: "Beta", diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index f3040e37fd03..8c61dd8ddcc7 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -2112,6 +2112,36 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); expect(result).toEqual({ changes: [], warnings: [], records }); + + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "codex", + targetDir: installDir, + version: VERSION, + npmResolution: { + name: "@openclaw/codex", + version: VERSION, + resolvedSpec: `@openclaw/codex@${VERSION}`, + }, + }); + await repairMissingConfiguredPluginInstalls({ + cfg: { + update: { channel: "extended-stable" }, + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + }, + env: {}, + }); + + expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), { + spec: `@openclaw/codex@${VERSION}`, + expectedPluginId: "codex", + trustedSourceLinkedOfficialInstall: true, + mode: "update", + }); }); it.each([ diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 762d634cfef1..b423ed6f6a71 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -591,6 +591,9 @@ function installedRuntimePackageVersionIsStale(params: { ) { return false; } + if (params.updateChannel === "extended-stable") { + return params.installedVersion !== params.currentVersion; + } const comparison = compareOpenClawReleaseVersions(params.installedVersion, params.currentVersion); return comparison === null ? params.installedVersion !== params.currentVersion : comparison < 0; } @@ -918,19 +921,29 @@ async function installCandidate(params: { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(params.env); const changes: string[] = []; - const clawhubSpecs = candidate.clawhubSpec - ? resolveClawHubInstallSpecsForUpdateChannel({ - spec: candidate.clawhubSpec, - updateChannel: params.updateChannel, - }) - : null; + const exactOfficialPluginVersion = + params.updateChannel === "extended-stable" && + candidate.trustedSourceLinkedOfficialInstall && + candidate.npmSpec + ? VERSION + : undefined; + const clawhubSpecs = + candidate.clawhubSpec && !exactOfficialPluginVersion + ? resolveClawHubInstallSpecsForUpdateChannel({ + spec: candidate.clawhubSpec, + updateChannel: params.updateChannel, + }) + : null; const npmSpecs = candidate.npmSpec ? resolveNpmInstallSpecsForUpdateChannel({ spec: candidate.npmSpec, updateChannel: params.updateChannel, + exactVersion: exactOfficialPluginVersion, }) : null; - const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec; + const clawhubInstallSpec = exactOfficialPluginVersion + ? undefined + : (clawhubSpecs?.installSpec ?? candidate.clawhubSpec); const npmInstallSpec = npmSpecs?.installSpec ?? candidate.npmSpec; const npmDir = resolveDefaultPluginNpmDir(params.env); const existingClawHubPackagePath = clawhubInstallSpec diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 418895dbcfdc..1454bcbd1273 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -593,6 +593,82 @@ describe("ensureOnboardingPluginInstalled", () => { expect(refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled(); }); + it("pins official onboarding installs to the extended-stable core version", async () => { + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: VERSION, + npmResolution: { + name: "@openclaw/demo-plugin", + version: VERSION, + resolvedSpec: `@openclaw/demo-plugin@${VERSION}`, + }, + }); + + await ensureOnboardingPluginInstalled({ + cfg: { update: { channel: "extended-stable" } }, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + clawhubSpec: "clawhub:@openclaw/demo-plugin", + npmSpec: "@openclaw/demo-plugin", + defaultChoice: "clawhub", + }, + trustedSourceLinkedOfficialInstall: true, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: {} as never, + promptInstall: false, + }); + + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + const [npmCall] = readFirstMockCall(installPluginFromNpmSpec, "installPluginFromNpmSpec") as [ + NpmSpecInstallCall, + ]; + expect(npmCall.spec).toBe(`@openclaw/demo-plugin@${VERSION}`); + expect(npmCall.trustedSourceLinkedOfficialInstall).toBe(true); + }); + + it("preserves third-party onboarding specs on extended-stable", async () => { + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "vendor-plugin", + targetDir: "/tmp/vendor-plugin", + version: "9.1.0", + npmResolution: { + name: "@vendor/plugin", + version: "9.1.0", + resolvedSpec: "@vendor/plugin@9.1.0", + }, + }); + + await ensureOnboardingPluginInstalled({ + cfg: { update: { channel: "extended-stable" } }, + entry: { + pluginId: "vendor-plugin", + label: "Vendor Plugin", + install: { npmSpec: "@vendor/plugin@latest" }, + }, + prompter: { + select: vi.fn(async () => "npm"), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: {} as never, + promptInstall: false, + }); + + const [npmCall] = readFirstMockCall(installPluginFromNpmSpec, "installPluginFromNpmSpec") as [ + NpmSpecInstallCall, + ]; + expect(npmCall.spec).toBe("@vendor/plugin@latest"); + expect(npmCall.trustedSourceLinkedOfficialInstall).toBeUndefined(); + }); + it("logs npm install warnings once while shortening the progress label", async () => { const warning = "npm rejected managed npm alias overrides; retrying plugin install without alias overrides for this npm version."; diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 91a358044fec..c3da64cf25d9 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -1031,26 +1031,34 @@ export async function ensureOnboardingPluginInstalled(params: { configChannel: normalizeUpdateChannel(next.update?.channel), currentVersion: VERSION, }); - const clawhubSpecs = clawhubSpec - ? resolveClawHubInstallSpecsForUpdateChannel({ - spec: clawhubSpec, - updateChannel, - }) - : null; + const exactOfficialPluginVersion = + updateChannel === "extended-stable" && entry.trustedSourceLinkedOfficialInstall && npmSpec + ? VERSION + : undefined; + const clawhubSpecs = + clawhubSpec && !exactOfficialPluginVersion + ? resolveClawHubInstallSpecsForUpdateChannel({ + spec: clawhubSpec, + updateChannel, + }) + : null; const npmSpecs = npmSpec ? resolveNpmInstallSpecsForUpdateChannel({ spec: npmSpec, updateChannel, + exactVersion: exactOfficialPluginVersion, }) : null; - const clawhubInstallSpec = clawhubSpecs?.installSpec ?? clawhubSpec; + const clawhubInstallSpec = exactOfficialPluginVersion + ? undefined + : (clawhubSpecs?.installSpec ?? clawhubSpec); const npmInstallSpec = npmSpecs?.installSpec ?? npmSpec; const defaultChoice = resolveInstallDefaultChoice({ cfg: next, entry, localPath, bundledLocalPath, - hasClawHubSpec: Boolean(clawhubSpec), + hasClawHubSpec: Boolean(clawhubInstallSpec), hasNpmSpec: Boolean(npmSpec), }); const choice = diff --git a/src/commands/status.update.test.ts b/src/commands/status.update.test.ts index ae03576a73bb..bf876ec070f0 100644 --- a/src/commands/status.update.test.ts +++ b/src/commands/status.update.test.ts @@ -159,6 +159,31 @@ describe("formatUpdateOneLiner", () => { ); }); + it("renders an installed version newer than extended-stable as ahead", () => { + const update = buildUpdate({ + installKind: "package", + packageManager: "npm", + registry: { latestVersion: "1.0.0", tag: "extended-stable" }, + }); + + expect(formatUpdateOneLiner(update)).toBe("Update: npm · ahead of extended-stable (1.0.0)"); + }); + + it("renders structured extended-stable resolver failures", () => { + const update = buildUpdate({ + installKind: "git", + packageManager: "pnpm", + registry: { + latestVersion: null, + tag: "extended-stable", + error: "unsupported_git_channel", + reason: "unsupported_git_channel", + }, + }); + + expect(formatUpdateOneLiner(update)).toContain("extended-stable requires a package install"); + }); + it("renders package-manager mode with registry error", () => { const update = buildUpdate({ installKind: "package", diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts index dd0eb5fe43dc..37121a4f147a 100644 --- a/src/commands/status.update.ts +++ b/src/commands/status.update.ts @@ -106,11 +106,31 @@ export function formatUpdateOneLiner(update: UpdateCheckResult): string { : `npm update ${update.registry.latestVersion}`, ); } else { - parts.push(`${registryLabel} ${update.registry.latestVersion} (local newer)`); + parts.push( + update.registry.tag === "extended-stable" + ? `ahead of extended-stable (${update.registry.latestVersion})` + : `${registryLabel} ${update.registry.latestVersion} (local newer)`, + ); } return; } if (update.registry?.error) { + if (update.registry.reason === "unsupported_git_channel") { + parts.push("extended-stable requires a package install"); + return; + } + if (update.registry.reason === "selector_missing") { + parts.push("npm extended-stable selector missing"); + return; + } + if (update.registry.reason === "selector_query_failed") { + parts.push("npm extended-stable query failed"); + return; + } + if (update.registry.reason === "exact_package_mismatch") { + parts.push("npm extended-stable exact package verification failed"); + return; + } parts.push(`${registryLabel} unknown`); } }; diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 0ef21e591fcd..bc8e013b777e 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -471,7 +471,7 @@ const ENUM_EXPECTATIONS: Record = { "logging.consoleStyle": ['"pretty"', '"compact"', '"json"'], "logging.redactSensitive": ['"off"', '"tools"'], "cli.banner.taglineMode": ['"random"', '"default"', '"off"'], - "update.channel": ['"stable"', '"beta"', '"dev"'], + "update.channel": ['"stable"', '"extended-stable"', '"beta"', '"dev"'], "agents.defaults.compaction.mode": ['"default"', '"safeguard"'], "agents.defaults.compaction.identifierPolicy": ['"strict"', '"off"', '"custom"'], }; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8b576b066c2a..7d02f2fa7884 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -56,9 +56,12 @@ export const FIELD_HELP: Record = { 'Controls tagline style in the CLI startup banner: "random" (default) picks from the rotating tagline pool, "default" always shows the neutral default tagline, and "off" hides tagline text while keeping the banner version line.', update: "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", - "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', - "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", - "update.auto.enabled": "Enable background auto-update for package installs (default: false).", + "update.channel": + 'Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). Extended-stable is package-only: installation is foreground-only, with optional read-only startup hints.', + "update.checkOnStart": + "Check for npm updates when the gateway starts, including read-only extended-stable hints (default: true).", + "update.auto.enabled": + "Enable background auto-update for stable and beta package installs; extended-stable never auto-applies (default: false).", "update.auto.stableDelayHours": "Minimum delay before stable-channel auto-apply starts (default: 6).", "update.auto.stableJitterHours": diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 11fbca679c46..8fd80ce201a0 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -138,8 +138,8 @@ export type OpenClawConfig = { /** Crestodian rescue/maintenance integration settings. */ crestodian?: CrestodianConfig; update?: { - /** Update channel for git + npm installs ("stable", "beta", or "dev"). */ - channel?: "stable" | "beta" | "dev"; + /** Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). */ + channel?: "stable" | "extended-stable" | "beta" | "dev"; /** Check for updates on gateway start (npm installs only). */ checkOnStart?: boolean; /** Core auto-update policy for package installs. */ diff --git a/src/config/validation.allowed-values.test.ts b/src/config/validation.allowed-values.test.ts index cf21e48fd926..da70fdbfe11b 100644 --- a/src/config/validation.allowed-values.test.ts +++ b/src/config/validation.allowed-values.test.ts @@ -28,6 +28,14 @@ function mapFirstIssue( } describe("config validation allowed-values metadata", () => { + it("accepts extended-stable as an additive update channel", () => { + expect( + validateConfigObjectRaw({ + update: { channel: "extended-stable" }, + }), + ).toMatchObject({ ok: true }); + }); + it("adds allowed values for invalid union paths", () => { const result = validateConfigObjectRaw({ update: { channel: "nightly" }, @@ -36,8 +44,8 @@ describe("config validation allowed-values metadata", () => { expect(result.ok).toBe(false); if (!result.ok) { const issue = requireIssue(result.issues, "update.channel"); - expect(issue.message).toContain('(allowed: "stable", "beta", "dev")'); - expect(issue.allowedValues).toEqual(["stable", "beta", "dev"]); + expect(issue.message).toContain('(allowed: "stable", "extended-stable", "beta", "dev")'); + expect(issue.allowedValues).toEqual(["stable", "extended-stable", "beta", "dev"]); expect(issue.allowedValuesHiddenCount).toBe(0); } }); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 16116b2fd67e..4168332bb5aa 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -611,7 +611,14 @@ export const OpenClawSchema = z crestodian: CrestodianSchema, update: z .object({ - channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(), + channel: z + .union([ + z.literal("stable"), + z.literal("extended-stable"), + z.literal("beta"), + z.literal("dev"), + ]) + .optional(), checkOnStart: z.boolean().optional(), auto: z .object({ diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 06029414f9a3..86f37f2520ce 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; +import type { UpdateChannel } from "../../infra/update-channels.js"; import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js"; // Capture the sentinel payload written during update.run @@ -23,7 +24,7 @@ const recordLatestUpdateRestartSentinelMock = vi.fn(); const isRestartEnabledMock = vi.fn(() => true); const readPackageVersionMock = vi.fn(async () => "1.0.0"); const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null); -const normalizeUpdateChannelMock = vi.fn((): "stable" | "beta" | "dev" | null => null); +const normalizeUpdateChannelMock = vi.fn((): UpdateChannel | null => null); const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({ status: "started" as const, pid: 12345, @@ -132,13 +133,16 @@ vi.mock("./restart-request.js", () => ({ vi.mock("../../infra/update-managed-service-handoff.js", () => ({ startManagedServiceUpdateHandoff: startManagedServiceUpdateHandoffMock, - formatManagedServiceUpdateCommand: (params?: { - timeoutMs?: number; - channel?: "stable" | "beta" | "dev"; - }) => - params?.timeoutMs - ? `openclaw update --yes --timeout ${Math.ceil(params.timeoutMs / 1000)}` - : "openclaw update --yes", + formatManagedServiceUpdateCommand: (params?: { timeoutMs?: number; channel?: UpdateChannel }) => { + const args = ["openclaw", "update", "--yes"]; + if (params?.channel) { + args.push("--channel", params.channel); + } + if (params?.timeoutMs) { + args.push("--timeout", String(Math.ceil(params.timeoutMs / 1000))); + } + return args.join(" "); + }, buildManagedServiceHandoffUnavailableMessage: (command: string) => [ "OpenClaw updates cannot safely run inside the live gateway process without a managed-service handoff.", @@ -555,6 +559,40 @@ describe("update.run restart scheduling", () => { expect(payload?.handoff?.command).not.toContain("--channel"); }); + it("rejects stored extended-stable on Git without starting a handoff or mutation", async () => { + normalizeUpdateChannelMock.mockReturnValueOnce("extended-stable"); + detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); + mockGitInstallSurface("/tmp/openclaw-git"); + + const payload = await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => + captureUpdateRunPayload(), + ); + + expect(runGatewayUpdateMock).not.toHaveBeenCalled(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(payload?.ok).toBe(false); + expect(payload?.result).toMatchObject({ + status: "error", + mode: "git", + reason: "unsupported_git_channel", + }); + }); + + it("forwards stored extended-stable to package managed-service handoff", async () => { + normalizeUpdateChannelMock.mockReturnValueOnce("extended-stable"); + detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); + mockGlobalInstallSurface(); + + await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => + captureUpdateRunPayload(), + ); + + expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "extended-stable" }), + ); + }); + it("keeps unsupervised git/dev updates on the in-process gateway update path", async () => { runGatewayUpdateMock.mockResolvedValueOnce({ status: "ok", diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index b9efc1b8daa1..64ace61da4a6 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -173,7 +173,16 @@ export const updateHandlers: GatewayRequestHandlers = { : false; const requiresManagedServiceHandoff = installSurface.kind === "global" || (installSurface.kind === "git" && supervisor !== null); - if (!isRestartEnabled(config) && !supervisor) { + if (configChannel === "extended-stable" && installSurface.kind === "git") { + result = { + status: "error", + mode: "git", + root: installSurface.root, + reason: "unsupported_git_channel", + steps: [], + durationMs: 0, + }; + } else if (!isRestartEnabled(config) && !supervisor) { // Package updates need a restart path to finish safely. Dev/git installs // can report the disabled restart directly, but global installs must not // mutate files if this process cannot come back. diff --git a/src/infra/clawhub-retry.test.ts b/src/infra/clawhub-retry.test.ts new file mode 100644 index 000000000000..79351987c113 --- /dev/null +++ b/src/infra/clawhub-retry.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import { retryClawHubRead } from "./clawhub-retry.js"; + +describe("retryClawHubRead", () => { + it("honors Retry-After and cancels the discarded response", async () => { + const cancel = vi.fn(); + const delays: number[] = []; + let attempts = 0; + + const result = await retryClawHubRead( + async () => { + attempts += 1; + if (attempts === 1) { + return { + response: new Response( + new ReadableStream({ + cancel() { + cancel(); + }, + }), + { + status: 503, + headers: { "Retry-After": "1" }, + }, + ), + }; + } + return { response: new Response("ok") }; + }, + { + disposeRetry: async ({ response }) => { + await response.body?.cancel(); + }, + sleep: async (ms) => { + delays.push(ms); + }, + }, + ); + + expect(await result.response.text()).toBe("ok"); + expect(attempts).toBe(2); + expect(delays).toEqual([1_000]); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("retries transport failures with the bounded schedule", async () => { + const delays: number[] = []; + let attempts = 0; + + const result = await retryClawHubRead( + async () => { + attempts += 1; + if (attempts === 1) { + throw new TypeError("fetch failed"); + } + return { response: new Response("ok") }; + }, + { + disposeRetry: async () => {}, + sleep: async (ms) => { + delays.push(ms); + }, + }, + ); + + expect(await result.response.text()).toBe("ok"); + expect(attempts).toBe(2); + expect(delays).toEqual([1_000]); + }); + + it.each([ + { name: "fractional seconds", retryAfter: "0.5", expectedDelayMs: 1_000 }, + { + name: "normalized invalid HTTP date", + retryAfter: "Sun, 31 Feb 2027 00:00:00 GMT", + expectedDelayMs: 1_000, + }, + ])("uses the bounded fallback for $name", async ({ retryAfter, expectedDelayMs }) => { + const delays: number[] = []; + let attempts = 0; + await retryClawHubRead( + async () => ({ + response: new Response(attempts++ === 0 ? "retry" : "ok", { + status: attempts === 1 ? 503 : 200, + headers: attempts === 1 ? { "Retry-After": retryAfter } : undefined, + }), + }), + { + disposeRetry: async ({ response }) => { + await response.body?.cancel(); + }, + sleep: async (ms) => { + delays.push(ms); + }, + }, + ); + + expect(delays).toEqual([expectedDelayMs]); + }); + + it("honors a strict IMF-fixdate within the retry cap", async () => { + const now = Date.UTC(2027, 0, 1, 0, 0, 0); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(now); + const delays: number[] = []; + let attempts = 0; + try { + await retryClawHubRead( + async () => ({ + response: new Response(attempts++ === 0 ? "retry" : "ok", { + status: attempts === 1 ? 503 : 200, + headers: + attempts === 1 ? { "Retry-After": new Date(now + 5_000).toUTCString() } : undefined, + }), + }), + { + disposeRetry: async ({ response }) => { + await response.body?.cancel(); + }, + sleep: async (ms) => { + delays.push(ms); + }, + }, + ); + } finally { + dateNow.mockRestore(); + } + + expect(delays).toEqual([5_000]); + }); + + it("does not retry 429 unless the caller enables rate-limit retries", async () => { + let defaultAttempts = 0; + const defaultResult = await retryClawHubRead( + async () => { + defaultAttempts += 1; + return { response: new Response("limited", { status: 429 }) }; + }, + { + disposeRetry: async () => {}, + sleep: async () => {}, + }, + ); + + let optedInAttempts = 0; + const optedInResult = await retryClawHubRead( + async () => { + optedInAttempts += 1; + return { + response: new Response(optedInAttempts === 1 ? "limited" : "ok", { + status: optedInAttempts === 1 ? 429 : 200, + }), + }; + }, + { + disposeRetry: async ({ response }) => { + await response.body?.cancel(); + }, + retryRateLimit: true, + sleep: async () => {}, + }, + ); + + expect(defaultResult.response.status).toBe(429); + expect(defaultAttempts).toBe(1); + expect(await optedInResult.response.text()).toBe("ok"); + expect(optedInAttempts).toBe(2); + }); +}); diff --git a/src/infra/clawhub-retry.ts b/src/infra/clawhub-retry.ts new file mode 100644 index 000000000000..b11d6fcbd854 --- /dev/null +++ b/src/infra/clawhub-retry.ts @@ -0,0 +1,92 @@ +// Defines the bounded retry contract shared by ClawHub runtime and release reads. +const CLAWHUB_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; +const CLAWHUB_MAX_RETRY_AFTER_MS = 60_000; + +type ClawHubResponseHandle = { + response: Response; +}; + +type ClawHubRetryOptions = { + disposeRetry: (result: T) => Promise; + retryRateLimit?: boolean; + sleep?: (ms: number) => Promise; +}; + +function isRetryableClawHubStatus(status: number, retryRateLimit: boolean): boolean { + return (retryRateLimit && status === 429) || status === 502 || status === 503 || status === 504; +} + +function parseRetryAfterMs(headers: Headers): number | undefined { + const retryAfter = headers.get("retry-after")?.trim(); + if (!retryAfter) { + return undefined; + } + if (/^(?:0|[1-9][0-9]*)$/u.test(retryAfter)) { + const seconds = Number(retryAfter); + if (!Number.isSafeInteger(seconds)) { + return undefined; + } + const delayMs = seconds * 1_000; + return delayMs <= CLAWHUB_MAX_RETRY_AFTER_MS ? delayMs : undefined; + } + if ( + !/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), [0-9]{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT$/u.test( + retryAfter, + ) + ) { + return undefined; + } + const retryAt = Date.parse(retryAfter); + if (!Number.isFinite(retryAt) || new Date(retryAt).toUTCString() !== retryAfter) { + return undefined; + } + const delayMs = Math.max(0, retryAt - Date.now()); + return delayMs <= CLAWHUB_MAX_RETRY_AFTER_MS ? delayMs : undefined; +} + +function retryDelayMs(response: Response | undefined, attempt: number): number { + return ( + (response ? parseRetryAfterMs(response.headers) : undefined) ?? + CLAWHUB_RETRY_DELAYS_MS[attempt] ?? + 0 + ); +} + +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Retries idempotent ClawHub reads on transient HTTP and transport failures. + * Callers retain the final response so their existing body limits and errors apply. + */ +export async function retryClawHubRead( + request: () => Promise, + options: ClawHubRetryOptions, +): Promise { + for (let attempt = 0; ; attempt += 1) { + let result: T; + try { + result = await request(); + } catch (error) { + if (attempt >= CLAWHUB_RETRY_DELAYS_MS.length) { + throw error; + } + await (options.sleep ?? defaultSleep)(retryDelayMs(undefined, attempt)); + continue; + } + + if ( + !isRetryableClawHubStatus(result.response.status, options.retryRateLimit === true) || + attempt >= CLAWHUB_RETRY_DELAYS_MS.length + ) { + return result; + } + + const delayMs = retryDelayMs(result.response, attempt); + await options.disposeRetry(result); + await (options.sleep ?? defaultSleep)(delayMs); + } +} diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 58a8f1360779..d9661afdf422 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -735,6 +735,72 @@ describe("clawhub helpers", () => { ).rejects.toThrow(/Rate limit exceeded Sign in for higher rate limits\.$/); }); + it("retries transient ClawHub reads and honors Retry-After", async () => { + const cancel = vi.fn(); + let attempts = 0; + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => { + attempts += 1; + if (attempts === 1) { + return new Response( + new ReadableStream({ + cancel() { + cancel(); + }, + }), + { + status: 503, + headers: { "Retry-After": "0" }, + }, + ); + } + return new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }), + ).resolves.toStrictEqual([]); + + expect(attempts).toBe(2); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("preserves the final ClawHub error body after transient retries are exhausted", async () => { + let attempts = 0; + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async () => { + attempts += 1; + return new Response("Rate limit temporarily unavailable", { + status: 503, + headers: { "Retry-After": "0" }, + }); + }, + }), + ).rejects.toThrow("ClawHub /api/v1/search failed (503): Rate limit temporarily unavailable"); + + expect(attempts).toBe(4); + }); + + it("does not retry non-idempotent ClawHub requests", async () => { + let attempts = 0; + await expect( + fetchClawHubSkillSecurityVerdicts({ + items: [], + skipAuth: true, + fetchImpl: async () => { + attempts += 1; + return new Response("temporarily unavailable", { status: 503 }); + }, + }), + ).rejects.toThrow("ClawHub /api/v1/skills/-/security-verdicts failed (503)"); + expect(attempts).toBe(1); + }); + it("wraps malformed successful ClawHub JSON responses", async () => { await expect( searchClawHubSkills({ diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 55b6479ca646..5b2060dde5bf 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -9,6 +9,7 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { retryClawHubRead } from "./clawhub-retry.js"; import { parseStrictPositiveInteger } from "./parse-finite-number.js"; import { isAtLeast, parseSemver } from "./runtime-guard.js"; import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; @@ -705,17 +706,13 @@ async function clawhubRequest( const token = params.skipAuth ? undefined : normalizeOptionalString(params.token) || (await resolveClawHubAuthToken()); - const controller = new AbortController(); - const timeout = setTimeout( - () => - controller.abort( - new Error( - `ClawHub request timed out after ${params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS}ms`, - ), - ), - params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, - ); - try { + const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + const request = async () => { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new Error(`ClawHub request timed out after ${timeoutMs}ms`)), + timeoutMs, + ); const headers = { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(params.json === undefined ? {} : { "Content-Type": "application/json" }), @@ -730,11 +727,23 @@ async function clawhubRequest( if (params.json !== undefined) { init.body = JSON.stringify(params.json); } - const response = await (params.fetchImpl ?? fetch)(url, init); - return { response, url, hasToken: Boolean(token) }; - } finally { - clearTimeout(timeout); + try { + const response = await (params.fetchImpl ?? fetch)(url, init); + return { response, url, hasToken: Boolean(token) }; + } finally { + clearTimeout(timeout); + } + }; + + // Writes can commit before a response fails, so only retry idempotent reads. + if ((params.method ?? "GET") !== "GET") { + return await request(); } + return await retryClawHubRead(request, { + disposeRetry: async ({ response }) => { + await response.body?.cancel().catch(() => undefined); + }, + }); } async function readErrorBody(response: Response): Promise { diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts index 557f15eb9d7e..0d09c37549e2 100644 --- a/src/infra/update-channels.test.ts +++ b/src/infra/update-channels.test.ts @@ -52,9 +52,11 @@ describe("update-channels tag detection", () => { describe("normalizeUpdateChannel", () => { it.each([ { value: "stable", expected: "stable" }, + { value: " extended-stable ", expected: "extended-stable" }, { value: " BETA ", expected: "beta" }, { value: "Dev", expected: "dev" }, { value: "", expected: null }, + { value: "daily", expected: null }, { value: " nightly ", expected: null }, { value: null, expected: null }, { value: undefined, expected: null }, @@ -69,6 +71,7 @@ describe("normalizeUpdateChannel", () => { describe("channelToNpmTag", () => { it.each([ { channel: "stable", expected: "latest" }, + { channel: "extended-stable", expected: "extended-stable" }, { channel: "beta", expected: "beta" }, { channel: "dev", expected: "dev" }, ] satisfies Array<{ channel: UpdateChannel; expected: string }>)( @@ -99,6 +102,15 @@ describe("resolveEffectiveUpdateChannel", () => { }, expected: { channel: "beta", source: "installed-version" }, }, + { + name: "keeps explicit extended-stable config on an installed beta version", + params: { + configChannel: "extended-stable", + currentVersion: "2026.5.2-beta.1", + installKind: "package" as const, + }, + expected: { channel: "extended-stable", source: "config" }, + }, { name: "uses beta git tag", params: { @@ -284,4 +296,13 @@ describe("resolveRegistryUpdateChannel", () => { }), ).toBe("beta"); }); + + it("keeps explicit extended-stable config on an installed beta version", () => { + expect( + resolveRegistryUpdateChannel({ + configChannel: "extended-stable", + currentVersion: "2026.5.2-beta.1", + }), + ).toBe("extended-stable"); + }); }); diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index ce18d65bb706..b7f63be9a0d9 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -3,7 +3,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { parseComparableSemver } from "./semver-compare.js"; /** Release stream used to choose registry tags and update policy defaults. */ -export type UpdateChannel = "stable" | "beta" | "dev"; +export type UpdateChannel = "stable" | "extended-stable" | "beta" | "dev"; /** Evidence source that decided the effective update channel. */ export type UpdateChannelSource = | "config" @@ -16,6 +16,8 @@ export type UpdateChannelSource = export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; /** Default channel for source installs where branch metadata is unavailable. */ export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev"; +/** Machine-readable validation failure when a tag override conflicts with the exact extended-stable contract. */ +export const EXTENDED_STABLE_TAG_UNSUPPORTED_REASON = "extended-stable-tag-unsupported"; /** Git branch that represents the development update stream. */ export const DEV_BRANCH = "main"; @@ -25,7 +27,12 @@ export function normalizeUpdateChannel(value?: string | null): UpdateChannel | n if (!normalized) { return null; } - if (normalized === "stable" || normalized === "beta" || normalized === "dev") { + if ( + normalized === "stable" || + normalized === "extended-stable" || + normalized === "beta" || + normalized === "dev" + ) { return normalized; } return null; @@ -33,6 +40,9 @@ export function normalizeUpdateChannel(value?: string | null): UpdateChannel | n /** Maps an OpenClaw update channel to the npm dist-tag used for package lookups. */ export function channelToNpmTag(channel: UpdateChannel): string { + if (channel === "extended-stable") { + return "extended-stable"; + } if (channel === "beta") { return "beta"; } @@ -71,6 +81,7 @@ export function resolveRegistryUpdateChannel(params: { if ( params.currentVersion && isBetaTag(params.currentVersion) && + params.configChannel !== "extended-stable" && params.configChannel !== "beta" && params.configChannel !== "dev" ) { @@ -89,6 +100,7 @@ export function resolveEffectiveUpdateChannel(params: { if ( params.currentVersion && isBetaTag(params.currentVersion) && + params.configChannel !== "extended-stable" && params.configChannel !== "beta" && params.configChannel !== "dev" ) { diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index b996b09b1fe2..8f793c60fc44 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -13,6 +13,7 @@ import { fetchNpmRegistryVersionForChannel, fetchNpmTagVersion, formatGitInstallLabel, + resolveExtendedStablePackage, resolveNpmChannelTag, } from "./update-check.js"; @@ -54,14 +55,13 @@ describe("resolveNpmChannelTag", () => { typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const tag = decodeURIComponent(url.split("/").pop() ?? ""); const version = versionByTag[tag] ?? null; - return { - ok: version != null, - status: version != null ? 200 : 404, - json: async () => ({ + return new Response( + JSON.stringify({ version, engines: version != null ? { node: ">=22.19.0" } : undefined, }), - } as Response; + { status: version != null ? 200 : 404 }, + ); }), ); }); @@ -144,6 +144,199 @@ describe("resolveNpmChannelTag", () => { }); }); +describe("resolveExtendedStablePackage", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves and verifies an exact public package without falling back", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.33" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.33" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "package", timeoutMs: 1000, env: {} }), + ).resolves.toEqual({ + status: "resolved", + selector: "extended-stable", + version: "2026.6.33", + packageSpec: "openclaw@2026.6.33", + registryUrl: "https://registry.npmjs.org/", + }); + expect(fetch.mock.calls.map((call) => call[0])).toEqual([ + "https://registry.npmjs.org/openclaw/extended-stable", + "https://registry.npmjs.org/openclaw/2026.6.33", + ]); + }); + + it("supports an explicit scoped-package override on a loopback test registry", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2000.4.34" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2000.4.34" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ + installKind: "package", + timeoutMs: 1000, + packageName: "@kevins8/openclaw", + env: { + OPENCLAW_UPDATE_PACKAGE_SPEC: "@kevins8/openclaw", + NPM_CONFIG_REGISTRY: "http://127.0.0.1:4873/", + }, + }), + ).resolves.toEqual({ + status: "resolved", + selector: "extended-stable", + version: "2000.4.34", + packageSpec: "@kevins8/openclaw@2000.4.34", + registryUrl: "http://127.0.0.1:4873/", + }); + expect(fetch.mock.calls.map((call) => call[0])).toEqual([ + "http://127.0.0.1:4873/%40kevins8%2Fopenclaw/extended-stable", + "http://127.0.0.1:4873/%40kevins8%2Fopenclaw/2000.4.34", + ]); + }); + + it("ignores package overrides that do not use a loopback registry", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.33" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.33" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ + installKind: "package", + timeoutMs: 1000, + packageName: "@kevins8/openclaw", + env: { + OPENCLAW_UPDATE_PACKAGE_SPEC: "@kevins8/openclaw", + NPM_CONFIG_REGISTRY: "https://registry.example.com/", + }, + }), + ).resolves.toMatchObject({ + status: "resolved", + packageSpec: "openclaw@2026.6.33", + registryUrl: "https://registry.npmjs.org/", + }); + expect(fetch.mock.calls.map((call) => call[0])).toEqual([ + "https://registry.npmjs.org/openclaw/extended-stable", + "https://registry.npmjs.org/openclaw/2026.6.33", + ]); + }); + + it("returns selector_missing for an absent public selector", async () => { + const fetch = vi.fn(async () => new Response("not found", { status: 404 })); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "package", timeoutMs: 1000 }), + ).resolves.toEqual({ status: "failed", reason: "selector_missing" }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("returns selector_query_failed for unusable selector metadata", async () => { + const fetch = vi.fn( + async () => + new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "package", timeoutMs: 1000 }), + ).resolves.toEqual({ status: "failed", reason: "selector_query_failed" }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("returns selector_query_failed for oversized selector metadata", async () => { + const fetch = vi.fn( + async () => + new Response(`{"version":"2026.6.33","padding":"${"x".repeat(1024 * 1024)}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "package", timeoutMs: 1000 }), + ).resolves.toEqual({ status: "failed", reason: "selector_query_failed" }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("returns exact_package_mismatch when exact readback differs", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.33" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ version: "2026.6.34" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "package", timeoutMs: 1000 }), + ).resolves.toEqual({ status: "failed", reason: "exact_package_mismatch" }); + expect(fetch.mock.calls.map((call) => String(call[0]))).not.toContain( + "https://registry.npmjs.org/openclaw/latest", + ); + }); + + it("rejects Git installs before making a registry request", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect( + resolveExtendedStablePackage({ installKind: "git", timeoutMs: 1000 }), + ).resolves.toEqual({ status: "failed", reason: "unsupported_git_channel" }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + describe("formatGitInstallLabel", () => { it("formats branch, detached tag, and non-git installs", () => { expect( @@ -332,4 +525,36 @@ describe("checkUpdateStatus", () => { expect(status.git?.root).toBe(linkedRoot); }); }); + + it("reports unsupported_git_channel for Git status without querying npm", async () => { + await withTempDir({ prefix: "openclaw-update-check-git-channel-" }, async (root) => { + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }), + "utf8", + ); + await runCommandWithTimeout(["git", "init"], { cwd: root, timeoutMs: 1000 }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + try { + const status = await checkUpdateStatus({ + root, + includeRegistry: true, + registryChannel: "extended-stable", + fetchGit: false, + timeoutMs: 1000, + }); + + expect(status.registry).toEqual({ + latestVersion: null, + tag: "extended-stable", + error: "unsupported_git_channel", + reason: "unsupported_git_channel", + }); + expect(fetch).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + }); }); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index b59982869857..5d78cab5bf09 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -1,6 +1,7 @@ // Computes git, dependency, and registry update status for OpenClaw installs. import fs from "node:fs/promises"; import path from "node:path"; +import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; @@ -8,6 +9,8 @@ import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js"; import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; +const NPM_PACKAGE_METADATA_MAX_BYTES = 1024 * 1024; + export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; export type GitUpdateStatus = { @@ -35,8 +38,28 @@ export type RegistryStatus = { latestVersion: string | null; tag?: string; error?: string; + reason?: ExtendedStableFailureReason; }; +export type ExtendedStableFailureReason = + | "selector_missing" + | "selector_query_failed" + | "exact_package_mismatch" + | "unsupported_git_channel"; + +export type ExtendedStableResolutionResult = + | { + status: "resolved"; + selector: "extended-stable"; + version: string; + packageSpec: string; + registryUrl: string; + } + | { + status: "failed"; + reason: ExtendedStableFailureReason; + }; + export type NpmTagStatus = { tag: string; version: string | null; @@ -59,6 +82,186 @@ export type UpdateCheckResult = { registry?: RegistryStatus; }; +type NpmMetadataCommandRunner = ( + argv: string[], + options: { + timeoutMs: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + maxOutputBytes?: number; + }, +) => Promise<{ + stdout: string; + stderr: string; + code: number | null; +}>; + +function toOptionalTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseNpmPackageTargetMetadata(raw: string): { + version: string | null; + nodeEngine: string | null; +} { + const parsed = JSON.parse(raw.trim()) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { version: null, nodeEngine: null }; + } + const rec = parsed as Record; + const engines = rec.engines && typeof rec.engines === "object" ? rec.engines : null; + const nodeEngine = + toOptionalTrimmedString(rec["engines.node"]) ?? + (engines ? toOptionalTrimmedString((engines as Record).node) : null); + return { + version: toOptionalTrimmedString(rec.version), + nodeEngine, + }; +} + +function formatNpmViewError(res: { stdout: string; stderr: string }): string { + const raw = (res.stderr.trim() || res.stdout.trim()).split("\n").slice(-3).join("\n"); + return raw ? `npm view failed: ${raw}` : "npm view failed"; +} + +function packageTargetSpec(params: { target: string; spec?: string }): string { + const spec = params.spec?.trim(); + return spec || `openclaw@${params.target.trim() || "latest"}`; +} + +const PUBLIC_NPM_REGISTRY_URL = "https://registry.npmjs.org/"; +const PUBLIC_NPM_PACKAGE_NAME = "openclaw"; + +function isLoopbackNpmRegistry(raw: string): boolean { + try { + const url = new URL(raw); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]") + ); + } catch { + return false; + } +} + +function resolveExtendedStableRegistryTarget(params: { + packageName?: string; + env?: NodeJS.ProcessEnv; +}): { registryUrl: string; packageName: string } { + const env = params.env ?? process.env; + const packageName = params.packageName?.trim() || PUBLIC_NPM_PACKAGE_NAME; + const packageSpecOverride = env.OPENCLAW_UPDATE_PACKAGE_SPEC?.trim(); + const registryOverride = env.NPM_CONFIG_REGISTRY?.trim() || env.npm_config_registry?.trim() || ""; + + // A matching package override plus a loopback registry is the explicit local + // integration-test seam. Production resolution remains pinned to public npm. + if (packageSpecOverride === packageName && isLoopbackNpmRegistry(registryOverride)) { + return { registryUrl: registryOverride, packageName }; + } + return { + registryUrl: PUBLIC_NPM_REGISTRY_URL, + packageName: PUBLIC_NPM_PACKAGE_NAME, + }; +} + +function npmRegistryTargetUrl(params: { + registryUrl: string; + packageName: string; + target: string; +}): string { + const baseUrl = params.registryUrl.endsWith("/") ? params.registryUrl : `${params.registryUrl}/`; + return new URL( + `${encodeURIComponent(params.packageName)}/${encodeURIComponent(params.target)}`, + baseUrl, + ).toString(); +} + +async function fetchNpmPackageTargetStatusFromRegistry(params: { + target: string; + timeoutMs: number; + registryUrl?: string; + packageName?: string; +}): Promise { + let res: Response | undefined; + try { + res = await fetchWithTimeout( + npmRegistryTargetUrl({ + registryUrl: params.registryUrl ?? PUBLIC_NPM_REGISTRY_URL, + packageName: params.packageName ?? PUBLIC_NPM_PACKAGE_NAME, + target: params.target, + }), + {}, + Math.max(250, params.timeoutMs), + ); + if (!res.ok) { + return { + target: params.target, + version: null, + nodeEngine: null, + error: `HTTP ${res.status}`, + }; + } + const json = await readProviderJsonResponse<{ + version?: unknown; + engines?: { node?: unknown }; + }>(res, "npm package metadata", { maxBytes: NPM_PACKAGE_METADATA_MAX_BYTES }); + return { + target: params.target, + version: toOptionalTrimmedString(json.version), + nodeEngine: toOptionalTrimmedString(json.engines?.node), + }; + } catch (err) { + return { target: params.target, version: null, nodeEngine: null, error: String(err) }; + } finally { + if (res?.bodyUsed !== true) { + await res?.body?.cancel().catch(() => undefined); + } + } +} + +/** Resolves the extended-stable selector and verifies its exact package manifest. */ +export async function resolveExtendedStablePackage(params: { + installKind: "git" | "package" | "unknown"; + timeoutMs?: number; + packageName?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + if (params.installKind === "git") { + return { status: "failed", reason: "unsupported_git_channel" }; + } + + const timeoutMs = params.timeoutMs ?? 3500; + const registryTarget = resolveExtendedStableRegistryTarget(params); + const selector = await fetchNpmPackageTargetStatusFromRegistry({ + target: "extended-stable", + timeoutMs, + ...registryTarget, + }); + if (!selector.version) { + return { + status: "failed", + reason: selector.error === "HTTP 404" ? "selector_missing" : "selector_query_failed", + }; + } + + const exact = await fetchNpmPackageTargetStatusFromRegistry({ + target: selector.version, + timeoutMs, + ...registryTarget, + }); + if (exact.version !== selector.version) { + return { status: "failed", reason: "exact_package_mismatch" }; + } + + return { + status: "resolved", + selector: "extended-stable", + version: selector.version, + packageSpec: `${registryTarget.packageName}@${selector.version}`, + registryUrl: registryTarget.registryUrl, + }; +} + export function formatGitInstallLabel(update: UpdateCheckResult): string | null { if (update.installKind !== "git") { return null; @@ -300,8 +503,17 @@ export async function checkDepsStatus(params: { export async function fetchNpmLatestVersion(params?: { timeoutMs?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + runCommand?: NpmMetadataCommandRunner; }): Promise { - const res = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params?.timeoutMs }); + const res = await fetchNpmTagVersion({ + tag: "latest", + timeoutMs: params?.timeoutMs, + cwd: params?.cwd, + env: params?.env, + runCommand: params?.runCommand, + }); return { latestVersion: res.version, error: res.error, @@ -311,38 +523,66 @@ export async function fetchNpmLatestVersion(params?: { export async function fetchNpmRegistryVersionForChannel(params: { channel: UpdateChannel; timeoutMs?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + runCommand?: NpmMetadataCommandRunner; }): Promise { const res = await resolveNpmChannelTag({ channel: params.channel, timeoutMs: params.timeoutMs, + cwd: params.cwd, + env: params.env, + runCommand: params.runCommand, }); return { latestVersion: res.version, tag: res.tag, + ...(res.reason ? { error: res.reason, reason: res.reason } : {}), }; } export async function fetchNpmPackageTargetStatus(params: { target: string; timeoutMs?: number; + spec?: string; + command?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + runCommand?: NpmMetadataCommandRunner; }): Promise { const timeoutMs = params.timeoutMs ?? 3500; const target = params.target; + if (!params.command && !params.runCommand) { + return await fetchNpmPackageTargetStatusFromRegistry({ target, timeoutMs }); + } + const runCommand = params.runCommand ?? runCommandWithTimeout; try { - const res = await fetchWithTimeout( - `https://registry.npmjs.org/openclaw/${encodeURIComponent(target)}`, - {}, - Math.max(250, timeoutMs), + const res = await runCommand( + [ + params.command ?? "npm", + "view", + packageTargetSpec({ target, spec: params.spec }), + "version", + "engines.node", + "--json", + "--global", + ], + { + timeoutMs: Math.max(250, timeoutMs), + cwd: params.cwd, + env: params.env, + maxOutputBytes: 1024 * 1024, + }, ); - if (!res.ok) { - return { target, version: null, nodeEngine: null, error: `HTTP ${res.status}` }; + if (res.code !== 0) { + return { + target, + version: null, + nodeEngine: null, + error: formatNpmViewError(res), + }; } - const json = (await res.json()) as { - version?: unknown; - engines?: { node?: unknown }; - }; - const version = typeof json?.version === "string" ? json.version : null; - const nodeEngine = typeof json?.engines?.node === "string" ? json.engines.node : null; + const { version, nodeEngine } = parseNpmPackageTargetMetadata(res.stdout); return { target, version, nodeEngine }; } catch (err) { return { target, version: null, nodeEngine: null, error: String(err) }; @@ -352,10 +592,20 @@ export async function fetchNpmPackageTargetStatus(params: { export async function fetchNpmTagVersion(params: { tag: string; timeoutMs?: number; + spec?: string; + command?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + runCommand?: NpmMetadataCommandRunner; }): Promise { const res = await fetchNpmPackageTargetStatus({ target: params.tag, timeoutMs: params.timeoutMs, + spec: params.spec, + command: params.command, + cwd: params.cwd, + env: params.env, + runCommand: params.runCommand, }); return { tag: params.tag, @@ -367,14 +617,45 @@ export async function fetchNpmTagVersion(params: { export async function resolveNpmChannelTag(params: { channel: UpdateChannel; timeoutMs?: number; -}): Promise<{ tag: string; version: string | null }> { + command?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + runCommand?: NpmMetadataCommandRunner; +}): Promise<{ + tag: string; + version: string | null; + reason?: ExtendedStableFailureReason; +}> { const channelTag = channelToNpmTag(params.channel); - const channelStatus = await fetchNpmTagVersion({ tag: channelTag, timeoutMs: params.timeoutMs }); + if (params.channel === "extended-stable") { + const resolved = await resolveExtendedStablePackage({ + installKind: "package", + timeoutMs: params.timeoutMs, + }); + return resolved.status === "resolved" + ? { tag: resolved.selector, version: resolved.version } + : { tag: channelTag, version: null, reason: resolved.reason }; + } + const channelStatus = await fetchNpmTagVersion({ + tag: channelTag, + timeoutMs: params.timeoutMs, + command: params.command, + cwd: params.cwd, + env: params.env, + runCommand: params.runCommand, + }); if (params.channel !== "beta") { return { tag: channelTag, version: channelStatus.version }; } - const latestStatus = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params.timeoutMs }); + const latestStatus = await fetchNpmTagVersion({ + tag: "latest", + timeoutMs: params.timeoutMs, + command: params.command, + cwd: params.cwd, + env: params.env, + runCommand: params.runCommand, + }); if (!latestStatus.version) { return { tag: channelTag, version: channelStatus.version }; } @@ -427,13 +708,20 @@ export async function checkUpdateStatus(params: { } const rootRealpath = await fs.realpath(root).catch(() => root); - const [pm, gitRoot, registry] = await Promise.all([ - detectPackageManager(root), - detectGitRoot(root), - params.includeRegistry ? fetchRegistry() : Promise.resolve(undefined), - ]); + const [pm, gitRoot] = await Promise.all([detectPackageManager(root), detectGitRoot(root)]); const isGit = gitRoot && path.resolve(gitRoot) === path.resolve(rootRealpath); + const registry = params.includeRegistry + ? params.registryChannel === "extended-stable" && isGit + ? { + latestVersion: null, + tag: "extended-stable", + error: "unsupported_git_channel", + reason: "unsupported_git_channel" as const, + } + : await fetchRegistry() + : undefined; + const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package"; const [git, deps] = await Promise.all([ isGit diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index a54789326c31..d5efc8483f14 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -137,6 +137,28 @@ describe("update global helpers", () => { ); }); + it("resolves scoped package paths from the package manager global root", async () => { + const globalRoot = path.join("tmp", "npm-root"); + const runCommand: CommandRunner = async () => ({ + stdout: `${globalRoot}\n`, + stderr: "", + code: 0, + }); + + await expect( + resolveGlobalInstallTarget({ + manager: "npm", + runCommand, + timeoutMs: 1000, + packageName: "@kevins8/openclaw", + }), + ).resolves.toMatchObject({ + manager: "npm", + globalRoot, + packageRoot: path.join(globalRoot, "@kevins8", "openclaw"), + }); + }); + it("maps main and explicit install specs for global installs", () => { expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe( OPENCLAW_MAIN_PACKAGE_SPEC, @@ -169,6 +191,19 @@ describe("update global helpers", () => { expect(explicitEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("1"); }); + it("binds both npm registry environment spellings to a verified registry", async () => { + const env = await createGlobalInstallEnv( + { + NPM_CONFIG_REGISTRY: "https://registry.example.com/", + npm_config_registry: "https://registry.example.net/", + }, + { registryUrl: "https://registry.npmjs.org/" }, + ); + + expect(env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org/"); + expect(env?.npm_config_registry).toBe("https://registry.npmjs.org/"); + }); + it("uses an absolute POSIX script shell for npm lifecycle scripts during global installs", async () => { await withMockedPlatform("linux", async () => { const existsSyncSpy = vi @@ -373,6 +408,32 @@ describe("update global helpers", () => { }); }); + it("honors an explicitly selected scoped npm package root", async () => { + await withTempDir({ prefix: "openclaw-update-scoped-npm-root-" }, async (base) => { + const owningRoot = path.join(base, "owned", "lib", "node_modules"); + const pkgRoot = path.join(owningRoot, "@openclaw", "openclaw"); + const pathNpmRoot = path.join(base, "path", "lib", "node_modules"); + await fs.mkdir(pkgRoot, { recursive: true }); + + const runCommand = createNpmRootRunner({ defaultNpmRoot: pathNpmRoot }); + + await expect( + resolveGlobalInstallTarget({ + manager: "npm", + runCommand, + timeoutMs: 1000, + pkgRoot, + honorPackageRoot: true, + packageName: "@openclaw/openclaw", + }), + ).resolves.toMatchObject({ + manager: "npm", + globalRoot: owningRoot, + packageRoot: pkgRoot, + }); + }); + }); + it("does not infer npm ownership from path shape alone when the owning npm binary is absent", async () => { await withTempDir({ prefix: "openclaw-update-npm-missing-bin-" }, async (base) => { const brewRoot = path.join(base, "opt", "homebrew", "lib", "node_modules"); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index ac22b7b48b54..19ed45855805 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -412,6 +412,7 @@ export function resolveGlobalInstallSpec(params: { */ export async function createGlobalInstallEnv( env?: NodeJS.ProcessEnv, + options: { registryUrl?: string } = {}, ): Promise { const pathPrepend = await resolvePortableGitPathPrepend(); const sourceEnv = env ?? process.env; @@ -420,6 +421,13 @@ export async function createGlobalInstallEnv( .filter(([, value]) => value != null) .map(([key, value]) => [key, String(value)]), ) as Record; + const registryUrl = options.registryUrl?.trim(); + if (registryUrl) { + // Extended-stable resolution verifies this registry. Keep the package + // manager on the same source so install bytes cannot drift afterward. + merged.NPM_CONFIG_REGISTRY = registryUrl; + merged.npm_config_registry = registryUrl; + } applyPathPrepend(merged, pathPrepend); applyWindowsPackageInstallEnv(merged); applyCorepackDownloadPromptEnv(merged); @@ -542,10 +550,29 @@ function inferGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null return null; } const normalized = path.resolve(trimmed); - const globalRoot = path.dirname(normalized); + const packageParent = path.dirname(normalized); + const globalRoot = path.basename(packageParent).startsWith("@") + ? path.dirname(packageParent) + : packageParent; return path.basename(globalRoot) === "node_modules" ? globalRoot : null; } +function resolvePackageRootFromGlobalRoot(params: { + globalRoot: string; + packageName?: string; +}): string { + const packageName = params.packageName?.trim() || PRIMARY_PACKAGE_NAME; + const parts = packageName.split("/"); + const hasSafeSegments = + parts.length > 0 && + parts.length <= 2 && + parts.every( + (part) => part.length > 0 && part !== "." && part !== ".." && !part.includes("\\"), + ) && + (parts.length === 1 || parts[0]?.startsWith("@")); + return path.join(params.globalRoot, ...(hasSafeSegments ? parts : [PRIMARY_PACKAGE_NAME])); +} + function isDirectNpmNodeModulesRoot(globalRoot: string | null): boolean { return ( globalRoot !== null && @@ -714,6 +741,7 @@ export async function resolveGlobalInstallTarget(params: { timeoutMs: number; pkgRoot?: string | null; honorPackageRoot?: boolean; + packageName?: string; }): Promise { const honoredPackageRootGlobalRoot = params.honorPackageRoot ? inferGlobalRootFromPackageRoot(params.pkgRoot) @@ -748,7 +776,12 @@ export async function resolveGlobalInstallTarget(params: { return { ...command, globalRoot: targetGlobalRoot, - packageRoot: targetGlobalRoot ? path.join(targetGlobalRoot, PRIMARY_PACKAGE_NAME) : null, + packageRoot: targetGlobalRoot + ? resolvePackageRootFromGlobalRoot({ + globalRoot: targetGlobalRoot, + packageName: params.packageName, + }) + : null, ...(honoredPackageRootGlobalRoot && targetGlobalRoot === honoredPackageRootGlobalRoot && honoredDirectNpmRoot diff --git a/src/infra/update-managed-service-handoff.test.ts b/src/infra/update-managed-service-handoff.test.ts index a242b3fcb4c8..59b33d4d0b19 100644 --- a/src/infra/update-managed-service-handoff.test.ts +++ b/src/infra/update-managed-service-handoff.test.ts @@ -264,6 +264,41 @@ describe("managed service update handoff", () => { expect(options.env.OPENCLAW_UPDATE_RUN_HANDOFF).toBe("1"); }); + it("serializes extended-stable into the detached CLI command", async () => { + const { startManagedServiceUpdateHandoff } = + await import("./update-managed-service-handoff.js"); + + const result = await startManagedServiceUpdateHandoff({ + root: "/tmp/openclaw", + channel: "extended-stable", + parentPid: 12345, + execPath: "/usr/local/bin/node", + argv1: "/opt/openclaw/openclaw.mjs", + meta: {}, + }); + + const spawnCall = spawnMock.mock.calls[0] as unknown as [string, string[]] | undefined; + const args = spawnCall?.[1]; + const paramsPath = args?.[1]; + if (!paramsPath) { + throw new Error("expected managed-service handoff params path"); + } + tempDirs.add(path.dirname(paramsPath)); + const helperParams = JSON.parse(await fs.readFile(paramsPath, "utf-8")) as { + commandArgv?: string[]; + }; + expect(helperParams.commandArgv).toEqual([ + "/usr/local/bin/node", + "/opt/openclaw/openclaw.mjs", + "update", + "--yes", + "--json", + "--channel", + "extended-stable", + ]); + expect(result.command).toContain("--channel extended-stable"); + }); + it("does not overwrite a restart sentinel owned by another startup task", async () => { const unrelatedSentinel = { version: 1, diff --git a/src/infra/update-managed-service-handoff.ts b/src/infra/update-managed-service-handoff.ts index 0f87a50abef3..3545a314365a 100644 --- a/src/infra/update-managed-service-handoff.ts +++ b/src/infra/update-managed-service-handoff.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { resolveRestartSentinelPath } from "./restart-sentinel.js"; import { SUPERVISOR_HINT_ENV_VARS, type RespawnSupervisor } from "./supervisor-markers.js"; +import type { UpdateChannel } from "./update-channels.js"; import { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV, type ControlPlaneUpdateSentinelMetaFile, @@ -266,7 +267,7 @@ function isNodeLikeRuntime(execPath: string | undefined): boolean { function resolveUpdateCliArgv(params: { timeoutMs?: number; - channel?: "stable" | "beta" | "dev"; + channel?: UpdateChannel; execPath?: string; argv1?: string; }): string[] { @@ -291,7 +292,7 @@ function resolveUpdateCliArgv(params: { export function formatManagedServiceUpdateCommand(params?: { timeoutMs?: number; - channel?: "stable" | "beta" | "dev"; + channel?: UpdateChannel; }): string { const args = ["openclaw", "update", "--yes"]; if (params?.channel) { @@ -417,7 +418,7 @@ async function resolveHandoffSpawn(params: { export async function startManagedServiceUpdateHandoff(params: { root: string; timeoutMs?: number; - channel?: "stable" | "beta" | "dev"; + channel?: UpdateChannel; restartDelayMs?: number; meta: UpdateRestartSentinelMeta; handoffId?: string; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index edc2d5633d98..e9a64b593019 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -10,6 +10,7 @@ import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; import { pathExists } from "../utils.js"; import { writePackageDistInventory } from "./package-dist-inventory.js"; import { resolveStableNodePath } from "./stable-node-path.js"; +import type { UpdateChannel } from "./update-channels.js"; import { runGatewayUpdate } from "./update-runner.js"; const execFileSyncMock = vi.hoisted(() => vi.fn(() => "/tmp/openclaw-test-global-npmrc\n")); @@ -241,7 +242,7 @@ describe("runGatewayUpdate", () => { options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, ) => Promise, options?: { - channel?: "stable" | "beta" | "dev"; + channel?: UpdateChannel; tag?: string; cwd?: string; devTargetRef?: string; @@ -266,7 +267,7 @@ describe("runGatewayUpdate", () => { async function runWithRunner( runner: (argv: string[]) => Promise, options?: { - channel?: "stable" | "beta" | "dev"; + channel?: UpdateChannel; tag?: string; cwd?: string; devTargetRef?: string; @@ -836,6 +837,25 @@ describe("runGatewayUpdate", () => { ); }); + it("rejects extended-stable Git updates before checkout mutation", async () => { + await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); + const { runner, calls } = createRunner({ + [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, + }); + + const result = await runWithRunner(runner, { channel: "extended-stable" }); + + expect(result).toMatchObject({ + status: "error", + mode: "git", + root: tempDir, + reason: "unsupported_git_channel", + steps: [], + }); + expect(calls).not.toContain(`git -C ${tempDir} fetch --all --prune --tags`); + expect(calls.some((call) => call.includes("checkout"))).toBe(false); + }); + it("uses pnpm highest resolution mode for update installs", async () => { await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); await setupUiIndex(); @@ -2644,6 +2664,30 @@ describe("runGatewayUpdate", () => { expect(calls).toContain(npmGlobalInstallCommand("openclaw@latest")); }); + it("rejects a tag override for the extended-stable global package channel", async () => { + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: npmGlobalInstallCommand("openclaw@latest"), + }); + + const result = await runWithCommand(runCommand, { + cwd: pkgRoot, + channel: "extended-stable", + tag: "latest", + }); + + expect(result).toMatchObject({ + status: "error", + mode: "npm", + root: pkgRoot, + reason: "extended-stable-tag-unsupported", + steps: [], + }); + expect(calls).not.toContain(npmGlobalInstallCommand("openclaw@latest")); + }); + it("cleans stale npm rename dirs before global update", async () => { const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 6ac3d245f35e..d69c06bcc682 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -24,11 +24,12 @@ import { channelToNpmTag, DEFAULT_PACKAGE_CHANNEL, DEV_BRANCH, + EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, isBetaTag, isStableTag, type UpdateChannel, } from "./update-channels.js"; -import { compareSemverStrings } from "./update-check.js"; +import { compareSemverStrings, resolveExtendedStablePackage } from "./update-check.js"; import { cleanupGlobalRenameDirs, createGlobalInstallEnv, @@ -832,6 +833,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } if (gitRoot && pkgRoot && (await pathsReferToSameLocation(gitRoot, pkgRoot))) { + const channel: UpdateChannel = opts.channel ?? "dev"; + if (channel === "extended-stable") { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "unsupported_git_channel", + steps: [], + durationMs: Date.now() - startedAt, + }; + } // Get current SHA (not a visible step, no progress) const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], { cwd: gitRoot, @@ -839,7 +851,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }); const beforeSha = beforeShaResult.stdout.trim() || null; const beforeVersion = await readPackageVersion(gitRoot); - const channel: UpdateChannel = opts.channel ?? "dev"; const devTargetRef = channel === "dev" ? normalizeDevTargetRef(opts.devTargetRef) : null; const branch = await readBranchName(runCommand, gitRoot, timeoutMs); const needsCheckoutMain = channel === "dev" && !devTargetRef && branch !== DEV_BRANCH; @@ -1658,25 +1669,66 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const beforeVersion = await readPackageVersion(pkgRoot); const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs); if (globalManager) { + const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; + if (channel === "extended-stable" && opts.tag !== undefined) { + return { + status: "error", + mode: globalManager, + root: pkgRoot, + reason: EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, + before: { version: beforeVersion }, + steps: [], + durationMs: Date.now() - startedAt, + }; + } + const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME; const installTarget = await resolveGlobalInstallTarget({ manager: globalManager, runCommand, timeoutMs, pkgRoot, + honorPackageRoot: channel === "extended-stable", + packageName, }); - const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME; await cleanupGlobalRenameDirs({ globalRoot: path.dirname(pkgRoot), packageName, }); - const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; - const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel)); - const globalInstallEnv = await createGlobalInstallEnv(); - const spec = resolveGlobalInstallSpec({ - packageName, - tag, - env: globalInstallEnv, + const extendedStable = + channel === "extended-stable" + ? await resolveExtendedStablePackage({ + installKind: "package", + timeoutMs, + packageName, + }) + : null; + if (extendedStable?.status === "failed") { + return { + status: "error", + mode: globalManager, + root: pkgRoot, + reason: extendedStable.reason, + before: { version: beforeVersion }, + steps: [], + durationMs: Date.now() - startedAt, + }; + } + const tag = normalizeTag( + extendedStable?.status === "resolved" + ? extendedStable.version + : (opts.tag ?? channelToNpmTag(channel)), + ); + const globalInstallEnv = await createGlobalInstallEnv(undefined, { + registryUrl: extendedStable?.status === "resolved" ? extendedStable.registryUrl : undefined, }); + const spec = + extendedStable?.status === "resolved" + ? extendedStable.packageSpec + : resolveGlobalInstallSpec({ + packageName, + tag, + env: globalInstallEnv, + }); const packageUpdate = await runGlobalPackageUpdateSteps({ installTarget, installSpec: spec, diff --git a/src/infra/update-startup.integration.test.ts b/src/infra/update-startup.integration.test.ts new file mode 100644 index 000000000000..72c0d7933734 --- /dev/null +++ b/src/infra/update-startup.integration.test.ts @@ -0,0 +1,112 @@ +// Proves startup update discovery through the real extended-stable registry resolver. +import http from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import type { UpdateCheckResult } from "./update-check.js"; + +vi.mock("./openclaw-root.js", async () => { + const actual = await vi.importActual("./openclaw-root.js"); + return { + ...actual, + resolveOpenClawPackageRoot: vi.fn(async () => "/opt/openclaw"), + }; +}); + +vi.mock("./update-check.js", async () => { + const actual = await vi.importActual("./update-check.js"); + return { + ...actual, + checkUpdateStatus: vi.fn( + async () => + ({ + root: "/opt/openclaw", + installKind: "package", + packageManager: "npm", + }) satisfies UpdateCheckResult, + ), + }; +}); + +vi.mock("../version.js", () => ({ + VERSION: "1.0.0", +})); + +describe("extended-stable startup update integration", () => { + let testState: OpenClawTestState; + let server: http.Server | undefined; + + beforeEach(async () => { + server = undefined; + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-update-startup-integration-", + env: { + NODE_ENV: "test", + NPM_CONFIG_REGISTRY: undefined, + OPENCLAW_UPDATE_PACKAGE_SPEC: undefined, + VITEST: undefined, + }, + }); + }); + + afterEach(async () => { + const activeServer = server; + if (activeServer) { + await new Promise((resolve, reject) => { + activeServer.close((error) => (error ? reject(error) : resolve())); + }); + } + closeOpenClawStateDatabaseForTest(); + await testState.cleanup(); + }); + + it("emits a read-only hint after verifying a newer exact loopback package", async () => { + const requests: string[] = []; + const registryServer = http.createServer((request, response) => { + requests.push(request.url ?? ""); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.0" })); + }); + server = registryServer; + await new Promise((resolve) => { + registryServer.listen(0, "127.0.0.1", resolve); + }); + const address = registryServer.address(); + if (!address || typeof address === "string") { + throw new Error("expected loopback registry address"); + } + process.env.OPENCLAW_UPDATE_PACKAGE_SPEC = "openclaw"; + process.env.NPM_CONFIG_REGISTRY = `http://127.0.0.1:${address.port}/`; + + const { runGatewayUpdateCheck, resetUpdateAvailableStateForTest } = + await import("./update-startup.js"); + resetUpdateAvailableStateForTest(); + const log = { info: vi.fn() }; + const onUpdateAvailableChange = vi.fn(); + const runAutoUpdate = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel: "extended-stable", auto: { enabled: true } } }, + log, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + runAutoUpdate, + }); + + expect(requests).toEqual(["/openclaw/extended-stable", "/openclaw/2.0.0"]); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(log.info).toHaveBeenCalledWith( + "update available (extended-stable): v2.0.0 (current v1.0.0). Run: openclaw update", + ); + expect(runAutoUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 3b4162cdc293..8c01ed195909 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -1,4 +1,5 @@ // Covers startup update check and auto-update behavior. +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -79,6 +80,23 @@ vi.mock("./update-managed-service-handoff.js", () => ({ startManagedServiceUpdateHandoff: startManagedServiceUpdateHandoffMock, })); +type PersistedUpdateCheckState = { + lastCheckedAt?: string; + lastCheckedChannel?: "stable" | "extended-stable" | "beta" | "dev"; + lastNotifiedVersion?: string; + lastNotifiedTag?: string; + lastAvailableVersion?: string; + lastAvailableTag?: string; + autoInstallId?: string; + autoFirstSeenVersion?: string; + autoFirstSeenTag?: string; + autoFirstSeenAt?: string; + autoLastAttemptVersion?: string; + autoLastAttemptAt?: string; + autoLastSuccessVersion?: string; + autoLastSuccessAt?: string; +}; + describe("update-startup", () => { let tempDir: string; let testState: OpenClawTestState; @@ -101,6 +119,23 @@ describe("update-startup", () => { return call; } + function readPersistedUpdateCheckState(): PersistedUpdateCheckState | null { + try { + return JSON.parse( + fsSync.readFileSync(path.join(tempDir, "update-check.json"), "utf-8"), + ) as PersistedUpdateCheckState; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + function writePersistedUpdateCheckState(state: PersistedUpdateCheckState): void { + fsSync.writeFileSync(path.join(tempDir, "update-check.json"), JSON.stringify(state), "utf-8"); + } + beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-17T10:00:00Z")); @@ -233,6 +268,69 @@ describe("update-startup", () => { }; } + function createExtendedStableConfig(params?: { checkOnStart?: boolean; autoEnabled?: boolean }) { + return { + update: { + ...(params?.checkOnStart === false ? { checkOnStart: false } : {}), + channel: "extended-stable" as const, + ...(params?.autoEnabled ? { auto: { enabled: true } } : {}), + }, + }; + } + + async function runExtendedStableUpdateCheck(params?: { + cfg?: ReturnType; + log?: Parameters[0]["log"]; + onUpdateAvailableChange?: Parameters< + typeof runGatewayUpdateCheck + >[0]["onUpdateAvailableChange"]; + runAutoUpdate?: ReturnType; + isNixMode?: boolean; + }) { + const log = params?.log ?? { info: vi.fn() }; + await runGatewayUpdateCheck({ + cfg: params?.cfg ?? createExtendedStableConfig(), + log, + isNixMode: params?.isNixMode ?? false, + allowInTests: true, + ...(params?.onUpdateAvailableChange + ? { onUpdateAvailableChange: params.onUpdateAvailableChange } + : {}), + ...(params?.runAutoUpdate ? { runAutoUpdate: params.runAutoUpdate } : {}), + }); + } + + async function seedExtendedStableAvailability(params?: { + onUpdateAvailableChange?: Parameters< + typeof runGatewayUpdateCheck + >[0]["onUpdateAvailableChange"]; + }) { + mockPackageInstallStatus(); + mockNpmChannelTag("extended-stable", "2.0.0"); + await runExtendedStableUpdateCheck({ + onUpdateAvailableChange: params?.onUpdateAvailableChange, + }); + } + + function seedStableAutoRolloutState() { + writePersistedUpdateCheckState({ + ...readPersistedUpdateCheckState(), + autoInstallId: "stable-install-id", + autoFirstSeenVersion: "3.0.0", + autoFirstSeenTag: "latest", + autoFirstSeenAt: "2026-01-16T10:00:00.000Z", + }); + } + + function expectStableAutoRolloutStatePreserved() { + expect(readPersistedUpdateCheckState()).toMatchObject({ + autoInstallId: "stable-install-id", + autoFirstSeenVersion: "3.0.0", + autoFirstSeenTag: "latest", + autoFirstSeenAt: "2026-01-16T10:00:00.000Z", + }); + } + async function runAutoUpdateCheckWithDefaults(params: { cfg: { update?: Record }; runAutoUpdate?: ReturnType; @@ -334,44 +432,207 @@ describe("update-startup", () => { expect(parsed.lastAvailableVersion).toBe("2.0.0"); }); - it("hydrates cached update from persisted state during throttle window", async () => { - const statePath = path.join(tempDir, "update-check.json"); - await fs.writeFile( - statePath, - JSON.stringify( - { - lastCheckedAt: new Date(Date.now()).toISOString(), - lastAvailableVersion: "2.0.0", - lastAvailableTag: "latest", - }, - null, - 2, - ), - "utf-8", - ); + it.each([ + { + channel: "stable" as const, + persistedTag: undefined, + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "stable" as const, + persistedTag: "latest", + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "beta" as const, + persistedTag: "beta", + expectedTag: "beta", + preflightsInstallKind: false, + }, + { + channel: "beta" as const, + persistedTag: "latest", + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "extended-stable" as const, + persistedTag: "extended-stable", + expectedTag: "extended-stable", + preflightsInstallKind: true, + }, + { + channel: "dev" as const, + persistedTag: "dev", + expectedTag: "dev", + preflightsInstallKind: false, + }, + ])( + "hydrates $channel cached availability from its compatible $expectedTag tag", + async ({ channel, persistedTag, expectedTag, preflightsInstallKind }) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: channel, + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + const onUpdateAvailableChange = vi.fn(); - const onUpdateAvailableChange = vi.fn(); - await runGatewayUpdateCheck({ - cfg: { update: { channel: "stable" } }, - log: { info: vi.fn() }, - isNixMode: false, - allowInTests: true, - onUpdateAvailableChange, + await runGatewayUpdateCheck({ + cfg: { update: { channel } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(preflightsInstallKind ? 1 : 0); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: expectedTag, + }); + expect(getUpdateAvailable()).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: expectedTag, + }); + }, + ); + + it.each([ + { channel: "stable" as const, persistedTag: "beta" }, + { channel: "stable" as const, persistedTag: "extended-stable" }, + { channel: "beta" as const, persistedTag: undefined }, + { channel: "beta" as const, persistedTag: "extended-stable" }, + { channel: "dev" as const, persistedTag: "latest" }, + ])( + "suppresses $persistedTag persisted availability on the $channel channel", + async ({ channel, persistedTag }) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: channel, + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + const onUpdateAvailableChange = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + }, + ); + + it.each(["latest", "beta"])( + "bypasses the shared throttle for mismatched %s availability on extended-stable", + async (persistedTag) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: persistedTag === "beta" ? "beta" : "stable", + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, + }); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + }, + ); + + it("bypasses a recent empty prior-channel check on extended-stable", async () => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: "stable", }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); - expect(vi.mocked(checkUpdateStatus)).not.toHaveBeenCalled(); - expect(onUpdateAvailableChange).toHaveBeenCalledWith({ - currentVersion: "1.0.0", - latestVersion: "2.0.0", - channel: "latest", + await runExtendedStableUpdateCheck(); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, }); expect(getUpdateAvailable()).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", - channel: "latest", + channel: "extended-stable", }); }); + it("honors the shared throttle after a recent extended-stable check marker", async () => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: "extended-stable", + lastAvailableVersion: "1.0.0", + lastAvailableTag: "extended-stable", + }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + + await runExtendedStableUpdateCheck(); + + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + }); + + it.each([ + { channel: "stable" as const, tag: "latest" }, + { channel: "beta" as const, tag: "beta" }, + ])( + "bypasses the shared throttle when switching from extended-stable to $channel", + async ({ channel, tag }) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastCheckedChannel: "extended-stable", + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + mockPackageUpdateStatus(tag, "2.0.0"); + + await runGatewayUpdateCheck({ + cfg: { update: { channel } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ channel, timeoutMs: 2500 }); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastCheckedChannel: channel, + lastAvailableVersion: "2.0.0", + lastAvailableTag: tag, + }); + }, + ); + it("emits update change callback when update state clears", async () => { mockPackageInstallStatus(); vi.mocked(resolveNpmChannelTag) @@ -412,6 +673,211 @@ describe("update-startup", () => { await expectPathMissing(path.join(tempDir, "update-check.json")); }); + it("discovers and deduplicates an exact extended-stable update without auto-applying", async () => { + const onUpdateAvailableChange = vi.fn(); + const runAutoUpdate = createAutoUpdateSuccessMock(); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ autoEnabled: true }), + log, + onUpdateAvailableChange, + runAutoUpdate, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ autoEnabled: true }), + log, + onUpdateAvailableChange, + runAutoUpdate, + }); + + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + expect(resolveNpmChannelTag).toHaveBeenNthCalledWith(1, { + channel: "extended-stable", + timeoutMs: 2500, + }); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( + `update available (extended-stable): v2.0.0 (current v1.0.0). Run: ${formatCliCommand("openclaw update")}`, + ); + expect(onUpdateAvailableChange).toHaveBeenCalledTimes(1); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(getUpdateAvailable()).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastNotifiedVersion: "2.0.0", + lastNotifiedTag: "extended-stable", + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(readPersistedUpdateCheckState()?.autoFirstSeenVersion).toBeUndefined(); + }); + + it("does no extended-stable hint or auto work when checkOnStart is false", async () => { + await seedExtendedStableAvailability(); + vi.mocked(resolveOpenClawPackageRoot).mockClear(); + vi.mocked(checkUpdateStatus).mockClear(); + vi.mocked(resolveNpmChannelTag).mockClear(); + const onUpdateAvailableChange = vi.fn(); + const runAutoUpdate = createAutoUpdateSuccessMock(); + + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ checkOnStart: false, autoEnabled: true }), + onUpdateAvailableChange, + runAutoUpdate, + }); + + expect(resolveOpenClawPackageRoot).not.toHaveBeenCalled(); + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + }); + + it.each([ + { name: "equal", version: "1.0.0" }, + { name: "older", version: "0.9.0" }, + ])("clears stale extended-stable availability for an $name target", async ({ version }) => { + const onUpdateAvailableChange = vi.fn(); + await seedExtendedStableAvailability({ onUpdateAvailableChange }); + seedStableAutoRolloutState(); + onUpdateAvailableChange.mockClear(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + + expect(log.info).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastNotifiedVersion: "2.0.0", + lastNotifiedTag: "extended-stable", + }); + expect(readPersistedUpdateCheckState()?.lastAvailableVersion).toBeUndefined(); + expect(readPersistedUpdateCheckState()?.lastAvailableTag).toBe("extended-stable"); + expectStableAutoRolloutStatePreserved(); + }); + + it.each(["selector_missing", "selector_query_failed", "exact_package_mismatch"] as const)( + "clears stale extended-stable availability when exact resolution fails with %s", + async (failure) => { + const onUpdateAvailableChange = vi.fn(); + await seedExtendedStableAvailability({ onUpdateAvailableChange }); + seedStableAutoRolloutState(); + onUpdateAvailableChange.mockClear(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version: null, + reason: failure, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + + expect(log.info).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()?.lastAvailableVersion).toBeUndefined(); + expect(readPersistedUpdateCheckState()?.lastAvailableTag).toBe("extended-stable"); + expectStableAutoRolloutStatePreserved(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + }, + ); + + it("preserves cross-channel persisted availability when extended-stable resolution fails", async () => { + writePersistedUpdateCheckState({ + lastCheckedAt: "2026-01-16T10:00:00.000Z", + lastAvailableVersion: "2.0.0", + lastAvailableTag: "latest", + }); + mockPackageInstallStatus(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version: null, + reason: "selector_query_failed", + }); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange }); + + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "latest", + }); + }); + + it("does not resolve the npm channel for an extended-stable Git install", async () => { + await seedExtendedStableAvailability(); + seedStableAutoRolloutState(); + resetUpdateAvailableStateForTest(); + vi.mocked(resolveOpenClawPackageRoot).mockClear(); + vi.mocked(checkUpdateStatus).mockClear(); + vi.mocked(resolveNpmChannelTag).mockClear(); + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/openclaw", + installKind: "git", + packageManager: "unknown", + } satisfies UpdateCheckResult); + const runAutoUpdate = createAutoUpdateSuccessMock(); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange, runAutoUpdate }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + expectStableAutoRolloutStatePreserved(); + }); + + it("skips all extended-stable work in Nix mode", async () => { + const runAutoUpdate = createAutoUpdateSuccessMock(); + + await runExtendedStableUpdateCheck({ isNixMode: true, runAutoUpdate }); + + expect(resolveOpenClawPackageRoot).not.toHaveBeenCalled(); + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(readPersistedUpdateCheckState()).toBeNull(); + }); + it("defers stable auto-update until rollout window is due", async () => { mockPackageUpdateStatus("latest", "2.0.0"); @@ -651,4 +1117,51 @@ describe("update-startup", () => { }); stop(); }); + + it("schedules an initial and recurring 24-hour extended-stable hint check with cleanup", async () => { + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + process.env.NODE_ENV = "production"; + delete process.env.VITEST; + const stop = scheduleGatewayUpdateCheck({ + cfg: { update: { channel: "extended-stable" } }, + log: { info: vi.fn() }, + isNixMode: false, + }); + + try { + await vi.waitFor(() => expect(resolveNpmChannelTag).toHaveBeenCalledTimes(1)); + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + await vi.waitFor(() => expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2)); + + stop(); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + } finally { + stop(); + process.env.NODE_ENV = previousNodeEnv; + if (previousVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previousVitest; + } + } + }); + + it("does not schedule extended-stable polling when checkOnStart is false", async () => { + const stop = scheduleGatewayUpdateCheck({ + cfg: { update: { channel: "extended-stable", checkOnStart: false } }, + log: { info: vi.fn() }, + isNixMode: false, + }); + + await vi.advanceTimersByTimeAsync(48 * 60 * 60 * 1000); + + expect(resolveOpenClawPackageRoot).not.toHaveBeenCalled(); + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + stop(); + }); }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 2d0c5a48a8c1..5245f4e94bca 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -18,13 +18,19 @@ import { writeJson } from "./json-files.js"; import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; import { scheduleGatewaySigusr1Restart } from "./restart.js"; import { detectRespawnSupervisor, type RespawnSupervisor } from "./supervisor-markers.js"; -import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js"; +import { + channelToNpmTag, + normalizeUpdateChannel, + DEFAULT_PACKAGE_CHANNEL, + type UpdateChannel, +} from "./update-channels.js"; import { compareSemverStrings, resolveNpmChannelTag, checkUpdateStatus } from "./update-check.js"; import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js"; import { startManagedServiceUpdateHandoff } from "./update-managed-service-handoff.js"; type UpdateCheckState = { lastCheckedAt?: string; + lastCheckedChannel?: UpdateChannel; lastNotifiedVersion?: string; lastNotifiedTag?: string; lastAvailableVersion?: string; @@ -169,23 +175,51 @@ function setUpdateAvailableCache(params: { params.onUpdateAvailableChange?.(params.next); } -function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailable | null { +function isPersistedAvailabilityForChannel(params: { + state: UpdateCheckState; + channel: UpdateChannel; +}): boolean { + const tag = params.state.lastAvailableTag?.trim(); + if (params.channel === "stable") { + return !tag || tag === "latest"; + } + if (params.channel === "beta") { + return tag === "beta" || tag === "latest"; + } + return tag === params.channel; +} + +function resolvePersistedUpdateAvailable( + state: UpdateCheckState, + channel: UpdateChannel, +): UpdateAvailable | null { const latestVersion = state.lastAvailableVersion?.trim(); - if (!latestVersion) { + if (!latestVersion || !isPersistedAvailabilityForChannel({ state, channel })) { return null; } const cmp = compareSemverStrings(VERSION, latestVersion); if (cmp == null || cmp >= 0) { return null; } - const channel = state.lastAvailableTag?.trim() || DEFAULT_PACKAGE_CHANNEL; + const persistedTag = state.lastAvailableTag?.trim() || channelToNpmTag(channel); return { currentVersion: VERSION, latestVersion, - channel, + channel: persistedTag, }; } +function clearPersistedAvailabilityForChannel( + nextState: UpdateCheckState, + channel: UpdateChannel, +): void { + if (!isPersistedAvailabilityForChannel({ state: nextState, channel })) { + return; + } + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; +} + function resolveStableJitterMs(params: { installId: string; version: string; @@ -391,6 +425,21 @@ function clearAutoState(nextState: UpdateCheckState): void { delete nextState.autoFirstSeenAt; } +async function resolveStartupInstallStatus() { + const root = await resolveOpenClawPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + const status = await checkUpdateStatus({ + root, + timeoutMs: 2500, + fetchGit: false, + includeRegistry: false, + }); + return { root, status }; +} + export async function runGatewayUpdateCheck(params: { cfg: OpenClawConfig; log: { info: (msg: string, meta?: Record) => void }; @@ -409,22 +458,46 @@ export async function runGatewayUpdateCheck(params: { if (params.isNixMode) { return; } + const configuredChannel = + normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; const auto = resolveAutoUpdatePolicy(params.cfg); const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE); - const shouldRunAutoUpdate = auto.enabled && !autoDisabledByEnv; + const isAutoUpdateChannel = configuredChannel === "stable" || configuredChannel === "beta"; + const shouldRunAutoUpdate = isAutoUpdateChannel && auto.enabled && !autoDisabledByEnv; const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false; if (!shouldRunUpdateHints && !shouldRunAutoUpdate) { + if (configuredChannel === "extended-stable") { + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } return; } + let installStatus: Awaited> | undefined; + if (configuredChannel === "extended-stable") { + installStatus = await resolveStartupInstallStatus(); + if (installStatus.status.installKind !== "package") { + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + return; + } + } + const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME); const state = await readState(statePath); const rawNow = Date.now(); const now = resolveUpdateCheckNowMs(rawNow); const rawNowIsValid = asDateTimestampMs(rawNow) !== undefined; const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; + const persistedAvailable = shouldRunUpdateHints + ? resolvePersistedUpdateAvailable(state, configuredChannel) + : null; + const shouldBypassSharedThrottle = state.lastCheckedChannel !== configuredChannel; if (shouldRunUpdateHints) { - const persistedAvailable = resolvePersistedUpdateAvailable(state); setUpdateAvailableCache({ next: persistedAvailable, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -438,27 +511,24 @@ export async function runGatewayUpdateCheck(params: { const checkIntervalMs = shouldRunAutoUpdate ? resolveCheckIntervalMs(params.cfg) : UPDATE_CHECK_INTERVAL_MS; - if (rawNowIsValid && lastCheckedAt && Number.isFinite(lastCheckedAt)) { + if ( + !shouldBypassSharedThrottle && + rawNowIsValid && + lastCheckedAt && + Number.isFinite(lastCheckedAt) + ) { if (now - lastCheckedAt < checkIntervalMs) { return; } } - const root = await resolveOpenClawPackageRoot({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), - }); - const status = await checkUpdateStatus({ - root, - timeoutMs: 2500, - fetchGit: false, - includeRegistry: false, - }); + installStatus ??= await resolveStartupInstallStatus(); + const { root, status } = installStatus; const nextState: UpdateCheckState = { ...state, lastCheckedAt: resolveUpdateCheckTimestamp(now), + lastCheckedChannel: configuredChannel, }; let pendingAutoUpdateRestartDelayMs: number | null = null; @@ -474,10 +544,20 @@ export async function runGatewayUpdateCheck(params: { return; } - const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + const channel = configuredChannel; const resolved = await resolveNpmChannelTag({ channel, timeoutMs: 2500 }); const tag = resolved.tag; if (!resolved.version) { + if (channel === "extended-stable") { + clearPersistedAvailabilityForChannel(nextState, channel); + if (!nextState.lastAvailableVersion) { + nextState.lastAvailableTag = channel; + } + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } await writeState(statePath, nextState); return; } @@ -507,7 +587,7 @@ export async function runGatewayUpdateCheck(params: { nextState.lastNotifiedTag = tag; } - if (auto.enabled && autoDisabledByEnv) { + if (channel !== "extended-stable" && auto.enabled && autoDisabledByEnv) { params.log.info("auto-update disabled by OPENCLAW_NO_AUTO_UPDATE", { version: resolved.version, tag, @@ -589,9 +669,16 @@ export async function runGatewayUpdateCheck(params: { } } } else { - delete nextState.lastAvailableVersion; - delete nextState.lastAvailableTag; - clearAutoState(nextState); + if (channel === "extended-stable") { + clearPersistedAvailabilityForChannel(nextState, channel); + if (!nextState.lastAvailableVersion) { + nextState.lastAvailableTag = channel; + } + } else { + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; + clearAutoState(nextState); + } setUpdateAvailableCache({ next: null, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -615,6 +702,10 @@ export function scheduleGatewayUpdateCheck(params: { isNixMode: boolean; onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; }): () => void { + const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + if (channel === "extended-stable" && params.cfg.update?.checkOnStart === false) { + return () => {}; + } let stopped = false; let timer: ReturnType | null = null; let running = false; diff --git a/src/plugins/install-channel-specs.ts b/src/plugins/install-channel-specs.ts index 122b1b90d7b8..aada00c0cb38 100644 --- a/src/plugins/install-channel-specs.ts +++ b/src/plugins/install-channel-specs.ts @@ -38,7 +38,18 @@ function isDefaultClawHubSpecForBetaChannel(spec: string): { name: string } | nu export function resolveNpmInstallSpecsForUpdateChannel(params: { spec: string; updateChannel?: UpdateChannel; + exactVersion?: string; }): ChannelInstallSpecs { + if (params.updateChannel === "extended-stable" && params.exactVersion) { + const parsed = parseRegistryNpmSpec(params.spec); + if (parsed) { + const exactSpec = `${parsed.name}@${params.exactVersion}`; + return { + installSpec: exactSpec, + recordSpec: exactSpec, + }; + } + } if (params.updateChannel !== "beta") { return { installSpec: params.spec, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1ccefd1b7099..ab8bc1fb4f4d 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -4175,6 +4175,98 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("pins externalized official plugins to the extended-stable core version", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "legacy-chat", + targetDir: "/tmp/openclaw-plugins/legacy-chat", + version: "2026.6.33", + }), + ); + + const result = await syncPluginsForUpdateChannel({ + channel: "extended-stable", + exactOfficialPluginVersion: "2026.6.33", + externalizedBundledPluginBridges: [ + { + bundledPluginId: "legacy-chat", + preferredSource: "clawhub", + clawhubSpec: "clawhub:legacy-chat", + npmSpec: "@openclaw/legacy-chat", + channelIds: ["legacy-chat"], + }, + ], + config: { + channels: { "legacy-chat": { enabled: true } }, + plugins: { + load: { paths: [appBundledPluginRoot("legacy-chat")] }, + installs: { + "legacy-chat": { + source: "path", + sourcePath: appBundledPluginRoot("legacy-chat"), + installPath: appBundledPluginRoot("legacy-chat"), + }, + }, + }, + }, + }); + + expect(npmInstallCall()?.spec).toBe("@openclaw/legacy-chat@2026.6.33"); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(result.summary.switchedToNpm).toEqual(["legacy-chat"]); + }); + + it("moves trusted official ClawHub installs to the exact extended-stable npm version", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "discord", + targetDir: "/tmp/openclaw-plugins/discord", + version: "2026.6.33", + npmResolution: { + name: "@openclaw/discord", + version: "2026.6.33", + resolvedSpec: "@openclaw/discord@2026.6.33", + }, + }), + ); + + const result = await syncPluginsForUpdateChannel({ + channel: "extended-stable", + exactOfficialPluginVersion: "2026.6.33", + config: { + plugins: { + installs: { + discord: { + source: "clawhub", + spec: "clawhub:@openclaw/discord", + installPath: "/tmp/openclaw-plugins/discord", + version: "2026.6.8", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/discord", + clawhubChannel: "official", + }, + }, + }, + }, + }); + + expect(npmInstallCall()?.spec).toBe("@openclaw/discord@2026.6.33"); + expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(result.summary.switchedToNpm).toEqual(["discord"]); + expectRecordFields(result.config.plugins?.installs?.discord, { + source: "npm", + spec: "@openclaw/discord@2026.6.33", + installPath: "/tmp/openclaw-plugins/discord", + version: "2026.6.33", + resolvedName: "@openclaw/discord", + resolvedVersion: "2026.6.33", + resolvedSpec: "@openclaw/discord@2026.6.33", + }); + }); + it("falls back from ClawHub to npm only when the ClawHub package is absent", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromClawHubMock.mockResolvedValue({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index eb6cf69b4e8a..7adb2c43a161 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -57,6 +57,7 @@ import { import { installPluginFromMarketplace } from "./marketplace.js"; import { checkMinHostVersion } from "./min-host-version.js"; import { + resolveTrustedSourceLinkedOfficialClawHubInstall, resolveTrustedSourceLinkedOfficialClawHubSpec, resolveTrustedSourceLinkedOfficialNpmSpec, } from "./official-external-install-records.js"; @@ -939,8 +940,10 @@ function resolveClawHubUpdateSpecs(params: { function isBridgeAlreadyInstalledFromPreferredSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; + preferredSource?: "clawhub" | "npm"; }): boolean { - const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); + const preferredSource = + params.preferredSource ?? getExternalizedBundledPluginPreferredSource(params.bridge); return preferredSource === "clawhub" ? isBridgeClawHubInstall(params) : isBridgeNpmInstall(params); @@ -949,8 +952,10 @@ function isBridgeAlreadyInstalledFromPreferredSource(params: { function isBridgeInstalledFromFallbackSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; + preferredSource?: "clawhub" | "npm"; }): boolean { - const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); + const preferredSource = + params.preferredSource ?? getExternalizedBundledPluginPreferredSource(params.bridge); return preferredSource === "clawhub" ? isBridgeNpmInstall(params) : isBridgeClawHubInstall(params); @@ -2135,6 +2140,7 @@ export async function syncPluginsForUpdateChannel(params: { env?: NodeJS.ProcessEnv; logger?: PluginUpdateLogger; externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; + exactOfficialPluginVersion?: string; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; @@ -2209,11 +2215,23 @@ export async function syncPluginsForUpdateChannel(params: { continue; } + const baseNpmSpec = getExternalizedBundledPluginNpmSpec(bridge); + const parsedNpmSpec = baseNpmSpec ? parseRegistryNpmSpec(baseNpmSpec) : null; + const exactNpmSpec = + params.channel === "extended-stable" && params.exactOfficialPluginVersion && parsedNpmSpec + ? `${parsedNpmSpec.name}@${params.exactOfficialPluginVersion}` + : baseNpmSpec; + const preferredSource = + params.channel === "extended-stable" && exactNpmSpec + ? "npm" + : getExternalizedBundledPluginPreferredSource(bridge); + if ( existing && isBridgeAlreadyInstalledFromPreferredSource({ bridge, record: existing.record, + preferredSource, }) ) { if (existing.pluginId !== targetPluginId) { @@ -2235,13 +2253,13 @@ export async function syncPluginsForUpdateChannel(params: { !isBridgeInstalledFromFallbackSource({ bridge, record: existing.record, + preferredSource, }) ) { continue; } - const preferredSource = getExternalizedBundledPluginPreferredSource(bridge); - const npmSpec = getExternalizedBundledPluginNpmSpec(bridge); + const npmSpec = exactNpmSpec; const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge); const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({ targetPluginId, @@ -2366,6 +2384,69 @@ export async function syncPluginsForUpdateChannel(params: { changed = true; } + if (params.channel === "extended-stable" && params.exactOfficialPluginVersion) { + for (const [pluginId, record] of Object.entries(installs)) { + const officialInstall = resolveTrustedSourceLinkedOfficialClawHubInstall({ + pluginId, + record, + }); + const parsedNpmSpec = officialInstall?.npmSpec + ? parseRegistryNpmSpec(officialInstall.npmSpec) + : null; + if (!parsedNpmSpec) { + continue; + } + + const exactNpmSpec = `${parsedNpmSpec.name}@${params.exactOfficialPluginVersion}`; + const result = await installPluginFromNpmSpec({ + spec: exactNpmSpec, + config: params.config, + mode: "update", + expectedPluginId: pluginId, + trustedSourceLinkedOfficialInstall: true, + logger, + }); + if (!result.ok) { + const message = formatNpmInstallFailure({ + pluginId, + spec: exactNpmSpec, + phase: "update", + result, + }); + summary.errors.push(message); + logger.error?.(message); + continue; + } + + const resolvedPluginId = result.pluginId; + if (pluginId !== resolvedPluginId) { + next = migratePluginConfigId(next, pluginId, resolvedPluginId); + } + const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "npm", + spec: resolveNpmInstallRecordSpec({ + requestedSpec: exactNpmSpec, + resolution: result.npmResolution, + pinResolvedRegistrySpec: true, + }), + installPath: result.targetDir, + version: nextVersion, + ...buildNpmResolutionInstallFields(result.npmResolution), + }); + installs = next.plugins?.installs ?? {}; + if (record.sourcePath) { + loadHelpers.removePath(record.sourcePath); + } + if (record.installPath) { + loadHelpers.removePath(record.installPath); + } + summary.switchedToNpm.push(resolvedPluginId); + changed = true; + } + } + for (const [pluginId, record] of Object.entries(installs)) { const bundledInfo = bundled.get(pluginId); if (!bundledInfo) { diff --git a/test/helpers/temp-dir.ts b/test/helpers/temp-dir.ts index 67577fbedfba..a446dc0b1b18 100644 --- a/test/helpers/temp-dir.ts +++ b/test/helpers/temp-dir.ts @@ -5,8 +5,22 @@ import path from "node:path"; // Synchronous temporary directory helpers for tests. +export type TempDirCollection = string[] | Set; +export type RegisterTempDirCleanup = (cleanup: () => void) => unknown; + +export interface TestTempDirTracker { + readonly dirs: ReadonlySet; + make(prefix: string): string; + cleanup(): void; +} + +export interface AutoCleanupTempDirTracker { + readonly dirs: ReadonlySet; + make(prefix: string): string; +} + /** Create a temp dir and register it in an array or set for cleanup. */ -export function makeTempDir(tempDirs: string[] | Set, prefix: string): string { +export function makeTempDir(tempDirs: TempDirCollection, prefix: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); if (Array.isArray(tempDirs)) { tempDirs.push(dir); @@ -17,12 +31,41 @@ export function makeTempDir(tempDirs: string[] | Set, prefix: string): s } /** Remove all tracked temporary directories and clear the tracker. */ -export function cleanupTempDirs(tempDirs: string[] | Set): void { +export function cleanupTempDirs(tempDirs: TempDirCollection): void { const dirs = Array.isArray(tempDirs) ? tempDirs.splice(0) : [...tempDirs]; for (const dir of dirs) { - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); } if (!Array.isArray(tempDirs)) { tempDirs.clear(); } } + +export function createTempDirTracker(): TestTempDirTracker { + const dirs = new Set(); + return { + dirs, + make(prefix: string): string { + return makeTempDir(dirs, prefix); + }, + cleanup(): void { + cleanupTempDirs(dirs); + }, + }; +} + +/** Create a temp dir tracker that Vitest cleans up after each test. */ +export function useAutoCleanupTempDirTracker( + registerCleanup: RegisterTempDirCleanup, +): AutoCleanupTempDirTracker { + const tracker = createTempDirTracker(); + registerCleanup(() => { + tracker.cleanup(); + }); + return { + dirs: tracker.dirs, + make(prefix: string): string { + return tracker.make(prefix); + }, + }; +} diff --git a/test/npm-publish-plan.test.ts b/test/npm-publish-plan.test.ts index 84a367f993fa..4a25e603fc39 100644 --- a/test/npm-publish-plan.test.ts +++ b/test/npm-publish-plan.test.ts @@ -2,11 +2,234 @@ import { describe, expect, it } from "vitest"; import { collectReleaseVersionFloorErrors, + fetchNpmRegistryPackumentWithRetry, resolveNpmDistTagMirrorAuth, resolveNpmPublishPlan, + resolvePublishedNpmVersionRoute, shouldRequireNpmDistTagMirrorAuth, } from "../scripts/lib/npm-publish-plan.mjs"; +function registryResponse(params: { + status?: number; + body?: string; + bodyError?: Error; + cancel?: () => void; +}): Response { + const status = params.status ?? 200; + const bytes = new TextEncoder().encode(params.body ?? "{}"); + let delivered = false; + return { + status, + ok: status >= 200 && status < 300, + headers: new Headers(), + body: { + cancel: async () => { + params.cancel?.(); + }, + getReader: () => ({ + cancel: async () => { + params.cancel?.(); + }, + read: async () => { + if (params.bodyError) { + throw params.bodyError; + } + if (delivered) { + return { done: true, value: undefined }; + } + delivered = true; + return { done: false, value: bytes }; + }, + releaseLock: () => {}, + }), + }, + } as unknown as Response; +} + +describe("fetchNpmRegistryPackumentWithRetry", () => { + it("retries a failed response body before returning the parsed packument", async () => { + const waits: number[] = []; + let fetchCalls = 0; + let cancelCalls = 0; + const packument = { versions: { "2026.7.1-beta.3": {} } }; + + const result = await fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + fetchImpl: async () => { + fetchCalls += 1; + return registryResponse( + fetchCalls === 1 + ? { + bodyError: new TypeError("terminated"), + cancel: () => { + cancelCalls += 1; + }, + } + : { body: JSON.stringify(packument) }, + ); + }, + sleep: async (delayMs) => { + waits.push(delayMs); + }, + createSignal: () => new AbortController().signal, + }); + + expect(result).toEqual({ status: 200, ok: true, packument }); + expect(fetchCalls).toBe(2); + expect(cancelCalls).toBe(1); + expect(waits).toEqual([1000]); + }); + + it("keeps response body failures within the bounded retry budget", async () => { + const waits: number[] = []; + let fetchCalls = 0; + let cancelCalls = 0; + + await expect( + fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + fetchImpl: async () => { + fetchCalls += 1; + return registryResponse({ + bodyError: new DOMException("timed out", "AbortError"), + cancel: () => { + cancelCalls += 1; + }, + }); + }, + sleep: async (delayMs) => { + waits.push(delayMs); + }, + createSignal: () => new AbortController().signal, + }), + ).rejects.toThrow("npm publication-route probe did not return a stable response"); + + expect(fetchCalls).toBe(3); + expect(cancelCalls).toBe(3); + expect(waits).toEqual([1000, 2000]); + }); + + it("retries malformed JSON before returning the parsed packument", async () => { + let fetchCalls = 0; + let cancelCalls = 0; + const waits: number[] = []; + const packument = { versions: { "2026.7.1-beta.3": {} } }; + + const result = await fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + fetchImpl: async () => { + fetchCalls += 1; + return registryResponse( + fetchCalls === 1 + ? { + body: "{", + cancel: () => { + cancelCalls += 1; + }, + } + : { body: JSON.stringify(packument) }, + ); + }, + sleep: async (delayMs) => { + waits.push(delayMs); + }, + createSignal: () => new AbortController().signal, + }); + + expect(result).toEqual({ status: 200, ok: true, packument }); + expect(fetchCalls).toBe(2); + expect(cancelCalls).toBe(1); + expect(waits).toEqual([1000]); + }); + + it("keeps malformed JSON within the bounded retry budget", async () => { + let fetchCalls = 0; + let cancelCalls = 0; + const waits: number[] = []; + + await expect( + fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + fetchImpl: async () => { + fetchCalls += 1; + return registryResponse({ + body: "{", + cancel: () => { + cancelCalls += 1; + }, + }); + }, + sleep: async (delayMs) => { + waits.push(delayMs); + }, + createSignal: () => new AbortController().signal, + }), + ).rejects.toThrow("npm publication-route probe returned invalid JSON"); + + expect(fetchCalls).toBe(3); + expect(cancelCalls).toBe(3); + expect(waits).toEqual([1000, 2000]); + }); + + it("rejects oversized npm packuments by content length", async () => { + await expect( + fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + attempts: 1, + maxBytes: 8, + fetchImpl: async () => + new Response('{"versions":{}}', { headers: { "content-length": "64" } }), + createSignal: () => new AbortController().signal, + }), + ).rejects.toThrow("npm packument response body exceeded 8 bytes"); + }); + + it("rejects oversized streamed npm packuments", async () => { + await expect( + fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + attempts: 1, + maxBytes: 8, + fetchImpl: async () => new Response('{"versions":{}}'), + createSignal: () => new AbortController().signal, + }), + ).rejects.toThrow("npm packument response body exceeded 8 bytes"); + }); + + it("returns a stable missing-package status without retrying", async () => { + let fetchCalls = 0; + let cancelCalls = 0; + + const result = await fetchNpmRegistryPackumentWithRetry({ + packageName: "@openclaw/meta-provider", + packageUrl: "https://registry.npmjs.org/%40openclaw%2Fmeta-provider", + fetchImpl: async () => { + fetchCalls += 1; + return registryResponse({ + status: 404, + cancel: () => { + cancelCalls += 1; + }, + }); + }, + sleep: async () => { + throw new Error("stable 404 must not sleep"); + }, + createSignal: () => new AbortController().signal, + }); + + expect(result).toEqual({ status: 404, ok: false, packument: null }); + expect(fetchCalls).toBe(1); + expect(cancelCalls).toBe(1); + }); +}); + describe("collectReleaseVersionFloorErrors", () => { it("blocks June 2026 stable and beta release trains below the published beta floor", () => { expect(collectReleaseVersionFloorErrors("2026.6.4")).toEqual([ @@ -24,6 +247,127 @@ describe("collectReleaseVersionFloorErrors", () => { }); }); +describe("resolvePublishedNpmVersionRoute", () => { + it.each([ + { + label: "missing beta", + version: "2026.7.1-beta.3", + distTags: {}, + }, + { + label: "lagging beta", + version: "2026.7.1-beta.3", + distTags: { beta: "2026.7.1-beta.2" }, + }, + { + label: "lagging alpha", + version: "2026.7.1-alpha.3", + distTags: { alpha: "2026.7.1-alpha.2" }, + }, + { + label: "lagging latest with a current beta mirror", + version: "2026.7.1", + distTags: { latest: "2026.6.11", beta: "2026.7.1" }, + }, + ])( + "requires tag repair when the primary $label selector is repairable", + ({ version, distTags }) => { + expect( + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags, + }), + ).toBe("npm-tag-repair"); + }, + ); + + it.each([ + ["ahead beta", "2026.7.1-beta.3", { beta: "2026.7.1-beta.4" }], + ["ahead alpha", "2026.7.1-alpha.3", { alpha: "2026.7.1-alpha.4" }], + ["ahead latest", "2026.7.1", { latest: "2026.8.1" }], + ["incomparable beta", "2026.7.1-beta.3", { beta: "not-a-version" }], + ["conflicting beta", "2026.7.1-beta.3", { beta: " 2026.7.1-beta.3 " }], + ])("rejects an unsafe primary %s selector", (_label, version, distTags) => { + expect(() => + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags, + }), + ).toThrow("cannot be safely moved"); + }); + + it("requires mirror repair only after the primary selector matches", () => { + const version = "2026.7.1"; + expect( + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags: { latest: version, beta: "2026.7.1-beta.3" }, + }), + ).toBe("npm-mirror"); + }); + + it("rejects an incomparable mirror instead of advertising repair", () => { + const version = "2026.7.1"; + expect(() => + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags: { latest: version, beta: "not-a-version" }, + }), + ).toThrow("cannot be safely moved"); + }); + + it("validates unsafe mirrors before returning primary tag repair", () => { + const version = "2026.7.1"; + expect(() => + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags: { latest: "2026.6.11", beta: "not-a-version" }, + }), + ).toThrow("cannot be safely moved"); + }); + + it("rejects an ahead mirror from an inconsistent publish plan", () => { + const version = "2026.7.1"; + expect(() => + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags: { latest: version, beta: "2026.8.1-beta.1" }, + }), + ).toThrow("cannot be safely moved"); + }); + + it("preserves an ahead beta selector when the publish plan omits the mirror", () => { + const version = "2026.7.1"; + expect( + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version, "2026.8.1-beta.1"), + distTags: { latest: version, beta: "2026.8.1-beta.1" }, + }), + ).toBe("npm-readback"); + }); + + it.each([ + ["beta", "2026.7.1-beta.3", { beta: "2026.7.1-beta.3" }], + ["alpha", "2026.7.1-alpha.3", { alpha: "2026.7.1-alpha.3" }], + ["stable", "2026.7.1", { latest: "2026.7.1", beta: "2026.7.1" }], + ])("accepts complete %s registry readback", (_label, version, distTags) => { + expect( + resolvePublishedNpmVersionRoute({ + packageVersion: version, + publishPlan: resolveNpmPublishPlan(version), + distTags, + }), + ).toBe("npm-readback"); + }); +}); + describe("shouldRequireNpmDistTagMirrorAuth", () => { it("does not require npm auth for dry-run preview commands", () => { const plan = resolveNpmPublishPlan("2026.4.1"); diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 8260ef28964a..b18ba8d910a3 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -655,8 +655,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => { const plan = await buildOpenClawReleaseClawHubPlan( { + bootstrapWorkflowSha: "a".repeat(40), releaseTag: "v2026.4.1-beta.1", + releaseSha: "b".repeat(40), releasePublishBranch: "main", + releasePublishRunAttempt: "1", releasePublishRunId: "12345", pluginPublishScope: "all-publishable", plugins: [], @@ -684,11 +687,15 @@ describe("buildOpenClawReleaseClawHubPlan", () => { }); expect(plan.bootstrap).toEqual({ workflow: "plugin-clawhub-new.yml", - ref: "v2026.4.1-beta.1", + ref: "main", shouldDispatch: true, packages: ["@openclaw/demo-two", "@openclaw/demo-three"], inputs: { + bootstrap_workflow_sha: "a".repeat(40), + ref: "b".repeat(40), + release_tag: "v2026.4.1-beta.1", plugins: "@openclaw/demo-two,@openclaw/demo-three", + release_publish_run_attempt: "1", release_publish_run_id: "12345", release_publish_branch: "main", }, @@ -734,8 +741,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => { const plan = await buildOpenClawReleaseClawHubPlan( { + bootstrapWorkflowSha: "a".repeat(40), releaseTag: "v2026.4.1-beta.1", + releaseSha: "b".repeat(40), releasePublishBranch: "release/2026.4.1", + releasePublishRunAttempt: "1", releasePublishRunId: "12345", pluginPublishScope: "selected", plugins: ["@openclaw/demo-plugin"], @@ -750,11 +760,15 @@ describe("buildOpenClawReleaseClawHubPlan", () => { expect(plan.normal.shouldDispatch).toBe(false); expect(plan.bootstrap).toMatchObject({ workflow: "plugin-clawhub-new.yml", - ref: "v2026.4.1-beta.1", + ref: "main", shouldDispatch: true, packages: ["@openclaw/demo-plugin"], inputs: { + bootstrap_workflow_sha: "a".repeat(40), + ref: "b".repeat(40), + release_tag: "v2026.4.1-beta.1", plugins: "@openclaw/demo-plugin", + release_publish_run_attempt: "1", release_publish_run_id: "12345", release_publish_branch: "release/2026.4.1", }, diff --git a/test/scripts/clawhub-bootstrap-artifact.test.ts b/test/scripts/clawhub-bootstrap-artifact.test.ts new file mode 100644 index 000000000000..4cf1cacf9416 --- /dev/null +++ b/test/scripts/clawhub-bootstrap-artifact.test.ts @@ -0,0 +1,537 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createClawHubBootstrapArtifactManifest, + downloadClawHubBootstrapArtifact, + verifyClawHubPackedArtifactIdentity, + verifyClawHubBootstrapArtifactManifest, +} from "../../scripts/lib/clawhub-bootstrap-artifact.mjs"; + +const tempDirs: string[] = []; +const targetSha = "a".repeat(40); +const workflowSha = "b".repeat(40); +const clawhubToolchainSha256 = "c".repeat(64); +const clawhubToolchainVersion = "0.23.1"; +const clawhubToolchainIntegrity = + "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA=="; + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-bootstrap-")); + tempDirs.push(root); + const artifactRoot = join(root, "artifact"); + const packageRoot = join(artifactRoot, "packages", "meta"); + const existingPackageRoot = join(artifactRoot, "packages", "existing"); + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(existingPackageRoot, { recursive: true }); + writeFileSync(join(packageRoot, "openclaw-meta-2026.7.1-beta.3.tgz"), "packed meta"); + writeFileSync( + join(existingPackageRoot, "openclaw-existing-2026.7.1-beta.3.tgz"), + "packed existing", + ); + const matrixPath = join(root, "matrix.json"); + writeFileSync( + matrixPath, + JSON.stringify([ + { + packageName: "@openclaw/meta", + version: "2026.7.1-beta.3", + packageDir: "extensions/meta", + publishTag: "beta", + bootstrapMode: "publish", + requiresManualOverride: false, + }, + { + packageName: "@openclaw/existing", + version: "2026.7.1-beta.3", + packageDir: "extensions/existing", + publishTag: "beta", + bootstrapMode: "configure-only", + requiresManualOverride: true, + }, + ]), + ); + return { + artifactRoot, + matrixPath, + manifestPath: join(artifactRoot, "manifest.json"), + }; +} + +function common(paths: ReturnType) { + return { + artifactRoot: paths.artifactRoot, + artifactName: `clawhub-bootstrap-${targetSha.slice(0, 12)}-123-2`, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + plugins: "@openclaw/meta,@openclaw/existing", + repository: "openclaw/openclaw", + runAttempt: "2", + runId: "123", + targetSha, + workflowSha, + }; +} + +function writeTarField(header: Buffer, offset: number, length: number, value: string) { + const bytes = Buffer.from(value); + if (bytes.byteLength > length) { + throw new Error(`tar field exceeds ${length} bytes`); + } + bytes.copy(header, offset); +} + +function writeTarOctal(header: Buffer, offset: number, length: number, value: number) { + writeTarField(header, offset, length, `${value.toString(8).padStart(length - 2, "0")} \0`); +} + +function tarEntry( + name: string, + prefix: string, + contents: string | Uint8Array, + type: "0" | "5" = "0", +) { + const bytes = Buffer.from(contents); + const header = Buffer.alloc(512); + writeTarField(header, 0, 100, name); + writeTarOctal(header, 100, 8, type === "5" ? 0o755 : 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, bytes.byteLength); + writeTarOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + header[156] = type.charCodeAt(0); + writeTarField(header, 257, 6, "ustar\0"); + writeTarField(header, 263, 2, "00"); + writeTarOctal(header, 329, 8, 0); + writeTarOctal(header, 337, 8, 0); + writeTarField(header, 345, 155, prefix); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeTarOctal(header, 148, 8, checksum); + const padding = Buffer.alloc((512 - (bytes.byteLength % 512)) % 512); + return Buffer.concat([header, bytes, padding]); +} + +function writeClawPack( + entries: Array<{ + name: string; + prefix?: string; + contents: string | Uint8Array; + type?: "0" | "5"; + }>, +) { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-packed-")); + tempDirs.push(root); + const bytes = gzipSync( + Buffer.concat([ + ...entries.map((entry) => + tarEntry(entry.name, entry.prefix ?? "", entry.contents, entry.type), + ), + Buffer.alloc(1024), + ]), + ); + const artifactPath = join(root, "package.tgz"); + writeFileSync(artifactPath, bytes); + return { + artifactPath, + bytes, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; +} + +describe("ClawHub bootstrap artifact manifest", () => { + it("binds the exact package set and packed file identity", async () => { + const paths = fixture(); + const created = await createClawHubBootstrapArtifactManifest({ + ...common(paths), + matrixPath: paths.matrixPath, + outputPath: paths.manifestPath, + }); + const meta = created.entries.find((entry) => entry.packageName === "@openclaw/meta"); + expect(meta).toMatchObject({ + artifactPath: "packages/meta/openclaw-meta-2026.7.1-beta.3.tgz", + size: 11, + }); + expect(meta?.sha256).toMatch(/^[a-f0-9]{64}$/u); + expect(created.clawhubToolchainIntegrity).toBe(clawhubToolchainIntegrity); + expect(created.clawhubToolchainSha256).toBe(clawhubToolchainSha256); + expect(created.clawhubToolchainVersion).toBe(clawhubToolchainVersion); + + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).resolves.toEqual(created); + }); + + it("rejects changed bytes and extra artifact files", async () => { + const paths = fixture(); + await createClawHubBootstrapArtifactManifest({ + ...common(paths), + matrixPath: paths.matrixPath, + outputPath: paths.manifestPath, + }); + writeFileSync( + join(paths.artifactRoot, "packages", "meta", "openclaw-meta-2026.7.1-beta.3.tgz"), + "changed", + ); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("packed artifact hash or size mismatch"); + + const manifest = JSON.parse(readFileSync(paths.manifestPath, "utf8")); + writeFileSync( + join(paths.artifactRoot, "packages", "meta", "openclaw-meta-2026.7.1-beta.3.tgz"), + "packed meta", + ); + writeFileSync(join(paths.artifactRoot, "unexpected.txt"), "unexpected"); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("artifact inventory mismatch"); + expect(manifest.entries).toHaveLength(2); + }); + + it("uses one exact fatal-UTF8 manifest schema for local and archive consumers", async () => { + const paths = fixture(); + const created = await createClawHubBootstrapArtifactManifest({ + ...common(paths), + matrixPath: paths.matrixPath, + outputPath: paths.manifestPath, + }); + + writeFileSync(paths.manifestPath, JSON.stringify({ ...created, unexpected: true })); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("keys are invalid"); + + writeFileSync(paths.manifestPath, Buffer.from([0xff])); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("not valid UTF-8 JSON"); + + writeFileSync(paths.manifestPath, JSON.stringify(created)); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + clawhubToolchainSha256: "d".repeat(64), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("clawhubToolchainSha256 mismatch"); + + writeFileSync( + paths.manifestPath, + JSON.stringify({ + ...created, + entries: [null, ...created.entries.slice(1)], + }), + ); + await expect( + verifyClawHubBootstrapArtifactManifest({ + ...common(paths), + manifestPath: paths.manifestPath, + }), + ).rejects.toThrow("manifest.entries[0] must be an object"); + }); + + it("binds exact target bytes to configure-only repairs", async () => { + const paths = fixture(); + const manifest = await createClawHubBootstrapArtifactManifest({ + ...common(paths), + matrixPath: paths.matrixPath, + outputPath: paths.manifestPath, + }); + const existing = manifest.entries.find((entry) => entry.packageName === "@openclaw/existing"); + expect(existing).toMatchObject({ + artifactPath: "packages/existing/openclaw-existing-2026.7.1-beta.3.tgz", + size: 15, + }); + expect(existing?.sha256).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("rejects preexisting and symlinked download output roots before fetching", async () => { + const paths = fixture(); + const downloadOptions = { + artifactDigest: "d".repeat(64), + artifactId: "456", + artifactName: `clawhub-bootstrap-${targetSha.slice(0, 12)}-123-2`, + artifactSize: "1", + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + consumerRunAttempt: "2", + repository: "openclaw/openclaw", + producerJobName: "Pack immutable ClawHub bootstrap artifacts", + runAttempt: "2", + runId: "123", + targetSha, + token: "test-token", + workflowSha, + fetchImpl: (() => { + throw new Error("fetch must not run"); + }) as typeof fetch, + }; + + const existingRoot = join(paths.artifactRoot, "existing-output"); + mkdirSync(existingRoot); + await expect( + downloadClawHubBootstrapArtifact({ + ...downloadOptions, + outputRoot: existingRoot, + }), + ).rejects.toThrow("output directory must not already exist"); + + const symlinkTarget = join(paths.artifactRoot, "symlink-target"); + const symlinkRoot = join(paths.artifactRoot, "symlink-output"); + mkdirSync(symlinkTarget); + symlinkSync(symlinkTarget, symlinkRoot); + await expect( + downloadClawHubBootstrapArtifact({ + ...downloadOptions, + outputRoot: symlinkRoot, + }), + ).rejects.toThrow("output directory must not already exist"); + }); +}); + +describe("ClawHub packed artifact identity", () => { + const expectedIdentity = { + expectedDir: "extensions/meta", + expectedName: "@openclaw/meta-provider", + expectedVersion: "2026.7.1-beta.3", + }; + + it("accepts one canonical package identity and plugin manifest", async () => { + const pack = writeClawPack([ + { + name: "package.json", + prefix: "package", + contents: JSON.stringify({ + name: "@openclaw/meta-provider", + version: "2026.7.1-beta.3", + openclaw: { + release: { + publishToClawHub: true, + publishToNpm: true, + }, + }, + }), + }, + { + name: "openclaw.plugin.json", + prefix: "package", + contents: JSON.stringify({ id: "meta" }), + }, + ]); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + expectedDir: "extensions/meta", + expectedName: "@openclaw/meta-provider", + expectedVersion: "2026.7.1-beta.3", + }), + ).resolves.toMatchObject({ + packageName: "@openclaw/meta-provider", + packageVersion: "2026.7.1-beta.3", + sha256: pack.sha256, + size: pack.bytes.byteLength, + }); + }); + + it("rejects a whitespace-bearing alias before a later package.json", async () => { + const pack = writeClawPack([ + { + name: " package.json ", + prefix: " package ", + contents: JSON.stringify({ + name: "@openclaw/meta-provider", + version: "2026.7.1-beta.3", + }), + }, + { + name: "package/package.json", + contents: JSON.stringify({ + name: "@openclaw/other", + version: "9.9.9", + }), + }, + { + name: "package/openclaw.plugin.json", + contents: JSON.stringify({ id: "meta" }), + }, + ]); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + expectedDir: "extensions/meta", + expectedName: "@openclaw/meta-provider", + expectedVersion: "2026.7.1-beta.3", + }), + ).rejects.toThrow("changes under the pinned ClawHub path normalization"); + }); + + it("rejects a whitespace-bearing alias after a canonical package.json", async () => { + const pack = writeClawPack([ + { + name: "package/package.json", + contents: JSON.stringify({ + name: "@openclaw/meta-provider", + version: "2026.7.1-beta.3", + }), + }, + { + name: " package.json ", + prefix: " package ", + contents: JSON.stringify({ + name: "@openclaw/other", + version: "9.9.9", + }), + }, + ]); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + ...expectedIdentity, + }), + ).rejects.toThrow("changes under the pinned ClawHub path normalization"); + }); + + it("rejects a compressed artifact above the ClawHub package limit before reading it", async () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-packed-limit-")); + tempDirs.push(root); + const artifactPath = join(root, "oversized.tgz"); + writeFileSync(artifactPath, ""); + truncateSync(artifactPath, 120 * 1024 * 1024 + 1); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath, + expectedSha256: "a".repeat(64), + expectedSize: "1", + ...expectedIdentity, + }), + ).rejects.toThrow("exceeds 125829120 bytes"); + }); + + it("bounds expanded tar bytes", async () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-expanded-limit-")); + tempDirs.push(root); + const artifactPath = join(root, "expanded.tgz"); + const bytes = gzipSync(Buffer.alloc(64 * 1024 * 1024 + 1)); + writeFileSync(artifactPath, bytes); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath, + expectedSha256: createHash("sha256").update(bytes).digest("hex"), + expectedSize: String(bytes.byteLength), + ...expectedIdentity, + }), + ).rejects.toThrow("expands beyond 67108864 bytes"); + }); + + it("bounds individual file payloads", async () => { + const pack = writeClawPack([ + { + name: "package/large.bin", + contents: Buffer.alloc(50 * 1024 * 1024 + 1), + }, + ]); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + ...expectedIdentity, + }), + ).rejects.toThrow("entry package/large.bin exceeds 52428800 bytes"); + }); + + it("bounds total file payload bytes", async () => { + const pack = writeClawPack( + Array.from({ length: 6 }, (_, index) => ({ + name: `package/chunk-${index}.bin`, + contents: Buffer.alloc(9 * 1024 * 1024), + })), + ); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + ...expectedIdentity, + }), + ).rejects.toThrow("file payload exceeds 52428800 bytes"); + }); + + it("bounds the total number of TAR entries", async () => { + const entries: Array<{ + name: string; + contents: string; + type: "0" | "5"; + }> = Array.from({ length: 10_000 }, (_, index) => ({ + name: `package/dir-${index}/`, + contents: "", + type: "5", + })); + entries.push({ + name: "package/package.json", + contents: "{}", + type: "0", + }); + const pack = writeClawPack(entries); + + await expect( + verifyClawHubPackedArtifactIdentity({ + artifactPath: pack.artifactPath, + expectedSha256: pack.sha256, + expectedSize: String(pack.bytes.byteLength), + ...expectedIdentity, + }), + ).rejects.toThrow("exceeds the 10000 entry limit"); + }); +}); diff --git a/test/scripts/npm-telegram-live.test.ts b/test/scripts/npm-telegram-live.test.ts index 8ac1213f4a20..7e07c3b57045 100644 --- a/test/scripts/npm-telegram-live.test.ts +++ b/test/scripts/npm-telegram-live.test.ts @@ -116,6 +116,7 @@ describe("package Telegram live Docker E2E", () => { it("can install a resolved package tarball instead of a registry spec", () => { const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); + expect(script).toContain("alpha|beta|extended-stable|latest"); expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ"); expect(script).toContain("OPENCLAW_CURRENT_PACKAGE_TGZ"); expect(script).toContain('-e OPENCLAW_QA_PACKAGE_SOURCE="$package_install_source"'); @@ -130,6 +131,22 @@ describe("package Telegram live Docker E2E", () => { ); }); + it("installs prepared root and companion tarballs through an exact local registry", () => { + const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); + + expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR"); + expect(script).toContain('package_source_kind="prepared-package-set"'); + expect(script).toContain('package_install_source="openclaw@$(read_package_version'); + expect(script).toContain('-v "$resolved_package_dir:/package-under-test:ro"'); + expect(script).toContain( + '-v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro"', + ); + expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_SET"); + expect(script).toContain("node /tmp/openclaw-npm-registry-server.mjs"); + expect(script).toContain("OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org"); + expect(script).toContain('export NPM_CONFIG_REGISTRY="$registry_url"'); + }); + it("keeps live Docker artifacts isolated by default", () => { const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); diff --git a/test/scripts/openclaw-cross-os-release-workflow.test.ts b/test/scripts/openclaw-cross-os-release-workflow.test.ts index 27daaae44dbc..580b61dea1b3 100644 --- a/test/scripts/openclaw-cross-os-release-workflow.test.ts +++ b/test/scripts/openclaw-cross-os-release-workflow.test.ts @@ -1,11 +1,53 @@ // Openclaw Cross Os Release Workflow tests cover openclaw cross os release workflow script behavior. import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; const WORKFLOW_PATH = ".github/workflows/openclaw-cross-os-release-checks-reusable.yml"; +const RELEASE_CHECKS_PATH = ".github/workflows/openclaw-release-checks.yml"; const WRAPPER_PATH = "scripts/github/run-openclaw-cross-os-release-checks.sh"; const HARNESS = "bash workflow/scripts/github/run-openclaw-cross-os-release-checks.sh"; +type WorkflowStep = { + env?: Record; + id?: string; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + outputs?: Record; + steps?: WorkflowStep[]; + with?: Record; +}; + +type Workflow = { + jobs: Record; + on?: { + workflow_call?: { inputs?: Record> }; + workflow_dispatch?: { inputs?: Record> }; + }; +}; + +function readWorkflow(path: string): Workflow { + return parse(readFileSync(path, "utf8")) as Workflow; +} + +function job(workflow: Workflow, name: string): WorkflowJob { + const found = workflow.jobs[name]; + expect(found, name).toBeDefined(); + return found!; +} + +function step(workflowJob: WorkflowJob, name: string): WorkflowStep { + const found = workflowJob.steps?.find((candidate) => candidate.name === name); + expect(found, name).toBeDefined(); + return found!; +} + describe("cross-OS release checks workflow", () => { it("runs the TypeScript release harness through the Windows-safe wrapper", () => { const workflow = readFileSync(WORKFLOW_PATH, "utf8"); @@ -22,7 +64,273 @@ describe("cross-OS release checks workflow", () => { expect(workflow).toContain("timeout --preserve-status 300s npm pack --ignore-scripts"); }); - it("uses Windows-safe npm resolution for the TypeScript loader bootstrap", () => { + it("keeps release artifact tarball filenames local before upload paths use them", () => { + const workflow = readFileSync(WORKFLOW_PATH, "utf8"); + + expect(workflow.match(/function resolveTarballFileName/g)).toHaveLength(1); + expect(workflow.match(/path\.win32\.basename\(fileName\)/g)).toHaveLength(2); + expect(workflow).toContain("candidate_file_name"); + expect(workflow).toContain("Baseline npm pack filename"); + expect(workflow).toContain("fileName !== path.basename(fileName)"); + expect(workflow).toContain("fileName !== path.win32.basename(fileName)"); + expect(workflow).toContain("process.stdout.write(`file_name=${fileName}\\n`);"); + }); + + it("binds the prepared release package to an immutable artifact and package tuple", () => { + const release = readWorkflow(RELEASE_CHECKS_PATH); + const producer = job(release, "prepare_release_package"); + expect(producer.outputs).toMatchObject({ + artifact_digest: "${{ steps.release_package_upload.outputs.artifact-digest }}", + artifact_id: "${{ steps.release_package_upload.outputs.artifact-id }}", + artifact_name: "${{ steps.artifact.outputs.name }}", + artifact_run_attempt: "${{ steps.artifact.outputs.run_attempt }}", + artifact_run_id: "${{ steps.artifact.outputs.run_id }}", + package_file_name: "${{ steps.artifact.outputs.file_name }}", + package_sha256: "${{ steps.package.outputs.sha256 }}", + package_version: "${{ steps.package.outputs.package_version }}", + source_sha: "${{ steps.package.outputs.source_sha }}", + }); + expect(step(producer, "Checkout trusted workflow ref").with).toMatchObject({ + ref: "${{ github.sha }}", + "persist-credentials": false, + }); + + const metadata = step(producer, "Set artifact metadata"); + expect(metadata.run).toContain( + "name=release-package-under-test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}", + ); + expect(metadata.run).toContain("file_name=openclaw-current.tgz"); + expect(metadata.run).toContain("run_attempt=${GITHUB_RUN_ATTEMPT}"); + expect(metadata.run).toContain("run_id=${GITHUB_RUN_ID}"); + + const upload = step(producer, "Upload release package artifact"); + expect(upload.id).toBe("release_package_upload"); + expect(upload.with).toMatchObject({ + name: "${{ steps.artifact.outputs.name }}", + "if-no-files-found": "error", + }); + + const binding = step(producer, "Validate release package artifact binding"); + expect(binding.env).toMatchObject({ + ARTIFACT_DIGEST: "${{ steps.release_package_upload.outputs.artifact-digest }}", + ARTIFACT_ID: "${{ steps.release_package_upload.outputs.artifact-id }}", + ARTIFACT_RUN_ATTEMPT: "${{ steps.artifact.outputs.run_attempt }}", + ARTIFACT_RUN_ID: "${{ steps.artifact.outputs.run_id }}", + PACKAGE_SHA256: "${{ steps.package.outputs.sha256 }}", + PACKAGE_SOURCE_SHA: "${{ steps.package.outputs.source_sha }}", + PACKAGE_VERSION: "${{ steps.package.outputs.package_version }}", + }); + expect(binding.run).toContain('[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]]'); + expect(binding.run).toContain('"$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID"'); + expect(binding.run).toContain('"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"'); + expect(binding.run).toContain('"$PACKAGE_SHA256" =~ ^[a-f0-9]{64}$'); + expect(binding.run).toContain('"$PACKAGE_SOURCE_SHA" =~ ^[a-f0-9]{40}$'); + + const crossOs = job(release, "cross_os_release_checks"); + expect(crossOs.with).toMatchObject({ + candidate_artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", + candidate_artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", + candidate_artifact_name: "${{ needs.prepare_release_package.outputs.artifact_name }}", + candidate_artifact_run_attempt: + "${{ needs.prepare_release_package.outputs.artifact_run_attempt }}", + candidate_artifact_run_id: "${{ needs.prepare_release_package.outputs.artifact_run_id }}", + candidate_file_name: "${{ needs.prepare_release_package.outputs.package_file_name }}", + candidate_sha256: "${{ needs.prepare_release_package.outputs.package_sha256 }}", + candidate_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}", + candidate_version: "${{ needs.prepare_release_package.outputs.package_version }}", + }); + + expect(job(release, "docker_e2e_release_checks").with).toMatchObject({ + package_artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", + package_artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", + package_artifact_name: "${{ needs.prepare_release_package.outputs.artifact_name }}", + package_artifact_run_attempt: + "${{ needs.prepare_release_package.outputs.artifact_run_attempt }}", + package_artifact_run_id: "${{ needs.prepare_release_package.outputs.artifact_run_id }}", + package_file_name: "${{ needs.prepare_release_package.outputs.package_file_name }}", + package_sha256: "${{ needs.prepare_release_package.outputs.package_sha256 }}", + package_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}", + package_version: "${{ needs.prepare_release_package.outputs.package_version }}", + }); + expect(job(release, "package_acceptance_release_checks").with).toMatchObject({ + artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", + artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", + artifact_name: "${{ needs.prepare_release_package.outputs.artifact_name }}", + artifact_run_attempt: "${{ needs.prepare_release_package.outputs.artifact_run_attempt }}", + artifact_run_id: "${{ needs.prepare_release_package.outputs.artifact_run_id }}", + package_file_name: "${{ needs.prepare_release_package.outputs.package_file_name }}", + package_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}", + package_version: "${{ needs.prepare_release_package.outputs.package_version }}", + workflow_ref: "${{ github.sha }}", + }); + }); + + it("downloads and re-exports exact candidate artifacts only by immutable id", () => { + const workflow = readWorkflow(WORKFLOW_PATH); + for (const inputName of [ + "candidate_artifact_digest", + "candidate_artifact_id", + "candidate_artifact_name", + "candidate_artifact_run_attempt", + "candidate_artifact_run_id", + "candidate_file_name", + "candidate_sha256", + "candidate_source_sha", + "candidate_version", + ]) { + expect(workflow.on?.workflow_dispatch?.inputs?.[inputName], inputName).toMatchObject({ + default: "", + type: "string", + }); + expect(workflow.on?.workflow_call?.inputs?.[inputName], inputName).toMatchObject({ + default: "", + type: "string", + }); + } + + const prepare = job(workflow, "prepare"); + expect(prepare.outputs).toMatchObject({ + baseline_artifact_digest: "${{ steps.upload_baseline.outputs.artifact-digest }}", + baseline_artifact_id: "${{ steps.upload_baseline.outputs.artifact-id }}", + baseline_artifact_run_attempt: "${{ github.run_attempt }}", + baseline_artifact_run_id: "${{ github.run_id }}", + baseline_sha256: "${{ steps.baseline_metadata.outputs.sha256 }}", + candidate_artifact_digest: "${{ steps.upload_candidate.outputs.artifact-digest }}", + candidate_artifact_id: "${{ steps.upload_candidate.outputs.artifact-id }}", + candidate_artifact_run_attempt: "${{ github.run_attempt }}", + candidate_artifact_run_id: "${{ github.run_id }}", + candidate_sha256: "${{ steps.candidate_metadata.outputs.sha256 }}", + candidate_version: "${{ steps.candidate_metadata.outputs.version }}", + source_sha: "${{ steps.candidate_metadata.outputs.source_sha }}", + }); + for (const [jobName, workflowJob] of Object.entries(workflow.jobs)) { + for (const checkout of workflowJob.steps?.filter((candidate) => + candidate.uses?.startsWith("actions/checkout@"), + ) ?? []) { + expect(checkout.with?.["persist-credentials"], `${jobName}:${checkout.name}`).toBe(false); + } + } + + const inputBinding = step(prepare, "Validate provided candidate artifact binding"); + expect(inputBinding.env).toMatchObject({ + ARTIFACT_DIGEST: "${{ inputs.candidate_artifact_digest }}", + ARTIFACT_ID: "${{ inputs.candidate_artifact_id }}", + ARTIFACT_RUN_ATTEMPT: "${{ inputs.candidate_artifact_run_attempt }}", + ARTIFACT_RUN_ID: "${{ inputs.candidate_artifact_run_id }}", + CANDIDATE_SHA256: "${{ inputs.candidate_sha256 }}", + CANDIDATE_SOURCE_SHA: "${{ inputs.candidate_source_sha }}", + CANDIDATE_VERSION: "${{ inputs.candidate_version }}", + }); + expect(inputBinding.run).toContain('! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$'); + expect(inputBinding.run).toContain('! "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$'); + expect(inputBinding.run).toContain( + '[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]]', + ); + expect(inputBinding.run).toContain('--arg digest "sha256:${ARTIFACT_DIGEST}"'); + expect(inputBinding.run).toContain( + "actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}", + ); + expect(inputBinding.run).toContain('"$CANDIDATE_SOURCE_SHA" != "$INPUT_REF"'); + + const inputDownload = step(prepare, "Download provided candidate artifact"); + expect(inputDownload.with).toMatchObject({ + "artifact-ids": "${{ inputs.candidate_artifact_id }}", + "run-id": "${{ inputs.candidate_artifact_run_id }}", + }); + expect(inputDownload.with?.name).toBeUndefined(); + expect( + prepare.steps?.findIndex( + (candidate) => candidate.name === "Validate provided candidate artifact binding", + ), + ).toBeLessThan( + prepare.steps?.findIndex( + (candidate) => candidate.name === "Download provided candidate artifact", + ) ?? -1, + ); + + const resolve = step(prepare, "Resolve provided candidate package"); + expect(resolve.run).toContain("resolve-openclaw-package-candidate.mjs"); + expect(resolve.run).toContain("--source artifact"); + expect(resolve.run).toContain('--package-sha256 "$INPUT_CANDIDATE_SHA256"'); + expect(resolve.run).toContain('"$actual_sha256" == "$INPUT_CANDIDATE_SHA256"'); + expect(resolve.run).toContain('"$actual_source_sha" == "$INPUT_CANDIDATE_SOURCE_SHA"'); + expect(resolve.run).toContain('"$actual_version" == "$INPUT_CANDIDATE_VERSION"'); + + const upload = step(prepare, "Upload candidate artifact"); + expect(upload.id).toBe("upload_candidate"); + expect(upload.with?.name).toBe( + "openclaw-cross-os-release-checks-candidate-${{ github.run_id }}-${{ github.run_attempt }}", + ); + const baselineUpload = step(prepare, "Upload baseline artifact"); + expect(baselineUpload.id).toBe("upload_baseline"); + expect(baselineUpload.with?.name).toBe( + "openclaw-cross-os-release-checks-baseline-${{ github.run_id }}-${{ github.run_attempt }}", + ); + + const consumer = job(workflow, "cross_os_release_checks"); + const binding = step(consumer, "Validate prepared candidate artifact binding"); + expect(binding.env).toMatchObject({ + ARTIFACT_DIGEST: "${{ needs.prepare.outputs.candidate_artifact_digest }}", + ARTIFACT_ID: "${{ needs.prepare.outputs.candidate_artifact_id }}", + ARTIFACT_NAME: + "${{ format('openclaw-cross-os-release-checks-candidate-{0}-{1}', needs.prepare.outputs.candidate_artifact_run_id, needs.prepare.outputs.candidate_artifact_run_attempt) }}", + ARTIFACT_RUN_ATTEMPT: "${{ needs.prepare.outputs.candidate_artifact_run_attempt }}", + ARTIFACT_RUN_ID: "${{ needs.prepare.outputs.candidate_artifact_run_id }}", + BASELINE_ARTIFACT_DIGEST: "${{ needs.prepare.outputs.baseline_artifact_digest }}", + BASELINE_ARTIFACT_ID: "${{ needs.prepare.outputs.baseline_artifact_id }}", + BASELINE_ARTIFACT_NAME: + "${{ format('openclaw-cross-os-release-checks-baseline-{0}-{1}', needs.prepare.outputs.baseline_artifact_run_id, needs.prepare.outputs.baseline_artifact_run_attempt) }}", + BASELINE_ARTIFACT_RUN_ATTEMPT: "${{ needs.prepare.outputs.baseline_artifact_run_attempt }}", + BASELINE_ARTIFACT_RUN_ID: "${{ needs.prepare.outputs.baseline_artifact_run_id }}", + BASELINE_SHA256: "${{ needs.prepare.outputs.baseline_sha256 }}", + CANDIDATE_SHA256: "${{ needs.prepare.outputs.candidate_sha256 }}", + CANDIDATE_SOURCE_SHA: "${{ needs.prepare.outputs.source_sha }}", + CANDIDATE_VERSION: "${{ needs.prepare.outputs.candidate_version }}", + GH_TOKEN: "${{ github.token }}", + }); + expect(binding.run).not.toContain('"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"'); + expect(binding.run).not.toContain('"$BASELINE_ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"'); + expect(binding.run).toContain("actions/artifacts/${tuple.id}"); + expect(binding.run).toContain("artifact.expired !== false"); + expect(binding.run).toContain("artifact.digest !== `sha256:${tuple.digest}`"); + expect(binding.run).toContain("String(artifact.workflow_run?.id) !== tuple.runId"); + expect(binding.run).toContain("actions/runs/${tuple.runId}/attempts/${tuple.runAttempt}"); + expect(binding.run).toContain("String(attempt.run_attempt) !== tuple.runAttempt"); + + for (const name of ["Download candidate artifact", "Retry candidate artifact download"]) { + const download = step(consumer, name); + expect(download.with?.["artifact-ids"], name).toBe( + "${{ needs.prepare.outputs.candidate_artifact_id }}", + ); + expect(download.with?.["github-token"], name).toBe("${{ github.token }}"); + expect(download.with?.["run-id"], name).toBe( + "${{ needs.prepare.outputs.candidate_artifact_run_id }}", + ); + expect(download.with?.name, name).toBeUndefined(); + } + for (const name of ["Download baseline artifact", "Retry baseline artifact download"]) { + const download = step(consumer, name); + expect(download.with?.["artifact-ids"], name).toBe( + "${{ needs.prepare.outputs.baseline_artifact_id }}", + ); + expect(download.with?.["github-token"], name).toBe("${{ github.token }}"); + expect(download.with?.["run-id"], name).toBe( + "${{ needs.prepare.outputs.baseline_artifact_run_id }}", + ); + expect(download.with?.name, name).toBeUndefined(); + } + const verify = step(consumer, "Verify release-check inputs"); + expect(verify.env?.EXPECTED_CANDIDATE_SHA256).toBe( + "${{ needs.prepare.outputs.candidate_sha256 }}", + ); + expect(verify.run).toContain('"$actual_sha256" != "$EXPECTED_CANDIDATE_SHA256"'); + expect(verify.env?.EXPECTED_BASELINE_SHA256).toBe( + "${{ needs.prepare.outputs.baseline_sha256 }}", + ); + expect(verify.run).toContain('"$actual_baseline_sha256" != "$EXPECTED_BASELINE_SHA256"'); + }); + + it("executes the release harness directly with Node", () => { const wrapper = readFileSync(WRAPPER_PATH, "utf8"); expect(wrapper).toContain("command -v npm.cmd"); diff --git a/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts index 95db0472b34e..44fdb61844a0 100644 --- a/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts @@ -5,8 +5,9 @@ import { parse } from "yaml"; const fullValidationPath = ".github/workflows/full-release-validation.yml"; const releaseChecksPath = ".github/workflows/openclaw-release-checks.yml"; +const releasePublishPath = ".github/workflows/openclaw-release-publish.yml"; -type Step = { name?: string; run?: string }; +type Step = { name?: string; run?: string; with?: Record }; type Job = { steps?: Step[] }; type Workflow = { jobs?: Record }; @@ -88,6 +89,18 @@ describe("extended-stable Full Release Validation workflow", () => { expect(fullValidation).toContain( "full-release-validation-${{ github.run_id }}-${{ github.run_attempt }}", ); + const legacyAlias = workflow(fullValidationPath).jobs?.summary?.steps?.find( + (step) => step.name === "Upload legacy release validation manifest alias", + ); + expect(legacyAlias?.with?.overwrite).toBe(true); + expect(fullValidation).toContain("version: 3"); + expect(fullValidation).toContain("workflowSha: $workflowSha"); + expect(fullValidation).toContain("workflowFullRef: $workflowFullRef"); + expect(fullValidation).toContain("workflowRefType: $workflowRefType"); + expect(fullValidation).toContain('performanceReportPublication: "artifact-only"'); + expect( + stepRun(fullValidationPath, "performance", "Dispatch and monitor OpenClaw Performance"), + ).toContain("-f publish_reports=false"); }); it("accepts only the exact extended-stable/YYYY.M.33 workflow-ref shape", () => { @@ -114,6 +127,18 @@ describe("extended-stable Full Release Validation workflow", () => { } }); + it("requires blocking performance evidence at the publish boundary", () => { + const validation = stepRun( + releasePublishPath, + "resolve_release_target", + "Validate full release validation manifest", + ); + expect(validation).toContain(".controls.performanceBlocking // false"); + expect(validation).toContain( + "Full release validation manifest does not record blocking product performance evidence.", + ); + }); + it("accepts the exact throwaway branch only with a full target SHA", () => { const workflowRef = "refs/heads/dev/throwaway-2026.0.33-v6.8"; expect(runReleaseChecksTrustedRefGuard(workflowRef, "a".repeat(40)).status).toBe(0); diff --git a/test/scripts/openclaw-npm-extended-stable-release.test.ts b/test/scripts/openclaw-npm-extended-stable-release.test.ts index 666c95363d1f..ba92ff2f16ba 100644 --- a/test/scripts/openclaw-npm-extended-stable-release.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-release.test.ts @@ -386,10 +386,10 @@ describe("extended-stable npm run identity", () => { describe("Full Validation manifest identity", () => { const valid = { workflowName: "Full Release Validation", - runId: "12345", - runAttempt: "2", workflowRef: branch, targetSha: sha, + runId: "123", + runAttempt: "2", }; it("accepts the exact branch and target SHA", () => { @@ -399,7 +399,7 @@ describe("Full Validation manifest identity", () => { npmDistTag: "extended-stable", expectedWorkflowRef: branch, expectedSha: sha, - expectedRunId: "12345", + expectedRunId: "123", expectedRunAttempt: "2", }), ).toBe(valid); @@ -412,6 +412,8 @@ describe("Full Validation manifest identity", () => { ["missing workflow ref", { workflowRef: undefined }], ["wrong target SHA", { targetSha: "b".repeat(40) }], ["missing target SHA", { targetSha: undefined }], + ["wrong run ID", { runId: "124" }], + ["wrong run attempt", { runAttempt: "1" }], ])("rejects %s", (_label, changes) => { expect(() => validateFullReleaseValidationManifest({ @@ -419,7 +421,7 @@ describe("Full Validation manifest identity", () => { npmDistTag: "extended-stable", expectedWorkflowRef: branch, expectedSha: sha, - expectedRunId: "12345", + expectedRunId: "123", expectedRunAttempt: "2", }), ).toThrow(); diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts index 812f897af283..109047ddba53 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -4,7 +4,15 @@ import { parse } from "yaml"; const workflowPath = ".github/workflows/openclaw-npm-release.yml"; -type Step = { env?: Record; id?: string; if?: string; name?: string; run?: string }; +type Step = { + env?: Record; + id?: string; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; type Job = { environment?: string; steps?: Step[] }; type Workflow = { on?: { @@ -136,7 +144,50 @@ describe("minimal npm extended-stable workflow", () => { expect(plugins.run).toContain("scripts/plugin-npm-publish.sh --pack"); expect(plugins.run).toContain("OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR"); expect(plugins.run).not.toContain("--publish"); - expect(step(preflight, "Upload extended-stable plugin npm packages")).toBeDefined(); + expect(step(preflight, "Upload extended-stable plugin npm packages").with?.overwrite).toBe( + true, + ); + }); + + it("overwrites only fixed-name preflight compatibility artifacts on rerun", () => { + const preflight = workflow().jobs?.preflight_openclaw_npm; + for (const name of [ + "Upload extended-stable plugin npm packages", + "Upload dependency release evidence", + "Upload dependency release evidence tag alias", + "Upload legacy prepared npm publish bundle alias", + "Upload legacy prepared npm publish bundle tag alias", + ]) { + expect(step(preflight, name).with?.overwrite, name).toBe(true); + } + expect( + step(preflight, "Upload immutable prepared npm publish bundle").with?.overwrite, + ).toBeUndefined(); + }); + + it("restores same-SHA preflight build outputs and keeps validation steps running", () => { + const parsed = workflow(); + const preflight = parsed.jobs?.preflight_openclaw_npm; + + const restore = step(preflight, "Restore preflight build outputs"); + expect(restore.uses).toContain("actions/cache/restore@"); + expect(restore.with?.key).toBe( + "${{ runner.os }}-npm-preflight-dist-v1-${{ github.workflow_sha }}-${{ steps.preflight_cache_key.outputs.sha }}-${{ hashFiles('pnpm-lock.yaml') }}", + ); + + // Only the build producers skip on a cache hit; every validation step + // still runs against the restored artifacts. + expect(step(preflight, "Build").if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'"); + expect(step(preflight, "Build Control UI").if).toBe( + "steps.dist_build_cache.outputs.cache-hit != 'true'", + ); + expect(step(preflight, "Check").if).toBeUndefined(); + expect(step(preflight, "Verify release contents").if).toBeUndefined(); + expect(step(preflight, "Verify prepared npm tarball install").if).toBeUndefined(); + + const save = step(preflight, "Save preflight build outputs"); + expect(save.uses).toContain("actions/cache/save@"); + expect(save.with?.key).toBe("${{ steps.dist_build_cache.outputs.cache-primary-key }}"); }); it("authenticates exact extended-stable run and Full Validation identities", () => { @@ -153,8 +204,31 @@ describe("minimal npm extended-stable workflow", () => { expect(fullRun.run).toContain( "actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}", ); - expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(5); + expect(raw).toContain("--json workflowName,headBranch,headSha,event,conclusion,url"); + const fullValidationRun = step( + parsed.jobs?.publish_openclaw_npm, + "Verify full release validation run metadata", + ); + expect(fullValidationRun.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe( + "${{ inputs.full_release_validation_run_attempt }}", + ); + expect(fullValidationRun.run).toContain( + "actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}", + ); + expect(fullValidationRun.run).toContain( + '"$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT"', + ); + expect(fullValidationRun.run).toContain('echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT"'); + expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(4); expect(raw).toContain("openclaw-npm-extended-stable-release.mjs verify-manifest"); + const manifest = step( + parsed.jobs?.publish_openclaw_npm, + "Verify full release validation target", + ); + expect(manifest.run).toContain( + 'if [[ "$RELEASE_PROFILE" != "stable" && "$RELEASE_PROFILE" != "full" ]]; then', + ); + expect(manifest.run).toContain("Stable releases require stable or full validation"); }); it("binds release evidence to exact attempts, artifact IDs, and digests", () => { diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 568132387563..fde5ea663d0d 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -14,6 +14,7 @@ type WorkflowStep = { }; type WorkflowJob = { + if?: string; steps?: WorkflowStep[]; }; @@ -56,6 +57,10 @@ describe("OpenClaw performance workflow", () => { expect(detect.if).toContain("inputs.publish_reports == true"); expect(prepare.if).toContain("inputs.publish_reports == true"); expect(publish.if).toContain("inputs.publish_reports == true"); + const guard = workflow.jobs?.verify_artifact_only; + expect(guard?.if).toContain("inputs.publish_reports == false"); + expect(guard?.steps?.[0]?.name).toBe("Confirm report publication is disabled"); + expect(guard?.steps?.[0]?.run).toContain('[[ "$PUBLISH_REPORTS" == "false" ]]'); }); it("uses the clawgrit reports token for every report repo push path", () => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 81095e2d0aa3..e32c95dde00b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1519,7 +1519,43 @@ describe("package artifact reuse", () => { expect(workflow).toContain("timeout-minutes: 120"); expect(workflow).toContain("environment: npm-release"); + expect(workflow).toContain("artifact-metadata: write"); + expect(workflow).toContain("attestations: write"); + expect(workflow).toContain("id-token: write"); + expect(workflow).toContain('expected_publish_ref="refs/heads/release-publish/${WORKFLOW_SHA}"'); + expect(workflow).toContain( + 'merge_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${WORKFLOW_SHA}...${main_sha}"', + ); + expect(npmWorkflow).toContain("refs/heads/release-publish/${WORKFLOW_SHA}"); + const dependencyEvidenceUpload = workflow.slice( + workflow.indexOf("upload_dependency_evidence_release_asset()"), + workflow.indexOf("attach_or_verify_release_asset()"), + ); + expect(dependencyEvidenceUpload).toContain( + "find dependency-evidence -type f -print | LC_ALL=C sort | zip -X -q", + ); + expect(dependencyEvidenceUpload).toContain( + 'attach_or_verify_release_asset "${asset_path}" "${asset_name}"', + ); + expect(dependencyEvidenceUpload).not.toContain("--clobber"); expect(workflow).toContain("Download OpenClaw npm preflight manifest"); + expect(workflow).toContain( + "openclaw-npm-preflight-${PREFLIGHT_RUN_ID}-${PREFLIGHT_RUN_ATTEMPT}", + ); + const releaseTargetJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"); + const preflightProvenance = workflowStep( + releaseTargetJob, + "Validate OpenClaw npm preflight run provenance", + ); + expectTextToIncludeAll(preflightProvenance.run, [ + 'workflow_name="$(jq -r \'.name // ""\' "$run_file")"', + 'workflow_path="${workflow_path%%@*}"', + '[[ "$workflow_name" != "OpenClaw NPM Release" ]]', + '[[ "$workflow_path" != ".github/workflows/openclaw-npm-release.yml" ]]', + '[[ "$event" != "workflow_dispatch" ]]', + '[[ "$head_sha" != "$EXPECTED_WORKFLOW_SHA" ]]', + ]); + expect(workflow).toContain('-f preflight_run_attempt="${PREFLIGHT_RUN_ATTEMPT}"'); expect(workflow).toContain("Validate OpenClaw npm preflight manifest"); expect(workflow).toContain("Download full release validation manifest"); expect(workflow).toContain("Validate full release validation manifest"); @@ -1553,10 +1589,15 @@ describe("package artifact reuse", () => { "needs.docker_runtime_assets_preflight.result == 'success'", ); expect(npmWorkflow).toContain("tarballSha256"); + expect(npmWorkflow).toContain("dependencyTarballs: []"); + expect(npmWorkflow).not.toContain("@openclaw/ai"); + expect(npmWorkflow).not.toContain("AI_RUNTIME_TARBALL_DIR"); expect(workflow).toContain("Checkout release SHA"); - expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"'); - expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }'); - expect(workflow).toContain("Unreleased prerelease fallback"); + expect(workflow).toContain( + 'git -C .release-target show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"', + ); + expect(workflow).toContain("scripts/render-github-release-notes.mjs"); + expect(workflow).not.toContain("Unreleased prerelease fallback"); expect(workflow).not.toContain("gh api --repo"); expect(workflow).not.toContain("timeout-minutes: 360"); }); @@ -1617,7 +1658,7 @@ describe("package artifact reuse", () => { "\n create_or_update_github_release\n", ); const promoteWindowsCall = releaseWorkflow.lastIndexOf( - "\n if ! promote_windows_release_assets; then\n", + "\n if promote_windows_release_assets; then\n", ); const publishReleaseCall = releaseWorkflow.lastIndexOf( "\n publish_github_release\n", @@ -1747,7 +1788,7 @@ describe("package artifact reuse", () => { expect(pluginPretagPackScript).toContain("scripts/check-plugin-npm-runtime-builds.mjs"); expect(pluginPretagPackScript).toContain("scripts/plugin-npm-publish.sh"); expect(pluginPretagPackScript).toContain("scripts/plugin-clawhub-publish.sh"); - expect(clawHubWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"'); + expect(clawHubWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.23.1"'); expect(clawHubWorkflow).not.toContain("CLAWHUB_REPOSITORY:"); expect(clawHubWorkflow).not.toContain("CLAWHUB_REF:"); expect(clawHubWorkflow).toContain("pack_plugins_clawhub_artifacts:"); @@ -1770,7 +1811,7 @@ describe("package artifact reuse", () => { "github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'", ); expect(clawHubWorkflow).toContain( - "uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721", + "uses: openclaw/clawhub/.github/workflows/package-publish.yml@d8096dfc039e86ab942ddf9ef117d04849fd84c1", ); expect(clawHubWorkflow).toContain("dry_run:"); expect(clawHubWorkflow).toContain("default: false"); @@ -1831,6 +1872,7 @@ describe("package artifact reuse", () => { expect(releaseWorkflow).toContain("plugin-clawhub-new.yml"); expect(releaseWorkflow).toContain("Plugin ClawHub bootstrap run ID"); expect(releaseWorkflow).toContain("scripts/openclaw-release-clawhub-plan.ts"); + expect(releaseWorkflow).toContain('--root-dir "${GITHUB_WORKSPACE}/.release-target"'); expect(releaseWorkflow).toContain("scripts/openclaw-release-clawhub-runtime-state.ts"); expect(isExecutable("scripts/openclaw-release-clawhub-plan.ts")).toBe(true); expect(isExecutable("scripts/openclaw-release-clawhub-runtime-state.ts")).toBe(true); @@ -1861,21 +1903,35 @@ describe("package artifact reuse", () => { ); expect(releaseWorkflow).toContain("registry tarball"); expect(releaseWorkflow).toContain("openclawNpmTarball"); - expect(releaseWorkflow).not.toContain('npm view "openclaw@${release_version}" dist.tarball'); + expect(releaseWorkflow).toContain('npm view "openclaw@${release_version}" dist.tarball'); expect(releaseWorkflow).toContain("release SHA"); expect(clawHubReleasePlanScript).toContain("not awaited by this proof"); expect(releaseWorkflow).toContain("wait_for_job_success"); expect(releaseWorkflow).toContain("Validate release publish approval"); expect(releaseWorkflow).toContain('conclusion" == "skipped"'); expect(releaseWorkflow).toContain("approve_child_publish_environment"); + expect(releaseWorkflow).toContain("deployments: write"); + const trustedCheckoutIndex = releaseWorkflow.indexOf( + "- name: Checkout trusted release tooling", + ); + const trustedSetupIndex = releaseWorkflow.indexOf("- name: Setup trusted Node environment"); + const targetCheckoutIndex = releaseWorkflow.indexOf("- name: Checkout release SHA as data"); + expect(trustedCheckoutIndex).toBeGreaterThan(-1); + expect(trustedSetupIndex).toBeGreaterThan(trustedCheckoutIndex); + expect(targetCheckoutIndex).toBeGreaterThan(trustedSetupIndex); + expect(releaseWorkflow).toContain("path: .release-target"); + expect(releaseWorkflow).not.toContain("path: .release-harness"); + expect(releaseWorkflow).not.toContain("uses: actions/checkout@v6"); + expect(releaseWorkflow).toContain('git -C .release-target show "${TARGET_SHA}:CHANGELOG.md"'); expect(releaseWorkflow).toContain("Approve child release gate after parent release approval"); - expect(releaseWorkflow).toContain("release:verify-beta"); + expect(releaseWorkflow).toContain("scripts/release-verify-beta.ts"); expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"'); expect(releaseWorkflow).toContain("--skip-github-release"); expect(clawHubReleasePlanScript).toContain("--plugin-clawhub-bootstrap-run"); expect(releaseWorkflow).toContain('verify_args+=(--plugins "${PLUGINS}")'); expect(releaseWorkflow).toContain("openclaw-release-postpublish-evidence"); expect(releaseWorkflow).toContain("Failed child job summary"); + expect(releaseWorkflow).not.toContain("return_run_details: true"); expect(releaseWorkflow).toContain("Workflow completion waits for ClawHub"); expect(releaseWorkflow).toContain("Workflow completion does not wait for ClawHub"); expect(releaseWorkflow).toContain('[[ "${WAIT_FOR_CLAWHUB}" == "true" ]]'); @@ -1886,7 +1942,7 @@ describe("package artifact reuse", () => { expect(pluginNpmWorkflow).toContain("Validate release publish approval run"); expect(clawHubWorkflow).toContain("Validate release publish approval run"); expect(openclawNpmWorkflow).toContain("Validate release publish approval run"); - expect(pluginNpmWorkflow).toContain("Check npm package version"); + expect(pluginNpmWorkflow).toContain("Check OIDC npm package version"); expect(pluginNpmWorkflow).toContain("already_published=true"); expect(pluginNpmWorkflow).toContain( "steps.npm_package_version.outputs.already_published != 'true'", @@ -1908,60 +1964,15 @@ describe("package artifact reuse", () => { expect(pluginNpmWorkflow).toContain("environment: npm-release"); expect(clawHubWorkflow.match(/environment: clawhub-plugin-release/g)?.length).toBe(1); expect(clawHubNewWorkflow).toContain("name: Plugin ClawHub New"); - expect(clawHubNewWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"'); - expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REPOSITORY:"); - expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REF:"); - expect(clawHubNewWorkflow).toContain("environment: clawhub-plugin-bootstrap"); - expect(clawHubNewWorkflow).toContain("secrets.CLAWHUB_TOKEN"); - expect(clawHubNewWorkflow).not.toContain( - "uses: openclaw/clawhub/.github/workflows/package-publish.yml", - ); - expect(clawHubNewWorkflow).not.toContain("clawhub_token:"); - expect(clawHubNewWorkflow).toContain("Validate pinned ClawHub trusted publisher CLI support"); - expect(clawHubNewWorkflow).toContain('npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}"'); + expect(clawHubNewWorkflow).toContain("Materialize locked ClawHub CLI"); + expect(clawHubNewWorkflow).toContain("Upload immutable ClawHub bootstrap artifact"); expect(clawHubNewWorkflow).toContain( - "CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows", + "Validate packed ClawHub package identities before credentials", ); - expect(clawHubNewWorkflow).toContain("Usage: clawhub package trusted-publisher set"); expect(clawHubNewWorkflow).toContain("Write ClawHub token config"); - expect(clawHubNewWorkflow).toContain("CLAWHUB_CONFIG_PATH=${config_path}"); - expect(clawHubNewWorkflow).toContain( - "CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.", - ); - expect(clawHubNewWorkflow).toContain( - "CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.", - ); - expect(clawHubNewWorkflow).toContain("JSON.stringify({ registry, token }, null, 2)"); - expect(clawHubNewWorkflow).toContain("Publish ClawHub bootstrap package"); - expect(clawHubNewWorkflow).toContain("bash scripts/plugin-clawhub-publish.sh --publish"); - expect(clawHubNewWorkflow).toContain("bootstrapMode"); - expect(clawHubNewWorkflow).toContain("BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}"); - expect(clawHubNewWorkflow).toContain("requiresManualOverride"); - expect(clawHubNewWorkflow).toContain( - 'OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"', - ); - expect(clawHubNewWorkflow).toContain("configure-only"); - expect(clawHubNewWorkflow).toContain( - "version is already present on ClawHub; configuring trusted publisher only", - ); - expect(clawHubNewWorkflow).toContain( - "EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", - ); - expect(clawHubNewWorkflow).toContain( - "TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", - ); - expect(clawHubNewWorkflow).toContain('OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"'); - expect(clawHubNewWorkflow).toContain("trusted-publisher set"); - expect(clawHubNewWorkflow).toContain("--workflow-filename plugin-clawhub-release.yml"); - expect(clawHubNewWorkflow).not.toContain("--environment clawhub-plugin-release"); - expect(clawHubNewWorkflow).toContain("trustedPublisher?.environment != null"); - expect(clawHubNewWorkflow).toContain("without an environment pin"); - expect(clawHubNewWorkflow).not.toContain("Checkout ClawHub CLI source"); - expect(clawHubNewWorkflow).not.toContain("packages/clawhub/src/cli.ts"); - expect(clawHubNewWorkflow).toContain("verify_bootstrap_clawhub_package:"); - expect(clawHubNewWorkflow).toContain("Verify bootstrap ClawHub package and trusted publisher"); - expect(clawHubNewWorkflow).toContain("/trusted-publisher"); - expect(clawHubNewWorkflow).toContain('trustedPublisher?.repository !== "openclaw/openclaw"'); + expect(clawHubNewWorkflow).toContain("Publish exact ClawHub bootstrap artifacts"); + expect(clawHubNewWorkflow).toContain("Verify exact ClawHub registry artifact bytes"); + expect(clawHubNewWorkflow).toContain("secrets.CLAWHUB_TOKEN"); expect(openclawNpmWorkflow).toContain("environment: npm-release"); expect(releaseWorkflow).toContain("default: from-validation"); expect(releaseWorkflow).toContain('--release-publish-branch "${CHILD_WORKFLOW_REF}"'); @@ -1974,6 +1985,11 @@ describe("package artifact reuse", () => { expect(releaseWorkflow).toContain( 'if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then', ); + expect(releaseWorkflow).toContain( + 'if [[ "$release_profile" != "stable" && "$release_profile" != "full" ]]; then', + ); + expect(releaseWorkflow).toContain('if [[ "$run_release_soak" != "true" ]]; then'); + expect(releaseWorkflow).toContain("Stable release publication requires runReleaseSoak=true"); expect(releaseWorkflow).toContain( 'echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT"', ); @@ -1981,7 +1997,7 @@ describe("package artifact reuse", () => { "has failed jobs before the workflow completed: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}", ); expect(releaseWorkflow.lastIndexOf("verify_published_release")).toBeLessThan( - releaseWorkflow.lastIndexOf("create_or_update_github_release"), + releaseWorkflow.lastIndexOf("publish_github_release"), ); expect(releaseWorkflow.lastIndexOf("create_or_update_github_release")).toBeLessThan( releaseWorkflow.lastIndexOf("append_release_proof_to_github_release"), diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 3836d0f7c421..cd472de52431 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -72,7 +72,15 @@ afterEach(() => { describe("parallels npm update smoke", () => { it("accepts one prepared tarball target for update and fresh install", () => { - expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({ + expect( + parseArgs([ + "--target-tarball", + "/tmp/openclaw-candidate.tgz", + "--dependency-tarball", + "/tmp/openclaw-ai-candidate.tgz", + ]), + ).toMatchObject({ + dependencyTarballs: ["/tmp/openclaw-ai-candidate.tgz"], targetTarball: "/tmp/openclaw-candidate.tgz", updateTarget: "", freshTargetSpec: undefined, @@ -80,6 +88,9 @@ describe("parallels npm update smoke", () => { expect(() => parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]), ).toThrow("--target-tarball cannot be combined"); + expect(() => parseArgs(["--dependency-tarball", "/tmp/openclaw-ai-candidate.tgz"])).toThrow( + "--dependency-tarball requires --target-tarball", + ); }); it("stops the host artifact server when the wrapper fails mid-run", async () => { @@ -132,18 +143,32 @@ describe("parallels npm update smoke", () => { expect(script).toContain("freshTargetStatus"); }); - it("host-serves a prepared candidate tarball for both proof phases", () => { + it("serves a prepared package set for both proof phases", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); expect(script).toContain("--target-tarball "); + expect(script).toContain("--dependency-tarball "); expect(script).toContain('label: "prepared candidate tgz"'); expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)"); - expect(script).toContain("dir: this.tgzDir"); - expect(script).toContain("this.updateTargetEffective = targetUrl"); - expect(script).toContain("this.freshTargetSpec = targetUrl"); + expect(script).toContain("startNpmRegistryServer"); + expect(script).toContain("this.updateTargetEffective = this.targetTarballVersion"); + expect(script).toContain("this.freshTargetSpec = this.updateTargetTarball"); expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion"); }); + it("routes update installs through the prepared package registry", () => { + const registry = "http://192.0.2.2:48123"; + const input = { + auth: TEST_AUTH, + expectedNeedle: "2026.7.1-beta.3", + npmRegistry: registry, + updateTarget: "2026.7.1-beta.3", + }; + + expect(macosUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`); + expect(linuxUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`); + expect(windowsUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY = '${registry}'`); + }); it("guards beta validation against cross-version harness checkouts", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 6846012e0fa1..50135ff38bbd 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -238,6 +238,11 @@ describe("Parallels smoke model selection", () => { expect(parseMacosSmokeArgs(["--mode", "fresh", "--", "--mode", "upgrade"]).mode).toBe("fresh"); expect(parseMacosSmokeArgs([]).vmNameExplicit).toBe(false); expect(parseMacosSmokeArgs(["--vm", "macOS"]).vmNameExplicit).toBe(true); + for (const parseArgs of [parseMacosSmokeArgs, parseLinuxSmokeArgs, parseWindowsSmokeArgs]) { + expect(parseArgs(["--npm-registry", "http://192.0.2.2:48123"]).npmRegistry).toBe( + "http://192.0.2.2:48123", + ); + } expect(parseNpmUpdateSmokeArgs(["--", "--package-spec", "openclaw@2026.5.1"]).packageSpec).toBe( "openclaw@2026.5.1", ); @@ -328,6 +333,8 @@ describe("Parallels smoke model selection", () => { expect(parallelsVm).toContain("export function resolveMacosVmName"); expect(parallelsVm).toContain("export function waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); + expect(hostServer).toContain("export async function startNpmRegistryServer"); + expect(hostServer).toContain('OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org"'); expect(hostServer).toContain("http.server"); expect(snapshots).toContain("export function resolveSnapshot"); expect(smokeCommon).toContain("runSmokeLane"); diff --git a/test/scripts/plugin-clawhub-new-workflow.test.ts b/test/scripts/plugin-clawhub-new-workflow.test.ts new file mode 100644 index 000000000000..a41bfb41a253 --- /dev/null +++ b/test/scripts/plugin-clawhub-new-workflow.test.ts @@ -0,0 +1,356 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +type Step = { + env?: Record; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type Job = { + environment?: string; + if?: string; + name?: string; + outputs?: Record; + permissions?: Record; + steps?: Step[]; + "timeout-minutes"?: number; +}; + +type Workflow = { + jobs?: Record; + on?: { + workflow_dispatch?: { + inputs?: Record; + }; + }; +}; + +const source = readFileSync(".github/workflows/plugin-clawhub-new.yml", "utf8"); +const workflow = parse(source) as Workflow; +const jobs = workflow.jobs ?? {}; +const materializerSource = readFileSync("scripts/materialize-clawhub-cli.sh", "utf8"); +const clawhubCliPackage = JSON.parse( + readFileSync(".github/release/clawhub-cli/package.json", "utf8"), +) as { dependencies?: Record }; +const clawhubCliLock = JSON.parse( + readFileSync(".github/release/clawhub-cli/package-lock.json", "utf8"), +) as { + packages?: Record; +}; + +function job(name: string): Job { + const value = jobs[name]; + expect(value, `missing ${name}`).toBeDefined(); + return value ?? {}; +} + +function step(jobValue: Job, name: string): Step { + const value = jobValue.steps?.find((entry) => entry.name === name); + expect(value, `missing step ${name}`).toBeDefined(); + return value ?? {}; +} + +describe("Plugin ClawHub New workflow", () => { + it("binds trusted-main workflow code to an exact release target SHA", () => { + expect(workflow.on?.workflow_dispatch?.inputs?.ref?.required).toBe(true); + for (const input of [ + "bootstrap_workflow_sha", + "release_tag", + "release_publish_run_id", + "release_publish_run_attempt", + "release_publish_branch", + "pretag_validation", + ]) { + expect(workflow.on?.workflow_dispatch?.inputs?.[input]?.required, input).toBe(false); + } + const resolve = job("resolve_bootstrap_plan"); + const checkout = step(resolve, "Checkout"); + expect(checkout.with?.ref).toBe("${{ github.sha }}"); + const guard = step(resolve, "Require trusted main workflow source").run ?? ""; + expect(guard).toContain('WORKFLOW_REF}" == "refs/heads/main"'); + expect(guard).toContain( + "Plugin ClawHub New workflow SHA does not match the parent-approved trusted-main SHA.", + ); + const target = step(resolve, "Resolve checked-out ref").run ?? ""; + expect(target).toContain('[[ "${TARGET_REF}" =~ ^[a-f0-9]{40}$ ]]'); + expect(target).toContain('git rev-parse "${RELEASE_TAG}^{commit}"'); + expect(target).toContain( + "Plugin ClawHub bootstrap target ${TARGET_REF} does not match ${RELEASE_TAG} (${tag_sha}).", + ); + expect(target).toContain("refs/remotes/origin/release"); + }); + + it("supports a secretless pre-tag validation mode without tag or parent approval", () => { + const resolveRun = step(job("resolve_bootstrap_plan"), "Resolve checked-out ref").run ?? ""; + expect(resolveRun).toContain('[[ "${PRETAG_VALIDATION}" == "true" ]]'); + expect(resolveRun).toContain( + "Plugin ClawHub pre-tag validation must not include a release tag or parent approval tuple.", + ); + expect(resolveRun).toContain( + "Plugin ClawHub pre-tag validation target must be reachable from main or release/*.", + ); + expect(resolveRun).toContain("Plugin ClawHub pre-tag validation requires dry_run=true."); + + const approval = job("validate_release_publish_approval"); + expect(approval.if).toContain("inputs.pretag_validation != true"); + for (const jobName of ["validate_bootstrap_trusted_publisher_cli", "pack_bootstrap_plugins"]) { + const validationJob = job(jobName); + expect(validationJob.if).toContain("inputs.pretag_validation == true"); + expect(validationJob.environment).toBeUndefined(); + expect(JSON.stringify(validationJob)).not.toContain("secrets."); + expect(JSON.stringify(validationJob)).not.toContain("--publish-packed"); + } + const protectedValidation = job("validate_bootstrap_artifact"); + expect(protectedValidation.if).toContain("inputs.pretag_validation == true"); + expect(protectedValidation.environment).toBe("clawhub-plugin-bootstrap"); + expect(JSON.stringify(protectedValidation)).not.toContain("secrets."); + expect(JSON.stringify(protectedValidation)).not.toContain("--publish-packed"); + expect(JSON.stringify(protectedValidation)).not.toContain("trusted-publisher set"); + + const publish = job("publish_bootstrap_plugins"); + expect(publish.if).toContain("inputs.pretag_validation != true"); + expect(publish.if).toContain("inputs.dry_run != true"); + }); + + it("requires an exact attested parent tuple for approved dry-run validation", () => { + const approval = job("validate_release_publish_approval"); + expect(approval.if).not.toContain("inputs.dry_run != true"); + expect(approval.if).toContain("inputs.pretag_validation != true"); + expect(approval.permissions).toEqual({ + actions: "read", + attestations: "read", + contents: "read", + }); + expect(step(approval, "Download parent ClawHub bootstrap approval").with).toMatchObject({ + name: "clawhub-bootstrap-approval-${{ inputs.release_publish_run_id }}-${{ inputs.release_publish_run_attempt }}", + "run-id": "${{ inputs.release_publish_run_id }}", + }); + const validation = step(approval, "Validate release publish approval run"); + expect(validation.env).toMatchObject({ + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + CHILD_WORKFLOW_SHA: "${{ github.sha }}", + RELEASE_PACKAGES: "${{ inputs.plugins }}", + RELEASE_TAG: "${{ inputs.release_tag }}", + RELEASE_TARGET_SHA: "${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}", + }); + expect(validation.run).toContain("gh attestation verify"); + expect(validation.run).toContain( + "actions/runs/${RELEASE_PUBLISH_RUN_ID}/attempts/${EXPECTED_RUN_ATTEMPT}", + ); + expect(validation.run).toContain('--source-digest "${EXPECTED_WORKFLOW_SHA}"'); + }); + + it("requires the child workflow SHA to match the separately attested bootstrap tooling SHA", () => { + const validation = step( + job("validate_release_publish_approval"), + "Validate release publish approval run", + ); + expect(validation.env?.CHILD_WORKFLOW_SHA).toBe("${{ github.sha }}"); + expect(readFileSync("scripts/validate-release-publish-approval.mjs", "utf8")).toContain( + "bootstrapWorkflowSha: childWorkflowSha", + ); + }); + + it("packs target code only in the secretless producer", () => { + const pack = job("pack_bootstrap_plugins"); + expect(pack.name).toBe("Pack immutable ClawHub bootstrap artifacts"); + expect(pack.environment).toBeUndefined(); + expect(pack.permissions).toEqual({ actions: "read", contents: "read" }); + const serialized = JSON.stringify(pack); + expect(serialized).not.toContain("secrets."); + expect(pack.outputs).toMatchObject({ + artifact_digest: "${{ steps.upload.outputs.artifact-digest }}", + artifact_id: "${{ steps.upload.outputs.artifact-id }}", + artifact_name: "${{ steps.artifact.outputs.name }}", + artifact_run_attempt: "${{ github.run_attempt }}", + artifact_run_id: "${{ github.run_id }}", + artifact_size: "${{ steps.upload_binding.outputs.size }}", + clawhub_toolchain_sha256: "${{ steps.clawhub_cli.outputs.lock_sha256 }}", + }); + expect(step(pack, "Upload immutable ClawHub bootstrap artifact").with).toMatchObject({ + archive: true, + name: "${{ steps.artifact.outputs.name }}", + path: "${{ runner.temp }}/clawhub-bootstrap-artifact", + }); + const packRun = step(pack, "Pack immutable ClawHub bootstrap artifacts").run ?? ""; + expect(packRun).not.toContain('mode}" == "configure-only"'); + expect(packRun).toContain("bash .release-harness/scripts/plugin-clawhub-publish.sh --pack"); + expect(packRun).not.toContain("bash scripts/plugin-clawhub-publish.sh --pack"); + expect(packRun).toContain("--validate-packed"); + expect(packRun).toContain("--clawhub-toolchain-integrity"); + expect(packRun).toContain("--clawhub-toolchain-sha256"); + expect(packRun).toContain("--clawhub-toolchain-version"); + }); + + it("always validates the immutable handoff without credentials, including dry runs", () => { + const validate = job("validate_bootstrap_artifact"); + expect(validate.environment).toBe("clawhub-plugin-bootstrap"); + expect(validate.permissions).toEqual({ actions: "read", contents: "read" }); + expect(validate.if).not.toContain("inputs.dry_run != true"); + expect(JSON.stringify(validate)).not.toContain("secrets."); + expect(validate["timeout-minutes"]).toBe(45); + const binding = + step(validate, "Download and verify immutable ClawHub bootstrap artifact").run ?? ""; + expect(binding).toContain("clawhub-bootstrap-artifact.mjs download"); + expect(binding).toContain('--artifact-size "${ARTIFACT_SIZE}"'); + expect(binding).toContain('--run-attempt "${ARTIFACT_RUN_ATTEMPT}"'); + expect(binding).toContain('--consumer-run-attempt "${GITHUB_RUN_ATTEMPT}"'); + expect(binding).toContain('--producer-job-name "Pack immutable ClawHub bootstrap artifacts"'); + expect(binding).toContain("--clawhub-toolchain-integrity"); + expect(binding).toContain("--clawhub-toolchain-sha256"); + expect(binding).toContain("--clawhub-toolchain-version"); + expect(step(validate, "Validate packed ClawHub package identities").run).toContain( + "--validate-packed", + ); + expect(step(validate, "Require configure-only registry bytes to match target").run).toContain( + "--mode configure-only-preflight", + ); + expect(step(validate, "Require configure-only registry bytes to match target").run).toContain( + '--terminal-run-attempt "${GITHUB_RUN_ATTEMPT}"', + ); + expect(step(validate, "Upload immutable bootstrap validation evidence").with?.name).toBe( + "clawhub-bootstrap-validation-${{ github.run_id }}-${{ github.run_attempt }}", + ); + }); + + it("uses a fresh trusted-main credential job after immutable validation", () => { + const publish = job("publish_bootstrap_plugins"); + expect(publish.environment).toBe("clawhub-plugin-bootstrap"); + expect(publish.permissions).toEqual({ actions: "read", contents: "read" }); + expect(publish.if).toContain("inputs.dry_run != true"); + expect(publish.if).toContain("inputs.pretag_validation != true"); + expect(publish.if).toContain("needs.validate_bootstrap_artifact.result == 'success'"); + expect(publish["timeout-minutes"]).toBe(120); + + const checkout = step(publish, "Checkout trusted workflow tooling"); + expect(checkout.with).toMatchObject({ + ref: "${{ github.sha }}", + path: ".release-harness", + "persist-credentials": false, + }); + const uses = (publish.steps ?? []).flatMap((entry) => (entry.uses ? [entry.uses] : [])); + expect(uses).toEqual([ + "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + ]); + + const binding = + step(publish, "Download and verify immutable ClawHub bootstrap artifact").run ?? ""; + expect(binding).toContain("clawhub-bootstrap-artifact.mjs download"); + expect(binding).toContain('--artifact-size "${ARTIFACT_SIZE}"'); + expect(binding).toContain('--run-attempt "${ARTIFACT_RUN_ATTEMPT}"'); + expect(binding).toContain('--consumer-run-attempt "${GITHUB_RUN_ATTEMPT}"'); + expect(binding).toContain('--producer-job-name "Pack immutable ClawHub bootstrap artifacts"'); + expect(binding).toContain("--clawhub-toolchain-integrity"); + expect(binding).toContain("--clawhub-toolchain-sha256"); + expect(binding).toContain("--clawhub-toolchain-version"); + }); + + it("rehashes and validates tgz identity before exposing the token", () => { + const publish = job("publish_bootstrap_plugins"); + const names = (publish.steps ?? []).map((entry) => entry.name); + expect(names.indexOf("Rehash immutable ClawHub bootstrap artifacts")).toBeLessThan( + names.indexOf("Write ClawHub token config"), + ); + expect( + names.indexOf("Validate packed ClawHub package identities before credentials"), + ).toBeLessThan(names.indexOf("Write ClawHub token config")); + expect(names.indexOf("Materialize locked ClawHub CLI")).toBeLessThan( + names.indexOf("Write ClawHub token config"), + ); + expect( + names.indexOf("Reconfirm configure-only registry bytes before credentials"), + ).toBeLessThan(names.indexOf("Write ClawHub token config")); + expect(names.indexOf("Reconfirm release tag before credentials")).toBeLessThan( + names.indexOf("Write ClawHub token config"), + ); + expect(step(publish, "Rehash immutable ClawHub bootstrap artifacts").run).toContain( + ".release-harness/scripts/lib/clawhub-bootstrap-artifact.mjs verify", + ); + expect( + step(publish, "Validate packed ClawHub package identities before credentials").run, + ).toContain("--validate-packed"); + expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain( + "--publish-packed", + ); + expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain( + "verify_release_tag_target", + ); + expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain( + "OPENCLAW_CLAWHUB_RELEASE_GIT_DIR", + ); + expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain( + "OPENCLAW_CLAWHUB_RELEASE_TAG", + ); + expect(step(publish, "Publish exact ClawHub bootstrap artifacts").run).toContain( + "OPENCLAW_CLAWHUB_TARGET_SHA", + ); + }); + + it("preserves configure-only repair and exact registry byte readback", () => { + const publish = job("publish_bootstrap_plugins"); + const publishRun = step(publish, "Publish exact ClawHub bootstrap artifacts").run ?? ""; + expect(publishRun).toContain('mode}" == "publish"'); + expect(publishRun).toContain("GitHub Actions immutable bootstrap retry"); + expect(publishRun).toContain("GitHub Actions trusted publisher repair before OIDC migration"); + expect(publishRun).toContain('"${OPENCLAW_CLAWHUB_CLI}" package trusted-publisher set'); + expect(publishRun).toContain("timeout --signal=TERM --kill-after=10s 300s"); + expect(publishRun).toContain("--repository openclaw/openclaw"); + expect(publishRun).toContain("--workflow-filename plugin-clawhub-release.yml"); + expect(publishRun).not.toContain("--environment"); + expect(step(publish, "Verify exact ClawHub registry artifact bytes").run).toContain( + ".release-harness/scripts/verify-clawhub-published-artifact.mjs", + ); + expect(step(publish, "Verify exact ClawHub registry artifact bytes").run).toContain( + '--terminal-run-attempt "${GITHUB_RUN_ATTEMPT}"', + ); + expect(step(publish, "Upload ClawHub bootstrap readback evidence").with?.name).toBe( + "clawhub-bootstrap-readback-${{ github.run_id }}-${{ github.run_attempt }}", + ); + expect( + step(publish, "Reconfirm configure-only registry bytes before credentials").run, + ).toContain("--mode configure-only-preflight"); + }); + + it("uses one lockfile-only ClawHub CLI graph and absolute binary path", () => { + expect(clawhubCliPackage.dependencies).toEqual({ clawhub: "0.23.1" }); + expect(clawhubCliLock.packages?.["node_modules/clawhub"]).toMatchObject({ + integrity: + "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA==", + version: "0.23.1", + }); + expect(materializerSource).toContain("npm ci"); + expect(materializerSource).toContain("--ignore-scripts"); + expect(materializerSource).toContain("--omit=dev"); + expect(materializerSource).toContain( + "f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816", + ); + expect(materializerSource).toContain("lock_sha256="); + expect(materializerSource).toContain("integrity=${clawhub_integrity}"); + expect(materializerSource).toContain("cli=${clawhub_cli}"); + expect(source).not.toContain("npm exec"); + expect(source).not.toContain("npm install"); + expect(source).not.toContain("CLAWHUB_CLI_PACKAGE"); + expect(source).toContain("OPENCLAW_CLAWHUB_CLI: ${{ steps.clawhub_cli.outputs.cli }}"); + expect(source).toContain('"${OPENCLAW_CLAWHUB_CLI}" package trusted-publisher set'); + }); + + it("bounds every job and keeps secretless validation active in dry-run mode", () => { + expect(job("resolve_bootstrap_plan")["timeout-minutes"]).toBe(30); + expect(job("validate_release_publish_approval")["timeout-minutes"]).toBe(20); + expect(job("validate_bootstrap_trusted_publisher_cli")["timeout-minutes"]).toBe(10); + expect(job("validate_bootstrap_trusted_publisher_cli").if).not.toContain( + "inputs.dry_run != true", + ); + expect(job("validate_release_publish_approval").if).toContain( + "inputs.pretag_validation != true", + ); + expect(job("pack_bootstrap_plugins")["timeout-minutes"]).toBe(60); + }); +}); diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index df2bb21165b2..c03406e1e359 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -1,22 +1,38 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; const workflowPath = ".github/workflows/plugin-npm-release.yml"; +const metaPackagePath = "extensions/meta/package.json"; +const metaManifestPath = "extensions/meta/openclaw.plugin.json"; -type Step = { env?: Record; name?: string; run?: string }; +type Step = { + env?: Record; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; type Job = { if?: string; needs?: string[] | string; + permissions?: Record; + "runs-on"?: string; steps?: Step[]; strategy?: { matrix?: { plugin?: string } }; }; +type WorkflowInput = { + default?: boolean | string; + description?: string; + options?: string[]; + required?: boolean; + type?: string; +}; type Workflow = { on?: { workflow_dispatch?: { - inputs?: { - npm_dist_tag?: { default?: string; options?: string[]; type?: string }; - }; + inputs?: Record; }; }; jobs?: Record; @@ -48,16 +64,37 @@ describe("plugin npm extended-stable workflow", () => { }); }); + it("exposes a closed preflight-only mode", () => { + const inputs = workflow().on?.workflow_dispatch?.inputs; + expect(inputs?.preflight_only).toEqual({ + description: "Prepare and verify immutable plugin npm artifacts without publishing", + required: true, + default: false, + type: "boolean", + }); + expect(inputs?.ref?.description).toBe( + "Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches", + ); + }); + it("uses one override for check, plan, preview, pack, and publish", () => { const parsed = workflow(); const raw = readFileSync(workflowPath, "utf8"); expect(raw.match(/--npm-dist-tag "\$\{NPM_DIST_TAG\}"/gu)).toHaveLength(2); const expectedOverride = "${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}"; - for (const name of ["Preview publish command", "Preview npm pack contents", "Publish"]) { + for (const name of [ + "Preview publish command", + "Preview npm pack contents", + "Publish with trusted publisher", + ]) { expect( step( - parsed.jobs?.[name === "Publish" ? "publish_plugins_npm" : "preview_plugin_pack"], + parsed.jobs?.[ + name === "Publish with trusted publisher" + ? "publish_plugins_npm" + : "preview_plugin_pack" + ], name, ).env, ).toMatchObject({ OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: expectedOverride }); @@ -76,21 +113,301 @@ describe("plugin npm extended-stable workflow", () => { ); }); + it("binds preflight to an exact source SHA without release-publish approval", () => { + const preview = workflow().jobs?.preview_plugins_npm; + const previewSteps = preview?.steps ?? []; + const trusted = step(preview, "Validate ref is on a trusted publish branch"); + expect(previewSteps.slice(0, 4).map((candidate) => candidate.name)).toEqual([ + "Checkout", + "Resolve checked-out ref", + "Validate ref is on a trusted publish branch", + "Setup Node environment", + ]); + const trustedIndex = previewSteps.indexOf(trusted); + expect(trustedIndex).toBe(2); + for (const candidate of previewSteps.slice(0, trustedIndex)) { + expect(candidate.uses?.startsWith("./"), candidate.name).not.toBe(true); + expect(candidate.run ?? "", candidate.name).not.toMatch(/\b(?:bun|npm|pnpm)\b/u); + } + expect(step(preview, "Setup Node environment").uses).toBe("./.github/actions/setup-node-env"); + expect(trusted.env).toMatchObject({ + PREFLIGHT_ONLY: + "${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }}", + RELEASE_PUBLISH_RUN_ID: + "${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}", + SOURCE_REF: "${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}", + WORKFLOW_REF: "${{ github.ref }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(trusted.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]'); + expect(trusted.run).toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main'); + expect(trusted.run).toContain('[[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]'); + expect(trusted.run).toContain( + '[[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]', + ); + expect(trusted.run).toContain("preflight must not include release_publish_run_id"); + const preflightBranchRejection = trusted.run?.indexOf( + "Plugin npm preflight target must be reachable from main or release/*.", + ); + const tideclawBranch = trusted.run?.indexOf( + 'if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/', + ); + expect(preflightBranchRejection).toBeGreaterThan(-1); + expect(tideclawBranch).toBeGreaterThan(preflightBranchRejection ?? Number.MAX_SAFE_INTEGER); + }); + + it("prepares and independently reads back immutable package evidence", () => { + const parsed = workflow(); + const preview = parsed.jobs?.preview_plugin_pack; + expect(preview?.if).toContain("inputs.preflight_only"); + expect(preview?.strategy?.matrix?.plugin).toContain("all_matrix"); + + const prepare = step(preview, "Prepare immutable npm preflight artifact"); + expect(prepare.env?.ARTIFACT_NAME).toBe( + "plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}", + ); + expect(prepare.if).toBeUndefined(); + expect(prepare.run).toContain('bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}"'); + expect(prepare.run).toContain('raw.lastIndexOf("[")'); + expect(prepare.run).toContain("npm can print bundled-dependency summaries"); + expect(prepare.run).toContain("if (index === 0)"); + expect(prepare.run).toContain( + "fs.writeFileSync(process.argv[3], `${JSON.stringify(pack, null, 2)}\\n`)", + ); + expect(prepare.run).toContain('path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json")'); + expect(prepare.run).toContain('kind: "openclaw-plugin-npm-preflight"'); + expect(prepare.run).toContain('mode: "preflight-only"'); + expect(prepare.run).toContain("source_package_json_sha256="); + expect(prepare.run).toContain("packed_package_json_sha256="); + expect(prepare.run).toContain( + "sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256", + ); + expect(prepare.run).toContain("packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256"); + expect(prepare.run).toContain("npmIntegrity: actualIntegrity"); + expect(prepare.run).toContain("npmShasum: actualShasum"); + expect(prepare.run).toContain('typeof pluginManifest.id !== "string"'); + expect(prepare.run).not.toContain("pluginManifest.id !== process.env.EXTENSION_ID"); + expect(prepare.run).toContain( + 'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"', + ); + expect(prepare.run).toContain("npmPublish: false"); + expect(prepare.run).toContain("environmentApproval: false"); + expect(prepare.run).toContain("oidcWrite: false"); + + const upload = step(preview, "Upload immutable npm preflight artifact"); + expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); + expect(upload.with).toMatchObject({ + "compression-level": 0, + "if-no-files-found": "error", + overwrite: true, + "retention-days": 30, + }); + + const verify = parsed.jobs?.verify_plugin_npm_preflight; + expect(verify?.needs).toEqual(["preview_plugins_npm", "preview_plugin_pack"]); + expect(verify?.strategy?.matrix?.plugin).toContain("all_matrix"); + expect(verify?.strategy?.matrix?.plugin).toContain("matrix"); + expect(verify?.name).toBe("Preflight plugin npm package (${{ matrix.plugin.packageName }})"); + const trustedCheckout = step(verify, "Checkout trusted npm preflight tooling"); + expect(trustedCheckout.with?.ref).toBe("${{ github.workflow_sha }}"); + const download = step(verify, "Download immutable npm preflight artifact"); + expect(download.uses).toBe( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + ); + expect(download.with?.name).toBe( + "plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}", + ); + const readback = step(verify, "Validate npm preflight artifact readback"); + expect(readback.run).toContain('git show "${SOURCE_SHA}:${PACKAGE_DIR}/package.json"'); + expect(readback.run).toContain("Expected exactly one live package artifact named"); + expect(readback.run).toContain('crypto.createHash("sha256")'); + expect(readback.run).toContain('crypto.createHash("sha512")'); + expect(readback.run).toContain('crypto.createHash("sha1")'); + expect(readback.run).toContain('echo "npm_integrity=${npm_integrity}"'); + expect(readback.run).toContain('echo "npm_shasum=${npm_shasum}"'); + expect(readback.run).toContain( + "Packed plugin identity, package hashes, or install route changed", + ); + expect(readback.run).toContain("manifest.package.pluginId !== pluginManifest.id"); + expect(readback.run).not.toContain("manifest.package.pluginId !== process.env.EXTENSION_ID"); + expect(readback.run).toContain( + 'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"', + ); + expect(readback.run).not.toContain("target-main-release-or-tideclaw"); + + const route = step(verify, "Verify npm publication route readiness"); + expect(route.env).toMatchObject({ + EXPECTED_NPM_INTEGRITY: "${{ steps.publication_artifact.outputs.npm_integrity }}", + EXPECTED_NPM_SHASUM: "${{ steps.publication_artifact.outputs.npm_shasum }}", + }); + expect(route.run).toContain("encodeURIComponent(packageName)"); + expect(route.run).toContain("fetchNpmRegistryPackumentWithRetry"); + expect(route.run).toContain("resolvePublishedNpmVersionRoute"); + expect(route.run).toContain('distTags: packument["dist-tags"] ?? {}'); + expect(route.run).toContain("const requestAttempts = 3"); + expect(route.run).toContain("const requestTimeoutMs = 20_000"); + expect(route.run).toContain("attempts: requestAttempts"); + expect(route.run).toContain("timeoutMs: requestTimeoutMs"); + expect(route.run).not.toContain("response.json()"); + expect(route.run).toContain("packument.versions?.[packageVersion]?.dist"); + expect(route.run).toContain("targetDist?.integrity !== expectedIntegrity"); + expect(route.run).toContain("targetDist?.shasum !== expectedShasum"); + expect(route.run).toContain("npm registry tarball identity does not match"); + expect(route.run).toContain('observations.push("npm-token-bootstrap")'); + expect(route.run).toContain('observations.push("npm-oidc")'); + + const evidence = step(verify, "Create immutable plugin npm publication evidence"); + expect(evidence.env?.PUBLISH_ROUTE).toBe("${{ steps.publication_route.outputs.route }}"); + expect(evidence.run).toContain("node scripts/plugin-publication-artifact.mjs create"); + expect(evidence.run).toContain("--publisher-policy-id plugin-npm-release-workflow"); + expect(evidence.run).toContain('--route "$PUBLISH_ROUTE"'); + expect(evidence.run).toContain('artifact_name="${ARTIFACT_NAME_PREFIX}-${PUBLISH_ROUTE}-'); + const evidenceUpload = step(verify, "Upload immutable plugin npm preflight evidence"); + expect(evidenceUpload.with?.name).toBe("${{ steps.preflight_evidence.outputs.artifact_name }}"); + expect(evidenceUpload.with?.path).toBe( + "${{ steps.preflight_evidence.outputs.artifact_path }}/*", + ); + }); + + it("makes every publication capability unreachable in preflight mode", () => { + const parsed = workflow(); + for (const jobName of [ + "validate_release_publish_approval", + "publish_plugins_npm", + "verify_plugins_npm", + ]) { + expect(parsed.jobs?.[jobName]?.if, jobName).toContain("!inputs.preflight_only"); + } + + for (const jobName of [ + "preview_plugins_npm", + "preview_plugin_pack", + "verify_plugin_npm_preflight", + ]) { + const job = parsed.jobs?.[jobName]; + expect(job?.environment, jobName).toBeUndefined(); + expect(job?.permissions?.["id-token"], jobName).not.toBe("write"); + const serialized = JSON.stringify(job); + expect(serialized, jobName).not.toContain("secrets."); + expect(serialized, jobName).not.toContain("plugin-npm-publish.sh --publish"); + expect(serialized, jobName).not.toMatch(/\bnpm publish\b/u); + expect(serialized, jobName).not.toMatch(/\bnpm dist-tag\b/u); + expect(serialized.replaceAll("clawHub: false", ""), jobName).not.toMatch(/\bclawhub\b/iu); + expect(serialized, jobName).not.toMatch(/\b(?:android|macos|windows)\b/iu); + } + }); + + it("attests the canonical Meta provider package and install route", () => { + if (!existsSync(metaPackagePath)) { + return; + } + const packageJson = JSON.parse(readFileSync(metaPackagePath, "utf8")) as { + name?: string; + openclaw?: { + install?: { npmSpec?: string }; + release?: { publishToClawHub?: boolean; publishToNpm?: boolean }; + }; + }; + const pluginManifest = JSON.parse(readFileSync(metaManifestPath, "utf8")) as { id?: string }; + expect(packageJson.name).toBe("@openclaw/meta-provider"); + expect(packageJson.openclaw?.install?.npmSpec).toBe("@openclaw/meta-provider"); + expect(packageJson.openclaw?.release).toEqual({ + publishToClawHub: true, + publishToNpm: true, + }); + expect(pluginManifest.id).toBe("meta"); + }); + it("publishes extended-stable with OIDC only and verifies every package tag", () => { const parsed = workflow(); - const publish = step(parsed.jobs?.publish_plugins_npm, "Publish"); - const tokenExpression = - "${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }}"; - expect(publish.env).toMatchObject({ - NODE_AUTH_TOKEN: tokenExpression, - NPM_TOKEN: tokenExpression, - OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher", + const setup = step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies"); + const publish = step(parsed.jobs?.publish_plugins_npm, "Publish with trusted publisher"); + const versionCheck = step(parsed.jobs?.publish_plugins_npm, "Check OIDC npm package version"); + expect(versionCheck.env).toMatchObject({ + EXPECTED_NPM_INTEGRITY: "${{ steps.publication_evidence.outputs.npm_integrity }}", + EXPECTED_NPM_SHASUM: "${{ steps.publication_evidence.outputs.npm_shasum }}", }); - expect(publish.run).toContain( - 'git fetch --no-tags origin "+refs/heads/${WORKFLOW_BRANCH}:refs/remotes/origin/${WORKFLOW_BRANCH}"', + expect(versionCheck.run).toContain(".dist.integrity"); + expect(versionCheck.run).toContain(".dist.shasum"); + expect(versionCheck.run).toContain("do not match immutable publication evidence"); + expect(setup.if).toContain("npm-oidc"); + expect(publish.env).toMatchObject({ + OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher", + OPENCLAW_PLUGIN_NPM_DEFER_DIST_TAG_MIRRORS: "1", + SOURCE_PACKAGE_DIR: "${{ steps.publication_evidence.outputs.source_package_dir }}", + TARBALL_PATH: "${{ steps.publication_evidence.outputs.tarball_path }}", + TARBALL_SHA256: "${{ steps.publication_evidence.outputs.tarball_sha256 }}", + }); + expect(publish.env?.NODE_AUTH_TOKEN).toBeUndefined(); + expect(publish.env?.NPM_TOKEN).toBeUndefined(); + const mirrorRepair = step(parsed.jobs?.publish_plugins_npm, "Repair required npm mirrors"); + expect(mirrorRepair.if).toContain("npm-oidc"); + expect(mirrorRepair.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}"); + expect(mirrorRepair.run).toContain("resolveNpmPublishPlan"); + expect(mirrorRepair.run).toContain('[[ "$mirror_dist_tags" != "beta" ]]'); + expect(mirrorRepair.run).toContain("NPM_CONFIG_USERCONFIG"); + expect(mirrorRepair.run).toContain("unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS"); + expect(mirrorRepair.run).toContain( + 'npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" beta', + ); + const bootstrap = step( + parsed.jobs?.publish_plugins_npm, + "Publish approved Meta bootstrap tarball", + ); + expect(bootstrap.if).toContain("npm-token-bootstrap"); + expect(bootstrap.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}"); + expect(bootstrap.run).toContain( + '[[ "$PACKAGE_NAME" == "@openclaw/meta-provider" && "$PACKAGE_DIR" == "extensions/meta" ]]', + ); + expect(bootstrap.run).toContain("NPM_CONFIG_USERCONFIG"); + expect(bootstrap.run).toContain("unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS"); + expect(bootstrap.run).toContain('npm publish "$TARBALL_PATH"'); + expect(bootstrap.run).toContain("--ignore-scripts"); + expect(bootstrap.run).not.toContain("bash scripts/plugin-npm-publish.sh"); + + const consume = step( + parsed.jobs?.publish_plugins_npm, + "Consume immutable npm publication evidence", + ); + expect(consume.run).toContain("node scripts/plugin-publication-artifact.mjs verify"); + expect(consume.run).toContain("--run-state-policy same-run-producer-success"); + expect(consume.run).toContain("producer_attempt"); + expect(consume.run).toContain("last.producer_attempt"); + expect(consume.run).toContain( + '--producer-job-name "Preflight plugin npm package (${PACKAGE_NAME})"', + ); + expect(consume.run).toContain("--workflow-jobs-metadata"); + expect(consume.run).toContain("--source-package-json-sha256"); + expect(consume.run).toContain( + '"$WORKFLOW_REF" != "refs/heads/release-publish/${WORKFLOW_SHA}"', + ); + expect(consume.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + expect( + step(parsed.jobs?.publish_plugins_npm, "Checkout trusted publication tooling").with?.ref, + ).toBe("${{ github.workflow_sha }}"); + expect( + step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if, + ).toContain("npm-token-bootstrap"); + expect( + step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if, + ).toContain("npm-readback"); + expect( + parsed.jobs?.publish_plugins_npm?.steps?.map((candidate) => candidate.name), + ).not.toContain("Checkout OIDC publication target"); + expect( + parsed.jobs?.publish_plugins_npm?.steps?.map((candidate) => candidate.name), + ).not.toContain("Setup OIDC publication target"); + expect(parsed.jobs?.reconcile_plugins_npm).toBeUndefined(); + expect(readFileSync(workflowPath, "utf8")).not.toContain( + 'npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" extended-stable', ); expect(publish.run).toContain('"$current_tip" != "$SOURCE_SHA"'); expect(publish.run).toContain("Refusing stale extended-stable plugin publish"); + expect(publish.run).toContain('sha256sum "$TARBALL_PATH"'); + expect(publish.run).toContain('"$actual_tarball_sha256" != "$TARBALL_SHA256"'); + expect(publish.run).toContain( + 'plugin-npm-publish.sh --publish "$SOURCE_PACKAGE_DIR" "$TARBALL_PATH"', + ); const verify = parsed.jobs?.verify_plugins_npm; expect(verify?.needs).toEqual(["preview_plugins_npm", "publish_plugins_npm"]); expect(verify?.if).toContain("always()"); diff --git a/test/scripts/plugin-npm-publish.test.ts b/test/scripts/plugin-npm-publish.test.ts index 7ffce68e5bad..056051a4a82e 100644 --- a/test/scripts/plugin-npm-publish.test.ts +++ b/test/scripts/plugin-npm-publish.test.ts @@ -1,6 +1,6 @@ // Plugin NPM Publish tests cover publish wrapper argument safety. -import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -35,7 +35,16 @@ function makePackage(version: string): { packageDir: string; path: string } { JSON.stringify({ name: "@openclaw/demo", version }), ); const npmPath = join(binDir, "npm"); - writeFileSync(npmPath, "#!/bin/sh\nexit 1\n"); + writeFileSync( + npmPath, + [ + "#!/bin/sh", + 'if [ "${1:-}" = "view" ]; then exit 1; fi', + 'if [ -n "${NPM_ARGS_FILE:-}" ]; then printf "%s\\n" "$@" > "$NPM_ARGS_FILE"; fi', + "exit 0", + "", + ].join("\n"), + ); chmodSync(npmPath, 0o755); return { packageDir, path: `${binDir}${delimiter}${process.env.PATH ?? ""}` }; } @@ -46,7 +55,7 @@ describe("plugin npm publish wrapper", () => { expect(result.status).toBe(0); expect(result.stdout.trim()).toBe( - "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] ", + "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] [verified-package.tgz]", ); expect(result.stderr).toBe(""); }); @@ -57,7 +66,7 @@ describe("plugin npm publish wrapper", () => { expect(result.status).toBe(2); expect(result.stdout).toBe(""); expect(result.stderr.trim()).toBe( - "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] ", + "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] [verified-package.tgz]", ); }); @@ -98,6 +107,98 @@ describe("plugin npm publish wrapper", () => { expect(result.stdout).toContain("npm publish --access public --tag extended-stable"); }); + it("publishes the verified tarball without rebuilding or repacking the package", () => { + const fixture = makePackage("2026.7.33"); + const stagingDir = join(fixture.packageDir, "..", "staging"); + const packedDir = join(stagingDir, "package"); + const tarballPath = join(fixture.packageDir, "..", "openclaw-demo-2026.7.33.tgz"); + const npmArgsPath = join(fixture.packageDir, "..", "npm-args.txt"); + mkdirSync(packedDir, { recursive: true }); + writeFileSync( + join(packedDir, "package.json"), + JSON.stringify({ name: "@openclaw/demo", version: "2026.7.33" }), + ); + execFileSync("tar", ["-czf", tarballPath, "-C", stagingDir, "package"]); + + const result = runPluginPublishWrapper(["--publish", fixture.packageDir, tarballPath], { + NPM_ARGS_FILE: npmArgsPath, + OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher", + OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: "extended-stable", + PATH: fixture.path, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`Resolved verified publish target: ${tarballPath}`); + expect(result.stdout).not.toContain("Package-local runtime build:"); + expect(readFileSync(npmArgsPath, "utf8").trim().split("\n")).toEqual([ + "publish", + tarballPath, + "--access", + "public", + "--tag", + "extended-stable", + "--provenance", + ]); + }); + + it("defers stable mirrors during trusted publication without requiring a token", () => { + const fixture = makePackage("2026.7.1"); + const stagingDir = join(fixture.packageDir, "..", "stable-staging"); + const packedDir = join(stagingDir, "package"); + const tarballPath = join(fixture.packageDir, "..", "openclaw-demo-2026.7.1.tgz"); + const npmArgsPath = join(fixture.packageDir, "..", "stable-npm-args.txt"); + mkdirSync(packedDir, { recursive: true }); + writeFileSync( + join(packedDir, "package.json"), + JSON.stringify({ name: "@openclaw/demo", version: "2026.7.1" }), + ); + execFileSync("tar", ["-czf", tarballPath, "-C", stagingDir, "package"]); + + const result = runPluginPublishWrapper(["--publish", fixture.packageDir, tarballPath], { + NPM_ARGS_FILE: npmArgsPath, + OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher", + OPENCLAW_PLUGIN_NPM_DEFER_DIST_TAG_MIRRORS: "1", + PATH: fixture.path, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Resolved mirror dist-tags: beta"); + expect(result.stdout).toContain( + "Mirror dist-tag execution: deferred to credential-isolated release tooling", + ); + expect(readFileSync(npmArgsPath, "utf8").trim().split("\n")).toEqual([ + "publish", + tarballPath, + "--access", + "public", + "--tag", + "latest", + "--provenance", + ]); + }); + + it("rejects a verified tarball whose package identity differs from the source target", () => { + const fixture = makePackage("2026.7.33"); + const stagingDir = join(fixture.packageDir, "..", "mismatch-staging"); + const packedDir = join(stagingDir, "package"); + const tarballPath = join(fixture.packageDir, "..", "mismatch.tgz"); + mkdirSync(packedDir, { recursive: true }); + writeFileSync( + join(packedDir, "package.json"), + JSON.stringify({ name: "@openclaw/demo", version: "2026.7.34" }), + ); + execFileSync("tar", ["-czf", tarballPath, "-C", stagingDir, "package"]); + + const result = runPluginPublishWrapper(["--publish", fixture.packageDir, tarballPath], { + PATH: fixture.path, + }); + + expect(result.status).toBe(2); + expect(result.stderr).toContain( + "verified plugin npm tarball identity mismatch: expected @openclaw/demo@2026.7.33, got @openclaw/demo@2026.7.34", + ); + }); + it("rejects extended-stable versions below patch 33", () => { const fixture = makePackage("2026.7.32"); const result = runPluginPublishWrapper(["--dry-run", fixture.packageDir], { diff --git a/test/scripts/plugin-publication-artifact.test.ts b/test/scripts/plugin-publication-artifact.test.ts new file mode 100644 index 000000000000..52dd15b87124 --- /dev/null +++ b/test/scripts/plugin-publication-artifact.test.ts @@ -0,0 +1,1765 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { deflateRawSync, gzipSync } from "node:zlib"; +import * as tar from "tar"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createPluginPublicationArtifact, + downloadActionsArtifactArchive, + inspectActionsArtifactZipWithPolicy, + verifyPluginPublicationArtifact, +} from "../../scripts/plugin-publication-artifact.mjs"; + +const TARGET_SHA = "1".repeat(40); +const WORKFLOW_SHA = "2".repeat(40); +const ARTIFACT_ID = 12345; +const RUN_ID = 67890; +const RUN_ATTEMPT = 2; +const REPOSITORY = "openclaw/openclaw"; +const WORKFLOW_PATH = ".github/workflows/plugin-npm-release.yml"; +const ARTIFACT_NAME = "plugin-npm-package-meta-2026.7.1-beta.3"; +const PACKAGE_NAME = "@openclaw/meta-provider"; +const PRODUCER_JOB_NAME = `Preflight plugin npm package (${PACKAGE_NAME})`; +const PACKAGE_VERSION = "2026.7.1-beta.3"; +const PACKAGE_DIR = "extensions/meta"; +const TARBALL_NAME = "openclaw-meta-provider-2026.7.1-beta.3.tgz"; +const MANUAL_OVERRIDE_REASON = + "OpenClaw Release Publish run 12345 approved token release for v2026.7.1-beta.3"; +const PUBLICATION_REASON = "First npm publication for the approved beta3 Meta package."; +const PUBLISHER_POLICY = { + policyId: "2026.7.1-beta.3", + schema: "openclaw.plugin-npm-publisher-policy/v1", + sha256: "6a40c33756ff1016744bb929660c1d9bf271cd478b0b9811fa8e2d8f1f775e95", +}; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-publication-artifact-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function writeTarString(header: Buffer, offset: number, length: number, value: string): void { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length > length) { + throw new Error(`Tar field is too long: ${value}`); + } + bytes.copy(header, offset); +} + +function writeTarOctal(header: Buffer, offset: number, length: number, value: number): void { + const raw = value.toString(8).padStart(length - 2, "0"); + if (raw.length !== length - 2) { + throw new Error(`Tar number is too large: ${value}`); + } + writeTarString(header, offset, length, `${raw} \0`); +} + +type TarEntry = { + content?: Buffer | string; + format?: "ustar" | "v7"; + linkPath?: string; + path: string; + prefix?: string; + type?: "0" | "2" | "3" | "5" | "K" | "L" | "g" | "x"; +}; + +function tarEntry(entry: TarEntry): Buffer { + const type = entry.type ?? "0"; + const content = Buffer.isBuffer(entry.content) + ? entry.content + : Buffer.from(entry.content ?? "", "utf8"); + const header = Buffer.alloc(512); + writeTarString(header, 0, 100, entry.path); + writeTarOctal(header, 100, 8, type === "5" ? 0o755 : 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, content.length); + writeTarOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + header[156] = type.charCodeAt(0); + if (entry.linkPath) { + writeTarString(header, 157, 100, entry.linkPath); + } + if (entry.format !== "v7") { + writeTarString(header, 257, 6, "ustar\0"); + writeTarString(header, 263, 2, "00"); + } + writeTarOctal(header, 329, 8, 0); + writeTarOctal(header, 337, 8, 0); + if (entry.prefix) { + writeTarString(header, 345, 155, entry.prefix); + } + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeTarOctal(header, 148, 8, checksum); + const padding = Buffer.alloc((512 - (content.length % 512)) % 512); + return Buffer.concat([header, content, padding]); +} + +function mutateTarEntryHeader( + entry: Buffer, + mutate: (header: Buffer) => void, + options: { preserveChecksumBytes?: boolean } = {}, +): Buffer { + const result = Buffer.from(entry); + const header = result.subarray(0, 512); + mutate(header); + if (!options.preserveChecksumBytes) { + header.fill(0x20, 148, 156); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeTarOctal(header, 148, 8, checksum); + } + return result; +} + +function createTarball(entries: TarEntry[]): Buffer { + return gzipSync(Buffer.concat([...entries.map((entry) => tarEntry(entry)), Buffer.alloc(1024)])); +} + +function createTarballFromParts(parts: Buffer[]): Buffer { + return gzipSync(Buffer.concat([...parts, Buffer.alloc(1024)])); +} + +function paxRecord(key: string, value: string): Buffer { + const payload = `${key}=${value}\n`; + let length = Buffer.byteLength(payload) + 2; + while (true) { + const record = `${length} ${payload}`; + const actualLength = Buffer.byteLength(record); + if (actualLength === length) { + return Buffer.from(record, "utf8"); + } + length = actualLength; + } +} + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +type ZipFile = { + bytes: Buffer; + centralFlags?: number; + compression?: 0 | 8; + compressedBytes?: Buffer; + declaredExpandedSize?: number; + descriptor?: boolean; + descriptorCrc?: number; + flags?: number; + gapAfter?: Buffer; + localCrc?: number; + localExpandedSize?: number; + localFlags?: number; + localNameBytes?: Buffer; + localCompressedSize?: number; + name: string; + nameBytes?: Buffer; +}; + +function createZip(files: ZipFile[]): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let localOffset = 0; + for (const file of files) { + const name = file.nameBytes ?? Buffer.from(file.name, "utf8"); + const localName = file.localNameBytes ?? name; + const compression = file.compression ?? 0; + const compressed = + file.compressedBytes ?? (compression === 8 ? deflateRawSync(file.bytes) : file.bytes); + const expandedSize = file.declaredExpandedSize ?? file.bytes.length; + const checksum = crc32(file.bytes); + const flags = file.flags ?? (file.descriptor ? 0x0008 : 0); + const localFlags = file.localFlags ?? flags; + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(localFlags, 6); + local.writeUInt16LE(compression, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(file.localCrc ?? (file.descriptor ? 0 : checksum), 14); + local.writeUInt32LE(file.localCompressedSize ?? (file.descriptor ? 0 : compressed.length), 18); + local.writeUInt32LE(file.localExpandedSize ?? (file.descriptor ? 0 : expandedSize), 22); + local.writeUInt16LE(localName.length, 26); + local.writeUInt16LE(0, 28); + const descriptor = file.descriptor + ? (() => { + const value = Buffer.alloc(16); + value.writeUInt32LE(0x08074b50, 0); + value.writeUInt32LE(file.descriptorCrc ?? checksum, 4); + value.writeUInt32LE(compressed.length, 8); + value.writeUInt32LE(expandedSize, 12); + return value; + })() + : Buffer.alloc(0); + const gap = file.gapAfter ?? Buffer.alloc(0); + localParts.push(local, localName, compressed, descriptor, gap); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(file.centralFlags ?? flags, 8); + central.writeUInt16LE(compression, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(expandedSize, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE((0o100600 * 0x10000) >>> 0, 38); + central.writeUInt32LE(localOffset, 42); + centralParts.push(central, name); + localOffset += + local.length + localName.length + compressed.length + descriptor.length + gap.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localOffset, 16); + end.writeUInt16LE(0, 20); + return Buffer.concat([...localParts, centralDirectory, end]); +} + +function inspectTestZip( + zip: Buffer, + overrides: Partial<{ + maxArchiveBytes: number; + maxCompressedEntryBytes: (name: string) => number; + maxEntries: number; + maxExpandedBytes: number; + maxEntryBytes: (name: string) => number; + minEntries: number; + }> = {}, +) { + return inspectActionsArtifactZipWithPolicy(zip, { + minEntries: 1, + maxEntries: 8, + maxArchiveBytes: 1024 * 1024, + maxExpandedBytes: 1024 * 1024, + allowPath: () => true, + maxCompressedEntryBytes: () => 1024 * 1024, + maxEntryBytes: () => 1024 * 1024, + ...overrides, + }); +} + +function metaPackageJson(markerPath: string, overrides: Record = {}): string { + return `${JSON.stringify( + { + name: PACKAGE_NAME, + version: PACKAGE_VERSION, + type: "module", + scripts: { + preinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'bad')"`, + postinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'bad')"`, + }, + openclaw: { + release: { + publishToClawHub: true, + publishToNpm: true, + }, + }, + ...overrides, + }, + null, + 2, + )}\n`; +} + +function publicationParams(artifactDir: string, overrides: Record = {}) { + const route = typeof overrides.route === "string" ? overrides.route : "npm-token-bootstrap"; + const npmPolicy = route.startsWith("npm-") + ? { + publicationReason: PUBLICATION_REASON, + publisherPolicy: PUBLISHER_POLICY, + } + : {}; + return { + artifactDir, + artifactName: ARTIFACT_NAME, + packageDir: PACKAGE_DIR, + packageName: PACKAGE_NAME, + publishTag: "beta", + route, + sourcePackageJsonSha256: "3".repeat(64), + targetSha: TARGET_SHA, + version: PACKAGE_VERSION, + ...npmPolicy, + ...overrides, + }; +} + +function createFixture( + options: { + packageJson?: string; + publicationOverrides?: Record; + tarEntries?: TarEntry[]; + } = {}, +) { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const outputDir = path.join(root, "verified"); + const markerPath = path.join(root, "lifecycle-ran"); + mkdirSync(artifactDir, { recursive: true }); + const packageJson = options.packageJson ?? metaPackageJson(markerPath); + const tarball = createTarball( + options.tarEntries ?? [ + { path: "package/", type: "5" }, + { content: packageJson, path: "package/package.json" }, + { content: '{"id":"meta"}\n', path: "package/openclaw.plugin.json" }, + { content: "export default {};\n", path: "package/index.js" }, + ], + ); + writeFileSync(path.join(artifactDir, TARBALL_NAME), tarball); + const created = createPluginPublicationArtifact( + publicationParams(artifactDir, options.publicationOverrides), + ); + const manifestBytes = readFileSync(created.manifestPath); + const zip = createZip([ + { bytes: tarball, name: TARBALL_NAME }, + { bytes: manifestBytes, name: "plugin-publication-manifest.json" }, + ]); + const zipPath = path.join(root, "artifact.zip"); + const metadataPath = path.join(root, "artifact.json"); + const workflowRunPath = path.join(root, "run.json"); + const workflowJobsPath = path.join(root, "jobs.json"); + writeFileSync(zipPath, zip); + writeArtifactMetadata(metadataPath, zip); + writeWorkflowRunMetadata(workflowRunPath); + writeFileSync( + workflowJobsPath, + `${JSON.stringify({ + total_count: 1, + jobs: [ + { + name: PRODUCER_JOB_NAME, + run_id: RUN_ID, + run_attempt: RUN_ATTEMPT, + head_sha: WORKFLOW_SHA, + status: "completed", + conclusion: "success", + }, + ], + })}\n`, + ); + return { + artifactDir, + created, + markerPath, + metadataPath, + outputDir, + publicationOverrides: options.publicationOverrides, + root, + tarball, + workflowRunPath, + workflowJobsPath, + zip, + zipPath, + }; +} + +function writeArtifactMetadata(metadataPath: string, zip: Buffer): void { + writeFileSync( + metadataPath, + `${JSON.stringify({ + id: ARTIFACT_ID, + name: ARTIFACT_NAME, + expired: false, + digest: `sha256:${sha256(zip)}`, + size_in_bytes: zip.length, + workflow_run: { + id: RUN_ID, + head_sha: WORKFLOW_SHA, + }, + })}\n`, + ); +} + +function writeWorkflowRunMetadata(workflowRunPath: string): void { + writeFileSync( + workflowRunPath, + `${JSON.stringify({ + id: RUN_ID, + run_attempt: RUN_ATTEMPT, + head_sha: WORKFLOW_SHA, + head_branch: "main", + event: "workflow_dispatch", + path: WORKFLOW_PATH, + status: "completed", + conclusion: "success", + repository: { full_name: REPOSITORY }, + head_repository: { full_name: REPOSITORY }, + })}\n`, + ); +} + +function replaceArtifactZip(fixture: ReturnType, files: ZipFile[]): void { + const zip = createZip(files); + fixture.zip = zip; + writeFileSync(fixture.zipPath, zip); + writeArtifactMetadata(fixture.metadataPath, zip); +} + +function verifyFixture( + fixture: ReturnType, + overrides: Record = {}, +) { + return verifyPluginPublicationArtifact({ + ...publicationParams(fixture.artifactDir, fixture.publicationOverrides), + artifactDigest: `sha256:${sha256(fixture.zip)}`, + artifactId: ARTIFACT_ID, + artifactMetadataPath: fixture.metadataPath, + artifactSizeBytes: fixture.zip.length, + artifactZipPath: fixture.zipPath, + outputDir: fixture.outputDir, + producerRunAttempt: RUN_ATTEMPT, + producerRunId: RUN_ID, + repository: REPOSITORY, + workflowEvent: "workflow_dispatch", + workflowHeadBranch: "main", + workflowPath: WORKFLOW_PATH, + workflowRunMetadataPath: fixture.workflowRunPath, + workflowSha: WORKFLOW_SHA, + ...overrides, + }); +} + +describe("plugin publication artifact", () => { + it("canonically binds and verifies the Meta beta3 token-bootstrap tuple without running lifecycle scripts", () => { + const fixture = createFixture(); + const verified = verifyFixture(fixture); + + expect(verified.manifest).toMatchObject({ + targetSha: TARGET_SHA, + package: { + dir: PACKAGE_DIR, + name: PACKAGE_NAME, + sourcePackageJsonSha256: "3".repeat(64), + version: PACKAGE_VERSION, + }, + publication: { + authMode: "token-bootstrap", + capability: "first-publication", + publisherPolicy: PUBLISHER_POLICY, + reason: PUBLICATION_REASON, + route: "npm-token-bootstrap", + tag: "beta", + }, + artifact: { + name: ARTIFACT_NAME, + npmIntegrity: `sha512-${createHash("sha512").update(fixture.tarball).digest("base64")}`, + npmShasum: createHash("sha1").update(fixture.tarball).digest("hex"), + sha256: sha256(fixture.tarball), + }, + }); + expect(verified).toMatchObject({ + npmIntegrity: verified.manifest.artifact.npmIntegrity, + npmShasum: verified.manifest.artifact.npmShasum, + packageJsonSha256: verified.manifest.package.packageJsonSha256, + pluginManifestSha256: verified.manifest.package.pluginManifestSha256, + sourcePackageJsonSha256: "3".repeat(64), + tarballName: TARBALL_NAME, + }); + expect(readFileSync(verified.tarballPath)).toEqual(fixture.tarball); + expect(verified.tarballInventory).toEqual(verified.manifest.artifact.inventory); + expect(verified.tarballSizeBytes).toBe(fixture.tarball.length); + expect(existsSync(fixture.markerPath)).toBe(false); + }); + + it("derives the closed npm auth capability and binds placeholder-recovery policy", () => { + const fixture = createFixture({ + publicationOverrides: { + route: "npm-token-placeholder-recovery", + }, + }); + const verified = verifyFixture(fixture); + + expect(verified.manifest.publication).toEqual({ + authMode: "token-bootstrap", + capability: "placeholder-recovery", + publisherPolicy: PUBLISHER_POLICY, + reason: PUBLICATION_REASON, + route: "npm-token-placeholder-recovery", + tag: "beta", + }); + expect(() => + verifyFixture(fixture, { + authMode: "release-token", + }), + ).toThrow("auth mode must be token-bootstrap"); + expect(() => + verifyFixture(fixture, { + capability: "first-publication", + }), + ).toThrow("capability must be placeholder-recovery"); + }); + + it("requires npm publication reason and exact publisher-policy identity", () => { + const fixture = createFixture(); + for (const overrides of [ + { publicationReason: "" }, + { publicationReason: "invalid\nreason" }, + { publisherPolicy: undefined }, + { publisherPolicy: { ...PUBLISHER_POLICY, sha256: "A".repeat(64) } }, + { publisherPolicy: { ...PUBLISHER_POLICY, extra: true } }, + ]) { + expect(() => verifyFixture(fixture, overrides)).toThrow(); + } + }); + + it("rejects a publication artifact bound to a different target package.json", () => { + const fixture = createFixture(); + + expect(() => + verifyFixture(fixture, { + sourcePackageJsonSha256: "4".repeat(64), + }), + ).toThrow("source package.json SHA-256 does not match the approved target source"); + }); + + it("binds exact tarball size and canonical inventory", () => { + const fixture = createFixture(); + const expectedInventory = JSON.parse(readFileSync(fixture.created.manifestPath, "utf8")) + .artifact.inventory; + expect( + verifyFixture(fixture, { + expectedInventory, + expectedTarballSha256: sha256(fixture.tarball), + expectedTarballSizeBytes: fixture.tarball.length, + }), + ).toMatchObject({ + tarballInventory: expectedInventory, + tarballSha256: sha256(fixture.tarball), + tarballSizeBytes: fixture.tarball.length, + }); + + const wrongSizeFixture = createFixture(); + expect(() => + verifyFixture(wrongSizeFixture, { + expectedTarballSizeBytes: wrongSizeFixture.tarball.length + 1, + }), + ).toThrow("tarball size does not match"); + + const wrongInventoryFixture = createFixture(); + expect(() => + verifyFixture(wrongInventoryFixture, { + expectedInventory: expectedInventory.slice(0, -1), + }), + ).toThrow("tarball inventory does not match"); + }); + + it("requires a fresh non-symlink output directory", () => { + const existingFixture = createFixture(); + mkdirSync(existingFixture.outputDir); + expect(() => verifyFixture(existingFixture)).toThrow("must not already exist"); + + const symlinkFixture = createFixture(); + const symlinkTarget = path.join(symlinkFixture.root, "output-target"); + mkdirSync(symlinkTarget); + symlinkSync(symlinkTarget, symlinkFixture.outputDir); + expect(() => verifyFixture(symlinkFixture)).toThrow("must not already exist"); + }); + + it("binds the exact ClawHub token-release manual override reason", () => { + const fixture = createFixture({ + publicationOverrides: { + manualOverrideReason: MANUAL_OVERRIDE_REASON, + requiresManualOverride: true, + route: "clawhub-token-release", + }, + }); + const verified = verifyFixture(fixture); + + expect(verified.manifest.publication).toMatchObject({ + manualOverrideReason: MANUAL_OVERRIDE_REASON, + requiresManualOverride: true, + route: "clawhub-token-release", + }); + expect(() => + verifyFixture(fixture, { + manualOverrideReason: + "OpenClaw Release Publish run 12345 approved token release for v2026.7.1-beta.4", + }), + ).toThrow(/does not canonically bind/u); + }); + + it("requires a valid reason exactly when a ClawHub manual override is approved", () => { + const fixture = createFixture(); + const invalidControls: Record[] = [ + { + requiresManualOverride: true, + route: "clawhub-token-release", + }, + { + manualOverrideReason: MANUAL_OVERRIDE_REASON, + route: "clawhub-token-release", + }, + { + manualOverrideReason: `${MANUAL_OVERRIDE_REASON}\nunsafe`, + requiresManualOverride: true, + route: "clawhub-token-release", + }, + { + bootstrapMode: "publish", + manualOverrideReason: MANUAL_OVERRIDE_REASON, + requiresManualOverride: false, + route: "clawhub-token-bootstrap", + }, + ]; + + for (const controls of invalidControls) { + expect(() => + verifyPluginPublicationArtifact({ + ...publicationParams(fixture.artifactDir, controls), + artifactDigest: `sha256:${sha256(fixture.zip)}`, + artifactId: ARTIFACT_ID, + artifactMetadataPath: fixture.metadataPath, + artifactZipPath: fixture.zipPath, + outputDir: fixture.outputDir, + runId: RUN_ID, + workflowSha: WORKFLOW_SHA, + }), + ).toThrow(); + } + }); + + it("rejects every mutable artifact identity dimension", () => { + const fixture = createFixture(); + const mismatches: Array<[string, Record]> = [ + ["artifact id", { artifactId: ARTIFACT_ID + 1 }], + ["artifact name", { artifactName: "plugin-npm-package-other-2026.7.1-beta.3" }], + ["artifact digest", { artifactDigest: `sha256:${"f".repeat(64)}` }], + ["artifact size", { artifactSizeBytes: fixture.zip.length + 1 }], + ["workflow run id", { producerRunId: RUN_ID + 1 }], + ["workflow run attempt", { producerRunAttempt: RUN_ATTEMPT + 1 }], + ["repository", { repository: "openclaw/not-openclaw" }], + ["workflow SHA", { workflowSha: "3".repeat(40) }], + ["workflow path", { workflowPath: ".github/workflows/other.yml" }], + ["workflow event", { workflowEvent: "workflow_call" }], + ["workflow head branch", { workflowHeadBranch: "release/2026.7.1" }], + ["target SHA", { targetSha: "4".repeat(40) }], + ["package name", { packageName: "@openclaw/not-meta" }], + ["package dir", { packageDir: "extensions/not-meta" }], + ["package version", { version: "2026.7.1-beta.2" }], + ["publication route", { route: "npm-oidc" }], + ["publish tag", { publishTag: "alpha" }], + ]; + for (const [label, overrides] of mismatches) { + expect(() => verifyFixture(fixture, overrides), label).toThrow(); + } + }); + + it("binds authoritative workflow status, conclusion, and head repository", () => { + const mutations = [ + (run: Record) => { + run.status = "in_progress"; + }, + (run: Record) => { + run.conclusion = "cancelled"; + }, + (run: Record) => { + run.head_repository = { full_name: "openclaw/not-openclaw" }; + }, + ]; + for (const mutate of mutations) { + const fixture = createFixture(); + const run = JSON.parse(readFileSync(fixture.workflowRunPath, "utf8")); + mutate(run); + writeFileSync(fixture.workflowRunPath, `${JSON.stringify(run)}\n`); + expect(() => verifyFixture(fixture)).toThrow( + /workflow run does not match the immutable publication tuple/u, + ); + } + }); + + it("accepts only the exact successful producer job for same-run publication", () => { + const fixture = createFixture(); + const workflowRun = JSON.parse(readFileSync(fixture.workflowRunPath, "utf8")); + workflowRun.status = "in_progress"; + workflowRun.conclusion = null; + writeFileSync(fixture.workflowRunPath, `${JSON.stringify(workflowRun)}\n`); + + expect( + verifyFixture(fixture, { + consumerRunAttempt: RUN_ATTEMPT, + producerJobName: PRODUCER_JOB_NAME, + runStatePolicy: "same-run-producer-success", + workflowJobsMetadataPath: fixture.workflowJobsPath, + }), + ).toMatchObject({ producerRunAttempt: RUN_ATTEMPT, producerRunId: RUN_ID }); + + const jobs = JSON.parse(readFileSync(fixture.workflowJobsPath, "utf8")); + jobs.jobs[0].conclusion = "failure"; + writeFileSync(fixture.workflowJobsPath, `${JSON.stringify(jobs)}\n`); + const failedFixture = createFixture(); + writeFileSync(failedFixture.workflowRunPath, `${JSON.stringify(workflowRun)}\n`); + writeFileSync(failedFixture.workflowJobsPath, `${JSON.stringify(jobs)}\n`); + expect(() => + verifyFixture(failedFixture, { + consumerRunAttempt: RUN_ATTEMPT, + producerJobName: PRODUCER_JOB_NAME, + runStatePolicy: "same-run-producer-success", + workflowJobsMetadataPath: failedFixture.workflowJobsPath, + }), + ).toThrow("producer job did not complete successfully"); + }); + + it("retries bounded metadata, attempt, and archive failures against the exact run attempt", async () => { + const zip = createZip([{ bytes: Buffer.from("proof"), name: "proof.txt" }]); + const artifactMetadata = { + id: ARTIFACT_ID, + name: ARTIFACT_NAME, + expired: false, + digest: `sha256:${sha256(zip)}`, + size_in_bytes: zip.length, + workflow_run: { + id: RUN_ID, + head_sha: WORKFLOW_SHA, + }, + }; + const workflowRun = { + id: RUN_ID, + run_attempt: RUN_ATTEMPT, + head_sha: WORKFLOW_SHA, + head_branch: "main", + event: "workflow_dispatch", + path: WORKFLOW_PATH, + status: "completed", + conclusion: "success", + repository: { full_name: REPOSITORY }, + head_repository: { full_name: REPOSITORY }, + }; + const callCounts = { archive: 0, artifact: 0, run: 0 }; + const urls: string[] = []; + const fetchImpl = (async (input: string | URL | Request) => { + const url = String(input); + urls.push(url); + if (url.endsWith(`/actions/artifacts/${ARTIFACT_ID}`)) { + callCounts.artifact += 1; + return callCounts.artifact === 1 + ? new Response("retry", { status: 503 }) + : Response.json(artifactMetadata); + } + if (url.endsWith(`/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}`)) { + callCounts.run += 1; + return callCounts.run === 1 + ? new Response("{", { status: 200 }) + : Response.json(workflowRun); + } + if (url.endsWith(`/actions/artifacts/${ARTIFACT_ID}/zip`)) { + callCounts.archive += 1; + return callCounts.archive === 1 + ? new Response("retry", { status: 502 }) + : new Response(zip, { + status: 200, + headers: { "content-length": String(zip.length) }, + }); + } + return new Response("unexpected", { status: 404 }); + }) as typeof fetch; + + const result = await downloadActionsArtifactArchive({ + expected: { + artifactDigest: `sha256:${sha256(zip)}`, + artifactId: ARTIFACT_ID, + artifactName: ARTIFACT_NAME, + artifactSizeBytes: zip.length, + repository: REPOSITORY, + runStatePolicy: "completed-success", + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + workflowEvent: "workflow_dispatch", + workflowHeadBranch: "main", + workflowPath: WORKFLOW_PATH, + workflowSha: WORKFLOW_SHA, + }, + fetchImpl, + maxArchiveBytes: 1024 * 1024, + retryAttempts: 3, + retryDelayMs: 1, + token: "test-token", + }); + + expect(result.archiveBytes).toEqual(zip); + expect(callCounts).toEqual({ archive: 2, artifact: 2, run: 2 }); + expect(urls).toContain( + `https://api.github.com/repos/${REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}`, + ); + }); + + it("reuses only an exact successful producer job from the current or a prior attempt", async () => { + const zip = createZip([{ bytes: Buffer.from("proof"), name: "proof.txt" }]); + const artifactMetadata = { + id: ARTIFACT_ID, + name: ARTIFACT_NAME, + expired: false, + digest: `sha256:${sha256(zip)}`, + size_in_bytes: zip.length, + workflow_run: { + id: RUN_ID, + head_sha: WORKFLOW_SHA, + }, + }; + const producerJobName = "Pack immutable ClawHub bootstrap artifacts"; + + async function downloadForAttempts( + producerAttempt: number, + consumerAttempt: number, + producerConclusion = "success", + ) { + const workflowRun = { + id: RUN_ID, + run_attempt: producerAttempt, + head_sha: WORKFLOW_SHA, + head_branch: "main", + event: "workflow_dispatch", + path: WORKFLOW_PATH, + status: producerAttempt === consumerAttempt ? "in_progress" : "completed", + conclusion: producerAttempt === consumerAttempt ? null : "failure", + repository: { full_name: REPOSITORY }, + head_repository: { full_name: REPOSITORY }, + }; + const workflowJobs = { + total_count: 1, + jobs: [ + { + name: producerJobName, + run_id: RUN_ID, + run_attempt: producerAttempt, + head_sha: WORKFLOW_SHA, + status: "completed", + conclusion: producerConclusion, + }, + ], + }; + const fetchImpl = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith(`/actions/artifacts/${ARTIFACT_ID}`)) { + return Response.json(artifactMetadata); + } + if (url.endsWith(`/actions/runs/${RUN_ID}/attempts/${producerAttempt}`)) { + return Response.json(workflowRun); + } + if (url.endsWith(`/actions/runs/${RUN_ID}/attempts/${producerAttempt}/jobs?per_page=100`)) { + return Response.json(workflowJobs); + } + if (url.endsWith(`/actions/artifacts/${ARTIFACT_ID}/zip`)) { + return new Response(zip, { + status: 200, + headers: { "content-length": String(zip.length) }, + }); + } + return new Response("unexpected", { status: 404 }); + }) as typeof fetch; + + return downloadActionsArtifactArchive({ + expected: { + artifactDigest: `sha256:${sha256(zip)}`, + artifactId: ARTIFACT_ID, + artifactName: ARTIFACT_NAME, + artifactSizeBytes: zip.length, + consumerRunAttempt: consumerAttempt, + producerJobName, + repository: REPOSITORY, + runStatePolicy: "same-run-producer-success", + runAttempt: producerAttempt, + runId: RUN_ID, + workflowEvent: "workflow_dispatch", + workflowHeadBranch: "main", + workflowPath: WORKFLOW_PATH, + workflowSha: WORKFLOW_SHA, + }, + fetchImpl, + maxArchiveBytes: 1024 * 1024, + retryAttempts: 1, + token: "test-token", + }); + } + + await expect(downloadForAttempts(2, 2)).resolves.toMatchObject({ + workflowJobs: { total_count: 1 }, + }); + await expect(downloadForAttempts(1, 2)).resolves.toMatchObject({ + workflowRun: { conclusion: "failure", status: "completed" }, + }); + await expect(downloadForAttempts(1, 2, "failure")).rejects.toThrow( + "Actions artifact producer job did not complete successfully.", + ); + await expect(downloadForAttempts(3, 2)).rejects.toThrow( + "Producer workflow run attempt must not be newer than the consumer attempt.", + ); + }); + + it("rejects ZIP traversal, additional files, and byte tampering", () => { + const fixture = createFixture(); + const manifest = readFileSync(fixture.created.manifestPath); + const malformedArtifacts: Array<{ + expected: RegExp; + files: Array<{ bytes: Buffer; name: string }>; + }> = [ + { + expected: /Unsafe ZIP entry path/u, + files: [ + { bytes: fixture.tarball, name: TARBALL_NAME }, + { bytes: manifest, name: "../plugin-publication-manifest.json" }, + ], + }, + { + expected: /must contain between 2 and 2 exact files/u, + files: [ + { bytes: fixture.tarball, name: TARBALL_NAME }, + { bytes: manifest, name: "plugin-publication-manifest.json" }, + { bytes: Buffer.from("unexpected"), name: "extra.txt" }, + ], + }, + ]; + for (const { expected, files } of malformedArtifacts) { + replaceArtifactZip(fixture, files); + expect(() => verifyFixture(fixture)).toThrow(expected); + } + + const tamperFixture = createFixture(); + const tampered = Buffer.from(tamperFixture.zip); + tampered[35] ^= 0xff; + writeFileSync(tamperFixture.zipPath, tampered); + expect(() => verifyFixture(tamperFixture)).toThrow(/digest/u); + }); + + it("accepts canonical signed data descriptors and rejects noncanonical ZIP structure", () => { + const canonical = createZip([ + { + bytes: Buffer.from("descriptor"), + compression: 8, + descriptor: true, + name: "descriptor.txt", + }, + ]); + expect(inspectTestZip(canonical).get("descriptor.txt")?.toString()).toBe("descriptor"); + + expect(() => inspectTestZip(Buffer.concat([canonical, Buffer.from("trailing")]))).toThrow( + /exact terminal end-of-central-directory/u, + ); + expect(() => + inspectTestZip( + createZip([ + { + bytes: Buffer.from("gap"), + gapAfter: Buffer.from([0]), + name: "gap.txt", + }, + ]), + ), + ).toThrow(/gap or overlap/u); + expect(() => + inspectTestZip( + createZip([ + { + bytes: Buffer.from("crc"), + localCrc: 0, + name: "crc.txt", + }, + ]), + ), + ).toThrow(/local sizes or CRC/u); + expect(() => + inspectTestZip( + createZip([ + { + bytes: Buffer.from("descriptor"), + descriptor: true, + descriptorCrc: 0, + name: "descriptor.txt", + }, + ]), + ), + ).toThrow(/data descriptor/u); + }); + + it("rejects unsupported flags, invalid names, aliases, and trailing deflate bytes", () => { + for (const flags of [0x0040, 0x2000]) { + expect(() => + inspectTestZip(createZip([{ bytes: Buffer.from("x"), flags, name: "flags.txt" }])), + ).toThrow(/Unsupported Actions artifact ZIP flags/u); + } + + expect(() => + inspectTestZip(createZip([{ bytes: Buffer.from("x"), name: "m\u00e9ta.txt" }])), + ).toThrow(/must set the UTF-8 language flag/u); + expect( + inspectTestZip( + createZip([{ bytes: Buffer.from("x"), flags: 0x0800, name: "m\u00e9ta.txt" }]), + ).has("m\u00e9ta.txt"), + ).toBe(true); + expect(() => + inspectTestZip( + createZip([ + { + bytes: Buffer.from("x"), + flags: 0x0800, + name: "invalid.txt", + nameBytes: Buffer.from([0xff]), + }, + ]), + ), + ).toThrow(/not valid UTF-8/u); + expect(() => + inspectTestZip( + createZip([ + { + bytes: Buffer.from("x"), + localNameBytes: Buffer.from("other.txt"), + name: "central.txt", + }, + ]), + ), + ).toThrow(/local and central names differ/u); + expect(() => + inspectTestZip( + createZip([ + { bytes: Buffer.from("a"), name: "Case.txt" }, + { bytes: Buffer.from("b"), name: "case.txt" }, + ]), + ), + ).toThrow(/duplicate, or aliased/u); + + const content = Buffer.from("deflate"); + expect(() => + inspectTestZip( + createZip([ + { + bytes: content, + compressedBytes: Buffer.concat([deflateRawSync(content), Buffer.from([0, 1])]), + compression: 8, + name: "deflate.txt", + }, + ]), + ), + ).toThrow(/entry expansion exceeds/u); + expect(() => + inspectTestZip(createZip([{ bytes: Buffer.from("compressed"), name: "cap.txt" }]), { + maxCompressedEntryBytes: () => 1, + }), + ).toThrow(/entry is too large/u); + }); + + it("caps publication JSON and tarball members before ZIP expansion", () => { + const fixture = createFixture(); + const manifest = readFileSync(fixture.created.manifestPath); + const oversizedManifest = Buffer.alloc(16 * 1024 * 1024 + 1, 0x20); + + replaceArtifactZip(fixture, [ + { bytes: fixture.tarball, name: TARBALL_NAME }, + { + bytes: oversizedManifest, + compression: 8, + name: "plugin-publication-manifest.json", + }, + ]); + expect(() => verifyFixture(fixture)).toThrow( + /entry is too large: plugin-publication-manifest\.json/u, + ); + + replaceArtifactZip(fixture, [ + { bytes: fixture.tarball, name: TARBALL_NAME }, + { + bytes: oversizedManifest, + compression: 8, + declaredExpandedSize: 1, + name: "plugin-publication-manifest.json", + }, + ]); + expect(() => verifyFixture(fixture)).toThrow( + /entry expansion exceeds its allowed range: plugin-publication-manifest\.json/u, + ); + + replaceArtifactZip(fixture, [ + { + bytes: fixture.tarball, + compression: 8, + declaredExpandedSize: 256 * 1024 * 1024 + 1, + name: TARBALL_NAME, + }, + { bytes: manifest, name: "plugin-publication-manifest.json" }, + ]); + expect(() => verifyFixture(fixture)).toThrow( + new RegExp(`entry is too large: ${TARBALL_NAME.replaceAll(".", "\\.")}`, "u"), + ); + }); + + it("caps the number of tar headers before retaining their inventory", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + ...Array.from({ length: 10_000 }, (_, index) => ({ + path: `package/file-${index.toString().padStart(5, "0")}`, + })), + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /exceeds the 10000 entry limit/u, + ); + }); + + it("rejects PAX metadata before retaining path inventory", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + const longPathPrefix = `package/${"a".repeat(900_000)}`; + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + ...Array.from({ length: 5 }, (_, index) => [ + { + content: paxRecord("path", `${longPathPrefix}${index}`), + path: `PaxHeader-${index}`, + type: "x" as const, + }, + { + path: `placeholder-${index}`, + }, + ]).flat(), + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + }); + + it("rejects concatenated gzip members before trusting combined tar inventory", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + const firstMember = gzipSync( + Buffer.concat([ + tarEntry({ path: "package/", type: "5" }), + tarEntry({ + content: metaPackageJson(markerPath), + path: "package/package.json", + }), + tarEntry({ + content: '{"id":"meta"}\n', + path: "package/openclaw.plugin.json", + }), + ]), + ); + const secondMember = gzipSync( + Buffer.concat([ + tarEntry({ + content: "hidden from the pinned ClawHub reader\n", + path: "package/second-member.txt", + }), + Buffer.alloc(1024), + ]), + ); + writeFileSync(path.join(artifactDir, TARBALL_NAME), Buffer.concat([firstMember, secondMember])); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /must contain exactly one gzip member/u, + ); + }); + + it("rejects a hidden duplicate package.json after a single zero tar block", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + const tarball = gzipSync( + Buffer.concat([ + tarEntry({ path: "package/", type: "5" }), + tarEntry({ + content: metaPackageJson(markerPath), + path: "package/package.json", + }), + Buffer.alloc(512), + tarEntry({ + content: metaPackageJson(markerPath, { + scripts: { + postinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'hidden')"`, + }, + }), + path: "package/package.json", + }), + Buffer.alloc(1024), + ]), + ); + writeFileSync(path.join(artifactDir, TARBALL_NAME), tarball); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /must end with two zero blocks and contain no trailing entries/u, + ); + expect(existsSync(markerPath)).toBe(false); + }); + + it("rejects directory tar entries with nonzero declared size", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { content: "x", path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /Directory tar entry "package\/" must have size zero/u, + ); + }); + + it("rejects regular-file paths that the consumer coerces into directories", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + const tarballPath = path.join(artifactDir, TARBALL_NAME); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + tarballPath, + createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath), + path: "package/package.json/", + }, + ]), + ); + + const consumerEntries: Array<{ path: string; size: number; type: string }> = []; + tar.t({ + file: tarballPath, + onReadEntry: (entry) => { + consumerEntries.push({ + path: entry.path, + size: entry.size, + type: entry.type, + }); + }, + onwarn: () => undefined, + sync: true, + }); + expect(consumerEntries.at(-1)).toMatchObject({ + path: "package/package.json/", + size: 0, + type: "Directory", + }); + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /Non-directory tar entry must not end with a slash/u, + ); + }); + + it.each([ + { path: " package.json", prefix: "package", field: "name" }, + { path: "package.json", prefix: " package", field: "prefix" }, + ])("rejects whitespace-bearing USTAR $field fields before manifest selection", (entry) => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath, { + scripts: { + postinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'smuggled')"`, + }, + }), + path: entry.path, + prefix: entry.prefix, + }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + new RegExp( + `tar entry ${entry.field} changes under the pinned ClawHub path normalization`, + "u", + ), + ); + expect(existsSync(markerPath)).toBe(false); + }); + + it("rejects V7 headers whose prefix bytes disagree with node-tar path semantics", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + const tarballPath = path.join(artifactDir, TARBALL_NAME); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + tarballPath, + createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath), + format: "v7", + path: "package.json", + prefix: "package", + }, + ]), + ); + + const consumerPaths: string[] = []; + tar.t({ + file: tarballPath, + onReadEntry: (entry) => consumerPaths.push(entry.path), + onwarn: () => undefined, + sync: true, + }); + expect(consumerPaths).toContain("package.json"); + expect(consumerPaths).not.toContain("package/package.json"); + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /canonical POSIX USTAR headers/u, + ); + }); + + it.each([ + { + label: "regular-file link path", + mutate(header: Buffer) { + writeTarString(header, 157, 100, "package/alias"); + }, + message: /Plugin tar entries must not carry link targets/u, + }, + { + label: "hidden link-path padding", + mutate(header: Buffer) { + header.fill(0, 157, 257); + Buffer.from("\0\nhidden-link", "utf8").copy(header, 157); + }, + message: /tar entry link path has non-zero bytes after its NUL terminator/u, + }, + { + label: "unterminated checksum", + mutate(header: Buffer) { + const checksum = Number.parseInt(header.subarray(148, 156).toString("ascii").trim(), 8); + writeTarString(header, 148, 8, checksum.toString(8).padStart(8, "0")); + }, + preserveChecksumBytes: true, + message: /tar checksum is not canonically encoded/u, + }, + ...[ + ["mode", 100, 8, "tar entry mode"], + ["uid", 108, 8, "tar entry uid"], + ["gid", 116, 8, "tar entry gid"], + ["mtime", 136, 12, "tar entry mtime"], + ["device major", 329, 8, "tar entry device major"], + ["device minor", 337, 8, "tar entry device minor"], + ["access time", 476, 12, "tar entry access time"], + ["change time", 488, 12, "tar entry change time"], + ].map(([label, offset, length, field]) => ({ + label: `invalid base-256 ${label}`, + mutate(header: Buffer) { + header.fill(0, offset as number, (offset as number) + (length as number)); + header[offset as number] = 0x81; + }, + message: new RegExp(`${field} must not use base-256 encoding`, "u"), + })), + ])("rejects $label headers that make npm consume a nested manifest", (testCase) => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + const tarballPath = path.join(artifactDir, TARBALL_NAME); + mkdirSync(artifactDir, { recursive: true }); + + const nestedManifest = tarEntry({ + content: metaPackageJson(markerPath, { + scripts: { + postinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'smuggled')"`, + }, + tag: "latest", + }), + path: "package/package.json", + }); + const decoy = mutateTarEntryHeader( + tarEntry({ + content: nestedManifest, + path: "package/decoy", + }), + testCase.mutate, + { preserveChecksumBytes: testCase.preserveChecksumBytes }, + ); + writeFileSync( + tarballPath, + createTarballFromParts([ + tarEntry({ path: "package/", type: "5" }), + tarEntry({ + content: metaPackageJson(markerPath), + path: "package/package.json", + }), + decoy, + ]), + ); + + const consumerPaths: string[] = []; + tar.t({ + file: tarballPath, + onReadEntry: (entry) => consumerPaths.push(entry.path), + onwarn: () => undefined, + sync: true, + }); + expect(consumerPaths.filter((entryPath) => entryPath === "package/package.json")).toHaveLength( + 2, + ); + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + testCase.message, + ); + expect(existsSync(markerPath)).toBe(false); + }); + + it("rejects PAX metadata containing control characters", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + const tarballPath = path.join(artifactDir, TARBALL_NAME); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + tarballPath, + createTarball([ + { path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + { + content: paxRecord("comment", "benign\npath=package/package.json"), + path: "PaxHeader", + type: "x", + }, + { + content: metaPackageJson(markerPath, { + scripts: { + postinstall: `node -e "require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'injected')"`, + }, + }), + path: "package/ignored.json", + }, + ]), + ); + + const consumerPaths: string[] = []; + tar.t({ + file: tarballPath, + onReadEntry: (entry) => consumerPaths.push(entry.path), + onwarn: () => undefined, + sync: true, + }); + expect(consumerPaths.filter((entryPath) => entryPath === "package/package.json")).toHaveLength( + 1, + ); + expect(consumerPaths).toContain("package/ignored.json"); + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + expect(existsSync(markerPath)).toBe(false); + }); + + it("rejects local PAX and GNU metadata entries", () => { + const cases: TarEntry[][] = [ + [ + { content: "package/ignored.json\0", path: "././@LongLink", type: "L" }, + { + content: paxRecord("path", "package/package.json"), + path: "PaxHeader", + type: "x", + }, + ], + [ + { + content: paxRecord("path", "package/ignored.json"), + path: "PaxHeader", + type: "x", + }, + { content: "package/package.json\0", path: "././@LongLink", type: "L" }, + ], + [ + { + content: paxRecord("path", "package/package.json"), + path: "PaxHeader", + type: "x", + }, + { content: paxRecord("mtime", "0"), path: "PaxHeader2", type: "x" }, + ], + ]; + + for (const [index, controls] of cases.entries()) { + const root = tempDir(); + const artifactDir = path.join(root, `artifact-${index}`); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + ...controls, + { + content: metaPackageJson(markerPath), + path: `placeholder-${index}.json`, + }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + } + }); + + it("rejects local PAX size overrides", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + { + content: paxRecord("size", "0"), + path: "PaxHeader", + type: "x", + }, + { content: "nonempty", path: "package/index.js" }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + }); + + it("rejects canonical PAX metadata for every plugin publication route", () => { + const root = tempDir(); + const markerPath = path.join(root, "marker"); + const longPath = `package/${"nested/".repeat(18)}index.js`; + const content = Buffer.from("export {};\n", "utf8"); + const tarball = createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath), + path: "package/package.json", + }, + { + content: Buffer.concat([ + paxRecord("path", longPath), + paxRecord("size", String(content.length)), + ]), + path: "PaxHeader", + type: "x", + }, + { content, path: "package/placeholder.js" }, + ]); + const npmArtifactDir = path.join(root, "npm-artifact"); + mkdirSync(npmArtifactDir, { recursive: true }); + writeFileSync(path.join(npmArtifactDir, TARBALL_NAME), tarball); + expect(() => createPluginPublicationArtifact(publicationParams(npmArtifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + + for (const controls of [ + { route: "clawhub-token-release" }, + { bootstrapMode: "publish", route: "clawhub-token-bootstrap" }, + { route: "clawhub-readback" }, + ]) { + const { route } = controls; + const artifactDir = path.join(root, route); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync(path.join(artifactDir, TARBALL_NAME), tarball); + expect(() => + createPluginPublicationArtifact(publicationParams(artifactDir, controls)), + ).toThrow(/PAX and GNU tar metadata are not supported/u); + } + }); + + it("rejects oversized PAX metadata before parsing it", () => { + const root = tempDir(); + const artifactDir = path.join(root, "artifact"); + const markerPath = path.join(root, "marker"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { + content: paxRecord("comment", "x".repeat(1024 * 1024)), + path: "PaxHeader", + type: "x", + }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /PAX and GNU tar metadata are not supported/u, + ); + }); + + it("rejects beta npm artifacts bound to latest or extended-stable", () => { + const fixture = createFixture(); + expect(() => + createPluginPublicationArtifact( + publicationParams(fixture.artifactDir, { publishTag: "latest" }), + ), + ).toThrow(/does not match release channel beta/u); + expect(() => + createPluginPublicationArtifact( + publicationParams(fixture.artifactDir, { publishTag: "extended-stable" }), + ), + ).toThrow(/Extended-stable npm publication requires/u); + }); + + it("rejects a package manifest tag that can override the approved npm dist-tag", () => { + const root = tempDir(); + const markerPath = path.join(root, "marker"); + const artifactDir = path.join(root, "artifact"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath, { tag: "latest" }), + path: "package/package.json", + }, + { content: '{"id":"meta"}\n', path: "package/openclaw.plugin.json" }, + ]), + ); + + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow( + /must not override the approved publication tag/u, + ); + }); + + it("rejects publishConfig overrides in packed package metadata", () => { + expect(() => + createFixture({ + packageJson: metaPackageJson(path.join(tempDir(), "marker"), { + publishConfig: { tag: "beta" }, + }), + }), + ).toThrow(/must not override publication through publishConfig/u); + }); + + it("rejects traversal, links, and special entries inside the plugin tarball", () => { + const root = tempDir(); + const markerPath = path.join(root, "marker"); + for (const badEntry of [ + { content: "bad", path: "package/../escape" }, + { linkPath: "package/package.json", path: "package/alias", type: "2" as const }, + { path: "package/device", type: "3" as const }, + ]) { + const artifactDir = path.join(root, Math.random().toString(16).slice(2)); + mkdirSync(artifactDir, { recursive: true }); + const entries: TarEntry[] = [ + { path: "package/", type: "5" }, + { content: metaPackageJson(markerPath), path: "package/package.json" }, + badEntry, + ]; + writeFileSync(path.join(artifactDir, TARBALL_NAME), createTarball(entries)); + expect(() => createPluginPublicationArtifact(publicationParams(artifactDir))).toThrow(); + } + expect(existsSync(markerPath)).toBe(false); + }); + + it("requires the exact Meta package name, dir, and both publication flags", () => { + const root = tempDir(); + const markerPath = path.join(root, "marker"); + const cases: Array<{ + manifestOverrides?: Record; + params?: Record; + }> = [ + { + manifestOverrides: { + openclaw: { release: { publishToClawHub: false, publishToNpm: true } }, + }, + }, + { + manifestOverrides: { + openclaw: { release: { publishToClawHub: true, publishToNpm: false } }, + }, + }, + { + manifestOverrides: { name: "@openclaw/not-meta" }, + params: { packageName: "@openclaw/not-meta" }, + }, + { + params: { packageDir: "extensions/not-meta" }, + }, + ]; + for (const [index, testCase] of cases.entries()) { + const artifactDir = path.join(root, Math.random().toString(16).slice(2)); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, TARBALL_NAME), + createTarball([ + { path: "package/", type: "5" }, + { + content: metaPackageJson(markerPath, testCase.manifestOverrides), + path: "package/package.json", + }, + { content: '{"id":"meta"}\n', path: "package/openclaw.plugin.json" }, + ]), + ); + expect( + () => createPluginPublicationArtifact(publicationParams(artifactDir, testCase.params)), + `Meta identity case ${index}`, + ).toThrow(/Meta publication requires extensions\/meta with npm and ClawHub enabled/u); + } + }); +}); diff --git a/test/scripts/plugins-assertions.test.ts b/test/scripts/plugins-assertions.test.ts index fa324069346d..23e94b4f5e08 100644 --- a/test/scripts/plugins-assertions.test.ts +++ b/test/scripts/plugins-assertions.test.ts @@ -9,11 +9,13 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { createServer } from "node:http"; +import { createServer, request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { createBoundedChildOutput } from "../helpers/bounded-child-output.js"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; const ASSERTIONS_SCRIPT = "scripts/e2e/lib/plugins/assertions.mjs"; @@ -124,6 +126,49 @@ function runPluginsSweepShell(script: string, env: NodeJS.ProcessEnv = {}) { }); } +async function waitForPortFile(portFile: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (existsSync(portFile)) { + const port = Number(readFileSync(portFile, "utf8")); + if (Number.isInteger(port) && port > 0) { + return port; + } + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for ${portFile}`); +} + +function requestFixtureRegistry( + port: number, + requestPath: string, + headers: Record = {}, +): Promise<{ body: string; contentLength: string | undefined; statusCode: number | undefined }> { + return new Promise((resolve, reject) => { + const request = httpRequest( + { headers, host: "127.0.0.1", method: "GET", path: requestPath, port }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => { + resolve({ + body, + contentLength: response.headers["content-length"], + statusCode: response.statusCode, + }); + }); + }, + ); + request.setTimeout(2_000, () => { + request.destroy(new Error(`timed out requesting ${requestPath}`)); + }); + request.on("error", reject); + request.end(); + }); +} describe("plugins Docker assertions", () => { it("rejects loose ClawHub preflight limits instead of parsing prefixes", () => { const timeoutResult = spawnSync(process.execPath, [ASSERTIONS_SCRIPT, "clawhub-preflight"], { @@ -370,6 +415,357 @@ test -d "$OPENCLAW_PLUGINS_TMP_DIR" } }); + it("serves tarball dependencies using the request-visible registry origin", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-package-"); + const packageDir = path.join(root, "package"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "openclaw.tgz"); + mkdirSync(packageDir); + writeJson(path.join(packageDir, "package.json"), { + name: "openclaw", + version: "2026.7.1-beta.3", + dependencies: { + "@openclaw/ai": "2026.7.1-beta.3", + zod: "4.3.6", + }, + optionalDependencies: { + "sqlite-vec": "0.1.7-alpha.2", + }, + }); + const packed = spawnSync("tar", ["-czf", tarballPath, "-C", root, "package"], { + encoding: "utf8", + }); + expect(packed.status, packed.stderr).toBe(0); + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "openclaw", + "2026.7.1-beta.3", + tarballPath, + ], + { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/openclaw", { + host: `192.0.2.2:${port}`, + }); + const metadata = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(metadata.versions["2026.7.1-beta.3"].dependencies).toEqual({ + "@openclaw/ai": "2026.7.1-beta.3", + zod: "4.3.6", + }); + expect(metadata.versions["2026.7.1-beta.3"].optionalDependencies).toEqual({ + "sqlite-vec": "0.1.7-alpha.2", + }); + expect(metadata.versions["2026.7.1-beta.3"].dist.tarball).toBe( + `http://192.0.2.2:${port}/openclaw/-/openclaw.tgz`, + ); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + cleanupTempDirs(tempDirs); + } + }); + + it("recomputes proxied content length after fetch decodes the response", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + const upstreamBody = JSON.stringify({ payload: "x".repeat(1_000) }); + const compressedBody = gzipSync(upstreamBody); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { + "content-encoding": "gzip", + "content-length": String(compressedBody.length), + "content-type": "application/json", + }); + response.end(compressedBody); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/upstream-package"); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(upstreamBody); + expect(response.contentLength).toBe(String(Buffer.byteLength(upstreamBody))); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + cleanupTempDirs(tempDirs); + } + }); + + it("rejects oversized proxied registry responses", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-limit-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { + "content-length": "64", + "content-type": "application/octet-stream", + }); + response.end("x".repeat(64)); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + OPENCLAW_NPM_REGISTRY_UPSTREAM_MAX_BYTES: "32", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/oversized"); + + expect(response.statusCode).toBe(502); + expect(response.body).toContain("upstream registry response exceeds 32 bytes"); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + cleanupTempDirs(tempDirs); + } + }); + + it("aborts stalled proxied registry responses", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-timeout-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.write("x"); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + OPENCLAW_NPM_REGISTRY_UPSTREAM_TIMEOUT_MS: "50", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/stalled"); + + expect(response.statusCode).toBe(502); + expect(response.body).toContain("upstream registry request exceeded 50ms"); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.closeAllConnections(); + upstream.close(() => resolve()); + }); + cleanupTempDirs(tempDirs); + } + }); + + it("does not let absolute-form request targets escape the configured upstream", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-origin-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + let configuredUpstreamHits = 0; + let escapeServerHits = 0; + let configuredUpstreamTarget: string | undefined; + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const configuredUpstream = createServer((request, response) => { + configuredUpstreamHits += 1; + configuredUpstreamTarget = request.url; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("configured upstream"); + }); + const escapeServer = createServer((_request, response) => { + escapeServerHits += 1; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("escaped upstream"); + }); + await Promise.all([ + new Promise((resolve) => { + configuredUpstream.listen(0, "127.0.0.1", resolve); + }), + new Promise((resolve) => { + escapeServer.listen(0, "127.0.0.1", resolve); + }), + ]); + const configuredAddress = configuredUpstream.address(); + const escapeAddress = escapeServer.address(); + if ( + !configuredAddress || + typeof configuredAddress === "string" || + !escapeAddress || + typeof escapeAddress === "string" + ) { + throw new Error("expected upstream registry addresses"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${configuredAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const escaped = await requestFixtureRegistry( + port, + `http://registry.invalid//127.0.0.1:${escapeAddress.port}/probe`, + ); + + expect(escaped.statusCode).toBe(502); + expect(escaped.body).toContain("refusing non-origin registry request URL"); + expect(configuredUpstreamHits).toBe(0); + expect(escapeServerHits).toBe(0); + + const valid = await requestFixtureRegistry(port, "/pkg?x=1"); + + expect(valid.statusCode).toBe(200); + expect(valid.body).toBe("configured upstream"); + expect(configuredUpstreamHits).toBe(1); + expect(configuredUpstreamTarget).toBe("/pkg?x=1"); + expect(escapeServerHits).toBe(0); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await Promise.all([ + new Promise((resolve) => { + configuredUpstream.close(() => resolve()); + }), + new Promise((resolve) => { + escapeServer.close(() => resolve()); + }), + ]); + cleanupTempDirs(tempDirs); + } + }); + it("cleans ClawHub fixture children when readiness times out", () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-clawhub-fixture-cleanup-")); try { diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index e7269b1fa5e8..35f29a620dbd 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -1,12 +1,80 @@ // Release Beta Verifier tests cover release beta verifier script behavior. -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + downloadClawHubBootstrapReadback, + fetchJsonWithRetry, + fetchStatusWithRetry, parseNpmViewFields, parseReleaseVerifyBetaArgs, readBoundedJsonResponse, runNpmViewWithRetry, + validateClawHubBootstrapEvidence, } from "../../scripts/lib/release-beta-verifier.ts"; +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createStoredZip(files: Array<{ name: string; bytes: Buffer }>): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let localOffset = 0; + for (const file of files) { + const name = Buffer.from(file.name, "utf8"); + const checksum = crc32(file.bytes); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(file.bytes.length, 18); + local.writeUInt32LE(file.bytes.length, 22); + local.writeUInt16LE(name.length, 26); + localParts.push(local, name, file.bytes); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(file.bytes.length, 20); + central.writeUInt32LE(file.bytes.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE((0o100600 * 0x10000) >>> 0, 38); + central.writeUInt32LE(localOffset, 42); + centralParts.push(central, name); + localOffset += local.length + name.length + file.bytes.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localOffset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + describe("parseReleaseVerifyBetaArgs", () => { it("defaults beta verification to the matching tag and repo", () => { expect(parseReleaseVerifyBetaArgs(["2026.5.10-beta.3"])).toEqual({ @@ -15,9 +83,11 @@ describe("parseReleaseVerifyBetaArgs", () => { distTag: "beta", repo: "openclaw/openclaw", registry: "https://clawhub.ai", + releaseSha: undefined, workflowRef: undefined, clawHubWorkflowRef: undefined, pluginSelection: [], + clawHubBootstrapPlugins: [], evidenceOut: undefined, skipPostpublish: false, skipGitHubRelease: false, @@ -34,6 +104,8 @@ describe("parseReleaseVerifyBetaArgs", () => { "2026.5.10-beta.3", "--workflow-ref", "release/2026.5.10", + "--release-sha", + "a".repeat(40), "--clawhub-workflow-ref", "v2026.5.10-beta.3", "--plugins", @@ -48,6 +120,8 @@ describe("parseReleaseVerifyBetaArgs", () => { "33", "--plugin-clawhub-bootstrap-run", "34", + "--clawhub-bootstrap-plugins", + "@openclaw/plugin-b", "--npm-telegram-run", "44", "--evidence-out", @@ -63,9 +137,11 @@ describe("parseReleaseVerifyBetaArgs", () => { distTag: "beta", repo: "openclaw/openclaw", registry: "https://clawhub.ai", + releaseSha: "a".repeat(40), workflowRef: "release/2026.5.10", clawHubWorkflowRef: "v2026.5.10-beta.3", pluginSelection: ["@openclaw/plugin-a", "@openclaw/plugin-b"], + clawHubBootstrapPlugins: ["@openclaw/plugin-b"], evidenceOut: ".artifacts/release-evidence.json", skipPostpublish: true, skipGitHubRelease: true, @@ -81,6 +157,385 @@ describe("parseReleaseVerifyBetaArgs", () => { }, }); }); + + it("requires exact target and package inputs for bootstrap run verification", () => { + expect(() => + parseReleaseVerifyBetaArgs(["2026.5.10-beta.3", "--plugin-clawhub-bootstrap-run", "34"]), + ).toThrow("--plugin-clawhub-bootstrap-run requires --release-sha"); + expect(() => + parseReleaseVerifyBetaArgs([ + "2026.5.10-beta.3", + "--release-sha", + "a".repeat(40), + "--plugin-clawhub-bootstrap-run", + "34", + ]), + ).toThrow("--plugin-clawhub-bootstrap-run requires --clawhub-bootstrap-plugins"); + expect(() => + parseReleaseVerifyBetaArgs([ + "2026.5.10-beta.3", + "--clawhub-bootstrap-plugins", + "@openclaw/plugin-b", + ]), + ).toThrow("--clawhub-bootstrap-plugins requires --plugin-clawhub-bootstrap-run"); + }); +}); + +describe("validateClawHubBootstrapEvidence", () => { + const clawhubToolchainIntegrity = + "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA=="; + const clawhubToolchainSha256 = "f44f670d70f13a8cde566a174cae5be682ad98456ec7a85aafd497f7d8c71816"; + const clawhubToolchainVersion = "0.23.1"; + const releaseSha = "a".repeat(40); + const workflowSha = "b".repeat(40); + const packageSha = "c".repeat(64); + const readbackSha = "d".repeat(64); + const run = { + id: 34, + name: "Plugin ClawHub New", + event: "workflow_dispatch", + head_branch: "main", + head_sha: workflowSha, + path: ".github/workflows/plugin-clawhub-new.yml@refs/heads/main", + run_attempt: 2, + status: "completed", + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/34", + created_at: "2026-07-10T00:00:00Z", + updated_at: "2026-07-10T00:02:00Z", + }; + const workflowRun = { + id: 34, + head_branch: "main", + head_sha: workflowSha, + }; + const readbackArtifact = { + id: 45, + name: "clawhub-bootstrap-readback-34-2", + digest: `sha256:${readbackSha}`, + size_in_bytes: 1, + expired: false, + workflow_run: workflowRun, + }; + const packageArtifact = { + id: 46, + name: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-1`, + digest: `sha256:${packageSha}`, + expired: false, + workflow_run: workflowRun, + }; + const evidence = { + schemaVersion: 2, + repository: "openclaw/openclaw", + targetSha: releaseSha, + workflowSha, + runId: "34", + producerRunAttempt: "1", + terminalRunAttempt: "2", + artifactName: packageArtifact.name, + artifactId: "46", + artifactDigest: packageSha, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + requestedPlugins: ["@openclaw/meta"], + verificationMode: "postpublish", + packages: [ + { + packageName: "@openclaw/meta", + version: "2026.7.1-beta.3", + expectedSha256: packageSha, + expectedSize: 123, + registrySha256: packageSha, + registrySize: 123, + npmIntegrity: "sha512-test", + npmShasum: "1".repeat(40), + artifactMetadata: { + kind: "npm-pack", + sha256: packageSha, + size: 123, + npmIntegrity: "sha512-test", + npmShasum: "1".repeat(40), + packageName: "@openclaw/meta", + version: "2026.7.1-beta.3", + }, + }, + ], + }; + + function validate( + overrides: { + run?: unknown; + readbackArtifact?: unknown; + packageArtifact?: unknown; + evidence?: unknown; + expectedPackages?: string[]; + } = {}, + ) { + return validateClawHubBootstrapEvidence({ + repo: "openclaw/openclaw", + runId: "34", + releaseSha, + expectedVersion: "2026.7.1-beta.3", + expectedPackages: overrides.expectedPackages ?? ["@openclaw/meta"], + run: overrides.run ?? run, + readbackArtifact: overrides.readbackArtifact ?? readbackArtifact, + readbackArchiveSha256: readbackSha, + packageArtifact: overrides.packageArtifact ?? packageArtifact, + evidence: overrides.evidence ?? evidence, + }); + } + + it("binds the exact main run, attempt, target, package set, and artifact tuple", () => { + expect(validate()).toMatchObject({ + id: "34", + label: "Plugin ClawHub New", + durationSeconds: 120, + bootstrapEvidence: { + targetSha: releaseSha, + workflowSha, + workflowPath: ".github/workflows/plugin-clawhub-new.yml", + producerRunAttempt: "1", + terminalRunAttempt: "2", + readbackArtifactId: "45", + readbackArtifactDigest: readbackSha, + packageArtifactId: "46", + packageArtifactDigest: packageSha, + packageCount: 1, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + }, + }); + }); + + it("rejects legacy release-ref runs and mismatched target/package evidence", () => { + expect(() => validate({ run: { ...run, head_branch: "release/2026.7.1" } })).toThrow( + "not dispatched from trusted main", + ); + expect(() => + validate({ + run: { ...run, path: ".github/workflows/not-plugin-clawhub-new.yml" }, + }), + ).toThrow("unexpected workflow path"); + expect(() => validate({ evidence: { ...evidence, targetSha: "e".repeat(40) } })).toThrow( + "target SHA mismatch", + ); + expect(() => validate({ expectedPackages: ["@openclaw/other"] })).toThrow( + "requested package set mismatch", + ); + }); + + it("rejects stale attempts, changed artifact bytes, and metadata drift", () => { + expect(() => + validate({ + readbackArtifact: { + ...readbackArtifact, + name: "clawhub-bootstrap-readback-34-1", + }, + }), + ).toThrow("does not bind the run attempt"); + expect(() => + validate({ + evidence: { ...evidence, terminalRunAttempt: "1" }, + }), + ).toThrow("readback evidence run tuple mismatch"); + expect(() => + validate({ + evidence: { ...evidence, producerRunAttempt: "3" }, + }), + ).toThrow("producer attempt is newer than its terminal attempt"); + expect(() => + validate({ + packageArtifact: { + ...packageArtifact, + name: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-2`, + }, + evidence: { + ...evidence, + artifactName: `clawhub-bootstrap-${releaseSha.slice(0, 12)}-34-2`, + }, + }), + ).toThrow("package artifact name does not bind the target and attempt"); + expect(() => + validate({ + packageArtifact: { + ...packageArtifact, + digest: `sha256:${"e".repeat(64)}`, + }, + }), + ).toThrow("package artifact digest mismatch"); + expect(() => + validate({ + evidence: { + ...evidence, + packages: [ + { + ...evidence.packages[0], + artifactMetadata: { + ...evidence.packages[0].artifactMetadata, + npmIntegrity: "sha512-different", + }, + }, + ], + }, + }), + ).toThrow("artifact metadata does not match downloaded bytes"); + expect(() => + validate({ + evidence: { + ...evidence, + clawhubToolchainSha256: "e".repeat(64), + }, + }), + ).toThrow("clawhubToolchainSha256 mismatch"); + }); +}); + +describe("downloadClawHubBootstrapReadback", () => { + const workflowSha = "b".repeat(40); + const run = { + id: 34, + name: "Plugin ClawHub New", + event: "workflow_dispatch", + head_branch: "main", + head_sha: workflowSha, + path: ".github/workflows/plugin-clawhub-new.yml@refs/heads/main", + run_attempt: 2, + status: "completed", + conclusion: "success", + }; + const workflowAttempt = { + id: 34, + run_attempt: 2, + head_sha: workflowSha, + head_branch: "main", + event: "workflow_dispatch", + path: ".github/workflows/plugin-clawhub-new.yml", + status: "completed", + conclusion: "success", + repository: { full_name: "openclaw/openclaw" }, + head_repository: { full_name: "openclaw/openclaw" }, + }; + + function createFixture( + archive: Buffer, + overrides: { + artifactMetadata?: Record; + workflowAttempt?: Record; + } = {}, + ) { + const readbackArtifact = { + id: 45, + name: "clawhub-bootstrap-readback-34-2", + digest: `sha256:${sha256(archive)}`, + size_in_bytes: archive.length, + expired: false, + workflow_run: { + id: 34, + head_branch: "main", + head_sha: workflowSha, + }, + }; + const artifactMetadata = { + ...readbackArtifact, + ...overrides.artifactMetadata, + }; + const attemptMetadata = { + ...workflowAttempt, + ...overrides.workflowAttempt, + }; + const fetchImpl = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/actions/artifacts/45")) { + return Response.json(artifactMetadata); + } + if (url.endsWith("/actions/runs/34/attempts/2")) { + return Response.json(attemptMetadata); + } + if (url.endsWith("/actions/artifacts/45/zip")) { + return new Response(archive, { + headers: { "content-length": String(archive.length) }, + }); + } + throw new Error(`unexpected request: ${url}`); + }); + return { fetchImpl, readbackArtifact }; + } + + async function download( + archive: Buffer, + overrides?: Parameters[1], + ): Promise<{ + result: Awaited>; + requests: number; + }> { + const fixture = createFixture(archive, overrides); + const result = await downloadClawHubBootstrapReadback({ + repo: "openclaw/openclaw", + runId: "34", + run, + readbackArtifact: fixture.readbackArtifact, + token: "test-token", + fetchImpl: fixture.fetchImpl, + retryAttempts: 1, + retryDelayMs: 1, + timeoutMs: 1_000, + }); + return { result, requests: fixture.fetchImpl.mock.calls.length }; + } + + it("downloads one exact readback file from the bound successful main attempt", async () => { + const evidence = { schemaVersion: 2, targetSha: "a".repeat(40) }; + const archive = createStoredZip([ + { + name: "clawhub-bootstrap-readback.json", + bytes: Buffer.from(JSON.stringify(evidence)), + }, + ]); + + await expect(download(archive)).resolves.toEqual({ + result: { + value: evidence, + archiveSha256: sha256(archive), + }, + requests: 3, + }); + }); + + it("rejects stale live artifact and workflow-attempt metadata", async () => { + const archive = createStoredZip([ + { + name: "clawhub-bootstrap-readback.json", + bytes: Buffer.from("{}"), + }, + ]); + + await expect( + download(archive, { + artifactMetadata: { digest: `sha256:${"c".repeat(64)}` }, + }), + ).rejects.toThrow("artifact metadata does not match the immutable publication tuple"); + await expect( + download(archive, { + workflowAttempt: { run_attempt: 1 }, + }), + ).rejects.toThrow("workflow run does not match the immutable publication tuple"); + }); + + it("rejects hostile or expanded readback inventories through the shared ZIP policy", async () => { + const hostileArchives = [ + createStoredZip([{ name: "../clawhub-bootstrap-readback.json", bytes: Buffer.from("{}") }]), + createStoredZip([ + { name: "clawhub-bootstrap-readback.json", bytes: Buffer.from("{}") }, + { name: "extra.json", bytes: Buffer.from("{}") }, + ]), + ]; + + for (const archive of hostileArchives) { + await expect(download(archive)).rejects.toThrow(/(?:Unsafe ZIP entry|Actions artifact ZIP)/u); + } + }); }); describe("parseNpmViewFields", () => { @@ -152,6 +607,114 @@ describe("runNpmViewWithRetry", () => { }); }); +describe("fetchStatusWithRetry", () => { + it("cancels retryable and returned GET response bodies", async () => { + vi.useFakeTimers(); + const canceled: string[] = []; + const responses = [ + new Response( + new ReadableStream({ + cancel() { + canceled.push("retry"); + }, + }), + { status: 500 }, + ), + new Response( + new ReadableStream({ + cancel() { + canceled.push("final"); + }, + }), + { status: 200 }, + ), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift(); + if (!response) { + throw new Error("unexpected fetch call"); + } + return response; + }); + vi.stubGlobal("fetch", fetchImpl); + + const status = fetchStatusWithRetry("https://clawhub.test/api/v1/package", "GET"); + await vi.advanceTimersByTimeAsync(1000); + + await expect(status).resolves.toBe(200); + expect(canceled).toEqual(["retry", "final"]); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); + +describe("fetchJsonWithRetry", () => { + it("retries invalid and failed response bodies within the attempt budget", async () => { + const delays: number[] = []; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response("{invalid")) + .mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("truncated")); + }, + }), + ), + ) + .mockResolvedValueOnce(Response.json({ ok: true })); + + await expect( + fetchJsonWithRetry("https://clawhub.test/api/v1/package", { + attempts: 3, + delay: async (delayMs) => { + delays.push(delayMs); + }, + fetchImpl, + }), + ).resolves.toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(delays).toEqual([1000, 2000]); + }); + + it("fails permanent client errors without retrying", async () => { + const delay = vi.fn(async () => {}); + const fetchImpl = vi.fn(async () => new Response("denied", { status: 403 })); + await expect( + fetchJsonWithRetry("https://clawhub.test/api/v1/package", { + attempts: 3, + delay, + fetchImpl, + }), + ).rejects.toThrow("returned HTTP 403"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(delay).not.toHaveBeenCalled(); + }); + + it("aborts stalled response bodies within the request timeout", async () => { + let canceled = false; + const fetchImpl = vi.fn( + async () => + new Response( + new ReadableStream({ + cancel() { + canceled = true; + }, + }), + ), + ); + + await expect( + fetchJsonWithRetry("https://clawhub.test/api/v1/package", { + attempts: 1, + fetchImpl, + timeoutMs: 10, + }), + ).rejects.toThrow("did not return stable JSON"); + await vi.waitFor(() => expect(canceled).toBe(true)); + }); +}); + describe("readBoundedJsonResponse", () => { it("parses JSON bodies within the release verifier limit", async () => { await expect( diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 47e8711d0675..b2018569a6fb 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -1,17 +1,428 @@ // Release Candidate Checklist tests cover release candidate checklist script behavior. +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; import { + buildReleaseCandidateState, buildPublishCommand, + candidateCumulativeShippedPullRequests, candidateParallelsArgs, candidateParallelsShellCommand, githubApi, parseArgs, parseRunIdFromDispatchOutput, + reconcileReleaseCandidateState, resolveArtifactName, + run, + selectDispatchedWorkflowRunId, + validateCandidateChangelogProvenance, + validateCandidateCheckout, + validateCandidateReleaseNotes, + validateFullManifest, + validatePreflightManifest, + validateTrustedWorkflowRunHead, validateWindowsSourceRelease, } from "../../scripts/release-candidate-checklist.mjs"; +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), init); +} + +async function withGithubApiTimeoutEnv(value: string, fn: () => Promise): Promise { + const previous = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS; + process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS = value; + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS; + } else { + process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS = previous; + } + } +} + describe("release candidate checklist", () => { + it("resumes exact workflow runs from matching release candidate state", () => { + const options = parseArgs(["--tag", "v2026.7.1-beta.4"]); + const expected = buildReleaseCandidateState(options, { + targetSha: "a".repeat(40), + toolingSha: "b".repeat(40), + }); + const resumed = reconcileReleaseCandidateState( + JSON.parse( + JSON.stringify({ + ...expected, + phase: "waiting", + fullReleaseRunId: "111", + npmPreflightRunId: "222", + }), + ), + expected, + ); + + expect(resumed).toMatchObject({ + phase: "waiting", + fullReleaseRunId: "111", + npmPreflightRunId: "222", + }); + }); + + it("rejects stale or conflicting release candidate state", () => { + const options = parseArgs(["--tag", "v2026.7.1-beta.4"]); + const expected = buildReleaseCandidateState(options, { + targetSha: "a".repeat(40), + toolingSha: "b".repeat(40), + }); + + expect(() => + reconcileReleaseCandidateState({ ...expected, targetSha: "c".repeat(40) }, expected), + ).toThrow("state mismatch for targetSha"); + expect(() => + reconcileReleaseCandidateState( + { ...expected, fullReleaseRunId: "111" }, + { ...expected, fullReleaseRunId: "333" }, + ), + ).toThrow("state mismatch for fullReleaseRunId"); + }); + + it("captures changelogs larger than the Node spawnSync default buffer", () => { + const output = run( + process.execPath, + ["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"], + { capture: true }, + ); + + expect(output).toHaveLength(2 * 1024 * 1024); + }); + + it("keeps the frozen release target separate from clean trusted workflow tooling", () => { + expect( + validateCandidateCheckout({ + targetSha: "a".repeat(40), + targetHeadSha: "a".repeat(40), + targetTrackedStatus: "", + toolingSha: "b".repeat(40), + trustedToolingSha: "b".repeat(40), + toolingTrackedStatus: "", + workflowRef: "main", + }), + ).toEqual({ + status: "passed", + targetSha: "a".repeat(40), + toolingSha: "b".repeat(40), + workflowRef: "main", + }); + expect(() => + validateCandidateCheckout({ + targetSha: "a".repeat(40), + targetHeadSha: "c".repeat(40), + targetTrackedStatus: "", + toolingSha: "b".repeat(40), + trustedToolingSha: "b".repeat(40), + toolingTrackedStatus: "", + workflowRef: "main", + }), + ).toThrow("target worktree HEAD"); + expect(() => + validateCandidateCheckout({ + targetSha: "a".repeat(40), + targetHeadSha: "a".repeat(40), + targetTrackedStatus: " M package.json", + toolingSha: "b".repeat(40), + trustedToolingSha: "b".repeat(40), + toolingTrackedStatus: "", + workflowRef: "main", + }), + ).toThrow("clean tracked target worktree"); + expect(() => + validateCandidateCheckout({ + targetSha: "a".repeat(40), + targetHeadSha: "a".repeat(40), + targetTrackedStatus: "", + toolingSha: "b".repeat(40), + trustedToolingSha: "c".repeat(40), + toolingTrackedStatus: "", + workflowRef: "main", + }), + ).toThrow("does not match trusted main"); + expect(() => + validateCandidateCheckout({ + targetSha: "a".repeat(40), + targetHeadSha: "a".repeat(40), + targetTrackedStatus: "", + toolingSha: "b".repeat(40), + trustedToolingSha: "b".repeat(40), + toolingTrackedStatus: " M scripts/release-candidate-checklist.mjs", + workflowRef: "main", + }), + ).toThrow("clean tracked tooling checkout"); + const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8"); + expect(source).toContain('const TOOLING_ROOT = fileURLToPath(new URL("../", import.meta.url))'); + expect(source).toContain("`+refs/heads/${workflowRef}:${remoteRef}`"); + expect(source).toContain('"worktree", "add", "--detach", toolingRoot, trustedToolingSha'); + expect(source).toContain( + '[join(toolingRoot, "scripts/release-candidate-checklist.mjs"), ...argv]', + ); + expect(source).toContain("cwd: targetRoot"); + expect(source).toContain('"worktree", "remove", "--force", toolingRoot'); + expect(source).toContain( + "const trustedToolingSha = fetchTrustedWorkflowSha(options.workflowRef, TOOLING_ROOT)", + ); + expect(source).toContain('targetHeadSha: gitRevParse("HEAD", targetRoot)'); + expect(source).toContain("toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT)"); + }); + + it("validates the exact tag changelog before dispatching the release matrix", () => { + const check = validateCandidateReleaseNotes({ + changelog: [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + "", + "### Complete contribution record", + "", + `- **PR #123** ${"record ".repeat(20_000)}`, + ].join("\n"), + repository: "openclaw/openclaw", + tag: "v2026.7.1-beta.3", + }); + const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8"); + const validationIndex = source.indexOf( + "const releaseNotesCheck = validateCandidateReleaseNotes", + ); + const fullMatrixDispatchIndex = source.indexOf( + "if (!options.fullReleaseRunId && !options.skipDispatch)", + ); + + expect(check).toMatchObject({ status: "passed", mode: "compact" }); + expect(validationIndex).toBeGreaterThanOrEqual(0); + expect(fullMatrixDispatchIndex).toBeGreaterThan(validationIndex); + expect(source).toContain('run("git", ["show", `${targetSha}:CHANGELOG.md`]'); + }); + + it("rejects contribution-record provenance outside the release tag history", () => { + const base = "v2026.6.11"; + const recordedTarget = "a".repeat(40); + const targetSha = "b".repeat(40); + const changelog = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + "", + "### Complete contribution record", + "", + `This audited record covers the complete ${base}..${recordedTarget} history: 1 merged PR.`, + "", + "#### Pull requests", + "", + "- **PR #123** fix: example.", + ].join("\n"); + const reachable = vi.fn((ancestor: string, target: string) => { + return ancestor === base && target === recordedTarget; + }); + + expect(() => + validateCandidateChangelogProvenance({ + changelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha, + isAncestor: reachable, + }), + ).toThrow(`contribution record target ${recordedTarget} is not reachable`); + expect(reachable).toHaveBeenCalledWith(base, recordedTarget); + expect(reachable).toHaveBeenCalledWith(recordedTarget, targetSha); + }); + + it("rejects duplicate contribution record rows even when the declared count matches", () => { + const targetSha = "b".repeat(40); + const changelog = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + "", + "### Complete contribution record", + "", + `This audited record covers the complete base..${targetSha} history: 1 merged PR.`, + "", + "#### Pull requests", + "", + "- **PR #123** fix: example.", + "- **PR #123** fix: duplicate.", + ].join("\n"); + + expect(() => + validateCandidateChangelogProvenance({ + changelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha, + isAncestor: () => true, + }), + ).toThrow("duplicate contribution record PR rows: #123"); + }); + + it("uses numbered historical record rows and skips Unreleased baseline rows", () => { + const changelog = [ + "# Changelog", + "", + "## Unreleased", + "", + "### Complete contribution record", + "", + "This audited record covers the complete base..HEAD history: 99 merged PRs.", + "", + "#### Pull requests", + "", + "- **PR #1** fix: not shipped.", + "", + "## 2026.6.11", + "", + "### Complete contribution record", + "", + "This audited record covers the complete base..HEAD history: 0 merged PRs.", + "", + "#### Pull requests", + "", + "- **PR #2** fix: shipped.", + ].join("\n"); + + expect([...candidateCumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]); + }); + + it("validates cumulative shipped baseline exclusion metadata", () => { + const base = "66e676d29b92d040716376a75aca32bad655cfac"; + const recordedTarget = "a".repeat(40); + const changelog = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + "", + "### Complete contribution record", + "", + `This audited record covers the complete ${base}..${recordedTarget} history: 1 merged PR.`, + "", + "Shipped baseline exclusions: v2026.6.11 (8 PRs: #101, #102, #103, #104, #105, #106, #107, #108).", + "", + "#### Pull requests", + "", + "- **PR #123** fix: example.", + ].join("\n"); + const shippedPullRequests = new Set([101, 102, 103, 104, 105, 106, 107, 108]); + const loadShippedBaseline = vi.fn(() => ({ + ref: "v2026.6.11", + pullRequests: shippedPullRequests, + })); + expect( + validateCandidateChangelogProvenance({ + changelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha: recordedTarget, + isAncestor: () => true, + loadShippedBaseline, + }), + ).toEqual({ + status: "passed", + base, + target: recordedTarget, + shippedBaselines: [ + { + ref: "v2026.6.11", + count: 8, + pullRequests: [101, 102, 103, 104, 105, 106, 107, 108], + }, + ], + }); + expect(loadShippedBaseline).toHaveBeenCalledWith("v2026.6.11"); + + expect(() => + validateCandidateChangelogProvenance({ + changelog: changelog.replace("8 PRs:", "8 pull requests:"), + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha: recordedTarget, + isAncestor: () => true, + loadShippedBaseline, + }), + ).toThrow("malformed shipped baseline exclusion"); + expect(() => + validateCandidateChangelogProvenance({ + changelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha: recordedTarget, + isAncestor: () => true, + loadShippedBaseline: () => ({ + ref: "v2026.6.11", + pullRequests: new Set([...shippedPullRequests].slice(1)), + }), + }), + ).toThrow("lists PRs absent from shipped baseline v2026.6.11: #101"); + expect(() => + validateCandidateChangelogProvenance({ + changelog: changelog.replace( + "- **PR #123** fix: example.", + "- **PR #101** fix: already shipped.", + ), + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha: recordedTarget, + isAncestor: () => true, + loadShippedBaseline, + }), + ).toThrow("still contains shipped PRs from v2026.6.11: #101"); + }); + + it("requires contribution records for beta candidates but permits alpha Unreleased fallback", () => { + const betaChangelog = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + ].join("\n"); + expect(() => + validateCandidateChangelogProvenance({ + changelog: betaChangelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha: "a".repeat(40), + }), + ).toThrow("missing ### Complete contribution record"); + + const alpha = validateCandidateChangelogProvenance({ + changelog: betaChangelog.replace("## 2026.7.1", "## Unreleased"), + version: "2026.7.1", + tag: "v2026.7.1-alpha.1", + targetSha: "a".repeat(40), + }); + expect(alpha).toEqual({ + status: "skipped", + reason: "alpha release uses the explicit Unreleased fallback", + shippedBaselines: [], + }); + }); + it("infers validation profiles from candidate tags", () => { expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).releaseProfile).toBe("beta"); expect(parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "v0.6.3"]).releaseProfile).toBe( @@ -49,8 +460,64 @@ describe("release candidate checklist", () => { candidateParallelsShellCommand( ".artifacts/preflight/openclaw candidate.tgz", "/opt/homebrew/bin/gtimeout", + [".artifacts/preflight/openclaw-ai candidate.tgz"], ), ).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'"); + expect( + candidateParallelsArgs(".artifacts/preflight/openclaw.tgz", [ + ".artifacts/preflight/openclaw-ai.tgz", + ]), + ).toEqual([ + "test:parallels:npm-update", + "--", + "--target-tarball", + ".artifacts/preflight/openclaw.tgz", + "--dependency-tarball", + ".artifacts/preflight/openclaw-ai.tgz", + "--json", + ]); + }); + + it("requires exact dependency tarball metadata in npm preflight manifests", () => { + const manifest = { + releaseTag: "v2026.7.1-beta.3", + releaseSha: "candidate-sha", + npmDistTag: "beta", + tarballName: "openclaw-2026.7.1-beta.3.tgz", + tarballSha256: "root-sha", + dependencyTarballs: [ + { + packageName: "@openclaw/ai", + packageVersion: "2026.7.1-beta.3", + tarballName: "openclaw-ai-2026.7.1-beta.3.tgz", + tarballSha256: "ai-sha", + }, + ], + }; + const params = { + tag: "v2026.7.1-beta.3", + targetSha: "candidate-sha", + npmDistTag: "beta", + }; + + expect(() => validatePreflightManifest(manifest, params)).not.toThrow(); + expect(() => + validatePreflightManifest({ ...manifest, dependencyTarballs: undefined }, params), + ).toThrow("missing dependency tarball metadata"); + expect(() => + validatePreflightManifest( + { + ...manifest, + dependencyTarballs: [ + { + ...manifest.dependencyTarballs[0], + tarballName: "../openclaw-ai.tgz", + }, + ], + }, + params, + ), + ).toThrow("invalid dependency tarball metadata"); }); it("requires run ids when dispatch is disabled", () => { @@ -59,6 +526,186 @@ describe("release candidate checklist", () => { ); }); + it("uses trusted main for regular release workflow tooling", () => { + expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).workflowRef).toBe("main"); + expect(() => + parseArgs(["--tag", "v2026.5.14-beta.3", "--workflow-ref", "release/2026.5.14"]), + ).toThrow("--workflow-ref must be main"); + }); + + it("preserves the matching Tideclaw alpha workflow source", () => { + const workflowRef = "tideclaw/alpha/2026-07-10-1200Z"; + const options = parseArgs([ + "--tag", + "v2026.7.1-alpha.3", + "--workflow-ref", + workflowRef, + "--npm-dist-tag", + "alpha", + ]); + Object.assign(options, { + fullReleaseRunId: "111", + fullReleaseRunAttempt: 1, + npmPreflightRunId: "222", + npmPreflightRunAttempt: 1, + }); + + expect(options.workflowRef).toBe(workflowRef); + expect(buildPublishCommand(options)).toContain(`'--ref' '${workflowRef}'`); + expect(() => parseArgs(["--tag", "v2026.7.1-alpha.3"])).toThrow( + "--workflow-ref must be the matching tideclaw/alpha/", + ); + }); + + it("rejects duplicate release candidate CLI options", () => { + const requiredArgs = ["--tag", "v2026.5.14-beta.3"]; + const duplicateOption = ( + flag: string, + firstValue: string, + secondValue: string, + prefix = requiredArgs, + ): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]]; + const duplicateFlag = (flag: string): [string, string[]] => [ + flag, + [...requiredArgs, flag, flag], + ]; + const duplicateCases = [ + duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []), + duplicateOption("--workflow-ref", "release/a", "release/b"), + duplicateOption("--repo", "openclaw/openclaw", "fork/openclaw"), + duplicateOption("--full-release-run", "111", "222"), + duplicateOption("--npm-preflight-run", "111", "222"), + duplicateOption("--windows-node-tag", "v0.6.3", "v0.6.4"), + duplicateFlag("--skip-dispatch"), + duplicateFlag("--skip-local-generated-check"), + duplicateFlag("--skip-parallels"), + duplicateFlag("--skip-telegram"), + duplicateOption("--telegram-provider-mode", "mock-openai", "live-frontier"), + duplicateOption("--provider", "blacksmith-testbox", "crabbox"), + duplicateOption("--mode", "fresh", "upgrade"), + duplicateOption("--release-profile", "beta", "stable"), + duplicateOption("--npm-dist-tag", "beta", "latest"), + duplicateOption("--plugin-publish-scope", "all-publishable", "selected"), + duplicateOption("--plugins", "telegram", "discord"), + duplicateOption("--output-dir", ".artifacts/a", ".artifacts/b"), + ] satisfies Array<[string, string[]]>; + + for (const [flag, args] of duplicateCases) { + expect(() => parseArgs(args), flag).toThrow(`${flag} was provided more than once`); + } + }); + + it("requires stable validation evidence to include soak and blocking performance", () => { + const stableManifest = { + workflowName: "Full Release Validation", + targetSha: "candidate-sha", + releaseProfile: "stable", + rerunGroup: "all", + runReleaseSoak: "true", + controls: { performanceBlocking: true }, + }; + + expect(() => + validateFullManifest(stableManifest, { + targetSha: "candidate-sha", + releaseProfile: "stable", + }), + ).not.toThrow(); + + expect(() => + validateFullManifest( + { + ...stableManifest, + runReleaseSoak: "false", + }, + { + targetSha: "candidate-sha", + releaseProfile: "stable", + }, + ), + ).toThrow("runReleaseSoak=true"); + expect(() => + validateFullManifest( + { + ...stableManifest, + controls: { performanceBlocking: false }, + }, + { + targetSha: "candidate-sha", + releaseProfile: "stable", + }, + ), + ).toThrow("blocking product performance"); + }); + + it("binds SHA-pinned full validation evidence through its manifest", () => { + const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8"); + + expect(source).toContain("allowShaPinnedWorkflowRef: true"); + expect(source).toContain("validateFullReleaseValidationEvidence({"); + expect(source).toContain("runStrictReleaseEvidenceValidation({ repository, runId })"); + expect(source).toContain("refs/heads/main:refs/remotes/origin/main"); + expect(source).toContain("validateTrustedWorkflowRunHead(fullRun"); + expect(source).toContain("allowShaPinnedWorkflowRef: true"); + expect(source).toContain("targetSha,"); + }); + + it("binds workflow run heads to trusted tooling while manifests bind the candidate", () => { + const trustedToolingSha = "a".repeat(40); + expect(() => + validateTrustedWorkflowRunHead( + { headSha: trustedToolingSha }, + { label: "npm preflight", trustedToolingSha }, + ), + ).not.toThrow(); + expect(() => + validateTrustedWorkflowRunHead( + { headSha: "b".repeat(40) }, + { label: "Full Release Validation", trustedToolingSha }, + ), + ).toThrow("Full Release Validation workflow SHA mismatch"); + const targetSha = "c".repeat(40); + expect(() => + validateTrustedWorkflowRunHead( + { headBranch: `release-ci/${targetSha.slice(0, 12)}-1783705000000`, headSha: targetSha }, + { + allowShaPinnedWorkflowRef: true, + label: "Full Release Validation", + targetSha, + trustedToolingSha, + }, + ), + ).not.toThrow(); + expect(() => + validateTrustedWorkflowRunHead( + { + headBranch: `release-ci/${targetSha.slice(0, 12)}-1783705000000`, + headSha: "d".repeat(40), + }, + { + allowShaPinnedWorkflowRef: true, + label: "Full Release Validation", + targetSha, + trustedToolingSha, + }, + ), + ).toThrow(`expected=${targetSha}`); + + expect(() => + validatePreflightManifest( + { + releaseTag: "v2026.7.1", + releaseSha: "c".repeat(40), + npmDistTag: "latest", + tarballName: "openclaw.tgz", + tarballSha256: "d".repeat(64), + dependencyTarballs: [], + }, + { tag: "v2026.7.1", targetSha: "e".repeat(40), npmDistTag: "latest" }, + ), + ).toThrow("npm preflight SHA mismatch"); + }); + it("stops parsing options after the argument terminator", () => { const options = parseArgs([ "--tag", @@ -99,21 +746,43 @@ describe("release candidate checklist", () => { "--tag", "v2026.5.14-beta.3", "--workflow-ref", - "release/2026.5.14", + "main", "--full-release-run", "111", "--npm-preflight-run", "222", "--skip-dispatch", ]), - workflowRef: "release/2026.5.14", + workflowRef: "main", + fullReleaseRunAttempt: 2, + npmPreflightRunAttempt: 3, + trustedToolingSha: "a".repeat(40), }; - expect(buildPublishCommand(options)).toContain("'full_release_validation_run_id=111'"); - expect(buildPublishCommand(options)).toContain("'preflight_run_id=222'"); - expect(buildPublishCommand(options)).toContain("'tag=v2026.5.14-beta.3'"); - expect(buildPublishCommand(options)).toContain("'plugin_publish_scope=all-publishable'"); - expect(buildPublishCommand(options)).not.toContain("windows_node_tag="); + const command = buildPublishCommand(options); + expect(command).toContain("'full_release_validation_run_id=111'"); + expect(command).toContain("'full_release_validation_run_attempt=2'"); + expect(command).toContain("'preflight_run_id=222'"); + expect(command).toContain("'preflight_run_attempt=3'"); + expect(command).toContain("'tag=v2026.5.14-beta.3'"); + expect(command).toContain("'plugin_publish_scope=all-publishable'"); + expect(command).toContain("'--ref' 'release-publish/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'"); + expect(command).toContain( + "'git' 'push' 'origin' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:refs/heads/release-publish/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", + ); + expect(command).not.toContain("windows_node_tag="); + + const workflow = parse( + readFileSync(".github/workflows/openclaw-release-publish.yml", "utf8"), + ) as { + on: { workflow_dispatch: { inputs: Record } }; + }; + const emittedInputs = [...command.matchAll(/'-f' '([^=']+)=/gu)].flatMap((match) => + match[1] === undefined ? [] : [match[1]], + ); + for (const input of emittedInputs) { + expect(workflow.on.workflow_dispatch.inputs).toHaveProperty(input); + } }); it("requires and carries an exact Windows Node tag for stable release candidates", () => { @@ -131,9 +800,14 @@ describe("release candidate checklist", () => { "--windows-node-tag", "v0.6.3", "--workflow-ref", - "release/2026.5.14", + "main", ]), - workflowRef: "release/2026.5.14", + workflowRef: "main", + fullReleaseRunId: "111", + fullReleaseRunAttempt: 1, + npmPreflightRunId: "222", + npmPreflightRunAttempt: 1, + trustedToolingSha: "c".repeat(40), windowsNodeInstallerDigests: JSON.stringify({ "OpenClawCompanion-Setup-x64.exe": `sha256:${"a".repeat(64)}`, "OpenClawCompanion-Setup-arm64.exe": `sha256:${"b".repeat(64)}`, @@ -157,16 +831,15 @@ describe("release candidate checklist", () => { digest: `sha256:${"b".repeat(64)}`, }, ]; - const fetchImpl = vi.fn(async () => ({ - ok: true, - json: async () => ({ + const fetchImpl = vi.fn(async () => { + return jsonResponse({ tag_name: "v0.6.3", draft: false, prerelease: false, html_url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3", assets, - }), - })); + }); + }); await expect( validateWindowsSourceRelease("v0.6.3", { @@ -218,9 +891,8 @@ describe("release candidate checklist", () => { "asset OpenClawCompanion-Setup-x64.exe is missing its SHA-256 digest", ], ])("rejects an invalid stable Windows source release", async (override, message) => { - const fetchImpl = vi.fn(async () => ({ - ok: true, - json: async () => ({ + const fetchImpl = vi.fn(async () => { + return jsonResponse({ tag_name: "v0.6.3", draft: false, prerelease: false, @@ -236,8 +908,8 @@ describe("release candidate checklist", () => { }, ], ...override, - }), - })); + }); + }); await expect( validateWindowsSourceRelease("v0.6.3", { @@ -254,15 +926,18 @@ describe("release candidate checklist", () => { "--tag", "v2026.5.14-beta.3", "--workflow-ref", - "release/2026.5.14", + "main", "--full-release-run", "111", "--npm-preflight-run", "222", "--skip-dispatch", ]), - workflowRef: "release/2026.5.14", + workflowRef: "main", npmTelegramRunId: "333", + fullReleaseRunAttempt: 1, + npmPreflightRunAttempt: 1, + trustedToolingSha: "d".repeat(40), }; expect(buildPublishCommand(options)).toContain("'npm_telegram_run_id=333'"); @@ -295,6 +970,31 @@ describe("release candidate checklist", () => { ).toBe("25922042055"); }); + it("selects only the exact correlated workflow dispatch fallback", () => { + const expected = { + correlationId: "90d733cb-e795-41ad-9599-cb708fbddf85", + headSha: "a".repeat(40), + workflowFile: "full-release-validation.yml", + workflowRef: "main", + }; + const run = { + display_title: expected.correlationId, + event: "workflow_dispatch", + head_branch: expected.workflowRef, + head_sha: expected.headSha, + id: 123, + path: ".github/workflows/full-release-validation.yml@refs/heads/main", + }; + + expect(selectDispatchedWorkflowRunId([run], expected)).toBe("123"); + expect(selectDispatchedWorkflowRunId([{ ...run, head_sha: "b".repeat(40) }], expected)).toBe( + "", + ); + expect(() => selectDispatchedWorkflowRunId([run, { ...run, id: 124 }], expected)).toThrow( + "matched multiple runs", + ); + }); + it("falls back to a single compatible artifact from the same run", () => { expect( resolveArtifactName( @@ -313,10 +1013,7 @@ describe("release candidate checklist", () => { Authorization: "Bearer test-token", "X-GitHub-Api-Version": "2022-11-28", }); - return { - ok: true, - json: async () => ({ workflow_runs: [] }), - }; + return jsonResponse({ workflow_runs: [] }); }); await expect( @@ -334,6 +1031,78 @@ describe("release candidate checklist", () => { ); }); + it("uses a positive integer GitHub API timeout env", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return jsonResponse({ workflow_runs: [] }); + }); + + await withGithubApiTimeoutEnv("2500", async () => { + await expect( + githubApi("repos/openclaw/openclaw/actions/runs", { + fetchImpl, + token: "test-token", + }), + ).resolves.toEqual({ workflow_runs: [] }); + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it.each(["1e3", "10.5", "0", "soon"])( + "rejects malformed GitHub API timeout env %s", + async (raw) => { + const fetchImpl = vi.fn(); + + await withGithubApiTimeoutEnv(raw, async () => { + await expect( + githubApi("repos/openclaw/openclaw/actions/runs", { + fetchImpl, + token: "test-token", + }), + ).rejects.toThrow( + "OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer", + ); + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }, + ); + + it("bounds GitHub API error bodies", async () => { + const fetchImpl = vi.fn(async () => { + return new Response("x".repeat(65), { + headers: { "content-length": "65" }, + status: 500, + }); + }); + + await expect( + githubApi("repos/openclaw/openclaw/actions/runs", { + fetchImpl, + maxBodyBytes: 64, + timeoutMs: 1234, + token: "test-token", + }), + ).rejects.toThrow( + "GitHub API repos/openclaw/openclaw/actions/runs response body exceeded 64 bytes", + ); + }); + + it("keeps GitHub API timeouts active while reading response bodies", async () => { + const fetchImpl = vi.fn(async () => { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + }); + }); + + await expect( + githubApi("repos/openclaw/openclaw/actions/runs", { + fetchImpl, + timeoutMs: 25, + token: "test-token", + }), + ).rejects.toThrow("GitHub API repos/openclaw/openclaw/actions/runs timed out after 25ms"); + }); + it("includes the GitHub API path when a request times out", async () => { const fetchImpl = vi.fn(async () => { throw new DOMException("request timed out", "TimeoutError"); diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts new file mode 100644 index 000000000000..dfcb70c6968c --- /dev/null +++ b/test/scripts/release-ci-summary.test.ts @@ -0,0 +1,1761 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + expectedChildDispatches, + expectedSelectedChildDispatches, + manifestChildEntries, + parseReleaseCiSummaryArgs, + readManifestArtifactArchive, + releaseCiWatchFingerprint, + requiredChildKeysForRerunGroup, + resolveManifestChildOriginAttempt, + selectExactChildRun, + selectExactChildRunFromPages, + selectManifestArtifact, + selectManifestParentJob, + selectedChildKeys, + validateEvidenceReuseChain, + validateManifestArtifactCompatibility, + validateManifestArtifactIdentity, + validateManifestChildRun, + validateParentManifest, + validateParentRunBinding, + validatePerformanceArtifactOnlyJobs, + validateReleaseRunEvidence, + validateTrustedProducerIdentity, + watchReleaseCiRun, +} from "../../scripts/release-ci-summary.mjs"; + +const SCRIPT = "scripts/release-ci-summary.mjs"; +const MANIFEST_ARTIFACT_ENTRY = "full-release-validation-manifest.json"; + +function crc32(input: Buffer): number { + let crc = 0xffffffff; + for (const byte of input) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function u16(value: number): Buffer { + const buffer = Buffer.alloc(2); + buffer.writeUInt16LE(value); + return buffer; +} + +function u32(value: number): Buffer { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value); + return buffer; +} + +function makeStoredZip(files: Record): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const [name, contents] of Object.entries(files)) { + const nameBuffer = Buffer.from(name, "utf8"); + const contentsBuffer = Buffer.from(contents, "utf8"); + const checksum = crc32(contentsBuffer); + const localHeader = Buffer.concat([ + u32(0x04034b50), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(checksum), + u32(contentsBuffer.length), + u32(contentsBuffer.length), + u16(nameBuffer.length), + u16(0), + nameBuffer, + ]); + localParts.push(localHeader, contentsBuffer); + centralParts.push( + Buffer.concat([ + u32(0x02014b50), + u16(20), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(checksum), + u32(contentsBuffer.length), + u32(contentsBuffer.length), + u16(nameBuffer.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32((0o100644 << 16) >>> 0), + u32(offset), + nameBuffer, + ]), + ); + offset += localHeader.length + contentsBuffer.length; + } + + const localData = Buffer.concat(localParts); + const centralDirectory = Buffer.concat(centralParts); + return Buffer.concat([ + localData, + centralDirectory, + u32(0x06054b50), + u16(0), + u16(0), + u16(Object.keys(files).length), + u16(Object.keys(files).length), + u32(centralDirectory.length), + u32(localData.length), + u16(0), + ]); +} + +function artifactDigest(bytes: Buffer): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function rawManifest({ + evidenceReuse, + rerunGroup = "all", + runId = "29090000000", + targetSha = "a".repeat(40), + version = 2, + workflowFullRef, + workflowRefType, + workflowSha, +}: { + evidenceReuse?: Record; + rerunGroup?: string; + runId?: string; + targetSha?: string; + version?: 2 | 3; + workflowFullRef?: string; + workflowRefType?: "branch" | "tag"; + workflowSha?: string; +}) { + return { + childRuns: { + normalCi: "101", + npmTelegram: "", + pluginPrerelease: "202", + productPerformance: { blocking: true, conclusion: "success", runId: "303" }, + releaseChecks: "404", + }, + controls: { + performanceBlocking: true, + performanceReportPublication: "artifact-only", + stableSoakRequired: false, + }, + evidenceReuse, + releaseProfile: "beta", + rerunGroup, + runAttempt: "2", + runId, + runReleaseSoak: "false", + targetSha, + validationInputs: { + codexPluginSpec: "", + crossOsSuiteFilter: "", + liveSuiteFilter: "", + mode: "direct", + packageAcceptancePackageSpec: "", + provider: "openai", + releasePackageSpec: "", + targetContextRef: "", + }, + version, + workflowName: "Full Release Validation", + workflowRef: "main", + ...(workflowSha ? { workflowSha } : {}), + ...(version === 3 + ? { + workflowFullRef: workflowFullRef ?? "refs/heads/main", + workflowRefType: workflowRefType ?? "branch", + } + : {}), + }; +} + +function trustedMainPackageFixture({ + manifestVersion = 2, + parentPath = ".github/workflows/full-release-validation.yml", + targetSha = "8".repeat(40), + workflowFullRef, + workflowRef = "main", + workflowRefType, + workflowSha = "0".repeat(40), +}: { + manifestVersion?: 2 | 3; + parentPath?: string; + targetSha?: string; + workflowFullRef?: string; + workflowRef?: string; + workflowRefType?: "branch" | "tag"; + workflowSha?: string; +} = {}) { + const runId = "29071366025"; + const childRunId = "29071382629"; + const manifest = rawManifest({ + rerunGroup: "package", + runId, + targetSha, + version: manifestVersion, + workflowFullRef, + workflowRefType, + workflowSha, + }); + manifest.childRuns = { + normalCi: "", + npmTelegram: "", + pluginPrerelease: "", + productPerformance: { blocking: true, conclusion: "", runId: "" }, + releaseChecks: childRunId, + }; + manifest.releaseProfile = "full"; + manifest.runAttempt = "1"; + manifest.runReleaseSoak = "true"; + manifest.workflowRef = workflowRef; + + const parentRun = { + conclusion: "success", + event: "workflow_dispatch", + head_branch: workflowRef, + head_sha: workflowSha, + html_url: `https://github.com/openclaw/openclaw/actions/runs/${runId}`, + id: Number(runId), + path: parentPath, + repository: { full_name: "openclaw/openclaw" }, + run_attempt: 1, + status: "completed", + }; + const parentView = { + attempt: 1, + conclusion: "success", + headBranch: workflowRef, + headSha: workflowSha, + jobs: [], + status: "completed", + url: parentRun.html_url, + }; + const child = expectedChildDispatches(runId, 1, workflowRef).find( + (entry) => entry.manifestKey === "releaseChecks", + ); + if (!child) { + throw new Error("missing release checks child fixture"); + } + const parentJob = { + completed_at: "2026-07-10T01:10:00Z", + conclusion: "success", + id: 86293408710, + name: child.parentJobName, + run_attempt: 1, + started_at: "2026-07-10T01:00:00Z", + status: "completed", + steps: [], + }; + const childRun = { + actor: { login: "github-actions[bot]" }, + conclusion: "success", + display_title: child.displayTitle, + event: "workflow_dispatch", + head_branch: workflowRef, + head_sha: workflowSha, + html_url: `https://github.com/openclaw/openclaw/actions/runs/${childRunId}`, + id: Number(childRunId), + path: ".github/workflows/openclaw-release-checks.yml", + repository: { full_name: "openclaw/openclaw" }, + run_attempt: 1, + status: "completed", + triggering_actor: { login: "github-actions[bot]" }, + }; + const artifact = { + digest: `sha256:${"9".repeat(64)}`, + expired: false, + id: 8220114429, + name: `full-release-validation-${runId}-1`, + size_in_bytes: 507, + workflow_run: { + head_branch: workflowRef, + head_sha: workflowSha, + id: Number(runId), + }, + }; + const client = { + compareCommits(base: string, head: string) { + expect(base).toBe(workflowSha); + return { + merge_base_commit: { sha: workflowSha }, + status: base === head ? "identical" : "ahead", + }; + }, + getJobLog(jobId: number) { + expect(jobId).toBe(parentJob.id); + return [ + `TARGET_SHA: ${targetSha}`, + `Dispatched openclaw-release-checks.yml: ${childRun.html_url}`, + ].join("\n"); + }, + getParentJobs(requestedRunId: string) { + expect(requestedRunId).toBe(runId); + return [parentJob]; + }, + getRun(requestedRunId: string) { + if (String(requestedRunId) === runId) { + return parentRun; + } + if (String(requestedRunId) === childRunId) { + return childRun; + } + throw new Error(`unexpected run: ${requestedRunId}`); + }, + getRunView(requestedRunId: string) { + expect(requestedRunId).toBe(runId); + return parentView; + }, + loadManifest(requestedRunId: string, requestedRunAttempt: number) { + expect(requestedRunId).toBe(runId); + expect(requestedRunAttempt).toBe(1); + return { artifact, manifest }; + }, + }; + + return { artifact, childRun, client, manifest, parentRun, runId, targetSha, workflowSha }; +} + +describe("release CI summary child correlation", () => { + it("parses the reusable strict validation CLI without changing positional summary mode", () => { + expect( + parseReleaseCiSummaryArgs([ + "--validate-run", + "29071366025", + "--repo", + "openclaw/openclaw", + "--manifest", + "/tmp/manifest.json", + "--json", + ]), + ).toEqual({ + json: true, + intervalMs: 30_000, + manifestPath: "/tmp/manifest.json", + repository: "openclaw/openclaw", + runId: "29071366025", + trustedWorkflowRef: "main", + validate: true, + verifierSourceFile: undefined, + verifierSourceSha: undefined, + watch: false, + }); + expect(parseReleaseCiSummaryArgs(["29071366025"])).toMatchObject({ + repository: "openclaw/openclaw", + runId: "29071366025", + trustedWorkflowRef: "main", + validate: false, + }); + expect(parseReleaseCiSummaryArgs(["29071366025", "--watch", "--interval", "15"])).toMatchObject( + { + intervalMs: 15_000, + watch: true, + }, + ); + expect(() => parseReleaseCiSummaryArgs(["29071366025", "--interval", "0"])).toThrow( + "positive number of seconds", + ); + expect(() => parseReleaseCiSummaryArgs(["--validate-run", "29071366025", "--watch"])).toThrow( + "--watch cannot be combined", + ); + expect(() => parseReleaseCiSummaryArgs(["--manifest", "/tmp/manifest.json"])).toThrow( + "--manifest requires --validate-run", + ); + expect(() => + parseReleaseCiSummaryArgs([ + "--validate-run", + "29071366025", + "--verifier-source-file", + "/tmp/verifier.mjs", + ]), + ).toThrow("--verifier-source-file requires --verifier-source-sha"); + expect( + parseReleaseCiSummaryArgs([ + "--validate-run", + "29071366025", + "--verifier-source-sha", + "a".repeat(40), + "--verifier-source-file", + "/tmp/verifier.mjs", + ]), + ).toMatchObject({ + verifierSourceFile: "/tmp/verifier.mjs", + verifierSourceSha: "a".repeat(40), + }); + }); + + it("changes the watch fingerprint only for visible run transitions", () => { + const parent = { + attempt: 1, + conclusion: "", + jobs: [{ name: "Run normal full CI", status: "in_progress", conclusion: "" }], + status: "in_progress", + url: "ignored", + }; + expect(releaseCiWatchFingerprint({ ...parent, url: "changed" })).toBe( + releaseCiWatchFingerprint(parent), + ); + expect( + releaseCiWatchFingerprint({ + ...parent, + jobs: [{ ...parent.jobs[0], conclusion: "success", status: "completed" }], + }), + ).not.toBe(releaseCiWatchFingerprint(parent)); + }); + + it("summarizes only transitions while watching a release run", async () => { + const states = [ + { attempt: 1, conclusion: "", jobs: [], status: "queued" }, + { attempt: 1, conclusion: "", jobs: [], status: "queued" }, + { + attempt: 1, + conclusion: "success", + jobs: [{ name: "Run normal full CI", status: "completed", conclusion: "success" }], + status: "completed", + }, + ]; + let index = 0; + let summaries = 0; + let sleeps = 0; + + await watchReleaseCiRun( + parseReleaseCiSummaryArgs(["29071366025", "--watch", "--interval", "1"]), + { + fetchParent: () => states[index++], + sleep: async () => { + sleeps += 1; + }, + summarize: () => { + summaries += 1; + }, + }, + ); + + expect(summaries).toBe(2); + expect(sleeps).toBe(2); + }); + + it("selects one immutable manifest artifact bound to the exact parent run", () => { + const { artifact, runId } = trustedMainPackageFixture(); + const legacyArtifact = { + ...artifact, + id: artifact.id + 1, + name: `full-release-validation-${runId}`, + }; + expect(selectManifestArtifact([artifact], runId, 1)).toBe(artifact); + expect(selectManifestArtifact([legacyArtifact, artifact], runId, 1)).toBe(artifact); + expect(selectManifestArtifact([legacyArtifact], runId, 1)).toBe(legacyArtifact); + expect(validateManifestArtifactCompatibility(legacyArtifact, { version: 2 }, runId, 1)).toBe( + legacyArtifact, + ); + expect( + selectManifestArtifact( + [{ ...artifact, workflow_run: { ...artifact.workflow_run, id: 1 } }], + runId, + 1, + ), + ).toBeUndefined(); + expect(() => + selectManifestArtifact([artifact, { ...artifact, id: artifact.id + 1 }], runId, 1), + ).toThrow("multiple release validation manifest artifacts"); + expect(() => + selectManifestArtifact( + [legacyArtifact, { ...legacyArtifact, id: legacyArtifact.id + 1 }], + runId, + 1, + ), + ).toThrow("multiple legacy release validation manifest artifacts"); + expect(() => selectManifestArtifact([legacyArtifact], runId, 2)).toThrow( + "legacy release validation manifest requires run attempt 1", + ); + expect(() => + validateManifestArtifactCompatibility(legacyArtifact, { version: 3 }, runId, 1), + ).toThrow("legacy release validation manifest artifact is not compatible"); + expect(selectManifestArtifact([artifact], runId, 2)).toBeUndefined(); + expect(() => selectManifestArtifact([{ ...artifact, digest: undefined }], runId, 1)).toThrow( + "manifest artifact digest is invalid", + ); + expect(() => + validateManifestArtifactIdentity( + { ...artifact, digest: `sha256:${"8".repeat(64)}` }, + { + artifactDigest: artifact.digest, + artifactId: artifact.id, + runAttempt: 1, + runId, + }, + ), + ).toThrow("manifest artifact identity mismatch"); + expect(() => + validateManifestArtifactIdentity( + { ...artifact, id: artifact.id + 1 }, + { + artifactDigest: artifact.digest, + artifactId: artifact.id, + runAttempt: 1, + runId, + }, + ), + ).toThrow("manifest artifact identity mismatch"); + + const source = readFileSync(SCRIPT, "utf8"); + expect(source).toContain("actions/artifacts/${artifactId}/zip"); + expect(source).not.toContain('"--name",'); + expect(source).not.toContain("gh run download"); + expect(source).toContain("execFileSync(resolvePlainGhBin(), args"); + expect(source).toContain('token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"'); + expect(source).not.toContain('token="$(gh auth token)"'); + expect(source).toContain( + "downloadParentManifestEvidence(runId, runAttempt, normalizedRepository, manifestPath)", + ); + }); + + it("hashes and safely streams one bounded manifest entry from the exact artifact ZIP", () => { + const root = mkdtempSync(join(tmpdir(), "release-manifest-artifact-")); + try { + const archivePath = join(root, "manifest.zip"); + const manifest = { runAttempt: 1, runId: "29071366025" }; + const archive = makeStoredZip({ + [MANIFEST_ARTIFACT_ENTRY]: JSON.stringify(manifest), + }); + writeFileSync(archivePath, archive); + expect(readManifestArtifactArchive(archivePath, artifactDigest(archive))).toEqual(manifest); + expect(() => readManifestArtifactArchive(archivePath, `sha256:${"0".repeat(64)}`)).toThrow( + "artifact digest mismatch", + ); + + const extraEntryArchive = makeStoredZip({ + [MANIFEST_ARTIFACT_ENTRY]: JSON.stringify(manifest), + "unexpected.json": "{}", + }); + writeFileSync(archivePath, extraEntryArchive); + expect(() => + readManifestArtifactArchive(archivePath, artifactDigest(extraEntryArchive)), + ).toThrow(`must contain only ${MANIFEST_ARTIFACT_ENTRY}`); + + const oversizedManifestArchive = makeStoredZip({ + [MANIFEST_ARTIFACT_ENTRY]: "x".repeat(128 * 1024 + 1), + }); + writeFileSync(archivePath, oversizedManifestArchive); + expect(() => + readManifestArtifactArchive(archivePath, artifactDigest(oversizedManifestArchive)), + ).toThrow("artifact entry size is invalid"); + + const oversizedArchive = Buffer.alloc(256 * 1024 + 1); + writeFileSync(archivePath, oversizedArchive); + expect(() => + readManifestArtifactArchive(archivePath, artifactDigest(oversizedArchive)), + ).toThrow("artifact compressed size is invalid"); + + const source = readFileSync(SCRIPT, "utf8"); + expect(source).toContain('execFileSync("unzip", ["-p", archivePath'); + expect(source).not.toContain('execFileSync("unzip", ["-q", archivePath, "-d"'); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("bridges only attempt-one manifest v2 artifacts with the legacy stable name", () => { + const legacyV2 = trustedMainPackageFixture(); + legacyV2.artifact.name = `full-release-validation-${legacyV2.runId}`; + expect( + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: legacyV2.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + legacyV2.client, + ).root.artifact.name, + ).toBe(legacyV2.artifact.name); + + const legacyV3 = trustedMainPackageFixture({ + manifestVersion: 3, + workflowSha: "a".repeat(40), + }); + legacyV3.artifact.name = `full-release-validation-${legacyV3.runId}`; + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: legacyV3.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + legacyV3.client, + ), + ).toThrow("legacy release validation manifest artifact is not compatible"); + }); + + it("normalizes a pre-tooling trusted-main producer separately from the current verifier", () => { + const fixture = trustedMainPackageFixture({ + targetSha: "8".repeat(40), + workflowSha: "0".repeat(40), + }); + const verifierSourceSha = "c".repeat(40); + const evidence = validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha, + }, + fixture.client, + ); + + expect(evidence).toMatchObject({ + directRoot: true, + evidenceReuse: null, + releaseProfile: "full", + repository: "openclaw/openclaw", + rerunGroup: "package", + runReleaseSoak: true, + schema: "openclaw.release-validation-evidence/v3", + producerOnTrustedMainLineage: true, + trustedWorkflowFullRef: "refs/heads/main", + trustedWorkflowRef: "main", + valid: true, + verifier: { + schemaVersion: 3, + sourceSha: verifierSourceSha, + }, + }); + expect(evidence.root).toMatchObject({ + manifestVersion: 2, + runAttempt: 1, + runId: fixture.runId, + targetSha: fixture.targetSha, + producerOnTrustedMainLineage: true, + workflowFullRef: "refs/heads/main", + workflowPath: ".github/workflows/full-release-validation.yml", + workflowQualifiedPath: ".github/workflows/full-release-validation.yml@refs/heads/main", + workflowRef: "main", + workflowRefProof: "legacy-v2-main-ancestry", + workflowRefType: "branch", + workflowSha: fixture.workflowSha, + }); + expect(evidence.root.workflowSha).not.toBe(evidence.root.targetSha); + expect(evidence.verifier.sourceSha).not.toBe(evidence.root.workflowSha); + expect(evidence.children).toEqual([ + expect.objectContaining({ + conclusion: "success", + dispatchNonce: `full-release-validation-${fixture.runId}-1-release-checks`, + headBranch: "main", + role: "releaseChecks", + runAttempt: 1, + runId: String(fixture.childRun.id), + sourceParentAttempt: 1, + workflowSha: fixture.workflowSha, + }), + ]); + expect(evidence.root.artifact).toEqual({ + digest: fixture.artifact.digest, + id: String(fixture.artifact.id), + name: fixture.artifact.name, + runAttempt: 1, + sizeInBytes: fixture.artifact.size_in_bytes, + }); + }); + + it("accepts a trusted-main producer when the candidate is the same main commit", () => { + const sharedSha = "a".repeat(40); + const fixture = trustedMainPackageFixture({ + targetSha: sharedSha, + workflowSha: sharedSha, + }); + expect( + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ).root, + ).toMatchObject({ + targetSha: sharedSha, + workflowRef: "main", + workflowSha: sharedSha, + }); + }); + + it("binds v3 producer evidence to the exact trusted branch ref", () => { + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowSha: "a".repeat(40), + }); + const evidence = validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ); + expect(evidence.root).toMatchObject({ + producerOnTrustedMainLineage: true, + workflowFullRef: "refs/heads/main", + workflowRefProof: "manifest-v3-branch", + workflowRefType: "branch", + workflowRunPath: ".github/workflows/full-release-validation.yml", + }); + }); + + it("accepts a Unicode trusted workflow ref", () => { + const workflowRef = "release/unicode-\u{1f4a5}"; + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowSha: "a".repeat(40), + }); + const evidence = validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + trustedWorkflowRef: workflowRef, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ); + + expect(evidence.root).toMatchObject({ + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + }); + }); + + it("rejects a v3 producer dispatched from a tag named main", () => { + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowFullRef: "refs/tags/main", + workflowRefType: "tag", + workflowSha: "a".repeat(40), + }); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toThrow("producer workflow full ref is not trusted"); + }); + + it("rejects a legacy producer outside the trusted main verifier lineage", () => { + const fixture = trustedMainPackageFixture({ workflowSha: "a".repeat(40) }); + fixture.client.compareCommits = () => ({ + merge_base_commit: { sha: "d".repeat(40) }, + status: "diverged", + }); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toThrow("producer is not on the trusted main verifier lineage"); + }); + + it("rejects a candidate branch producer even when its SHA differs from the target", () => { + const fixture = trustedMainPackageFixture({ + targetSha: "8".repeat(40), + workflowRef: "release/2026.7.1", + workflowSha: "7".repeat(40), + }); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + trustedWorkflowRef: "main", + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toThrow("producer must run from trusted workflow ref: main"); + }); + + it("accepts canonical SHA-pinned v3 evidence on the trusted main lineage", () => { + const workflowSha = "7".repeat(40); + const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + targetSha: "8".repeat(40), + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowSha, + }); + fixture.manifest.targetRef = fixture.targetSha; + + expect( + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ).root, + ).toMatchObject({ + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowRefProof: "manifest-v3-sha-pinned-main-ancestry", + workflowSha, + }); + }); + + it.each(["main", "refs/heads/main"])( + "accepts a REST workflow path qualified with %s", + (qualifiedRef) => { + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + parentPath: `.github/workflows/full-release-validation.yml@${qualifiedRef}`, + workflowSha: "7".repeat(40), + }); + + expect( + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ).root, + ).toMatchObject({ workflowFullRef: "refs/heads/main" }); + }, + ); + + it("accepts SHA-pinned producer identity with exact-target evidence reuse", () => { + const workflowSha = "7".repeat(40); + const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowSha, + }); + fixture.manifest.targetRef = fixture.targetSha; + fixture.manifest.evidenceReuse = { + changedPaths: [], + evidenceSha: fixture.targetSha, + policy: "exact-target-full-validation-v1", + runId: "29071366024", + selectedRunId: "29071366024", + }; + + expect( + validateTrustedProducerIdentity( + { + manifest: fixture.manifest, + parentRun: fixture.parentRun, + }, + fixture.client, + { sourceSha: "c".repeat(40) }, + "main", + ), + ).toMatchObject({ + producerOnTrustedMainLineage: true, + workflowRefProof: "manifest-v3-sha-pinned-main-ancestry", + }); + }); + + it("rejects a SHA-pinned evidenceReuse field even when false", () => { + const workflowSha = "7".repeat(40); + const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowSha, + }); + fixture.manifest.targetRef = fixture.targetSha; + fixture.manifest.evidenceReuse = false; + + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toThrow("evidence reuse is invalid"); + }); + + it("rejects dirty verifier bytes and a forged verifier source SHA", () => { + const fixture = trustedMainPackageFixture(); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceContent: "different verifier bytes", + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toThrow("verifier script differs from its source SHA"); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + verifierSourceSha: "f".repeat(40), + }, + fixture.client, + ), + ).toThrow("verifier source blob is unavailable"); + }); + + it("binds verifier bytes from the repository root even outside the caller cwd", () => { + const repositoryRoot = mkdtempSync(join(tmpdir(), "release-verifier-repo-")); + const outsideCwd = mkdtempSync(join(tmpdir(), "release-verifier-cwd-")); + try { + const scriptPath = join(repositoryRoot, SCRIPT); + mkdirSync(dirname(scriptPath), { recursive: true }); + writeFileSync(scriptPath, readFileSync(SCRIPT)); + execFileSync("git", ["init", "-q"], { cwd: repositoryRoot }); + execFileSync("git", ["add", SCRIPT], { cwd: repositoryRoot }); + execFileSync( + "git", + [ + "-c", + "user.name=Release Test", + "-c", + "user.email=release-test@example.invalid", + "-c", + "commit.gpgSign=false", + "commit", + "-qm", + "test verifier", + ], + { cwd: repositoryRoot }, + ); + const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repositoryRoot, + encoding: "utf8", + }).trim(); + + const moduleUrl = pathToFileURL(resolve(SCRIPT)).href; + const output = execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { resolveVerifierIdentity } from ${JSON.stringify(moduleUrl)}; + process.stdout.write(JSON.stringify(resolveVerifierIdentity( + process.env.SOURCE_SHA, + undefined, + process.env.REPOSITORY_ROOT, + )));`, + ], + { + cwd: outsideCwd, + encoding: "utf8", + env: { + ...process.env, + REPOSITORY_ROOT: repositoryRoot, + SOURCE_SHA: sourceSha, + }, + }, + ); + expect(JSON.parse(output)).toMatchObject({ + script: SCRIPT, + sourceSha, + }); + } finally { + rmSync(repositoryRoot, { force: true, recursive: true }); + rmSync(outsideCwd, { force: true, recursive: true }); + } + }); + + it("binds the parent to the exact Full Release Validation REST run", () => { + const parentView = { + attempt: 2, + headBranch: "main", + headSha: "a".repeat(40), + }; + const parentRest = { + event: "workflow_dispatch", + head_branch: parentView.headBranch, + head_sha: parentView.headSha, + id: 29090000000, + path: ".github/workflows/full-release-validation.yml@refs/heads/main", + run_attempt: parentView.attempt, + }; + + expect(validateParentRunBinding(parentView, parentRest, "29090000000")).toBe(parentRest); + expect(() => + validateParentRunBinding( + parentView, + { ...parentRest, path: ".github/workflows/openclaw-release-checks.yml" }, + "29090000000", + ), + ).toThrow("full release parent run binding mismatch"); + }); + + it("derives every child title from the exact parent run and attempt", () => { + expect(expectedChildDispatches("29090000000", 3, "release/2026.7.1")).toEqual([ + { + displayTitle: "CI full-release-validation-29090000000-3-ci", + headBranch: "release/2026.7.1", + manifestKey: "normalCi", + name: "CI", + parentJobName: "Run normal full CI", + suffix: "-ci", + trustedRef: "parent", + workflow: "ci.yml", + }, + { + displayTitle: + "OpenClaw Release Checks full-release-validation-29090000000-3-release-checks", + headBranch: "release/2026.7.1", + manifestKey: "releaseChecks", + name: "OpenClaw Release Checks", + parentJobName: "Run release/live/Docker/QA validation", + suffix: "-release-checks", + trustedRef: "parent", + workflow: "openclaw-release-checks.yml", + }, + { + displayTitle: "Plugin Prerelease full-release-validation-29090000000-3-plugin-prerelease", + headBranch: "release/2026.7.1", + manifestKey: "pluginPrerelease", + name: "Plugin Prerelease", + parentJobName: "Run plugin prerelease validation", + suffix: "-plugin-prerelease", + trustedRef: "parent", + workflow: "plugin-prerelease.yml", + }, + { + displayTitle: "NPM Telegram Beta E2E full-release-validation-29090000000-3-npm-telegram", + headBranch: "release/2026.7.1", + manifestKey: "npmTelegram", + name: "NPM Telegram Beta E2E", + parentJobName: "Run package Telegram E2E", + suffix: "-npm-telegram", + trustedRef: "parent", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + displayTitle: "OpenClaw Performance full-release-validation-29090000000-3", + headBranch: "release/2026.7.1", + manifestKey: "productPerformance", + name: "OpenClaw Performance", + parentJobName: "Run product performance evidence", + suffix: "", + trustedRef: "parent", + workflow: "openclaw-performance.yml", + }, + ]); + }); + + it("ignores same-SHA and nearby-name runs without the exact parent dispatch binding", () => { + const expected = "OpenClaw Performance full-release-validation-29090000000-3"; + const exact = { + display_title: expected, + event: "workflow_dispatch", + head_branch: "main", + head_sha: "a".repeat(40), + id: 303, + }; + expect( + selectExactChildRun( + [ + { + display_title: "OpenClaw Performance", + event: "workflow_dispatch", + head_branch: "main", + head_sha: exact.head_sha, + id: 101, + }, + { ...exact, event: "push", id: 202 }, + exact, + ], + expected, + "main", + ), + ).toBe(exact); + }); + + it("fails closed on duplicate exact dispatch bindings and ignores branch collisions", () => { + const expected = "CI full-release-validation-29090000000-3-ci"; + const exact = { + display_title: expected, + event: "workflow_dispatch", + head_branch: "main", + id: 1, + }; + expect( + selectExactChildRun( + [{ ...exact, head_branch: "release/2026.7.1", id: 0 }, exact], + expected, + "main", + ), + ).toBe(exact); + expect(() => selectExactChildRun([exact, { ...exact, id: 2 }], expected, "main")).toThrow( + "multiple child runs have exact dispatch title and branch", + ); + + const source = readFileSync(SCRIPT, "utf8"); + expect(source).not.toContain("created_at >= since"); + expect(source).not.toContain("head_sha === parent.headSha"); + expect(source).not.toContain("created:"); + expect(source).toContain("workflow-sha:"); + expect(source).toContain("candidate-sha:"); + expect(source).not.toContain("console.log(`sha:"); + expect(source).toContain("actions/workflows/${child.workflow}/runs"); + }); + + it("returns one exact child after a full bounded pagination scan", () => { + const expected = "OpenClaw Performance full-release-validation-29090000000-3"; + const exact = { + display_title: expected, + event: "workflow_dispatch", + head_branch: "main", + id: 999, + }; + const pages = Array.from({ length: 10 }, (_, pageIndex) => + Array.from({ length: 100 }, (_, runIndex) => ({ + display_title: `decoy-${pageIndex}-${runIndex}`, + event: "workflow_dispatch", + head_branch: "main", + id: pageIndex * 100 + runIndex, + })), + ); + pages[9][99] = exact; + + expect(selectExactChildRunFromPages(pages, expected, "main")).toBe(exact); + pages[0][0] = { ...exact, id: 1001 }; + expect(() => selectExactChildRunFromPages(pages, expected, "main")).toThrow( + "multiple child runs have exact dispatch title and branch", + ); + }); + + it("validates candidate identity and selected child completeness from the parent manifest", () => { + const manifest = validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + }); + expect(manifest.targetSha).toBe("a".repeat(40)); + expect(manifest.rerunGroup).toBe("all"); + const children = expectedChildDispatches(manifest.runId, manifest.runAttempt, "main"); + const selected = requiredChildKeysForRerunGroup(manifest.rerunGroup); + expect(manifestChildEntries(manifest, children, selected).map((entry) => entry.runId)).toEqual([ + "101", + "404", + "202", + "303", + ]); + + const missing = { + ...manifest, + childRunIds: { ...manifest.childRunIds, normalCi: "" }, + }; + expect(() => manifestChildEntries(missing, children, selected)).toThrow( + "selected child is missing from manifest: CI", + ); + }); + + it("keeps historical non-reuse v2 manifests readable without validation inputs", () => { + const legacy = rawManifest({}); + delete (legacy as { validationInputs?: unknown }).validationInputs; + const manifest = validateParentManifest(legacy, { + runAttempt: 2, + runId: "29090000000", + }); + + expect(manifest.validationInputs).toBeUndefined(); + expect(manifest.rerunGroup).toBe("all"); + }); + + it("binds v3 manifests to their immutable producer workflow SHA", () => { + const workflowSha = "b".repeat(40); + const manifest = validateParentManifest(rawManifest({ version: 3, workflowSha }), { + runAttempt: 2, + runId: "29090000000", + workflowRef: "main", + workflowSha, + }); + expect(manifest).toMatchObject({ + version: 3, + workflowSha, + }); + expect(() => + validateParentManifest(rawManifest({ version: 3, workflowSha }), { + runAttempt: 2, + runId: "29090000000", + workflowSha: "c".repeat(40), + }), + ).toThrow("release validation manifest workflow SHA mismatch"); + }); + + it("requires v3 manifests to record artifact-only performance publication", () => { + const workflowSha = "b".repeat(40); + const missing = rawManifest({ version: 3, workflowSha }); + delete ( + missing.controls as { + performanceReportPublication?: string; + } + ).performanceReportPublication; + expect(() => + validateParentManifest(missing, { + runAttempt: 2, + runId: "29090000000", + workflowSha, + }), + ).toThrow("release validation manifest performance report publication mode is invalid"); + + const publishing = rawManifest({ version: 3, workflowSha }); + publishing.controls.performanceReportPublication = "publish"; + expect(() => + validateParentManifest(publishing, { + runAttempt: 2, + runId: "29090000000", + workflowSha, + }), + ).toThrow("release validation manifest performance report publication mode is invalid"); + }); + + it("requires v3 manifests to record blocking performance evidence", () => { + const workflowSha = "b".repeat(40); + const advisory = rawManifest({ version: 3, workflowSha }); + advisory.controls.performanceBlocking = false; + expect(() => + validateParentManifest(advisory, { + runAttempt: 2, + runId: "29090000000", + workflowSha, + }), + ).toThrow("release validation manifest performance blocking control is invalid"); + }); + + it("requires a successful artifact-only performance guard for the current attempt", () => { + const guard = { + conclusion: "success", + name: "Verify artifact-only report mode", + run_attempt: 2, + status: "completed", + }; + const skippedPublisher = { + conclusion: "skipped", + name: "Publish mock provider report", + run_attempt: 2, + status: "completed", + }; + expect( + validatePerformanceArtifactOnlyJobs( + [{ ...guard, conclusion: "failure", run_attempt: 1 }, guard, skippedPublisher], + 2, + ), + ).toBe(guard); + expect(() => validatePerformanceArtifactOnlyJobs([skippedPublisher], 2)).toThrow( + "performance artifact-only guard is missing or unsuccessful", + ); + expect(() => + validatePerformanceArtifactOnlyJobs([{ ...guard, conclusion: "failure" }], 2), + ).toThrow("performance artifact-only guard is missing or unsuccessful"); + expect(() => + validatePerformanceArtifactOnlyJobs( + [guard, { ...skippedPublisher, conclusion: "success" }], + 2, + ), + ).toThrow("performance report publisher was not skipped"); + }); + + it("requires the child mapped by rerunGroup and scans only selected in-progress workflows", () => { + const focused = validateParentManifest( + { + ...rawManifest({ rerunGroup: "npm-telegram" }), + childRuns: { + normalCi: "", + npmTelegram: "", + pluginPrerelease: "", + productPerformance: { runId: "" }, + releaseChecks: "", + }, + }, + { runAttempt: 2, runId: "29090000000" }, + ); + const selected = requiredChildKeysForRerunGroup(focused.rerunGroup); + const children = expectedSelectedChildDispatches( + focused.runId, + focused.runAttempt, + focused.workflowRef, + selected, + ); + expect(children.map((child) => child.manifestKey)).toEqual(["npmTelegram"]); + expect(() => manifestChildEntries(focused, children, selected)).toThrow( + "selected child is missing from manifest: NPM Telegram Beta E2E", + ); + + const inProgress = selectedChildKeys([ + { conclusion: "skipped", name: "Run normal full CI" }, + { conclusion: "skipped", name: "Run plugin prerelease validation" }, + { conclusion: undefined, name: "Run product performance evidence" }, + { conclusion: "skipped", name: "Run release/live/Docker/QA validation" }, + ]); + expect( + expectedSelectedChildDispatches("29090000000", 2, "main", inProgress).map( + (child) => child.manifestKey, + ), + ).toEqual(["productPerformance"]); + }); + + it("authorizes only exact-target reuse through the selected root manifest", () => { + const root = validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + }); + const current = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: [], + evidenceSha: root.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: root.runId, + }, + runId: "29090000001", + targetSha: root.targetSha, + }), + { runAttempt: 2, runId: "29090000001" }, + ); + + expect(validateEvidenceReuseChain(current, root, root)).toBe(root.targetSha); + expect(current.targetSha).toBe(root.targetSha); + }); + + it("rejects changed paths and cross-SHA targets in Full Release reuse", () => { + const root = validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + }); + const changedPaths = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: ["CHANGELOG.md"], + evidenceSha: root.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: root.runId, + }, + runId: "29090000001", + targetSha: root.targetSha, + }), + { runAttempt: 2, runId: "29090000001" }, + ); + expect(() => validateEvidenceReuseChain(changedPaths, root, root)).toThrow( + "requires an exact target with no changed paths", + ); + + const changedTarget = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: [], + evidenceSha: root.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: root.runId, + }, + runId: "29090000001", + targetSha: "b".repeat(40), + }), + { runAttempt: 2, runId: "29090000001" }, + ); + expect(() => validateEvidenceReuseChain(changedTarget, root, root)).toThrow( + "full release evidence reuse target SHA mismatch", + ); + }); + + it("rejects exact-target reuse without matching root policy and authorization", () => { + const root = validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + }); + const current = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: [], + evidenceSha: root.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: root.runId, + }, + runId: "29090000001", + targetSha: root.targetSha, + }), + { runAttempt: 2, runId: "29090000001" }, + ); + const mismatchedRoot = { + ...root, + validationInputs: { ...root.validationInputs, provider: "anthropic" }, + }; + + expect(() => validateEvidenceReuseChain(current, mismatchedRoot, mismatchedRoot)).toThrow( + "evidence reuse current manifest policy differs from the chain root", + ); + expect(() => + validateEvidenceReuseChain({ ...current, evidenceReuse: undefined }, root, root), + ).toThrow("does not authorize evidence reuse"); + }); + + it("rejects any selected manifest that itself reuses evidence", () => { + const root = validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + }); + const intermediate = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: [], + evidenceSha: root.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: root.runId, + }, + runId: "29090000001", + targetSha: root.targetSha, + }), + { runAttempt: 2, runId: "29090000001" }, + ); + const current = validateParentManifest( + rawManifest({ + evidenceReuse: { + changedPaths: [], + evidenceSha: intermediate.targetSha, + policy: "exact-target-full-validation-v1", + runId: root.runId, + selectedRunId: intermediate.runId, + }, + runId: "29090000002", + targetSha: intermediate.targetSha, + }), + { runAttempt: 2, runId: "29090000002" }, + ); + + expect(() => validateEvidenceReuseChain(current, intermediate, root)).toThrow( + "evidence reuse must select a root execution manifest", + ); + }); + + it("binds each manifest workflow ref to the fetched parent branch", () => { + expect(() => + validateParentManifest(rawManifest({}), { + runAttempt: 2, + runId: "29090000000", + workflowRef: "release/2026.7.1", + }), + ).toThrow("release validation manifest workflow ref mismatch"); + }); + + it("validates manifest child workflow, dispatch tuple, branch, and attempt", () => { + const child = expectedChildDispatches("29090000000", 3, "main")[0]; + const parentManifest = { + runAttempt: 3, + runId: "29090000000", + targetSha: "a".repeat(40), + workflowSha: "b".repeat(40), + }; + const parentJobs = [ + { + completed_at: "2026-07-10T01:10:00Z", + conclusion: "success", + id: 901, + name: child.parentJobName, + run_attempt: 3, + started_at: "2026-07-10T01:00:00Z", + status: "completed", + steps: [], + }, + ]; + const parentLog = [ + `TARGET_SHA: ${parentManifest.targetSha}`, + "Dispatched ci.yml: https://github.com/openclaw/openclaw/actions/runs/101", + ].join("\n"); + const run = { + actor: { login: "github-actions[bot]" }, + display_title: child.displayTitle, + event: "workflow_dispatch", + head_branch: child.headBranch, + head_sha: parentManifest.workflowSha, + id: 101, + path: ".github/workflows/ci.yml@refs/heads/main", + run_attempt: 1, + triggering_actor: { login: "github-actions[bot]" }, + }; + expect(validateManifestChildRun(run, child, "101", parentManifest, parentJobs, parentLog)).toBe( + run, + ); + const maintainerRerun = { + ...run, + run_attempt: 2, + triggering_actor: { login: "maintainer" }, + }; + expect( + validateManifestChildRun( + maintainerRerun, + child, + "101", + parentManifest, + parentJobs, + parentLog, + "maintainer", + "openclaw/openclaw", + ), + ).toBe(maintainerRerun); + expect(() => + validateManifestChildRun( + { ...maintainerRerun, triggering_actor: { login: "other-maintainer" } }, + child, + "101", + parentManifest, + parentJobs, + parentLog, + "maintainer", + "openclaw/openclaw", + ), + ).toThrow("manifest child dispatch tuple mismatch"); + expect(() => + validateManifestChildRun( + { ...run, actor: { login: "maintainer" } }, + child, + "101", + parentManifest, + parentJobs, + parentLog, + ), + ).toThrow("manifest child dispatch tuple mismatch"); + expect(() => + validateManifestChildRun( + { ...run, head_branch: "release/2026.7.1" }, + child, + "101", + parentManifest, + parentJobs, + parentLog, + ), + ).toThrow("manifest child dispatch tuple mismatch"); + expect(() => + validateParentManifest(rawManifest({}), { runAttempt: 3, runId: "29090000000" }), + ).toThrow("release validation manifest run attempt mismatch"); + }); + + it("accepts strongly bound legacy and correlated children across parent attempts", () => { + const parentManifest = { + runAttempt: 2, + runId: "28717729503", + targetSha: "a".repeat(40), + workflowSha: "b".repeat(40), + }; + const children = expectedChildDispatches( + parentManifest.runId, + parentManifest.runAttempt, + "main", + ); + const fixtures = new Map([ + ["normalCi", { originAttempt: 2, runId: 28718903263, title: "CI" }], + ["pluginPrerelease", { originAttempt: 1, runId: 28717802268, title: "Plugin Prerelease" }], + [ + "productPerformance", + { + originAttempt: 1, + runId: 28717802171, + title: "OpenClaw Performance full-release-validation-28717729503-1", + }, + ], + ["releaseChecks", { originAttempt: 1, runId: 28717802397, title: "OpenClaw Release Checks" }], + ]); + const fingerprint = { + completed_at: "2026-07-04T20:29:21Z", + conclusion: "success", + started_at: "2026-07-04T19:53:02Z", + status: "completed", + steps: [ + { + completed_at: "2026-07-04T20:29:20Z", + conclusion: "success", + name: "Dispatch and monitor child", + number: 1, + started_at: "2026-07-04T19:53:03Z", + status: "completed", + }, + ], + }; + + for (const child of children.filter((entry) => fixtures.has(entry.manifestKey))) { + const fixture = fixtures.get(child.manifestKey); + if (!fixture) { + throw new Error(`missing fixture for ${child.manifestKey}`); + } + const { originAttempt, runId, title } = fixture; + const parentJobs = [ + ...(originAttempt === 1 + ? [ + { + ...fingerprint, + id: 900, + name: child.parentJobName, + run_attempt: 1, + }, + ] + : []), + { + ...fingerprint, + id: 901, + name: child.parentJobName, + run_attempt: 2, + }, + ]; + const run = { + actor: { login: "github-actions[bot]" }, + display_title: title, + event: "workflow_dispatch", + head_branch: child.headBranch, + head_sha: parentManifest.workflowSha, + id: runId, + path: `.github/workflows/${child.workflow}@refs/heads/${child.headBranch}`, + run_attempt: 1, + triggering_actor: { login: "github-actions[bot]" }, + }; + const parentLog = [ + `TARGET_SHA: ${parentManifest.targetSha}`, + ...(child.manifestKey === "productPerformance" ? ["-f publish_reports=false"] : []), + `Dispatched ${child.workflow}: https://github.com/openclaw/openclaw/actions/runs/${runId}`, + ].join("\n"); + expect(resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs)).toBe( + originAttempt, + ); + expect( + validateManifestChildRun(run, child, String(runId), parentManifest, parentJobs, parentLog), + ).toBe(run); + if (child.manifestKey === "productPerformance") { + expect(() => + validateManifestChildRun( + run, + child, + String(runId), + parentManifest, + parentJobs, + parentLog.replace("-f publish_reports=false\n", ""), + ), + ).toThrow("manifest performance child is not dispatched in artifact-only mode"); + } + } + + const ci = children.find((child) => child.manifestKey === "normalCi"); + if (!ci) { + throw new Error("missing CI child fixture"); + } + const wrongParent = { + display_title: `CI full-release-validation-28717729504-1-ci`, + event: "workflow_dispatch", + head_branch: "main", + id: 101, + path: ".github/workflows/ci.yml@refs/heads/main", + }; + const ciJobs = [ + { + ...fingerprint, + id: 901, + name: ci.parentJobName, + run_attempt: 2, + }, + ]; + const ciLog = [ + `TARGET_SHA: ${parentManifest.targetSha}`, + "Dispatched ci.yml: https://github.com/openclaw/openclaw/actions/runs/101", + ].join("\n"); + expect(() => + validateManifestChildRun(wrongParent, ci, "101", parentManifest, ciJobs, ciLog), + ).toThrow("manifest child dispatch tuple mismatch"); + expect(() => + validateManifestChildRun( + { + ...wrongParent, + display_title: `CI full-release-validation-${parentManifest.runId}-3-ci`, + }, + ci, + "101", + parentManifest, + ciJobs, + ciLog, + ), + ).toThrow("manifest child dispatch tuple mismatch"); + expect( + resolveManifestChildOriginAttempt({ display_title: "CI nearby" }, ci, parentManifest, ciJobs), + ).toBeUndefined(); + }); + + it("rejects carried parent jobs whose selected-attempt execution fingerprint changed", () => { + const child = expectedChildDispatches("28717729503", 2, "main").find( + (entry) => entry.manifestKey === "pluginPrerelease", + ); + if (!child) { + throw new Error("missing plugin prerelease fixture"); + } + const parentManifest = { runAttempt: 2, runId: "28717729503" }; + const parentJobs = [ + { + completed_at: "2026-07-04T20:29:21Z", + conclusion: "success", + id: 900, + name: child.parentJobName, + run_attempt: 1, + started_at: "2026-07-04T19:53:02Z", + status: "completed", + steps: [], + }, + { + completed_at: "2026-07-04T20:30:21Z", + conclusion: "success", + id: 901, + name: child.parentJobName, + run_attempt: 2, + started_at: "2026-07-04T19:53:02Z", + status: "completed", + steps: [], + }, + ]; + + expect(() => selectManifestParentJob(parentJobs, child, parentManifest, 1)).toThrow( + "manifest parent job carry-forward fingerprint mismatch", + ); + }); +}); diff --git a/test/scripts/release-notes-ledger.test.ts b/test/scripts/release-notes-ledger.test.ts new file mode 100644 index 000000000000..c855a7c2a67d --- /dev/null +++ b/test/scripts/release-notes-ledger.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { + contributionRecordFor, + ledgerChecks, + ledgerFor, + renderContributionRecordEntry, +} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs"; + +describe("renderContributionRecordEntry", () => { + it("keeps source and linked issue references without repeating PR titles", () => { + expect( + renderContributionRecordEntry({ + number: 123, + title: "Fix local openclaw/openclaw#45 and openclaw/imsg#141", + linkedIssues: [{ number: 45 }, { number: 67 }], + thanks: ["alice", "bob"], + }), + ).toBe("- **PR #123** Related #45, openclaw/imsg#141, #67. Thanks @alice and @bob."); + }); + + it("deduplicates title references and retains seeded cross-repository references", () => { + expect( + renderContributionRecordEntry({ + number: 124, + title: "Fix #45, #45, and OpenClaw/imsg#141", + externalReferences: ["openclaw/imsg#141"], + priorReferences: [67], + linkedIssues: [{ number: 45 }], + thanks: [], + }), + ).toBe("- **PR #124** Related #45, OpenClaw/imsg#141, #67."); + }); + + it("renders every source PR even without issue references or credits", () => { + expect( + renderContributionRecordEntry({ + number: 456, + title: "Internal cleanup", + linkedIssues: [], + thanks: [], + }), + ).toBe("- **PR #456**"); + }); + + it("retains references and credits when a compact record is seeded again", () => { + const line = "- **PR #125** Related #45, openclaw/imsg#141. Thanks @alice and @bob."; + const record = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + line, + ].join("\n"), + }); + const seeded = record.pullRequests.get(125); + + expect(seeded).toEqual({ + externalReferences: ["openclaw/imsg#141"], + references: [45], + thanks: ["alice", "bob"], + }); + expect( + renderContributionRecordEntry({ + number: 125, + title: "Title changed after release", + priorReferences: seeded?.references, + externalReferences: seeded?.externalReferences, + linkedIssues: [], + thanks: seeded?.thanks ?? [], + }), + ).toBe(line); + }); + + it("retains seeded credits when the production ledger is rebuilt", () => { + const priorRecord = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #125** Thanks @alice and @bob.", + ].join("\n"), + }); + const nodes = new Map([ + [ + 125, + { + __typename: "PullRequest", + author: { __typename: "User", login: "carol" }, + closingIssuesReferences: { nodes: [] }, + mergedAt: "2026-07-08T00:00:00Z", + title: "fix: keep release credits", + }, + ], + ]); + + const result = ledgerFor( + "v2026.6.11", + "HEAD", + [125], + nodes, + new Map(), + new Map(), + { issuesByPullRequest: new Map() }, + priorRecord, + new Set([125]), + new Set(), + new Set(), + new Set(), + new Set(), + [], + Date.parse("2026-07-09T00:00:00Z"), + ); + + expect(result.ledger).toContain("- **PR #125** Thanks @carol and @alice and @bob."); + }); + + it("retains references from a verbose record when the source title changes", () => { + const record = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #126** Fix #46 and openclaw/imsg#142. Related #68. Thanks @alice.", + ].join("\n"), + }); + const seeded = record.pullRequests.get(126); + + expect(seeded).toEqual({ + externalReferences: ["openclaw/imsg#142"], + references: [46, 68], + thanks: ["alice"], + }); + }); + + it("requires complete reference tokens rather than matching substrings", () => { + const source = [ + "## 2026.7.1", + "", + "### Highlights", + "", + "- Highlight one.", + "- Highlight two.", + "- Highlight three.", + "- Highlight four.", + "- Highlight five.", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #456** Related openclaw/imsg#141.", + ].join("\n"); + const entry = { + number: 456, + title: "Internal cleanup", + editorialEligible: false, + priorReferences: [45, 141], + externalReferences: [], + linkedIssues: [], + thanks: [], + }; + + expect( + ledgerChecks({ source }, [entry], new Map([[456, { __typename: "PullRequest" }]]), []), + ).toEqual([ + "missing #45 on contribution record for PR #456", + "missing #141 on contribution record for PR #456", + ]); + }); + + it("accepts case-only differences in cross-repository references", () => { + const line = "- **PR #127** Related OpenClaw/imsg#143."; + const source = [ + "## 2026.7.1", + "", + "### Highlights", + "", + "- Highlight one.", + "- Highlight two.", + "- Highlight three.", + "- Highlight four.", + "- Highlight five.", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + line, + ].join("\n"); + const entry = { + number: 127, + title: "Internal cleanup", + editorialEligible: false, + priorReferences: [], + externalReferences: ["openclaw/imsg#143"], + linkedIssues: [], + thanks: [], + }; + + expect( + ledgerChecks({ source }, [entry], new Map([[127, { __typename: "PullRequest" }]]), []), + ).toEqual([]); + }); +}); diff --git a/test/scripts/release-wrapper-scripts.test.ts b/test/scripts/release-wrapper-scripts.test.ts new file mode 100644 index 000000000000..dab69d66638f --- /dev/null +++ b/test/scripts/release-wrapper-scripts.test.ts @@ -0,0 +1,140 @@ +// Release wrapper script tests keep changed-target routing tied to scripts that load the wrappers. +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const UNKNOWN_PACKAGE = "@openclaw/not-a-real-release-wrapper-test-package"; +const tempDirs: string[] = []; +const tsxImport = import.meta.resolve("tsx"); + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function runTsxScript(scriptPath: string, args: string[], cwd = process.cwd()) { + return spawnSync(process.execPath, ["--import", tsxImport, scriptPath, ...args], { + cwd, + encoding: "utf8", + }); +} + +function createOldReleaseTarget() { + const root = mkdtempSync(join(tmpdir(), "openclaw-old-release-target-")); + tempDirs.push(root); + mkdirSync(join(root, "extensions"), { recursive: true }); + mkdirSync(join(root, "scripts"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ name: "openclaw", version: "2026.7.1-beta.3" }, null, 2)}\n`, + ); + writeFileSync( + join(root, "scripts", "openclaw-release-clawhub-plan.ts"), + 'throw new Error("old target planner invoked");\n', + ); + writeFileSync( + join(root, "scripts", "release-verify-beta.ts"), + 'throw new Error("old target verifier invoked");\n', + ); + return root; +} + +describe("release wrapper scripts", () => { + it("runs plugin release wrapper CLIs and rejects unknown explicit selections", () => { + for (const scriptPath of [ + "scripts/plugin-npm-release-plan.ts", + "scripts/plugin-npm-release-check.ts", + "scripts/plugin-clawhub-release-plan.ts", + "scripts/plugin-clawhub-release-check.ts", + ]) { + const result = runTsxScript(scriptPath, ["--plugins", UNKNOWN_PACKAGE]); + + expect(result.status, scriptPath).toBe(1); + expect(result.stderr, scriptPath).toContain( + `Unknown or non-publishable plugin package selection: ${UNKNOWN_PACKAGE}.`, + ); + expect(result.stdout, scriptPath).toBe(""); + } + }); + + it("loads the OpenClaw ClawHub plan CLI and validates required arguments before planning", () => { + const result = runTsxScript("scripts/openclaw-release-clawhub-plan.ts", [ + "--bootstrap-workflow-sha", + "b".repeat(40), + "--release-tag", + "v2026.6.21-beta.1", + "--release-sha", + "a".repeat(40), + "--release-publish-run-attempt", + "1", + "--release-publish-run-id", + "123", + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("--release-publish-branch is required."); + expect(result.stdout).toBe(""); + }); + + it("runs trusted harness planning against an explicit old release target root", () => { + const oldTarget = createOldReleaseTarget(); + const repositoryRoot = process.cwd(); + const releaseSha = "a".repeat(40); + const plan = runTsxScript(join(repositoryRoot, "scripts/openclaw-release-clawhub-plan.ts"), [ + "--bootstrap-workflow-sha", + "b".repeat(40), + "--release-tag", + "v2026.7.1-beta.3", + "--release-sha", + releaseSha, + "--release-publish-branch", + "main", + "--release-publish-run-attempt", + "1", + "--release-publish-run-id", + "123", + "--plugin-publish-scope", + "all-publishable", + "--root-dir", + oldTarget, + ]); + expect(plan.status).toBe(0); + expect(JSON.parse(plan.stdout)).toMatchObject({ + bootstrapWorkflowSha: "b".repeat(40), + bootstrap: { ref: "main", shouldDispatch: false }, + normal: { ref: "v2026.7.1-beta.3", shouldDispatch: false }, + }); + expect(plan.stderr).not.toContain("old target planner invoked"); + + const verify = runTsxScript( + join(repositoryRoot, "scripts/release-verify-beta.ts"), + [ + "2026.7.1-beta.4", + "--release-sha", + releaseSha, + "--clawhub-bootstrap-plugins", + "@openclaw/meta", + "--plugin-clawhub-bootstrap-run", + "34", + ], + oldTarget, + ); + expect(verify.status).toBe(1); + expect(verify.stderr).toContain( + "package.json version is 2026.7.1-beta.3; expected 2026.7.1-beta.4.", + ); + expect(verify.stderr).not.toContain("old target verifier invoked"); + expect(verify.stderr).not.toContain("Unknown argument"); + }); + + it("loads the beta verifier CLI and validates required version input before remote checks", () => { + const result = runTsxScript("scripts/release-verify-beta.ts", ["--skip-clawhub"]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage: pnpm release:verify-beta -- "); + expect(result.stdout).toBe(""); + }); +}); diff --git a/test/scripts/render-github-release-notes.test.ts b/test/scripts/render-github-release-notes.test.ts new file mode 100644 index 000000000000..f1575e2beb37 --- /dev/null +++ b/test/scripts/render-github-release-notes.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "vitest"; +import { + GITHUB_RELEASE_BODY_MAX_BYTES, + GITHUB_RELEASE_BODY_MAX_CHARACTERS, + extractChangelogSection, + formatShippedBaselineExclusions, + parseShippedBaselineExclusions, + releaseNotesVersionForTag, + renderGithubReleaseNotes, + verifyGithubReleaseNotes, +} from "../../scripts/render-github-release-notes.mjs"; + +const repository = "openclaw/openclaw"; +const tag = "v2026.7.1-beta.3"; +const version = "2026.7.1"; + +function changelogFor(record: string): string { + return [ + "# Changelog", + "", + `## ${version}`, + "", + "### Highlights", + "", + "- A grouped user-facing highlight.", + "", + "### Fixes", + "", + "- A grouped user-facing fix.", + "", + "### Complete contribution record", + "", + record, + "", + "## 2026.6.11", + "", + "- Previous release.", + "", + ].join("\n"); +} + +describe("GitHub release-note rendering", () => { + it("emits the complete matching section including its version heading when it fits", () => { + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("full"); + expect(rendered.body).toBe( + [ + `## ${version}`, + "", + "### Highlights", + "", + "- A grouped user-facing highlight.", + "", + "### Fixes", + "", + "- A grouped user-facing fix.", + "", + "### Complete contribution record", + "", + "- **PR #123** fix: example. Thanks @contributor.", + ].join("\n"), + ); + }); + + it("replaces an oversized contribution record with a tag-pinned link", () => { + const oversizedRecord = `- **PR #123** ${"record-only-detail ".repeat(9_000)}`; + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor(oversizedRecord), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.body).toContain(`## ${version}\n\n### Highlights`); + expect(rendered.body).toContain("- A grouped user-facing fix."); + expect(rendered.body).toContain("### Complete contribution record"); + expect(rendered.body).toContain( + "https://github.com/openclaw/openclaw/blob/v2026.7.1-beta.3/CHANGELOG.md#complete-contribution-record", + ); + expect(rendered.body).not.toContain("record-only-detail"); + expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + }); + + it("keeps a fitting full section and omits only a proof tail that would overflow", () => { + const nearlyFullRecord = `- **PR #123** ${"x".repeat(124_500)}`; + const changelog = changelogFor(nearlyFullRecord); + const withoutProof = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }); + const withProof = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification: `### Release verification\n\n- proof: ${"y".repeat(1_000)}`, + }); + + expect(withoutProof.mode).toBe("full"); + expect(withProof.mode).toBe("full"); + expect(withProof.verificationIncluded).toBe(false); + expect(withProof.verificationOmitted).toBe(true); + expect(withProof.body).toBe(withoutProof.body); + expect(withProof.body).not.toContain("### Release verification"); + expect(withProof.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + expect(withProof.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + }); + + it("uses the full form at exactly 125,000 bytes and compacts at 125,001", () => { + const seed = renderGithubReleaseNotes({ + changelog: changelogFor("x"), + version, + tag, + repository, + }); + const exactRecordLength = + GITHUB_RELEASE_BODY_MAX_BYTES - seed.size.bytes + Buffer.byteLength("x"); + const exact = renderGithubReleaseNotes({ + changelog: changelogFor("x".repeat(exactRecordLength)), + version, + tag, + repository, + }); + const over = renderGithubReleaseNotes({ + changelog: changelogFor("x".repeat(exactRecordLength + 1)), + version, + tag, + repository, + }); + + expect(exact.mode).toBe("full"); + expect(exact.size).toEqual({ + characters: GITHUB_RELEASE_BODY_MAX_CHARACTERS, + bytes: GITHUB_RELEASE_BODY_MAX_BYTES, + }); + expect(over.mode).toBe("compact"); + }); + + it("compacts when multibyte text exceeds the byte limit before the character limit", () => { + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("é".repeat(63_000)), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + }); + + it("normalizes correction tags to the stable changelog section", () => { + expect(releaseNotesVersionForTag("v2026.7.1-2")).toBe("2026.7.1"); + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: correction."), + version, + tag: "v2026.7.1-2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1"); + }); + + it("prefers a correction tag's dedicated changelog section when one exists", () => { + const changelog = [ + "# Changelog", + "", + "## 2026.7.1-2", + "", + "- Correction-only fix.", + "", + `## ${version}`, + "", + "- Stable release notes.", + "", + ].join("\n"); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1-2"); + expect(rendered.body).toContain("Correction-only fix."); + expect(rendered.body).not.toContain("Stable release notes."); + }); + + it("round-trips canonical shipped baseline exclusions and rejects malformed metadata", () => { + const line = formatShippedBaselineExclusions([ + { ref: "v2026.6.11", count: 2, pullRequests: [108, 101] }, + { ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] }, + ]); + + expect(line).toBe( + "Shipped baseline exclusions: v2026.6.10-beta.2 (0 PRs); v2026.6.11 (2 PRs: #101, #108).", + ); + expect(parseShippedBaselineExclusions(line)).toEqual([ + { ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] }, + { ref: "v2026.6.11", count: 2, pullRequests: [101, 108] }, + ]); + expect(() => + parseShippedBaselineExclusions("Shipped baseline exclusion: v2026.6.11 (8 PRs)."), + ).toThrow("malformed shipped baseline exclusion"); + expect(() => + parseShippedBaselineExclusions("Shipped baseline exclusions: v2026.6.11 (2 PRs: #101)."), + ).toThrow("invalid shipped baseline exclusion count"); + }); + + it("rejects tag/version drift and legacy oversized ledger anchors", () => { + expect(() => + renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: example."), + version, + tag: "v2026.7.2-beta.1", + repository, + }), + ).toThrow("requires CHANGELOG.md version 2026.7.2"); + + expect(() => + renderGithubReleaseNotes({ + changelog: changelogFor( + `### Complete contribution ledger\n\n${"legacy ".repeat(20_000)}`, + ).replace("### Complete contribution record\n\n", ""), + version, + tag, + repository, + }), + ).toThrow("cannot be compacted without a complete contribution record"); + }); + + it("prefers an alpha tag's exact changelog heading over the Unreleased fallback", () => { + // Shipped alpha tags carry their own heading with no base section, + // matching the tagged CHANGELOG.md shape of v2026.6.20-alpha.1. + const changelog = [ + "# Changelog", + "", + "## 2026.7.1-alpha.2", + "", + "- Alpha-only fix.", + "", + "## 2026.6.11", + "", + "- Previous release.", + "", + ].join("\n"); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-alpha.2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1-alpha.2"); + expect(rendered.body).toContain("Alpha-only fix."); + expect(rendered.body).not.toContain("Previous release."); + }); + + it("permits the Unreleased fallback only for alpha tags", () => { + const changelog = changelogFor("- **PR #123** fix: example.").replace( + "## 2026.7.1", + "## Unreleased", + ); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-alpha.1", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1"); + expect(rendered.body).not.toContain("## Unreleased"); + expect(() => + renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }), + ).toThrow("CHANGELOG.md does not contain ## 2026.7.1"); + expect(() => + renderGithubReleaseNotes({ + changelog, + version: "foo", + tag: "vfoo-alpha.1", + repository, + }), + ).toThrow("invalid release tag"); + }); + + it("ignores fenced pseudo-headings and handles a release heading at EOF", () => { + const fenced = [ + `## ${version}`, + "", + "```md", + "## 2099.1.1", + "```", + "", + "### Fixes", + "", + "- Still in the current release.", + "", + "## 2026.6.11", + ].join("\n"); + + expect(extractChangelogSection(fenced, version)).toContain("- Still in the current release."); + expect(extractChangelogSection(`## ${version}`, version)).toBe(`## ${version}`); + }); + + it("compacts at the real contribution record instead of a fenced pseudo-heading", () => { + const changelog = changelogFor(`- **PR #123** ${"record-only-detail ".repeat(9_000)}`).replace( + "### Fixes", + ["```md", "### Complete contribution record", "```", "", "### Fixes"].join("\n"), + ); + const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.body).toContain("```md\n### Complete contribution record\n```"); + expect(rendered.body).toContain("- A grouped user-facing fix."); + expect(rendered.body).not.toContain("record-only-detail"); + }); + + it("verifies the exact generated compact body and optional proof tail", () => { + const changelog = changelogFor(`- **PR #123** ${"z".repeat(130_000)}`); + const verification = "### Release verification\n\n- release SHA: `abc123`"; + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification, + }); + + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag, + repository, + }), + ).toMatchObject({ matches: true, mode: "compact" }); + expect( + verifyGithubReleaseNotes({ + body: rendered.body.replace("tag-pinned", "mutable"), + changelog, + version, + tag, + repository, + }).matches, + ).toBe(false); + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag: "v2026.7.1-beta.2", + repository, + }).matches, + ).toBe(false); + }); + + it("does not treat fenced verification headings as appended proof", () => { + const changelog = changelogFor( + [ + "```md", + "### Release verification", + "", + "- Example only.", + "```", + "", + "- **PR #123** fix: example.", + ].join("\n"), + ); + const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository }); + + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag, + repository, + }), + ).toMatchObject({ matches: true, verificationIncluded: false }); + }); +}); diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index a688dd28066f..3e7efc6704be 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -103,6 +103,7 @@ describe("resolve-openclaw-package-candidate", () => { for (const spec of [ "openclaw@beta", "openclaw@alpha", + "openclaw@extended-stable", "openclaw@latest", "openclaw@2026.4.27", "openclaw@2026.4.27-1", diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 5e40fff9e020..29252697cc93 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -494,7 +494,10 @@ describe("bun global install smoke", () => { const script = readFileSync(BUN_GLOBAL_SMOKE_PATH, "utf8"); const assertions = readFileSync(BUN_GLOBAL_ASSERTIONS_PATH, "utf8"); - expect(script).toContain("npm pack --ignore-scripts --json --pack-destination"); + expect(script).toContain("node scripts/package-openclaw-for-docker.mjs"); + expect(script).toContain("--skip-build"); + expect(script).toContain("--output-name openclaw-current.tgz"); + expect(script).not.toContain("npm pack --ignore-scripts --json --pack-destination"); expect(script).toContain('"$bun_path" install -g "$PACKAGE_TGZ" --no-progress'); expect(script).toContain("infer image providers --json"); expect(script).toContain("assert-image-providers"); @@ -508,6 +511,8 @@ describe("bun global install smoke", () => { expect(script).toContain( 'docker_e2e_docker_cmd cp "${container_id}:/app/dist" "$temp_dir/dist"', ); + expect(script).not.toContain("@openclaw/ai"); + expect(script).not.toContain("packages/ai"); expect(script).toContain("cleanup_restore_dist() {"); expect(script).toContain('mv "$ROOT_DIR/dist" "$backup_dir"'); expect(script).toContain('mv "$temp_dir/dist" "$ROOT_DIR/dist"'); @@ -522,6 +527,15 @@ describe("bun global install smoke", () => { expect(script).not.toContain('\n rm -rf "$ROOT_DIR/dist"\n'); }); + it("uses the canonical package builder for bundled workspace dependencies", () => { + const script = readFileSync(BUN_GLOBAL_SMOKE_PATH, "utf8"); + + expect(script).toContain('PACK_DIR="$(mktemp -d'); + expect(script).toContain("node scripts/package-openclaw-for-docker.mjs"); + expect(script).toContain('--output-dir "$PACK_DIR"'); + expect(script).toContain("--output-name openclaw-current.tgz"); + }); + it("gates workflow Bun install smoke to scheduled and release-check runs", () => { const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8"); const releaseChecks = readFileSync(RELEASE_CHECKS_WORKFLOW_PATH, "utf8"); diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts new file mode 100644 index 000000000000..534543f6b32e --- /dev/null +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -0,0 +1,312 @@ +// Full release validation evidence tests cover producer and candidate binding. +import { describe, expect, it, vi } from "vitest"; +import { + isShaPinnedReleaseValidationBranch, + normalizeFullReleaseValidationRun, + validateFullReleaseValidationEvidence, +} from "../../scripts/validate-full-release-validation-evidence.mjs"; + +const targetSha = "b".repeat(40); +const workflowSha = "a".repeat(40); +const pinnedBranch = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; + +function releaseRun(overrides: Record = {}) { + return { + id: 123, + run_attempt: 2, + name: "Full Release Validation", + path: ".github/workflows/full-release-validation.yml", + repository: { full_name: "openclaw/openclaw" }, + head_branch: pinnedBranch, + head_sha: workflowSha, + event: "workflow_dispatch", + status: "completed", + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/123", + ...overrides, + }; +} + +function releaseManifest(overrides: Record = {}) { + return { + version: 3, + workflowName: "Full Release Validation", + runId: "123", + runAttempt: "2", + workflowRef: pinnedBranch, + workflowSha, + workflowFullRef: `refs/heads/${pinnedBranch}`, + workflowRefType: "branch", + targetRef: targetSha, + targetSha, + controls: { performanceBlocking: true }, + ...overrides, + }; +} + +function exactTargetEvidenceReuse() { + return { + changedPaths: [], + evidenceSha: targetSha, + policy: "exact-target-full-validation-v1", + runId: "122", + selectedRunId: "122", + }; +} + +function strictEvidenceReuse() { + return { + schema: "openclaw.release-validation-evidence/v3", + valid: true, + current: { runId: "123", targetSha }, + root: { runId: "122", targetSha }, + evidenceReuse: { + evidenceSha: targetSha, + rootRunId: "122", + selectedRunId: "122", + }, + conclusions: { allRequiredSucceeded: true }, + }; +} + +function validate( + runOverrides: Record = {}, + manifestOverrides: Record = {}, + trusted = true, +) { + const isTrustedMainAncestor = vi.fn(() => trusted); + const result = validateFullReleaseValidationEvidence({ + run: releaseRun(runOverrides), + manifest: releaseManifest(manifestOverrides), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: "release/2026.7.1", + expectedWorkflowSha: workflowSha, + isTrustedMainAncestor, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), + }); + return { isTrustedMainAncestor, result }; +} + +describe("full release validation evidence", () => { + it("rejects manifests without blocking performance evidence", () => { + expect(() => validate({}, { controls: { performanceBlocking: false } })).toThrow( + "does not record blocking product performance evidence", + ); + }); + + it("normalizes REST and gh run metadata", () => { + const normalized = normalizeFullReleaseValidationRun(releaseRun()); + + expect(normalized).toMatchObject({ + databaseId: "123", + runAttempt: 2, + workflowName: "Full Release Validation", + workflowPath: ".github/workflows/full-release-validation.yml", + repository: "openclaw/openclaw", + headBranch: pinnedBranch, + headSha: workflowSha, + }); + expect( + normalizeFullReleaseValidationRun({ + databaseId: 123, + attempt: 2, + workflowName: "Full Release Validation", + workflowPath: ".github/workflows/full-release-validation.yml@refs/heads/main", + }), + ).toMatchObject({ + databaseId: "123", + runAttempt: 2, + workflowPath: ".github/workflows/full-release-validation.yml", + workflowQualifiedRef: "refs/heads/main", + }); + }); + + it("accepts canonical SHA-pinned evidence bound to current main", () => { + const { isTrustedMainAncestor, result } = validate(); + + expect(result.source).toBe("sha-pinned-main"); + expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha); + expect(isShaPinnedReleaseValidationBranch(pinnedBranch)).toBe(true); + }); + + it.each([pinnedBranch, `refs/heads/${pinnedBranch}`])( + "accepts a REST workflow path qualified with %s", + (qualifiedRef) => { + const { result } = validate({ + path: `.github/workflows/full-release-validation.yml@${qualifiedRef}`, + }); + + expect(result.source).toBe("sha-pinned-main"); + }, + ); + + it.each(["main", "release/2026.7.1"])("keeps direct %s evidence valid", (branch) => { + const { isTrustedMainAncestor, result } = validate( + { head_branch: branch }, + { + workflowRef: branch, + workflowFullRef: `refs/heads/${branch}`, + targetRef: "v2026.7.1-beta.3", + }, + ); + + expect(result.source).toBe("direct"); + if (branch === "main") { + expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha); + } else { + expect(isTrustedMainAncestor).not.toHaveBeenCalled(); + } + }); + + it("rejects a direct release workflow branch at a different commit", () => { + expect(() => + validate( + { head_branch: "release/2026.7.1", head_sha: "c".repeat(40) }, + { + workflowRef: "release/2026.7.1", + workflowSha: "c".repeat(40), + workflowFullRef: "refs/heads/release/2026.7.1", + targetRef: "v2026.7.1-beta.3", + }, + ), + ).toThrow("does not match trusted"); + }); + + it("rejects direct main evidence outside current main", () => { + expect(() => + validate( + { head_branch: "main" }, + { + workflowRef: "main", + workflowFullRef: "refs/heads/main", + targetRef: "v2026.7.1-beta.3", + }, + false, + ), + ).toThrow("not reachable from current main"); + }); + + it.each([ + ["repository", { repository: { full_name: "attacker/openclaw" } }, {}, "repository"], + ["workflow path", { path: ".github/workflows/other.yml" }, {}, "workflowPath"], + [ + "qualified workflow ref", + { path: ".github/workflows/full-release-validation.yml@refs/heads/other" }, + {}, + "workflow path ref", + ], + ["run id", { id: 124 }, {}, "databaseId"], + ["manifest run id", {}, { runId: "124" }, "runId"], + ["run attempt", {}, { runAttempt: "1" }, "runAttempt"], + ["workflow ref", {}, { workflowRef: "main" }, "workflowRef"], + ["workflow SHA", {}, { workflowSha: "c".repeat(40) }, "workflowSha"], + ["workflow full ref", {}, { workflowFullRef: "refs/heads/main" }, "workflowFullRef"], + ["target SHA", {}, { targetSha: "c".repeat(40) }, "targetSha"], + ["target ref", {}, { targetRef: "v2026.7.1-beta.3" }, "target ref"], + ["manifest version", {}, { version: 2 }, "version 3"], + ])("rejects mismatched %s", (_name, runOverrides, manifestOverrides, message) => { + expect(() => validate(runOverrides, manifestOverrides)).toThrow(message); + }); + + it("rejects a forged SHA-pinned branch prefix", () => { + const branch = `release-ci/${"c".repeat(12)}-1783705000000`; + expect(() => + validate( + { head_branch: branch }, + { workflowRef: branch, workflowFullRef: `refs/heads/${branch}` }, + ), + ).toThrow("does not match workflow SHA"); + }); + + it("rejects SHA-pinned workflow commits outside current main", () => { + expect(() => validate({}, {}, false)).toThrow("not reachable from current main"); + }); + + it("accepts exact-target evidence reuse on the SHA-pinned path", () => { + expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe( + "sha-pinned-main", + ); + }); + + it("requires strict root and child validation for reused evidence", () => { + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest({ evidenceReuse: exactTargetEvidenceReuse() }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: "release/2026.7.1", + isTrustedMainAncestor: () => true, + }), + ).toThrow("requires strict chain validation"); + + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest({ evidenceReuse: exactTargetEvidenceReuse() }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: "release/2026.7.1", + isTrustedMainAncestor: () => true, + validateEvidenceReuseStrictly: () => ({ + ...strictEvidenceReuse(), + conclusions: { allRequiredSucceeded: false }, + }), + }), + ).toThrow("failed strict chain validation"); + }); + + it("rejects malformed evidence reuse on the SHA-pinned path", () => { + expect(() => + validate( + {}, + { evidenceReuse: { ...exactTargetEvidenceReuse(), changedPaths: ["src/a.ts"] } }, + ), + ).toThrow("evidence reuse is invalid"); + expect(() => + validate( + {}, + { evidenceReuse: { ...exactTargetEvidenceReuse(), evidenceSha: "c".repeat(40) } }, + ), + ).toThrow("evidence reuse is invalid"); + }); + + it("keeps a pinned-shaped expected branch on the pinned trust path", () => { + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest({ + evidenceReuse: { ...exactTargetEvidenceReuse(), selectedRunId: "" }, + }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: pinnedBranch, + isTrustedMainAncestor: () => true, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), + }), + ).toThrow("evidence reuse is invalid"); + }); + + it("does not treat a malformed release-ci expected branch as direct", () => { + const branch = "release-ci/not-canonical"; + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun({ head_branch: branch }), + manifest: releaseManifest({ + workflowRef: branch, + workflowFullRef: `refs/heads/${branch}`, + }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: branch, + isTrustedMainAncestor: () => true, + }), + ).toThrow("untrusted head branch"); + }); +}); diff --git a/test/scripts/validate-release-publish-approval.test.ts b/test/scripts/validate-release-publish-approval.test.ts new file mode 100644 index 000000000000..ef4f60da8b53 --- /dev/null +++ b/test/scripts/validate-release-publish-approval.test.ts @@ -0,0 +1,297 @@ +// Validate release publish approval tests cover the stdin/env CLI contract. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const SCRIPT_PATH = "scripts/validate-release-publish-approval.mjs"; +const tempRoots = useAutoCleanupTempDirTracker(afterEach); + +function runApprovalScript( + run: Record, + env: { + CHILD_WORKFLOW_SHA?: string; + DIRECT_RELEASE_RECOVERY?: string; + EXPECTED_WORKFLOW_BRANCH?: string; + EXPECTED_RUN_ATTEMPT?: string; + APPROVAL_PATH?: string; + GITHUB_REPOSITORY?: string; + RELEASE_APPROVAL_KIND?: string; + RELEASE_PACKAGES?: string; + RELEASE_TAG?: string; + RELEASE_PUBLISH_RUN_ID?: string; + RELEASE_TARGET_SHA?: string; + } = {}, +) { + return spawnSync(process.execPath, [SCRIPT_PATH], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + CHILD_WORKFLOW_SHA: env.CHILD_WORKFLOW_SHA ?? "b".repeat(40), + DIRECT_RELEASE_RECOVERY: env.DIRECT_RELEASE_RECOVERY ?? "false", + EXPECTED_WORKFLOW_BRANCH: env.EXPECTED_WORKFLOW_BRANCH ?? "release/2026.6.21", + EXPECTED_RUN_ATTEMPT: env.EXPECTED_RUN_ATTEMPT ?? "", + APPROVAL_PATH: env.APPROVAL_PATH ?? "", + GITHUB_REPOSITORY: env.GITHUB_REPOSITORY ?? "openclaw/openclaw", + RELEASE_APPROVAL_KIND: env.RELEASE_APPROVAL_KIND ?? "android", + RELEASE_PACKAGES: env.RELEASE_PACKAGES ?? "", + RELEASE_TAG: env.RELEASE_TAG ?? "v2026.6.21", + RELEASE_PUBLISH_RUN_ID: env.RELEASE_PUBLISH_RUN_ID ?? "123", + RELEASE_TARGET_SHA: env.RELEASE_TARGET_SHA ?? "a".repeat(40), + }, + input: JSON.stringify(run), + }); +} + +function writeApproval(overrides: Record = {}) { + const tempRoot = tempRoots.make("openclaw-release-approval-"); + const approvalPath = path.join(tempRoot, "approval.json"); + fs.writeFileSync( + approvalPath, + `${JSON.stringify({ + version: 1, + repository: "openclaw/openclaw", + workflow: "OpenClaw Release Publish", + parentRunId: "123", + workflowBranch: "release/2026.6.21", + releaseTag: "v2026.6.21", + targetSha: "a".repeat(40), + ...overrides, + })}\n`, + ); + return approvalPath; +} + +function approvalRun(overrides: Record = {}) { + return { + conclusion: null, + event: "workflow_dispatch", + headBranch: "release/2026.6.21", + status: "in_progress", + url: "https://github.com/openclaw/openclaw/actions/runs/123", + workflowName: "OpenClaw Release Publish", + ...overrides, + }; +} + +function writeClawHubApproval(overrides: Record = {}) { + const tempRoot = tempRoots.make("openclaw-clawhub-bootstrap-approval-"); + const approvalPath = path.join(tempRoot, "approval.json"); + fs.writeFileSync( + approvalPath, + `${JSON.stringify({ + version: 2, + kind: "clawhub-bootstrap", + repository: "openclaw/openclaw", + workflow: "OpenClaw Release Publish", + parentRunId: "123", + parentRunAttempt: 2, + workflowBranch: "main", + parentWorkflowSha: "d".repeat(40), + bootstrapWorkflowSha: "b".repeat(40), + releaseTag: "v2026.7.1-beta.3", + targetSha: "a".repeat(40), + packages: ["@openclaw/meta-provider", "@openclaw/voice-call"], + ...overrides, + })}\n`, + ); + return approvalPath; +} + +describe("scripts/validate-release-publish-approval.mjs", () => { + it("accepts an in-progress release publish workflow run for approval", () => { + const result = runApprovalScript(approvalRun()); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Using release publish approval run 123: https://github.com/openclaw/openclaw/actions/runs/123", + ); + expect(result.stderr).toBe(""); + }); + + it("rejects approval runs from the wrong workflow branch", () => { + const result = runApprovalScript(approvalRun({ headBranch: "main" })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Referenced release publish run 123 must have headBranch=release/2026.6.21, got main.", + ); + expect(result.stdout).toBe(""); + }); + + it("rejects completed runs for normal approval handoff", () => { + const result = runApprovalScript(approvalRun({ conclusion: "success", status: "completed" })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Referenced release publish run 123 must still be in_progress, got completed.", + ); + expect(result.stdout).toBe(""); + }); + + it("accepts an exact attested Android release approval", () => { + const approvalPath = writeApproval(); + + const result = runApprovalScript(approvalRun(), { APPROVAL_PATH: approvalPath }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("accepts an exact attested ClawHub bootstrap parent tuple", () => { + const approvalPath = writeClawHubApproval(); + const result = runApprovalScript( + approvalRun({ + headBranch: "main", + headSha: "d".repeat(40), + runAttempt: 2, + }), + { + APPROVAL_PATH: approvalPath, + EXPECTED_WORKFLOW_BRANCH: "main", + EXPECTED_RUN_ATTEMPT: "2", + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + RELEASE_PACKAGES: "@openclaw/voice-call,@openclaw/meta-provider", + RELEASE_TAG: "v2026.7.1-beta.3", + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("accepts a child workflow SHA that differs from the approving parent tooling", () => { + const approvalPath = writeClawHubApproval(); + const result = runApprovalScript( + approvalRun({ + headBranch: "main", + headSha: "d".repeat(40), + runAttempt: 2, + }), + { + APPROVAL_PATH: approvalPath, + EXPECTED_WORKFLOW_BRANCH: "main", + EXPECTED_RUN_ATTEMPT: "2", + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call", + RELEASE_TAG: "v2026.7.1-beta.3", + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("rejects a child workflow SHA that differs from the attested bootstrap tooling", () => { + const approvalPath = writeClawHubApproval(); + const result = runApprovalScript( + approvalRun({ + headBranch: "main", + headSha: "d".repeat(40), + runAttempt: 2, + }), + { + APPROVAL_PATH: approvalPath, + CHILD_WORKFLOW_SHA: "c".repeat(40), + EXPECTED_WORKFLOW_BRANCH: "main", + EXPECTED_RUN_ATTEMPT: "2", + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call", + RELEASE_TAG: "v2026.7.1-beta.3", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Attested ClawHub bootstrap approval does not match this release target and package set.", + ); + }); + + it("rejects a ClawHub bootstrap handoff without an attested approval artifact", () => { + const result = runApprovalScript( + approvalRun({ + headBranch: "main", + headSha: "d".repeat(40), + runAttempt: 2, + }), + { + EXPECTED_WORKFLOW_BRANCH: "main", + EXPECTED_RUN_ATTEMPT: "2", + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call", + RELEASE_TAG: "v2026.7.1-beta.3", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "ClawHub bootstrap approval requires an attested approval artifact.", + ); + }); + + it.each([ + ["release tag", { releaseTag: "v2026.7.1-beta.2" }, {}], + ["target SHA", { targetSha: "c".repeat(40) }, {}], + ["package set", { packages: ["@openclaw/meta-provider"] }, {}], + ["parent attempt", { parentRunAttempt: 1 }, {}], + ["parent workflow SHA", { parentWorkflowSha: "c".repeat(40) }, {}], + ["bootstrap workflow SHA", { bootstrapWorkflowSha: "c".repeat(40) }, {}], + ["extra field", { unexpected: true }, {}], + ["requested attempt", {}, { EXPECTED_RUN_ATTEMPT: "3" }], + ])("rejects a ClawHub bootstrap approval for another %s", (_name, overrides, envOverrides) => { + const approvalPath = writeClawHubApproval(overrides); + const result = runApprovalScript( + approvalRun({ + headBranch: "main", + headSha: "d".repeat(40), + runAttempt: 2, + }), + { + APPROVAL_PATH: approvalPath, + EXPECTED_WORKFLOW_BRANCH: "main", + EXPECTED_RUN_ATTEMPT: "2", + RELEASE_APPROVAL_KIND: "clawhub-bootstrap", + RELEASE_PACKAGES: "@openclaw/meta-provider,@openclaw/voice-call", + RELEASE_TAG: "v2026.7.1-beta.3", + ...envOverrides, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toMatch( + /Attested ClawHub bootstrap approval does not match|must use attempt/u, + ); + }); + + it.each([ + ["parent run", { parentRunId: "999" }], + ["release tag", { releaseTag: "v2026.6.22" }], + ["target SHA", { targetSha: "b".repeat(40) }], + ["extra field", { unexpected: true }], + ])("rejects an attested Android approval for another %s", (_name, overrides) => { + const approvalPath = writeApproval(overrides); + + const result = runApprovalScript(approvalRun(), { APPROVAL_PATH: approvalPath }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Attested Android release approval does not match this run request.", + ); + }); + + it("accepts completed success or failure runs for direct recovery", () => { + for (const conclusion of ["success", "failure"]) { + const result = runApprovalScript(approvalRun({ conclusion, status: "completed" }), { + DIRECT_RELEASE_RECOVERY: "true", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + `Using completed release publish run 123 (${conclusion}) for direct recovery: https://github.com/openclaw/openclaw/actions/runs/123`, + ); + expect(result.stderr).toBe(""); + } + }); +}); diff --git a/test/scripts/verify-clawhub-published-artifact.test.ts b/test/scripts/verify-clawhub-published-artifact.test.ts new file mode 100644 index 000000000000..c50e568c8b48 --- /dev/null +++ b/test/scripts/verify-clawhub-published-artifact.test.ts @@ -0,0 +1,666 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + verifyPublishedClawHubArtifacts, + verifyPublishedClawHubPackage, +} from "../../scripts/verify-clawhub-published-artifact.mjs"; + +const tempDirs: string[] = []; +const clawhubToolchainSha256 = "d".repeat(64); +const clawhubToolchainVersion = "0.23.1"; +const clawhubToolchainIntegrity = + "sha512-YvUImhsVaM90BUAv3uP7lfABziwR5XL3ch2Owa+GvNxwQ2xzZFmZC0yVjAtQbvep+dDDS16nUGRwKx7jqnTOEA=="; + +function immutableBinding() { + return { + artifactDigest: "c".repeat(64), + artifactId: "456", + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + }; +} + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function identity(artifact: Uint8Array) { + return { + sha256: createHash("sha256").update(artifact).digest("hex"), + size: artifact.byteLength, + npmIntegrity: `sha512-${createHash("sha512").update(artifact).digest("base64")}`, + npmShasum: createHash("sha1").update(artifact).digest("hex"), + }; +} + +function writeManifest(mode: "publish" | "configure-only", artifact: Uint8Array, runAttempt = "1") { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-readback-")); + tempDirs.push(root); + const path = join(root, "manifest.json"); + const artifactIdentity = identity(artifact); + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + repository: "openclaw/openclaw", + targetSha: "a".repeat(40), + workflowSha: "b".repeat(40), + runId: "123", + runAttempt, + artifactName: `clawhub-bootstrap-aaaaaaaaaaaa-123-${runAttempt}`, + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + requestedPlugins: ["@openclaw/meta"], + entries: [ + { + packageName: "@openclaw/meta", + version: "2026.7.1-beta.3", + packageDir: "extensions/meta", + publishTag: "beta", + bootstrapMode: mode, + requiresManualOverride: mode === "configure-only", + artifactPath: "packages/meta/openclaw-meta-2026.7.1-beta.3.tgz", + sha256: artifactIdentity.sha256, + size: artifactIdentity.size, + }, + ], + }), + ); + return path; +} + +function writeExpectedArtifact(artifact: Uint8Array) { + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-oidc-readback-")); + tempDirs.push(root); + const artifactDir = join(root, "artifact"); + mkdirSync(artifactDir); + writeFileSync(join(artifactDir, "openclaw-meta-2026.7.1-beta.3.tgz"), artifact); + return artifactDir; +} + +function artifactResponse(artifact: Uint8Array, body: BodyInit = artifact) { + const artifactIdentity = identity(artifact); + return new Response(body, { + headers: { + "content-length": String(artifact.byteLength), + "x-clawhub-artifact-sha256": artifactIdentity.sha256, + "x-clawhub-npm-integrity": artifactIdentity.npmIntegrity, + "x-clawhub-npm-shasum": artifactIdentity.npmShasum, + }, + }); +} + +function metadataResponse(artifact: Uint8Array, body?: BodyInit) { + const artifactIdentity = identity(artifact); + return new Response( + body ?? + JSON.stringify({ + package: { name: "@openclaw/meta" }, + version: "2026.7.1-beta.3", + artifact: { + kind: "npm-pack", + sha256: artifactIdentity.sha256, + size: artifactIdentity.size, + npmIntegrity: artifactIdentity.npmIntegrity, + npmShasum: artifactIdentity.npmShasum, + }, + }), + { headers: { "content-type": "application/json" } }, + ); +} + +function registryFetch(artifact: Uint8Array) { + return vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/trusted-publisher")) { + return Response.json({ + trustedPublisher: { + provider: "github-actions", + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + environment: null, + }, + }); + } + if (url.endsWith("/artifact/download")) { + return artifactResponse(artifact); + } + if (url.endsWith("/artifact")) { + return metadataResponse(artifact); + } + return Response.json({ + package: { tags: { beta: "2026.7.1-beta.3" } }, + }); + }); +} + +describe("ClawHub published artifact verification", () => { + it("uses bounded streaming reads with an active attempt timeout", () => { + const source = readFileSync("scripts/verify-clawhub-published-artifact.mjs", "utf8"); + expect(source).not.toContain(".arrayBuffer("); + expect(source).toContain("response.body.getReader()"); + expect(source).toContain("readBoundedBytes(response, url, MAX_JSON_BYTES)"); + expect(source).toContain("readBoundedBytes(response, url, MAX_ARTIFACT_BYTES)"); + expect(source).toContain("AbortSignal.timeout(timeoutMs)"); + }); + + it("verifies normal OIDC publication against the exact prepared artifact bytes", async () => { + const artifact = new TextEncoder().encode("exact oidc tgz bytes"); + const fetchImpl = registryFetch(artifact); + const evidence = await verifyPublishedClawHubPackage({ + expectedArtifactDir: writeExpectedArtifact(artifact), + packageName: "@openclaw/meta", + packageVersion: "2026.7.1-beta.3", + publishTag: "beta", + registry: "https://clawhub.example", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }); + + expect(evidence).toMatchObject({ + schemaVersion: 1, + verificationMode: "oidc-postpublish", + expectedArtifact: identity(artifact), + package: { + packageName: "@openclaw/meta", + registrySha256: identity(artifact).sha256, + registrySize: artifact.byteLength, + }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it("rejects a non-GitHub Actions trusted publisher", async () => { + const artifact = new TextEncoder().encode("exact oidc tgz bytes"); + const fetchImpl = registryFetch(artifact); + fetchImpl + .mockResolvedValueOnce( + Response.json({ + package: { tags: { beta: "2026.7.1-beta.3" } }, + }), + ) + .mockResolvedValueOnce( + Response.json({ + trustedPublisher: { + provider: "other", + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + environment: null, + }, + }), + ); + + await expect( + verifyPublishedClawHubPackage({ + expectedArtifactDir: writeExpectedArtifact(artifact), + packageName: "@openclaw/meta", + packageVersion: "2026.7.1-beta.3", + publishTag: "beta", + registry: "https://clawhub.example", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("trusted publisher provider mismatch"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("rejects ambiguous or symlinked normal OIDC artifacts before registry access", async () => { + const artifact = new TextEncoder().encode("exact oidc tgz bytes"); + const fetchImpl = registryFetch(artifact); + const ambiguous = writeExpectedArtifact(artifact); + writeFileSync(join(ambiguous, "second.tgz"), artifact); + await expect( + verifyPublishedClawHubPackage({ + expectedArtifactDir: ambiguous, + packageName: "@openclaw/meta", + packageVersion: "2026.7.1-beta.3", + publishTag: "beta", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("exactly one root .tgz regular file"); + + const root = mkdtempSync(join(tmpdir(), "openclaw-clawhub-oidc-symlink-")); + tempDirs.push(root); + const artifactDir = join(root, "artifact"); + const target = join(root, "target.tgz"); + mkdirSync(artifactDir); + writeFileSync(target, artifact); + symlinkSync(target, join(artifactDir, "linked.tgz")); + await expect( + verifyPublishedClawHubPackage({ + expectedArtifactDir: artifactDir, + packageName: "@openclaw/meta", + packageVersion: "2026.7.1-beta.3", + publishTag: "beta", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("exactly one root .tgz regular file"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("requires exact bytes and complete artifact metadata", async () => { + const artifact = new TextEncoder().encode("exact tgz bytes"); + const evidence = await verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("publish", artifact), + registry: "https://clawhub.example", + terminalRunAttempt: "2", + retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 }, + }); + expect(evidence).toMatchObject({ + schemaVersion: 2, + producerRunAttempt: "1", + terminalRunAttempt: "2", + artifactName: "clawhub-bootstrap-aaaaaaaaaaaa-123-1", + clawhubToolchainIntegrity, + clawhubToolchainSha256, + clawhubToolchainVersion, + requestedPlugins: ["@openclaw/meta"], + verificationMode: "postpublish", + packages: [ + { + packageName: "@openclaw/meta", + registrySha256: identity(artifact).sha256, + registrySize: artifact.byteLength, + npmIntegrity: identity(artifact).npmIntegrity, + npmShasum: identity(artifact).npmShasum, + artifactMetadata: { + kind: "npm-pack", + packageName: "@openclaw/meta", + version: "2026.7.1-beta.3", + }, + }, + ], + }); + }); + + it("proves configure-only registry bytes before trusted-publisher mutation", async () => { + const artifact = new TextEncoder().encode("historical exact bytes"); + const fetchImpl = registryFetch(artifact); + const evidence = await verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }); + expect(evidence.packages[0]).toMatchObject({ + bootstrapMode: "configure-only", + expectedSha256: identity(artifact).sha256, + registrySha256: identity(artifact).sha256, + }); + expect(fetchImpl.mock.calls.some(([url]) => String(url).endsWith("/trusted-publisher"))).toBe( + false, + ); + }); + + it("rejects a missing configure-only tag before artifact or publisher requests", async () => { + const artifact = new TextEncoder().encode("historical exact bytes"); + const fetchImpl = vi.fn(async () => + Response.json({ + package: { tags: { beta: "2026.7.1-beta.2" } }, + }), + ); + + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow( + "@openclaw/meta@2026.7.1-beta.3 ClawHub artifact did not stabilize after 1 attempts; last failure @openclaw/meta ClawHub tag beta mismatch", + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls.some(([url]) => String(url).includes("/artifact"))).toBe(false); + expect(fetchImpl.mock.calls.some(([url]) => String(url).endsWith("/trusted-publisher"))).toBe( + false, + ); + }); + + it("retries invalid JSON, body read failures, and eventual byte convergence", async () => { + const expected = new TextEncoder().encode("expected"); + const wrong = new TextEncoder().encode("wrong"); + let detailCalls = 0; + let artifactCalls = 0; + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (!url.includes("/artifact") && !url.endsWith("/trusted-publisher")) { + detailCalls += 1; + if (detailCalls === 1) { + return new Response("{invalid"); + } + return Response.json({ package: { tags: { beta: "2026.7.1-beta.3" } } }); + } + if (url.endsWith("/trusted-publisher")) { + return Response.json({ + trustedPublisher: { + provider: "github-actions", + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + environment: null, + }, + }); + } + if (url.endsWith("/artifact")) { + return metadataResponse(expected); + } + artifactCalls += 1; + if (artifactCalls === 1) { + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("truncated body")); + }, + }), + ); + } + if (artifactCalls === 2) { + return artifactResponse(expected, wrong); + } + return artifactResponse(expected); + }); + const sleep = vi.fn(async () => {}); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("publish", expected), + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 4, delayMs: 1, sleep }, + }), + ).resolves.toMatchObject({ packages: [{ registrySha256: identity(expected).sha256 }] }); + expect(sleep).toHaveBeenCalledTimes(3); + }); + + it("keeps the attempt timeout active through a stalled body", async () => { + const artifact = new TextEncoder().encode("expected"); + let artifactCalls = 0; + const fetchImpl = vi.fn( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = String(input); + if (url.endsWith("/artifact")) { + return metadataResponse(artifact); + } + if (url.endsWith("/artifact/download")) { + artifactCalls += 1; + if (artifactCalls === 1) { + return new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener( + "abort", + () => controller.error(init.signal?.reason), + { once: true }, + ); + }, + }), + ); + } + return artifactResponse(artifact); + } + if (!url.includes("/artifact")) { + return Response.json({ + package: { tags: { beta: "2026.7.1-beta.3" } }, + }); + } + throw new Error(`unexpected URL ${url}`); + }, + ); + + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 2, delayMs: 1, timeoutMs: 10 }, + }), + ).resolves.toMatchObject({ packages: [{ registrySize: artifact.byteLength }] }); + expect(artifactCalls).toBe(2); + }); + + it("cancels retryable response bodies and never sleeps after the final attempt", async () => { + const artifact = new TextEncoder().encode("expected"); + const canceled: string[] = []; + const sleep = vi.fn(async () => {}); + const fetchImpl = vi.fn(async () => { + return new Response( + new ReadableStream({ + cancel() { + canceled.push("retry"); + }, + }), + { status: 503 }, + ); + }); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 2, delayMs: 1, sleep }, + }), + ).rejects.toThrow("did not stabilize after 2 attempts"); + expect(canceled).toEqual(["retry", "retry"]); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("fails immediately on permanent HTTP errors and explicit size limits", async () => { + const artifact = new TextEncoder().encode("expected"); + const permanentFetch = vi.fn(async () => new Response("denied", { status: 403 })); + const permanentSleep = vi.fn(async () => {}); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { + fetchImpl: permanentFetch, + attempts: 3, + delayMs: 1, + sleep: permanentSleep, + }, + }), + ).rejects.toThrow("returned HTTP 403"); + expect(permanentFetch).toHaveBeenCalledTimes(1); + expect(permanentSleep).not.toHaveBeenCalled(); + + const oversizedFetch = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/artifact")) { + return new Response("{}", { + headers: { "content-length": String(1024 * 1024 + 1) }, + }); + } + return Response.json({ + package: { tags: { beta: "2026.7.1-beta.3" } }, + }); + }); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl: oversizedFetch, attempts: 3, delayMs: 1 }, + }), + ).rejects.toThrow("exceeded 1048576 bytes"); + expect(oversizedFetch).toHaveBeenCalledTimes(2); + + const oversizedArtifactFetch = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/artifact/download")) { + return new Response(null, { + headers: { "content-length": String(130 * 1024 * 1024 + 1) }, + }); + } + if (url.endsWith("/artifact")) { + return metadataResponse(artifact); + } + return Response.json({ + package: { tags: { beta: "2026.7.1-beta.3" } }, + }); + }); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl: oversizedArtifactFetch, attempts: 3, delayMs: 1 }, + }), + ).rejects.toThrow("exceeded 136314880 bytes"); + }); + + it("requires a terminal attempt at or after the immutable producer attempt", async () => { + const artifact = new TextEncoder().encode("expected"); + const baseOptions = { + ...immutableBinding(), + manifestPath: writeManifest("configure-only", artifact), + mode: "configure-only-preflight", + registry: "https://clawhub.example", + retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 }, + }; + + await expect(verifyPublishedClawHubArtifacts(baseOptions)).rejects.toThrow( + "terminalRunAttempt must be an integer", + ); + await expect( + verifyPublishedClawHubArtifacts({ ...baseOptions, terminalRunAttempt: "0" }), + ).rejects.toThrow("terminalRunAttempt must be an integer"); + await expect( + verifyPublishedClawHubArtifacts({ + ...baseOptions, + manifestPath: writeManifest("configure-only", artifact, "2"), + terminalRunAttempt: "1", + }), + ).rejects.toThrow("greater than or equal to the producer run attempt"); + + for (const invalid of ["1junk", "1.5", "1e2"]) { + await expect( + verifyPublishedClawHubArtifacts({ + ...baseOptions, + terminalRunAttempt: invalid, + }), + ).rejects.toThrow("terminalRunAttempt must be an integer"); + } + await expect( + verifyPublishedClawHubArtifacts({ + ...baseOptions, + artifactId: "1junk", + terminalRunAttempt: "1", + }), + ).rejects.toThrow("artifactId must be an integer"); + await expect( + verifyPublishedClawHubArtifacts({ + ...baseOptions, + artifactDigest: "A".repeat(64), + terminalRunAttempt: "1", + }), + ).rejects.toThrow("artifactDigest is invalid"); + }); + + it("requires the locked ClawHub toolchain identity in the validated manifest", async () => { + const artifact = new TextEncoder().encode("expected"); + const manifestPath = writeManifest("configure-only", artifact); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.clawhubToolchainSha256 = "A".repeat(64); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath, + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("manifest.clawhubToolchainSha256 is invalid"); + + manifest.clawhubToolchainSha256 = clawhubToolchainSha256; + writeFileSync(manifestPath, JSON.stringify(manifest)); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + clawhubToolchainSha256: "e".repeat(64), + manifestPath, + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl: registryFetch(artifact), attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("clawhubToolchainSha256 mismatch"); + }); + + it("rejects noncanonical, oversized, and symlinked manifests before registry access", async () => { + const artifact = new TextEncoder().encode("expected"); + const fetchImpl = registryFetch(artifact); + const manifestPath = writeManifest("configure-only", artifact); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + writeFileSync(manifestPath, JSON.stringify({ ...manifest, unexpected: true })); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath, + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("keys are invalid"); + + const oversizedPath = writeManifest("configure-only", artifact); + truncateSync(oversizedPath, 2 * 1024 * 1024 + 1); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: oversizedPath, + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("size is outside the allowed range: 2097153"); + + const targetPath = writeManifest("configure-only", artifact); + const symlinkPath = `${targetPath}.link`; + symlinkSync(targetPath, symlinkPath); + await expect( + verifyPublishedClawHubArtifacts({ + ...immutableBinding(), + manifestPath: symlinkPath, + mode: "configure-only-preflight", + registry: "https://clawhub.example", + terminalRunAttempt: "1", + retryOptions: { fetchImpl, attempts: 1, delayMs: 1 }, + }), + ).rejects.toThrow("must be a regular file"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts new file mode 100644 index 000000000000..9a926dc0ab09 --- /dev/null +++ b/test/scripts/verify-release-notes.test.ts @@ -0,0 +1,553 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + contaminatingPullRequestReferences, + cherryPickedFromCommits, + countTopLevelSectionBullets, + createGithubSnapshotState, + cumulativeShippedPullRequests, + defaultGithubSnapshotPath, + githubGraphqlData, + githubApiWithSnapshot, + highlightCountError, + persistGithubSnapshot, + releaseNoteReferences, + standardRevertedHash, + subtractShippedPullRequests, + withoutExcludedContributionRecords, +} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs"; + +const verifier = resolve( + ".agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs", +); + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "OpenClaw Test", + GIT_AUTHOR_EMAIL: "test@openclaw.invalid", + GIT_COMMITTER_NAME: "OpenClaw Test", + GIT_COMMITTER_EMAIL: "test@openclaw.invalid", + }, + }).trim(); +} + +describe("release-note verification", () => { + it("rejects partial GraphQL data when GitHub also reports errors", () => { + expect(() => + githubGraphqlData({ + data: { commit0: null }, + errors: [{ message: "Something went wrong while executing your query." }], + }), + ).toThrow("GitHub GraphQL response included errors"); + }); + + it("stores default GitHub snapshots in the shared Git common directory", () => { + const commonDir = resolve("/tmp/openclaw-shared-git"); + expect(defaultGithubSnapshotPath("a".repeat(40), "b".repeat(40), commonDir)).toBe( + join( + commonDir, + "openclaw-release-cache", + `verify-release-notes-${"a".repeat(40)}-${"b".repeat(40)}.json`, + ), + ); + }); + + it("reuses exact-range GitHub GraphQL snapshots without caching REST reads", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + let fetches = 0; + const fetchApi = (args: string[]) => { + fetches += 1; + return { data: { request: args, fetches } }; + }; + const first = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + + expect(githubApiWithSnapshot(["graphql", "-f", "query=one"], fetchApi, first)).toEqual({ + data: { + request: ["graphql", "-f", "query=one"], + fetches: 1, + }, + }); + expect( + githubApiWithSnapshot(["repos/openclaw/openclaw/releases/tags/v1"], fetchApi, first), + ).toEqual({ + data: { + request: ["repos/openclaw/openclaw/releases/tags/v1"], + fetches: 2, + }, + }); + persistGithubSnapshot(first); + + const second = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + expect(githubApiWithSnapshot(["graphql", "-f", "query=one"], fetchApi, second)).toEqual({ + data: { + request: ["graphql", "-f", "query=one"], + fetches: 1, + }, + }); + expect(second.hits).toBe(1); + expect(second.misses).toBe(0); + expect(fetches).toBe(2); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("checkpoints successful GraphQL responses during long verification runs", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + const state = createGithubSnapshotState({ + base: "a".repeat(40), + checkpointEvery: 2, + filePath, + target: "b".repeat(40), + }); + const fetchApi = (args: string[]) => ({ data: { request: args } }); + + githubApiWithSnapshot(["graphql", "-f", "query=one"], fetchApi, state); + expect(state.dirty).toBe(true); + expect(state.writesSincePersist).toBe(1); + githubApiWithSnapshot(["graphql", "-f", "query=two"], fetchApi, state); + + expect(state.dirty).toBe(false); + expect(state.writesSincePersist).toBe(0); + expect(JSON.parse(readFileSync(filePath, "utf8")).responses).toHaveProperty( + JSON.stringify(["graphql", "-f", "query=two"]), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("does not cache transient GraphQL errors", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + const state = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + let fetches = 0; + const fetchApi = () => { + fetches += 1; + return fetches === 1 + ? { errors: [{ message: "rate limited" }] } + : { data: { repository: { id: "repository-id" } } }; + }; + const args = ["graphql", "-f", "query=one"]; + + expect(githubApiWithSnapshot(args, fetchApi, state)).toEqual({ + errors: [{ message: "rate limited" }], + }); + expect(state.dirty).toBe(false); + expect(state.responses).toEqual({}); + expect(githubApiWithSnapshot(args, fetchApi, state)).toEqual({ + data: { repository: { id: "repository-id" } }, + }); + expect(state.misses).toBe(2); + expect(fetches).toBe(2); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("rejects a snapshot bound to a different release target", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-snapshot-")); + try { + const filePath = join(cwd, "snapshot.json"); + const state = createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "b".repeat(40), + }); + githubApiWithSnapshot(["graphql", "-f", "query=one"], () => ({ data: true }), state); + persistGithubSnapshot(state); + + expect(() => + createGithubSnapshotState({ + base: "a".repeat(40), + filePath, + target: "c".repeat(40), + }), + ).toThrow("use --refresh-github-snapshot"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("ignores nested revert markers in squash-merge bodies", () => { + const nestedRevert = [ + "feat(android): render display math (#101435)", + "", + "* feat(android): render display math", + "", + ' * Revert "docs(changelog): note display math"', + "", + `This reverts commit ${"a".repeat(40)}.`, + ].join("\n"); + const topLevelRevert = [ + 'Revert "fix(qa): keep smoke profile on one channel (#101173)" (#101184)', + "", + `This reverts commit ${"b".repeat(40)}.`, + ].join("\n"); + const squashRevert = [ + "Revert chat session picker inline search (#85527)", + "", + '* Revert "fix(ui): keep chat session search inline (#85490)"', + "", + `This reverts commit ${"c".repeat(40)}.`, + "", + "* fix(ui): clear applied chat picker search on empty input", + ].join("\n"); + const conventionalSquashRevert = [ + "chore: revert dependency guard backfill machinery (#87867)", + "", + '* Revert "ci: isolate dependency guard backfill label (#87882)"', + "", + `This reverts commit ${"d".repeat(40)}.`, + "", + "* ci: preserve clawsweeper bot label filter", + ].join("\n"); + const explainedTopLevelRevert = [ + "revert: restore a provider default", + "", + "The replacement broke non-native endpoints.", + "", + `This reverts commit ${"e".repeat(40)}.`, + ].join("\n"); + + expect(standardRevertedHash(nestedRevert)).toBeUndefined(); + expect(standardRevertedHash(topLevelRevert)).toBe("b".repeat(40)); + expect(standardRevertedHash(squashRevert)).toBe("c".repeat(40)); + expect(standardRevertedHash(conventionalSquashRevert)).toBe("d".repeat(40)); + expect(standardRevertedHash(explainedTopLevelRevert)).toBe("e".repeat(40)); + }); + + it("accepts canonical cherry-pick origins and rejects malformed provenance", () => { + const origin = "a".repeat(40); + const secondOrigin = "b".repeat(40); + expect( + cherryPickedFromCommits(`Fix release behavior\n\n(cherry picked from commit ${origin})`), + ).toEqual([origin]); + expect(cherryPickedFromCommits("Fix release behavior")).toEqual([]); + expect( + cherryPickedFromCommits( + `(cherry picked from commit ${origin})\n(cherry picked from commit ${secondOrigin})`, + ), + ).toEqual([origin, secondOrigin]); + expect(() => cherryPickedFromCommits("(cherry picked from commit abc123)")).toThrow( + "invalid cherry-pick provenance", + ); + }); + + it("counts only top-level Highlights bullets and enforces the 5-8 policy input", () => { + const highlights = [ + "### Highlights", + "", + "- One", + " - nested detail", + "- Two", + "- Three", + "- Four", + "- Five", + "", + "### Changes", + "", + "- Not a highlight", + ].join("\n"); + const overLimit = highlights.replace("- Five", "- Five\n- Six\n- Seven\n- Eight\n- Nine"); + + expect(countTopLevelSectionBullets(highlights, "Highlights")).toBe(5); + expect(countTopLevelSectionBullets(overLimit, "Highlights")).toBe(9); + expect(highlightCountError(highlights)).toBeUndefined(); + expect(highlightCountError(overLimit)).toBe( + "### Highlights must contain 5-8 top-level bullets; found 9", + ); + }); + + it("rejects prior-release PRs from prose or the existing record unless explicitly seeded", () => { + const nodes = new Map([ + [97118, { __typename: "PullRequest" }], + [102000, { __typename: "PullRequest" }], + [98565, { __typename: "Issue" }], + ]); + const params = { + noteReferences: [97118, 98565], + recordedReferences: [97118, 102000], + sourcePullRequests: new Set([102000]), + sourceReferences: [102000, 98565], + seededPullRequests: new Set(), + nodes, + }; + + expect(contaminatingPullRequestReferences(params)).toEqual([97118]); + expect( + contaminatingPullRequestReferences({ + ...params, + seededPullRequests: new Set([97118]), + }), + ).toEqual([]); + }); + + it("excludes Unreleased records from a cumulative shipped tag boundary", () => { + const changelog = [ + "# Changelog", + "", + "## Unreleased", + "", + "### Complete contribution record", + "", + `This audited record covers the complete base..${"a".repeat(40)} history: 1 merged PR.`, + "", + "#### Pull requests", + "", + "- **PR #1** fix: not shipped.", + "", + "## 2026.6.11", + "", + "### Complete contribution record", + "", + "This audited record covers the complete base..HEAD history: 0 merged PRs.", + "", + "#### Pull requests", + "", + "- **PR #2** fix: shipped.", + ].join("\n"); + + expect([...cumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]); + }); + + it("subtracts cumulative shipped PRs deterministically from the source inventory", () => { + const source = { + pullRequests: new Set([1, 2, 3]), + references: [1, 2, 4], + }; + + const result = subtractShippedPullRequests(source, [ + { ref: "v2026.6.11", pullRequests: new Set([1, 2]) }, + { ref: "v2026.6.10", pullRequests: new Set([2, 4]) }, + ]); + + expect([...source.pullRequests]).toEqual([3]); + expect(source.references).toEqual([]); + expect(result.baselines).toEqual([ + { ref: "v2026.6.10", count: 2, pullRequests: [2, 4] }, + { ref: "v2026.6.11", count: 1, pullRequests: [1] }, + ]); + expect([...result.pullRequests].toSorted((a, b) => a - b)).toEqual([1, 2, 4]); + }); + + it("removes rewrite-excluded references from an existing contribution record", () => { + const record = { + pullRequests: new Map([ + [1, { references: [2, 10], thanks: [] }], + [2, { references: [11], thanks: [] }], + ]), + legacyIssues: new Map([ + [10, { references: [], thanks: [] }], + [11, { references: [], thanks: [] }], + ]), + }; + + const filtered = withoutExcludedContributionRecords(record, new Set([2, 10])); + + expect([...filtered.pullRequests]).toEqual([ + [1, { externalReferences: [], references: [], thanks: [] }], + ]); + expect([...filtered.legacyIssues]).toEqual([ + [11, { externalReferences: [], references: [], thanks: [] }], + ]); + }); + + it("does not treat the shipped baseline inventory as current release-note references", () => { + const baselines = [{ ref: "v2026.6.11", count: 2, pullRequests: [1, 2] }]; + const section = [ + "## 2026.7.1", + "", + "- Fixes #1 in the current range.", + "", + "### Complete contribution record", + "", + "Shipped baseline exclusions: v2026.6.11 (2 PRs: #1, #2).", + "", + "- **PR #3** fix: current work.", + ].join("\n"); + + expect(releaseNoteReferences(section, baselines)).toEqual([1, 3]); + }); + + it("records a canonical target SHA when --target is symbolic", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-")); + try { + git(cwd, ["init", "-q"]); + writeFileSync( + join(cwd, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- One.", + "- Two.", + "- Three.", + "- Four.", + "- Five.", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + "This stale generated record must be replaced without validation.", + "", + "- **PR #999999999** stale: no longer resolvable.", + ].join("\n"), + ); + git(cwd, ["add", "CHANGELOG.md"]); + git(cwd, ["commit", "-qm", "initial"]); + const targetSha = git(cwd, ["rev-parse", "HEAD"]); + + const result = spawnSync( + process.execPath, + [ + verifier, + "--base", + "HEAD", + "--target", + "HEAD", + "--version", + "2026.7.1", + "--write-ledger", + "--json", + ], + { cwd, encoding: "utf8" }, + ); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).target).toBe(targetSha); + expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toContain( + `This audited record covers the complete HEAD..${targetSha} history:`, + ); + expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).not.toContain("#999999999"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("does not replace CHANGELOG.md when generated-ledger validation fails", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-")); + try { + git(cwd, ["init", "-q"]); + const original = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- Too few highlights.", + "", + "### Changes", + "", + "### Fixes", + ].join("\n"); + writeFileSync(join(cwd, "CHANGELOG.md"), original); + git(cwd, ["add", "CHANGELOG.md"]); + git(cwd, ["commit", "-qm", "initial"]); + + const result = spawnSync( + process.execPath, + [ + verifier, + "--base", + "HEAD", + "--target", + "HEAD", + "--version", + "2026.7.1", + "--write-ledger", + "--json", + ], + { cwd, encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).errors).toContain( + "### Highlights must contain 5-8 top-level bullets; found 1", + ); + expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toBe(original); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("rejects a release base that is not an ancestor of the target", () => { + const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-")); + try { + git(cwd, ["init", "-q"]); + writeFileSync( + join(cwd, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- Test release.", + "", + "### Complete contribution record", + "", + ].join("\n"), + ); + git(cwd, ["add", "CHANGELOG.md"]); + git(cwd, ["commit", "-qm", "initial"]); + git(cwd, ["branch", "target"]); + + writeFileSync(join(cwd, "base.txt"), "base\n"); + git(cwd, ["add", "base.txt"]); + git(cwd, ["commit", "-qm", "base"]); + git(cwd, ["tag", "base-ref"]); + + git(cwd, ["checkout", "-q", "target"]); + writeFileSync(join(cwd, "target.txt"), "target\n"); + git(cwd, ["add", "target.txt"]); + git(cwd, ["commit", "-qm", "target"]); + + const result = spawnSync( + process.execPath, + [verifier, "--base", "base-ref", "--target", "HEAD", "--version", "2026.7.1"], + { cwd, encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "release range base base-ref must be an ancestor of target HEAD", + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +});