fix(release): keep frozen validation independent of main (#126622)

* fix(release): freeze validation tooling identity

* fix(release): enforce frozen validation contract

* fix(release): validate candidate identity in parent

* fix(ci): close release isolation gate findings
This commit is contained in:
Vincent Koc 2026-08-20 04:32:38 -07:00 committed by GitHub
parent a59abcf4a8
commit c28c279afa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 851 additions and 156 deletions

View file

@ -310,9 +310,11 @@ package with `run_release_soak=true` or explicit focused groups.
Stable-publish uses `release_profile=stable`.
```bash
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
node scripts/full-release-validation-at-sha.mjs \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
That helper is for regular releases. Extended-stable dispatches Full Release
@ -321,11 +323,15 @@ Validation directly from and against `extended-stable/YYYY.M.33` with
replaced by a `release-ci/*` run. Use `$release-openclaw-ci` for its failure
classification and run-identity rules.
The helper pins the Tooling SHA on trusted `main`, passes the resolved Code SHA
as `expected_sha`, and records the canonical release branch as context. It
infers `beta` for alpha/beta package versions and `stable` for
stable/correction versions. Pass `-f release_profile=full` only for the broad
advisory provider/media sweep. Do not make `full` faster by silently dropping
The helper verifies and pins the recorded Tooling SHA on trusted `main`, passes
the resolved Code SHA as `expected_sha`, and records the canonical release
branch as context. Reuse that SHA for the release; never refresh it from moving
`main`. Regular release branches accept only their final package version or a
matching beta prerelease. Tideclaw alpha validation uses its matching alpha
branch and exact alpha tag. The helper infers `beta` for beta candidates and
exact alpha tags, and `stable` for stable/correction versions. Pass
`-f release_profile=full` only for the broad advisory provider/media sweep. Do
not make `full` faster by silently dropping
suites; use the bounded phase that matches the release decision.
Standalone manual `CI` dispatches do not run the plugin prerelease suite, the

View file

@ -16,7 +16,7 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
- Hold the release scope once a release branch or Code SHA exists. Validate and
ship that exact release; do not turn moving `main` into a second work queue.
- Record every active validation run as the immutable tuple **Validation SHA +
Tooling SHA**. Validation SHA maps to the Code SHA for product validation or
Tooling SHA + rerun group**. Validation SHA maps to the Code SHA for product validation or
the Release SHA for changelog-only validation; it is not a third release
identity. A branch or temporary ref is context and transport.
- Apply a release firebreak after the Code SHA is frozen. Admit only confirmed
@ -161,24 +161,35 @@ Prefer an immutable trusted-main workflow revision, target the exact Code SHA:
satisfy a newer `main`-only check.
```bash
TOOLING_SHA="<exact-main-ancestor-sha>"
node scripts/full-release-validation-at-sha.mjs \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
For regular `release/*` validation, never raw-dispatch the workflow without
`target_context_ref` (the helper's `--target-ref` records it); the
extended-stable `.33+` canonical-branch dispatch below is the one exception —
there the SHA-pinned helper's `release-ci/*` identity is rejected, so it
dispatches without `target_context_ref` by design. Trusted-workflow
release-branch CI passes `target_ref` + `release_candidate_ref`; never
`release_gate` there — it requires workflow head == target. (The PR-head
ci.yml fallback below is a different dispatch and does use
`release_gate=true`.)
`target_context_ref` (the helper's `--target-ref` records it). Canonical
`release/*` and `extended-stable/*` workflow refs remain supported routes, but
their Telegram child must retain the exact parent workflow ref and SHA through
OIDC and attestation. Trusted-workflow release-branch CI passes `target_ref` +
`release_candidate_ref`; never `release_gate` there — it requires workflow head
== target. (The PR-head ci.yml fallback below is a different dispatch and does
use `release_gate=true`.)
The release branch may advance after the Code SHA is frozen. The helper accepts
that frozen SHA only while it remains an ancestor of the canonical release
branch and its package version is either the branch's final version or a
matching beta prerelease. Alpha remains on the Tideclaw path with a matching
alpha branch and exact alpha tag. Extended-stable branches and all tags require
an exact package-version match.
Always pass the previously recorded full Tooling SHA for release-branch runs.
Never replace it with a fresh `main` lookup. The Tooling SHA must declare the
current release-isolation contract; older workflow revisions fail closed.
For immutable workflow proof on a moving `main`, use
`pnpm ci:full-release --sha <code-sha> --target-ref
release/YYYY.M.PATCH`. Its canonical `release-ci/*` ref keeps evidence reuse
release/YYYY.M.PATCH --workflow-sha <tooling-sha>`. Its canonical `release-ci/*` ref keeps evidence reuse
enabled after proving the workflow commit is still on trusted `main` lineage.
Pass `-f reuse_evidence=false` only when the operator intentionally needs a
fresh full run.
@ -190,20 +201,22 @@ against the Release SHA. The parent must report
dispatching child lanes. Npm preflight and package/install acceptance still run
against the exact Release SHA and its new tarball bytes.
The SHA-pinned helper infers `beta` for alpha/beta package versions and `stable`
for stable/correction versions and passes the Validation SHA + Tooling SHA run
identity. `beta` without soak is the bounded beta-publish gate. Run broad live
QA and E2E as postpublish confidence with `run_release_soak=true` or explicit
groups. Stable and full profiles force the release soak. Use a narrow
`rerun_group` after focused fixes; never widen automatically.
The SHA-pinned helper infers `beta` for matching beta release candidates and
exact alpha tags, and `stable` for stable/correction versions, then passes the
Validation SHA + Tooling SHA run identity. `beta` without soak is the bounded
beta-publish gate. Run broad live QA and E2E as postpublish confidence with
`run_release_soak=true` or explicit groups. Stable and full profiles force the
release soak. Use a narrow `rerun_group` after focused fixes; never widen
automatically.
Publish with `openclaw-release-publish.yml` using `release_profile=from-validation`
unless a maintainer intentionally wants to cross-check a specific profile; the
publish workflow reads the effective profile from the full-validation manifest.
### Extended-stable validation
For `.33+`, dispatch from and target the canonical branch; the regular
SHA-pinned helper would produce a rejected `release-ci/*` identity:
For `.33+`, dispatch from and target the canonical branch. This direct route is
intentional: downstream extended-stable evidence requires the canonical branch
identity, while Telegram still authenticates the exact branch SHA:
```bash
RELEASE_SHA="$(git rev-parse HEAD)"

View file

@ -28,6 +28,10 @@
- Preserve successful exact-tuple evidence when the documented finalization
rules allow reuse. Narrow evidence does not become publish authorization by
itself, and there is no standalone rerunnable finalizer today.
- Once a release branch run records its Validation SHA, Tooling SHA, and rerun
group, later `main` or release-branch movement does not replace any tuple
member. The frozen candidate may remain behind the release branch only while
it is still an ancestor; release tags remain exact.
- Leave bad secrets unset. A 401 candidate from 1Password should not overwrite GitHub.
- Make the final release evidence note durable: parent URL, child run URLs, SHA, command proof, and gaps.

View file

@ -1028,7 +1028,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`false` for beta. Let it run in parallel with Code SHA validation.
8. Run the deterministic source preflight, then Full Release Validation against
the exact Code SHA with
`node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH`.
`node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH --workflow-sha <tooling-sha>`.
Reuse the recorded full Tooling SHA for every later release validation; do
not refresh it from moving `main`.
For beta-publish, keep `release_profile=beta` and
`run_release_soak=false`. Record the Validation SHA + Tooling SHA tuple
(Validation SHA is the Code SHA in this phase) and use one transition

View file

@ -162,6 +162,7 @@ env:
# Read retries and one-shot dispatch recovery share this classifier; dispatch POSTs never retry.
GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN: "HTTP 5[0-9][0-9]|Server Error|invalid character .* looking for beginning of value|error connecting to|context deadline exceeded|connection reset by peer|connection refused|TLS handshake timeout|i/o timeout|network is unreachable|(^|[^A-Za-z0-9_])EOF([^A-Za-z0-9_]|$)|ETIMEDOUT|ECONNRESET|EAI_AGAIN"
NODE_VERSION: "24.16.0"
RELEASE_ISOLATION_TOOLING_CONTRACT: "1"
jobs:
resolve_target:
@ -192,10 +193,25 @@ jobs:
--expected-sha "$EXPECTED_SHA" \
--github-output "$GITHUB_OUTPUT"
- name: Checkout target package manifest
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ steps.resolve.outputs.sha }}
path: target
sparse-checkout: package.json
sparse-checkout-cone-mode: false
fetch-depth: 1
persist-credentials: false
submodules: false
- name: Validate release inputs
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
SKIP_PACKAGE_TELEGRAM_E2E: ${{ inputs.skip_package_telegram_e2e }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ steps.resolve.outputs.sha }}
run: |
set -euo pipefail
if [[ "$SKIP_PACKAGE_TELEGRAM_E2E" == "true" && "$RELEASE_PROFILE" != "beta" ]]; then
@ -203,6 +219,67 @@ jobs:
exit 1
fi
context_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}"
context_ref="${context_ref#refs/heads/}"
context_ref="${context_ref#refs/tags/}"
target_version="$(jq -er '.version | select(type == "string")' target/package.json)"
release_version_pattern=""
expected_version=""
identity_kind=""
if [[ "$context_ref" =~ ^release/([0-9]{4}\.([1-9]|1[0-2])\.[1-9][0-9]*)$ ]]; then
expected_version="${BASH_REMATCH[1]}"
release_version_pattern="${expected_version//./\\.}"
identity_kind="release branch"
if [[ "$target_version" != "$expected_version" &&
! "$target_version" =~ ^${release_version_pattern}-beta\.[1-9][0-9]*$ ]]; then
echo "Target package version ${target_version} does not belong to release branch ${context_ref}; expected ${expected_version} or a beta prerelease of it." >&2
exit 1
fi
elif [[ "$context_ref" =~ ^extended-stable/([0-9]{4}\.([1-9]|1[0-2])\.33)$ ]]; then
expected_version="${BASH_REMATCH[1]}"
identity_kind="extended-stable branch"
elif [[ "$context_ref" =~ ^v([0-9]{4}\.([1-9]|1[0-2])\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then
expected_version="${BASH_REMATCH[1]}"
identity_kind="release tag"
elif [[ -n "$TARGET_CONTEXT_REF" ]]; then
echo "target_context_ref must be a canonical OpenClaw release branch or tag." >&2
exit 1
fi
if [[ -n "$expected_version" &&
"$identity_kind" != "release branch" &&
"$target_version" != "$expected_version" ]]; then
echo "Target package version ${target_version} does not match ${identity_kind} ${context_ref}; expected ${expected_version}." >&2
exit 1
fi
if [[ -n "$TARGET_CONTEXT_REF" ]]; then
if [[ ! "$TARGET_REF" =~ ^[a-f0-9]{40}$ || "$TARGET_REF" != "$TARGET_SHA" ]]; then
echo "target_context_ref requires ref to be the resolved full Validation SHA." >&2
exit 1
fi
repository_url="https://github.com/${GITHUB_REPOSITORY}.git"
if [[ "$identity_kind" == "release tag" ]]; then
remote_sha="$(git ls-remote --tags "$repository_url" "refs/tags/${context_ref}^{}" | awk 'NR == 1 { print $1 }')"
if [[ -z "$remote_sha" ]]; then
remote_sha="$(git ls-remote --tags "$repository_url" "refs/tags/${context_ref}" | awk 'NR == 1 { print $1 }')"
fi
if [[ "$remote_sha" != "$TARGET_SHA" ]]; then
echo "Target SHA ${TARGET_SHA} does not match release tag ${context_ref} at ${remote_sha:-missing}." >&2
exit 1
fi
else
remote_sha="$(git ls-remote --heads "$repository_url" "refs/heads/${context_ref}" | awk 'NR == 1 { print $1 }')"
if [[ -z "$remote_sha" ]]; then
echo "Release context branch ${context_ref} does not resolve." >&2
exit 1
fi
comparison_status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_SHA}...${remote_sha}" --jq .status)"
if [[ "$comparison_status" != "ahead" && "$comparison_status" != "identical" ]]; then
echo "Target SHA ${TARGET_SHA} is not reachable from release context branch ${context_ref} at ${remote_sha}." >&2
exit 1
fi
fi
fi
- name: Summarize target
env:
TARGET_REF: ${{ inputs.ref }}
@ -230,6 +307,7 @@ jobs:
echo "- Target ref: \`${TARGET_REF}\`"
echo "- Validation SHA: \`${TARGET_SHA}\`"
echo "- Tooling SHA: \`${TOOLING_SHA}\`"
echo "- Frozen tuple: \`${TARGET_SHA} / ${TOOLING_SHA} / ${RERUN_GROUP}\`"
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
echo "- Fail fast: \`${FAIL_FAST}\`"
echo "- Package Acceptance Telegram E2E deferred: \`${SKIP_PACKAGE_TELEGRAM_E2E}\`"
@ -633,7 +711,7 @@ jobs:
local workflow="$1"
local dispatch_run_name="$2"
shift 2
local dispatch_output dispatch_status dispatch_run_ids matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha expected_workflow_id
local dispatch_output dispatch_status dispatch_run_ids matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha expected_workflow_id started_epoch elapsed_seconds elapsed_minutes
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
@ -722,6 +800,7 @@ jobs:
fi
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
started_epoch="$(date +%s)"
fail_fast_failed_jobs() {
if [[ "$FAIL_FAST" != "true" ]]; then
@ -792,9 +871,9 @@ jobs:
poll_count=$((poll_count + 1))
if (( poll_count % 5 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
elapsed_seconds=$(( $(date +%s) - started_epoch ))
elapsed_minutes=$(( elapsed_seconds / 60 ))
echo "Still waiting on ${workflow} after ${elapsed_minutes}m: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: (.url // .html_url)}' || true
fi
sleep 60

View file

@ -127,7 +127,7 @@ on:
type: string
concurrency:
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
env:
@ -2081,6 +2081,8 @@ jobs:
- name: Dispatch and await trusted Telegram QA
env:
GH_TOKEN: ${{ github.token }}
PARENT_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.revision }}
shell: bash
@ -2094,7 +2096,7 @@ jobs:
RUN_NAME="$run_name" gh api -X GET \
"repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F branch=main \
-F branch="$PARENT_WORKFLOW_REF" \
-F per_page=100 \
--jq '[.workflow_runs[] | select(.display_title == env.RUN_NAME) | {id, head_sha}]'
}
@ -2127,51 +2129,40 @@ jobs:
trap 'exit 130' INT
trap 'exit 143' TERM
for dispatch_attempt in 1 2 3 4 5; do
expected_trusted_workflow_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha
)"
dispatch_id="release-checks-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${dispatch_attempt}-$(openssl rand -hex 16)"
run_name="OpenClaw Release Telegram QA ${dispatch_id}"
run_id=""
child_head_sha=""
dispatch_id="release-checks-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-$(openssl rand -hex 16)"
run_name="OpenClaw Release Telegram QA ${dispatch_id}"
child_head_sha=""
gh workflow run "$workflow" \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f dispatch_id="$dispatch_id" \
-f expected_trusted_workflow_sha="$expected_trusted_workflow_sha" \
-f target_context_ref="$TARGET_CONTEXT_REF" \
-f target_ref="$TARGET_SHA" \
-f target_sha="$TARGET_SHA"
gh workflow run "$workflow" \
--repo "$GITHUB_REPOSITORY" \
--ref "$PARENT_WORKFLOW_REF" \
-f dispatch_id="$dispatch_id" \
-f expected_trusted_workflow_sha="$PARENT_WORKFLOW_SHA" \
-f target_context_ref="$TARGET_CONTEXT_REF" \
-f target_ref="$TARGET_SHA" \
-f target_sha="$TARGET_SHA"
for _ in $(seq 1 60); do
matches_json="$(find_child_runs)"
match_count="$(jq 'length' <<<"$matches_json")"
if ((match_count > 1)); then
echo "Multiple Telegram QA runs matched ${run_name}; refusing to guess." >&2
exit 1
fi
if ((match_count == 1)); then
run_id="$(jq -r '.[0].id' <<<"$matches_json")"
child_head_sha="$(jq -r '.[0].head_sha' <<<"$matches_json")"
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "Could not find exact dispatched Telegram QA run ${run_name}." >&2
for _ in $(seq 1 60); do
matches_json="$(find_child_runs)"
match_count="$(jq 'length' <<<"$matches_json")"
if ((match_count > 1)); then
echo "Multiple Telegram QA runs matched ${run_name}; refusing to guess." >&2
exit 1
fi
if [[ "$child_head_sha" == "$expected_trusted_workflow_sha" ]]; then
if ((match_count == 1)); then
run_id="$(jq -r '.[0].id' <<<"$matches_json")"
child_head_sha="$(jq -r '.[0].head_sha' <<<"$matches_json")"
break
fi
echo "Trusted main moved from ${expected_trusted_workflow_sha} to ${child_head_sha} during dispatch attempt ${dispatch_attempt}; retrying." >&2
cancel_child
run_id=""
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "Trusted main kept moving during Telegram QA dispatch; refusing an unpinned child." >&2
echo "Could not find exact dispatched Telegram QA run ${run_name}." >&2
exit 1
fi
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "Telegram QA used tooling SHA ${child_head_sha}, expected ${PARENT_WORKFLOW_SHA}." >&2
cancel_child
exit 1
fi

View file

@ -3,12 +3,10 @@ name: OpenClaw Release Telegram QA
run-name: ${{ github.event_name == 'workflow_dispatch' && format('OpenClaw Release Telegram QA {0}', inputs.dispatch_id) || 'OpenClaw Release Telegram QA' }}
on:
# Transitional compatibility for supported release refs whose parent still calls @main.
# Current main dispatches this workflow so qa-live-shared secrets stay in this run.
workflow_call:
inputs:
expected_trusted_workflow_sha:
description: Resolved main SHA authorized for this trusted workflow
description: Exact trusted tooling SHA authorized for this workflow
required: true
type: string
target_ref:
@ -38,7 +36,7 @@ on:
required: true
type: string
expected_trusted_workflow_sha:
description: Resolved main SHA authorized for this trusted workflow
description: Exact trusted tooling SHA authorized for this workflow
required: true
type: string
target_ref:
@ -76,7 +74,7 @@ jobs:
workflow_repository: ${{ steps.identity.outputs.workflow_repository }}
workflow_sha: ${{ steps.identity.outputs.workflow_sha }}
steps:
- name: Verify dispatched-main identity
- name: Verify dispatched workflow identity
id: identity
env:
CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
@ -93,7 +91,8 @@ jobs:
set -euo pipefail
expected_repository="openclaw/openclaw"
expected_ref="${expected_repository}/.github/workflows/openclaw-release-telegram-qa.yml@refs/heads/main"
workflow_path=".github/workflows/openclaw-release-telegram-qa.yml"
caller_path=".github/workflows/openclaw-release-checks.yml"
if [[ "$GITHUB_REPOSITORY" != "$expected_repository" ||
! "$EXPECTED_TRUSTED_WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ ||
! "$TARGET_SHA" =~ ^[a-f0-9]{40}$ ||
@ -113,13 +112,38 @@ jobs:
echo "Telegram QA release context requires an exact-SHA target ref." >&2
exit 1
fi
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
expected_ref="${expected_repository}/${workflow_path}@${GITHUB_REF}"
elif [[ "$GITHUB_REF" =~ ^refs/heads/release-ci/([a-f0-9]{12})-([1-9][0-9]*)$ ]]; then
release_ci_sha_prefix="${BASH_REMATCH[1]}"
if [[ "$release_ci_sha_prefix" != "${EXPECTED_TRUSTED_WORKFLOW_SHA:0:12}" ]]; then
echo "Telegram QA release-ci ref does not match the authorized tooling SHA." >&2
exit 1
fi
expected_ref="${expected_repository}/${workflow_path}@${GITHUB_REF}"
elif [[ "$GITHUB_REF" =~ ^refs/heads/release/[0-9]{4}\.([1-9]|1[0-2])\.[1-9][0-9]*$ ||
"$GITHUB_REF" =~ ^refs/heads/extended-stable/[0-9]{4}\.([1-9]|1[0-2])\.33$ ]]; then
expected_ref="${expected_repository}/${workflow_path}@${GITHUB_REF}"
else
echo "Telegram QA tooling ref must be exact main, canonical release or extended-stable, or canonical SHA-bound release-ci." >&2
exit 1
fi
if [[ "$GITHUB_SHA" != "$EXPECTED_TRUSTED_WORKFLOW_SHA" ||
"$WORKFLOW_SHA" != "$EXPECTED_TRUSTED_WORKFLOW_SHA" ]]; then
echo "Telegram QA tooling SHA does not match the authorized workflow SHA." >&2
exit 1
fi
INVOCATION_MODE=reusable
if [[ "$WORKFLOW_REF" == "$expected_ref" ]]; then
INVOCATION_MODE=dispatch
[[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" &&
"$GITHUB_REF" == "refs/heads/main" &&
"$GITHUB_SHA" == "$EXPECTED_TRUSTED_WORKFLOW_SHA" &&
"$WORKFLOW_SHA" == "$EXPECTED_TRUSTED_WORKFLOW_SHA" ]]
[[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]
else
expected_caller_ref="${expected_repository}/${caller_path}@${GITHUB_REF}"
if [[ "$WORKFLOW_REF" != "$expected_caller_ref" ||
"$CALLER_WORKFLOW_SHA" != "$EXPECTED_TRUSTED_WORKFLOW_SHA" ]]; then
echo "Telegram QA reusable caller does not match the authorized tooling tuple." >&2
exit 1
fi
fi
export INVOCATION_MODE
@ -815,6 +839,7 @@ jobs:
env:
ARCHIVE_NAME: ${{ needs.build_candidate.outputs.archive_name }}
ARCHIVE_SHA256: ${{ needs.build_candidate.outputs.archive_sha256 }}
CALLED_WORKFLOW_REF: ${{ needs.trusted_identity.outputs.workflow_ref }}
CALLED_WORKFLOW_SHA: ${{ needs.trusted_identity.outputs.workflow_sha }}
CANDIDATE_TREE: ${{ needs.build_candidate.outputs.candidate_tree }}
GH_TOKEN: ${{ github.token }}
@ -830,7 +855,7 @@ jobs:
gh attestation verify "$archive_path" \
--repo "$GITHUB_REPOSITORY" \
--cert-identity "https://github.com/openclaw/openclaw/.github/workflows/openclaw-release-telegram-qa.yml@refs/heads/main" \
--cert-identity "https://github.com/${CALLED_WORKFLOW_REF}" \
--signer-digest "$CALLED_WORKFLOW_SHA" \
--source-ref "$GITHUB_REF" \
--source-digest "$GITHUB_SHA" \

View file

@ -40,7 +40,7 @@ permissions:
contents: read
concurrency:
group: plugin-prerelease-${{ inputs.target_ref }}
group: plugin-prerelease-${{ inputs.target_ref }}-${{ github.sha }}
cancel-in-progress: ${{ inputs.target_ref == 'main' }}
env:

View file

@ -392,14 +392,22 @@ For pinned commit proof on a fast-moving branch, use the helper instead of
`gh workflow run ... --ref main -f ref=<sha>`:
```bash
pnpm ci:full-release --sha <full-sha>
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
VALIDATION_SHA="<full-release-candidate-sha>"
pnpm ci:full-release \
--sha "$VALIDATION_SHA" \
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
GitHub workflow dispatch refs must be branches or tags, not raw commit SHAs. The
helper pushes a temporary `release-ci/<sha>-...` branch at a trusted Tooling
SHA, passes the requested Validation SHA through `ref` and `expected_sha`, reuses
strict exact-target evidence when available, and verifies every child workflow
`headSha` matches the Tooling SHA.
`headSha` matches the Tooling SHA. Record that Tooling SHA once and never refresh
it from moving `main`. Regular release branches accept only their final package
version or a matching beta prerelease; Tideclaw alpha validation uses its exact
alpha tag and matching alpha branch.
`release_profile` controls live/provider breadth passed into release checks. The
manual release workflows default to `stable`; use `full` only when you

View file

@ -230,7 +230,7 @@ This checklist is the public shape of the release flow. Private credentials, sig
1. Start from current `main`: pull latest, confirm the target commit is pushed, and confirm `main` CI is green enough to branch from.
2. Create `release/YYYY.M.PATCH` from that commit. Backports are optional; apply only the operator-selected set. Bump every required version location, run `pnpm release:prep`, finish release fixes and required forward-ports, and review `src/plugins/compat/registry.ts` plus `src/commands/doctor/shared/deprecation-compat.ts`.
3. Freeze the product-complete pre-changelog commit as the **Code SHA** and record the trusted **Tooling SHA**. Run the deterministic source preflight, then use `node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH`. Beta-publish uses `release_profile=beta` without soak; postpublish-confidence owns broad live, QA-live, mobile, and Parallels work.
3. Freeze the product-complete pre-changelog commit as the **Code SHA** and record the trusted **Tooling SHA**. Run the deterministic source preflight, then use `node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH --workflow-sha <tooling-sha>`. Reuse that exact Tooling SHA for later release validation; never refresh it from moving `main`. Beta-publish uses `release_profile=beta` without soak; postpublish-confidence owns broad live, QA-live, mobile, and Parallels work.
4. Classify failures before editing as product, harness/tooling/provenance, infrastructure/credential, or wrapper. Only confirmed product failure creates a new Code SHA. Use one diagnosis, one fix when needed, and one narrow retry, then reassess.
5. Only after the Code SHA is green, generate the top `CHANGELOG.md` section from merged PRs and direct commits since the last reachable shipped tag. Keep entries user-facing and deduplicated. When a divergent shipped tag or later forward-port re-associates already-released PRs, pass it explicitly as `--shipped-ref`.
6. Commit only `CHANGELOG.md`. This commit is the **Release SHA**. The complete diff from Code SHA to Release SHA must be exactly `CHANGELOG.md`; any other changed path returns the release to step 2.
@ -291,9 +291,11 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
Provide `release_package_spec` after publishing a beta to reuse the shipped npm package across release checks, Package Acceptance, and package Telegram E2E without rebuilding the release tarball. Provide `npm_telegram_package_spec` only when Telegram should use a different published package from the rest of release validation. Provide `package_acceptance_package_spec` when Package Acceptance should use a different published package from the release package spec. Provide `evidence_package_spec` when the release evidence report should prove that validation matches a published npm package without forcing Telegram E2E.
```bash
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
node scripts/full-release-validation-at-sha.mjs \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
- Run the manual `Package Acceptance` workflow when you want side-channel proof for a package candidate while release work continues. Use `source=npm` for `openclaw@beta`, `openclaw@latest`, or an exact release version; `source=ref` to pack a trusted `package_ref` branch/tag/SHA with the current `workflow_ref` harness; `source=url` for a public HTTPS tarball with a required SHA-256 and strict public URL policy; `source=trusted-url` for a named trusted-source policy using required `trusted_source_id` and SHA-256; or `source=artifact` for a tarball uploaded by another GitHub Actions run.
@ -364,19 +366,36 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
`Full Release Validation` is how operators kick off the full product matrix from one entrypoint. Use the helper so every child workflow runs from a temporary branch fixed at one trusted `main` workflow SHA while the requested commit remains the candidate under test:
```bash
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
pnpm ci:full-release \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
The helper fetches current `origin/main`, pushes `release-ci/<workflow-sha>-...` at that trusted Tooling SHA, infers `beta` from alpha/beta package versions and `stable` otherwise, and dispatches `Full Release Validation` with the Validation SHA as `expected_sha`. Target resolution rejects a mismatch before child dispatch. Every child workflow `headSha` must match the Tooling SHA. Pass `-f reuse_evidence=false` to force a fresh run, `-f release_profile=full` for the broad advisory sweep, or `--workflow-sha <trusted-main-sha>` to pin a compatible older commit still reachable from current `origin/main`. The helper rejects pinned tooling that lacks the `expected_sha` dispatch input and never silently selects a newer Tooling SHA. The workflow itself never writes repository refs.
The helper verifies that the recorded Tooling SHA remains reachable from current
`origin/main`, pushes `release-ci/<workflow-sha>-...` at that exact commit,
accepts only the release branch's final package version or a matching beta
prerelease, infers `beta` for that beta path and `stable` for final versions, and
dispatches `Full Release Validation` with the Validation SHA as `expected_sha`.
Target resolution rejects a mismatch before child dispatch. Every child workflow
`headSha` must match the Tooling SHA. Pass `-f reuse_evidence=false` to force a
fresh run or `-f release_profile=full` for the broad advisory sweep. Never
replace the recorded Tooling SHA with a fresh `main` lookup. The helper rejects
pinned tooling that lacks the current release-isolation contract or the
`expected_sha` dispatch input and never silently selects newer tooling. The
workflow itself never writes repository refs. Tideclaw alpha validation remains
on its matching alpha branch and exact alpha tag rather than a regular
`release/*` context.
After the Code SHA is green, commit only `CHANGELOG.md` and run the same helper with the Release SHA:
```bash
TOOLING_SHA="<same-recorded-tooling-sha>"
pnpm ci:full-release \
--sha <release-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
The second parent reuses product evidence only when GitHub proves the Release SHA descends from the Code SHA and the complete changed path set is exactly `CHANGELOG.md`. It records `changelog-only-release-v1` and dispatches no product children. Npm preflight and package/install acceptance still run on the Release SHA because its tarball bytes changed.
@ -390,7 +409,7 @@ skipped.
See [Full release validation](/reference/full-release-validation) for the complete stage matrix, exact workflow job names, stable versus full profile differences, artifacts, and focused rerun handles.
Child workflows are dispatched from the SHA-pinned trusted ref that runs `Full Release Validation`. Every child run must use the exact parent workflow SHA. Do not use raw `--ref main -f ref=<sha>` dispatches for release proof; use `pnpm ci:full-release --sha <target-sha> --target-ref release/YYYY.M.PATCH`.
Child workflows are dispatched from the SHA-pinned trusted ref that runs `Full Release Validation`. Every child run must use the exact parent workflow SHA. Do not use raw `--ref main -f ref=<sha>` dispatches for release proof; use `pnpm ci:full-release --sha <target-sha> --target-ref release/YYYY.M.PATCH --workflow-sha <tooling-sha>`.
Use `release_profile` to select live/provider breadth:
@ -407,20 +426,25 @@ The cross-OS OpenAI install smoke uses `OPENCLAW_CROSS_OS_OPENAI_MODEL` when the
Use these variants depending on release stage:
```bash
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
# Validate the product-complete Code SHA.
pnpm ci:full-release \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
# Validate the changelog-only Release SHA by reusing Code SHA product evidence.
pnpm ci:full-release \
--sha <release-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
# After publishing a beta, add published-package Telegram E2E.
pnpm ci:full-release \
--sha <release-sha> \
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA" \
-f release_package_spec=openclaw@YYYY.M.PATCH-beta.N \
-f evidence_package_spec=openclaw@YYYY.M.PATCH-beta.N \
-f run_release_soak=true \

View file

@ -18,15 +18,23 @@ Freeze the product-complete pre-changelog commit as the **Code SHA** and select
one trusted workflow commit as the **Tooling SHA**, then run:
```bash
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
pnpm ci:full-release \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
--target-ref release/YYYY.M.PATCH \
--workflow-sha "$TOOLING_SHA"
```
Record the Tooling SHA once for the release and reuse it for later Code-SHA,
Release-SHA, and focused reruns. Do not refresh it from moving `main`.
`provider` also accepts `anthropic` or `minimax` for cross-OS onboarding and the
end-to-end agent turn. The helper infers the `beta` profile from alpha/beta
package versions and `stable` otherwise. Pass alternate workflow inputs with
`-f key=value`; use `-f release_profile=full` only for the broad advisory sweep.
end-to-end agent turn. Regular `release/*` targets accept only the branch's final
package version or a matching beta prerelease. Tideclaw alpha validation uses
its exact alpha tag and matching alpha branch. The helper maps beta releases and
exact alpha tags to the `beta` profile and final versions to `stable`. Pass
alternate workflow inputs with `-f key=value`; use `-f release_profile=full`
only for the broad advisory sweep.
`fail_fast` defaults to `false`, so dispatched child workflows finish and expose
independent failures together. Pass `-f fail_fast=true` when the shorter
first-failure cancellation path is preferable.
@ -38,12 +46,12 @@ SHA for product validation or the Release SHA for changelog-only validation; it
is not a third release identity. The workflow rejects malformed or mismatched
expected SHAs before child dispatch. Every child must report the same Tooling
SHA. Pass
`-f reuse_evidence=false` to force a fresh run or
`--workflow-sha <trusted-main-sha>` to select a compatible older workflow
commit still reachable from current `origin/main`. The helper rejects a pinned
Tooling SHA that does not declare the `expected_sha` dispatch input; it never
silently substitutes newer tooling. The workflow never creates or updates
repository refs itself.
`-f reuse_evidence=false` to force a fresh run. Regular release-branch runs
require `--workflow-sha` with the recorded full SHA, which must remain reachable
from current `origin/main`. The helper rejects a pinned Tooling SHA that does
not declare the current release-isolation contract or the `expected_sha`
dispatch input; it never silently substitutes newer tooling. The workflow never
creates or updates repository refs itself.
## Extended-stable exception

View file

@ -15,6 +15,8 @@ import { execGhRead } from "./lib/plain-gh.mjs";
const WORKFLOW = "full-release-validation.yml";
const TRUSTED_WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`;
const RELEASE_ISOLATION_TOOLING_CONTRACT = "1";
const RELEASE_ISOLATION_TOOLING_CONTRACT_ENV = "RELEASE_ISOLATION_TOOLING_CONTRACT";
const RELEASE_EVIDENCE_VERIFIER_PATHS = [
"scripts/release-ci-summary.mjs",
".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs",
@ -22,15 +24,20 @@ const RELEASE_EVIDENCE_VERIFIER_PATHS = [
const GH_READ_TIMEOUT_MS = 60_000;
export const FULL_RELEASE_WAIT_TIMEOUT_MINUTES = 720;
export const FULL_RELEASE_WAIT_POLL_INTERVAL_MS = 45_000;
const FULL_RELEASE_PROGRESS_INTERVAL_MS = 5 * 60_000;
const GH_READ_OPTIONS = {
encoding: "utf8",
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "inherit"],
timeout: GH_READ_TIMEOUT_MS,
} satisfies ExecFileSyncOptionsWithStringEncoding;
const RELEASE_BRANCH_PATTERN =
/^(?:release\/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable\/[0-9]{4}\.[0-9]+\.33)$/u;
const RELEASE_TAG_PATTERN = /^v[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)\.[0-9]+)?$/u;
const RELEASE_BRANCH_PATTERN = /^release\/([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*)$/u;
const EXTENDED_STABLE_BRANCH_PATTERN = /^extended-stable\/([0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u;
const RELEASE_CONTEXT_BRANCH_PATTERN =
/^(?:release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u;
const RELEASE_TAG_PATTERN =
/^v([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*)?)$/u;
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const DEFAULT_INPUTS = {
provider: "openai",
mode: "both",
@ -76,8 +83,10 @@ temporary branches by default. --keep-branch retains both branches. Exact-target
evidence reuse stay enabled; pass -f reuse_evidence=false to force a fresh
run. Child workflows collect independent failures by default; pass
-f fail_fast=true to cancel each child after its first failed job. The release
profile defaults to beta for alpha/beta package versions and stable otherwise;
pass -f release_profile=full for the broad advisory sweep.`);
branch accepts only its final package version or a matching beta prerelease.
Exact alpha tags remain supported for Tideclaw. The release profile defaults to
beta for beta candidates and exact alpha tags, and stable otherwise; pass
-f release_profile=full for the broad advisory sweep.`);
}
function run(command: string, args: string[], options: CommandOptions = {}) {
@ -218,11 +227,19 @@ export function parseArgs(argv: string[]) {
}
if (
args.targetRef &&
!RELEASE_BRANCH_PATTERN.test(args.targetRef) &&
!RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) &&
!RELEASE_TAG_PATTERN.test(args.targetRef)
) {
throw new Error("--target-ref must be a canonical OpenClaw release branch or tag");
}
if (
RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) &&
!SHA_PATTERN.test(args.workflowSha.toLowerCase())
) {
throw new Error(
"release-branch validation requires --workflow-sha with an explicit full Tooling SHA",
);
}
return args;
}
@ -230,7 +247,7 @@ export function resolveRemoteTargetRefSha(
targetRef: string,
executeGit: (args: string[]) => string = (args) => run("git", args),
) {
if (RELEASE_BRANCH_PATTERN.test(targetRef)) {
if (RELEASE_CONTEXT_BRANCH_PATTERN.test(targetRef)) {
return (
executeGit(["ls-remote", "--heads", "origin", `refs/heads/${targetRef}`]).split(/\s+/u)[0] ??
""
@ -245,12 +262,56 @@ export function resolveRemoteTargetRefSha(
return executeGit(["ls-remote", "--tags", "origin", tagRef]).split(/\s+/u)[0] ?? "";
}
function verifyTargetRef(targetRef: string, targetSha: string) {
export function verifyTargetRef(
targetRef: string,
targetSha: string,
targetVersion: string,
resolveRemoteSha: (ref: string) => string = resolveRemoteTargetRefSha,
isAncestor: (ancestor: string, descendant: string) => boolean = (ancestor, descendant) =>
runStatus("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
stdio: ["ignore", "ignore", "ignore"],
}).status === 0,
) {
if (!targetRef) {
return targetSha;
}
const remoteSha = resolveRemoteTargetRefSha(targetRef);
if (remoteSha !== targetSha) {
const releaseMatch = targetRef.match(RELEASE_BRANCH_PATTERN);
const extendedStableMatch = targetRef.match(EXTENDED_STABLE_BRANCH_PATTERN);
const tagMatch = targetRef.match(RELEASE_TAG_PATTERN);
if (releaseMatch) {
const releaseVersion = releaseMatch[1]!;
const prereleaseMatch = targetVersion.match(
/^([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*)-beta\.[1-9][0-9]*$/u,
);
if (targetVersion !== releaseVersion && prereleaseMatch?.[1] !== releaseVersion) {
throw new Error(
`Target package version ${targetVersion} does not belong to release branch ${targetRef}; expected ${releaseVersion} or a beta prerelease of it`,
);
}
} else if (extendedStableMatch) {
if (targetVersion !== extendedStableMatch[1]) {
throw new Error(
`Target package version ${targetVersion} does not match extended-stable branch ${targetRef}`,
);
}
} else if (tagMatch && targetVersion !== tagMatch[1]) {
throw new Error(
`Target package version ${targetVersion} does not match release tag ${targetRef}`,
);
}
const remoteSha = resolveRemoteSha(targetRef);
if (!remoteSha) {
throw new Error(`Target ref ${targetRef} does not resolve to a commit`);
}
if (RELEASE_CONTEXT_BRANCH_PATTERN.test(targetRef)) {
if (!isAncestor(targetSha, remoteSha)) {
throw new Error(
`Target SHA ${targetSha} is not reachable from release branch ${targetRef} at ${remoteSha}`,
);
}
return targetRef;
}
if (remoteSha.toLowerCase() !== targetSha.toLowerCase()) {
throw new Error(`Target ref ${targetRef} does not resolve to ${targetSha}`);
}
return targetRef;
@ -265,7 +326,7 @@ function fetchTargetRef(targetRef: string) {
if (!targetRef) {
return;
}
const sourceRef = RELEASE_BRANCH_PATTERN.test(targetRef)
const sourceRef = RELEASE_CONTEXT_BRANCH_PATTERN.test(targetRef)
? `refs/heads/${targetRef}`
: `refs/tags/${targetRef}`;
run("git", ["fetch", "--no-tags", "origin", sourceRef], {
@ -290,10 +351,10 @@ function resolveTargetSha(requestedSha: string, targetRef: string) {
return resolvedSha;
}
export function releaseProfileForTarget(
function targetVersionForTarget(
targetSha: string,
readPackageJson: (sha: string) => string = (sha) => run("git", ["show", `${sha}:package.json`]),
): "beta" | "stable" {
): string {
let version: unknown;
try {
version = JSON.parse(readPackageJson(targetSha)).version;
@ -303,9 +364,20 @@ export function releaseProfileForTarget(
if (typeof version !== "string" || !/^[0-9]{4}\.[0-9]+\.[0-9]+(?:-.+)?$/u.test(version)) {
throw new Error(`Target SHA ${targetSha} has an invalid package version`);
}
return version;
}
function releaseProfileForVersion(version: string): "beta" | "stable" {
return /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version) ? "beta" : "stable";
}
export function releaseProfileForTarget(
targetSha: string,
readPackageJson: (sha: string) => string = (sha) => run("git", ["show", `${sha}:package.json`]),
): "beta" | "stable" {
return releaseProfileForVersion(targetVersionForTarget(targetSha, readPackageJson));
}
function resolveTrustedWorkflowSha(requestedSha: string) {
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
stdio: "inherit",
@ -375,10 +447,31 @@ function readWorkflowRun(parentRunId: string, workflowSha: string) {
return workflowRun;
}
function readActiveParentJobs(parentRunId: string) {
const response: unknown = JSON.parse(
execGhRead(
["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}/jobs?per_page=100`],
GH_READ_OPTIONS,
),
);
if (!isJsonRecord(response) || !Array.isArray(response.jobs)) {
throw new Error(`Full Release Validation run ${parentRunId} returned invalid jobs`);
}
return response.jobs
.filter((job) => isJsonRecord(job) && job.status !== "completed")
.map((job) => ({
name: isJsonRecord(job) ? stringValue(job.name, "<unnamed>") : "<unnamed>",
status: isJsonRecord(job) ? stringValue(job.status, "pending") : "pending",
url: isJsonRecord(job) ? stringValue(job.html_url) : "",
}));
}
function waitForWorkflowRun(parentRunId: string, workflowSha: string) {
let lastSummary = "";
let consecutiveErrors = 0;
const deadline = Date.now() + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000;
const startedAt = Date.now();
const deadline = startedAt + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000;
let nextProgressAt = startedAt + FULL_RELEASE_PROGRESS_INTERVAL_MS;
while (Date.now() < deadline) {
let suite: Record<string, unknown> | undefined;
try {
@ -408,6 +501,24 @@ function waitForWorkflowRun(parentRunId: string, workflowSha: string) {
`Full Release Validation concluded ${stringValue(suite.conclusion, "unknown").toLowerCase()}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
const now = Date.now();
if (now >= nextProgressAt) {
const elapsedMinutes = Math.floor((now - startedAt) / 60_000);
try {
const activeJobs = readActiveParentJobs(parentRunId);
console.log(
`Parent run progress after ${elapsedMinutes}m: ${activeJobs.length} active job(s)`,
);
for (const job of activeJobs) {
console.log(`- ${job.name}: ${job.status}${job.url ? ` ${job.url}` : ""}`);
}
} catch (error) {
console.warn(
`Parent run progress query failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
nextProgressAt += FULL_RELEASE_PROGRESS_INTERVAL_MS;
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
@ -477,6 +588,14 @@ export function assertTrustedWorkflowHarness(
}
if (
!isJsonRecord(workflow) ||
!isJsonRecord(workflow.env) ||
workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV] !== RELEASE_ISOLATION_TOOLING_CONTRACT
) {
throw new Error(
`Tooling SHA ${workflowSha} does not declare ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=${RELEASE_ISOLATION_TOOLING_CONTRACT} in ${TRUSTED_WORKFLOW_PATH}`,
);
}
if (
!isJsonRecord(workflow.on) ||
!isJsonRecord(workflow.on.workflow_dispatch) ||
!isJsonRecord(workflow.on.workflow_dispatch.inputs) ||
@ -543,9 +662,10 @@ function verifyReleaseEvidence(parentRunId: string, workflowSha: string) {
function main() {
const args = parseArgs(process.argv.slice(2));
const targetSha = resolveTargetSha(args.sha, args.targetRef);
args.inputs.release_profile ??= releaseProfileForTarget(targetSha);
const targetVersion = targetVersionForTarget(targetSha);
args.inputs.release_profile ??= releaseProfileForVersion(targetVersion);
args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true";
const targetContextRef = verifyTargetRef(args.targetRef, targetSha);
const targetContextRef = verifyTargetRef(args.targetRef, targetSha, targetVersion);
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
assertTrustedWorkflowHarness(workflowSha);
const shortSha = workflowSha.slice(0, 12);
@ -562,6 +682,9 @@ function main() {
console.log(`Validation SHA: ${targetSha}`);
console.log(`Tooling SHA: ${workflowSha}`);
console.log(
`Frozen validation tuple: candidate=${targetSha} tooling=${workflowSha} rerun_group=${args.inputs.rerun_group}`,
);
console.log(`Temporary target ref: ${targetBranch}`);
console.log(`Temporary workflow ref: ${branch}`);

View file

@ -8515,7 +8515,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
(step: WorkflowStep) => step.name === "Dispatch and await trusted Telegram QA",
);
const identityStep = telegramWorkflow.jobs.trusted_identity.steps.find(
(step: WorkflowStep) => step.name === "Verify dispatched-main identity",
(step: WorkflowStep) => step.name === "Verify dispatched workflow identity",
);
const provenanceSteps = [
telegramWorkflow.jobs.build_candidate.steps.find(

View file

@ -13,9 +13,26 @@ import {
releaseEvidenceVerifierPath,
resolveRemoteTargetRefSha,
shouldDeleteTemporaryWorkflowRef,
verifyTargetRef,
} from "../../scripts/full-release-validation-at-sha.mts";
const SCRIPT_PATH = resolve("scripts/full-release-validation-at-sha.mjs");
const CURRENT_WORKFLOW_SOURCE = `name: Full Release Validation
env:
RELEASE_ISOLATION_TOOLING_CONTRACT: "1"
on:
workflow_dispatch:
inputs:
expected_sha:
required: false
`;
const LEGACY_WORKFLOW_SOURCE = `name: Full Release Validation
on:
workflow_dispatch:
inputs:
expected_sha:
required: false
`;
function runGit(cwd: string, args: string[]): string {
return execFileSync("git", args, {
@ -44,17 +61,10 @@ function createDispatchFixture(options: { workflowSource?: string } = {}) {
runGit(checkout, ["config", "user.name", "OpenClaw Release Test"]);
mkdirSync(join(checkout, ".github", "workflows"), { recursive: true });
mkdirSync(join(checkout, "scripts"), { recursive: true });
writeFileSync(join(checkout, "package.json"), '{"version":"2026.8.1"}\n');
writeFileSync(join(checkout, "package.json"), '{"version":"2026.7.9"}\n');
writeFileSync(
join(checkout, ".github", "workflows", "full-release-validation.yml"),
options.workflowSource ??
`name: Full Release Validation
on:
workflow_dispatch:
inputs:
expected_sha:
required: false
`,
LEGACY_WORKFLOW_SOURCE,
);
writeFileSync(
join(checkout, "scripts", "release-ci-summary.mjs"),
@ -73,7 +83,15 @@ console.log(JSON.stringify({ valid: true, current: { runId: "123" }, root: { run
`,
);
runGit(checkout, ["add", "."]);
runGit(checkout, ["commit", "-m", "test: trusted workflow"]);
runGit(checkout, ["commit", "-m", "test: legacy workflow"]);
const oldWorkflowSha = runGit(checkout, ["rev-parse", "HEAD"]);
writeFileSync(
join(checkout, ".github", "workflows", "full-release-validation.yml"),
options.workflowSource ?? CURRENT_WORKFLOW_SOURCE,
);
writeFileSync(join(checkout, "package.json"), '{"version":"2026.8.1"}\n');
runGit(checkout, ["add", ".github/workflows/full-release-validation.yml", "package.json"]);
runGit(checkout, ["commit", "-m", "test: trusted workflow contract"]);
const workflowSha = runGit(checkout, ["rev-parse", "HEAD"]);
runGit(checkout, ["remote", "add", "origin", origin]);
runGit(checkout, ["push", "-u", "origin", "main"]);
@ -151,6 +169,7 @@ if (args[0] === "workflow" && args[1] === "run") {
ghCallsPath,
gitCallsPath,
origin,
oldWorkflowSha,
readCalls,
releaseRef,
run,
@ -166,7 +185,7 @@ describe("full-release-validation-at-sha", () => {
"--sha",
"abc123",
"--workflow-sha",
"origin/main",
"a".repeat(40),
"--target-ref",
"release/2026.7.1",
"--keep-branch",
@ -187,7 +206,7 @@ describe("full-release-validation-at-sha", () => {
},
sha: "abc123",
targetRef: "release/2026.7.1",
workflowSha: "origin/main",
workflowSha: "a".repeat(40),
});
});
@ -224,14 +243,21 @@ describe("full-release-validation-at-sha", () => {
});
it("accepts only canonical release branch or tag context", () => {
expect(parseArgs(["--target-ref", "extended-stable/2026.6.33"]).targetRef).toBe(
"extended-stable/2026.6.33",
);
expect(
parseArgs(["--target-ref", "extended-stable/2026.6.33", "--workflow-sha", "a".repeat(40)])
.targetRef,
).toBe("extended-stable/2026.6.33");
expect(parseArgs(["--target-ref", "v2026.7.1-beta.5"]).targetRef).toBe("v2026.7.1-beta.5");
expect(parseArgs(["--target-ref", "v2026.7.1"]).targetRef).toBe("v2026.7.1");
expect(() => parseArgs(["--target-ref", "feature/not-release"])).toThrow(
"canonical OpenClaw release branch or tag",
);
expect(() => parseArgs(["--target-ref", "release/2026.7.1"])).toThrow(
"requires --workflow-sha with an explicit full Tooling SHA",
);
expect(() =>
parseArgs(["--target-ref", "release/2026.7.1", "--workflow-sha", "origin/main"]),
).toThrow("explicit full Tooling SHA");
});
it("resolves annotated release tags through their peeled commit", () => {
@ -259,6 +285,92 @@ describe("full-release-validation-at-sha", () => {
]);
});
it("binds frozen release candidates to the branch or tag package version", () => {
const candidateSha = "a".repeat(40);
const branchTipSha = "b".repeat(40);
expect(
verifyTargetRef(
"release/2026.7.1",
candidateSha,
"2026.7.1-beta.5",
() => branchTipSha,
(ancestor, descendant) => ancestor === candidateSha && descendant === branchTipSha,
),
).toBe("release/2026.7.1");
expect(() =>
verifyTargetRef(
"release/2026.7.1",
candidateSha,
"2026.7.1-alpha.5",
() => branchTipSha,
() => true,
),
).toThrow("expected 2026.7.1 or a beta prerelease of it");
expect(() =>
verifyTargetRef(
"release/2026.7.1",
candidateSha,
"2026.7.1",
() => branchTipSha,
() => false,
),
).toThrow("is not reachable from release branch");
expect(() =>
verifyTargetRef(
"release/2026.7.1",
candidateSha,
"2026.6.9",
() => branchTipSha,
() => true,
),
).toThrow("does not belong to release branch");
expect(
verifyTargetRef(
"extended-stable/2026.6.33",
candidateSha,
"2026.6.33",
() => branchTipSha,
() => true,
),
).toBe("extended-stable/2026.6.33");
expect(() =>
verifyTargetRef(
"extended-stable/2026.6.33",
candidateSha,
"2026.6.33-beta.1",
() => branchTipSha,
() => true,
),
).toThrow("does not match extended-stable branch");
expect(
verifyTargetRef(
"v2026.7.1-beta.5",
candidateSha,
"2026.7.1-beta.5",
() => candidateSha,
() => false,
),
).toBe("v2026.7.1-beta.5");
expect(() =>
verifyTargetRef(
"v2026.7.1-beta.5",
candidateSha,
"2026.7.1-beta.5",
() => branchTipSha,
() => true,
),
).toThrow("does not resolve");
expect(() =>
verifyTargetRef(
"v2026.7.1-beta.5",
candidateSha,
"2026.7.1-beta.4",
() => candidateSha,
() => true,
),
).toThrow("does not match release tag");
});
it("allows exact-target reuse to be disabled for a forced fresh run", () => {
expect(parseArgs(["-f", "reuse_evidence=false"]).inputs.reuse_evidence).toBe("false");
expect(() => parseArgs(["-f", "reuse_evidence=maybe"])).toThrow(
@ -308,11 +420,13 @@ describe("full-release-validation-at-sha", () => {
const source = readFileSync("scripts/full-release-validation-at-sha.mts", "utf8");
expect(FULL_RELEASE_WAIT_TIMEOUT_MINUTES).toBe(720);
expect(FULL_RELEASE_WAIT_POLL_INTERVAL_MS).toBe(45_000);
expect(source).toContain("const FULL_RELEASE_PROGRESS_INTERVAL_MS = 5 * 60_000;");
expect(source).toContain("workflowRun.head_sha !== workflowSha");
expect(source).toContain("return suite;");
expect(source).toContain("Date.now() + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000");
expect(source).toContain("startedAt + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000");
expect(source).toContain("const remainingMs = deadline - Date.now();");
expect(source).toContain("Math.min(FULL_RELEASE_WAIT_POLL_INTERVAL_MS, remainingMs)");
expect(source).toContain("Parent run progress after ${elapsedMinutes}m");
expect(source).toContain(
"Timed out after ${FULL_RELEASE_WAIT_TIMEOUT_MINUTES} minutes waiting for Full Release Validation",
);
@ -322,7 +436,7 @@ describe("full-release-validation-at-sha", () => {
it("bounds GitHub reads without applying a timeout to workflow dispatch", () => {
const source = readFileSync("scripts/full-release-validation-at-sha.mts", "utf8");
expect(source).toContain("timeout: GH_READ_TIMEOUT_MS");
expect(source.match(/GH_READ_OPTIONS/gu)).toHaveLength(3);
expect(source.match(/GH_READ_OPTIONS/gu)).toHaveLength(4);
expect(source).toContain('const dispatchOutput = run("gh", dispatchArgs');
});
@ -337,7 +451,7 @@ describe("full-release-validation-at-sha", () => {
checked.push(relativePath);
return relativePath === workflowPath || relativePath === verifierPath;
},
() => "on:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n",
() => CURRENT_WORKFLOW_SOURCE,
),
).toBe(verifierPath);
expect(checked).toEqual([workflowPath, verifierPath]);
@ -346,14 +460,22 @@ describe("full-release-validation-at-sha", () => {
assertTrustedWorkflowHarness(
"a".repeat(40),
(relativePath) => relativePath === workflowPath,
() => "on:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n",
() => CURRENT_WORKFLOW_SOURCE,
),
).toThrow("supported release evidence verifier");
expect(() =>
assertTrustedWorkflowHarness(
"b".repeat(40),
() => true,
() => "on:\n workflow_dispatch:\n inputs: {}\n",
() => LEGACY_WORKFLOW_SOURCE,
),
).toThrow("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1");
expect(() =>
assertTrustedWorkflowHarness(
"b".repeat(40),
() => true,
() =>
'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n inputs: {}\n',
),
).toThrow(`Tooling SHA ${"b".repeat(40)} is missing workflow_dispatch input expected_sha`);
});
@ -396,7 +518,7 @@ describe("full-release-validation-at-sha", () => {
it("pushes an exact target ref, dispatches it, prints the run URL, and cleans both refs", () => {
const fixture = createDispatchFixture();
try {
const result = fixture.run();
const result = fixture.run(["--workflow-sha", fixture.workflowSha]);
expect(result.status, result.stderr).toBe(0);
const gitCalls = fixture.readCalls(fixture.gitCallsPath);
const ghCalls = fixture.readCalls(fixture.ghCallsPath);
@ -451,6 +573,9 @@ describe("full-release-validation-at-sha", () => {
expect(ghCalls.some((args) => args[0] === "run" && args[1] === "watch")).toBe(false);
expect(result.stdout).toContain(`Validation SHA: ${fixture.targetSha}`);
expect(result.stdout).toContain(`Tooling SHA: ${fixture.workflowSha}`);
expect(result.stdout).toContain(
`Frozen validation tuple: candidate=${fixture.targetSha} tooling=${fixture.workflowSha} rerun_group=all`,
);
expect(result.stdout).toContain(
"Parent run: https://github.com/openclaw/openclaw/actions/runs/123",
);
@ -473,7 +598,8 @@ describe("full-release-validation-at-sha", () => {
it("rejects pinned old-schema tooling before either remote ref is pushed", () => {
const fixture = createDispatchFixture({
workflowSource: "name: Full Release Validation\non:\n workflow_dispatch:\n",
workflowSource:
'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n',
});
try {
const result = fixture.run(["--workflow-sha", fixture.workflowSha]);
@ -489,10 +615,48 @@ describe("full-release-validation-at-sha", () => {
}
});
it("rejects pinned pre-contract tooling before either remote ref is pushed", () => {
const fixture = createDispatchFixture();
try {
const result = fixture.run(["--workflow-sha", fixture.oldWorkflowSha]);
expect(result.status).toBe(1);
expect(result.stderr).toContain(`Tooling SHA ${fixture.oldWorkflowSha}`);
expect(result.stderr).toContain("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1");
expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual(
[],
);
expect(readFileSync(fixture.ghCallsPath, "utf8")).toBe("");
} finally {
fixture.cleanup();
}
});
it("rejects an arbitrary older release-branch ancestor with the wrong package version", () => {
const fixture = createDispatchFixture();
try {
const result = fixture.run([
"--sha",
fixture.oldWorkflowSha,
"--workflow-sha",
fixture.workflowSha,
]);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"Target package version 2026.7.9 does not belong to release branch release/2026.8.1; expected 2026.8.1 or a beta prerelease of it",
);
expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual(
[],
);
expect(readFileSync(fixture.ghCallsPath, "utf8")).toBe("");
} finally {
fixture.cleanup();
}
});
it("keeps both temporary refs with --keep-branch", () => {
const fixture = createDispatchFixture();
try {
const result = fixture.run(["--keep-branch"]);
const result = fixture.run(["--workflow-sha", fixture.workflowSha, "--keep-branch"]);
expect(result.status, result.stderr).toBe(0);
const gitCalls = fixture.readCalls(fixture.gitCallsPath);
expect(
@ -524,7 +688,15 @@ describe("full-release-validation-at-sha", () => {
const missingSha = "f".repeat(40);
const result = spawnSync(
process.execPath,
[SCRIPT_PATH, "--sha", missingSha, "--target-ref", fixture.releaseRef],
[
SCRIPT_PATH,
"--sha",
missingSha,
"--target-ref",
fixture.releaseRef,
"--workflow-sha",
fixture.workflowSha,
],
{
cwd: fixture.checkout,
encoding: "utf8",

View file

@ -83,17 +83,18 @@ function runIdentityVerification(params: {
oidcJobWorkflowSha?: string;
oidcWorkflowSha?: string;
targetContextRef?: string;
workflowBranch?: string;
workflowSha?: string;
}) {
const repository = "openclaw/openclaw";
const trustedWorkflowRef = `${repository}/.github/workflows/openclaw-release-telegram-qa.yml@refs/heads/main`;
const workflowBranch = params.workflowBranch ?? "main";
const workflowRefName = `refs/heads/${workflowBranch}`;
const trustedWorkflowRef = `${repository}/.github/workflows/openclaw-release-telegram-qa.yml@${workflowRefName}`;
const invocation = params.invocation ?? "dispatch";
const workflowRef =
invocation === "dispatch"
? trustedWorkflowRef
: `${repository}/.github/workflows/openclaw-release-checks.yml@refs/heads/release-ci/test`;
const workflowRefName =
invocation === "dispatch" ? "refs/heads/main" : "refs/heads/release-ci/test";
: `${repository}/.github/workflows/openclaw-release-checks.yml@${workflowRefName}`;
const workdir = tempDirs.make("openclaw-telegram-identity-");
const fakeBin = join(workdir, "bin");
const githubOutput = join(workdir, "github-output");
@ -128,7 +129,7 @@ function runIdentityVerification(params: {
);
return spawnSync(
"bash",
["-c", requireRun("trusted_identity", "Verify dispatched-main identity")],
["-c", requireRun("trusted_identity", "Verify dispatched workflow identity")],
{
cwd: workdir,
encoding: "utf8",
@ -371,7 +372,7 @@ describe("release Telegram QA workflow", () => {
"runs-on": "ubuntu-24.04",
"timeout-minutes": 5,
});
expect(step("trusted_identity", "Verify dispatched-main identity").id).toBe("identity");
expect(step("trusted_identity", "Verify dispatched workflow identity").id).toBe("identity");
const candidateBuild = requireRun(
"build_candidate",
@ -388,6 +389,14 @@ describe("release Telegram QA workflow", () => {
expect(requireRun("run_telegram", "Build trusted QA harness").trim()).toBe(
"pnpm build qaRuntime",
);
const extractCandidate = step("run_telegram", "Verify attestation and bounded extract");
expect(extractCandidate.env?.CALLED_WORKFLOW_REF).toBe(
"${{ needs.trusted_identity.outputs.workflow_ref }}",
);
expect(extractCandidate.run).toContain(
'--cert-identity "https://github.com/${CALLED_WORKFLOW_REF}"',
);
expect(extractCandidate.run).not.toContain("openclaw-release-telegram-qa.yml@refs/heads/main");
const runJob = job("run_telegram");
expect(runJob.environment).toBe("qa-live-shared");
@ -410,9 +419,28 @@ describe("release Telegram QA workflow", () => {
}
});
it("accepts only the resolved trusted workflow identity", () => {
it("routes every documented workflow ref through exact direct and reusable identity", () => {
const trustedSha = "b".repeat(40);
const releaseCiBranch = `release-ci/${trustedSha.slice(0, 12)}-1787215404735`;
for (const workflowBranch of [
"main",
"release/2026.7.1",
"extended-stable/2026.7.33",
releaseCiBranch,
]) {
for (const invocation of ["dispatch", "reusable"] as const) {
const result = runIdentityVerification({
expectedTrustedWorkflowSha: trustedSha,
invocation,
workflowBranch,
});
expect(result.status, `${workflowBranch}/${invocation}: ${result.stderr}`).toBe(0);
}
}
});
it("accepts only canonical exact-SHA workflow and target identities", () => {
const trustedSha = "b".repeat(40);
expect(runIdentityVerification({ expectedTrustedWorkflowSha: trustedSha }).status).toBe(0);
for (const targetContextRef of [
"release/2026.7.1",
"extended-stable/2026.7.33",
@ -438,6 +466,31 @@ describe("release Telegram QA workflow", () => {
oidcJobWorkflowSha: "c".repeat(40),
}).stderr,
).toContain("OIDC job_workflow_sha mismatch");
expect(
runIdentityVerification({
expectedTrustedWorkflowSha: trustedSha,
workflowBranch: "release-ci/not-canonical",
}).stderr,
).toContain("must be exact main, canonical release or extended-stable");
expect(
runIdentityVerification({
expectedTrustedWorkflowSha: trustedSha,
workflowBranch: `release-ci/${"c".repeat(12)}-1787215404735`,
}).stderr,
).toContain("release-ci ref does not match the authorized tooling SHA");
for (const workflowBranch of [
"release/2026.0.1",
"release/2026.07.1",
"extended-stable/2026.13.33",
"extended-stable/2026.7.32",
]) {
expect(
runIdentityVerification({
expectedTrustedWorkflowSha: trustedSha,
workflowBranch,
}).stderr,
).toContain("must be exact main, canonical release or extended-stable");
}
});
it("accepts trusted release provenance and rejects same-repository PR heads", () => {

View file

@ -176,6 +176,10 @@ type WorkflowJob = {
};
type Workflow = {
concurrency?: {
group?: string;
"cancel-in-progress"?: boolean | string;
};
env?: Record<string, string>;
jobs?: Record<string, WorkflowJob>;
on?: {
@ -276,12 +280,88 @@ function runFullReleaseInputValidation(releaseProfile: string, skipTelegram: str
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target"),
"Validate release inputs",
);
const workdir = tempDirs.make("full-release-input-validation-");
mkdirSync(resolve(workdir, "target"));
writeFileSync(resolve(workdir, "target", "package.json"), '{"version":"2026.8.1"}\n', "utf8");
return spawnSync("bash", ["-c", step.run ?? ""], {
cwd: workdir,
encoding: "utf8",
env: {
PATH: process.env.PATH,
RELEASE_PROFILE: releaseProfile,
SKIP_PACKAGE_TELEGRAM_E2E: skipTelegram,
TARGET_CONTEXT_REF: "",
TARGET_REF: "main",
},
});
}
function runFullReleaseTargetIdentityValidation(params: {
comparisonStatus?: string;
remoteSha?: string;
targetContextRef?: string;
targetRef: string;
version: string;
}) {
const step = workflowStep(
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target"),
"Validate release inputs",
);
const workdir = tempDirs.make("full-release-target-identity-");
const fakeBin = resolve(workdir, "bin");
mkdirSync(fakeBin);
mkdirSync(resolve(workdir, "target"));
writeFileSync(
resolve(workdir, "target", "package.json"),
`${JSON.stringify({ version: params.version })}\n`,
"utf8",
);
writeFileSync(
resolve(fakeBin, "git"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == *"ls-remote"* ]]; then
printf '%s\\t%s\\n' "$FAKE_REMOTE_SHA" "$FAKE_REMOTE_REF"
exit 0
fi
exit 64
`,
{ mode: 0o755 },
);
writeFileSync(
resolve(fakeBin, "gh"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == *"api repos/"*"/compare/"* ]]; then
printf '%s\\n' "$FAKE_COMPARISON_STATUS"
exit 0
fi
exit 64
`,
{ mode: 0o755 },
);
const targetSha = params.targetRef.match(/^[a-f0-9]{40}$/u)?.[0] ?? "a".repeat(40);
const normalizedContextRef = (params.targetContextRef ?? params.targetRef)
.replace(/^refs\/heads\//u, "")
.replace(/^refs\/tags\//u, "");
const remoteRef = normalizedContextRef.startsWith("v")
? `refs/tags/${normalizedContextRef}`
: `refs/heads/${normalizedContextRef}`;
return spawnSync("bash", ["-c", step.run ?? ""], {
cwd: workdir,
encoding: "utf8",
env: {
FAKE_COMPARISON_STATUS: params.comparisonStatus ?? "ahead",
FAKE_REMOTE_REF: remoteRef,
FAKE_REMOTE_SHA: params.remoteSha ?? targetSha,
GH_TOKEN: "test-token",
GITHUB_REPOSITORY: "openclaw/openclaw",
PATH: `${fakeBin}:${process.env.PATH}`,
RELEASE_PROFILE: "beta",
SKIP_PACKAGE_TELEGRAM_E2E: "false",
TARGET_CONTEXT_REF: params.targetContextRef ?? "",
TARGET_REF: params.targetRef,
TARGET_SHA: targetSha,
},
});
}
@ -2274,6 +2354,12 @@ describe("package acceptance workflow", () => {
expect(readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8")).toContain(
"format('NPM Telegram Beta E2E {0}', inputs.dispatch_id)",
);
expect(readWorkflow(PLUGIN_PRERELEASE_WORKFLOW).concurrency?.group).toBe(
"plugin-prerelease-${{ inputs.target_ref }}-${{ github.sha }}",
);
expect(readWorkflow(RELEASE_CHECKS_WORKFLOW).concurrency?.group).toBe(
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}",
);
});
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
@ -3763,6 +3849,73 @@ describe("package artifact reuse", () => {
expect(umbrella.status, umbrella.stderr).toBe(0);
});
it.each([
["release/2026.8.1", "2026.8.1"],
["release/2026.8.1", "2026.8.1-beta.3"],
["extended-stable/2026.7.33", "2026.7.33"],
["v2026.8.1", "2026.8.1"],
["v2026.8.1-alpha.2", "2026.8.1-alpha.2"],
["v2026.8.1-beta.3", "2026.8.1-beta.3"],
])("accepts direct Full Release Validation identity %s at package %s", (targetRef, version) => {
const result = runFullReleaseTargetIdentityValidation({ targetRef, version });
expect(result.status, result.stderr).toBe(0);
});
it.each([
["release/2026.8.1", "2026.8.2", "does not belong to release branch"],
["release/2026.8.1", "2026.8.1-alpha.2", "expected 2026.8.1 or a beta prerelease"],
["extended-stable/2026.7.33", "2026.7.33-beta.1", "does not match extended-stable branch"],
["v2026.8.1", "2026.8.1-beta.1", "does not match release tag"],
["v2026.8.1-alpha.2", "2026.8.1-alpha.3", "does not match release tag"],
])(
"rejects direct Full Release Validation identity %s at package %s",
(targetRef, version, error) => {
const result = runFullReleaseTargetIdentityValidation({ targetRef, version });
expect(result.status).toBe(1);
expect(result.stderr).toContain(error);
},
);
it("validates an exact-SHA helper target against its canonical release context", () => {
const accepted = runFullReleaseTargetIdentityValidation({
targetContextRef: "release/2026.8.1",
targetRef: "a".repeat(40),
version: "2026.8.1-beta.3",
});
const rejected = runFullReleaseTargetIdentityValidation({
targetContextRef: "release/2026.8.1",
targetRef: "a".repeat(40),
version: "2026.8.1-alpha.3",
});
expect(accepted.status, accepted.stderr).toBe(0);
expect(rejected.status).toBe(1);
expect(rejected.stderr).toContain("expected 2026.8.1 or a beta prerelease");
});
it("rejects exact-SHA release contexts outside the named branch or tag", () => {
const divergedBranch = runFullReleaseTargetIdentityValidation({
comparisonStatus: "diverged",
remoteSha: "b".repeat(40),
targetContextRef: "release/2026.8.1",
targetRef: "a".repeat(40),
version: "2026.8.1-beta.3",
});
const mismatchedTag = runFullReleaseTargetIdentityValidation({
remoteSha: "b".repeat(40),
targetContextRef: "v2026.8.1-alpha.2",
targetRef: "a".repeat(40),
version: "2026.8.1-alpha.2",
});
expect(divergedBranch.status).toBe(1);
expect(divergedBranch.stderr).toContain("is not reachable from release context branch");
expect(mismatchedTag.status).toBe(1);
expect(mismatchedTag.stderr).toContain("does not match release tag");
});
it.each(["stable", "full"])(
"preserves normal %s validation when Telegram deferral is false",
(releaseProfile) => {
@ -4800,12 +4953,18 @@ describe("package artifact reuse", () => {
const workflowInputs = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).on?.workflow_dispatch
?.inputs;
const resolveTargetJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target");
const resolveTargetSteps = resolveTargetJob.steps ?? [];
const evidenceReuseJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "evidence_reuse");
const releaseChecksJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "release_checks");
const npmTelegramJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "npm_telegram");
const performanceJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance");
const summaryJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "summary");
const targetSummaryStep = workflowStep(resolveTargetJob, "Summarize target");
const targetManifestCheckout = workflowStep(
resolveTargetJob,
"Checkout target package manifest",
);
const releaseInputValidation = workflowStep(resolveTargetJob, "Validate release inputs");
const evidenceReuseStep = workflowStep(evidenceReuseJob, "Find reusable validation evidence");
const releaseChecksDispatchStep = workflowStep(
releaseChecksJob,
@ -4823,6 +4982,24 @@ describe("package artifact reuse", () => {
});
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
expect(targetManifestCheckout.with).toMatchObject({
ref: "${{ steps.resolve.outputs.sha }}",
path: "target",
"sparse-checkout": "package.json",
"sparse-checkout-cone-mode": false,
"persist-credentials": false,
});
expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan(
resolveTargetSteps.indexOf(releaseInputValidation),
);
expectTextToIncludeAll(releaseInputValidation.run, [
'target_version="$(jq -er',
"does not belong to release branch",
"does not match ${identity_kind}",
"is not reachable from release context branch",
"does not match release tag",
"target_context_ref must be a canonical OpenClaw release branch or tag.",
]);
expect(npmTelegramJob.name).toBe("Run package Telegram E2E");
expect(npmTelegramJob.needs).toEqual(["resolve_target", "evidence_reuse"]);
expect(npmTelegramJob["timeout-minutes"]).toBe(
@ -4853,6 +5030,7 @@ describe("package artifact reuse", () => {
});
expectTextToIncludeAll(targetSummaryStep.run, [
"Validation SHA:",
"Frozen tuple:",
"Package Acceptance Telegram E2E deferred:",
"Package Telegram E2E: deferred by \\`skip_package_telegram_e2e\\`",
]);
@ -4901,6 +5079,7 @@ describe("package artifact reuse", () => {
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
"The dispatch was not retried to avoid creating a duplicate child.",
'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then',
"Still waiting on ${workflow} after ${elapsed_minutes}m:",
'-f harness_ref="$TARGET_SHA"',
'args=(-f package_spec="$PACKAGE_SPEC"',
'args+=(-f scenario="$SCENARIO")',
@ -5539,13 +5718,17 @@ describe("package artifact reuse", () => {
const telegramDispatch = workflowStep(telegramCaller, "Dispatch and await trusted Telegram QA");
expect(telegramDispatch.run).toContain('workflow="openclaw-release-telegram-qa.yml"');
expect(telegramDispatch.run).toContain('--repo "$GITHUB_REPOSITORY"');
expect(telegramDispatch.run).toContain("--ref main");
expect(telegramDispatch.env).toMatchObject({
PARENT_WORKFLOW_REF: "${{ github.ref_name }}",
PARENT_WORKFLOW_SHA: "${{ github.sha }}",
});
expect(telegramDispatch.run).toContain('--ref "$PARENT_WORKFLOW_REF"');
expect(telegramDispatch.run).toContain(
'-f expected_trusted_workflow_sha="$expected_trusted_workflow_sha"',
);
expect(telegramDispatch.run).toContain(
'[[ "$child_head_sha" == "$expected_trusted_workflow_sha" ]]',
'-f expected_trusted_workflow_sha="$PARENT_WORKFLOW_SHA"',
);
expect(telegramDispatch.run).toContain('[[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]');
expect(telegramDispatch.run).not.toContain("commits/main");
expect(telegramDispatch.run).not.toContain("dispatch_attempt");
expect(telegramCaller["continue-on-error"]).toBeUndefined();
expect(telegramCaller["timeout-minutes"]).toBe(210);
@ -6904,6 +7087,10 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
'VALIDATION_SHA="<full-commit-sha>"',
'-f ref="$VALIDATION_SHA"',
'-f expected_sha="$VALIDATION_SHA"',
'TOOLING_SHA="<recorded-full-main-ancestor-sha>"',
'VALIDATION_SHA="<full-release-candidate-sha>"',
"--target-ref release/YYYY.M.PATCH",
'--workflow-sha "$TOOLING_SHA"',
]);
for (const text of [releaseCi, releaseCiNotes, testing, parallels, ciDocs, maintainer]) {
expect(text).toContain("Validation SHA + Tooling SHA");

View file

@ -846,7 +846,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(releaseChecksWorkflow.concurrency).toEqual({
group:
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}",
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}",
"cancel-in-progress": "${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}",
});
expect(fullReleaseWorkflow.concurrency).toEqual({