mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(release): keep validation evidence immutable across reruns (#103906)
* fix(release): bind validation evidence to exact attempts * test(release): cover exact validation attempts
This commit is contained in:
@@ -14,6 +14,7 @@ import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.
|
||||
|
||||
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_FILE = fileURLToPath(import.meta.url);
|
||||
const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), "../../../..");
|
||||
@@ -1061,20 +1062,43 @@ function trustedWorkflowFullRef(workflowRef) {
|
||||
return `refs/heads/${workflowRef}`;
|
||||
}
|
||||
|
||||
function normalizeWorkflowPathRef(ref) {
|
||||
if (!ref || ref.startsWith("refs/")) {
|
||||
return ref;
|
||||
}
|
||||
return `refs/heads/${ref}`;
|
||||
}
|
||||
|
||||
function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) {
|
||||
const { manifest, parentRun } = evidence;
|
||||
const expectedFullRef = trustedWorkflowFullRef(trustedWorkflowRef);
|
||||
if (manifest.workflowRef !== trustedWorkflowRef) {
|
||||
// Keep this predicate local: verifier source identity covers this file only.
|
||||
const shaPinned = SHA_PINNED_BRANCH_PATTERN.test(manifest.workflowRef ?? "");
|
||||
if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) {
|
||||
throw new Error(
|
||||
`release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`,
|
||||
);
|
||||
}
|
||||
if (shaPinned) {
|
||||
if (manifest.version !== 3) {
|
||||
throw new Error("SHA-pinned release evidence requires a v3 manifest");
|
||||
}
|
||||
if (!manifest.workflowRef.startsWith(`release-ci/${manifest.workflowSha.slice(0, 12)}-`)) {
|
||||
throw new Error("SHA-pinned release evidence branch does not match its workflow SHA");
|
||||
}
|
||||
if (manifest.targetRef !== manifest.targetSha) {
|
||||
throw new Error("SHA-pinned release evidence target ref must equal its target SHA");
|
||||
}
|
||||
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 ?? "");
|
||||
const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2);
|
||||
if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") {
|
||||
throw new Error("release evidence producer workflow path is not trusted");
|
||||
}
|
||||
if (runWorkflowFullRef && runWorkflowFullRef !== expectedFullRef) {
|
||||
if (runWorkflowFullRef && normalizeWorkflowPathRef(runWorkflowFullRef) !== expectedFullRef) {
|
||||
throw new Error("release evidence producer workflow full ref is not trusted");
|
||||
}
|
||||
|
||||
@@ -1083,7 +1107,7 @@ function validateTrustedProducerIdentity(evidence, client, verifier, trustedWork
|
||||
if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) {
|
||||
throw new Error("release evidence producer workflow full ref is not trusted");
|
||||
}
|
||||
workflowRefProof = "manifest-v3-branch";
|
||||
workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch";
|
||||
}
|
||||
|
||||
const comparison = client.compareCommits(manifest.workflowSha, verifier.sourceSha);
|
||||
@@ -1256,6 +1280,17 @@ export function validateReleaseRunEvidence(
|
||||
repository: normalizedRepository,
|
||||
runId: normalizedRunId,
|
||||
});
|
||||
const producerIdentities = new Map([
|
||||
[
|
||||
currentEvidence.manifest.runId,
|
||||
validateTrustedProducerIdentity(
|
||||
currentEvidence,
|
||||
evidenceClient,
|
||||
verifier,
|
||||
normalizedTrustedWorkflowRef,
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
let rootEvidence = currentEvidence;
|
||||
let selectedEvidence = currentEvidence;
|
||||
@@ -1281,7 +1316,6 @@ export function validateReleaseRunEvidence(
|
||||
);
|
||||
}
|
||||
|
||||
const producerIdentities = new Map();
|
||||
for (const evidence of [currentEvidence, selectedEvidence, rootEvidence]) {
|
||||
if (!producerIdentities.has(evidence.manifest.runId)) {
|
||||
producerIdentities.set(
|
||||
|
||||
@@ -173,15 +173,17 @@ on pinned current `main` as the exact command and validation contract.
|
||||
npm preflight run ID.
|
||||
4. Dispatch `full-release-validation.yml` from the same branch with
|
||||
`ref=extended-stable/YYYY.M.33` and `release_profile=stable`. Save the
|
||||
successful exact-head validation run ID.
|
||||
successful exact-head validation run ID and its exact `run_attempt` from
|
||||
`gh api repos/openclaw/openclaw/actions/runs/<run-id> --jq .run_attempt`.
|
||||
5. Dispatch `plugin-npm-release.yml` from the same branch with
|
||||
`publish_scope=all-publishable`, the full release SHA as `ref`, and
|
||||
`npm_dist_tag=extended-stable`. Require complete exact-version and selector
|
||||
readback, then save the successful plugin run ID.
|
||||
6. Dispatch the real `openclaw-npm-release.yml` publish from the same branch
|
||||
with the intended tag, `npm_dist_tag=extended-stable`, and all three saved
|
||||
run IDs. The workflow must publish the exact prepared core tarball and prove
|
||||
the referenced runs match the canonical branch and release SHA.
|
||||
with the intended tag, `npm_dist_tag=extended-stable`, all three saved run
|
||||
IDs, and `full_release_validation_run_attempt=<saved-attempt>`. The workflow
|
||||
must publish the exact prepared core tarball and prove the referenced runs
|
||||
match the canonical branch and release SHA.
|
||||
7. Independently verify the exact core package, every official plugin package,
|
||||
and all `extended-stable` selectors. If only the core selector readback
|
||||
fails, use the `openclaw` repair command generated by the core workflow. If
|
||||
@@ -726,10 +728,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- The npm workflow and the release-ops mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- Real npm publish requires a prior successful npm preflight run id and the
|
||||
successful Full Release Validation run id for the same tag/SHA so the publish
|
||||
job promotes the prepared tarball instead of rebuilding it and attaches the
|
||||
correct release evidence.
|
||||
- Real npm publish requires a prior successful npm preflight run id plus the
|
||||
successful Full Release Validation run id and exact run attempt for the same
|
||||
tag/SHA so the publish job promotes the prepared tarball instead of rebuilding
|
||||
it and attaches the correct release evidence.
|
||||
- Real release-ops mac publish requires a prior successful release-ops mac
|
||||
preflight run id so the publish job promotes the prepared artifacts instead of
|
||||
rebuilding or renotarizing them again.
|
||||
@@ -908,7 +910,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
the same as the preflight run, and pass the successful npm
|
||||
`preflight_run_id` plus the successful `full_release_validation_run_id`.
|
||||
`preflight_run_id` plus the successful `full_release_validation_run_id` and
|
||||
its exact `full_release_validation_run_attempt`.
|
||||
For stable publish, also pass the exact non-prerelease
|
||||
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
|
||||
candidate-approved installer digest map as `windows_node_installer_digests`.
|
||||
|
||||
@@ -199,13 +199,17 @@ git tag -a "$TAG" "$SHA" -m "openclaw ${TAG#v}"
|
||||
git push origin "$TAG"
|
||||
```
|
||||
|
||||
8. Dispatch the publish wrapper from the same alpha branch. Use the successful npm preflight run ID and full release validation run ID from the same head SHA:
|
||||
8. Dispatch the publish wrapper from the same alpha branch. Use the successful npm preflight run ID and the full release validation run ID plus exact attempt from the same head SHA:
|
||||
|
||||
```bash
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT="$(gh api \
|
||||
"repos/openclaw/openclaw/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
--jq .run_attempt)"
|
||||
"$GH" workflow run openclaw-release-publish.yml --repo openclaw/openclaw --ref "$BRANCH" \
|
||||
-f tag="$TAG" \
|
||||
-f preflight_run_id="$NPM_PREFLIGHT_RUN_ID" \
|
||||
-f full_release_validation_run_id="$FULL_RELEASE_VALIDATION_RUN_ID" \
|
||||
-f full_release_validation_run_attempt="$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" \
|
||||
-f npm_dist_tag=alpha \
|
||||
-f plugin_publish_scope=all-publishable \
|
||||
-f publish_openclaw_npm=true \
|
||||
|
||||
@@ -17,7 +17,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
full_release_validation_run_id:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
|
||||
description: Successful Full Release Validation run id for this tag/SHA, paired with its exact attempt for real publish
|
||||
required: false
|
||||
type: string
|
||||
full_release_validation_run_attempt:
|
||||
description: Exact successful Full Release Validation run attempt for this tag/SHA
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
@@ -579,6 +583,7 @@ jobs:
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }}
|
||||
PLUGIN_NPM_RUN_ID: ${{ inputs.plugin_npm_run_id }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
run: |
|
||||
@@ -588,12 +593,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" ]]; then
|
||||
echo "full_release_validation_run_attempt requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" == "beta" ]]; then
|
||||
echo "::warning::Beta publish is proceeding from npm preflight only; full release validation remains required before stable/latest promotion."
|
||||
else
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [[ ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Real publish with full release validation requires a positive full_release_validation_run_attempt." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && -z "${PLUGIN_NPM_RUN_ID// }" ]]; then
|
||||
echo "Extended-stable publish requires plugin_npm_run_id from a successful Plugin NPM Release run." >&2
|
||||
@@ -719,10 +731,12 @@ jobs:
|
||||
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
|
||||
|
||||
- name: Verify full release validation run metadata
|
||||
id: full_run
|
||||
if: ${{ inputs.full_release_validation_run_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
EXPECTED_EXTENDED_STABLE_BRANCH: ${{ github.ref_name }}
|
||||
RUN_KIND: validation
|
||||
@@ -730,8 +744,23 @@ jobs:
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
export EXPECTED_RELEASE_SHA
|
||||
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,headSha,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
|
||||
run_file="${RUNNER_TEMP}/full-release-validation-run.json"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" > "$run_file"
|
||||
jq '{
|
||||
workflowName: .name,
|
||||
headBranch: .head_branch,
|
||||
headSha: .head_sha,
|
||||
event,
|
||||
status,
|
||||
conclusion,
|
||||
url: .html_url
|
||||
}' "$run_file" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
|
||||
run_attempt="$(jq -r '.run_attempt // ""' "$run_file")"
|
||||
if [[ "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then
|
||||
echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt:-<missing>}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify plugin npm release run metadata
|
||||
if: ${{ inputs.npm_dist_tag == 'extended-stable' }}
|
||||
@@ -805,7 +834,7 @@ jobs:
|
||||
if: ${{ inputs.full_release_validation_run_id != '' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }}
|
||||
path: full-release-validation
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.full_release_validation_run_id }}
|
||||
@@ -887,6 +916,8 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
EXPECTED_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ steps.full_run.outputs.attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
|
||||
required: false
|
||||
type: string
|
||||
full_release_validation_run_attempt:
|
||||
description: Exact Full Release Validation run attempt; when omitted, the current attempt is resolved once before publish
|
||||
required: false
|
||||
type: string
|
||||
windows_node_tag:
|
||||
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
|
||||
required: false
|
||||
@@ -90,6 +94,7 @@ jobs:
|
||||
outputs:
|
||||
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
|
||||
full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }}
|
||||
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
@@ -97,6 +102,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
|
||||
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
|
||||
@@ -127,6 +133,14 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
|
||||
echo "full_release_validation_run_attempt requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && ! "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "full_release_validation_run_attempt must be a positive integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
stable_release=true
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
|
||||
stable_release=false
|
||||
@@ -289,16 +303,54 @@ jobs:
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve full release validation run
|
||||
id: full_run
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_file="${RUNNER_TEMP}/full-release-validation-run.json"
|
||||
run_endpoint="repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" ]]; then
|
||||
run_endpoint+="/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}"
|
||||
fi
|
||||
gh api "$run_endpoint" > "$run_file"
|
||||
run_attempt="$(jq -r '.run_attempt // ""' "$run_file")"
|
||||
if [[ ! "$run_attempt" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Full Release Validation run ${FULL_RELEASE_VALIDATION_RUN_ID} has invalid attempt: ${run_attempt:-<missing>}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" && "$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" ]]; then
|
||||
echo "Full Release Validation run attempt mismatch: expected ${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run_attempt}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download full release validation manifest
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }}
|
||||
path: ${{ runner.temp }}/full-release-validation-manifest
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.full_release_validation_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Download trusted release validation tooling
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tooling_dir="${RUNNER_TEMP}/release-validation-tooling"
|
||||
mkdir -p "$tooling_dir"
|
||||
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"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
@@ -364,17 +416,21 @@ jobs:
|
||||
EXPECTED_SHA: ${{ steps.ref.outputs.sha }}
|
||||
EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } const allowedBranches = new Set(["main", process.env.EXPECTED_WORKFLOW_BRANCH].filter(Boolean)); if (!allowedBranches.has(run.headBranch)) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have headBranch in ${[...allowedBranches].join(", ")}, got ${run.headBranch ?? "<missing>"}.`); process.exit(1); } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
|
||||
|
||||
manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json"
|
||||
if [[ ! -f "$manifest" ]]; then
|
||||
echo "Full release validation manifest is missing." >&2
|
||||
ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main
|
||||
MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"
|
||||
|
||||
workflow_name="$(jq -r '.workflowName // ""' "$manifest")"
|
||||
target_sha="$(jq -r '.targetSha // ""' "$manifest")"
|
||||
release_profile="$(jq -r '.releaseProfile // ""' "$manifest")"
|
||||
@@ -482,7 +538,7 @@ jobs:
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
|
||||
name: full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}
|
||||
path: ${{ runner.temp }}/full-release-validation-manifest
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.full_release_validation_run_id }}
|
||||
@@ -564,6 +620,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
@@ -1451,6 +1508,8 @@ jobs:
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
|
||||
local validation_manifest validation_run_attempt validation_run_id
|
||||
local validation_target_sha validation_url validation_workflow_ref
|
||||
local -a verify_args
|
||||
|
||||
skip_clawhub="${1:-false}"
|
||||
@@ -1468,7 +1527,6 @@ jobs:
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--clawhub-workflow-ref "${clawhub_workflow_ref}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
--skip-github-release
|
||||
@@ -1491,8 +1549,38 @@ jobs:
|
||||
fi
|
||||
|
||||
pnpm "${verify_args[@]}"
|
||||
jq --arg release_publish_run_id "$GITHUB_RUN_ID" \
|
||||
'.releasePublishRunId = $release_publish_run_id' \
|
||||
|
||||
# Resolve already validated this exact v3 run before mutation. Append
|
||||
# its immutable tuple here so frozen targets need no new CLI option.
|
||||
validation_manifest="${FULL_RELEASE_VALIDATION_MANIFEST_DIR}/full-release-validation-manifest.json"
|
||||
validation_run_id="$(jq -er '.runId | select(type == "string" and length > 0)' "${validation_manifest}")"
|
||||
validation_run_attempt="$(jq -er '.runAttempt | select(type == "string" and test("^[1-9][0-9]*$"))' "${validation_manifest}")"
|
||||
validation_workflow_ref="$(jq -er '.workflowRef | select(type == "string" and length > 0)' "${validation_manifest}")"
|
||||
validation_target_sha="$(jq -er '.targetSha | select(type == "string" and test("^[a-f0-9]{40}$"))' "${validation_manifest}")"
|
||||
if [[ "${validation_run_id}" != "${FULL_RELEASE_VALIDATION_RUN_ID}" ||
|
||||
"${validation_run_attempt}" != "${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" ||
|
||||
"${validation_target_sha}" != "${TARGET_SHA}" ]]; then
|
||||
echo "Full release validation evidence changed after prepublish validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
validation_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${validation_run_id}"
|
||||
jq \
|
||||
--arg release_publish_run_id "$GITHUB_RUN_ID" \
|
||||
--arg validation_run_id "${validation_run_id}" \
|
||||
--arg validation_run_attempt "${validation_run_attempt}" \
|
||||
--arg validation_target_sha "${validation_target_sha}" \
|
||||
--arg validation_url "${validation_url}" \
|
||||
--arg validation_workflow_ref "${validation_workflow_ref}" '
|
||||
.releasePublishRunId = $release_publish_run_id |
|
||||
.workflowRuns += [{
|
||||
id: $validation_run_id,
|
||||
label: "Full Release Validation",
|
||||
runAttempt: $validation_run_attempt,
|
||||
targetSha: $validation_target_sha,
|
||||
url: $validation_url,
|
||||
workflowRef: $validation_workflow_ref
|
||||
}]
|
||||
' \
|
||||
"${evidence_path}" > "${evidence_path}.next"
|
||||
mv "${evidence_path}.next" "${evidence_path}"
|
||||
{
|
||||
@@ -1706,6 +1794,7 @@ jobs:
|
||||
-f preflight_only=false \
|
||||
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
|
||||
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
-f full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" \
|
||||
-f release_publish_run_id="${GITHUB_RUN_ID}" \
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
|
||||
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
full_release_validation_run_id: ${{ steps.inputs.outputs.full_release_validation_run_id }}
|
||||
full_release_validation_run_attempt: ${{ steps.inputs.outputs.full_release_validation_run_attempt }}
|
||||
release_publish_run_id: ${{ steps.inputs.outputs.release_publish_run_id }}
|
||||
rollback_drill_date: ${{ steps.inputs.outputs.rollback_drill_date }}
|
||||
rollback_drill_id: ${{ steps.inputs.outputs.rollback_drill_id }}
|
||||
@@ -150,6 +151,8 @@ jobs:
|
||||
evidence_version="${evidence_source_tag#v}"
|
||||
evidence_asset="openclaw-${evidence_version}-postpublish-evidence.json"
|
||||
evidence_checksum_asset="${evidence_asset}.sha256"
|
||||
release_manifest_asset="openclaw-${evidence_version}-release-manifest.json"
|
||||
release_manifest_checksum_asset="${release_manifest_asset}.sha256"
|
||||
closeout_asset="openclaw-${release_asset_version}-stable-main-closeout.json"
|
||||
closeout_checksum_asset="${closeout_asset}.sha256"
|
||||
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
|
||||
@@ -160,6 +163,7 @@ jobs:
|
||||
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
|
||||
repair_partial_closeout=false
|
||||
existing_closeout_full_release_validation_run_id=""
|
||||
existing_closeout_full_release_validation_run_attempt=""
|
||||
existing_closeout_release_publish_run_id=""
|
||||
if [[ -f "$closeout_json_path" && -f "$closeout_checksum_path" ]]; then
|
||||
expected_closeout_digest="$(awk 'NF { print $1; exit }' "$closeout_checksum_path")"
|
||||
@@ -177,17 +181,22 @@ jobs:
|
||||
if [[ -f "$closeout_json_path" ]]; then
|
||||
existing_closeout_tag="$(jq -r '.releaseTag // empty' "$closeout_json_path")"
|
||||
existing_closeout_version="$(jq -r '.releaseVersion // empty' "$closeout_json_path")"
|
||||
existing_closeout_schema_version="$(jq -r '.version // empty' "$closeout_json_path")"
|
||||
existing_closeout_release_tag_sha="$(jq -r '.releaseTagSha // empty' "$closeout_json_path")"
|
||||
existing_closeout_main_ref="$(jq -r '.mainSha // empty' "$closeout_json_path")"
|
||||
existing_closeout_full_release_validation_run_id="$(jq -r '.fullReleaseValidationRunId // empty' "$closeout_json_path")"
|
||||
existing_closeout_full_release_validation_run_attempt="$(jq -r '.fullReleaseValidationRunAttempt // empty' "$closeout_json_path")"
|
||||
existing_closeout_release_publish_run_id="$(jq -r '.releasePublishRunId // empty' "$closeout_json_path")"
|
||||
existing_closeout_rollback_drill_id="$(jq -r '.rollbackDrill.id // empty' "$closeout_json_path")"
|
||||
existing_closeout_rollback_drill_date="$(jq -r '.rollbackDrill.date // empty' "$closeout_json_path")"
|
||||
if [[ "$existing_closeout_tag" != "$tag" ||
|
||||
# Schema v1 never shipped as a release asset and cannot bind an immutable run attempt.
|
||||
if [[ "$existing_closeout_schema_version" != "2" ||
|
||||
"$existing_closeout_tag" != "$tag" ||
|
||||
"$existing_closeout_version" != "$tag_package_version" ||
|
||||
! "$existing_closeout_release_tag_sha" =~ ^[0-9a-f]{40}$ ||
|
||||
! "$existing_closeout_main_ref" =~ ^[0-9a-f]{40}$ ||
|
||||
-z "$existing_closeout_full_release_validation_run_id" ||
|
||||
! "$existing_closeout_full_release_validation_run_attempt" =~ ^[1-9][0-9]*$ ||
|
||||
-z "$existing_closeout_release_publish_run_id" ||
|
||||
-z "$existing_closeout_rollback_drill_id" ||
|
||||
! "$existing_closeout_rollback_drill_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
@@ -211,9 +220,15 @@ jobs:
|
||||
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
|
||||
mkdir -p "$evidence_dir"
|
||||
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
|
||||
--pattern "$evidence_asset" \
|
||||
--pattern "$evidence_checksum_asset" \
|
||||
--pattern "$release_manifest_asset" \
|
||||
--pattern "$release_manifest_checksum_asset" \
|
||||
--dir "$evidence_dir" || true
|
||||
evidence_path="$evidence_dir/$evidence_asset"
|
||||
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
|
||||
release_manifest_path="$evidence_dir/$release_manifest_asset"
|
||||
release_manifest_checksum_path="$evidence_dir/$release_manifest_checksum_asset"
|
||||
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
|
||||
@@ -223,22 +238,59 @@ jobs:
|
||||
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$release_manifest_path" || ! -f "$release_manifest_checksum_path" ]]; then
|
||||
echo "Stable closeout is required for $tag, but immutable Full Release Validation evidence from $evidence_source_tag is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! (
|
||||
cd "$evidence_dir"
|
||||
sha256sum --strict --status -c "$evidence_checksum_asset"
|
||||
sha256sum --strict --status -c "$release_manifest_checksum_asset"
|
||||
); then
|
||||
echo "Postpublish evidence checksum failed for $tag." >&2
|
||||
echo "Release evidence checksum failed for $tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
evidence_release_tag="$(jq -r '.releaseTag // empty' "$evidence_path")"
|
||||
full_release_validation_run_id="$(jq -r '[.workflowRuns[]? | select(.label == "Full Release Validation") | .id] | if length == 1 then .[0] else empty end' "$evidence_path")"
|
||||
if ! full_release_validation_run_json="$(jq -ce '
|
||||
[.workflowRuns[]? | select(.label == "Full Release Validation")] |
|
||||
if length == 1 and
|
||||
(.[0].id | type == "string" and test("^[1-9][0-9]*$")) and
|
||||
((.[0].runAttempt == null) or
|
||||
(.[0].runAttempt | type == "string" and test("^[1-9][0-9]*$")))
|
||||
then {id: .[0].id, runAttempt: (.[0].runAttempt // "")}
|
||||
else empty
|
||||
end
|
||||
' "$evidence_path")"; then
|
||||
echo "Stable closeout is required for $tag, but postpublish evidence does not bind exactly one Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! release_manifest_run_json="$(jq -ce '
|
||||
if .workflowName == "Full Release Validation" and
|
||||
(.runId | type == "string" and test("^[1-9][0-9]*$")) and
|
||||
(.runAttempt | type == "string" and test("^[1-9][0-9]*$"))
|
||||
then {id: .runId, runAttempt: .runAttempt}
|
||||
else empty
|
||||
end
|
||||
' "$release_manifest_path")"; then
|
||||
echo "Stable closeout release manifest does not bind a Full Release Validation run attempt." >&2
|
||||
exit 1
|
||||
fi
|
||||
evidence_full_release_validation_run_id="$(jq -r '.id' <<<"$full_release_validation_run_json")"
|
||||
evidence_full_release_validation_run_attempt="$(jq -r '.runAttempt' <<<"$full_release_validation_run_json")"
|
||||
full_release_validation_run_id="$(jq -r '.id' <<<"$release_manifest_run_json")"
|
||||
full_release_validation_run_attempt="$(jq -r '.runAttempt' <<<"$release_manifest_run_json")"
|
||||
release_publish_run_id="$(jq -r '.releasePublishRunId // empty' "$evidence_path")"
|
||||
if [[ "$evidence_release_tag" != "$evidence_source_tag" || -z "$full_release_validation_run_id" || -z "$release_publish_run_id" ]]; then
|
||||
if [[ "$evidence_release_tag" != "$evidence_source_tag" ||
|
||||
"$evidence_full_release_validation_run_id" != "$full_release_validation_run_id" ||
|
||||
( -n "$evidence_full_release_validation_run_attempt" &&
|
||||
"$evidence_full_release_validation_run_attempt" != "$full_release_validation_run_attempt" ) ||
|
||||
-z "$release_publish_run_id" ]]; then
|
||||
echo "Stable closeout is required for $tag, but postpublish evidence does not bind $evidence_source_tag to exactly one Full Release Validation run and its Publish run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$existing_closeout_full_release_validation_run_id" &&
|
||||
( "$existing_closeout_full_release_validation_run_id" != "$full_release_validation_run_id" ||
|
||||
"$existing_closeout_full_release_validation_run_attempt" != "$full_release_validation_run_attempt" ||
|
||||
"$existing_closeout_release_publish_run_id" != "$release_publish_run_id" ) ]]; then
|
||||
echo "Stable closeout manifest for $tag does not match immutable postpublish evidence; refusing to accept it." >&2
|
||||
exit 1
|
||||
@@ -254,6 +306,7 @@ jobs:
|
||||
fi
|
||||
{
|
||||
echo "full_release_validation_run_id=$full_release_validation_run_id"
|
||||
echo "full_release_validation_run_attempt=$full_release_validation_run_attempt"
|
||||
echo "release_publish_run_id=$release_publish_run_id"
|
||||
echo "rollback_drill_date=$ROLLBACK_DRILL_DATE"
|
||||
echo "rollback_drill_id=$ROLLBACK_DRILL_ID"
|
||||
@@ -334,19 +387,20 @@ jobs:
|
||||
- name: Verify release workflow evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVIDENCE_TAG: ${{ needs.resolve.outputs.evidence_tag }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ needs.resolve.outputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve.outputs.full_release_validation_run_attempt }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" \
|
||||
> "$RUNNER_TEMP/full-release-validation-run.json"
|
||||
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
|
||||
import { readFileSync } from "node:fs";
|
||||
const run = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
for (const [key, expected] of [
|
||||
["workflowName", "Full Release Validation"],
|
||||
["name", "Full Release Validation"],
|
||||
["event", "workflow_dispatch"],
|
||||
["status", "completed"],
|
||||
["conclusion", "success"],
|
||||
@@ -355,6 +409,9 @@ jobs:
|
||||
throw new Error(`Full Release Validation must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
|
||||
}
|
||||
}
|
||||
if (String(run.run_attempt ?? "") !== process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT) {
|
||||
throw new Error(`Full Release Validation run attempt mismatch: expected ${process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT}, got ${run.run_attempt ?? "<missing>"}.`);
|
||||
}
|
||||
NODE
|
||||
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
@@ -377,18 +434,34 @@ jobs:
|
||||
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
|
||||
rm -rf "$manifest_dir"
|
||||
mkdir -p "$manifest_dir"
|
||||
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
evidence_version="${EVIDENCE_TAG#v}"
|
||||
manifest_asset="openclaw-${evidence_version}-release-manifest.json"
|
||||
manifest_checksum_asset="${manifest_asset}.sha256"
|
||||
gh_with_retry release download "$EVIDENCE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$manifest_asset" \
|
||||
--pattern "$manifest_checksum_asset" \
|
||||
--dir "$manifest_dir"
|
||||
(
|
||||
cd "$manifest_dir"
|
||||
sha256sum --strict --status -c "$manifest_checksum_asset"
|
||||
) || {
|
||||
echo "Full Release Validation manifest checksum failed for $EVIDENCE_TAG." >&2
|
||||
exit 1
|
||||
}
|
||||
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
|
||||
jq -e --arg tag_sha "$tag_sha" '
|
||||
jq -e \
|
||||
--arg tag_sha "$tag_sha" \
|
||||
--arg run_id "$FULL_RELEASE_VALIDATION_RUN_ID" \
|
||||
--arg run_attempt "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" '
|
||||
.workflowName == "Full Release Validation" and
|
||||
.runId == $run_id and
|
||||
.runAttempt == $run_attempt and
|
||||
.targetSha == $tag_sha and
|
||||
.rerunGroup == "all" and
|
||||
.runReleaseSoak == "true" and
|
||||
.controls.performanceBlocking == true and
|
||||
.childRuns.productPerformance.conclusion == "success"
|
||||
' "$manifest_dir/full-release-validation-manifest.json" >/dev/null || {
|
||||
' "$manifest_dir/$manifest_asset" >/dev/null || {
|
||||
echo "Full Release Validation manifest does not contain the required stable release controls." >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -398,6 +471,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ needs.resolve.outputs.full_release_validation_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve.outputs.full_release_validation_run_attempt }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
|
||||
ROLLBACK_DRILL_ID: ${{ needs.resolve.outputs.rollback_drill_id }}
|
||||
ROLLBACK_DRILL_DATE: ${{ needs.resolve.outputs.rollback_drill_date }}
|
||||
@@ -416,6 +490,7 @@ jobs:
|
||||
--tag-dir "$GITHUB_WORKSPACE/release-tag" \
|
||||
--release-json "$CLOSEOUT_DIR/github-release.json" \
|
||||
--full-release-validation-run-id "$FULL_RELEASE_VALIDATION_RUN_ID" \
|
||||
--full-release-validation-run-attempt "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT" \
|
||||
--release-publish-run-id "$RELEASE_PUBLISH_RUN_ID" \
|
||||
--rollback-drill-id "$ROLLBACK_DRILL_ID" \
|
||||
--rollback-drill-date "$ROLLBACK_DRILL_DATE" \
|
||||
|
||||
+8
-6
@@ -247,7 +247,8 @@ focused rerun handles.
|
||||
from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the
|
||||
OpenClaw npm preflight has succeeded (the preflight runs `pnpm plugins:sync:check`
|
||||
among its checks). It requires the saved `preflight_run_id` and a successful
|
||||
`full_release_validation_run_id`, dispatches `Plugin NPM Release` for all
|
||||
`full_release_validation_run_id` and its exact
|
||||
`full_release_validation_run_attempt`, dispatches `Plugin NPM Release` for all
|
||||
publishable plugin packages, dispatches `Plugin ClawHub Release` for the same
|
||||
release SHA, and only then dispatches `OpenClaw NPM Release`. Stable publish also
|
||||
requires an exact `windows_node_tag`; the workflow verifies the Windows source
|
||||
@@ -262,6 +263,7 @@ gh workflow run openclaw-release-publish.yml \
|
||||
-f tag=vYYYY.M.PATCH-beta.N \
|
||||
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
|
||||
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
|
||||
-f full_release_validation_run_attempt=<successful-full-release-validation-run-attempt> \
|
||||
-f npm_dist_tag=beta
|
||||
```
|
||||
|
||||
@@ -273,11 +275,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 the target SHA,
|
||||
dispatches `Full Release Validation` from that pinned ref, verifies every child
|
||||
workflow `headSha` matches the target, and deletes the temporary branch when the
|
||||
run completes. The umbrella verifier also fails if any child workflow ran at a
|
||||
different SHA.
|
||||
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.
|
||||
|
||||
`release_profile` controls live/provider breadth passed into release checks. The
|
||||
manual release workflows default to `stable`; use `full` only when you
|
||||
|
||||
@@ -63,7 +63,8 @@ The workflows consume this prepared tree; they do not bump or synchronize
|
||||
versions for you.
|
||||
|
||||
Run the npm preflight and Full Release Validation from that exact prepared
|
||||
branch tip, then save both run IDs:
|
||||
branch tip, then save both run IDs and the successful Full Release Validation
|
||||
run attempt:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-npm-release.yml \
|
||||
@@ -116,6 +117,7 @@ gh workflow run openclaw-npm-release.yml \
|
||||
-f npm_dist_tag=extended-stable \
|
||||
-f preflight_run_id=<npm-preflight-run-id> \
|
||||
-f full_release_validation_run_id=<full-validation-run-id> \
|
||||
-f full_release_validation_run_attempt=<full-validation-run-attempt> \
|
||||
-f plugin_npm_run_id=<plugin-npm-run-id>
|
||||
```
|
||||
|
||||
@@ -164,7 +166,7 @@ This checklist is the public shape of the release flow. Private credentials, sig
|
||||
4. Create `release/YYYY.M.PATCH` from current `main`. Do not do normal release work directly on `main`.
|
||||
5. Bump every required version location for the tag, then run `pnpm release:prep`. It refreshes plugin versions, npm shrinkwraps, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, and plugin SDK API baseline in order. Commit any generated drift before tagging, then run the local deterministic preflight: `pnpm check:test-types`, `pnpm check:architecture`, `pnpm build && pnpm ui:build`, and `pnpm release:check`.
|
||||
6. Run `OpenClaw NPM Release` with `preflight_only=true`. Before a tag exists, a full 40-character release-branch SHA is allowed for validation-only preflight. The preflight generates dependency release evidence for the exact checked-out dependency graph and stores it in the npm preflight artifact. Save the successful `preflight_run_id`.
|
||||
7. Kick off all pre-release tests with `Full Release Validation` for the release branch, tag, or full commit SHA. This is the one manual entrypoint for the four big release test boxes: Vitest, Docker, QA Lab, and Package. Save the `full_release_validation_run_id`; it is required input for both `OpenClaw NPM Release` and `OpenClaw Release Publish`.
|
||||
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`. 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.
|
||||
|
||||
@@ -236,7 +238,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
|
||||
- Run `pnpm qa:observability:smoke` for the source-checkout OpenTelemetry and Prometheus smoke lanes back to back.
|
||||
- Run `pnpm release:check` before every tagged release.
|
||||
- `OpenClaw NPM Release` preflight generates dependency release evidence before it packs the npm tarball. The npm advisory vulnerability gate is release-blocking. The transitive manifest risk, dependency ownership/install surface, and dependency change reports are release evidence only. The dependency change report compares the release candidate with the previous reachable release tag. The preflight uploads dependency evidence as `openclaw-release-dependency-evidence-<tag>` and also embeds it under `dependency-evidence/` inside the prepared npm preflight artifact. The real publish path reuses that preflight artifact, then attaches the same evidence to the GitHub release as `openclaw-<version>-dependency-evidence.zip`.
|
||||
- Run `OpenClaw Release Publish` for the mutating publish sequence after the tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a main-reachable tag), pass the release tag, successful OpenClaw npm `preflight_run_id`, and successful `full_release_validation_run_id`, and keep the default plugin publish scope `all-publishable` unless you are deliberately running a focused repair. The workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw npm publish so the core package is not published before its externalized plugins; Windows and Android promotion runs concurrently with the core npm publish against the draft release page. Publish reruns are resumable: an already-published core npm version skips the core dispatch after the workflow proves the registry tarball matches the tag's preflight artifact, and Windows/Android promotion is skipped when the release already carries the verified asset contract, so a retry only redoes the failed stages.
|
||||
- Run `OpenClaw Release Publish` for the mutating publish sequence after the tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a main-reachable tag), pass the release tag, successful OpenClaw npm `preflight_run_id`, successful `full_release_validation_run_id`, and exact `full_release_validation_run_attempt`, and keep the default plugin publish scope `all-publishable` unless you are deliberately running a focused repair. The workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw npm publish so the core package is not published before its externalized plugins; Windows and Android promotion runs concurrently with the core npm publish against the draft release page. Publish reruns are resumable: an already-published core npm version skips the core dispatch after the workflow proves the registry tarball matches the tag's preflight artifact, and Windows/Android promotion is skipped when the release already carries the verified asset contract, so a retry only redoes the failed stages.
|
||||
- Stable `OpenClaw Release Publish` requires an exact `windows_node_tag` after the matching non-prerelease `openclaw/openclaw-windows-node` release exists, plus the candidate-approved `windows_node_installer_digests` map. Before dispatching any publish child, it verifies that source release is published, non-prerelease, contains the required x64/ARM64 installers, and still matches that approved map. It then dispatches `Windows Node Release` while the OpenClaw release is still a draft, carrying the pinned installer digest map unchanged. The child workflow downloads the signed Windows Hub installers from that exact tag, matches them against the pinned digests, verifies their Authenticode signatures use the expected OpenClaw Foundation signer on a Windows runner, writes a SHA-256 manifest, and uploads the installers plus manifest onto the canonical OpenClaw GitHub release, then re-downloads the promoted assets and verifies manifest membership and hashes. The parent verifies the current x64, ARM64, and checksum asset contract before publication. Direct recovery rejects unexpected `OpenClawCompanion-*` asset names before replacing the expected contract assets with the pinned source bytes.
|
||||
|
||||
Manually dispatch `Windows Node Release` only for recovery, and always pass an exact tag, never `latest`, plus the explicit `expected_installer_digests` JSON map from the approved source release. Website download links should target exact OpenClaw release asset URLs for the current stable release, or `releases/latest/download/...` only after verifying GitHub's latest redirect points at that same release; do not link only to the companion repo release page.
|
||||
@@ -461,7 +463,7 @@ release needs:
|
||||
3. Run `pnpm plugins:sync:check`.
|
||||
4. Dispatch `Plugin NPM Release` with `publish_scope=all-publishable` and `ref=<release-sha>`.
|
||||
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA.
|
||||
6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved `full_release_validation_run_id`.
|
||||
6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved `full_release_validation_run_id` and exact run attempt.
|
||||
7. For stable releases, create or update the GitHub release as a draft, dispatch `Windows Node Release` with the explicit `windows_node_tag` and candidate-approved `windows_node_installer_digests`, and verify the canonical Windows installer/checksum assets. Also dispatch `Android Release` to build the exact-tag signed APK plus checksum and provenance. Verify both native asset contracts before publishing the draft.
|
||||
|
||||
Beta publish example:
|
||||
@@ -472,6 +474,7 @@ gh workflow run openclaw-release-publish.yml \
|
||||
-f tag=vYYYY.M.PATCH-beta.N \
|
||||
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
|
||||
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
|
||||
-f full_release_validation_run_attempt=<successful-full-release-validation-run-attempt> \
|
||||
-f npm_dist_tag=beta
|
||||
```
|
||||
|
||||
@@ -485,6 +488,7 @@ gh workflow run openclaw-release-publish.yml \
|
||||
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
|
||||
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
|
||||
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
|
||||
-f full_release_validation_run_attempt=<successful-full-release-validation-run-attempt> \
|
||||
-f npm_dist_tag=beta
|
||||
```
|
||||
|
||||
@@ -498,6 +502,7 @@ gh workflow run openclaw-release-publish.yml \
|
||||
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
|
||||
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
|
||||
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
|
||||
-f full_release_validation_run_attempt=<successful-full-release-validation-run-attempt> \
|
||||
-f npm_dist_tag=latest
|
||||
```
|
||||
|
||||
@@ -511,6 +516,7 @@ Use the lower-level `Plugin NPM Release` and `Plugin ClawHub Release` workflows
|
||||
- `preflight_only`: `true` for validation/build/package only, `false` for the real publish path
|
||||
- `preflight_run_id`: existing successful preflight run id, required on the real publish path so the workflow reuses the prepared tarball instead of rebuilding it
|
||||
- `full_release_validation_run_id`: successful `Full Release Validation` run id for this tag/SHA, required for real publish. Beta publishes may proceed on preflight alone with a warning, but stable/`latest` promotion still requires it.
|
||||
- `full_release_validation_run_attempt`: exact positive run attempt paired with `full_release_validation_run_id`; required whenever the run id is provided so reruns cannot change the authorization evidence during publish.
|
||||
- `release_publish_run_id`: approved `OpenClaw Release Publish` run id; required when this workflow is dispatched by that parent (bot-actor real-publish calls)
|
||||
- `plugin_npm_run_id`: successful exact-head `Plugin NPM Release` run id; required for a real `extended-stable` core publish
|
||||
- `npm_dist_tag`: npm target tag for the publish path; accepts `alpha`, `beta`, `latest`, or `extended-stable` and defaults to `beta`. Final patch `33` and later must use `extended-stable`; by default, `extended-stable` rejects earlier patches, and it always rejects non-final tags.
|
||||
@@ -532,6 +538,7 @@ readback confirms that every exact package and `extended-stable` tag converged.
|
||||
- `tag`: required release tag; must already exist
|
||||
- `preflight_run_id`: successful `OpenClaw NPM Release` preflight run id; required when `publish_openclaw_npm=true`
|
||||
- `full_release_validation_run_id`: successful `Full Release Validation` run id; required when `publish_openclaw_npm=true`
|
||||
- `full_release_validation_run_attempt`: exact positive attempt paired with `full_release_validation_run_id`; required whenever the run id is provided
|
||||
- `windows_node_tag`: exact non-prerelease `openclaw/openclaw-windows-node` release tag; required for stable OpenClaw publish
|
||||
- `windows_node_installer_digests`: candidate-approved compact JSON map of the current Windows installer names to their pinned `sha256:` digests; required for stable OpenClaw publish
|
||||
- `npm_telegram_run_id`: optional successful `NPM Telegram Beta E2E` run id to include in final release evidence
|
||||
@@ -565,8 +572,8 @@ When cutting a regular orchestrated stable release:
|
||||
2. Choose `npm_dist_tag=beta` for the normal beta-first flow, or `latest` only when you intentionally want a direct stable publish.
|
||||
3. Run `Full Release Validation` on the release branch, release tag, or full commit SHA when you want normal CI plus live prompt cache, Docker, QA Lab, Matrix, and Telegram coverage from one manual workflow. If you intentionally only need the deterministic normal test graph, run the manual `CI` workflow on the release ref instead.
|
||||
4. Select the exact non-prerelease `openclaw/openclaw-windows-node` release tag whose signed x64 and ARM64 installers should ship. Save it as `windows_node_tag`, and save their validated digest map as `windows_node_installer_digests`. The release-candidate helper records both and includes them in its generated publish command.
|
||||
5. Save the successful `preflight_run_id` and `full_release_validation_run_id`.
|
||||
6. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`, the selected `windows_node_tag`, its saved `windows_node_installer_digests`, the saved `preflight_run_id`, and the saved `full_release_validation_run_id`. It publishes externalized plugins to npm and ClawHub before promoting the OpenClaw npm package.
|
||||
5. Save the successful `preflight_run_id`, `full_release_validation_run_id`, and exact `full_release_validation_run_attempt`.
|
||||
6. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`, the selected `windows_node_tag`, its saved `windows_node_installer_digests`, the saved `preflight_run_id`, `full_release_validation_run_id`, and `full_release_validation_run_attempt`. It publishes externalized plugins to npm and ClawHub before promoting the OpenClaw npm package.
|
||||
7. If the release landed on `beta`, use the `openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow to promote that stable version from `beta` to `latest`.
|
||||
8. If the release intentionally published directly to `latest` and `beta` should follow the same stable build immediately, use that same release workflow to point both dist-tags at the stable version, or let its scheduled self-healing sync move `beta` later.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const DEFAULT_INPUTS = {
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--workflow-sha <trusted-main-ref>] [--branch <name>] [--keep-branch] [--dry-run] [-- -f key=value ...]
|
||||
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--workflow-sha <trusted-main-ref>] [--keep-branch] [--dry-run] [-- -f key=value ...]
|
||||
|
||||
Creates a temporary remote branch pinned to trusted main release tooling,
|
||||
dispatches Full Release Validation with the target commit as its ref input,
|
||||
@@ -57,7 +57,6 @@ export function parseArgs(argv) {
|
||||
const args = {
|
||||
sha: "",
|
||||
workflowSha: "",
|
||||
branch: "",
|
||||
keepBranch: false,
|
||||
dryRun: false,
|
||||
inputs: { ...DEFAULT_INPUTS },
|
||||
@@ -79,11 +78,6 @@ export function parseArgs(argv) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--branch") {
|
||||
args.branch = readOptionValue(argv, i, arg);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--keep-branch") {
|
||||
args.keepBranch = true;
|
||||
continue;
|
||||
@@ -128,17 +122,12 @@ export function parseArgs(argv) {
|
||||
if (args.inputs.reuse_evidence !== "false") {
|
||||
throw new Error("SHA-pinned release validation always disables evidence reuse");
|
||||
}
|
||||
if (Object.hasOwn(args.inputs, "ref")) {
|
||||
throw new Error("SHA-pinned release validation reserves the ref input for --sha");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function sanitizeBranchPart(value) {
|
||||
return value
|
||||
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
||||
.replace(/\/+/g, "/")
|
||||
.replace(/^[/.-]+|[/.-]+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function resolveSha(requestedSha) {
|
||||
const rev = requestedSha || "HEAD";
|
||||
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
|
||||
@@ -237,7 +226,7 @@ function main() {
|
||||
const targetSha = resolveSha(args.sha);
|
||||
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
|
||||
const shortSha = workflowSha.slice(0, 12);
|
||||
const branch = sanitizeBranchPart(args.branch || `release-ci/${shortSha}-${Date.now()}`);
|
||||
const branch = `release-ci/${shortSha}-${Date.now()}`;
|
||||
const remoteBranchRef = `refs/heads/${branch}`;
|
||||
const dispatchInputs = { ref: targetSha, ...args.inputs };
|
||||
|
||||
|
||||
@@ -100,6 +100,13 @@ export function verifyStableMainCloseout(params) {
|
||||
tagVersion !== baseVersion && mainVersion === baseVersion && tagPackageVersion === baseVersion;
|
||||
const version = fallbackCorrection ? baseVersion : tagVersion;
|
||||
|
||||
const fullReleaseValidationRunAttempt = params.fullReleaseValidationRunAttempt ?? "";
|
||||
if (!/^[1-9]\d*$/u.test(fullReleaseValidationRunAttempt)) {
|
||||
errors.push(
|
||||
`full release validation run attempt is invalid: ${fullReleaseValidationRunAttempt || "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (mainVersion && mainVersion !== version) {
|
||||
errors.push(
|
||||
`main package.json version is ${mainVersion}, expected shipped version ${version}.`,
|
||||
@@ -165,7 +172,7 @@ export function verifyStableMainCloseout(params) {
|
||||
return {
|
||||
errors,
|
||||
manifest: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
releaseTag: params.tag,
|
||||
releaseVersion: version,
|
||||
releaseTagSha: params.releaseTagSha,
|
||||
@@ -175,6 +182,7 @@ export function verifyStableMainCloseout(params) {
|
||||
changelogSha256: sha256(mainChangelog),
|
||||
appcastSha256: sha256(params.mainAppcast),
|
||||
fullReleaseValidationRunId: params.fullReleaseValidationRunId,
|
||||
fullReleaseValidationRunAttempt,
|
||||
releasePublishRunId: params.releasePublishRunId,
|
||||
rollbackDrill: {
|
||||
id: params.rollbackDrillId,
|
||||
|
||||
@@ -210,6 +210,8 @@ export function validateFullReleaseValidationManifest({
|
||||
npmDistTag,
|
||||
expectedWorkflowRef,
|
||||
expectedSha,
|
||||
expectedRunId,
|
||||
expectedRunAttempt,
|
||||
}) {
|
||||
if (manifest.workflowName !== "Full Release Validation") {
|
||||
throw new Error(
|
||||
@@ -221,6 +223,19 @@ export function validateFullReleaseValidationManifest({
|
||||
`Full release validation target SHA mismatch: expected ${expectedSha}, got ${manifest.targetSha ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
if (expectedRunId !== undefined && String(manifest.runId) !== String(expectedRunId)) {
|
||||
throw new Error(
|
||||
`Full release validation run ID mismatch: expected ${expectedRunId}, got ${manifest.runId ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
expectedRunAttempt !== undefined &&
|
||||
String(manifest.runAttempt) !== String(expectedRunAttempt)
|
||||
) {
|
||||
throw new Error(
|
||||
`Full release validation run attempt mismatch: expected ${expectedRunAttempt}, got ${manifest.runAttempt ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
if (npmDistTag === "extended-stable" && manifest.workflowRef !== expectedWorkflowRef) {
|
||||
throw new Error(
|
||||
`Full release validation workflow ref mismatch: expected ${expectedWorkflowRef}, got ${manifest.workflowRef ?? "<missing>"}.`,
|
||||
@@ -482,6 +497,8 @@ async function main() {
|
||||
npmDistTag: process.env.RELEASE_NPM_DIST_TAG,
|
||||
expectedWorkflowRef: process.env.EXPECTED_WORKFLOW_REF,
|
||||
expectedSha: process.env.EXPECTED_RELEASE_SHA,
|
||||
expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID,
|
||||
expectedRunAttempt: process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
releaseNotesVersionForTag,
|
||||
renderGithubReleaseNotes,
|
||||
} from "./render-github-release-notes.mjs";
|
||||
import {
|
||||
isShaPinnedReleaseValidationBranch,
|
||||
validateFullReleaseValidationEvidence,
|
||||
} from "./validate-full-release-validation-evidence.mjs";
|
||||
|
||||
const DEFAULT_REPO = "openclaw/openclaw";
|
||||
const DEFAULT_PROVIDER = "openai";
|
||||
@@ -701,7 +705,10 @@ async function runInfo(repo, runId) {
|
||||
]);
|
||||
return {
|
||||
databaseId: runData.id,
|
||||
runAttempt: runData.run_attempt,
|
||||
workflowName: runData.name,
|
||||
workflowPath: runData.path,
|
||||
repository: runData.repository?.full_name,
|
||||
headBranch: runData.head_branch,
|
||||
headSha: runData.head_sha,
|
||||
event: runData.event,
|
||||
@@ -778,7 +785,9 @@ async function waitForSuccessfulRun(repo, runId, expected) {
|
||||
`run ${runId} workflow mismatch: expected ${expected.workflowName}, got ${info.workflowName}`,
|
||||
);
|
||||
}
|
||||
if (info.headBranch !== expected.workflowRef) {
|
||||
const acceptsPinnedWorkflow =
|
||||
expected.allowShaPinnedWorkflowRef && isShaPinnedReleaseValidationBranch(info.headBranch);
|
||||
if (info.headBranch !== expected.workflowRef && !acceptsPinnedWorkflow) {
|
||||
throw new Error(
|
||||
`run ${runId} branch mismatch: expected ${expected.workflowRef}, got ${info.headBranch}`,
|
||||
);
|
||||
@@ -852,6 +861,7 @@ export function buildPublishCommand(options) {
|
||||
["tag", options.tag],
|
||||
["preflight_run_id", options.npmPreflightRunId],
|
||||
["full_release_validation_run_id", options.fullReleaseRunId],
|
||||
["full_release_validation_run_attempt", options.fullReleaseRunAttempt],
|
||||
["npm_dist_tag", options.npmDistTag],
|
||||
["plugin_publish_scope", options.pluginPublishScope],
|
||||
["publish_openclaw_npm", "true"],
|
||||
@@ -1080,15 +1090,14 @@ async function main() {
|
||||
const fullRun = await waitForSuccessfulRun(options.repo, options.fullReleaseRunId, {
|
||||
workflowName: "Full Release Validation",
|
||||
workflowRef: options.workflowRef,
|
||||
allowShaPinnedWorkflowRef: true,
|
||||
});
|
||||
const npmRun = await waitForSuccessfulRun(options.repo, options.npmPreflightRunId, {
|
||||
workflowName: "OpenClaw NPM Release",
|
||||
workflowRef: options.workflowRef,
|
||||
});
|
||||
if (fullRun.headSha !== targetSha || npmRun.headSha !== targetSha) {
|
||||
throw new Error(
|
||||
`run SHA mismatch: tag=${targetSha} full=${fullRun.headSha} npm=${npmRun.headSha}`,
|
||||
);
|
||||
if (npmRun.headSha !== targetSha) {
|
||||
throw new Error(`run SHA mismatch: tag=${targetSha} npm=${npmRun.headSha}`);
|
||||
}
|
||||
|
||||
const npmDir = join(options.outputDir, "npm-preflight");
|
||||
@@ -1100,19 +1109,32 @@ async function main() {
|
||||
"openclaw-npm-preflight-",
|
||||
npmDir,
|
||||
);
|
||||
const fullArtifactName = await downloadResolvedArtifact(
|
||||
options.repo,
|
||||
options.fullReleaseRunId,
|
||||
`full-release-validation-${options.fullReleaseRunId}`,
|
||||
"full-release-validation-",
|
||||
fullDir,
|
||||
);
|
||||
if (!Number.isInteger(fullRun.runAttempt) || fullRun.runAttempt < 1) {
|
||||
throw new Error(`Full Release Validation run ${options.fullReleaseRunId} has invalid attempt.`);
|
||||
}
|
||||
const fullArtifactName = `full-release-validation-${options.fullReleaseRunId}-${fullRun.runAttempt}`;
|
||||
downloadArtifact(options.repo, options.fullReleaseRunId, fullArtifactName, fullDir);
|
||||
|
||||
const npmManifest = readJson(join(npmDir, "preflight-manifest.json"), "npm preflight manifest");
|
||||
const fullManifest = readJson(
|
||||
join(fullDir, "full-release-validation-manifest.json"),
|
||||
"full validation manifest",
|
||||
);
|
||||
run("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"], {
|
||||
capture: true,
|
||||
});
|
||||
const fullValidationEvidence = validateFullReleaseValidationEvidence({
|
||||
run: fullRun,
|
||||
manifest: fullManifest,
|
||||
expectedRepository: options.repo,
|
||||
expectedRunId: options.fullReleaseRunId,
|
||||
expectedTargetSha: targetSha,
|
||||
expectedWorkflowBranch: options.workflowRef,
|
||||
isTrustedMainAncestor: (sha) => gitIsAncestor(sha, "refs/remotes/origin/main"),
|
||||
});
|
||||
if (fullValidationEvidence.source === "direct" && fullRun.headSha !== targetSha) {
|
||||
throw new Error(`run SHA mismatch: tag=${targetSha} full=${fullRun.headSha}`);
|
||||
}
|
||||
validatePreflightManifest(npmManifest, {
|
||||
tag: options.tag,
|
||||
targetSha,
|
||||
@@ -1157,7 +1179,10 @@ async function main() {
|
||||
"scripts/plugin-clawhub-release-plan.ts",
|
||||
options,
|
||||
);
|
||||
const publishCommand = buildPublishCommand(options);
|
||||
const publishCommand = buildPublishCommand({
|
||||
...options,
|
||||
fullReleaseRunAttempt: fullRun.runAttempt,
|
||||
});
|
||||
const evidence = {
|
||||
version: 1,
|
||||
tag: options.tag,
|
||||
@@ -1165,6 +1190,7 @@ async function main() {
|
||||
workflowRef: options.workflowRef,
|
||||
npmDistTag: options.npmDistTag,
|
||||
fullReleaseValidationRunId: options.fullReleaseRunId,
|
||||
fullReleaseValidationRunAttempt: fullRun.runAttempt,
|
||||
npmPreflightRunId: options.npmPreflightRunId,
|
||||
windowsNodeTag: options.windowsNodeTag || undefined,
|
||||
windowsNodeSourceRelease,
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
// Binds Full Release Validation run metadata to its v3 evidence manifest.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const FULL_RELEASE_WORKFLOW = "Full Release Validation";
|
||||
const FULL_RELEASE_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const PINNED_BRANCH_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
|
||||
|
||||
function normalizeWorkflowPathRef(ref) {
|
||||
if (!ref || ref.startsWith("refs/")) {
|
||||
return ref;
|
||||
}
|
||||
return `refs/heads/${ref}`;
|
||||
}
|
||||
|
||||
export function normalizeFullReleaseValidationRun(run) {
|
||||
const [workflowPath, workflowQualifiedRef] = String(run.path ?? run.workflowPath ?? "").split(
|
||||
"@",
|
||||
2,
|
||||
);
|
||||
return {
|
||||
databaseId: String(run.id ?? run.databaseId ?? ""),
|
||||
runAttempt: Number(run.run_attempt ?? run.runAttempt ?? run.attempt),
|
||||
workflowName: run.name ?? run.workflowName,
|
||||
workflowPath,
|
||||
workflowQualifiedRef,
|
||||
repository: run.repository?.full_name ?? run.repository,
|
||||
headBranch: run.head_branch ?? run.headBranch,
|
||||
headSha: run.head_sha ?? run.headSha,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
url: run.html_url ?? run.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function isShaPinnedReleaseValidationBranch(branch) {
|
||||
return PINNED_BRANCH_PATTERN.test(branch ?? "");
|
||||
}
|
||||
|
||||
export function validateFullReleaseValidationEvidence({
|
||||
run: rawRun,
|
||||
manifest,
|
||||
expectedRepository,
|
||||
expectedRunId,
|
||||
expectedTargetSha,
|
||||
expectedWorkflowBranch,
|
||||
isTrustedMainAncestor,
|
||||
}) {
|
||||
const run = normalizeFullReleaseValidationRun(rawRun);
|
||||
const checks = [
|
||||
["databaseId", String(expectedRunId)],
|
||||
["workflowName", FULL_RELEASE_WORKFLOW],
|
||||
["workflowPath", FULL_RELEASE_WORKFLOW_PATH],
|
||||
["repository", expectedRepository],
|
||||
["event", "workflow_dispatch"],
|
||||
["status", "completed"],
|
||||
["conclusion", "success"],
|
||||
];
|
||||
for (const [key, expected] of checks) {
|
||||
if (run[key] !== expected) {
|
||||
throw new Error(
|
||||
`Referenced full release validation run ${expectedRunId} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(run.runAttempt) || run.runAttempt < 1) {
|
||||
throw new Error(`Referenced full release validation run ${expectedRunId} has invalid attempt.`);
|
||||
}
|
||||
if (!SHA_PATTERN.test(run.headSha ?? "")) {
|
||||
throw new Error(
|
||||
`Referenced full release validation run ${expectedRunId} has invalid head SHA.`,
|
||||
);
|
||||
}
|
||||
const expectedQualifiedRef = `refs/heads/${run.headBranch}`;
|
||||
const workflowQualifiedRef = normalizeWorkflowPathRef(run.workflowQualifiedRef);
|
||||
if (workflowQualifiedRef && workflowQualifiedRef !== expectedQualifiedRef) {
|
||||
throw new Error(
|
||||
`Referenced full release validation run ${expectedRunId} has workflow path ref ${run.workflowQualifiedRef}, expected ${expectedQualifiedRef}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.version !== 3) {
|
||||
throw new Error(
|
||||
`Full release validation manifest must use version 3, got ${manifest.version}.`,
|
||||
);
|
||||
}
|
||||
const manifestChecks = [
|
||||
["workflowName", FULL_RELEASE_WORKFLOW],
|
||||
["runId", String(expectedRunId)],
|
||||
["runAttempt", String(run.runAttempt)],
|
||||
["workflowRef", run.headBranch],
|
||||
["workflowSha", run.headSha],
|
||||
["workflowFullRef", expectedQualifiedRef],
|
||||
["workflowRefType", "branch"],
|
||||
["targetSha", expectedTargetSha],
|
||||
];
|
||||
for (const [key, expected] of manifestChecks) {
|
||||
if (String(manifest[key] ?? "") !== expected) {
|
||||
throw new Error(
|
||||
`Full release validation manifest ${key} mismatch: expected ${expected}, got ${manifest[key] ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedMatch = PINNED_BRANCH_PATTERN.exec(run.headBranch ?? "");
|
||||
if (!pinnedMatch) {
|
||||
if (run.headBranch?.startsWith("release-ci/")) {
|
||||
throw new Error(
|
||||
`Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch}.`,
|
||||
);
|
||||
}
|
||||
const directBranches = new Set(["main", expectedWorkflowBranch].filter(Boolean));
|
||||
if (directBranches.has(run.headBranch)) {
|
||||
if (run.headBranch === "main" && !isTrustedMainAncestor?.(run.headSha)) {
|
||||
throw new Error(
|
||||
`Direct main validation workflow ${run.headSha} is not reachable from current main.`,
|
||||
);
|
||||
}
|
||||
return { run, source: "direct" };
|
||||
}
|
||||
throw new Error(
|
||||
`Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
if (pinnedMatch[1] !== run.headSha.slice(0, 12)) {
|
||||
throw new Error(
|
||||
`SHA-pinned validation branch ${run.headBranch} does not match workflow SHA ${run.headSha}.`,
|
||||
);
|
||||
}
|
||||
if (manifest.targetRef !== expectedTargetSha) {
|
||||
throw new Error(
|
||||
`SHA-pinned validation target ref mismatch: expected ${expectedTargetSha}, got ${manifest.targetRef ?? "<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.`,
|
||||
);
|
||||
}
|
||||
return { run, source: "sha-pinned-main" };
|
||||
}
|
||||
|
||||
function gitIsAncestor(ancestor, target) {
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
["merge-base", "--is-ancestor", `${ancestor}^{commit}`, `${target}^{commit}`],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
if (result.status === 0) {
|
||||
return true;
|
||||
}
|
||||
if (result.status === 1) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not validate trusted workflow ancestry: ${result.stderr?.trim() || result.signal || result.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const manifestPath = process.env.MANIFEST_FILE ?? "";
|
||||
if (!manifestPath) {
|
||||
throw new Error("MANIFEST_FILE is required.");
|
||||
}
|
||||
const run = JSON.parse(readFileSync(0, "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
const trustedMainRef = process.env.TRUSTED_MAIN_REF ?? "refs/remotes/origin/main";
|
||||
const result = validateFullReleaseValidationEvidence({
|
||||
run,
|
||||
manifest,
|
||||
expectedRepository: process.env.GITHUB_REPOSITORY,
|
||||
expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID,
|
||||
expectedTargetSha: process.env.EXPECTED_SHA,
|
||||
expectedWorkflowBranch: process.env.EXPECTED_WORKFLOW_BRANCH,
|
||||
isTrustedMainAncestor: (sha) => gitIsAncestor(sha, trustedMainRef),
|
||||
});
|
||||
console.log(
|
||||
`Using full release validation run ${result.run.databaseId} (${result.source}): ${result.run.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function parseArgs(argv) {
|
||||
"tag-dir",
|
||||
"release-json",
|
||||
"full-release-validation-run-id",
|
||||
"full-release-validation-run-attempt",
|
||||
"release-publish-run-id",
|
||||
"rollback-drill-id",
|
||||
"rollback-drill-date",
|
||||
@@ -64,6 +65,7 @@ function main() {
|
||||
releaseTagSha: gitSha(tagDir),
|
||||
mainSha: gitSha(mainDir),
|
||||
fullReleaseValidationRunId: args["full-release-validation-run-id"],
|
||||
fullReleaseValidationRunAttempt: args["full-release-validation-run-attempt"],
|
||||
releasePublishRunId: args["release-publish-run-id"],
|
||||
rollbackDrillId: args["rollback-drill-id"],
|
||||
rollbackDrillDate: args["rollback-drill-date"],
|
||||
|
||||
@@ -9,8 +9,6 @@ describe("full-release-validation-at-sha", () => {
|
||||
"abc123",
|
||||
"--workflow-sha",
|
||||
"origin/main",
|
||||
"--branch",
|
||||
"release/proof",
|
||||
"--keep-branch",
|
||||
"--dry-run",
|
||||
"-f",
|
||||
@@ -19,7 +17,6 @@ describe("full-release-validation-at-sha", () => {
|
||||
"mode=linux",
|
||||
]),
|
||||
).toMatchObject({
|
||||
branch: "release/proof",
|
||||
dryRun: true,
|
||||
keepBranch: true,
|
||||
inputs: {
|
||||
@@ -39,8 +36,6 @@ describe("full-release-validation-at-sha", () => {
|
||||
"--workflow-sha requires a value",
|
||||
);
|
||||
expect(() => parseArgs(["--workflow-sha", "-h"])).toThrow("--workflow-sha requires a value");
|
||||
expect(() => parseArgs(["--branch"])).toThrow("--branch requires a value");
|
||||
expect(() => parseArgs(["--branch", "-h"])).toThrow("--branch requires a value");
|
||||
expect(() => parseArgs(["-f", "--dry-run"])).toThrow("-f requires a value");
|
||||
expect(() => parseArgs(["-f", "-h"])).toThrow("-f requires a value");
|
||||
});
|
||||
@@ -50,4 +45,9 @@ describe("full-release-validation-at-sha", () => {
|
||||
"always disables evidence reuse",
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves the candidate ref for the resolved --sha", () => {
|
||||
expect(() => parseArgs(["-f", "ref=other"])).toThrow("reserves the ref input");
|
||||
expect(() => parseArgs(["--", "ref=other"])).toThrow("reserves the ref input");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +381,13 @@ describe("extended-stable npm run identity", () => {
|
||||
});
|
||||
|
||||
describe("Full Validation manifest identity", () => {
|
||||
const valid = { workflowName: "Full Release Validation", workflowRef: branch, targetSha: sha };
|
||||
const valid = {
|
||||
workflowName: "Full Release Validation",
|
||||
workflowRef: branch,
|
||||
targetSha: sha,
|
||||
runId: "123",
|
||||
runAttempt: "2",
|
||||
};
|
||||
|
||||
it("accepts the exact branch and target SHA", () => {
|
||||
expect(
|
||||
@@ -390,6 +396,8 @@ describe("Full Validation manifest identity", () => {
|
||||
npmDistTag: "extended-stable",
|
||||
expectedWorkflowRef: branch,
|
||||
expectedSha: sha,
|
||||
expectedRunId: "123",
|
||||
expectedRunAttempt: "2",
|
||||
}),
|
||||
).toBe(valid);
|
||||
});
|
||||
@@ -399,6 +407,8 @@ describe("Full Validation manifest identity", () => {
|
||||
["missing workflow ref", { workflowRef: undefined }],
|
||||
["wrong target SHA", { targetSha: "b".repeat(40) }],
|
||||
["missing target SHA", { targetSha: undefined }],
|
||||
["wrong run ID", { runId: "124" }],
|
||||
["wrong run attempt", { runAttempt: "1" }],
|
||||
])("rejects %s", (_label, changes) => {
|
||||
expect(() =>
|
||||
validateFullReleaseValidationManifest({
|
||||
@@ -406,6 +416,8 @@ describe("Full Validation manifest identity", () => {
|
||||
npmDistTag: "extended-stable",
|
||||
expectedWorkflowRef: branch,
|
||||
expectedSha: sha,
|
||||
expectedRunId: "123",
|
||||
expectedRunAttempt: "2",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -170,9 +170,23 @@ describe("minimal npm extended-stable workflow", () => {
|
||||
});
|
||||
|
||||
it("authenticates exact extended-stable run and Full Validation identities", () => {
|
||||
const parsed = workflow();
|
||||
const raw = readFileSync(workflowPath, "utf8");
|
||||
expect(raw).toContain("--json workflowName,headBranch,headSha,event,conclusion,url");
|
||||
expect(raw).toContain("--json workflowName,headBranch,headSha,event,status,conclusion,url");
|
||||
const fullValidationRun = step(
|
||||
parsed.jobs?.publish_openclaw_npm,
|
||||
"Verify full release validation run metadata",
|
||||
);
|
||||
expect(fullValidationRun.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe(
|
||||
"${{ inputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(fullValidationRun.run).toContain(
|
||||
"actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}",
|
||||
);
|
||||
expect(fullValidationRun.run).toContain(
|
||||
'"$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT"',
|
||||
);
|
||||
expect(fullValidationRun.run).toContain('echo "attempt=$run_attempt" >> "$GITHUB_OUTPUT"');
|
||||
expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(3);
|
||||
expect(raw).toContain("openclaw-npm-extended-stable-release.mjs verify-manifest");
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
type WorkflowStep = {
|
||||
"continue-on-error"?: boolean | string;
|
||||
env?: Record<string, string>;
|
||||
id?: string;
|
||||
if?: string;
|
||||
name?: string;
|
||||
run?: string;
|
||||
@@ -79,6 +80,7 @@ type WorkflowJob = {
|
||||
if?: string;
|
||||
name?: string;
|
||||
needs?: string | string[];
|
||||
outputs?: Record<string, string>;
|
||||
permissions?: Record<string, string>;
|
||||
"runs-on"?: string;
|
||||
strategy?: {
|
||||
@@ -272,6 +274,10 @@ function runReleaseChecksSummary(params: {
|
||||
describe("package acceptance workflow", () => {
|
||||
it("verifies immutable postpublish evidence before stable closeout reads it", () => {
|
||||
const workflow = readFileSync(STABLE_MAIN_CLOSEOUT_WORKFLOW, "utf8");
|
||||
const evidenceStep = workflowStep(
|
||||
workflowJob(STABLE_MAIN_CLOSEOUT_WORKFLOW, "verify"),
|
||||
"Verify release workflow evidence",
|
||||
);
|
||||
const checksumIndex = workflow.indexOf(
|
||||
'sha256sum --strict --status -c "$evidence_checksum_asset"',
|
||||
);
|
||||
@@ -351,6 +357,35 @@ describe("package acceptance workflow", () => {
|
||||
'awk -v asset="openclaw-${release_version}-stable-main-closeout.json"',
|
||||
);
|
||||
expect(workflow).toContain("attach_or_verify \\");
|
||||
expect(workflow).toContain(
|
||||
"full_release_validation_run_attempt: ${{ steps.inputs.outputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(workflow).toContain("(.[0].runAttempt == null) or");
|
||||
expect(workflow).toContain(
|
||||
'release_manifest_asset="openclaw-${evidence_version}-release-manifest.json"',
|
||||
);
|
||||
expect(workflow).toContain('sha256sum --strict --status -c "$release_manifest_checksum_asset"');
|
||||
expect(workflow).toContain(
|
||||
'"$existing_closeout_full_release_validation_run_attempt" != "$full_release_validation_run_attempt"',
|
||||
);
|
||||
expect(evidenceStep.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe(
|
||||
"${{ needs.resolve.outputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(evidenceStep.run).toContain(
|
||||
"actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}",
|
||||
);
|
||||
expect(evidenceStep.run).toContain(
|
||||
'String(run.run_attempt ?? "") !== process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT',
|
||||
);
|
||||
expect(evidenceStep.run).toContain(
|
||||
'manifest_asset="openclaw-${evidence_version}-release-manifest.json"',
|
||||
);
|
||||
expect(evidenceStep.run).toContain('gh_with_retry release download "$EVIDENCE_TAG"');
|
||||
expect(evidenceStep.run).toContain(".runId == $run_id");
|
||||
expect(evidenceStep.run).toContain(".runAttempt == $run_attempt");
|
||||
expect(workflow).toContain(
|
||||
'--full-release-validation-run-attempt "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT"',
|
||||
);
|
||||
expect(checksumIndex).toBeGreaterThan(-1);
|
||||
expect(evidenceReadIndex).toBeGreaterThan(checksumIndex);
|
||||
expect(existingCloseoutEvidenceMatchIndex).toBeGreaterThan(evidenceReadIndex);
|
||||
@@ -2673,7 +2708,26 @@ describe("package artifact reuse", () => {
|
||||
it("keeps release publish creation compatible with gh api and prerelease notes", () => {
|
||||
const workflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
|
||||
const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
|
||||
const maintainerSkill = readFileSync(
|
||||
".agents/skills/release-openclaw-maintainer/SKILL.md",
|
||||
"utf8",
|
||||
);
|
||||
const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
|
||||
const resolveJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target");
|
||||
const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish");
|
||||
const resolveFullRun = workflowStep(resolveJob, "Resolve full release validation run");
|
||||
const resolveDownload = workflowStep(resolveJob, "Download full release validation manifest");
|
||||
const trustedTooling = workflowStep(resolveJob, "Download trusted release validation tooling");
|
||||
const validateManifest = workflowStep(resolveJob, "Validate full release validation manifest");
|
||||
const publishDownload = workflowStep(publishJob, "Download full release validation manifest");
|
||||
const publishOrchestration = workflowStep(publishJob, "Dispatch publish workflows");
|
||||
const npmPublishJob = workflowJob(
|
||||
".github/workflows/openclaw-npm-release.yml",
|
||||
"publish_openclaw_npm",
|
||||
);
|
||||
const npmFullRun = workflowStep(npmPublishJob, "Verify full release validation run metadata");
|
||||
const npmDownload = workflowStep(npmPublishJob, "Download full release validation manifest");
|
||||
const npmTarget = workflowStep(npmPublishJob, "Verify full release validation target");
|
||||
|
||||
expect(workflow).toContain("timeout-minutes: 120");
|
||||
expect(workflow).toContain("environment: npm-release");
|
||||
@@ -2681,7 +2735,65 @@ describe("package artifact reuse", () => {
|
||||
expect(workflow).toContain("Validate OpenClaw npm preflight manifest");
|
||||
expect(workflow).toContain("Download full release validation manifest");
|
||||
expect(workflow).toContain("Validate full release validation manifest");
|
||||
expect(workflow).toContain("scripts/validate-full-release-validation-evidence.mjs");
|
||||
expect(workflow).toContain("+refs/heads/main:refs/remotes/origin/main");
|
||||
expect(workflow).toContain("full_release_validation_run_attempt");
|
||||
expect(workflow).toContain("full_release_validation_run_id");
|
||||
expect(resolveFullRun.id).toBe("full_run");
|
||||
expect(resolveFullRun.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe(
|
||||
"${{ inputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(resolveFullRun.run).toContain(
|
||||
'run_endpoint+="/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}"',
|
||||
);
|
||||
expect(resolveFullRun.run).toContain('gh api "$run_endpoint"');
|
||||
expect(resolveJob.outputs?.full_release_validation_run_attempt).toBe(
|
||||
"${{ steps.full_run.outputs.attempt }}",
|
||||
);
|
||||
expect(resolveDownload.with?.name).toBe(
|
||||
"full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }}",
|
||||
);
|
||||
expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}");
|
||||
expect(trustedTooling.run).toContain("validate-full-release-validation-evidence.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",
|
||||
});
|
||||
expect(validateManifest.run).toContain(
|
||||
'MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"',
|
||||
);
|
||||
expect(publishDownload.with?.name).toBe(
|
||||
"full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(publishOrchestration.run).not.toContain("--full-release-validation-workflow-ref");
|
||||
expect(publishOrchestration.run).not.toContain("--full-release-validation-run");
|
||||
expect(publishOrchestration.run).toContain('pnpm "${verify_args[@]}"');
|
||||
expect(publishOrchestration.run).toContain(".workflowRuns += [");
|
||||
expect(publishOrchestration.run).toContain('label: "Full Release Validation"');
|
||||
expect(publishOrchestration.run).toContain('"${validation_target_sha}" != "${TARGET_SHA}"');
|
||||
expect(publishOrchestration.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe(
|
||||
"${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(publishOrchestration.run).toContain(
|
||||
'-f full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}"',
|
||||
);
|
||||
expect(npmFullRun.id).toBe("full_run");
|
||||
expect(npmFullRun.env?.FULL_RELEASE_VALIDATION_RUN_ATTEMPT).toBe(
|
||||
"${{ inputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
expect(npmFullRun.run).toContain(
|
||||
"actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}",
|
||||
);
|
||||
expect(npmFullRun.run).toContain('"$run_attempt" != "$FULL_RELEASE_VALIDATION_RUN_ATTEMPT"');
|
||||
expect(npmDownload.with?.name).toBe(
|
||||
"full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ steps.full_run.outputs.attempt }}",
|
||||
);
|
||||
expect(npmTarget.env).toMatchObject({
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: "${{ inputs.full_release_validation_run_id }}",
|
||||
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: "${{ steps.full_run.outputs.attempt }}",
|
||||
});
|
||||
expect(workflow).toContain(
|
||||
"Full release validation must run rerun_group=all before npm publish",
|
||||
);
|
||||
@@ -2708,6 +2820,7 @@ describe("package artifact reuse", () => {
|
||||
expect(npmWorkflow).toContain("full_release_validation_run_id");
|
||||
expect(npmWorkflow).toContain("release_publish_run_id");
|
||||
expect(npmWorkflow).toContain("Real publish requires full_release_validation_run_id");
|
||||
expect(maintainerSkill).toContain("full_release_validation_run_attempt=<saved-attempt>");
|
||||
expect(npmWorkflow).toContain(
|
||||
"Workflow-dispatched real publish requires release_publish_run_id",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Release Candidate Checklist tests cover release candidate checklist script behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
buildPublishCommand,
|
||||
candidateCumulativeShippedPullRequests,
|
||||
@@ -507,6 +508,19 @@ describe("release candidate checklist", () => {
|
||||
).toThrow("blocking product performance");
|
||||
});
|
||||
|
||||
it("binds SHA-pinned full validation evidence through its manifest", () => {
|
||||
const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8");
|
||||
|
||||
expect(source).toContain("allowShaPinnedWorkflowRef: true");
|
||||
expect(source).toContain(
|
||||
"const fullValidationEvidence = validateFullReleaseValidationEvidence({",
|
||||
);
|
||||
expect(source).toContain("refs/heads/main:refs/remotes/origin/main");
|
||||
expect(source).toContain(
|
||||
'fullValidationEvidence.source === "direct" && fullRun.headSha !== targetSha',
|
||||
);
|
||||
});
|
||||
|
||||
it("stops parsing options after the argument terminator", () => {
|
||||
const options = parseArgs([
|
||||
"--tag",
|
||||
@@ -555,13 +569,26 @@ describe("release candidate checklist", () => {
|
||||
"--skip-dispatch",
|
||||
]),
|
||||
workflowRef: "release/2026.5.14",
|
||||
fullReleaseRunAttempt: 2,
|
||||
};
|
||||
|
||||
expect(buildPublishCommand(options)).toContain("'full_release_validation_run_id=111'");
|
||||
expect(buildPublishCommand(options)).toContain("'preflight_run_id=222'");
|
||||
expect(buildPublishCommand(options)).toContain("'tag=v2026.5.14-beta.3'");
|
||||
expect(buildPublishCommand(options)).toContain("'plugin_publish_scope=all-publishable'");
|
||||
expect(buildPublishCommand(options)).not.toContain("windows_node_tag=");
|
||||
const command = buildPublishCommand(options);
|
||||
expect(command).toContain("'full_release_validation_run_id=111'");
|
||||
expect(command).toContain("'full_release_validation_run_attempt=2'");
|
||||
expect(command).toContain("'preflight_run_id=222'");
|
||||
expect(command).toContain("'tag=v2026.5.14-beta.3'");
|
||||
expect(command).toContain("'plugin_publish_scope=all-publishable'");
|
||||
expect(command).not.toContain("windows_node_tag=");
|
||||
|
||||
const workflow = parse(
|
||||
readFileSync(".github/workflows/openclaw-release-publish.yml", "utf8"),
|
||||
) as {
|
||||
on: { workflow_dispatch: { inputs: Record<string, unknown> } };
|
||||
};
|
||||
const emittedInputs = [...command.matchAll(/'-f' '([^=']+)=/gu)].map((match) => match[1]);
|
||||
for (const input of emittedInputs) {
|
||||
expect(workflow.on.workflow_dispatch.inputs).toHaveProperty(input);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires and carries an exact Windows Node tag for stable release candidates", () => {
|
||||
|
||||
@@ -686,6 +686,115 @@ describe("release CI summary child correlation", () => {
|
||||
).toThrow("producer must run from trusted workflow ref: main");
|
||||
});
|
||||
|
||||
it("accepts canonical SHA-pinned v3 evidence on the trusted main lineage", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
targetSha: "8".repeat(40),
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
|
||||
expect(
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: fixture.runId,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
fixture.client,
|
||||
).root,
|
||||
).toMatchObject({
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowRefProof: "manifest-v3-sha-pinned-main-ancestry",
|
||||
workflowSha,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["main", "refs/heads/main"])(
|
||||
"accepts a REST workflow path qualified with %s",
|
||||
(qualifiedRef) => {
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
parentPath: `.github/workflows/full-release-validation.yml@${qualifiedRef}`,
|
||||
workflowSha: "7".repeat(40),
|
||||
});
|
||||
|
||||
expect(
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: fixture.runId,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
fixture.client,
|
||||
).root,
|
||||
).toMatchObject({ workflowFullRef: "refs/heads/main" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects SHA-pinned evidence reuse", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
fixture.manifest.evidenceReuse = {
|
||||
changedPaths: [],
|
||||
evidenceSha: fixture.targetSha,
|
||||
policy: "exact-target-full-validation-v1",
|
||||
runId: fixture.runId,
|
||||
selectedRunId: fixture.runId,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: fixture.runId,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
fixture.client,
|
||||
),
|
||||
).toThrow("must not reuse another validation run");
|
||||
});
|
||||
|
||||
it("rejects a SHA-pinned evidenceReuse field even when false", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
fixture.manifest.evidenceReuse = false;
|
||||
|
||||
expect(() =>
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: fixture.runId,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
fixture.client,
|
||||
),
|
||||
).toThrow("evidence reuse is invalid");
|
||||
});
|
||||
|
||||
it("rejects dirty verifier bytes and a forged verifier source SHA", () => {
|
||||
const fixture = trustedMainPackageFixture();
|
||||
expect(() =>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Full release validation evidence tests cover producer and candidate binding.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isShaPinnedReleaseValidationBranch,
|
||||
normalizeFullReleaseValidationRun,
|
||||
validateFullReleaseValidationEvidence,
|
||||
} from "../../scripts/validate-full-release-validation-evidence.mjs";
|
||||
|
||||
const targetSha = "b".repeat(40);
|
||||
const workflowSha = "a".repeat(40);
|
||||
const pinnedBranch = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
|
||||
function releaseRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 123,
|
||||
run_attempt: 2,
|
||||
name: "Full Release Validation",
|
||||
path: ".github/workflows/full-release-validation.yml",
|
||||
repository: { full_name: "openclaw/openclaw" },
|
||||
head_branch: pinnedBranch,
|
||||
head_sha: workflowSha,
|
||||
event: "workflow_dispatch",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/123",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function releaseManifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
version: 3,
|
||||
workflowName: "Full Release Validation",
|
||||
runId: "123",
|
||||
runAttempt: "2",
|
||||
workflowRef: pinnedBranch,
|
||||
workflowSha,
|
||||
workflowFullRef: `refs/heads/${pinnedBranch}`,
|
||||
workflowRefType: "branch",
|
||||
targetRef: targetSha,
|
||||
targetSha,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function validate(
|
||||
runOverrides: Record<string, unknown> = {},
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
trusted = true,
|
||||
) {
|
||||
const isTrustedMainAncestor = vi.fn(() => trusted);
|
||||
const result = validateFullReleaseValidationEvidence({
|
||||
run: releaseRun(runOverrides),
|
||||
manifest: releaseManifest(manifestOverrides),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedWorkflowBranch: "release/2026.7.1",
|
||||
isTrustedMainAncestor,
|
||||
});
|
||||
return { isTrustedMainAncestor, result };
|
||||
}
|
||||
|
||||
describe("full release validation evidence", () => {
|
||||
it("normalizes REST and gh run metadata", () => {
|
||||
const normalized = normalizeFullReleaseValidationRun(releaseRun());
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
databaseId: "123",
|
||||
runAttempt: 2,
|
||||
workflowName: "Full Release Validation",
|
||||
workflowPath: ".github/workflows/full-release-validation.yml",
|
||||
repository: "openclaw/openclaw",
|
||||
headBranch: pinnedBranch,
|
||||
headSha: workflowSha,
|
||||
});
|
||||
expect(
|
||||
normalizeFullReleaseValidationRun({
|
||||
databaseId: 123,
|
||||
attempt: 2,
|
||||
workflowName: "Full Release Validation",
|
||||
workflowPath: ".github/workflows/full-release-validation.yml@refs/heads/main",
|
||||
}),
|
||||
).toMatchObject({
|
||||
databaseId: "123",
|
||||
runAttempt: 2,
|
||||
workflowPath: ".github/workflows/full-release-validation.yml",
|
||||
workflowQualifiedRef: "refs/heads/main",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts canonical SHA-pinned evidence bound to current main", () => {
|
||||
const { isTrustedMainAncestor, result } = validate();
|
||||
|
||||
expect(result.source).toBe("sha-pinned-main");
|
||||
expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha);
|
||||
expect(isShaPinnedReleaseValidationBranch(pinnedBranch)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([pinnedBranch, `refs/heads/${pinnedBranch}`])(
|
||||
"accepts a REST workflow path qualified with %s",
|
||||
(qualifiedRef) => {
|
||||
const { result } = validate({
|
||||
path: `.github/workflows/full-release-validation.yml@${qualifiedRef}`,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("sha-pinned-main");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["main", "release/2026.7.1"])("keeps direct %s evidence valid", (branch) => {
|
||||
const { isTrustedMainAncestor, result } = validate(
|
||||
{ head_branch: branch },
|
||||
{
|
||||
workflowRef: branch,
|
||||
workflowFullRef: `refs/heads/${branch}`,
|
||||
targetRef: "v2026.7.1-beta.3",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.source).toBe("direct");
|
||||
if (branch === "main") {
|
||||
expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha);
|
||||
} else {
|
||||
expect(isTrustedMainAncestor).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects direct main evidence outside current main", () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
{ head_branch: "main" },
|
||||
{
|
||||
workflowRef: "main",
|
||||
workflowFullRef: "refs/heads/main",
|
||||
targetRef: "v2026.7.1-beta.3",
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toThrow("not reachable from current main");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["repository", { repository: { full_name: "attacker/openclaw" } }, {}, "repository"],
|
||||
["workflow path", { path: ".github/workflows/other.yml" }, {}, "workflowPath"],
|
||||
[
|
||||
"qualified workflow ref",
|
||||
{ path: ".github/workflows/full-release-validation.yml@refs/heads/other" },
|
||||
{},
|
||||
"workflow path ref",
|
||||
],
|
||||
["run id", { id: 124 }, {}, "databaseId"],
|
||||
["manifest run id", {}, { runId: "124" }, "runId"],
|
||||
["run attempt", {}, { runAttempt: "1" }, "runAttempt"],
|
||||
["workflow ref", {}, { workflowRef: "main" }, "workflowRef"],
|
||||
["workflow SHA", {}, { workflowSha: "c".repeat(40) }, "workflowSha"],
|
||||
["workflow full ref", {}, { workflowFullRef: "refs/heads/main" }, "workflowFullRef"],
|
||||
["target SHA", {}, { targetSha: "c".repeat(40) }, "targetSha"],
|
||||
["target ref", {}, { targetRef: "v2026.7.1-beta.3" }, "target ref"],
|
||||
["manifest version", {}, { version: 2 }, "version 3"],
|
||||
])("rejects mismatched %s", (_name, runOverrides, manifestOverrides, message) => {
|
||||
expect(() => validate(runOverrides, manifestOverrides)).toThrow(message);
|
||||
});
|
||||
|
||||
it("rejects a forged SHA-pinned branch prefix", () => {
|
||||
const branch = `release-ci/${"c".repeat(12)}-1783705000000`;
|
||||
expect(() =>
|
||||
validate(
|
||||
{ head_branch: branch },
|
||||
{ workflowRef: branch, workflowFullRef: `refs/heads/${branch}` },
|
||||
),
|
||||
).toThrow("does not match workflow SHA");
|
||||
});
|
||||
|
||||
it("rejects SHA-pinned workflow commits outside current main", () => {
|
||||
expect(() => validate({}, {}, false)).toThrow("not reachable from current main");
|
||||
});
|
||||
|
||||
it("rejects evidence reuse on the SHA-pinned path", () => {
|
||||
expect(() => validate({}, { evidenceReuse: { runId: "122" } })).toThrow(
|
||||
"must not reuse another validation run",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a pinned-shaped expected branch on the pinned trust path", () => {
|
||||
expect(() =>
|
||||
validateFullReleaseValidationEvidence({
|
||||
run: releaseRun(),
|
||||
manifest: releaseManifest({ evidenceReuse: { runId: "122" } }),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedWorkflowBranch: pinnedBranch,
|
||||
isTrustedMainAncestor: () => false,
|
||||
}),
|
||||
).toThrow("must not reuse another validation run");
|
||||
});
|
||||
|
||||
it("does not treat a malformed release-ci expected branch as direct", () => {
|
||||
const branch = "release-ci/not-canonical";
|
||||
expect(() =>
|
||||
validateFullReleaseValidationEvidence({
|
||||
run: releaseRun({ head_branch: branch }),
|
||||
manifest: releaseManifest({
|
||||
workflowRef: branch,
|
||||
workflowFullRef: `refs/heads/${branch}`,
|
||||
}),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedWorkflowBranch: branch,
|
||||
isTrustedMainAncestor: () => true,
|
||||
}),
|
||||
).toThrow("untrusted head branch");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const validCloseoutParams = {
|
||||
releaseTagSha: "tag-sha",
|
||||
mainSha: "main-sha",
|
||||
fullReleaseValidationRunId: "11",
|
||||
fullReleaseValidationRunAttempt: "2",
|
||||
releasePublishRunId: "12",
|
||||
rollbackDrillId: "rollback-drill-2026-q2",
|
||||
rollbackDrillDate: "2026-06-01",
|
||||
@@ -38,9 +39,7 @@ describe("stable release closeout", () => {
|
||||
it("parses stable and correction tags", () => {
|
||||
expect(parseStableReleaseTag("v2026.6.8")).toBe("2026.6.8");
|
||||
expect(parseStableReleaseTag("v2026.6.8-2")).toBe("2026.6.8");
|
||||
expect(() => parseStableReleaseTag("v2026.6.8-0")).toThrow(
|
||||
"expected a stable release tag",
|
||||
);
|
||||
expect(() => parseStableReleaseTag("v2026.6.8-0")).toThrow("expected a stable release tag");
|
||||
expect(() => parseStableReleaseTag("v2026.6.8-beta.1")).toThrow(
|
||||
"expected a stable release tag",
|
||||
);
|
||||
@@ -60,13 +59,26 @@ describe("stable release closeout", () => {
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.manifest).toMatchObject({
|
||||
version: 2,
|
||||
releaseTag: "v2026.6.8",
|
||||
releaseVersion: "2026.6.8",
|
||||
fullReleaseValidationRunAttempt: "2",
|
||||
rollbackDrill: { id: "rollback-drill-2026-q2", date: "2026-06-01" },
|
||||
});
|
||||
expect(result.manifest).not.toHaveProperty("verifiedAt");
|
||||
});
|
||||
|
||||
it("requires an exact Full Release Validation run attempt", () => {
|
||||
const result = verifyStableMainCloseout({
|
||||
...validCloseoutParams,
|
||||
fullReleaseValidationRunAttempt: "",
|
||||
nowMs: Date.parse("2026-06-17T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.errors).toContain("full release validation run attempt is invalid: <missing>.");
|
||||
expect(result.manifest).toBeNull();
|
||||
});
|
||||
|
||||
it("writes identical closeout evidence when replayed", () => {
|
||||
const first = verifyStableMainCloseout({
|
||||
...validCloseoutParams,
|
||||
|
||||
Reference in New Issue
Block a user