improve(release): reuse exact-SHA validation evidence (#104162)

* perf(release): share changelog verification snapshots

* perf(release): reuse exact-SHA validation evidence

* feat(release): checkpoint candidate workflow state

* feat(release): watch CI transitions compactly

* fix(testbox): rotate stale reusable leases

* refactor(release): move CI verifier into scripts

* fix(release): preserve verifier executable mode

* fix(testbox): force noninteractive remote hydration

* perf(testbox): skip sync for proven clean heads

* fix(testbox): keep changed gates synchronized

* fix(testbox): isolate git state probes

* fix(testbox): isolate wrapper git commands

* fix(testbox): preserve git command contracts

* fix(release): validate reused SHA evidence

* fix(release): resume serialized plugin selections

* fix(testbox): sync source on every lease reuse

* fix(release): verify from trusted workflow checkout

* fix(release): gate evidence reuse on trusted lineage

* fix(release): support legacy verifier checkouts

* fix(testbox): export CI across shell snippets

* fix(release): revalidate reused evidence before publish

* fix(release): reject untrusted reuse before lookup

* fix(release): reuse SHA-pinned root evidence

* fix(ci): allow unreleased notes in QA packages

* fix(release): satisfy script lint contracts

* fix(release): handle Unicode workflow refs safely
This commit is contained in:
Vincent Koc
2026-07-11 12:48:27 +08:00
committed by GitHub
parent 6595d1756b
commit c47ceb0f3d
31 changed files with 1306 additions and 163 deletions
@@ -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,
+14 -4
View File
@@ -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 <candidate-branch-or-sha>`. 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 <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 <full-release-run-id>
node scripts/release-ci-summary.mjs <full-release-run-id> --watch
```
Then watch only when useful:
For a one-shot snapshot:
```bash
gh run watch <full-release-run-id> --repo openclaw/openclaw --exit-status
node scripts/release-ci-summary.mjs <full-release-run-id>
```
Stop watchers before ending the turn or switching strategy.
+1
View File
@@ -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
@@ -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" \
@@ -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"
+5 -3
View File
@@ -283,9 +283,11 @@ pnpm ci:full-release --sha <full-sha>
GitHub workflow dispatch refs must be branches or tags, not raw commit SHAs. The
helper pushes a temporary `release-ci/<sha>-...` 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
+2 -2
View File
@@ -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/<tag>/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 <full-sha>
```
The helper fetches current `origin/main`, pushes `release-ci/<workflow-sha>-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=<target-sha>` and `reuse_evidence=false`, verifies every child workflow `headSha` matches the pinned parent workflow SHA, then deletes the temporary branch. Pass `--workflow-sha <trusted-main-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/<workflow-sha>-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=<target-sha>`, 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 <trusted-main-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`:
+9 -6
View File
@@ -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 <target-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 <trusted-main-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 <trusted-main-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
+8
View File
@@ -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.
+63 -6
View File
@@ -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) => {
+51 -41
View File
@@ -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}`], {
@@ -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 <sha> --workflow-sha <sha> \
--workflow-ref <main|release-ci/sha12-timestamp> \
--release-profile <beta|stable|full> --inputs-json <json> \
[--run-release-soak <true|false>] [--repo <owner/repo>] [--repo-dir <path>] \
[--workflow <file>] [--max-candidates <n>] [--github-output <file>]
@@ -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 <owner/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])-"))
)
)
)
+17 -11
View File
@@ -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;
}
+11 -2
View File
@@ -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,
+120 -1
View File
@@ -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}`);
@@ -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 <full-release-run-id>",
" release-ci-summary.mjs --validate-run <id> [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] --json",
" release-ci-summary.mjs <full-release-run-id> --watch [--interval seconds]",
" release-ci-summary.mjs --validate-run <id> [--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);
},
);
}
+131
View File
@@ -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);
}
@@ -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 ?? "<missing>"}.`,
);
}
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}`,
@@ -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[] = [];
+1
View File
@@ -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"]);
+31 -1
View File
@@ -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",
);
@@ -178,17 +178,24 @@ function normalizedEvidence(options: {
targetSha: string;
validationInputs?: Record<string, string> | 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<string, string> {
}
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);
@@ -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 });
}
});
});
@@ -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"',
+19
View File
@@ -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
@@ -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<T>(value: string, fn: () => Promise<T>):
}
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',
+133 -16
View File
@@ -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 });
@@ -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",
@@ -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",
]);
});
});
@@ -43,6 +43,31 @@ function releaseManifest(overrides: Record<string, unknown> = {}) {
};
}
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<string, unknown> = {},
manifestOverrides: Record<string, unknown> = {},
@@ -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", () => {
+39
View File
@@ -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 {