mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 22:36:41 +00:00
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:
parent
a59abcf4a8
commit
c28c279afa
17 changed files with 851 additions and 156 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
87
.github/workflows/full-release-validation.yml
vendored
87
.github/workflows/full-release-validation.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
69
.github/workflows/openclaw-release-checks.yml
vendored
69
.github/workflows/openclaw-release-checks.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
2
.github/workflows/plugin-prerelease.yml
vendored
2
.github/workflows/plugin-prerelease.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
12
docs/ci.md
12
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=<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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue