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/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index e2fe645422e3..5f93e8d5988d 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 @@ -109,6 +113,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 @@ -116,16 +126,16 @@ publish workflow reads the effective profile from the full-validation manifest. ## Watch -Use the summary helper instead of repeated raw polling: +Use the transition-only summary watcher instead of repeated raw polling: ```bash -node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +node scripts/release-ci-summary.mjs --watch ``` -Then watch only when useful: +For a one-shot snapshot: ```bash -gh run watch --repo openclaw/openclaw --exit-status +node scripts/release-ci-summary.mjs ``` Stop watchers before ending the turn or switching strategy. diff --git a/.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/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 3ea643d5a113..d7bf986c2f7b 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: @@ -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/.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/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..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. @@ -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/docs/reference/test.md b/docs/reference/test.md index 6e671433871a..b44abcda821f 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -24,6 +24,14 @@ 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. 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 not permission to silently run a broad local gate. diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index d40ff1ca30cf..ae0d81482f7b 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, @@ -451,10 +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 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, }); @@ -2929,7 +2939,8 @@ function isSparseCheckout() { } function isWorktreeClean() { - return gitOutput(["status", "--porcelain=v1"]).stdout === ""; + const status = gitOutput(["status", "--porcelain=v1"]); + return status.status === 0 && status.stdout === ""; } function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) { @@ -3173,6 +3184,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] = `export 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); @@ -3253,6 +3281,19 @@ if (canonicalProvider === "blacksmith-testbox") { enforceCrabboxOwnedBlacksmithLease(normalizedArgs); } +let testboxLeaseFreshness; +try { + testboxLeaseFreshness = prepareTestboxLeaseFreshness({ + args: normalizedArgs, + env: { ...process.env, CI: process.env.CI || "true" }, + provider: canonicalProvider, + repoRoot, + }); +} catch (error) { + console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); +} + let childCwd = repoRoot; let cleanupChildCwd = () => {}; let fullCheckout = null; @@ -3329,6 +3370,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 && @@ -3364,7 +3408,7 @@ try { cleanupOnce(); throw error; } -const childArgs = +const childArgs = injectRemoteTestboxCi( childCwd === repoRoot ? injectRemoteWindowsHydratedNodeModulesBootstrap( injectRemoteAwsMacosSwiftBootstrap( @@ -3384,7 +3428,9 @@ const childArgs = provider, ), remoteChangedGateBase, - ); + ), + provider, +); let fullCheckoutKeepaliveIntervalMsValue = 0; if (fullCheckout) { try { @@ -3437,16 +3483,27 @@ child.on("exit", (code, signal) => { if (childTreeShutdownStarted) { return; } + let exitCode = code; let fullCheckoutAvailable = true; 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)}`, + ); + exitCode = 2; + } + } cleanupOnce(); if (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/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index ec100f3099f0..be199a5c30ab 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 { existsSync, 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"; @@ -9,7 +12,7 @@ const DEFAULT_INPUTS = { mode: "both", release_profile: "full", rerun_group: "all", - reuse_evidence: "false", + reuse_evidence: "true", }; function usage() { @@ -18,8 +21,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 is disabled because it is trusted only from main.`); +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 = {}) { @@ -119,8 +123,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"); @@ -177,47 +181,53 @@ 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}.`, - ); +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; +} - 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}`, +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 = releaseEvidenceVerifierPath(verifierWorktree); + const evidence = JSON.parse( + run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), ); - failed ||= !ok; - } - if (failed) { - throw new Error(`One or more child workflows failed or did not run at ${workflowSha}.`); + 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 }); } } @@ -280,7 +290,7 @@ function main() { `Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`, ); } - verifyChildHeads(parentRunId, workflowSha); + verifyReleaseEvidence(parentRunId, workflowSha); } finally { if (!args.keepBranch) { run("git", ["push", "origin", `:${remoteBranchRef}`], { diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index e4ea54b4283e..f7ffeb747e82 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="" @@ -19,11 +20,12 @@ 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' 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 @@ -191,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/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/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index da45c4986f5e..94743d3a8825 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -2,10 +2,19 @@ // 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"; +import { isDeepStrictEqual } from "node:util"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs"; import { readBoundedResponseText } from "./lib/bounded-response.mjs"; import { @@ -20,6 +29,7 @@ import { } from "./render-github-release-notes.mjs"; import { isShaPinnedReleaseValidationBranch, + runStrictReleaseEvidenceValidation, validateFullReleaseValidationEvidence, } from "./validate-full-release-validation-evidence.mjs"; @@ -42,6 +52,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 +286,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 (!isDeepStrictEqual(saved[key], expected[key])) { + throw new Error( + `release candidate state mismatch for ${key}: saved=${JSON.stringify(saved[key])} current=${JSON.stringify(expected[key])}`, + ); + } + } + for (const key of ["fullReleaseRunId", "npmPreflightRunId"]) { + if (saved[key] && expected[key] && saved[key] !== expected[key]) { + throw new Error(`release candidate state mismatch for ${key}`); + } + } + return { + ...expected, + phase: typeof saved.phase === "string" ? saved.phase : expected.phase, + fullReleaseRunId: expected.fullReleaseRunId || saved.fullReleaseRunId || "", + npmPreflightRunId: expected.npmPreflightRunId || saved.npmPreflightRunId || "", + }; +} + +function writeReleaseCandidateState(path, state) { + mkdirSync(join(path, ".."), { recursive: true }); + const temporaryPath = `${path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`); + renameSync(temporaryPath, path); +} + +function updateReleaseCandidateState(path, state, phase, runIds = {}) { + const next = { ...state, ...runIds, phase }; + writeReleaseCandidateState(path, next); + return next; +} + function githubApiTimeoutMs() { const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS; if (!raw) { @@ -1150,6 +1247,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 +1292,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 +1304,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", @@ -1241,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}`); @@ -1376,6 +1494,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/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs similarity index 92% rename from .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs rename to scripts/release-ci-summary.mjs index 6a392042c6fa..a5d526dc4823 100755 --- 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; @@ -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 = Array.from(workflowRef).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return ( + codePoint <= 0x1f || + codePoint === 0x7f || + character.trim() === "" || + "~^:?*[\\".includes(character) + ); + }); + if (workflowRef.length === 0 || workflowRef.length > 255 || hasForbiddenCharacter) { throw new Error(`${label} is invalid`); } return workflowRef; @@ -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) { @@ -1069,7 +1070,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 +1089,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 ?? ""); @@ -1398,12 +1396,16 @@ 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, + verifierSourceFile: undefined, + verifierSourceSha: undefined, + watch: false, }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; @@ -1416,8 +1418,20 @@ 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") { + 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 { @@ -1427,6 +1441,12 @@ 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.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"); } @@ -1437,12 +1457,83 @@ function printUsage() { console.error( [ "usage: release-ci-summary.mjs ", - " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] --json", + " release-ci-summary.mjs --watch [--interval seconds]", + " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json", ].join("\n"), ); } -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((complete) => { + setTimeout(complete, milliseconds); + })); + let previousFingerprint; + while (true) { + const parent = fetchParent(); + const fingerprint = releaseCiWatchFingerprint(parent); + if (fingerprint !== previousFingerprint) { + summarize(); + previousFingerprint = fingerprint; + } + if (parent.status === "completed") { + if (parent.conclusion !== "success") { + throw new Error( + `full release run ${options.runId} completed with ${parent.conclusion || "no conclusion"}`, + ); + } + return; + } + await sleep(options.intervalMs); + } +} + +async function main() { let options; try { options = parseReleaseCiSummaryArgs(process.argv.slice(2)); @@ -1460,6 +1551,10 @@ 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) { @@ -1477,6 +1572,10 @@ function main() { } return; } + if (options.watch) { + await watchReleaseCiRun(options); + return; + } const core = rate(); if (core) { @@ -1684,5 +1783,10 @@ function main() { } if (process.argv[1]?.endsWith("release-ci-summary.mjs")) { - main(); + 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 new file mode 100644 index 000000000000..84ce5915f9f9 --- /dev/null +++ b/scripts/testbox-lease-freshness.mjs @@ -0,0 +1,131 @@ +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", + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null" }, + }).trim(); +} + +function listFiles(path) { + if (!existsSync(path)) { + return []; + } + if (statSync(path).isFile()) { + return [path]; + } + return readdirSync(path, { withFileTypes: true }) + .flatMap((entry) => listFiles(resolve(path, entry.name))) + .toSorted((left, right) => left.localeCompare(right)); +} + +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, + 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"), + 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 }; + } + 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/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index be0d3d848920..683b6268d439 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 = [ @@ -136,17 +137,87 @@ export function validateFullReleaseValidationEvidence({ `SHA-pinned validation target ref mismatch: expected ${expectedTargetSha}, got ${manifest.targetRef ?? ""}.`, ); } - if (Object.hasOwn(manifest, "evidenceReuse")) { - throw new Error("SHA-pinned validation evidence must not reuse another validation run."); - } if (!isTrustedMainAncestor?.(run.headSha)) { throw new Error( `SHA-pinned validation workflow ${run.headSha} is not reachable from current main.`, ); } + if (Object.hasOwn(manifest, "evidenceReuse")) { + const reuse = manifest.evidenceReuse; + if ( + !reuse || + typeof reuse !== "object" || + Array.isArray(reuse) || + reuse.policy !== "exact-target-full-validation-v1" || + reuse.evidenceSha !== expectedTargetSha || + !Array.isArray(reuse.changedPaths) || + reuse.changedPaths.length !== 0 || + !/^[1-9][0-9]*$/u.test(String(reuse.runId ?? "")) || + !/^[1-9][0-9]*$/u.test(String(reuse.selectedRunId ?? "")) + ) { + throw new Error("SHA-pinned validation evidence reuse is invalid."); + } + if (typeof validateEvidenceReuseStrictly !== "function") { + throw new Error("SHA-pinned validation evidence reuse requires strict chain validation."); + } + const strictEvidence = validateEvidenceReuseStrictly({ + repository: expectedRepository, + runId: String(expectedRunId), + targetSha: expectedTargetSha, + }); + if ( + strictEvidence?.schema !== "openclaw.release-validation-evidence/v3" || + strictEvidence.valid !== true || + String(strictEvidence.current?.runId ?? "") !== String(expectedRunId) || + strictEvidence.current?.targetSha !== expectedTargetSha || + strictEvidence.root?.targetSha !== expectedTargetSha || + strictEvidence.evidenceReuse?.evidenceSha !== expectedTargetSha || + String(strictEvidence.evidenceReuse?.rootRunId ?? "") !== String(reuse.runId) || + String(strictEvidence.evidenceReuse?.selectedRunId ?? "") !== String(reuse.selectedRunId) || + strictEvidence.conclusions?.allRequiredSucceeded !== true + ) { + throw new Error("SHA-pinned validation evidence reuse failed strict chain validation."); + } + } return { run, source: "sha-pinned-main" }; } +export function runStrictReleaseEvidenceValidation({ + repository, + runId, + validatorFile = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)), + verifierSourceSha, +}) { + const verifierSourceArgs = verifierSourceSha + ? ["--verifier-source-sha", verifierSourceSha, "--verifier-source-file", validatorFile] + : []; + const result = spawnSync( + process.execPath, + [ + validatorFile, + "--validate-run", + String(runId), + "--repo", + repository, + "--trusted-workflow-ref", + "main", + "--json", + ...verifierSourceArgs, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status !== 0) { + throw new Error( + `Strict release evidence validation failed: ${result.stderr?.trim() || result.signal || result.status}.`, + ); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error("Strict release evidence validator returned invalid JSON."); + } +} + function gitIsAncestor(ancestor, target) { const result = spawnSync( "git", @@ -180,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/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/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 9efd78f7f9b5..1906d780af93 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,10 +784,36 @@ describe("scripts/crabbox-wrapper", () => { "--id", "blue-hermit", "--", + "env", + "CI=true", "echo ok", ]); }); + 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", @@ -3166,7 +3194,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", ); diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index 24c8da630e36..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, @@ -310,7 +321,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, }, @@ -409,8 +420,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 +439,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 +482,62 @@ 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); + 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); diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 450e6c35323e..cf44371204fb 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -1,5 +1,12 @@ +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 } from "../../scripts/full-release-validation-at-sha.mjs"; +import { + parseArgs, + releaseEvidenceVerificationArgs, + releaseEvidenceVerifierPath, +} from "../../scripts/full-release-validation-at-sha.mjs"; describe("full-release-validation-at-sha", () => { it("parses release validation dispatch args", () => { @@ -22,7 +29,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 +47,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", ); }); @@ -50,4 +58,39 @@ 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"); + }); + + 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 }); + } + }); }); 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/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 diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index b36ec085bcc9..043fcd5c3e1e 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,49 @@ 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( + JSON.parse( + JSON.stringify({ + ...expected, + phase: "waiting", + fullReleaseRunId: "111", + npmPreflightRunId: "222", + }), + ), + expected, + ); + + expect(resumed).toMatchObject({ + phase: "waiting", + fullReleaseRunId: "111", + npmPreflightRunId: "222", + }); + }); + + it("rejects stale or conflicting release candidate state", () => { + const options = parseArgs(["--tag", "v2026.7.1-beta.4"]); + const expected = buildReleaseCandidateState(options, { + targetSha: "a".repeat(40), + toolingSha: "b".repeat(40), + }); + + expect(() => + reconcileReleaseCandidateState({ ...expected, targetSha: "c".repeat(40) }, expected), + ).toThrow("state mismatch for targetSha"); + expect(() => + reconcileReleaseCandidateState( + { ...expected, fullReleaseRunId: "111" }, + { ...expected, fullReleaseRunId: "333" }, + ), + ).toThrow("state mismatch for fullReleaseRunId"); + }); + it("captures changelogs larger than the Node spawnSync default buffer", () => { const output = run( process.execPath, @@ -593,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 3eaf1134aad3..a7769ac55d1c 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 { @@ -11,6 +11,7 @@ import { manifestChildEntries, parseReleaseCiSummaryArgs, readManifestArtifactArchive, + releaseCiWatchFingerprint, requiredChildKeysForRerunGroup, resolveManifestChildOriginAttempt, selectExactChildRun, @@ -26,9 +27,11 @@ import { validateParentRunBinding, validatePerformanceArtifactOnlyJobs, validateReleaseRunEvidence, -} from "../../.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; + validateTrustedProducerIdentity, + watchReleaseCiRun, +} 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 { @@ -344,11 +347,15 @@ 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, + verifierSourceFile: undefined, + verifierSourceSha: undefined, + watch: false, }); expect(parseReleaseCiSummaryArgs(["29071366025"])).toMatchObject({ repository: "openclaw/openclaw", @@ -356,9 +363,93 @@ 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", ); + expect(() => + parseReleaseCiSummaryArgs([ + "--validate-run", + "29071366025", + "--verifier-source-file", + "/tmp/verifier.mjs", + ]), + ).toThrow("--verifier-source-file requires --verifier-source-sha"); + expect( + parseReleaseCiSummaryArgs([ + "--validate-run", + "29071366025", + "--verifier-source-sha", + "a".repeat(40), + "--verifier-source-file", + "/tmp/verifier.mjs", + ]), + ).toMatchObject({ + verifierSourceFile: "/tmp/verifier.mjs", + verifierSourceSha: "a".repeat(40), + }); + }); + + it("changes the watch fingerprint only for visible run transitions", () => { + const parent = { + attempt: 1, + conclusion: "", + jobs: [{ name: "Run normal full CI", status: "in_progress", conclusion: "" }], + status: "in_progress", + url: "ignored", + }; + expect(releaseCiWatchFingerprint({ ...parent, url: "changed" })).toBe( + releaseCiWatchFingerprint(parent), + ); + expect( + releaseCiWatchFingerprint({ + ...parent, + jobs: [{ ...parent.jobs[0], conclusion: "success", status: "completed" }], + }), + ).not.toBe(releaseCiWatchFingerprint(parent)); + }); + + it("summarizes only transitions while watching a release run", async () => { + const states = [ + { attempt: 1, conclusion: "", jobs: [], status: "queued" }, + { attempt: 1, conclusion: "", jobs: [], status: "queued" }, + { + attempt: 1, + conclusion: "success", + jobs: [{ name: "Run normal full CI", status: "completed", conclusion: "success" }], + status: "completed", + }, + ]; + let index = 0; + let summaries = 0; + let sleeps = 0; + + await watchReleaseCiRun( + parseReleaseCiSummaryArgs(["29071366025", "--watch", "--interval", "1"]), + { + fetchParent: () => states[index++], + sleep: async () => { + sleeps += 1; + }, + summarize: () => { + summaries += 1; + }, + }, + ); + + expect(summaries).toBe(2); + expect(sleeps).toBe(2); }); it("selects one immutable manifest artifact bound to the exact parent run", () => { @@ -627,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, @@ -739,7 +855,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 +869,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", () => { @@ -825,9 +944,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 }); 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/testbox-lease-freshness.test.ts b/test/scripts/testbox-lease-freshness.test.ts new file mode 100644 index 000000000000..12574b671cef --- /dev/null +++ b/test/scripts/testbox-lease-freshness.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { 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", + 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", + ]); + }); +}); diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index 212da64e679a..660ceb8f22a0 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -43,6 +43,31 @@ function releaseManifest(overrides: Record = {}) { }; } +function exactTargetEvidenceReuse() { + return { + changedPaths: [], + evidenceSha: targetSha, + policy: "exact-target-full-validation-v1", + runId: "122", + selectedRunId: "122", + }; +} + +function strictEvidenceReuse() { + return { + schema: "openclaw.release-validation-evidence/v3", + valid: true, + current: { runId: "123", targetSha }, + root: { runId: "122", targetSha }, + evidenceReuse: { + evidenceSha: targetSha, + rootRunId: "122", + selectedRunId: "122", + }, + conclusions: { allRequiredSucceeded: true }, + }; +} + function validate( runOverrides: Record = {}, manifestOverrides: Record = {}, @@ -57,6 +82,7 @@ function validate( expectedTargetSha: targetSha, expectedWorkflowBranch: "release/2026.7.1", isTrustedMainAncestor, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), }); return { isTrustedMainAncestor, result }; } @@ -176,24 +202,72 @@ 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("requires strict root and child validation for reused evidence", () => { + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest({ evidenceReuse: exactTargetEvidenceReuse() }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: "release/2026.7.1", + isTrustedMainAncestor: () => true, + }), + ).toThrow("requires strict chain validation"); + + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest({ evidenceReuse: exactTargetEvidenceReuse() }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: "release/2026.7.1", + isTrustedMainAncestor: () => true, + validateEvidenceReuseStrictly: () => ({ + ...strictEvidenceReuse(), + conclusions: { allRequiredSucceeded: false }, + }), + }), + ).toThrow("failed strict chain validation"); + }); + + it("rejects malformed evidence reuse on the SHA-pinned path", () => { + expect(() => + validate( + {}, + { evidenceReuse: { ...exactTargetEvidenceReuse(), changedPaths: ["src/a.ts"] } }, + ), + ).toThrow("evidence reuse is invalid"); + expect(() => + validate( + {}, + { evidenceReuse: { ...exactTargetEvidenceReuse(), evidenceSha: "c".repeat(40) } }, + ), + ).toThrow("evidence reuse is invalid"); + }); + it("keeps a pinned-shaped expected branch on the pinned trust path", () => { expect(() => validateFullReleaseValidationEvidence({ run: releaseRun(), - manifest: releaseManifest({ evidenceReuse: { runId: "122" } }), + manifest: releaseManifest({ + evidenceReuse: { ...exactTargetEvidenceReuse(), selectedRunId: "" }, + }), expectedRepository: "openclaw/openclaw", expectedRunId: "123", expectedTargetSha: targetSha, expectedWorkflowBranch: pinnedBranch, - isTrustedMainAncestor: () => false, + isTrustedMainAncestor: () => true, + validateEvidenceReuseStrictly: () => strictEvidenceReuse(), }), - ).toThrow("must not reuse another validation run"); + ).toThrow("evidence reuse is invalid"); }); it("does not treat a malformed release-ci expected branch as direct", () => { 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 {