diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index 647aa606dc2..ac32b916826 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -310,9 +310,11 @@ package with `run_release_soak=true` or explicit focused groups. Stable-publish uses `release_profile=stable`. ```bash +TOOLING_SHA="" node scripts/full-release-validation-at-sha.mjs \ --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 diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 9d181b6977b..60ac35a595b 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -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="" node scripts/full-release-validation-at-sha.mjs \ --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 --target-ref -release/YYYY.M.PATCH`. Its canonical `release-ci/*` ref keeps evidence reuse +release/YYYY.M.PATCH --workflow-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)" diff --git a/.agents/skills/release-openclaw-ci/references/release-ci-notes.md b/.agents/skills/release-openclaw-ci/references/release-ci-notes.md index ffd8716a26d..0f2838aaf00 100644 --- a/.agents/skills/release-openclaw-ci/references/release-ci-notes.md +++ b/.agents/skills/release-openclaw-ci/references/release-ci-notes.md @@ -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. diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index f69fc17ce6b..97ca58940d2 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -1028,7 +1028,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts `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 --target-ref release/YYYY.M.PATCH`. + `node scripts/full-release-validation-at-sha.mjs --sha --target-ref release/YYYY.M.PATCH --workflow-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 diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 9bffba1bad3..f1176cd3b3a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -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 diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 8ab17c1136f..a5f29088b73 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -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 diff --git a/.github/workflows/openclaw-release-telegram-qa.yml b/.github/workflows/openclaw-release-telegram-qa.yml index 3179ef4e543..f8427bec39d 100644 --- a/.github/workflows/openclaw-release-telegram-qa.yml +++ b/.github/workflows/openclaw-release-telegram-qa.yml @@ -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" \ diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index e9848effca2..c95a9919eee 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -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: diff --git a/docs/ci.md b/docs/ci.md index 741201284cd..5d6c66795db 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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=`: ```bash -pnpm ci:full-release --sha +TOOLING_SHA="" +VALIDATION_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/-...` 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 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index abc9231518e..acc5358d940 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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 --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 --target-ref release/YYYY.M.PATCH --workflow-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="" node scripts/full-release-validation-at-sha.mjs \ --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="" pnpm ci:full-release \ --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/-...` 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 ` 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/-...` 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="" pnpm ci:full-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=` dispatches for release proof; use `pnpm ci:full-release --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=` dispatches for release proof; use `pnpm ci:full-release --sha --target-ref release/YYYY.M.PATCH --workflow-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="" + # Validate the product-complete Code SHA. pnpm ci:full-release \ --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 \ - --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 \ --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 \ diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 7ab079170d1..f58c24a155a 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -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="" pnpm ci:full-release \ --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 ` 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 diff --git a/scripts/full-release-validation-at-sha.mts b/scripts/full-release-validation-at-sha.mts index f37a20aa31e..db5e50b6340 100644 --- a/scripts/full-release-validation-at-sha.mts +++ b/scripts/full-release-validation-at-sha.mts @@ -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, "") : "", + 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 | 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}`); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 3d435c14f32..2971f31aa4a 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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( diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index c029eb7123f..d53906ff87d 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -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", diff --git a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts index a44b2261ae3..8d675e54b62 100644 --- a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts +++ b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts @@ -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", () => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 0f53b895a6a..9f448ace831 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -176,6 +176,10 @@ type WorkflowJob = { }; type Workflow = { + concurrency?: { + group?: string; + "cancel-in-progress"?: boolean | string; + }; env?: Record; jobs?: Record; 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=""', '-f ref="$VALIDATION_SHA"', '-f expected_sha="$VALIDATION_SHA"', + 'TOOLING_SHA=""', + 'VALIDATION_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"); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 71300fb7f6b..5fa5d459938 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -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({