From 0c9ebe961eb1c66b4e93e803738f42768a45a335 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:16:35 -0700 Subject: [PATCH 01/27] perf(release): share changelog verification snapshots --- .../scripts/verify-release-notes.mjs | 31 ++++++++++++--- test/scripts/verify-release-notes.test.ts | 39 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs index 07c442a685e3..c842ee13571a 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -14,6 +14,7 @@ import { 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([ @@ -182,10 +183,10 @@ function parseArgs(argv) { return options; } -function run(command, args) { +function run(command, args, options = {}) { return execFileSync(command, args, { encoding: "utf8", - env: { ...process.env, NO_COLOR: "1" }, + env: { ...process.env, NO_COLOR: "1", ...options.env }, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); @@ -220,7 +221,12 @@ function gitIsAncestor(base, target) { function fetchGithubApi(args) { try { - return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + return JSON.parse( + run("ghx", ["api", ...args], { env: { GHX_NO_CACHE: "1" } }).replace( + /\u001B\[[0-?]*[ -/]*[@-~]/g, + "", + ), + ); } catch (error) { if (typeof error.stdout === "string" && error.stdout.trim() !== "") { return JSON.parse(error.stdout.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); @@ -231,11 +237,15 @@ function fetchGithubApi(args) { 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; @@ -265,6 +275,7 @@ export function createGithubSnapshotState({ } return { base, + checkpointEvery, dirty: refresh && existsSync(filePath), filePath, hits: 0, @@ -272,6 +283,7 @@ export function createGithubSnapshotState({ repository, responses, target, + writesSincePersist: 0, }; } @@ -298,6 +310,10 @@ export function githubApiWithSnapshot(args, fetchApi, snapshotState) { } snapshotState.responses[key] = structuredClone(response); snapshotState.dirty = true; + snapshotState.writesSincePersist += 1; + if (snapshotState.writesSincePersist >= snapshotState.checkpointEvery) { + persistGithubSnapshot(snapshotState); + } return response; } @@ -322,6 +338,7 @@ export function persistGithubSnapshot(snapshotState) { writeFileSync(tempPath, output); renameSync(tempPath, snapshotState.filePath); snapshotState.dirty = false; + snapshotState.writesSincePersist = 0; } finally { rmSync(tempPath, { force: true }); } @@ -331,16 +348,20 @@ 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 defaultName = `verify-release-notes-${base}-${target}.json`; const filePath = path.resolve( options.githubSnapshotPath ?? - git(["rev-parse", "--git-path", `openclaw-release-cache/${defaultName}`]), + defaultGithubSnapshotPath(base, target, git(["rev-parse", "--git-common-dir"])), ); const state = createGithubSnapshotState({ base, diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index bcd4216f4326..68508956a459 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -8,6 +8,7 @@ import { countTopLevelSectionBullets, createGithubSnapshotState, cumulativeShippedPullRequests, + defaultGithubSnapshotPath, githubApiWithSnapshot, highlightCountError, persistGithubSnapshot, @@ -36,6 +37,17 @@ function git(cwd: string, args: string[]): string { } describe("release-note verification", () => { + 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 { @@ -86,6 +98,33 @@ describe("release-note verification", () => { } }); + 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 { From d86b62dea9746e579426482579bee4339471f43d Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:19:51 -0700 Subject: [PATCH 02/27] perf(release): reuse exact-SHA validation evidence --- .agents/skills/release-openclaw-ci/SKILL.md | 6 +++ .../scripts/release-ci-summary.mjs | 5 +-- .github/workflows/full-release-validation.yml | 2 +- docs/ci.md | 8 ++-- docs/reference/RELEASING.md | 2 +- docs/reference/full-release-validation.md | 15 +++++--- scripts/full-release-validation-at-sha.mjs | 8 ++-- ...idate-full-release-validation-evidence.mjs | 15 +++++++- .../full-release-validation-at-sha.test.ts | 9 +++-- test/scripts/release-ci-summary.test.ts | 24 +++++++----- test/scripts/release-no-push-workflow.test.ts | 1 + ...e-full-release-validation-evidence.test.ts | 37 ++++++++++++++++--- 12 files changed, 93 insertions(+), 39 deletions(-) diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index e2fe645422e3..285e2dbdad02 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -109,6 +109,12 @@ gh workflow run full-release-validation.yml \ -f rerun_group=all ``` +For immutable workflow proof on a moving `main`, use +`pnpm ci:full-release --sha `. Its canonical `release-ci/*` ref +keeps exact-target evidence reuse enabled after proving the workflow commit is +still on trusted `main` lineage. Pass `-f reuse_evidence=false` only when the +operator intentionally needs a fresh full run. + 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 diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs index 6a392042c6fa..4a72cae694c4 100755 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs @@ -1069,7 +1069,7 @@ function normalizeWorkflowPathRef(ref) { return `refs/heads/${ref}`; } -function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) { +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 ?? ""); @@ -1088,9 +1088,6 @@ function validateTrustedProducerIdentity(evidence, client, verifier, trustedWork if (manifest.targetRef !== manifest.targetSha) { throw new Error("SHA-pinned release evidence target ref must equal its target SHA"); } - if (manifest.evidenceReuse !== undefined) { - throw new Error("SHA-pinned release evidence must not reuse another validation run"); - } } const expectedFullRef = trustedWorkflowFullRef(manifest.workflowRef); const runPath = String(parentRun.path ?? ""); diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 3ea643d5a113..0e719b329154 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -241,7 +241,7 @@ jobs: evidence_reuse: name: Check for reusable validation evidence needs: [resolve_target] - if: inputs.rerun_group == 'all' && inputs.reuse_evidence && github.ref == 'refs/heads/main' + if: inputs.rerun_group == 'all' && inputs.reuse_evidence && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-ci/')) runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: diff --git a/docs/ci.md b/docs/ci.md index 4ec3452263cb..1f7d70208691 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -283,9 +283,11 @@ pnpm ci:full-release --sha GitHub workflow dispatch refs must be branches or tags, not raw commit SHAs. The helper pushes a temporary `release-ci/-...` branch at a trusted `main` workflow SHA, passes the requested target SHA through the workflow `ref` input, -verifies every child workflow `headSha` matches the trusted workflow SHA, and -deletes the temporary branch when the run completes. The umbrella verifier also -fails if any child workflow ran at a different workflow SHA. +reuses strict exact-target evidence when available, verifies every child +workflow `headSha` matches the trusted workflow SHA, and deletes the temporary +branch when the run completes. Pass `-f reuse_evidence=false` to force fresh +validation. The umbrella verifier also fails if any child workflow ran at a +different workflow SHA. `release_profile` controls live/provider breadth passed into release checks. The manual release workflows default to `stable`; use `full` only when you diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 9e07e3cda74d..177da41db9fd 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -280,7 +280,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c pnpm ci:full-release --sha ``` -The helper fetches current `origin/main`, pushes `release-ci/-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=` and `reuse_evidence=false`, verifies every child workflow `headSha` matches the pinned parent workflow SHA, then deletes the temporary branch. Pass `--workflow-sha ` to pin an older commit that is still reachable from current `origin/main`. The workflow itself never writes repository refs. This keeps main-only release tooling available without adding tooling commits to the candidate and avoids proving a newer `main` child run by accident. +The helper fetches current `origin/main`, pushes `release-ci/-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=`, reuses strict exact-target evidence when available, verifies every child workflow `headSha` matches the pinned parent workflow SHA, then deletes the temporary branch. Pass `-f reuse_evidence=false` to force a fresh run or `--workflow-sha ` to pin an older commit that is still reachable from current `origin/main`. The workflow itself never writes repository refs. This keeps main-only release tooling available without adding tooling commits to the candidate and avoids proving a newer `main` child run by accident. For release branch or tag validation, run it from the trusted `main` workflow ref and pass the release branch or tag as `ref`: diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 203741b963b0..95ac15c52980 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -35,10 +35,12 @@ dispatches, the umbrella fails closed even when the child itself succeeds. For an immutable exact-commit proof, use `pnpm ci:full-release --sha `. The helper creates a temporary `release-ci/*` ref pinned to current trusted `origin/main`, passes the target -SHA only as the candidate `ref`, disables evidence reuse, and deletes the ref -after validation. Pass `--workflow-sha ` to select an older -workflow commit still reachable from current `origin/main`. The workflow never -creates or updates repository refs itself. +SHA only as the candidate `ref`, reuses strict exact-target evidence when +available, and deletes the ref after validation. Pass +`-f reuse_evidence=false` to force a fresh run or +`--workflow-sha ` to select an older workflow commit still +reachable from current `origin/main`. The workflow never creates or updates +repository refs itself. `release_profile=stable` and `release_profile=full` always run the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the same soak lanes @@ -68,8 +70,9 @@ re-checks the immutable parent artifact, child runs, and dispatch logs. This is same-candidate rerun recovery only; it does not authorize cross-SHA reuse. For a changed candidate, rerun every package, artifact, install, Docker, or provider gate affected by that delta. Pass `reuse_evidence=false` to force a fresh full -run. Evidence reuse runs only when the umbrella itself was dispatched from -`main`; non-main workflow refs run the selected lanes fresh. +run. Evidence reuse runs only from `main` or a canonical SHA-pinned +`release-ci/*` ref whose workflow commit remains on trusted `main` lineage; +other workflow refs run the selected lanes fresh. Also for `rerun_group=all`, a `Verify Docker runtime image assets` job builds the `runtime-assets` Docker target with diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index ec100f3099f0..e11c131ef89e 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -9,7 +9,7 @@ const DEFAULT_INPUTS = { mode: "both", release_profile: "full", rerun_group: "all", - reuse_evidence: "false", + reuse_evidence: "true", }; function usage() { @@ -19,7 +19,7 @@ Creates a temporary remote branch pinned to trusted main release tooling, dispatches Full Release Validation with the target commit as its ref input, watches the parent run, verifies all child workflow head SHAs match the trusted workflow SHA, then deletes the temporary branch by default. Exact-target -evidence reuse is disabled because it is trusted only from main.`); +evidence reuse stays enabled; pass -f reuse_evidence=false to force a fresh run.`); } function run(command, args, options = {}) { @@ -119,8 +119,8 @@ export function parseArgs(argv) { throw new Error(`Unknown argument: ${arg}`); } - if (args.inputs.reuse_evidence !== "false") { - throw new Error("SHA-pinned release validation always disables evidence reuse"); + if (!["true", "false"].includes(args.inputs.reuse_evidence)) { + throw new Error("reuse_evidence must be true or false"); } if (Object.hasOwn(args.inputs, "ref")) { throw new Error("SHA-pinned release validation reserves the ref input for --sha"); diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index be0d3d848920..5c26a99dba96 100755 --- a/scripts/validate-full-release-validation-evidence.mjs +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -137,7 +137,20 @@ export function validateFullReleaseValidationEvidence({ ); } if (Object.hasOwn(manifest, "evidenceReuse")) { - throw new Error("SHA-pinned validation evidence must not reuse another validation run."); + 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 (!isTrustedMainAncestor?.(run.headSha)) { throw new Error( diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 450e6c35323e..f8beeec6c8ff 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -22,7 +22,7 @@ describe("full-release-validation-at-sha", () => { inputs: { mode: "linux", provider: "anthropic", - reuse_evidence: "false", + reuse_evidence: "true", }, sha: "abc123", workflowSha: "origin/main", @@ -40,9 +40,10 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["-f", "-h"])).toThrow("-f requires a value"); }); - it("cannot enable evidence reuse on a temporary SHA-pinned workflow ref", () => { - expect(() => parseArgs(["-f", "reuse_evidence=true"])).toThrow( - "always disables evidence reuse", + it("allows exact-target reuse to be disabled for a forced fresh run", () => { + expect(parseArgs(["-f", "reuse_evidence=false"]).inputs.reuse_evidence).toBe("false"); + expect(() => parseArgs(["-f", "reuse_evidence=maybe"])).toThrow( + "reuse_evidence must be true or false", ); }); diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 3eaf1134aad3..782096311c9a 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -26,6 +26,7 @@ import { validateParentRunBinding, validatePerformanceArtifactOnlyJobs, validateReleaseRunEvidence, + validateTrustedProducerIdentity, } from "../../.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; const SCRIPT = ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; @@ -739,7 +740,7 @@ describe("release CI summary child correlation", () => { }, ); - it("rejects SHA-pinned evidence reuse", () => { + 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({ @@ -753,21 +754,24 @@ describe("release CI summary child correlation", () => { changedPaths: [], evidenceSha: fixture.targetSha, policy: "exact-target-full-validation-v1", - runId: fixture.runId, - selectedRunId: fixture.runId, + runId: "29071366024", + selectedRunId: "29071366024", }; - expect(() => - validateReleaseRunEvidence( + expect( + validateTrustedProducerIdentity( { - repository: "openclaw/openclaw", - runId: fixture.runId, - verifierSourceContent: readFileSync(SCRIPT), - verifierSourceSha: "c".repeat(40), + manifest: fixture.manifest, + parentRun: fixture.parentRun, }, fixture.client, + { sourceSha: "c".repeat(40) }, + "main", ), - ).toThrow("must not reuse another validation run"); + ).toMatchObject({ + producerOnTrustedMainLineage: true, + workflowRefProof: "manifest-v3-sha-pinned-main-ancestry", + }); }); it("rejects a SHA-pinned evidenceReuse field even when false", () => { diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index a63fe0afbb6b..4a467ffed371 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -271,6 +271,7 @@ describe("release validation no-push transport", () => { const dockerAssets = job(full, "docker_runtime_assets_preflight"); expect(step(dockerAssets, "Checkout target SHA").with?.["persist-credentials"]).toBe(false); expect(evidenceReuse.if).toContain("github.ref == 'refs/heads/main'"); + expect(evidenceReuse.if).toContain("startsWith(github.ref, 'refs/heads/release-ci/')"); expect( evidenceReuse.steps?.find( (candidate) => candidate.name === "Require trusted main workflow ref", diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index 212da64e679a..e88b8c496c03 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -43,6 +43,16 @@ function releaseManifest(overrides: Record = {}) { }; } +function exactTargetEvidenceReuse() { + return { + changedPaths: [], + evidenceSha: targetSha, + policy: "exact-target-full-validation-v1", + runId: "122", + selectedRunId: "122", + }; +} + function validate( runOverrides: Record = {}, manifestOverrides: Record = {}, @@ -176,24 +186,41 @@ describe("full release validation evidence", () => { expect(() => validate({}, {}, false)).toThrow("not reachable from current main"); }); - it("rejects evidence reuse on the SHA-pinned path", () => { - expect(() => validate({}, { evidenceReuse: { runId: "122" } })).toThrow( - "must not reuse another validation run", + it("accepts exact-target evidence reuse on the SHA-pinned path", () => { + expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe( + "sha-pinned-main", ); }); + 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: { runId: "122" } }), + manifest: releaseManifest({ + evidenceReuse: { ...exactTargetEvidenceReuse(), selectedRunId: "" }, + }), expectedRepository: "openclaw/openclaw", expectedRunId: "123", expectedTargetSha: targetSha, expectedWorkflowBranch: pinnedBranch, isTrustedMainAncestor: () => false, }), - ).toThrow("must not reuse another validation run"); + ).toThrow("evidence reuse is invalid"); }); it("does not treat a malformed release-ci expected branch as direct", () => { From 6a606f93a31f15d37693dddf1116f40bc01639b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:21:27 -0700 Subject: [PATCH 03/27] feat(release): checkpoint candidate workflow state --- docs/reference/RELEASING.md | 2 +- scripts/release-candidate-checklist.mjs | 117 +++++++++++++++++- .../release-candidate-checklist.test.ts | 43 +++++++ 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 177da41db9fd..ca9ec9d0636c 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -168,7 +168,7 @@ This checklist is the public shape of the release flow. Private credentials, sig 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. Save the `full_release_validation_run_id` and exact `full_release_validation_run_attempt`; both are required inputs for `OpenClaw NPM Release` and `OpenClaw Release Publish`. 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. -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, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. The helper uses trusted `main` as the workflow source while each workflow targets the exact tag. Before it dispatches the full validation matrix, the helper deterministically renders the exact tag's GitHub release body and rejects a missing version heading, an over-limit body that cannot use the canonical compact form, or contribution-record base/target provenance that is not reachable from the tag. It also validates any explicit shipped-baseline exclusion metadata against the referenced cumulative tag records. It then runs the local generated-release checks, dispatches or verifies 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 `OpenClaw Release Publish` command only after the evidence bundle is green. +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, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. The helper uses trusted `main` as the workflow source while each workflow targets the exact tag. It checkpoints immutable candidate/tooling identity and dispatched run IDs in `.artifacts/release-candidate//release-candidate-state.json`; rerunning the same command resumes those exact runs, while any candidate, tooling, profile, or option drift fails closed. Before it dispatches the full validation matrix, the helper deterministically renders the exact tag's GitHub release body and rejects a missing version heading, an over-limit body that cannot use the canonical compact form, or contribution-record base/target provenance that is not reachable from the tag. It also validates any explicit shipped-baseline exclusion metadata against the referenced cumulative tag records. It then runs the local generated-release checks, dispatches or verifies 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 `OpenClaw Release Publish` command only after the evidence bundle is green. `OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front and promotes Windows and Android assets concurrently with the OpenClaw npm publish, closes out the release page and dependency evidence once those stages succeed, waits for ClawHub whenever OpenClaw npm is being published, then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted. diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index da45c4986f5e..4be0aa170583 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -2,7 +2,15 @@ // 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, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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"; @@ -42,6 +50,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] @@ -257,6 +284,74 @@ 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 (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) { @@ -1150,6 +1245,15 @@ async function main() { 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({ @@ -1186,6 +1290,9 @@ async function main() { options.releaseProfile === "stable" || options.releaseProfile === "full" ? "true" : "false", rerun_group: "all", }); + candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", { + fullReleaseRunId: options.fullReleaseRunId, + }); } if (!options.npmPreflightRunId && !options.skipDispatch) { @@ -1195,7 +1302,14 @@ async function main() { preflight_only: "true", npm_dist_tag: options.npmDistTag, }); + candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", { + npmPreflightRunId: options.npmPreflightRunId, + }); } + candidateState = updateReleaseCandidateState(statePath, candidateState, "waiting", { + fullReleaseRunId: options.fullReleaseRunId, + npmPreflightRunId: options.npmPreflightRunId, + }); const fullRun = await waitForSuccessfulRun(options.repo, options.fullReleaseRunId, { workflowName: "Full Release Validation", @@ -1376,6 +1490,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/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index b36ec085bcc9..4e2a02b92494 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import { + buildReleaseCandidateState, buildPublishCommand, candidateCumulativeShippedPullRequests, candidateParallelsArgs, @@ -10,6 +11,7 @@ import { githubApi, parseArgs, parseRunIdFromDispatchOutput, + reconcileReleaseCandidateState, resolveArtifactName, requireRunIdFromDispatchOutput, run, @@ -40,6 +42,47 @@ async function withGithubApiTimeoutEnv(value: string, fn: () => Promise): } 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( + { + ...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, From 52b18ca42f406c4676cabcade483af4fadb5b80f Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:23:08 -0700 Subject: [PATCH 04/27] feat(release): watch CI transitions compactly --- .agents/skills/release-openclaw-ci/SKILL.md | 14 +-- .../scripts/release-ci-summary.mjs | 92 ++++++++++++++++++- test/scripts/release-ci-summary.test.ts | 67 ++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 285e2dbdad02..0598f7dfd06b 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -122,18 +122,18 @@ 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 --watch +``` + +For a one-shot snapshot: ```bash node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs ``` -Then watch only when useful: - -```bash -gh run watch --repo openclaw/openclaw --exit-status -``` - Stop watchers before ending the turn or switching strategy. ## Failure Triage diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs index 4a72cae694c4..7a43f0f047b0 100755 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs @@ -1395,12 +1395,14 @@ export function validateReleaseRunEvidence( export function parseReleaseCiSummaryArgs(argv) { const options = { + intervalMs: 30_000, json: false, manifestPath: undefined, repository: DEFAULT_REPO, runId: undefined, trustedWorkflowRef: "main", validate: false, + watch: false, }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; @@ -1415,6 +1417,14 @@ export function parseReleaseCiSummaryArgs(argv) { options.trustedWorkflowRef = 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 { @@ -1424,6 +1434,9 @@ export function parseReleaseCiSummaryArgs(argv) { 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.runId) { throw new Error("full release run ID is required"); } @@ -1434,12 +1447,80 @@ 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] --json", ].join("\n"), ); } -function main() { +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((resolve) => setTimeout(resolve, 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)); @@ -1474,6 +1555,10 @@ function main() { } return; } + if (options.watch) { + await watchReleaseCiRun(options); + return; + } const core = rate(); if (core) { @@ -1681,5 +1766,8 @@ function main() { } if (process.argv[1]?.endsWith("release-ci-summary.mjs")) { - main(); + await main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); } diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 782096311c9a..fe43678318dd 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -11,6 +11,7 @@ import { manifestChildEntries, parseReleaseCiSummaryArgs, readManifestArtifactArchive, + releaseCiWatchFingerprint, requiredChildKeysForRerunGroup, resolveManifestChildOriginAttempt, selectExactChildRun, @@ -27,6 +28,7 @@ import { validatePerformanceArtifactOnlyJobs, validateReleaseRunEvidence, validateTrustedProducerIdentity, + watchReleaseCiRun, } from "../../.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; const SCRIPT = ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; @@ -345,11 +347,13 @@ describe("release CI summary child correlation", () => { ]), ).toEqual({ json: true, + intervalMs: 30_000, manifestPath: "/tmp/manifest.json", repository: "openclaw/openclaw", runId: "29071366025", trustedWorkflowRef: "main", validate: true, + watch: false, }); expect(parseReleaseCiSummaryArgs(["29071366025"])).toMatchObject({ repository: "openclaw/openclaw", @@ -357,11 +361,74 @@ describe("release CI summary child correlation", () => { 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", ); }); + 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 = { From 883a615516f8d7c65d0195e8ce328003f5059928 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:24:31 -0700 Subject: [PATCH 05/27] fix(testbox): rotate stale reusable leases --- .agents/skills/release-openclaw-ci/SKILL.md | 4 + docs/reference/test.md | 7 ++ scripts/crabbox-wrapper.mjs | 53 +++++++- scripts/testbox-lease-freshness.mjs | 125 +++++++++++++++++++ test/scripts/testbox-lease-freshness.test.ts | 35 ++++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 scripts/testbox-lease-freshness.mjs create mode 100644 test/scripts/testbox-lease-freshness.test.ts diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 0598f7dfd06b..1e15c19ee841 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -39,6 +39,10 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele 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 diff --git a/docs/reference/test.md b/docs/reference/test.md index 6e671433871a..8144e08aba8c 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -24,6 +24,13 @@ stop it before handoff: node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json ``` +After the first successful reuse, the wrapper records the lease's base, +dependency, and Testbox workflow fingerprint under `.crabbox/testbox-leases/`. +Source-only edits keep reusing the warmed box. A changed merge base, lockfile, +package-manager input, wrapper, or Testbox workflow fails closed and requires a +fresh lease. `OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional +diagnostics, not release proof. + Local test commands below are for human workflows or an explicit agent fallback requested by the user. Remote-provider unavailability must be reported; it is not permission to silently run a broad local gate. diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index d40ff1ca30cf..66ef52300656 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -24,6 +24,10 @@ import { isProviderAdvertised, parseProvidersFromHelp, } from "./crabbox-wrapper-providers.mjs"; +import { + prepareTestboxLeaseFreshness, + recordTestboxLeaseFreshness, +} from "./testbox-lease-freshness.mjs"; import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -365,7 +369,11 @@ function buildBatchCommandLine(command, commandArgs) { return `"${[escapedCommand, ...escapedArgs].join(" ")}"`; } -function checkedOutput(command, commandArgs, timeoutMs = resolveMetadataProbeTimeoutMs(process.env)) { +function checkedOutput( + command, + commandArgs, + timeoutMs = resolveMetadataProbeTimeoutMs(process.env), +) { const invocation = spawnInvocation(command, commandArgs, process.env, process.platform); const result = spawnSync(invocation.command, invocation.args, { cwd: repoRoot, @@ -2932,7 +2940,16 @@ function isWorktreeClean() { return gitOutput(["status", "--porcelain=v1"]).stdout === ""; } -function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) { +function hasAgentsBranchChanges() { + const base = gitOutput(["merge-base", "HEAD", "refs/remotes/origin/main"]); + if (base.status !== 0) { + return false; + } + const changed = gitOutput(["diff", "--name-only", `${base.stdout}..HEAD`, "--", ".agents"]); + return changed.status === 0 && changed.stdout.length > 0; +} + +function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, providerName) { if (commandArgs[0] !== "run") { return false; } @@ -2943,7 +2960,11 @@ function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) { return false; } - return isSparseCheckout() || isChangedGateCommand(runCommandArgs(commandArgs)); + return ( + isSparseCheckout() || + isChangedGateCommand(runCommandArgs(commandArgs)) || + (canonicalProviderName(providerName) === "blacksmith-testbox" && hasAgentsBranchChanges()) + ); } function defaultFullCheckoutSyncRoot() { @@ -3329,6 +3350,9 @@ if (normalizedArgs[0] === "run" && isBrokeredWsl2RemoteTarget(normalizedArgs, pr } const childEnv = { ...process.env }; +if (canonicalProvider === "blacksmith-testbox" && !childEnv.CI) { + childEnv.CI = "true"; +} if ( isLocalContainerProvider(provider) && !childEnv.CRABBOX_LOCAL_CONTAINER_DOCKER_SOCKET && @@ -3385,6 +3409,19 @@ const childArgs = ), remoteChangedGateBase, ); +let testboxLeaseFreshness; +try { + testboxLeaseFreshness = prepareTestboxLeaseFreshness({ + args: childArgs, + env: childEnv, + provider: canonicalProvider, + repoRoot, + }); +} catch (error) { + cleanupOnce(); + console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); +} let fullCheckoutKeepaliveIntervalMsValue = 0; if (fullCheckout) { try { @@ -3441,6 +3478,16 @@ child.on("exit", (code, signal) => { if (fullCheckout) { fullCheckoutAvailable = assertFullCheckoutAvailableBeforeExit(fullCheckout.dir); } + if (!signal && code === 0) { + try { + recordTestboxLeaseFreshness(testboxLeaseFreshness); + } catch (error) { + console.error( + `[crabbox] failed to record Testbox lease freshness: ${error instanceof Error ? error.message : String(error)}`, + ); + code = 2; + } + } cleanupOnce(); if (signal) { process.exit(signalExitCodes.get(signal) ?? 1); diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs new file mode 100644 index 000000000000..d3eab7ceabfa --- /dev/null +++ b/scripts/testbox-lease-freshness.mjs @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; + +const STATE_VERSION = 1; +const DEPENDENCY_INPUTS = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"]; +const ENVIRONMENT_INPUTS = [ + ".crabbox.yaml", + ".github/workflows/ci-check-testbox.yml", + ".node-version", + "scripts/crabbox-wrapper.mjs", +]; + +function optionValue(args, name, fallback = "") { + const shortName = name.replace(/^--/u, "-"); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === name || argument === shortName) { + return args[index + 1] ?? fallback; + } + if (argument.startsWith(`${name}=`) || argument.startsWith(`${shortName}=`)) { + return argument.slice(argument.indexOf("=") + 1); + } + } + return fallback; +} + +function git(repoRoot, args) { + return execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).trim(); +} + +function listFiles(path, root = path) { + if (!existsSync(path)) { + return []; + } + if (statSync(path).isFile()) { + return [path]; + } + return readdirSync(path, { withFileTypes: true }) + .flatMap((entry) => listFiles(resolve(path, entry.name), root)) + .toSorted(); +} + +function digestInputs(repoRoot, inputs) { + const hash = createHash("sha256"); + for (const input of inputs) { + for (const path of listFiles(resolve(repoRoot, input))) { + hash.update(path.slice(repoRoot.length)); + hash.update("\0"); + hash.update(readFileSync(path)); + hash.update("\0"); + } + } + return hash.digest("hex"); +} + +export function buildTestboxLeaseFingerprint(repoRoot, args) { + let baseSha; + try { + baseSha = git(repoRoot, ["merge-base", "HEAD", "refs/remotes/origin/main"]); + } catch { + baseSha = git(repoRoot, ["rev-parse", "HEAD"]); + } + return { + version: STATE_VERSION, + baseSha, + dependencyDigest: digestInputs(repoRoot, [...DEPENDENCY_INPUTS, "patches"]), + environmentDigest: digestInputs(repoRoot, ENVIRONMENT_INPUTS), + workflow: optionValue(args, "--blacksmith-workflow", ".github/workflows/ci-check-testbox.yml"), + job: optionValue(args, "--blacksmith-job", "check"), + ref: optionValue(args, "--blacksmith-ref", "main"), + }; +} + +export function testboxLeaseStaleReasons(saved, current) { + if (!saved || saved.version !== STATE_VERSION) { + return ["state schema"]; + } + return ["baseSha", "dependencyDigest", "environmentDigest", "workflow", "job", "ref"].filter( + (key) => saved[key] !== current[key], + ); +} + +export function prepareTestboxLeaseFreshness({ args, env, provider, repoRoot }) { + const id = optionValue(args, "--id"); + if (provider !== "blacksmith-testbox" || args[0] !== "run" || !id?.startsWith("tbx_")) { + return null; + } + const configuredStateDir = env.OPENCLAW_TESTBOX_LEASE_STATE_DIR?.trim(); + if (env.VITEST && !configuredStateDir) { + return null; + } + const stateDir = resolve(configuredStateDir || resolve(repoRoot, ".crabbox", "testbox-leases")); + const path = resolve(stateDir, `${id}.json`); + const current = buildTestboxLeaseFingerprint(repoRoot, args); + if (existsSync(path)) { + const saved = JSON.parse(readFileSync(path, "utf8")); + const staleReasons = testboxLeaseStaleReasons(saved, current); + if (staleReasons.length > 0 && env.OPENCLAW_TESTBOX_ALLOW_STALE !== "1") { + throw new Error( + `Testbox ${id} is stale (${staleReasons.join(", ")}); stop it and warm a fresh lease, or set OPENCLAW_TESTBOX_ALLOW_STALE=1 for an intentional diagnostic reuse`, + ); + } + } + return { current, path }; +} + +export function recordTestboxLeaseFreshness(prepared) { + if (!prepared) { + return; + } + mkdirSync(resolve(prepared.path, ".."), { recursive: true }); + const temporaryPath = `${prepared.path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, `${JSON.stringify(prepared.current, null, 2)}\n`); + renameSync(temporaryPath, prepared.path); +} diff --git a/test/scripts/testbox-lease-freshness.test.ts b/test/scripts/testbox-lease-freshness.test.ts new file mode 100644 index 000000000000..64d8a9d54074 --- /dev/null +++ b/test/scripts/testbox-lease-freshness.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { testboxLeaseStaleReasons } from "../../scripts/testbox-lease-freshness.mjs"; + +const fingerprint = { + version: 1, + baseSha: "a".repeat(40), + dependencyDigest: "b".repeat(64), + environmentDigest: "c".repeat(64), + workflow: ".github/workflows/ci-check-testbox.yml", + job: "check", + ref: "main", +}; + +describe("Testbox lease freshness", () => { + it("reuses a lease when hydrated inputs still match", () => { + expect(testboxLeaseStaleReasons(fingerprint, { ...fingerprint })).toEqual([]); + }); + + it("rotates a lease when base, dependency, or workflow inputs drift", () => { + expect( + testboxLeaseStaleReasons(fingerprint, { + ...fingerprint, + baseSha: "d".repeat(40), + dependencyDigest: "e".repeat(64), + workflow: "other.yml", + }), + ).toEqual(["baseSha", "dependencyDigest", "workflow"]); + }); + + it("rejects unknown provenance schemas", () => { + expect(testboxLeaseStaleReasons({ ...fingerprint, version: 2 }, fingerprint)).toEqual([ + "state schema", + ]); + }); +}); From 28f018e5206d3ccaee8458ddf8fea163f213a959 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:29:23 -0700 Subject: [PATCH 06/27] refactor(release): move CI verifier into scripts --- .agents/skills/release-openclaw-ci/SKILL.md | 4 ++-- scripts/github/find-reusable-release-validation.sh | 2 +- .../scripts => scripts}/release-ci-summary.mjs | 6 +++--- test/scripts/find-reusable-release-validation.test.ts | 2 +- test/scripts/release-ci-summary.test.ts | 10 ++++------ 5 files changed, 11 insertions(+), 13 deletions(-) rename {.agents/skills/release-openclaw-ci/scripts => scripts}/release-ci-summary.mjs (99%) mode change 100755 => 100644 diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 1e15c19ee841..5f93e8d5988d 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -129,13 +129,13 @@ publish workflow reads the effective profile from the full-validation manifest. Use the transition-only summary watcher instead of repeated raw polling: ```bash -node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs --watch +node scripts/release-ci-summary.mjs --watch ``` For a one-shot snapshot: ```bash -node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +node scripts/release-ci-summary.mjs ``` Stop watchers before ending the turn or switching strategy. diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index e4ea54b4283e..543b43142458 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -19,7 +19,7 @@ GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFLIGHT="${SCRIPT_DIR}/../release-preflight.mjs" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -VALIDATOR="${OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR:-${REPO_ROOT}/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs}" +VALIDATOR="${OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR:-${REPO_ROOT}/scripts/release-ci-summary.mjs}" usage() { cat >&2 <<'EOF' diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs old mode 100755 new mode 100644 similarity index 99% rename from .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs rename to scripts/release-ci-summary.mjs index 7a43f0f047b0..d10ad54545c9 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -10,14 +10,14 @@ 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 "../../../../scripts/lib/plain-gh.mjs"; +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 = ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; +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 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; diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index 24c8da630e36..d72777c62c44 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -310,7 +310,7 @@ function normalizedEvidence(options: { validationInputs, verifier: { schemaVersion: 3, - script: ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs", + script: "scripts/release-ci-summary.mjs", scriptSha256: "b".repeat(64), sourceSha: options.verifierSha === undefined ? VERIFIER_SHA : options.verifierSha, }, diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index fe43678318dd..d6378931a49f 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -2,7 +2,7 @@ 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 { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { @@ -29,9 +29,9 @@ import { validateReleaseRunEvidence, validateTrustedProducerIdentity, watchReleaseCiRun, -} from "../../.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; +} from "../../scripts/release-ci-summary.mjs"; -const SCRIPT = ".agents/skills/release-openclaw-ci/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 { @@ -896,9 +896,7 @@ describe("release CI summary child correlation", () => { const outsideCwd = mkdtempSync(join(tmpdir(), "release-verifier-cwd-")); try { const scriptPath = join(repositoryRoot, SCRIPT); - mkdirSync(join(repositoryRoot, ".agents/skills/release-openclaw-ci/scripts"), { - recursive: true, - }); + mkdirSync(dirname(scriptPath), { recursive: true }); writeFileSync(scriptPath, readFileSync(SCRIPT)); execFileSync("git", ["init", "-q"], { cwd: repositoryRoot }); execFileSync("git", ["add", SCRIPT], { cwd: repositoryRoot }); From 48704f065a669345868fe35149b0d90da840cd43 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:29:28 -0700 Subject: [PATCH 07/27] fix(release): preserve verifier executable mode --- scripts/release-ci-summary.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/release-ci-summary.mjs diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs old mode 100644 new mode 100755 From e85a4f0ef286a9a25e5bc111ddace8648e61aa3f Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:29:30 -0700 Subject: [PATCH 08/27] fix(testbox): force noninteractive remote hydration --- scripts/crabbox-wrapper.mjs | 40 ++++++++++++++++------------ test/scripts/crabbox-wrapper.test.ts | 8 +++++- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 66ef52300656..a2a221084cb3 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -2940,16 +2940,7 @@ function isWorktreeClean() { return gitOutput(["status", "--porcelain=v1"]).stdout === ""; } -function hasAgentsBranchChanges() { - const base = gitOutput(["merge-base", "HEAD", "refs/remotes/origin/main"]); - if (base.status !== 0) { - return false; - } - const changed = gitOutput(["diff", "--name-only", `${base.stdout}..HEAD`, "--", ".agents"]); - return changed.status === 0 && changed.stdout.length > 0; -} - -function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, providerName) { +function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) { if (commandArgs[0] !== "run") { return false; } @@ -2960,11 +2951,7 @@ function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, providerName) { return false; } - return ( - isSparseCheckout() || - isChangedGateCommand(runCommandArgs(commandArgs)) || - (canonicalProviderName(providerName) === "blacksmith-testbox" && hasAgentsBranchChanges()) - ); + return isSparseCheckout() || isChangedGateCommand(runCommandArgs(commandArgs)); } function defaultFullCheckoutSyncRoot() { @@ -3194,6 +3181,23 @@ function injectFullCheckoutLeaseReclaim(commandArgs) { return normalizedArgs; } +function injectRemoteTestboxCi(commandArgs, providerName) { + if (commandArgs[0] !== "run" || canonicalProviderName(providerName) !== "blacksmith-testbox") { + return commandArgs; + } + const normalizedArgs = [...commandArgs]; + const { start } = runCommandBounds(normalizedArgs); + if (start < 0) { + return normalizedArgs; + } + if (hasOption(normalizedArgs, "--shell")) { + normalizedArgs[start] = `env CI=true ${normalizedArgs[start]}`; + } else { + normalizedArgs.splice(start, 0, "env", "CI=true"); + } + return normalizedArgs; +} + const version = probeCrabboxMetadata(binary, ["--version"]); const help = probeCrabboxMetadata(binary, ["run", "--help"]); const providers = parseProvidersFromHelp(help.text); @@ -3388,7 +3392,7 @@ try { cleanupOnce(); throw error; } -const childArgs = +const childArgs = injectRemoteTestboxCi( childCwd === repoRoot ? injectRemoteWindowsHydratedNodeModulesBootstrap( injectRemoteAwsMacosSwiftBootstrap( @@ -3408,7 +3412,9 @@ const childArgs = provider, ), remoteChangedGateBase, - ); + ), + provider, +); let testboxLeaseFreshness; try { testboxLeaseFreshness = prepareTestboxLeaseFreshness({ diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 9efd78f7f9b5..92dc04d1b243 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -760,6 +760,8 @@ describe("scripts/crabbox-wrapper", () => { "--id", "tbx_owned", "--", + "env", + "CI=true", "echo ok", ]); }); @@ -782,6 +784,8 @@ describe("scripts/crabbox-wrapper", () => { "--id", "blue-hermit", "--", + "env", + "CI=true", "echo ok", ]); }); @@ -3166,7 +3170,9 @@ describe("scripts/crabbox-wrapper", () => { expect(result.error).toBeUndefined(); expect(result.status).toBe(0); expect(result.stderr).not.toContain("could not parse provider list"); - expect(result.stderr).not.toContain("selected binary failed basic --version/--help sanity checks"); + expect(result.stderr).not.toContain( + "selected binary failed basic --version/--help sanity checks", + ); expect(result.stderr).toContain( "providers=hetzner,aws,local-container,blacksmith-testbox,cloudflare", ); From 29cfc61b6f89c31fb925dffb0beec86887525c20 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:32:08 -0700 Subject: [PATCH 09/27] perf(testbox): skip sync for proven clean heads --- docs/reference/test.md | 3 +- scripts/crabbox-wrapper.mjs | 42 ++++++++++++++------ scripts/testbox-lease-freshness.mjs | 17 +++++++- test/scripts/testbox-lease-freshness.test.ts | 25 +++++++++++- 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/docs/reference/test.md b/docs/reference/test.md index 8144e08aba8c..a70172bcc531 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -29,7 +29,8 @@ dependency, and Testbox workflow fingerprint under `.crabbox/testbox-leases/`. Source-only edits keep reusing the warmed box. A changed merge base, lockfile, package-manager input, wrapper, or Testbox workflow fails closed and requires a fresh lease. `OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional -diagnostics, not release proof. +diagnostics, not release proof. Repeating a run from the exact clean HEAD that +already succeeded skips source sync; a new commit or dirty edit syncs normally. Local test commands below are for human workflows or an explicit agent fallback requested by the user. Remote-provider unavailability must be reported; it is diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index a2a221084cb3..379fcb90a3f7 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -3181,6 +3181,16 @@ function injectFullCheckoutLeaseReclaim(commandArgs) { return normalizedArgs; } +function injectReusableTestboxNoSync(commandArgs) { + if (hasOption(commandArgs, "--no-sync")) { + return commandArgs; + } + const normalizedArgs = [...commandArgs]; + const { optionEnd } = runCommandBounds(normalizedArgs); + normalizedArgs.splice(optionEnd, 0, "--no-sync"); + return normalizedArgs; +} + function injectRemoteTestboxCi(commandArgs, providerName) { if (commandArgs[0] !== "run" || canonicalProviderName(providerName) !== "blacksmith-testbox") { return commandArgs; @@ -3278,6 +3288,25 @@ if (canonicalProvider === "blacksmith-testbox") { enforceCrabboxOwnedBlacksmithLease(normalizedArgs); } +let testboxLeaseFreshness; +try { + testboxLeaseFreshness = prepareTestboxLeaseFreshness({ + args: normalizedArgs, + env: { ...process.env, CI: process.env.CI || "true" }, + provider: canonicalProvider, + repoRoot, + }); + if (testboxLeaseFreshness?.skipSync) { + normalizedArgs = injectReusableTestboxNoSync(normalizedArgs); + console.error( + `[crabbox] Testbox ${optionValue(normalizedArgs, "--id")} already has clean HEAD ${testboxLeaseFreshness.current.headSha}; skipping source sync`, + ); + } +} catch (error) { + console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); +} + let childCwd = repoRoot; let cleanupChildCwd = () => {}; let fullCheckout = null; @@ -3415,19 +3444,6 @@ const childArgs = injectRemoteTestboxCi( ), provider, ); -let testboxLeaseFreshness; -try { - testboxLeaseFreshness = prepareTestboxLeaseFreshness({ - args: childArgs, - env: childEnv, - provider: canonicalProvider, - repoRoot, - }); -} catch (error) { - cleanupOnce(); - console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`); - process.exit(2); -} let fullCheckoutKeepaliveIntervalMsValue = 0; if (fullCheckout) { try { diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs index d3eab7ceabfa..7095b3cf67d0 100644 --- a/scripts/testbox-lease-freshness.mjs +++ b/scripts/testbox-lease-freshness.mjs @@ -73,6 +73,8 @@ export function buildTestboxLeaseFingerprint(repoRoot, args) { return { version: STATE_VERSION, baseSha, + headSha: git(repoRoot, ["rev-parse", "HEAD"]), + workingTreeClean: git(repoRoot, ["status", "--porcelain=v1"]) === "", dependencyDigest: digestInputs(repoRoot, [...DEPENDENCY_INPUTS, "patches"]), environmentDigest: digestInputs(repoRoot, ENVIRONMENT_INPUTS), workflow: optionValue(args, "--blacksmith-workflow", ".github/workflows/ci-check-testbox.yml"), @@ -81,6 +83,12 @@ export function buildTestboxLeaseFingerprint(repoRoot, args) { }; } +export function testboxLeaseCanSkipSync(saved, current) { + return Boolean( + saved?.syncedCleanHead && current.workingTreeClean && saved.syncedCleanHead === current.headSha, + ); +} + export function testboxLeaseStaleReasons(saved, current) { if (!saved || saved.version !== STATE_VERSION) { return ["state schema"]; @@ -110,8 +118,9 @@ export function prepareTestboxLeaseFreshness({ args, env, provider, repoRoot }) `Testbox ${id} is stale (${staleReasons.join(", ")}); stop it and warm a fresh lease, or set OPENCLAW_TESTBOX_ALLOW_STALE=1 for an intentional diagnostic reuse`, ); } + return { current, path, skipSync: testboxLeaseCanSkipSync(saved, current) }; } - return { current, path }; + return { current, path, skipSync: false }; } export function recordTestboxLeaseFreshness(prepared) { @@ -120,6 +129,10 @@ export function recordTestboxLeaseFreshness(prepared) { } mkdirSync(resolve(prepared.path, ".."), { recursive: true }); const temporaryPath = `${prepared.path}.tmp-${process.pid}`; - writeFileSync(temporaryPath, `${JSON.stringify(prepared.current, null, 2)}\n`); + const state = { + ...prepared.current, + syncedCleanHead: prepared.current.workingTreeClean ? prepared.current.headSha : "", + }; + writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`); renameSync(temporaryPath, prepared.path); } diff --git a/test/scripts/testbox-lease-freshness.test.ts b/test/scripts/testbox-lease-freshness.test.ts index 64d8a9d54074..fe5cc9cc107e 100644 --- a/test/scripts/testbox-lease-freshness.test.ts +++ b/test/scripts/testbox-lease-freshness.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from "vitest"; -import { testboxLeaseStaleReasons } from "../../scripts/testbox-lease-freshness.mjs"; +import { + testboxLeaseCanSkipSync, + testboxLeaseStaleReasons, +} from "../../scripts/testbox-lease-freshness.mjs"; const fingerprint = { version: 1, baseSha: "a".repeat(40), + headSha: "d".repeat(40), + workingTreeClean: true, dependencyDigest: "b".repeat(64), environmentDigest: "c".repeat(64), workflow: ".github/workflows/ci-check-testbox.yml", @@ -16,6 +21,24 @@ describe("Testbox lease freshness", () => { expect(testboxLeaseStaleReasons(fingerprint, { ...fingerprint })).toEqual([]); }); + it("skips sync only for the exact clean HEAD already proven on the lease", () => { + expect( + testboxLeaseCanSkipSync( + { ...fingerprint, syncedCleanHead: fingerprint.headSha }, + fingerprint, + ), + ).toBe(true); + expect( + testboxLeaseCanSkipSync( + { ...fingerprint, syncedCleanHead: fingerprint.headSha }, + { ...fingerprint, workingTreeClean: false }, + ), + ).toBe(false); + expect( + testboxLeaseCanSkipSync({ ...fingerprint, syncedCleanHead: "e".repeat(40) }, fingerprint), + ).toBe(false); + }); + it("rotates a lease when base, dependency, or workflow inputs drift", () => { expect( testboxLeaseStaleReasons(fingerprint, { From 1a384f71db0372c2a5a1bbe71b0a8e789abb5252 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:34:21 -0700 Subject: [PATCH 10/27] fix(testbox): keep changed gates synchronized --- docs/reference/test.md | 5 +++-- scripts/crabbox-wrapper.mjs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/reference/test.md b/docs/reference/test.md index a70172bcc531..46ed426089d6 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -29,8 +29,9 @@ dependency, and Testbox workflow fingerprint under `.crabbox/testbox-leases/`. Source-only edits keep reusing the warmed box. A changed merge base, lockfile, package-manager input, wrapper, or Testbox workflow fails closed and requires a fresh lease. `OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional -diagnostics, not release proof. Repeating a run from the exact clean HEAD that -already succeeded skips source sync; a new commit or dirty edit syncs normally. +diagnostics, not release proof. Repeating a targeted run from the exact clean +HEAD that already succeeded skips source sync; changed gates, a new commit, or +a dirty edit sync normally. Local test commands below are for human workflows or an explicit agent fallback requested by the user. Remote-provider unavailability must be reported; it is diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 379fcb90a3f7..f15385d12579 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -3296,7 +3296,7 @@ try { provider: canonicalProvider, repoRoot, }); - if (testboxLeaseFreshness?.skipSync) { + if (testboxLeaseFreshness?.skipSync && !isChangedGateCommand(runCommandArgs(normalizedArgs))) { normalizedArgs = injectReusableTestboxNoSync(normalizedArgs); console.error( `[crabbox] Testbox ${optionValue(normalizedArgs, "--id")} already has clean HEAD ${testboxLeaseFreshness.current.headSha}; skipping source sync`, From 947803066b387f91df62fac5e10537b104e16f7d Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:35:23 -0700 Subject: [PATCH 11/27] fix(testbox): isolate git state probes --- scripts/crabbox-wrapper.mjs | 3 ++- scripts/testbox-lease-freshness.mjs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index f15385d12579..f06d765713bc 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -2937,7 +2937,8 @@ function isSparseCheckout() { } function isWorktreeClean() { - return gitOutput(["status", "--porcelain=v1"]).stdout === ""; + const status = gitOutput(["-c", "core.excludesFile=/dev/null", "status", "--porcelain=v1"]); + return status.status === 0 && status.stdout === ""; } function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) { diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs index 7095b3cf67d0..9d6607ecd786 100644 --- a/scripts/testbox-lease-freshness.mjs +++ b/scripts/testbox-lease-freshness.mjs @@ -35,7 +35,9 @@ function optionValue(args, name, fallback = "") { } function git(repoRoot, args) { - return execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).trim(); + return execFileSync("git", ["-c", "core.excludesFile=/dev/null", "-C", repoRoot, ...args], { + encoding: "utf8", + }).trim(); } function listFiles(path, root = path) { From 2843773be77e4b60c698e8215ff8031386fa6b5a Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:35:41 -0700 Subject: [PATCH 12/27] fix(testbox): isolate wrapper git commands --- scripts/crabbox-wrapper.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index f06d765713bc..08bb64e74c1e 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -459,7 +459,12 @@ function satisfiesMinimumCrabboxVersion(version, minimum) { function gitOutput(commandArgs) { const gitBinary = resolvePathBinary("git", process.env, process.platform) ?? "git"; - const invocation = spawnInvocation(gitBinary, commandArgs, process.env, process.platform); + const invocation = spawnInvocation( + gitBinary, + ["-c", "core.excludesFile=/dev/null", ...commandArgs], + process.env, + process.platform, + ); const result = spawnSync(invocation.command, invocation.args, { cwd: repoRoot, encoding: "utf8", @@ -2937,7 +2942,7 @@ function isSparseCheckout() { } function isWorktreeClean() { - const status = gitOutput(["-c", "core.excludesFile=/dev/null", "status", "--porcelain=v1"]); + const status = gitOutput(["status", "--porcelain=v1"]); return status.status === 0 && status.stdout === ""; } From 1bb51a59af1e4446abb3a09e4806902b79bd03ea Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:39:27 -0700 Subject: [PATCH 13/27] fix(testbox): preserve git command contracts --- scripts/crabbox-wrapper.mjs | 9 +++------ scripts/testbox-lease-freshness.mjs | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 08bb64e74c1e..1ec4cda43309 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -459,15 +459,12 @@ function satisfiesMinimumCrabboxVersion(version, minimum) { function gitOutput(commandArgs) { const gitBinary = resolvePathBinary("git", process.env, process.platform) ?? "git"; - const invocation = spawnInvocation( - gitBinary, - ["-c", "core.excludesFile=/dev/null", ...commandArgs], - process.env, - process.platform, - ); + const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null" }; + const invocation = spawnInvocation(gitBinary, commandArgs, gitEnv, process.platform); const result = spawnSync(invocation.command, invocation.args, { cwd: repoRoot, encoding: "utf8", + env: gitEnv, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs index 9d6607ecd786..22e1da91ba8f 100644 --- a/scripts/testbox-lease-freshness.mjs +++ b/scripts/testbox-lease-freshness.mjs @@ -35,8 +35,9 @@ function optionValue(args, name, fallback = "") { } function git(repoRoot, args) { - return execFileSync("git", ["-c", "core.excludesFile=/dev/null", "-C", repoRoot, ...args], { + return execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null" }, }).trim(); } From 50319d27b0a38eceb629054954b58a5993fb51a8 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:48:23 -0700 Subject: [PATCH 14/27] fix(release): validate reused SHA evidence --- scripts/full-release-validation-at-sha.mjs | 59 ++++++------------- .../full-release-validation-at-sha.test.ts | 16 ++++- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index e11c131ef89e..e1748276eac2 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -18,8 +18,9 @@ function usage() { Creates a temporary remote branch pinned to trusted main release tooling, dispatches Full Release Validation with the target commit as its ref input, watches the parent run, verifies all child workflow head SHAs match the trusted -workflow SHA, then deletes the temporary branch by default. Exact-target -evidence reuse stays enabled; pass -f reuse_evidence=false to force a fresh run.`); +workflow lineage through the release evidence manifest, then deletes the +temporary branch by default. Exact-target evidence reuse stays enabled; pass +-f reuse_evidence=false to force a fresh run.`); } function run(command, args, options = {}) { @@ -177,48 +178,24 @@ function findLatestRunId(branch, sha) { return match?.databaseId ? String(match.databaseId) : ""; } -function childRunIds(parentRunId) { - const jobsJson = run("gh", ["run", "view", parentRunId, "--json", "jobs"]); - const jobs = JSON.parse(jobsJson).jobs ?? []; - const summaryJob = jobs.find((job) => job.name === "Verify full validation"); - if (!summaryJob?.databaseId) { - return []; +export function releaseEvidenceVerificationArgs(parentRunId) { + if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) { + throw new Error("parent run ID must be a positive decimal"); } - const log = run("gh", [ - "run", - "view", - parentRunId, - "--job", - String(summaryJob.databaseId), - "--log", - ]); - return [...new Set([...log.matchAll(/actions\/runs\/(\d+)/g)].map((match) => match[1]))]; + return ["--validate-run", String(parentRunId), "--trusted-workflow-ref", "main", "--json"]; } -function verifyChildHeads(parentRunId, workflowSha) { - const ids = childRunIds(parentRunId); - if (ids.length === 0) { - throw new Error( - `Could not find child workflow run ids in parent verifier logs for ${parentRunId}.`, - ); - } - - let failed = false; - for (const id of ids) { - const json = run("gh", ["run", "view", id, "--json", "name,status,conclusion,headSha,url"]); - const child = JSON.parse(json); - const ok = - child.headSha === workflowSha && - child.status === "completed" && - child.conclusion === "success"; - console.log( - `${ok ? "ok" : "bad"} ${child.name} ${child.status}/${child.conclusion} ${child.headSha} ${child.url}`, - ); - failed ||= !ok; - } - if (failed) { - throw new Error(`One or more child workflows failed or did not run at ${workflowSha}.`); +function verifyReleaseEvidence(parentRunId) { + const verifier = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)); + const evidence = JSON.parse( + run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), + ); + if (evidence.valid !== true) { + throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`); } + console.log( + `ok release evidence current=${evidence.current.runId} root=${evidence.root.runId} reused=${Boolean(evidence.evidenceReuse)}`, + ); } function main() { @@ -280,7 +257,7 @@ function main() { `Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`, ); } - verifyChildHeads(parentRunId, workflowSha); + verifyReleaseEvidence(parentRunId); } finally { if (!args.keepBranch) { run("git", ["push", "origin", `:${remoteBranchRef}`], { diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index f8beeec6c8ff..0887c792feeb 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { parseArgs } from "../../scripts/full-release-validation-at-sha.mjs"; +import { + parseArgs, + releaseEvidenceVerificationArgs, +} from "../../scripts/full-release-validation-at-sha.mjs"; describe("full-release-validation-at-sha", () => { it("parses release validation dispatch args", () => { @@ -51,4 +54,15 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["-f", "ref=other"])).toThrow("reserves the ref input"); expect(() => parseArgs(["--", "ref=other"])).toThrow("reserves the ref input"); }); + + it("validates direct and reused runs through the strict evidence verifier", () => { + expect(releaseEvidenceVerificationArgs("123")).toEqual([ + "--validate-run", + "123", + "--trusted-workflow-ref", + "main", + "--json", + ]); + expect(() => releaseEvidenceVerificationArgs("")).toThrow("positive decimal"); + }); }); From 1fa8ec497e4d270223ceb7ecd7f35007f8127cf0 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:48:25 -0700 Subject: [PATCH 15/27] fix(release): resume serialized plugin selections --- scripts/release-candidate-checklist.mjs | 3 ++- test/scripts/release-candidate-checklist.test.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 4be0aa170583..cad69b1d13fb 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -14,6 +14,7 @@ import { 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 { @@ -320,7 +321,7 @@ export function reconcileReleaseCandidateState(saved, expected) { throw new Error("release candidate state has an unsupported schema"); } for (const key of RELEASE_CANDIDATE_STATE_KEYS) { - if (saved[key] !== expected[key]) { + 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])}`, ); diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 4e2a02b92494..97c3b0860f81 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -49,12 +49,14 @@ describe("release candidate checklist", () => { toolingSha: "b".repeat(40), }); const resumed = reconcileReleaseCandidateState( - { - ...expected, - phase: "waiting", - fullReleaseRunId: "111", - npmPreflightRunId: "222", - }, + JSON.parse( + JSON.stringify({ + ...expected, + phase: "waiting", + fullReleaseRunId: "111", + npmPreflightRunId: "222", + }), + ), expected, ); From cf3443796ce23fba3f7495b64a2cde2a8cd5afd7 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:48:28 -0700 Subject: [PATCH 16/27] fix(testbox): sync source on every lease reuse --- docs/reference/test.md | 7 +++--- scripts/crabbox-wrapper.mjs | 16 -------------- scripts/testbox-lease-freshness.mjs | 16 +++----------- test/scripts/testbox-lease-freshness.test.ts | 23 +------------------- 4 files changed, 7 insertions(+), 55 deletions(-) diff --git a/docs/reference/test.md b/docs/reference/test.md index 46ed426089d6..b44abcda821f 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -28,10 +28,9 @@ After the first successful reuse, the wrapper records the lease's base, dependency, and Testbox workflow fingerprint under `.crabbox/testbox-leases/`. Source-only edits keep reusing the warmed box. A changed merge base, lockfile, package-manager input, wrapper, or Testbox workflow fails closed and requires a -fresh lease. `OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional -diagnostics, not release proof. Repeating a targeted run from the exact clean -HEAD that already succeeded skips source sync; changed gates, a new commit, or -a dirty edit sync normally. +fresh lease. Every run still syncs the current checkout. +`OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional diagnostics, not +release proof. Local test commands below are for human workflows or an explicit agent fallback requested by the user. Remote-provider unavailability must be reported; it is diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 1ec4cda43309..b73310ed0edf 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -3184,16 +3184,6 @@ function injectFullCheckoutLeaseReclaim(commandArgs) { return normalizedArgs; } -function injectReusableTestboxNoSync(commandArgs) { - if (hasOption(commandArgs, "--no-sync")) { - return commandArgs; - } - const normalizedArgs = [...commandArgs]; - const { optionEnd } = runCommandBounds(normalizedArgs); - normalizedArgs.splice(optionEnd, 0, "--no-sync"); - return normalizedArgs; -} - function injectRemoteTestboxCi(commandArgs, providerName) { if (commandArgs[0] !== "run" || canonicalProviderName(providerName) !== "blacksmith-testbox") { return commandArgs; @@ -3299,12 +3289,6 @@ try { provider: canonicalProvider, repoRoot, }); - if (testboxLeaseFreshness?.skipSync && !isChangedGateCommand(runCommandArgs(normalizedArgs))) { - normalizedArgs = injectReusableTestboxNoSync(normalizedArgs); - console.error( - `[crabbox] Testbox ${optionValue(normalizedArgs, "--id")} already has clean HEAD ${testboxLeaseFreshness.current.headSha}; skipping source sync`, - ); - } } catch (error) { console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`); process.exit(2); diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs index 22e1da91ba8f..dfa2a1c6c968 100644 --- a/scripts/testbox-lease-freshness.mjs +++ b/scripts/testbox-lease-freshness.mjs @@ -86,12 +86,6 @@ export function buildTestboxLeaseFingerprint(repoRoot, args) { }; } -export function testboxLeaseCanSkipSync(saved, current) { - return Boolean( - saved?.syncedCleanHead && current.workingTreeClean && saved.syncedCleanHead === current.headSha, - ); -} - export function testboxLeaseStaleReasons(saved, current) { if (!saved || saved.version !== STATE_VERSION) { return ["state schema"]; @@ -121,9 +115,9 @@ export function prepareTestboxLeaseFreshness({ args, env, provider, repoRoot }) `Testbox ${id} is stale (${staleReasons.join(", ")}); stop it and warm a fresh lease, or set OPENCLAW_TESTBOX_ALLOW_STALE=1 for an intentional diagnostic reuse`, ); } - return { current, path, skipSync: testboxLeaseCanSkipSync(saved, current) }; + return { current, path }; } - return { current, path, skipSync: false }; + return { current, path }; } export function recordTestboxLeaseFreshness(prepared) { @@ -132,10 +126,6 @@ export function recordTestboxLeaseFreshness(prepared) { } mkdirSync(resolve(prepared.path, ".."), { recursive: true }); const temporaryPath = `${prepared.path}.tmp-${process.pid}`; - const state = { - ...prepared.current, - syncedCleanHead: prepared.current.workingTreeClean ? prepared.current.headSha : "", - }; - writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`); + writeFileSync(temporaryPath, `${JSON.stringify(prepared.current, null, 2)}\n`); renameSync(temporaryPath, prepared.path); } diff --git a/test/scripts/testbox-lease-freshness.test.ts b/test/scripts/testbox-lease-freshness.test.ts index fe5cc9cc107e..12574b671cef 100644 --- a/test/scripts/testbox-lease-freshness.test.ts +++ b/test/scripts/testbox-lease-freshness.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - testboxLeaseCanSkipSync, - testboxLeaseStaleReasons, -} from "../../scripts/testbox-lease-freshness.mjs"; +import { testboxLeaseStaleReasons } from "../../scripts/testbox-lease-freshness.mjs"; const fingerprint = { version: 1, @@ -21,24 +18,6 @@ describe("Testbox lease freshness", () => { expect(testboxLeaseStaleReasons(fingerprint, { ...fingerprint })).toEqual([]); }); - it("skips sync only for the exact clean HEAD already proven on the lease", () => { - expect( - testboxLeaseCanSkipSync( - { ...fingerprint, syncedCleanHead: fingerprint.headSha }, - fingerprint, - ), - ).toBe(true); - expect( - testboxLeaseCanSkipSync( - { ...fingerprint, syncedCleanHead: fingerprint.headSha }, - { ...fingerprint, workingTreeClean: false }, - ), - ).toBe(false); - expect( - testboxLeaseCanSkipSync({ ...fingerprint, syncedCleanHead: "e".repeat(40) }, fingerprint), - ).toBe(false); - }); - it("rotates a lease when base, dependency, or workflow inputs drift", () => { expect( testboxLeaseStaleReasons(fingerprint, { From fb4ffe76e6e87af33716474d4231b4596c320949 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:32 -0700 Subject: [PATCH 17/27] fix(release): verify from trusted workflow checkout --- scripts/full-release-validation-at-sha.mjs | 36 +++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index e1748276eac2..04e844b9f44d 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node // Dispatches full release validation against a temporary SHA-pinned branch. import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { pathToFileURL } from "node:url"; const WORKFLOW = "full-release-validation.yml"; @@ -185,17 +188,28 @@ export function releaseEvidenceVerificationArgs(parentRunId) { return ["--validate-run", String(parentRunId), "--trusted-workflow-ref", "main", "--json"]; } -function verifyReleaseEvidence(parentRunId) { - const verifier = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)); - const evidence = JSON.parse( - run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), - ); - if (evidence.valid !== true) { - throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`); +function verifyReleaseEvidence(parentRunId, workflowSha) { + const verifierWorktree = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-")); + try { + run("git", ["worktree", "add", "--detach", verifierWorktree, workflowSha], { + stdio: ["ignore", "ignore", "inherit"], + }); + const verifier = join(verifierWorktree, "scripts", "release-ci-summary.mjs"); + const evidence = JSON.parse( + run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), + ); + if (evidence.valid !== true) { + throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`); + } + console.log( + `ok release evidence current=${evidence.current.runId} root=${evidence.root.runId} reused=${Boolean(evidence.evidenceReuse)}`, + ); + } finally { + runStatus("git", ["worktree", "remove", "--force", verifierWorktree], { + stdio: ["ignore", "ignore", "ignore"], + }); + rmSync(verifierWorktree, { force: true, recursive: true }); } - console.log( - `ok release evidence current=${evidence.current.runId} root=${evidence.root.runId} reused=${Boolean(evidence.evidenceReuse)}`, - ); } function main() { @@ -257,7 +271,7 @@ function main() { `Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`, ); } - verifyReleaseEvidence(parentRunId); + verifyReleaseEvidence(parentRunId, workflowSha); } finally { if (!args.keepBranch) { run("git", ["push", "origin", `:${remoteBranchRef}`], { From e0cb3cef93e07414409ec819a139bb0475557970 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:57:32 -0700 Subject: [PATCH 18/27] fix(release): gate evidence reuse on trusted lineage --- .github/workflows/full-release-validation.yml | 2 + .../find-reusable-release-validation.sh | 27 ++++++++++++ .../find-reusable-release-validation.test.ts | 42 ++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 0e719b329154..d7bf986c2f7b 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -276,6 +276,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + WORKFLOW_REF: ${{ github.ref_name }} RELEASE_PROFILE: ${{ inputs.release_profile }} RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} PROVIDER: ${{ inputs.provider }} @@ -309,6 +310,7 @@ jobs: bash workflow/scripts/github/find-reusable-release-validation.sh \ --target-sha "$TARGET_SHA" \ --workflow-sha "$GITHUB_SHA" \ + --workflow-ref "$WORKFLOW_REF" \ --release-profile "$RELEASE_PROFILE" \ --run-release-soak "$RUN_RELEASE_SOAK" \ --inputs-json "$inputs_json" \ diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index 543b43142458..53cacf241d6a 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -10,6 +10,7 @@ REPO="${GH_REPO:-}" WORKFLOW_FILE="full-release-validation.yml" TARGET_SHA="" VERIFIER_WORKFLOW_SHA="" +WORKFLOW_REF="" RELEASE_PROFILE="" RUN_RELEASE_SOAK="false" INPUTS_JSON="" @@ -24,6 +25,7 @@ VALIDATOR="${OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR:-${REPO_ROOT}/scripts/release usage() { cat >&2 <<'EOF' Usage: find-reusable-release-validation.sh --target-sha --workflow-sha \ + --workflow-ref \ --release-profile --inputs-json \ [--run-release-soak ] [--repo ] [--repo-dir ] \ [--workflow ] [--max-candidates ] [--github-output ] @@ -47,6 +49,10 @@ while [[ $# -gt 0 ]]; do VERIFIER_WORKFLOW_SHA="${2:-}" shift 2 ;; + --workflow-ref) + WORKFLOW_REF="${2:-}" + shift 2 + ;; --release-profile) RELEASE_PROFILE="${2:-}" shift 2 @@ -116,6 +122,13 @@ if [[ ! "$VERIFIER_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "Expected --workflow-sha to be a full lowercase commit SHA; got: ${VERIFIER_WORKFLOW_SHA}" >&2 exit 2 fi +if [[ "$WORKFLOW_REF" != "main" ]]; then + expected_release_ref="release-ci/${VERIFIER_WORKFLOW_SHA:0:12}-" + if [[ ! "$WORKFLOW_REF" =~ ^release-ci/[0-9a-f]{12}-[1-9][0-9]*$ ]] || + [[ "$WORKFLOW_REF" != "$expected_release_ref"* ]]; then + no_reuse "workflow ref is not a canonical SHA-pinned release ref" + fi +fi if [[ -z "$REPO" ]]; then echo "Expected --repo or GH_REPO." >&2 exit 2 @@ -134,6 +147,20 @@ if ! expected_inputs="$(jq -Sc 'if type == "object" then . else error("expected exit 2 fi +workflow_lineage="" +if ! workflow_lineage="$( + gh api "repos/${REPO}/compare/${VERIFIER_WORKFLOW_SHA}...main" +)"; then + no_reuse "could not verify workflow SHA against trusted main" +fi +if ! jq -e \ + --arg workflow_sha "$VERIFIER_WORKFLOW_SHA" ' + (.status == "ahead" or .status == "identical") + and .merge_base_commit.sha == $workflow_sha + ' <<< "$workflow_lineage" >/dev/null; then + no_reuse "workflow SHA is not on trusted main lineage" +fi + # Exact-target reuse still requires internally consistent version stamps # (for example package.json must agree with the macOS plist). if ! (cd "$REPO_DIR" && node "$PREFLIGHT" --macos-versions-only >&2); then diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index d72777c62c44..2ea11c1299fb 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -409,8 +409,18 @@ function runResolver(args: { runReleaseSoak?: string; targetSha: string; validatorPath: string; + verifierOnMain?: boolean; verifierSha?: string; + workflowRef?: string; }) { + const verifierSha = args.verifierSha ?? VERIFIER_SHA; + writeFileSync( + fixtureName(args.fixtures, `repos/${REPOSITORY}/compare/${verifierSha}...main`), + JSON.stringify({ + merge_base_commit: { sha: args.verifierOnMain === false ? "f".repeat(40) : verifierSha }, + status: args.verifierOnMain === false ? "diverged" : "ahead", + }), + ); return spawnSync( "bash", [ @@ -418,7 +428,9 @@ function runResolver(args: { "--target-sha", args.targetSha, "--workflow-sha", - args.verifierSha ?? VERIFIER_SHA, + verifierSha, + "--workflow-ref", + args.workflowRef ?? "main", "--release-profile", args.releaseProfile ?? "full", "--run-release-soak", @@ -459,6 +471,34 @@ function parseOutput(output: string): Record { } describe("scripts/github/find-reusable-release-validation.sh", () => { + it("rejects noncanonical release refs and workflow SHAs outside trusted main", () => { + const { origin, priorSha } = createRepo(); + const clone = cloneHead(origin); + const record = normalizedEvidence({ targetSha: priorSha }); + const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]); + + const forgedRef = runResolver({ + binDir, + fixtures, + repoDir: clone, + targetSha: priorSha, + validatorPath, + workflowRef: "release-ci/not-trusted", + }); + expect(parseOutput(forgedRef.stdout)).toMatchObject({ reuse: "false" }); + + const untrustedSha = runResolver({ + binDir, + fixtures, + repoDir: clone, + targetSha: priorSha, + validatorPath, + verifierOnMain: false, + workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`, + }); + expect(parseOutput(untrustedSha.stdout)).toMatchObject({ reuse: "false" }); + }); + it("reuses pre-tooling trusted-main evidence for the exact target", () => { const { origin, priorSha } = createRepo(); const clone = cloneHead(origin); From 349728abf88f6d6af71048b58fb9ca218fd50009 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:57:34 -0700 Subject: [PATCH 19/27] fix(release): support legacy verifier checkouts --- scripts/full-release-validation-at-sha.mjs | 23 +++++++++++++-- .../full-release-validation-at-sha.test.ts | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index 04e844b9f44d..be199a5c30ab 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Dispatches full release validation against a temporary SHA-pinned branch. import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -188,13 +188,32 @@ export function releaseEvidenceVerificationArgs(parentRunId) { return ["--validate-run", String(parentRunId), "--trusted-workflow-ref", "main", "--json"]; } +export function releaseEvidenceVerifierPath(worktreeRoot) { + const candidates = [ + join(worktreeRoot, "scripts", "release-ci-summary.mjs"), + join( + worktreeRoot, + ".agents", + "skills", + "release-openclaw-ci", + "scripts", + "release-ci-summary.mjs", + ), + ]; + const verifier = candidates.find((candidate) => existsSync(candidate)); + if (!verifier) { + throw new Error("trusted workflow checkout does not contain a release evidence verifier"); + } + return verifier; +} + function verifyReleaseEvidence(parentRunId, workflowSha) { const verifierWorktree = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-")); try { run("git", ["worktree", "add", "--detach", verifierWorktree, workflowSha], { stdio: ["ignore", "ignore", "inherit"], }); - const verifier = join(verifierWorktree, "scripts", "release-ci-summary.mjs"); + const verifier = releaseEvidenceVerifierPath(verifierWorktree); const evidence = JSON.parse( run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), ); diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 0887c792feeb..cf44371204fb 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -1,7 +1,11 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parseArgs, releaseEvidenceVerificationArgs, + releaseEvidenceVerifierPath, } from "../../scripts/full-release-validation-at-sha.mjs"; describe("full-release-validation-at-sha", () => { @@ -65,4 +69,28 @@ describe("full-release-validation-at-sha", () => { ]); expect(() => releaseEvidenceVerificationArgs("")).toThrow("positive decimal"); }); + + it("supports current and legacy verifier locations in trusted workflow checkouts", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-path-")); + try { + const legacy = join( + root, + ".agents", + "skills", + "release-openclaw-ci", + "scripts", + "release-ci-summary.mjs", + ); + mkdirSync(join(legacy, ".."), { recursive: true }); + writeFileSync(legacy, ""); + expect(releaseEvidenceVerifierPath(root)).toBe(legacy); + + const current = join(root, "scripts", "release-ci-summary.mjs"); + mkdirSync(join(current, ".."), { recursive: true }); + writeFileSync(current, ""); + expect(releaseEvidenceVerifierPath(root)).toBe(current); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); }); From 50b765617ff9a75958857f823566f6c163ddb56c Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:19 -0700 Subject: [PATCH 20/27] fix(testbox): export CI across shell snippets --- scripts/crabbox-wrapper.mjs | 2 +- test/scripts/crabbox-wrapper.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index b73310ed0edf..cdac2987fa41 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -3194,7 +3194,7 @@ function injectRemoteTestboxCi(commandArgs, providerName) { return normalizedArgs; } if (hasOption(normalizedArgs, "--shell")) { - normalizedArgs[start] = `env CI=true ${normalizedArgs[start]}`; + normalizedArgs[start] = `export CI=true; ${normalizedArgs[start]}`; } else { normalizedArgs.splice(start, 0, "env", "CI=true"); } diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 92dc04d1b243..1906d780af93 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -790,6 +790,30 @@ describe("scripts/crabbox-wrapper", () => { ]); }); + it("exports CI for complete Blacksmith Testbox shell snippets", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + [ + "run", + "--provider", + "blacksmith-testbox", + "--shell", + "--", + "cd packages && pnpm install && pnpm build", + ], + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toEqual([ + "run", + "--provider", + "blacksmith-testbox", + "--shell", + "--", + "export CI=true; cd packages && pnpm install && pnpm build", + ]); + }); + it("only forces the short local-container Docker work root on Linux", () => { const result = runWrapper( "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", From 6b38f68a0648d28337809757418a47cef432475c Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:22 -0700 Subject: [PATCH 21/27] fix(release): revalidate reused evidence before publish --- .../workflows/openclaw-release-publish.yml | 7 +- scripts/release-candidate-checklist.mjs | 3 + scripts/release-ci-summary.mjs | 15 ++++- ...idate-full-release-validation-evidence.mjs | 67 +++++++++++++++++++ .../package-acceptance-workflow.test.ts | 3 + .../release-candidate-checklist.test.ts | 1 + test/scripts/release-ci-summary.test.ts | 23 +++++++ ...e-full-release-validation-evidence.test.ts | 47 +++++++++++++ 8 files changed, 164 insertions(+), 2 deletions(-) diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 662c26e19746..6d49091da522 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -357,9 +357,13 @@ jobs: run: | set -euo pipefail tooling_dir="${RUNNER_TEMP}/release-validation-tooling" - mkdir -p "$tooling_dir" + 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -429,6 +433,7 @@ jobs: 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 manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json" diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index cad69b1d13fb..94743d3a8825 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -29,6 +29,7 @@ import { } from "./render-github-release-notes.mjs"; import { isShaPinnedReleaseValidationBranch, + runStrictReleaseEvidenceValidation, validateFullReleaseValidationEvidence, } from "./validate-full-release-validation-evidence.mjs"; @@ -1356,6 +1357,8 @@ async function main() { expectedTargetSha: targetSha, expectedWorkflowBranch: options.workflowRef, isTrustedMainAncestor: (sha) => gitIsAncestor(sha, "refs/remotes/origin/main"), + validateEvidenceReuseStrictly: ({ repository, runId }) => + runStrictReleaseEvidenceValidation({ repository, runId }), }); if (fullValidationEvidence.source === "direct" && fullRun.headSha !== targetSha) { throw new Error(`run SHA mismatch: tag=${targetSha} full=${fullRun.headSha}`); diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index d10ad54545c9..9070a90911a9 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -1402,6 +1402,8 @@ export function parseReleaseCiSummaryArgs(argv) { runId: undefined, trustedWorkflowRef: "main", validate: false, + verifierSourceFile: undefined, + verifierSourceSha: undefined, watch: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -1415,6 +1417,10 @@ export function parseReleaseCiSummaryArgs(argv) { 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") { @@ -1437,6 +1443,9 @@ export function parseReleaseCiSummaryArgs(argv) { 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"); } @@ -1448,7 +1457,7 @@ function printUsage() { [ "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] --json", + " 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"), ); } @@ -1538,6 +1547,10 @@ async function main() { 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) { diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index 5c26a99dba96..4f35212674f1 100755 --- a/scripts/validate-full-release-validation-evidence.mjs +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -49,6 +49,7 @@ export function validateFullReleaseValidationEvidence({ expectedTargetSha, expectedWorkflowBranch, isTrustedMainAncestor, + validateEvidenceReuseStrictly, }) { const run = normalizeFullReleaseValidationRun(rawRun); const checks = [ @@ -151,6 +152,27 @@ export function validateFullReleaseValidationEvidence({ ) { 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."); + } } if (!isTrustedMainAncestor?.(run.headSha)) { throw new Error( @@ -160,6 +182,42 @@ export function validateFullReleaseValidationEvidence({ 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", @@ -193,6 +251,15 @@ function main() { expectedTargetSha: process.env.EXPECTED_SHA, expectedWorkflowBranch: process.env.EXPECTED_WORKFLOW_BRANCH, 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}`, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 1bd39e4e10ea..cad847c387e3 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2844,11 +2844,14 @@ describe("package artifact reuse", () => { ); expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}"); expect(trustedTooling.run).toContain("validate-full-release-validation-evidence.mjs"); + expect(trustedTooling.run).toContain("release-ci-summary.mjs"); + expect(trustedTooling.run).toContain("scripts/lib/plain-gh.mjs"); expect(validateManifest.env).toMatchObject({ 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", }); expect(validateManifest.run).toContain( 'MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"', diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 97c3b0860f81..043fcd5c3e1e 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -638,6 +638,7 @@ describe("release candidate checklist", () => { expect(source).toContain( "const fullValidationEvidence = validateFullReleaseValidationEvidence({", ); + expect(source).toContain("runStrictReleaseEvidenceValidation({ repository, runId })"); expect(source).toContain("refs/heads/main:refs/remotes/origin/main"); expect(source).toContain( 'fullValidationEvidence.source === "direct" && fullRun.headSha !== targetSha', diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index d6378931a49f..91fb756c666c 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -353,6 +353,8 @@ describe("release CI summary child correlation", () => { runId: "29071366025", trustedWorkflowRef: "main", validate: true, + verifierSourceFile: undefined, + verifierSourceSha: undefined, watch: false, }); expect(parseReleaseCiSummaryArgs(["29071366025"])).toMatchObject({ @@ -376,6 +378,27 @@ describe("release CI summary child correlation", () => { 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", () => { diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index e88b8c496c03..e4655b4dc259 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -53,6 +53,21 @@ function exactTargetEvidenceReuse() { }; } +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 = {}, @@ -67,6 +82,7 @@ function validate( expectedTargetSha: targetSha, expectedWorkflowBranch: "release/2026.7.1", isTrustedMainAncestor, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), }); return { isTrustedMainAncestor, result }; } @@ -192,6 +208,36 @@ describe("full release validation evidence", () => { ); }); + 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( @@ -219,6 +265,7 @@ describe("full release validation evidence", () => { expectedTargetSha: targetSha, expectedWorkflowBranch: pinnedBranch, isTrustedMainAncestor: () => false, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), }), ).toThrow("evidence reuse is invalid"); }); From f677d924f9d351aea5baa28f02db456e4c1eadb0 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:40 -0700 Subject: [PATCH 22/27] fix(release): reject untrusted reuse before lookup --- scripts/validate-full-release-validation-evidence.mjs | 10 +++++----- .../validate-full-release-validation-evidence.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index 4f35212674f1..683b6268d439 100755 --- a/scripts/validate-full-release-validation-evidence.mjs +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -137,6 +137,11 @@ export function validateFullReleaseValidationEvidence({ `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 ( @@ -174,11 +179,6 @@ export function validateFullReleaseValidationEvidence({ throw new Error("SHA-pinned validation evidence reuse failed strict chain validation."); } } - if (!isTrustedMainAncestor?.(run.headSha)) { - throw new Error( - `SHA-pinned validation workflow ${run.headSha} is not reachable from current main.`, - ); - } return { run, source: "sha-pinned-main" }; } diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index e4655b4dc259..660ceb8f22a0 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -264,7 +264,7 @@ describe("full release validation evidence", () => { expectedRunId: "123", expectedTargetSha: targetSha, expectedWorkflowBranch: pinnedBranch, - isTrustedMainAncestor: () => false, + isTrustedMainAncestor: () => true, validateEvidenceReuseStrictly: () => strictEvidenceReuse(), }), ).toThrow("evidence reuse is invalid"); From bcb10e2a2837e5a8a9aefe4e98eaaeb20f63e3d8 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:12:49 -0700 Subject: [PATCH 23/27] fix(release): reuse SHA-pinned root evidence --- .../find-reusable-release-validation.sh | 27 ++++++--- .../find-reusable-release-validation.test.ts | 57 ++++++++++++++++--- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index 53cacf241d6a..f7ffeb747e82 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -218,22 +218,33 @@ for ((index = 0; index < run_count; index += 1)); do and (.root.targetSha | type == "string" and test("^[0-9a-f]{40}$")) and (.root.artifact.digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) and all($record.current, $record.root; - .producerOnTrustedMainLineage == true - and .workflowFullRef == "refs/heads/main" + . as $parent + | .producerOnTrustedMainLineage == true and .workflowRefType == "branch" and .workflowPath == ".github/workflows/full-release-validation.yml" + and .workflowFullRef == ("refs/heads/" + .workflowRef) and .workflowQualifiedPath == - ".github/workflows/full-release-validation.yml@refs/heads/main" + (".github/workflows/full-release-validation.yml@" + .workflowFullRef) and ( .workflowRunPath == ".github/workflows/full-release-validation.yml" - or .workflowRunPath == - ".github/workflows/full-release-validation.yml@refs/heads/main" + or .workflowRunPath == .workflowQualifiedPath ) and ( - (.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch") + ( + .workflowRef == "main" + and ( + (.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch") + or ( + .manifestVersion == 2 + and .workflowRefProof == "legacy-v2-main-ancestry" + ) + ) + ) or ( - .manifestVersion == 2 - and .workflowRefProof == "legacy-v2-main-ancestry" + .manifestVersion == 3 + and .workflowRefProof == "manifest-v3-sha-pinned-main-ancestry" + and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$")) + and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-")) ) ) ) diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index 2ea11c1299fb..fc75854cf4df 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -178,17 +178,24 @@ function normalizedEvidence(options: { targetSha: string; validationInputs?: Record | null; verifierSha?: string | null; + workflowRef?: string; }): NormalizedEvidence { const runId = options.runId ?? "111"; const producerSha = options.producerSha ?? PRODUCER_SHA; const releaseProfile = options.releaseProfile ?? "full"; const soak = options.soak ?? true; + const workflowRef = options.workflowRef ?? "main"; + const workflowFullRef = `refs/heads/${workflowRef}`; + const shaPinned = workflowRef.startsWith("release-ci/"); const validationInputs = options.validationInputs === undefined ? DEFAULT_INPUTS : options.validationInputs; const manifest = { - version: 2, + version: shaPinned ? 3 : 2, workflowName: "Full Release Validation", - workflowRef: "main", + workflowRef, + workflowSha: producerSha, + workflowFullRef, + workflowRefType: "branch", runId, runAttempt: "2", targetRef: "release/2026.7.1", @@ -224,20 +231,24 @@ function normalizedEvidence(options: { }, conclusion: "success", manifest, - manifestVersion: 2, + manifestVersion: shaPinned ? 3 : 2, runAttempt: 2, runId, status: "completed", targetSha: options.targetSha, url: `https://example.test/runs/${runId}`, producerOnTrustedMainLineage: true, - workflowFullRef: "refs/heads/main", + workflowFullRef, workflowPath: ".github/workflows/full-release-validation.yml", - workflowQualifiedPath: ".github/workflows/full-release-validation.yml@refs/heads/main", - workflowRef: "main", - workflowRefProof: "legacy-v2-main-ancestry", + workflowQualifiedPath: `.github/workflows/full-release-validation.yml@${workflowFullRef}`, + workflowRef, + workflowRefProof: shaPinned + ? "manifest-v3-sha-pinned-main-ancestry" + : "legacy-v2-main-ancestry", workflowRefType: "branch", - workflowRunPath: ".github/workflows/full-release-validation.yml", + workflowRunPath: shaPinned + ? `.github/workflows/full-release-validation.yml@${workflowFullRef}` + : ".github/workflows/full-release-validation.yml", workflowSha: producerSha, }; const roles = [ @@ -268,7 +279,7 @@ function normalizedEvidence(options: { dispatchNonce: `full-release-validation-${runId}-${sourceParentAttempt}${suffix}`, displayTitle: `${name} full-release-validation-${runId}-${sourceParentAttempt}${suffix}`, event: "workflow_dispatch", - headBranch: "main", + headBranch: workflowRef, parentJobId: `job-${role}`, path: `.github/workflows/${workflow}`, role, @@ -471,6 +482,34 @@ function parseOutput(output: string): Record { } describe("scripts/github/find-reusable-release-validation.sh", () => { + it("reuses strict direct-root evidence produced by a canonical SHA-pinned run", () => { + const { origin, priorSha } = createRepo(); + const clone = cloneHead(origin); + const producerSha = "d".repeat(40); + const producerRef = `release-ci/${producerSha.slice(0, 12)}-122`; + const record = normalizedEvidence({ + producerSha, + targetSha: priorSha, + workflowRef: producerRef, + }); + const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]); + + const result = runResolver({ + binDir, + fixtures, + repoDir: clone, + targetSha: priorSha, + validatorPath, + workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`, + }); + + expect(result.status).toBe(0); + expect(parseOutput(result.stdout)).toMatchObject({ + evidence_run_id: "111", + reuse: "true", + }); + }); + it("rejects noncanonical release refs and workflow SHAs outside trusted main", () => { const { origin, priorSha } = createRepo(); const clone = cloneHead(origin); From 1403b29436e2b080e5d603d9bbf125b20b41ca12 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:29:57 -0700 Subject: [PATCH 24/27] fix(ci): allow unreleased notes in QA packages --- .github/workflows/ci.yml | 1 + scripts/package-changelog.mjs | 28 ++++++----- scripts/package-openclaw-for-docker.mjs | 13 ++++- .../package-openclaw-for-docker.e2e.test.ts | 48 +++++++++++++++++++ test/scripts/ci-workflow-guards.test.ts | 1 + test/scripts/package-changelog.test.ts | 19 ++++++++ 6 files changed, 97 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfc72551724..61346f7de803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -965,6 +965,7 @@ jobs: node scripts/build-all.mjs qaRuntime pnpm ui:build node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ --skip-build \ --output-dir .artifacts/qa-e2e/smoke-ci-package \ --output-name openclaw-current.tgz diff --git a/scripts/package-changelog.mjs b/scripts/package-changelog.mjs index 5865e2b4789f..74f48f126f74 100644 --- a/scripts/package-changelog.mjs +++ b/scripts/package-changelog.mjs @@ -22,7 +22,7 @@ const PRERELEASE_VERSION_PATTERN = /** * Resolves acceptable changelog headings for a package version. */ -export function resolvePackageChangelogVersions(packageVersion) { +export function resolvePackageChangelogVersions(packageVersion, options = {}) { const match = RELEASE_VERSION_PATTERN.exec(packageVersion); if (!match) { throw new Error( @@ -32,7 +32,7 @@ export function resolvePackageChangelogVersions(packageVersion) { if (PRERELEASE_VERSION_PATTERN.test(packageVersion)) { return [packageVersion, match[1], UNRELEASED_HEADING]; } - return [packageVersion]; + return options.allowUnreleased ? [packageVersion, UNRELEASED_HEADING] : [packageVersion]; } function splitLines(content) { @@ -61,8 +61,8 @@ function extractPreamble(lines, firstHeadingIndex) { /** * Extracts the current release changelog section for package publishing. */ -export function extractCurrentPackageChangelog(content, packageVersion) { - const targetVersions = resolvePackageChangelogVersions(packageVersion); +export function extractCurrentPackageChangelog(content, packageVersion, options = {}) { + const targetVersions = resolvePackageChangelogVersions(packageVersion, options); const lines = splitLines(content); const headings = findLevelTwoHeadings(lines); const heading = targetVersions @@ -125,11 +125,17 @@ export async function restorePackageChangelog(cwd = process.cwd()) { try { expectedPackaged = extractCurrentPackageChangelog(backup, packageVersion); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Refusing to restore stale packaged changelog backup from ${BACKUP_PATH}: ${message}`, - { cause: error }, - ); + try { + expectedPackaged = extractCurrentPackageChangelog(backup, packageVersion, { + allowUnreleased: true, + }); + } catch { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Refusing to restore stale packaged changelog backup from ${BACKUP_PATH}: ${message}`, + { cause: error }, + ); + } } if (current !== expectedPackaged) { throw new Error( @@ -145,13 +151,13 @@ export async function restorePackageChangelog(cwd = process.cwd()) { /** * Writes packaged changelog content while preserving a restorable backup. */ -export async function preparePackageChangelog(cwd = process.cwd()) { +export async function preparePackageChangelog(cwd = process.cwd(), options = {}) { await restorePackageChangelog(cwd); const changelogPath = path.join(cwd, CHANGELOG_PATH); const backupPath = path.join(cwd, BACKUP_PATH); const original = await readFile(changelogPath, "utf8"); const packageVersion = await readPackageVersion(cwd); - const packaged = extractCurrentPackageChangelog(original, packageVersion); + const packaged = extractCurrentPackageChangelog(original, packageVersion, options); if (packaged === original) { return false; } diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 333d77a04af3..b6ecc6730484 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -137,6 +137,7 @@ function resolvePackedOpenClawFileName(value) { export function parseArgs(argv) { const options = { + allowUnreleasedChangelog: false, outputDir: "", outputName: "", packJson: "", @@ -154,7 +155,9 @@ export function parseArgs(argv) { }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - if (arg === "--output-dir") { + if (arg === "--allow-unreleased-changelog") { + setOnce(arg, "allowUnreleasedChangelog", true); + } else if (arg === "--output-dir") { setOnce("--output-dir", "outputDir", readOptionValue(argv, index, arg)); index += 1; } else if (arg?.startsWith("--output-dir=")) { @@ -653,7 +656,12 @@ export async function prepareBundledAiRuntimePackage( export async function packOpenClawPackageForDocker(sourceDir, outputDir, options = {}) { const runCaptureImpl = options.runCaptureImpl ?? runCapture; - const prepareChangelog = options.prepareChangelog ?? preparePackageChangelog; + const prepareChangelog = + options.prepareChangelog ?? + ((cwd) => + preparePackageChangelog(cwd, { + allowUnreleased: options.allowUnreleasedChangelog, + })); const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog; const prepareBundledAiRuntime = options.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage; const packTool = options.pnpmPack ? "pnpm" : "npm"; @@ -740,6 +748,7 @@ async function main() { ); const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + allowUnreleasedChangelog: options.allowUnreleasedChangelog, outputName: options.outputName, packJsonPath: options.packJson, pnpmPack: options.pnpmPack, diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 0b57950ea7c2..c479ea1f8edf 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -91,9 +91,11 @@ describe("package-openclaw-for-docker", () => { ".artifacts/docker/pack.json", "--source-dir", "/repo", + "--allow-unreleased-changelog", "--skip-build", ]), ).toEqual({ + allowUnreleasedChangelog: true, outputDir: ".artifacts/docker", outputName: "openclaw-current.tgz", packJson: ".artifacts/docker/pack.json", @@ -118,6 +120,10 @@ describe("package-openclaw-for-docker", () => { ["--output-dir", ["--output-dir", "one", "--output-dir=two"]], ["--output-name", ["--output-name", "one.tgz", "--output-name=two.tgz"]], ["--pack-json", ["--pack-json", "one.json", "--pack-json=two.json"]], + [ + "--allow-unreleased-changelog", + ["--allow-unreleased-changelog", "--allow-unreleased-changelog"], + ], ["--pnpm-pack", ["--pnpm-pack", "--pnpm-pack"]], ["--source-dir", ["--source-dir", "/repo-a", "--source-dir=/repo-b"]], ["--skip-build", ["--skip-build", "--skip-build"]], @@ -423,6 +429,48 @@ describe("package-openclaw-for-docker", () => { ]); }); + it("packages Unreleased notes for explicitly non-publish stable artifacts", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-package-")); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-output-")); + const sourceChangelog = [ + "# Changelog", + "", + "## Unreleased", + "### Fixes", + "- Pending release notes with enough detail.", + "", + "## 2026.5.28", + "- Previous release notes with enough detail.", + "", + ].join("\n"); + fs.writeFileSync( + path.join(sourceDir, "package.json"), + '{"name":"openclaw","version":"2026.5.29"}\n', + ); + fs.writeFileSync(path.join(sourceDir, "CHANGELOG.md"), sourceChangelog); + + try { + const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + allowUnreleasedChangelog: true, + prepareBundledAiRuntime: skipBundledAiRuntime, + runCaptureImpl: async () => { + const packagedChangelog = fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8"); + expect(packagedChangelog).toContain("## Unreleased"); + expect(packagedChangelog).not.toContain("## 2026.5.28"); + const packedPath = path.join(outputDir, "openclaw-2026.5.29.tgz"); + fs.writeFileSync(packedPath, "package"); + return "openclaw-2026.5.29.tgz\n"; + }, + }); + + expect(tarball).toBe(path.join(outputDir, "openclaw-2026.5.29.tgz")); + expect(fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); + it("uses pnpm pack when requested", async () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-pack-")); const calls: string[] = []; diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 68ae05e613ca..68d3b663a621 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2166,6 +2166,7 @@ describe("ci workflow guards", () => { expect(smokeBuildStep.run).toContain("pnpm ui:build"); expect(smokeBuildStep.env.OPENCLAW_BUILD_PRIVATE_QA).toBe("1"); expect(smokeBuildStep.run).toContain("--skip-build"); + expect(smokeBuildStep.run).toContain("--allow-unreleased-changelog"); expect(workflow.jobs["qa-smoke-ci-artifacts"]).toBeUndefined(); expect(workflow.jobs["qa-smoke-ci"]).toBeUndefined(); expect(smokeProfileJob.needs).toEqual(["preflight"]); diff --git a/test/scripts/package-changelog.test.ts b/test/scripts/package-changelog.test.ts index 776a48bc8614..3361f6d51aa2 100644 --- a/test/scripts/package-changelog.test.ts +++ b/test/scripts/package-changelog.test.ts @@ -124,6 +124,25 @@ Docs: https://docs.openclaw.ai ); }); + it("allows Unreleased notes for explicitly non-publish stable artifacts", () => { + const unreleasedChangelog = cumulativeChangelog.replace( + "- Pending note.", + "- Pending release note with enough detail.", + ); + expect( + extractCurrentPackageChangelog(unreleasedChangelog, "2026.5.29", { + allowUnreleased: true, + }), + ).toBe(changelog` +# Changelog +Docs: https://docs.openclaw.ai + +## Unreleased +### Fixes +- Pending release note with enough detail. +`); + }); + it("fails closed when the packaged changelog is unexpectedly large", () => { const source = changelog` # Changelog From 5d742697f3ba3f5ae69d123e887e6f8f544eceac Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:34:37 -0700 Subject: [PATCH 25/27] fix(release): satisfy script lint contracts --- scripts/crabbox-wrapper.mjs | 5 +-- scripts/release-ci-summary.mjs | 54 ++++++++++++++++------------- scripts/testbox-lease-freshness.mjs | 6 ++-- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index cdac2987fa41..ae0d81482f7b 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -3483,6 +3483,7 @@ child.on("exit", (code, signal) => { if (childTreeShutdownStarted) { return; } + let exitCode = code; let fullCheckoutAvailable = true; if (fullCheckout) { fullCheckoutAvailable = assertFullCheckoutAvailableBeforeExit(fullCheckout.dir); @@ -3494,7 +3495,7 @@ child.on("exit", (code, signal) => { console.error( `[crabbox] failed to record Testbox lease freshness: ${error instanceof Error ? error.message : String(error)}`, ); - code = 2; + exitCode = 2; } } cleanupOnce(); @@ -3502,7 +3503,7 @@ child.on("exit", (code, signal) => { process.exit(signalExitCodes.get(signal) ?? 1); return; } - process.exit(fullCheckoutAvailable ? (code ?? 1) : 1); + process.exit(fullCheckoutAvailable ? (exitCode ?? 1) : 1); }); child.on("error", (error) => { diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 9070a90911a9..256356c47a6c 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -154,11 +154,11 @@ function rate() { } export function validateParentRunBinding(parentView, parentRest, expectedRunId) { - const workflowPath = String(parentRest.path ?? "").split("@", 1)[0]; + const boundWorkflowPath = String(parentRest.path ?? "").split("@", 1)[0]; if ( String(parentRest.id) !== String(expectedRunId) || parentRest.event !== "workflow_dispatch" || - workflowPath !== ".github/workflows/full-release-validation.yml" || + boundWorkflowPath !== ".github/workflows/full-release-validation.yml" || Number(parentRest.run_attempt) !== Number(parentView.attempt) || parentRest.head_branch !== parentView.headBranch || parentRest.head_sha !== parentView.headSha @@ -309,11 +309,16 @@ function normalizeRepository(value) { function normalizeWorkflowRef(value, label) { const workflowRef = String(value ?? ""); - if ( - workflowRef.length === 0 || - workflowRef.length > 255 || - /[\u0000-\u001f\u007f~^:?*[\\\s]/u.test(workflowRef) - ) { + const hasForbiddenCharacter = [...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; @@ -349,7 +354,7 @@ function canonicalJson(value) { if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) + .toSorted(([left], [right]) => left.localeCompare(right)) .map(([key, entry]) => [key, canonicalJson(entry)]), ); } @@ -713,8 +718,8 @@ export function validateManifestChildRun( ) { throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); } - const workflowPath = String(run.path ?? "").split("@", 1)[0]; - if (workflowPath !== `.github/workflows/${child.workflow}`) { + 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); @@ -909,16 +914,12 @@ export function readManifestArtifactArchive(archivePath, expectedDigest) { return JSON.parse(manifestBytes.toString("utf8")); } -function downloadParentManifestEvidence( - runId, - runAttempt, - repository = DEFAULT_REPO, - manifestPath, -) { +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}`, repository) + githubRestJson(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`, targetRepository) .artifacts ?? []; artifacts.push(...pageArtifacts); if (pageArtifacts.length < 100) { @@ -930,7 +931,7 @@ function downloadParentManifestEvidence( return undefined; } const artifact = validateManifestArtifactIdentity( - githubRestJson(`actions/artifacts/${listedArtifact.id}`, repository), + githubRestJson(`actions/artifacts/${listedArtifact.id}`, targetRepository), { artifactDigest: listedArtifact.digest, artifactId: listedArtifact.id, @@ -941,7 +942,7 @@ function downloadParentManifestEvidence( const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-release-ci-summary-")); try { const archivePath = join(downloadDir, "manifest.zip"); - downloadArtifactZip(String(artifact.id), archivePath, repository); + downloadArtifactZip(String(artifact.id), archivePath, targetRepository); const manifest = readManifestArtifactArchive(archivePath, artifact.digest); validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt); if (manifestPath) { @@ -1508,7 +1509,10 @@ export async function watchReleaseCiRun(options, overrides = {}) { const summarize = overrides.summarize ?? (() => summarizeReleaseCiRun(options)); const sleep = overrides.sleep ?? - ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + ((milliseconds) => + new Promise((complete) => { + setTimeout(complete, milliseconds); + })); let previousFingerprint; while (true) { const parent = fetchParent(); @@ -1779,8 +1783,10 @@ async function main() { } if (process.argv[1]?.endsWith("release-ci-summary.mjs")) { - await main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - }); + await main().catch( + /** @param {unknown} error */ (error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }, + ); } diff --git a/scripts/testbox-lease-freshness.mjs b/scripts/testbox-lease-freshness.mjs index dfa2a1c6c968..84ce5915f9f9 100644 --- a/scripts/testbox-lease-freshness.mjs +++ b/scripts/testbox-lease-freshness.mjs @@ -41,7 +41,7 @@ function git(repoRoot, args) { }).trim(); } -function listFiles(path, root = path) { +function listFiles(path) { if (!existsSync(path)) { return []; } @@ -49,8 +49,8 @@ function listFiles(path, root = path) { return [path]; } return readdirSync(path, { withFileTypes: true }) - .flatMap((entry) => listFiles(resolve(path, entry.name), root)) - .toSorted(); + .flatMap((entry) => listFiles(resolve(path, entry.name))) + .toSorted((left, right) => left.localeCompare(right)); } function digestInputs(repoRoot, inputs) { From 16ba8b52847a9f980c61578097b3d9fce4fd1323 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:39:46 -0700 Subject: [PATCH 26/27] fix(release): handle Unicode workflow refs safely --- scripts/release-ci-summary.mjs | 2 +- test/scripts/release-ci-summary.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 256356c47a6c..a5d526dc4823 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -309,7 +309,7 @@ function normalizeRepository(value) { function normalizeWorkflowRef(value, label) { const workflowRef = String(value ?? ""); - const hasForbiddenCharacter = [...workflowRef].some((character) => { + const hasForbiddenCharacter = Array.from(workflowRef).some((character) => { const codePoint = character.codePointAt(0) ?? 0; return ( codePoint <= 0x1f || diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 91fb756c666c..a7769ac55d1c 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -718,6 +718,31 @@ describe("release CI summary child correlation", () => { }); }); + 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, From 358d4cd4cc113293227099b494eb3c956bad4991 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:24:36 -0700 Subject: [PATCH 27/27] fix(pr): reject stale canonical wrapper code --- scripts/pr | 27 +++++++++++++++ test/scripts/pr-wrappers.test.ts | 56 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/scripts/pr b/scripts/pr index 24ffef3f503d..e172989d429c 100755 --- a/scripts/pr +++ b/scripts/pr @@ -17,6 +17,33 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute canonical_repo_root="$(dirname "$common_git_dir")" canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")" if [ "$script_self" != "$canonical_self" ] && [ -x "$canonical_self" ]; then + if ! git -C "$script_parent_dir" diff --quiet HEAD -- \ + ":(top)scripts/pr" ":(top)scripts/pr-lib" ":(top)scripts/lib/plain-gh.sh" || + ! git -C "$canonical_repo_root" diff --quiet HEAD -- \ + ":(top)scripts/pr" ":(top)scripts/pr-lib" ":(top)scripts/lib/plain-gh.sh"; then + echo "scripts/pr wrapper files have uncommitted changes in this worktree or the canonical checkout." >&2 + echo "Refusing to silently substitute canonical wrapper code from: $canonical_repo_root" >&2 + exit 1 + fi + linked_wrapper_revision=$( + git -C "$script_parent_dir" rev-parse \ + HEAD:scripts/pr \ + HEAD:scripts/pr-lib \ + HEAD:scripts/lib/plain-gh.sh 2>/dev/null || true + ) + canonical_wrapper_revision=$( + git -C "$canonical_repo_root" rev-parse \ + HEAD:scripts/pr \ + HEAD:scripts/pr-lib \ + HEAD:scripts/lib/plain-gh.sh 2>/dev/null || true + ) + if [ -z "$linked_wrapper_revision" ] || + [ "$linked_wrapper_revision" != "$canonical_wrapper_revision" ]; then + echo "scripts/pr implementation differs between this worktree and the canonical checkout." >&2 + echo "Refusing to silently substitute canonical wrapper code from: $canonical_repo_root" >&2 + echo "Run scripts/pr from a trusted checkout with matching scripts/pr and scripts/pr-lib revisions." >&2 + exit 1 + fi exec "$canonical_self" "$@" fi fi diff --git a/test/scripts/pr-wrappers.test.ts b/test/scripts/pr-wrappers.test.ts index 6b072b0a554c..d58d5f4c91c9 100644 --- a/test/scripts/pr-wrappers.test.ts +++ b/test/scripts/pr-wrappers.test.ts @@ -1,6 +1,6 @@ // PR wrapper tests cover maintainer helper command delegation. import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -66,6 +66,60 @@ describe("scripts/pr wrappers", () => { expect(script).toContain('exec "$base" review-init "$@"'); }); + it("refuses to substitute a different canonical wrapper implementation", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-pr-wrapper-revision-")); + const repo = join(dir, "repo"); + const linked = join(dir, "linked"); + mkdirSync(join(repo, "scripts", "lib"), { recursive: true }); + mkdirSync(join(repo, "scripts", "pr-lib"), { recursive: true }); + writeFileSync(join(repo, "scripts", "pr"), readScript("scripts/pr")); + writeFileSync(join(repo, "scripts", "lib", "plain-gh.sh"), "# canonical\n"); + writeFileSync(join(repo, "scripts", "pr-lib", "merge.sh"), "# canonical\n"); + chmodSync(join(repo, "scripts", "pr"), 0o755); + + const git = (cwd: string, args: string[]) => + spawnSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + expect(git(repo, ["init", "-b", "main"]).status).toBe(0); + expect(git(repo, ["config", "user.name", "OpenClaw Test"]).status).toBe(0); + expect(git(repo, ["config", "user.email", "test@example.invalid"]).status).toBe(0); + expect(git(repo, ["add", "scripts"]).status).toBe(0); + expect(git(repo, ["commit", "-m", "test: canonical wrapper"]).status).toBe(0); + expect(git(repo, ["worktree", "add", "-b", "feature", linked]).status).toBe(0); + + writeFileSync(join(linked, "scripts", "pr-lib", "merge.sh"), "# dirty linked\n"); + const dirtyLinkedResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { + cwd: linked, + encoding: "utf8", + }); + expect(dirtyLinkedResult.status).toBe(1); + expect(dirtyLinkedResult.stderr).toContain("scripts/pr wrapper files have uncommitted changes"); + expect(git(linked, ["restore", "scripts/pr-lib/merge.sh"]).status).toBe(0); + + writeFileSync(join(repo, "scripts", "pr-lib", "merge.sh"), "# dirty canonical\n"); + const dirtyResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { + cwd: linked, + encoding: "utf8", + }); + expect(dirtyResult.status).toBe(1); + expect(dirtyResult.stderr).toContain("scripts/pr wrapper files have uncommitted changes"); + expect(git(repo, ["restore", "scripts/pr-lib/merge.sh"]).status).toBe(0); + + writeFileSync(join(linked, "scripts", "pr-lib", "merge.sh"), "# linked\n"); + expect(git(linked, ["add", "scripts/pr-lib/merge.sh"]).status).toBe(0); + expect(git(linked, ["commit", "-m", "test: linked wrapper"]).status).toBe(0); + + const result = spawnSync(join(linked, "scripts", "pr"), ["ls"], { + cwd: linked, + encoding: "utf8", + }); + rmSync(dir, { recursive: true, force: true }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "scripts/pr implementation differs between this worktree and the canonical checkout.", + ); + }); + it("verifies local GitHub auth through GraphQL when REST quota is unavailable", () => { const dir = mkdtempSync(join(tmpdir(), "openclaw-pr-auth-")); const gh = join(dir, "gh");