diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index 9e5bd6a167a0..dccf904ef6c0 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -235,9 +235,9 @@ every human `Thanks @...` attribution. --release-tag v \ --check-github ``` -- add one `--release-tag` for every beta and stable page in the train; a - `### Release verification` tail is permitted, but any other body drift - fails the check +- add one `--release-tag` for every beta, stable, and extended-stable page in + the train; a `### Release verification` tail is permitted, but any other + body drift fails the check - `scripts/render-github-release-notes.mts` is the canonical release-body renderer used by candidate validation, publish, and verification. When the complete `## YYYY.M.PATCH` section fits GitHub's 125,000-character limit and @@ -269,11 +269,13 @@ every human `Thanks @...` attribution. ## Extended-Stable Variant -Extended-stable has one release commit and no GitHub Release body. After version -prep and approved backports, regenerate `## YYYY.M.P` with the regular manifest -and original-main-PR provenance rules. Land it by PR, then validate the final -branch tip before tagging. Re-audit after a product backport; a tooling-only -repair needs no changelog entry. Never rewrite a published tag or changelog. +Extended-stable has one release commit and one canonical GitHub Release body. +After version prep and approved backports, regenerate `## YYYY.M.P` with the +regular manifest and original-main-PR provenance rules. Land it by PR, then +validate the final branch tip before tagging. The Docker closeout renders that +tag-owned section into a non-Latest release page. Re-audit after a product +backport; a tooling-only repair needs no changelog entry. Never rewrite a +published tag or changelog. ## Quota / API Outage Rule diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index ac32b9168266..e5877d51dcaa 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -317,11 +317,11 @@ node scripts/full-release-validation-at-sha.mjs \ --workflow-sha "$TOOLING_SHA" ``` -That helper is for regular releases. Extended-stable dispatches Full Release -Validation directly from and against `extended-stable/YYYY.M.33` with -`release_profile=stable`; its exact branch-tip evidence is fresh and cannot be -replaced by a `release-ci/*` run. Use `$release-openclaw-ci` for its failure -classification and run-identity rules. +Extended-stable may use the helper's trusted main-pinned `release-ci/*` harness +or a direct run from `extended-stable/YYYY.M.33` with +`release_profile=stable`. In either case, require a complete v3 manifest that +binds the exact canonical branch tip, workflow SHA, and run attempt. Use +`$release-openclaw-ci` for failure classification and identity rules. 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 diff --git a/.agents/skills/release-openclaw-announcement/SKILL.md b/.agents/skills/release-openclaw-announcement/SKILL.md index 2f7d05a20054..7b9573760116 100644 --- a/.agents/skills/release-openclaw-announcement/SKILL.md +++ b/.agents/skills/release-openclaw-announcement/SKILL.md @@ -13,7 +13,8 @@ user. Before drafting focus areas, read real release evidence: -1. GitHub release body, or the immutable tag and publish run for extended-stable. +1. GitHub release body and immutable tag; for extended-stable, also confirm the + npm/container-only scope and non-Latest classification. 2. `CHANGELOG.md` section for the released base version. 3. Commits since the previous shipped version or the operator-specified base. 4. Registry/package metadata for the exact version and current dist-tag. @@ -79,8 +80,8 @@ openclaw --version Do not add `--yes`: users moving from newer regular stable must see the downgrade warning because older versions may not understand newer configuration. Link the -tag or changelog; do not imply a GitHub Release or inherit regular stable -macOS, Windows, ClawHub, `latest`, or website claims. +GitHub Release, but do not inherit regular stable macOS, Windows, ClawHub, +`latest`, or website claims. ## Style diff --git a/.agents/skills/release-openclaw-announcement/agents/openai.yaml b/.agents/skills/release-openclaw-announcement/agents/openai.yaml index 882bc8d0c5f7..ed934efbbf68 100644 --- a/.agents/skills/release-openclaw-announcement/agents/openai.yaml +++ b/.agents/skills/release-openclaw-announcement/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "OpenClaw Release Announcement" - short_description: "Draft Discord beta/stable release announcements from evidence." - default_prompt: "Use this skill to draft an OpenClaw beta or stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence." + short_description: "Draft Discord beta/stable/extended-stable release announcements from evidence." + default_prompt: "Use this skill to draft an OpenClaw beta, stable, or extended-stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence." diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 60ac35a595b1..c61fc0d7e66c 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -214,9 +214,9 @@ publish workflow reads the effective profile from the full-validation manifest. ### Extended-stable validation -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: +For `.33+`, prefer the trusted main-pinned helper so current workflow tooling +targets the immutable canonical branch tip. A direct canonical-branch run is +also valid: ```bash RELEASE_SHA="$(git rev-parse HEAD)" @@ -227,10 +227,12 @@ gh workflow run full-release-validation.yml \ -f release_profile=stable ``` -Accept only a complete `rerun_group=all` run whose branch, head/target SHAs, -manifest `workflowRef`, and package versions identify the same commit. Save its -successful `run_attempt` and require the final tag to resolve there. Reject -`release-ci/*`, current-main, narrow, and earlier-attempt evidence. +Accept only a complete `rerun_group=all` run from the canonical branch, from +current `main` whose workflow SHA remains reachable from `main`, or from the +trusted main-pinned `release-ci/*` harness with a v3 exact-target manifest. +Require the head/target SHA, manifest identity, package versions, saved +`run_attempt`, and final tag to identify the same commit. Reject narrow, +untrusted-main, mismatched-target, and earlier-attempt evidence. Product failures need an approved backport. Frozen-target tooling failures need the smallest behavior-preserving repair. Provider, approval, runner, or log diff --git a/.agents/skills/release-openclaw-mac/SKILL.md b/.agents/skills/release-openclaw-mac/SKILL.md index 424f2b4ae9ea..0d1e589e8d80 100644 --- a/.agents/skills/release-openclaw-mac/SKILL.md +++ b/.agents/skills/release-openclaw-mac/SKILL.md @@ -8,8 +8,8 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved. This is a regular stable-release skill. Do not invoke it for extended-stable; -that track does not inherit macOS assets, appcast promotion, or a GitHub Release -unless the current extended-stable release policy explicitly adds them. +that track's notes-only GitHub Release does not inherit macOS assets or appcast +promotion. ## Credentials diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 97ca58940d20..6c08dff775b2 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -303,8 +303,12 @@ on pinned current `main` as the exact command and validation contract. digest and refuse automatic rollback. For alias repair, dispatch the approval-gated `docker-channel-promote.yml` from current `main` with the exact tag; never rebuild or move the release tag. -10. Do not create a GitHub Release or publish macOS, Windows, mobile, website, - ClawHub, or private dist-tag artifacts from this path. +10. Dispatch the Docker-only `OpenClaw Release Publish` closeout. It must + reverify the exact npm selector and tarball, prepare a canonical + non-prerelease GitHub Release draft with `latest=false`, publish Docker, + and only then make the release public. Do not publish macOS, Windows, + mobile, website, ClawHub, npm `latest`, or private dist-tag artifacts from + this path. ## Keep release channel naming aligned @@ -512,8 +516,8 @@ HEAD/worktree-bound manifest under git metadata for cutover review. body tail. Do not discard a fitting full contribution record to make room for proof. - Before publishing or closing a release, run - `$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable - and beta release tag in the train. Do not publish or leave a page live when + `$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable, + beta, and extended-stable release tag in the train. Do not publish or leave a page live when it is missing a source-history reference, eligible human credit, or the complete matching changelog body. - Treat the selected `--base` as a strict history boundary: it must be an diff --git a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md index 48622f856ee8..1443b29dbfce 100644 --- a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md @@ -24,8 +24,9 @@ extended-stable package and publication constraints. - Carry the complete current-main Docker release-channel unit in the tagged tree: workflow, promoter, policy, shared release-version classifier, tests, and workflow validation. GitHub evaluates tag-push workflows from that tree. -- Exclude ClawHub publication, GitHub Releases, the macOS app, Windows Hub, - mobile apps, website downloads, and private-repository dist-tags. +- Exclude ClawHub publication, GitHub Release assets, the macOS app, Windows + Hub, mobile apps, website downloads, npm `latest`, and private-repository + dist-tags. The Docker closeout creates a notes-only GitHub Release page. - Review the complete mainline delta using the shared evidence-driven audit. Do not stop after the first obvious fixes or consider public PRs, titles, or dependency bumps the complete source set. @@ -161,7 +162,8 @@ fi ``` Do not use GitHub's latest nonprerelease Release as the source of truth. The -extended-stable lane intentionally creates no GitHub Release. In bootstrap +npm `extended-stable` selector remains authoritative for the active line; its +notes-only GitHub Release is always created with `latest=false`. In bootstrap mode, record the approving maintainer and approved base commit. Stop before discovery or mutation if npm, the canonical branch, tags, package versions, approved base, or protected `main` disagree. @@ -238,6 +240,8 @@ path alone. out of scope. - Treat macOS-app-only, Windows-Hub-only, mobile-only, website-only, and GitHub Release-only fixes as `skip` for this Gateway extended-stable line. +- Keep GitHub Release automation repairs in trusted current-main release + tooling; they are not product backports for the maintenance branch. - Treat cross-repository or package-topology uncertainty as `blocked` until the shipped npm surface and release owner are proven. @@ -369,8 +373,9 @@ Report: harness compatibility repair, and superseded validation runs; - remaining security, release, or maintainer approvals; - the coordinated PR URL or why no PR was opened; -- exact intended Docker images and aliases, plus explicit confirmation that no - other non-npm publication is planned. +- exact intended Docker images and aliases, plus explicit confirmation that + the notes-only, non-Latest GitHub Release is the only other publication + planned. Then follow the parent skill's publish and recovery sequence. Keep exact branch/tag/package/run identity, never republish for selector repair, and move diff --git a/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md b/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md index 0e66311256c9..acf35eadb586 100644 --- a/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md +++ b/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md @@ -33,6 +33,7 @@ operator steering. Do not preserve superseded scope. - Plugin NPM Release: `` - publish parent: `` - Docker release/repair: `` +- GitHub Release: `` - immutable successful children: `` - registry/provenance readback: `` diff --git a/.agents/skills/release-openclaw-plugin-testing/SKILL.md b/.agents/skills/release-openclaw-plugin-testing/SKILL.md index e65455095aa6..a8aabfda22b7 100644 --- a/.agents/skills/release-openclaw-plugin-testing/SKILL.md +++ b/.agents/skills/release-openclaw-plugin-testing/SKILL.md @@ -121,8 +121,10 @@ Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep `workflow_ref` as trusted current harness code unless the release process says otherwise. -For extended-stable, branch-owned Full Release Validation is publication -evidence; Package Acceptance is a post-publish selector smoke: +For extended-stable, complete exact-target Full Release Validation from the +canonical branch, trusted reachable current-main tooling, or the main-pinned +`release-ci/*` harness is publication evidence; Package Acceptance is a +post-publish selector smoke: ```bash gh workflow run package-acceptance.yml \ diff --git a/.agents/skills/verify-release/SKILL.md b/.agents/skills/verify-release/SKILL.md index 3dc568e1c80d..14a3cce2610e 100644 --- a/.agents/skills/verify-release/SKILL.md +++ b/.agents/skills/verify-release/SKILL.md @@ -14,8 +14,9 @@ publish skill; use `$release-openclaw-maintainer` before changing release state. - Resolve short suffixes like `.27` to the concrete CalVer version from the current date/context, then say the resolved version. - Resolve the track first. Regular beta/stable uses a GitHub Release and the - platform graph; extended-stable uses its canonical branch, npm selector, and - Gateway surfaces. Do not require one track's artifacts from the other. + platform graph; extended-stable uses a notes-only GitHub Release, its + canonical branch, npm selector, and Gateway surfaces. Do not require one + track's artifacts from the other. - Verify live state. Do not trust local checkout state, release notes, or old memory as current truth. - If the checkout is dirty or divergent, use it only for scripts/reference. @@ -76,13 +77,15 @@ Use these checks only for the regular orchestrated release track. ## Extended-stable checks -Extended-stable has no GitHub Release ledger. Verify live tag, workflow, -registry, provenance, and image state directly. +Extended-stable has a notes-only GitHub Release ledger. Verify it alongside +the live tag, workflow, registry, provenance, and image state. 1. **Identity:** require final `v` at patch `33+`, with no suffix, contained in `extended-stable/YYYY.M.33`. Only an active candidate must equal the tip. Root and every publishable official plugin must declare ``. - Require the Git tag and no GitHub Release. + Require the Git tag and a public, non-prerelease GitHub Release whose title + and canonical body match the tag. Require `isLatest=false` and no native or + platform assets. 2. **Workflow chain:** find successful preflight, complete validation, plugin npm, and core publish runs on the canonical branch and SHA. Validation must use `rerun_group=all`, `release_profile=stable`, blocking soak/performance, @@ -103,7 +106,8 @@ Promotion` for the exact tag, without rebuilding. 6. **Recovery:** never republish. Use the generated command only for the root selector and approved credential-isolated tooling for others, then repeat complete readback. Do not require ClawHub, native/mobile apps, website, - private dist-tags, regular `latest`, or a GitHub Release. + private dist-tags, or regular `latest`. Require the notes-only GitHub + Release, but do not require regular-release assets or platform evidence. ## Shared live smoke diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b6b03c8ff462..c2c60aa065a7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -62,8 +62,9 @@ /docs/reference/secretref-credential-surface.md @openclaw/openclaw-secops /docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/openclaw-secops -# Release workflow and its supporting release-path checks. +# Release workflows and their supporting release-path checks. /.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers +/.github/workflows/openclaw-release-publish.yml @openclaw/openclaw-release-managers /docs/reference/RELEASING.md @openclaw/openclaw-release-managers /scripts/openclaw-npm-publish.sh @openclaw/openclaw-release-managers /scripts/openclaw-npm-release-check.ts @openclaw/openclaw-release-managers diff --git a/.github/workflows/control-ui-locale-refresh.yml b/.github/workflows/control-ui-locale-refresh.yml index ff76be30a5fd..e02a04e874db 100644 --- a/.github/workflows/control-ui-locale-refresh.yml +++ b/.github/workflows/control-ui-locale-refresh.yml @@ -46,8 +46,50 @@ concurrency: cancel-in-progress: false jobs: + release_policy: + name: Classify release trigger + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.release.outputs.should_run }} + steps: + - name: Download trusted release classifier + if: ${{ github.event_name == 'release' }} + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs" + + - name: Classify release track + id: release + env: + CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || '' }} + run: | + if [[ "${EVENT_NAME}" != "release" ]]; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + node --input-type=module <<'NODE' + import { appendFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { classifyReleaseTrain, parseReleaseVersion } = await import( + pathToFileURL(process.env.CLASSIFIER_FILE).href + ); + + const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, "")); + const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed); + appendFileSync(process.env.GITHUB_OUTPUT, `should_run=${track !== "extended-stable"}\n`); + NODE + resolve-base: + needs: release_policy if: >- + needs.release_policy.outputs.should_run == 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') runs-on: ubuntu-latest diff --git a/.github/workflows/docs-translate-trigger-release.yml b/.github/workflows/docs-translate-trigger-release.yml index 6201ae6f813d..82071c0684a8 100644 --- a/.github/workflows/docs-translate-trigger-release.yml +++ b/.github/workflows/docs-translate-trigger-release.yml @@ -12,7 +12,36 @@ jobs: dispatch-translate: runs-on: ubuntu-latest steps: + - name: Download trusted release classifier + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs" + + - name: Classify release track + id: release + env: + CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + node --input-type=module <<'NODE' + import { appendFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { classifyReleaseTrain, parseReleaseVersion } = await import( + pathToFileURL(process.env.CLASSIFIER_FILE).href + ); + + const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, "")); + const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed); + appendFileSync(process.env.GITHUB_OUTPUT, `track=${track}\n`); + NODE + - name: Trigger translation coordinator in publish repo + if: ${{ steps.release.outputs.track != 'extended-stable' }} env: GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }} RELEASE_TAG: ${{ github.event.release.tag_name }} diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 43453da22484..11dacfa07cbf 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -2469,13 +2469,17 @@ jobs: path: ${{ runner.temp }}/openclaw-release-postpublish-evidence if-no-files-found: error - verify_core_npm_registry: - name: Verify already-published core npm package + prepare_extended_stable_release: + name: Verify npm and prepare extended-stable release needs: [resolve_release_target] if: ${{ inputs.publish_docker_only }} runs-on: ubuntu-latest + environment: npm-release permissions: - contents: read + contents: write + outputs: + release_id: ${{ steps.release.outputs.release_id }} + release_body_sha256: ${{ steps.release.outputs.release_body_sha256 }} steps: - name: Verify exact npm and selector readback matches preflight bytes env: @@ -2501,10 +2505,175 @@ jobs: exit 1 fi + - name: Checkout release SHA + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve_release_target.outputs.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Checkout trusted release tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + path: .release-harness + fetch-depth: 1 + persist-credentials: false + + - name: Create or resume the canonical draft release + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} + run: | + set -euo pipefail + release_version="${RELEASE_TAG#v}" + release_title="openclaw ${release_version}" + notes_file="${RUNNER_TEMP}/extended-stable-release-notes.md" + verification_file="${RUNNER_TEMP}/extended-stable-release-verification.md" + + verify_release_tag_target() { + local direct_sha peeled_sha remote_refs remote_sha + remote_refs="$(git ls-remote --tags origin \ + "refs/tags/${RELEASE_TAG}" \ + "refs/tags/${RELEASE_TAG}^{}")" + direct_sha="$(printf '%s\n' "${remote_refs}" | + awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')" + peeled_sha="$(printf '%s\n' "${remote_refs}" | + awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')" + remote_sha="${peeled_sha:-${direct_sha}}" + if [[ -z "${remote_sha}" || "${remote_sha}" != "${TARGET_SHA}" ]]; then + echo "Release tag ${RELEASE_TAG} must still resolve to ${TARGET_SHA}; found ${remote_sha:-}." >&2 + exit 1 + fi + } + + resolve_release_id() { + gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId --jq '.databaseId' 2>/dev/null || true + } + + wait_for_release_id() { + local attempt release_id + for attempt in $(seq 1 12); do + release_id="$(resolve_release_id)" + if [[ "${release_id}" =~ ^[1-9][0-9]*$ ]]; then + printf '%s\n' "${release_id}" + return 0 + fi + if [[ "${attempt}" != "12" ]]; then + sleep 5 + fi + done + return 1 + } + + verify_release_resource() { + local release_id="$1" + local expected_draft="${2:-}" + local body_file="${RUNNER_TEMP}/extended-stable-existing-release.md" + local resource_file="${RUNNER_TEMP}/extended-stable-release.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${resource_file}" + jq -r '.body // ""' "${resource_file}" > "${body_file}" + RELEASE_BODY_FILE="${body_file}" \ + RELEASE_CHANGELOG_FILE="${GITHUB_WORKSPACE}/CHANGELOG.md" \ + RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \ + RELEASE_TAG="${RELEASE_TAG}" \ + RELEASE_TITLE="${release_title}" \ + RELEASE_RESOURCE_FILE="${resource_file}" \ + EXPECTED_DRAFT="${expected_draft}" \ + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { + releaseNotesVersionForTag, + verifyGithubReleaseNotes, + } from "./.release-harness/scripts/render-github-release-notes.mjs"; + + const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8")); + const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8"); + const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8"); + const expectedDraft = process.env.EXPECTED_DRAFT; + const notes = verifyGithubReleaseNotes({ + body, + changelog, + version: releaseNotesVersionForTag(process.env.RELEASE_TAG), + tag: process.env.RELEASE_TAG, + repository: process.env.RELEASE_REPOSITORY, + }); + if ( + release.tag_name !== process.env.RELEASE_TAG || + release.name !== process.env.RELEASE_TITLE || + release.prerelease !== false || + (expectedDraft !== "" && release.draft !== (expectedDraft === "true")) || + !notes.matches + ) { + console.error("Existing GitHub release does not match the canonical extended-stable contract."); + process.exitCode = 1; + } + NODE + } + + verify_release_tag_target + cat > "${verification_file}" <&2 + exit 1 + } + verify_release_resource "${release_id}" + fi + release_body_sha256="$( + gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" | + jq -j '.body // ""' | + sha256sum | + awk '{print $1}' + )" + echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" + echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT" + echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" + publish_docker: name: Publish Docker images - needs: [resolve_release_target, publish, verify_core_npm_registry] - if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.verify_core_npm_registry.result == 'success')) }} + needs: [resolve_release_target, publish, prepare_extended_stable_release] + if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success')) }} uses: ./.github/workflows/docker-release.yml with: tag: ${{ inputs.tag }} @@ -2556,3 +2725,89 @@ jobs: echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2 exit 1 fi + + finalize_extended_stable_github_release: + name: Finalize extended-stable GitHub release + needs: [resolve_release_target, prepare_extended_stable_release, publish_docker] + if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.publish_docker.result == 'success' }} + runs-on: ubuntu-latest + environment: npm-release + permissions: + contents: write + steps: + - name: Publish the verified extended-stable draft + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_BODY_SHA256: ${{ needs.prepare_extended_stable_release.outputs.release_body_sha256 }} + RELEASE_ID: ${{ needs.prepare_extended_stable_release.outputs.release_id }} + RELEASE_TAG: ${{ inputs.tag }} + TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_ID}" =~ ^[1-9][0-9]*$ ]]; then + echo "Extended-stable finalization requires a numeric GitHub release ID." >&2 + exit 1 + fi + if [[ ! "${EXPECTED_BODY_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Extended-stable finalization requires a canonical release body digest." >&2 + exit 1 + fi + release_version="${RELEASE_TAG#v}" + release_title="openclaw ${release_version}" + resource_file="${RUNNER_TEMP}/extended-stable-final-release.json" + + verify_release_state() { + local expected_draft="$1" + gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > "${resource_file}" + RELEASE_RESOURCE_FILE="${resource_file}" \ + RELEASE_TAG="${RELEASE_TAG}" \ + RELEASE_TITLE="${release_title}" \ + EXPECTED_BODY_SHA256="${EXPECTED_BODY_SHA256}" \ + EXPECTED_DRAFT="${expected_draft}" \ + node --input-type=module <<'NODE' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + + const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8")); + const bodySha256 = createHash("sha256").update(release.body ?? "").digest("hex"); + if ( + release.tag_name !== process.env.RELEASE_TAG || + release.name !== process.env.RELEASE_TITLE || + release.prerelease !== false || + release.draft !== (process.env.EXPECTED_DRAFT === "true") || + bodySha256 !== process.env.EXPECTED_BODY_SHA256 + ) { + console.error("GitHub release state does not match the extended-stable contract."); + process.exitCode = 1; + } + NODE + } + + current_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.draft')" + if [[ "${current_draft}" == "true" ]]; then + verify_release_state true + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=false \ + -f make_latest=false >/dev/null + elif [[ "${current_draft}" != "false" ]]; then + echo "GitHub release ${RELEASE_ID} has an invalid draft state." >&2 + exit 1 + fi + + for attempt in $(seq 1 12); do + if verify_release_state false; then + break + fi + if [[ "${attempt}" == "12" ]]; then + echo "GitHub release ${RELEASE_ID} did not reach its public state." >&2 + exit 1 + fi + sleep 5 + done + latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')" + if [[ "${latest_tag}" == "${RELEASE_TAG}" ]]; then + echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2 + exit 1 + fi + echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/ci.md b/docs/ci.md index 5d6c66795dbf..9b9f8b6f2874 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -186,11 +186,13 @@ gh workflow run full-release-validation.yml --ref main \ Gateway extended-stable runs npm preflight, Full Release Validation, and plugin npm release from `extended-stable/YYYY.M.33`; core publish consumes those three -run IDs plus the validation attempt. `release-ci/*` evidence is invalid because -publish binds every run to the canonical branch and release SHA. The tag -publishes Gateway images and only the `extended-stable*` aliases; the path skips -the regular orchestrator and its ClawHub, native-app, GitHub Release, website, -and private dist-tag surfaces. See [Monthly Gateway extended-stable +run IDs plus the validation attempt. Complete evidence may come from the +canonical branch, reachable current-main tooling, or the trusted main-pinned +`release-ci/*` harness when its v3 manifest binds the exact target and attempt. +The post-npm `OpenClaw Release Publish` closeout publishes Gateway images and +only the `extended-stable*` aliases, then publishes the notes-only GitHub +Release with `latest=false`; it skips ClawHub, native-app, website, and private +dist-tag surfaces. See [Monthly Gateway extended-stable publication](/reference/RELEASING#monthly-gateway-extended-stable-publication) for commands and recovery. @@ -378,6 +380,12 @@ Focused plugin-only repairs use `plugin_publish_scope=selected` with a nonempty package list. Plugin-only `all-publishable` runs require the same immutable npm preflight and Full Release Validation evidence as a core publish. +Extended-stable uses this workflow only after npm publication, with +`publish_openclaw_npm=false` and `publish_docker_only=true`. That closeout +rechecks immutable npm evidence, publishes Docker, and finalizes the notes-only +non-Latest GitHub Release without entering plugin, ClawHub, or native-app +publication. + ```bash gh workflow run openclaw-release-publish.yml \ --ref main \ diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index acc5358d9400..e3302289222b 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -17,7 +17,8 @@ OpenClaw exposes four user-facing update channels: - dev: the moving head of `main` Extended-stable ships the trailing month's Gateway, official npm plugins, and -Docker images without moving regular `latest` or `main` selectors. +Docker images without moving regular `latest` or `main` selectors. Each release +also has a notes-only GitHub Release page that is never marked Latest. Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `alpha`), covered under [NPM workflow inputs](#npm-workflow-inputs) and [Release test boxes](#release-test-boxes). @@ -92,7 +93,9 @@ gh workflow run full-release-validation.yml \ The SHA form is preflight-only. Run validation on the canonical branch; publish binds its workflow ref, head/target SHA, run ID, and attempt. Save both IDs and -the successful `run_attempt`; reject `release-ci/*` evidence. +the successful `run_attempt`. Accept a direct canonical-branch run, a direct +current-`main` run whose workflow SHA remains reachable from `main`, or the +trusted main-pinned `release-ci/*` harness; reject narrow reruns. Classify failures before editing: @@ -199,9 +202,11 @@ digest; regular aliases remain unchanged and automatic rollback is rejected. After that core registry readback succeeds, start Docker publication only through `OpenClaw Release Publish`. Its Docker-only extended-stable path rechecks the saved npm preflight artifact, exact `Full Release Validation` evidence, exact npm -version and `extended-stable` selector, and published tarball digest before it -calls the reusable `Docker Release` workflow. A tag push never publishes Docker -images by itself: +version and `extended-stable` selector, and published tarball digest. It then +creates or resumes a canonical non-prerelease GitHub Release draft with +`latest=false`, calls the reusable `Docker Release` workflow, and makes the +draft public only after Docker succeeds. A tag push never publishes Docker +images or a release page by itself: ```bash gh workflow run openclaw-release-publish.yml \ @@ -221,8 +226,9 @@ an explicit rollback, and never rebuilds images. Slack, Discord, and Codex are the initial documented support surfaces, not a release allowlist: every npm-publishable official plugin ships. The regular -checklist alone owns beta/`latest`, GitHub Releases, ClawHub, native apps, mobile, -website, and private dist-tags; do not run those steps for this Gateway path. +checklist alone owns beta/`latest`, GitHub Release assets, ClawHub, native apps, +mobile, website, and private dist-tags; do not run those steps for this Gateway +path. Extended-stable owns only its notes-only, non-Latest GitHub Release page. ## Regular release operator checklist @@ -562,9 +568,9 @@ For package-candidate Telegram proof, enable `telegram_mode=mock-openai` or `tel For beta, `latest`, plugin, GitHub Release, and platform publication, `OpenClaw Release Publish` is the normal mutating entrypoint. The monthly -`.33+` Gateway extended-stable path does not use this orchestrator. The -regular workflow orchestrates the trusted-publisher workflows in the order the -release needs: +`.33+` Gateway extended-stable path also uses its Docker-only closeout after npm +publication. The regular path orchestrates the trusted-publisher workflows in +the order the release needs: 1. Check out the release tag and resolve its commit SHA. 2. Verify the tag is reachable from `main` or `release/*` (or a Tideclaw alpha branch for alpha prereleases). @@ -723,7 +729,7 @@ readback confirms that every exact package and `extended-stable` tag converged. - `windows_node_installer_digests`: candidate-approved compact JSON map of the current Windows installer names to their pinned `sha256:` digests; required for stable OpenClaw publish - `npm_telegram_run_id`: optional successful `NPM Telegram Beta E2E` run id to include in final release evidence - `npm_dist_tag`: npm target tag for the OpenClaw package, one of `alpha`, `beta`, `latest`, or `extended-stable` -- `publish_docker_only`: beta or extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selected dist-tag, and tarball digest before invoking Docker publication. +- `publish_docker_only`: extended-stable-only recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selector, and tarball digest, prepares the notes-only GitHub Release draft, publishes Docker, and makes the release public with `latest=false`. - `plugin_publish_scope`: defaults to `all-publishable`; use `selected` only for focused plugin-only repair work with `publish_openclaw_npm=false` - `plugins`: comma-separated `@openclaw/*` package names when `plugin_publish_scope=selected` - `publish_openclaw_npm`: defaults to `true`; set `false` only when using the workflow as a plugin-only repair orchestrator diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index f58c24a155ad..6341a4bea5cf 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -53,10 +53,11 @@ 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 +## Extended-stable validation -Extended-stable publish requires a run whose workflow and target are both the -canonical branch: +Extended-stable publish accepts a complete exact-target run from the canonical +branch, a direct current-main run whose workflow SHA remains reachable from +current `main`, or the trusted main-pinned helper: ```bash RELEASE_SHA="$(git rev-parse HEAD)" @@ -67,9 +68,10 @@ gh workflow run full-release-validation.yml \ -f release_profile=stable ``` -Do not use `pnpm ci:full-release` or `release-ci/*`. Publish binds the run's -branch, head/target SHA, manifest `workflowRef`, ID, and attempt to the canonical -branch and release commit. +The helper's temporary `release-ci/*` ref is valid only when every child uses +the pinned trusted workflow SHA and the v3 manifest binds the canonical branch, +exact release commit, run ID, and attempt. Reject narrow runs, stale attempts, +untrusted workflow SHAs, and mismatched targets. Backport product failures; make the smallest behavior-preserving repair for frozen-target tooling; retry provider, approval, or runner failures without a @@ -331,8 +333,8 @@ Narrow evidence is not publish authorization by itself. For a regular release, record both Code SHA and Release SHA, the reuse policy and changed-path set, the green Code SHA parent run, and the lightweight Release SHA parent run. For extended-stable, record the canonical branch, exact release -SHA, fresh parent run id and attempt, workflow ref, every child run, and any -frozen-target compatibility repair or intentional omission. +SHA, accepted producer identity, parent run id and attempt, workflow ref, every +child run, and any frozen-target compatibility repair or intentional omission. Useful artifacts: diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 13279177a59a..6511f367e650 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -41,6 +41,7 @@ const MANTIS_GITHUB_APP_CLIENT_ID = "Iv23liPJCozR0uHm6P7G"; const OPENGREP_PR_DIFF_WORKFLOW = ".github/workflows/opengrep-precise.yml"; const OPENGREP_FULL_WORKFLOW = ".github/workflows/opengrep-precise-full.yml"; const CONTROL_UI_LOCALE_REFRESH_WORKFLOW = ".github/workflows/control-ui-locale-refresh.yml"; +const DOCS_TRANSLATE_RELEASE_WORKFLOW = ".github/workflows/docs-translate-trigger-release.yml"; const NATIVE_APP_LOCALE_REFRESH_WORKFLOW = ".github/workflows/native-app-locale-refresh.yml"; const CREATE_GENERATED_PR_TOKENS_ACTION = ".github/actions/create-generated-pr-tokens/action.yml"; const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml"; @@ -1861,7 +1862,9 @@ NODE it("keeps locale refresh matrices alive and publishes each aggregate through a PR", () => { const controlUiWorkflow = parse(readFileSync(CONTROL_UI_LOCALE_REFRESH_WORKFLOW, "utf8")); + const docsTranslateWorkflow = parse(readFileSync(DOCS_TRANSLATE_RELEASE_WORKFLOW, "utf8")); const workflow = parse(readFileSync(NATIVE_APP_LOCALE_REFRESH_WORKFLOW, "utf8")); + const controlUiReleasePolicy = controlUiWorkflow.jobs.release_policy; const controlUiResolveBase = controlUiWorkflow.jobs["resolve-base"]; const nativeResolveBase = workflow.jobs["resolve-base"]; const controlUiPreflight = controlUiWorkflow.jobs["publisher-preflight"]; @@ -1914,8 +1917,23 @@ NODE expect(controlUiResolveBase.if).not.toContain("chore(ui): refresh control ui locales"); const controlResolveCondition = controlUiResolveBase.if.replace(/\s+/gu, " "); expect(controlResolveCondition).toBe( - "github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')", + "needs.release_policy.outputs.should_run == 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')", ); + expect(controlUiResolveBase.needs).toBe("release_policy"); + expect(controlUiReleasePolicy.outputs.should_run).toBe( + "${{ steps.release.outputs.should_run }}", + ); + expect( + controlUiReleasePolicy.steps.find( + (step: WorkflowStep) => step.name === "Classify release track", + ).run, + ).toContain('track !== "extended-stable"'); + const docsDispatch = docsTranslateWorkflow.jobs["dispatch-translate"]; + expect( + docsDispatch.steps.find( + (step: WorkflowStep) => step.name === "Trigger translation coordinator in publish repo", + ).if, + ).toBe("${{ steps.release.outputs.track != 'extended-stable' }}"); expect(controlResolveCondition).not.toContain("inputs.token_preflight_only"); expect(controlResolveCondition).not.toContain("github.ref_type"); expect(nativeResolveBase.if).toBe( diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b053a31a7fe1..c1d945b58145 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -6246,6 +6246,36 @@ describe("package artifact reuse", () => { expect(finalizeJob.needs).toEqual(["publish", "publish_docker"]); expect(finalizeJob.if).toContain("needs.publish_docker.result == 'success'"); expect(finalizeRelease.run).toContain('gh release edit "${RELEASE_TAG}"'); + + const extendedPrepareJob = workflowJob( + RELEASE_PUBLISH_WORKFLOW, + "prepare_extended_stable_release", + ); + const extendedPrepare = workflowStep( + extendedPrepareJob, + "Create or resume the canonical draft release", + ); + const extendedFinalizeJob = workflowJob( + RELEASE_PUBLISH_WORKFLOW, + "finalize_extended_stable_github_release", + ); + const extendedFinalize = workflowStep( + extendedFinalizeJob, + "Publish the verified extended-stable draft", + ); + expect(extendedPrepareJob.needs).toEqual(["resolve_release_target"]); + expect(extendedPrepare.run).toContain("verify_release_tag_target"); + expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes"); + expect(extendedPrepare.run).toContain("--draft"); + expect(extendedPrepare.run).toContain("--latest=false"); + expect(extendedFinalizeJob.needs).toEqual([ + "resolve_release_target", + "prepare_extended_stable_release", + "publish_docker", + ]); + expect(extendedFinalizeJob.if).toContain("needs.publish_docker.result == 'success'"); + expect(extendedFinalize.run).toContain("-f make_latest=false"); + expect(extendedFinalize.run).toContain("EXPECTED_BODY_SHA256"); }); it("accepts tag-matched frozen release branches in OpenClaw npm preflight", () => { diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 8ea3d7f70d2b..4ed8f52c59d4 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1036,10 +1036,10 @@ describe("release validation no-push transport", () => { expect(dockerCall.needs).toEqual([ "resolve_release_target", "publish", - "verify_core_npm_registry", + "prepare_extended_stable_release", ]); expect(dockerCall.if).toContain("needs.publish.result == 'success'"); - expect(dockerCall.if).toContain("needs.verify_core_npm_registry.result == 'success'"); + expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'"); expect(dockerCall.with).toEqual({ tag: "${{ inputs.tag }}", release_sha: "${{ needs.resolve_release_target.outputs.sha }}", @@ -1068,6 +1068,61 @@ describe("release validation no-push transport", () => { "publish_docker", ]); + const prepareRelease = job(releasePublish, "prepare_extended_stable_release"); + const verifyNpm = step( + prepareRelease, + "Verify exact npm and selector readback matches preflight bytes", + ); + const createDraft = step(prepareRelease, "Create or resume the canonical draft release"); + const finalizeRelease = job(releasePublish, "finalize_extended_stable_github_release"); + const publishDraft = step(finalizeRelease, "Publish the verified extended-stable draft"); + + expect(prepareRelease.needs).toEqual(["resolve_release_target"]); + expect(prepareRelease.if).toBe("${{ inputs.publish_docker_only }}"); + expect(prepareRelease.environment).toBe("npm-release"); + expect(prepareRelease.permissions).toEqual({ contents: "write" }); + expect(prepareRelease.outputs).toEqual({ + release_id: "${{ steps.release.outputs.release_id }}", + release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}", + }); + expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version'); + expect(verifyNpm.run).toContain("Published npm tarball does not match"); + expect(createDraft.run).toContain("verify_release_tag_target"); + expect(createDraft.run).toContain(".release-harness/scripts/render-github-release-notes.mjs"); + expect(createDraft.run).toContain('gh release create "${RELEASE_TAG}"'); + expect(createDraft.run).toContain("--verify-tag"); + expect(createDraft.run).toContain("--draft"); + expect(createDraft.run).toContain("--prerelease=false"); + expect(createDraft.run).toContain("--latest=false"); + expect(createDraft.run).toContain("Existing canonical public release will be revalidated"); + expect(createDraft.run).toContain("wait_for_release_id"); + expect(createDraft.run).toContain("release_body_sha256="); + + expect(finalizeRelease.needs).toEqual([ + "resolve_release_target", + "prepare_extended_stable_release", + "publish_docker", + ]); + expect(finalizeRelease.if).toContain("inputs.publish_docker_only"); + expect(finalizeRelease.if).toContain( + "needs.prepare_extended_stable_release.result == 'success'", + ); + expect(finalizeRelease.if).toContain("needs.publish_docker.result == 'success'"); + expect(finalizeRelease.environment).toBe("npm-release"); + expect(finalizeRelease.permissions).toEqual({ contents: "write" }); + expect(publishDraft.env).toMatchObject({ + EXPECTED_BODY_SHA256: + "${{ needs.prepare_extended_stable_release.outputs.release_body_sha256 }}", + RELEASE_ID: "${{ needs.prepare_extended_stable_release.outputs.release_id }}", + }); + expect(publishDraft.run).toContain("repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}"); + expect(publishDraft.run).toContain("-F draft=false"); + expect(publishDraft.run).toContain("-F prerelease=false"); + expect(publishDraft.run).toContain("-f make_latest=false"); + expect(publishDraft.run).toContain("EXPECTED_BODY_SHA256"); + expect(publishDraft.run).toContain("releases/latest"); + expect(publishDraft.run).toContain("must not become GitHub Latest"); + const identity = step( job(dockerRelease, "validate_release_identity"), "Verify tag, SHA, and package identity agree",