diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index dccf904ef6c0..4c7aa874fc32 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -272,7 +272,7 @@ every human `Thanks @...` attribution. 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 +validate the final branch tip before tagging. The release 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. diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 77d030f75327..9dcf58369e84 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -302,14 +302,14 @@ 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. 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, - persist and verify the exact-SHA Docker completion status, and only then - make the release public. On retry, skip Docker only for that durable status; - release draft/public state is never completion evidence. Do not publish macOS, Windows, - mobile, website, ClawHub, npm `latest`, or private dist-tag artifacts from - this path. +10. Dispatch the extended-stable `OpenClaw Release Publish` closeout. It must + reverify the exact npm selector and tarball, render the canonical release + notes, publish Docker, and only then create the public non-prerelease GitHub + Release with `latest=false`. Re-run failed jobs to retry a failed finalizer + without repeating successful Docker work; a fresh dispatch safely repeats + same-version Docker verification and promotion. Do not publish macOS, + Windows, mobile, website, ClawHub, npm `latest`, or private dist-tag + artifacts from this path. ## Keep release channel naming aligned 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 1443b29dbfce..928a36076304 100644 --- a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md @@ -26,7 +26,7 @@ extended-stable package and publication constraints. and workflow validation. GitHub evaluates tag-push workflows from that tree. - 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. + dist-tags. The release 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. 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 acf35eadb586..7f99a698899c 100644 --- a/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md +++ b/.agents/skills/release-openclaw-maintainer/references/release-handoff-template.md @@ -33,7 +33,7 @@ operator steering. Do not preserve superseded scope. - Plugin NPM Release: `` - publish parent: `` - Docker release/repair: `` -- GitHub Release: `` +- GitHub Release: `` - immutable successful children: `` - registry/provenance readback: `` diff --git a/.agents/skills/verify-release/SKILL.md b/.agents/skills/verify-release/SKILL.md index c29c4651ea4f..7db13ba19a6b 100644 --- a/.agents/skills/verify-release/SKILL.md +++ b/.agents/skills/verify-release/SKILL.md @@ -101,10 +101,10 @@ the live tag, workflow, registry, provenance, and image state. digest binding to the release SHA. Preserve output and workflow URLs. 5. **Docker:** verify exact default, slim, browser, and architecture images and attestations in both registries. Only the three `extended-stable*` aliases may - resolve to those digests. Require the canonical `openclaw/docker-release/` - success status on the immutable release SHA and its `OpenClaw Release Publish` - run URL; release visibility is not Docker evidence. Repair aliases through - current-main `Docker Channel Promotion` for the exact tag, without rebuilding. + resolve to those digests. Require the successful `OpenClaw Release Publish` + closeout run whose Docker job precedes GitHub Release publication. Repair + aliases through current-main `Docker Channel 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, diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 9c7dd4b0fb8d..9d17ce08629f 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -904,7 +904,6 @@ jobs: permissions: contents: read packages: write - statuses: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -912,14 +911,6 @@ jobs: ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 1 - - 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 - - *buildkit_prepull_step - name: Set up Docker Builder uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -1126,32 +1117,3 @@ jobs: --image-tag-suffix "${IMAGE_TAG_SUFFIX}" \ --image "${GHCR_IMAGE}" \ --image "${DOCKERHUB_IMAGE}" - - # This status is the durable resume boundary. Release visibility is not - # Docker evidence and must never suppress a missing publication. - - name: Record durable extended-stable Docker completion - if: ${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }} - env: - GH_TOKEN: ${{ github.token }} - RELEASE_SHA: ${{ inputs.release_sha }} - VERSION: ${{ needs.resolve_release_policy.outputs.version }} - run: | - set -euo pipefail - payload="$(node .release-harness/scripts/docker-channel-promote.mjs \ - --status-payload \ - --version "${VERSION}" \ - --repository "${GITHUB_REPOSITORY}" \ - --source-sha "${RELEASE_SHA}" \ - --run-id "${GITHUB_RUN_ID}")" - for attempt in 1 2 3; do - if printf '%s' "${payload}" | gh api --method POST \ - "repos/${GITHUB_REPOSITORY}/statuses/${RELEASE_SHA}" \ - --input - >/dev/null; then - exit 0 - fi - if [[ "${attempt}" != "3" ]]; then - sleep $((attempt * 5)) - fi - done - echo "Failed to persist durable Docker completion after verified publication." >&2 - exit 1 diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 2b38fdef156c..7afdd12ee009 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -84,7 +84,7 @@ on: default: true type: boolean publish_docker_only: - description: Publish Docker only after independently verifying an already-published beta or extended-stable npm package + description: Complete an already-published extended-stable release by verifying npm, publishing Docker, then publishing its GitHub Release required: true default: false type: boolean @@ -314,7 +314,7 @@ jobs: if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then if [[ ! "${RELEASE_TAG}" =~ ^v([0-9]{4})\.([1-9][0-9]*)\.([1-9][0-9]*)$ ]] || \ (( 10#${BASH_REMATCH[3]:-0} < 33 )); then - echo "Extended-stable Docker closeout requires a final .33+ release tag." >&2 + echo "Extended-stable closeout requires a final .33+ release tag." >&2 exit 1 fi expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33" @@ -2782,20 +2782,12 @@ jobs: if-no-files-found: error prepare_extended_stable_release: - name: Verify npm and prepare extended-stable release + name: Verify npm and prepare extended-stable release notes needs: [resolve_release_target] if: ${{ inputs.publish_docker_only }} runs-on: ubuntu-latest - environment: npm-release permissions: - actions: read - contents: write - statuses: read - outputs: - docker_already_published: ${{ steps.release.outputs.docker_already_published }} - docker_status_run_id: ${{ steps.release.outputs.docker_status_run_id }} - release_id: ${{ steps.release.outputs.release_id }} - release_body_sha256: ${{ steps.release.outputs.release_body_sha256 }} + contents: read steps: - name: Verify exact npm and selector readback matches preflight bytes env: @@ -2821,258 +2813,60 @@ 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: Setup Node environment + - name: Setup trusted release tooling uses: ./.github/actions/setup-node-env with: - install-deps: "false" install-bun: "false" - - name: Install trusted release tooling dependencies + - name: Render canonical extended-stable release notes env: - CI: "true" - run: | - set -euo pipefail - install_args=( - --dir .release-harness - install - --frozen-lockfile - --prefer-offline - --config.engine-strict=false - --config.enable-pre-post-scripts=true - --config.side-effects-cache=true - ) - pnpm "${install_args[@]}" || pnpm "${install_args[@]}" - # Node resolves --import tsx from the process cwd. Point that root at - # the trusted harness install instead of the frozen target checkout. - ln -s .release-harness/node_modules node_modules - - - 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" + notes_dir="${RUNNER_TEMP}/extended-stable-release-notes" + changelog_file="${notes_dir}/CHANGELOG.md" + notes_file="${notes_dir}/release-notes.md" + verification_file="${notes_dir}/verification.md" + mkdir -p "${notes_dir}" - 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 require_exact_body="${3:-false}" - local resource_file="${RUNNER_TEMP}/extended-stable-release.json" - gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${resource_file}" - RELEASE_BODY_FILE="${notes_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}" \ - REQUIRE_EXACT_BODY="${require_exact_body}" \ - node --import tsx --input-type=module <<'NODE' - import { readFileSync } from "node:fs"; - import { - releaseNotesVersionForTag, - verifyGithubReleaseNotes, - } from "./.release-harness/scripts/render-github-release-notes.mts"; - - const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8")); - const body = release.body ?? ""; - const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8"); - const expectedDraft = process.env.EXPECTED_DRAFT; - const expectedBody = readFileSync(process.env.RELEASE_BODY_FILE, "utf8"); - 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")) || - !Array.isArray(release.assets) || - release.assets.length !== 0 || - (process.env.REQUIRE_EXACT_BODY === "true" && body !== expectedBody) || - !notes.matches - ) { - console.error("Existing GitHub release does not match the canonical extended-stable contract."); - process.exitCode = 1; - } - NODE - } - - wait_until_not_latest() { - local attempt latest_tag - for attempt in $(seq 1 12); do - latest_tag="$( - gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true - )" - if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then - return 0 - fi - if [[ "${attempt}" != "12" ]]; then - sleep 5 - fi - done - echo "Extended-stable release ${RELEASE_TAG} must not be GitHub Latest." >&2 - return 1 - } - - verify_release_tag_target + git fetch --no-tags --depth=1 origin "${TARGET_SHA}" + git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" cat > "${verification_file}" <&2 - exit 1 - } - verify_release_resource "${release_id}" - fi - release_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" --jq '.draft')" - if [[ "${release_draft}" == "true" ]]; then - gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ - --title "${release_title}" \ - --notes-file "${notes_file}" \ - --prerelease=false \ - --latest=false - verify_release_resource "${release_id}" true true - elif [[ "${release_draft}" == "false" ]]; then - verify_release_resource "${release_id}" false true - wait_until_not_latest - release_is_public=true - echo "Existing canonical public release found; Docker completion will be checked independently." - else - echo "GitHub release ${release_id} has an invalid draft state." >&2 - exit 1 - fi - release_body_sha256="$( - sha256sum "${notes_file}" | awk '{print $1}' - )" - docker_status_file="${RUNNER_TEMP}/extended-stable-docker-status.json" - gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${docker_status_file}" - docker_status_run_id="$(node .release-harness/scripts/docker-channel-promote.mjs \ - --find-statuses-file "${docker_status_file}" \ - --version "${release_version}" \ - --repository "${GITHUB_REPOSITORY}" \ - --source-sha "${TARGET_SHA}")" - docker_already_published=false - if [[ -n "${docker_status_run_id}" ]]; then - docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json" - gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}" - jq -e --arg repository "${GITHUB_REPOSITORY}" \ - --arg run_id "${docker_status_run_id}" ' - (.id | tostring) == $run_id and - .name == "OpenClaw Release Publish" and - (.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and - .event == "workflow_dispatch" and - .status == "completed" and - .repository.full_name == $repository - ' "${docker_run_file}" >/dev/null || { - echo "Docker completion status does not reference a completed OpenClaw Release Publish run." >&2 - exit 1 - } - docker_already_published=true - fi - echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" - echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT" - echo "docker_already_published=${docker_already_published}" >> "$GITHUB_OUTPUT" - echo "docker_status_run_id=${docker_status_run_id}" >> "$GITHUB_OUTPUT" - if [[ "${docker_already_published}" == "true" ]]; then - echo "- Docker completion: durable status from run ${docker_status_run_id}; rebuild skipped" >> "$GITHUB_STEP_SUMMARY" - elif [[ "${release_is_public}" == "true" ]]; then - echo "- Extended-stable GitHub release: public without Docker completion; Docker publication required" >> "$GITHUB_STEP_SUMMARY" - else - echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" - fi + - name: Upload canonical extended-stable release notes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: extended-stable-release-notes-${{ inputs.tag }} + path: ${{ runner.temp }}/extended-stable-release-notes/release-notes.md + if-no-files-found: error + retention-days: 7 publish_docker: name: Publish Docker images 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' && needs.prepare_extended_stable_release.outputs.docker_already_published != 'true')) }} + 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 }} @@ -3089,7 +2883,6 @@ jobs: attestations: read contents: read packages: write - statuses: write publish_vcr: name: Mirror Docker images to Vercel Container Registry @@ -3132,130 +2925,37 @@ jobs: exit 1 fi - verify_extended_stable_docker_completion: - name: Verify durable extended-stable Docker completion - 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' || (needs.publish_docker.result == 'skipped' && needs.prepare_extended_stable_release.outputs.docker_already_published == 'true')) }} - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - statuses: read - steps: - - name: Checkout trusted release tooling - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ github.sha }} - fetch-depth: 1 - persist-credentials: false - - - name: Verify durable Docker completion status - 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}" - status_file="${RUNNER_TEMP}/extended-stable-docker-status.json" - docker_status_run_id="" - for attempt in $(seq 1 12); do - gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${status_file}" - docker_status_run_id="$(node scripts/docker-channel-promote.mjs \ - --find-statuses-file "${status_file}" \ - --version "${release_version}" \ - --repository "${GITHUB_REPOSITORY}" \ - --source-sha "${TARGET_SHA}")" - if [[ -n "${docker_status_run_id}" ]]; then - break - fi - if [[ "${attempt}" == "12" ]]; then - echo "Docker publication completed without a durable completion status." >&2 - exit 1 - fi - sleep 5 - done - - docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json" - gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}" - jq -e --arg current_run_id "${GITHUB_RUN_ID}" \ - --arg repository "${GITHUB_REPOSITORY}" \ - --arg run_id "${docker_status_run_id}" ' - (.id | tostring) == $run_id and - .name == "OpenClaw Release Publish" and - (.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and - .event == "workflow_dispatch" and - (.status == "completed" or ($run_id == $current_run_id and .status == "in_progress")) and - .repository.full_name == $repository - ' "${docker_run_file}" >/dev/null || { - echo "Docker completion status does not reference the expected release workflow run." >&2 - exit 1 - } - echo "- Durable Docker completion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" >> "$GITHUB_STEP_SUMMARY" - finalize_extended_stable_github_release: - name: Finalize extended-stable GitHub release - needs: - [ - resolve_release_target, - prepare_extended_stable_release, - verify_extended_stable_docker_completion, - ] - if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.verify_extended_stable_docker_completion.result == 'success' }} + name: Publish 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 + - name: Download canonical extended-stable release notes + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v8.0.1 + with: + name: extended-stable-release-notes-${{ inputs.tag }} + path: ${{ runner.temp }}/extended-stable-release-notes + + - name: Publish canonical extended-stable release 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" + notes_file="${RUNNER_TEMP}/extended-stable-release-notes/release-notes.md" + resource_file="${RUNNER_TEMP}/extended-stable-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") || - !Array.isArray(release.assets) || - release.assets.length !== 0 || - bodySha256 !== process.env.EXPECTED_BODY_SHA256 - ) { - console.error("GitHub release state does not match the extended-stable contract."); - process.exitCode = 1; - } - NODE - } + if [[ ! -s "${notes_file}" ]]; then + echo "Canonical extended-stable release notes artifact is missing or empty." >&2 + exit 1 + fi verify_release_tag_target() { local direct_sha peeled_sha remote_refs remote_sha @@ -3268,49 +2968,82 @@ jobs: 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 + echo "Release tag ${RELEASE_TAG} must resolve to ${TARGET_SHA}; found ${remote_sha:-}." >&2 exit 1 fi } - # Docker publication can take long enough for the tag to change after - # preparation. Recheck immediately before accepting or publishing the page. - verify_release_tag_target - current_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.draft')" - if [[ "${current_draft}" == "true" ]]; then - verify_release_state true - # GITHUB_TOKEN publication intentionally does not fan out release.published - # workflows; extended-stable scope stays owned by this closeout. - 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 + fetch_release_resource() { + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" \ + > "${resource_file}" 2>/dev/null + } - 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 - for attempt in $(seq 1 12); do + verify_release_resource() { + local expected_draft="$1" + RELEASE_BODY_FILE="${notes_file}" \ + RELEASE_RESOURCE_FILE="${resource_file}" \ + RELEASE_TAG="${RELEASE_TAG}" \ + RELEASE_TITLE="${release_title}" \ + EXPECTED_DRAFT="${expected_draft}" \ + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + + const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8")); + const expectedBody = readFileSync(process.env.RELEASE_BODY_FILE, "utf8"); + if ( + release.tag_name !== process.env.RELEASE_TAG || + release.name !== process.env.RELEASE_TITLE || + release.draft !== (process.env.EXPECTED_DRAFT === "true") || + release.prerelease !== false || + !Array.isArray(release.assets) || + release.assets.length !== 0 || + (release.body ?? "") !== expectedBody + ) { + console.error("GitHub release does not match the canonical extended-stable contract."); + process.exitCode = 1; + } + NODE + } + + wait_until_public_and_canonical() { + local attempt + for attempt in $(seq 1 12); do + if fetch_release_resource && verify_release_resource false; then + return 0 + fi + [[ "${attempt}" == "12" ]] || sleep 5 + done + echo "Extended-stable GitHub release did not reach its canonical public state." >&2 + return 1 + } + + verify_not_latest() { + local latest_tag latest_tag="$( gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true )" - if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then - break + if [[ -z "${latest_tag}" || "${latest_tag}" == "${RELEASE_TAG}" ]]; then + echo "Extended-stable release ${RELEASE_TAG} must not be GitHub Latest." >&2 + return 1 fi - if [[ "${attempt}" == "12" ]]; then - echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2 - exit 1 + } + + verify_release_tag_target + if fetch_release_resource; then + verify_release_resource false + verify_not_latest + echo "- Extended-stable GitHub release: already public and canonical" >> "${GITHUB_STEP_SUMMARY}" + exit 0 + else + if ! gh release create "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --title "${release_title}" \ + --notes-file "${notes_file}" \ + --latest=false; then + echo "Release creation raced with another writer; verifying the existing page." fi - sleep 5 - done - echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" + fi + + wait_until_public_and_canonical + verify_not_latest + echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/openclaw-stable-main-closeout.yml b/.github/workflows/openclaw-stable-main-closeout.yml index 452883301c9c..a4a59155bdd5 100644 --- a/.github/workflows/openclaw-stable-main-closeout.yml +++ b/.github/workflows/openclaw-stable-main-closeout.yml @@ -73,8 +73,7 @@ jobs: } BASH - - name: Checkout pushed main - if: ${{ github.event_name == 'push' }} + - name: Checkout trusted release tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} @@ -98,9 +97,25 @@ jobs: . "$RUNNER_TEMP/github-api-backoff.sh" if [[ "$EVENT_NAME" == "push" ]]; then main_ref="$TRIGGER_SHA" - tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \ - --json tagName,isPrerelease,publishedAt \ - --jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')" + releases_file="$RUNNER_TEMP/published-releases.json" + gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \ + --json tagName,isPrerelease,publishedAt > "$releases_file" + tag="$(RELEASES_FILE="$releases_file" node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs"; + + const releases = JSON.parse(readFileSync(process.env.RELEASES_FILE, "utf8")); + const candidates = releases + .filter((release) => release?.isPrerelease === false) + .filter((release) => { + const tag = typeof release?.tagName === "string" ? release.tagName : ""; + const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null; + return parsed !== null && classifyReleaseTrain(parsed) === "stable"; + }) + .toSorted((left, right) => String(left.publishedAt).localeCompare(String(right.publishedAt))); + process.stdout.write(candidates.at(-1)?.tagName ?? ""); + NODE + )" if [[ -z "$tag" ]]; then echo "should_closeout=false" >> "$GITHUB_OUTPUT" exit 0 @@ -108,12 +123,21 @@ jobs: else tag="$MANUAL_TAG" fi - if [[ ! "$tag" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + if ! RELEASE_TAG="$tag" node --input-type=module <<'NODE' + import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs"; + + const tag = process.env.RELEASE_TAG ?? ""; + const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null; + if (parsed === null || classifyReleaseTrain(parsed) !== "stable") { + process.exitCode = 1; + } + NODE + then if [[ "$EVENT_NAME" == "push" ]]; then echo "should_closeout=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Stable main closeout accepts only a stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag, got $tag." >&2 + echo "Stable main closeout accepts only a regular stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag below the extended-stable .33 boundary, got $tag." >&2 exit 1 fi release_asset_version="${tag#v}" diff --git a/docs/ci.md b/docs/ci.md index 8df4015886ea..ea5563b6fde8 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -393,11 +393,11 @@ 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 and the exact canonical validation branch, -publishes Docker, records durable Docker completion on the immutable release -SHA, and finalizes the notes-only non-Latest GitHub Release without entering -plugin, ClawHub, or native-app publication. A public release page never skips -Docker by itself; only the verified completion status can make a retry resume -after Docker. +renders canonical release notes, publishes Docker, and creates the public +notes-only non-Latest GitHub Release without entering plugin, ClawHub, or +native-app publication. Re-running failed jobs retries the finalizer without +repeating a successful Docker job; a fresh dispatch repeats the idempotent +same-version Docker verification and promotion. ```bash gh workflow run openclaw-release-publish.yml \ diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index dc9f0ab22580..1ffac130563c 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -200,17 +200,16 @@ must advance only 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 +`OpenClaw Release Publish`. Its extended-stable closeout path rechecks the saved npm preflight artifact, exact `Full Release Validation` evidence, exact npm 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. After image, attestation, platform, -and channel-alias verification, Docker Release records a success status on the -immutable release SHA. Retries skip a rebuild only when that exact durable -status and its release-workflow run are verified; a public GitHub Release page -alone is never Docker-completion evidence. A tag push never publishes Docker -images or a release page by itself: +renders and validates the canonical release notes, calls the reusable +`Docker Release` workflow, and creates the public non-prerelease GitHub Release +with `latest=false` only after Docker succeeds. Re-run failed jobs to retry a +failed release-page finalizer without repeating successful Docker work; a fresh +workflow dispatch safely repeats same-version Docker verification and +promotion. A tag push never publishes Docker images or a release page by +itself: ```bash gh workflow run openclaw-release-publish.yml \ @@ -591,7 +590,7 @@ 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 also uses its Docker-only closeout after npm +`.33+` Gateway extended-stable path also uses its closeout after npm publication. The regular path orchestrates the trusted-publisher workflows in the order the release needs: @@ -752,7 +751,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`: 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`. +- `publish_docker_only`: legacy input name for the extended-stable 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, renders canonical notes, publishes Docker, and creates the public notes-only GitHub Release 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 20d0048bd97a..2177968ee98a 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -110,7 +110,7 @@ gh workflow run full-release-validation.yml \ -f release_profile=stable ``` -The Docker-only closeout derives `extended-stable/YYYY.M.33` from the final +The extended-stable closeout derives `extended-stable/YYYY.M.33` from the final `.33+` tag, requires the tag commit to be reachable from that exact branch, and passes that branch to the validation-evidence verifier. It does not substitute the closeout workflow's `main` ref for the candidate branch. diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs index f6124cba2411..7d3714bc12ce 100644 --- a/scripts/docker-channel-promote.mjs +++ b/scripts/docker-channel-promote.mjs @@ -394,7 +394,6 @@ function main() { args: process.argv.slice(2), options: { "allow-rollback": { type: "boolean" }, - "find-statuses-file": { type: "string" }, help: { type: "boolean", short: "h" }, image: { type: "string", multiple: true }, "image-tag-suffix": { type: "string", default: "" }, @@ -414,28 +413,6 @@ function main() { if (!version) { throw new Error("--version is required."); } - if (values["status-payload"]) { - const payload = createDockerPublicationStatus({ - version, - repository: values.repository ?? "", - sourceSha: values["source-sha"] ?? "", - runId: values["run-id"] ?? "", - }); - process.stdout.write(`${JSON.stringify(payload)}\n`); - return; - } - if (values["find-statuses-file"]) { - const match = findDockerPublicationStatus({ - statuses: JSON.parse(readFileSync(values["find-statuses-file"], "utf8")), - version, - repository: values.repository ?? "", - sourceSha: values["source-sha"] ?? "", - }); - if (match) { - process.stdout.write(`${match.runId}\n`); - } - return; - } const images = (values.image ?? []).map((image) => image.trim()); if (images.length === 0 || images.some((image) => image.length === 0)) { throw new Error("At least one non-empty --image is required."); diff --git a/scripts/resolve-upgrade-survivor-baselines.mts b/scripts/resolve-upgrade-survivor-baselines.mts index e8ae260c3df0..422f6a8c1b9a 100644 --- a/scripts/resolve-upgrade-survivor-baselines.mts +++ b/scripts/resolve-upgrade-survivor-baselines.mts @@ -3,7 +3,11 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { normalizeUpgradeSurvivorBaselineSpec } from "./lib/docker-e2e-plan.mts"; -import { compareReleaseVersions, parseReleaseVersion } from "./lib/release-version.mjs"; +import { + classifyReleaseTrain, + compareReleaseVersions, + parseReleaseVersion, +} from "./lib/release-version.mjs"; type ReleaseRecord = Partial>; @@ -73,7 +77,7 @@ function stableVersionFromTag(tagName: unknown) { function parseStableVersion(version: unknown) { const parsed = parseReleaseVersion(typeof version === "string" ? version : ""); - return parsed?.channel === "stable" ? parsed : undefined; + return parsed && classifyReleaseTrain(parsed) === "stable" ? parsed : undefined; } function compareStableVersions(left: string, right: string) { diff --git a/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts index 3e715fc7ed95..3bed096407e6 100644 --- a/test/scripts/docker-channel-promote.test.ts +++ b/test/scripts/docker-channel-promote.test.ts @@ -6,8 +6,6 @@ import { describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import { createDockerChannelPromotionPlan, - createDockerPublicationStatus, - findDockerPublicationStatus, promoteDockerChannel, } from "../../scripts/docker-channel-promote.mjs"; @@ -106,132 +104,6 @@ const bashRunsWorkflowSteps = spawnSync("bash", ["-c", "type mapfile"], { encoding: "utf8" }).status === 0; describe("Docker channel promotion", () => { - it("accepts the newest canonical status from reverse-chronological history", () => { - const sourceSha = "a".repeat(40); - const payload = createDockerPublicationStatus({ - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - runId: "12345", - }); - - expect(payload).toEqual({ - context: "openclaw/docker-release/2026.6.35", - description: - "Verified GHCR + Docker Hub images, attestations, platforms, and channel aliases.", - state: "success", - target_url: "https://github.com/openclaw/openclaw/actions/runs/12345", - }); - expect( - findDockerPublicationStatus({ - statuses: [ - { context: "unrelated/status", state: "success" }, - { - ...payload, - creator: { login: "github-actions[bot]" }, - url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, - }, - { - ...payload, - creator: { login: "github-actions[bot]" }, - target_url: "https://github.com/openclaw/openclaw/actions/runs/11111", - url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, - }, - ], - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toEqual({ runId: "12345", targetUrl: payload.target_url }); - }); - - it("does not accept absent, malformed, or combined-response status evidence", () => { - const sourceSha = "a".repeat(40); - expect( - findDockerPublicationStatus({ - statuses: [], - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toBeNull(); - - const payload = createDockerPublicationStatus({ - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - runId: "12345", - }); - const canonical = { - ...payload, - creator: { login: "github-actions[bot]" }, - url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, - }; - for (const status of [ - { ...canonical, state: "pending" }, - { ...canonical, creator: { login: "someone-else" } }, - { - ...canonical, - description: "images probably published", - }, - { - ...canonical, - url: `https://api.github.com/repos/openclaw/openclaw/statuses/${"b".repeat(40)}`, - }, - ]) { - expect(() => - findDockerPublicationStatus({ - statuses: [status], - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toThrow("is not canonical"); - } - - expect(() => - findDockerPublicationStatus({ - statuses: { sha: sourceSha, statuses: [] }, - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toThrow("status history must be an array"); - }); - - it("fails closed on a malformed newest matching status", () => { - const sourceSha = "a".repeat(40); - const payload = createDockerPublicationStatus({ - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - runId: "12345", - }); - const canonical = { - ...payload, - creator: { login: "github-actions[bot]" }, - url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, - }; - - expect(() => - findDockerPublicationStatus({ - statuses: [{ ...canonical, description: "unverified images" }, canonical], - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toThrow("is not canonical"); - - expect(() => - findDockerPublicationStatus({ - statuses: [{ ...canonical, context: canonical.context.toUpperCase() }, canonical], - version: "2026.6.35", - repository: "openclaw/openclaw", - sourceSha, - }), - ).toThrow("is not canonical"); - }); - it("plans every extended-stable image variant in both registries", () => { expect(createDockerChannelPromotionPlan({ version: "2026.6.33", images })).toEqual({ channel: "extended-stable", @@ -800,7 +672,6 @@ describe("Docker channel promotion", () => { expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write", - statuses: "write", }); const manifestTagStep = createManifest.steps?.find( @@ -828,15 +699,6 @@ describe("Docker channel promotion", () => { "node scripts/docker-channel-promote.mjs", ); expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback"); - const completionIndex = releaseSteps.findIndex( - (step) => step.name === "Record durable extended-stable Docker completion", - ); - expect(completionIndex).toBeGreaterThan(releasePromotionIndex); - expect(releaseSteps[completionIndex]?.if).toBe( - "${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }}", - ); - expect(releaseSteps[completionIndex]?.run).toContain("--status-payload"); - expect(releaseSteps[completionIndex]?.run).toContain("statuses/${RELEASE_SHA}"); expect( Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) => (job.steps ?? []).filter( diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 64981e3fcce4..bbcba83f7fb9 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2096,6 +2096,9 @@ describe("package acceptance workflow", () => { expect(workflow).toContain("main_ref: ${{ steps.inputs.outputs.main_ref }}"); expect(workflow).toContain("TRIGGER_SHA: ${{ github.sha }}"); expect(workflow).toContain('main_ref="$TRIGGER_SHA"'); + expect(workflow).toContain('classifyReleaseTrain(parsed) === "stable"'); + expect(workflow).toContain('classifyReleaseTrain(parsed) !== "stable"'); + expect(workflow).toContain("below the extended-stable .33 boundary"); expect(workflow).toContain("ref: ${{ needs.resolve.outputs.main_ref }}"); expect(workflow).toContain( "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence.", @@ -6575,98 +6578,6 @@ 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 extendedPrepareSteps = extendedPrepareJob.steps ?? []; - const extendedPrepareStepNames = extendedPrepareSteps.map((step) => step.name); - const extendedHarnessCheckoutIndex = extendedPrepareStepNames.indexOf( - "Checkout trusted release tooling", - ); - const extendedSetupIndex = extendedPrepareStepNames.indexOf("Setup Node environment"); - const extendedInstallIndex = extendedPrepareStepNames.indexOf( - "Install trusted release tooling dependencies", - ); - const extendedDraftIndex = extendedPrepareStepNames.indexOf( - "Create or resume the canonical draft release", - ); - const extendedDockerCompletionJob = workflowJob( - RELEASE_PUBLISH_WORKFLOW, - "verify_extended_stable_docker_completion", - ); - const extendedDockerCompletion = workflowStep( - extendedDockerCompletionJob, - "Verify durable Docker completion status", - ); - const extendedFinalizeJob = workflowJob( - RELEASE_PUBLISH_WORKFLOW, - "finalize_extended_stable_github_release", - ); - const extendedFinalize = workflowStep( - extendedFinalizeJob, - "Publish the verified extended-stable draft", - ); - const extendedFinalizeRun = extendedFinalize.run ?? ""; - expect(extendedPrepareJob.needs).toEqual(["resolve_release_target"]); - expect(extendedSetupIndex).toBeGreaterThan(extendedHarnessCheckoutIndex); - expect(extendedInstallIndex).toBeGreaterThan(extendedSetupIndex); - expect(extendedDraftIndex).toBeGreaterThan(extendedInstallIndex); - expect(workflowStep(extendedPrepareJob, "Setup Node environment").with).toMatchObject({ - "install-bun": "false", - "install-deps": "false", - }); - expect( - workflowStep(extendedPrepareJob, "Install trusted release tooling dependencies").run, - ).toContain("ln -s .release-harness/node_modules node_modules"); - expect(extendedPrepare.run).toContain("verify_release_tag_target"); - expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes"); - expect(extendedPrepare.run).toContain( - "node --import tsx .release-harness/scripts/render-github-release-notes.mts", - ); - expect(extendedPrepare.run).toContain("node --import tsx --input-type=module"); - expect(extendedPrepare.run).not.toContain("render-github-release-notes.mjs"); - expect(extendedPrepare.run).toContain("body !== expectedBody"); - expect(extendedPrepare.run).toContain("release.assets.length !== 0"); - expect(extendedPrepare.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); - expect(extendedPrepare.run).toContain("--find-statuses-file"); - expect(extendedPrepare.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); - expect(extendedPrepare.run).toContain("docker_already_published=true"); - expect(extendedPrepare.run).toContain("public without Docker completion"); - expect(extendedPrepare.run).toContain("--draft"); - expect(extendedPrepare.run).toContain("--latest=false"); - expect(extendedDockerCompletionJob.needs).toEqual([ - "resolve_release_target", - "prepare_extended_stable_release", - "publish_docker", - ]); - expect(extendedDockerCompletion.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); - expect(extendedDockerCompletion.run).toContain("--find-statuses-file"); - expect(extendedDockerCompletion.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); - expect(extendedDockerCompletion.run).toContain("Docker publication completed without"); - expect(extendedFinalizeJob.needs).toEqual([ - "resolve_release_target", - "prepare_extended_stable_release", - "verify_extended_stable_docker_completion", - ]); - expect(extendedFinalizeJob.if).toContain( - "needs.verify_extended_stable_docker_completion.result == 'success'", - ); - expect(extendedFinalizeRun).toContain("-f make_latest=false"); - expect(extendedFinalizeRun).toContain("EXPECTED_BODY_SHA256"); - expect(extendedFinalizeRun).toContain("release.assets.length !== 0"); - expect(extendedFinalizeRun).toContain("for attempt in $(seq 1 12)"); - expect(extendedFinalizeRun).toContain( - 'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"', - ); - expect(extendedFinalizeRun.indexOf("verify_release_tag_target\n")).toBeLessThan( - extendedFinalizeRun.indexOf('current_draft="$(gh api'), - ); }); 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 dddffac6d4d4..9b94acc3fc60 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1516,9 +1516,6 @@ describe("release validation no-push transport", () => { ]); expect(dockerCall.if).toContain("needs.publish.result == 'success'"); expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'"); - expect(dockerCall.if).toContain( - "needs.prepare_extended_stable_release.outputs.docker_already_published != 'true'", - ); expect(dockerCall.with).toEqual({ tag: "${{ inputs.tag }}", release_sha: "${{ needs.resolve_release_target.outputs.sha }}", @@ -1556,134 +1553,85 @@ describe("release validation no-push transport", () => { ]); const prepareRelease = job(releasePublish, "prepare_extended_stable_release"); + const prepareSteps = prepareRelease.steps ?? []; + const prepareStepNames = prepareSteps.map((workflowStep) => workflowStep.name); 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 prepareSteps = prepareRelease.steps ?? []; - const prepareStepNames = prepareSteps.map((workflowStep) => workflowStep.name); - const checkoutHarnessIndex = prepareStepNames.indexOf("Checkout trusted release tooling"); - const setupNodeIndex = prepareStepNames.indexOf("Setup Node environment"); - const installHarnessIndex = prepareStepNames.indexOf( - "Install trusted release tooling dependencies", - ); - const createDraftIndex = prepareStepNames.indexOf( - "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"); - const publishDraftRun = publishDraft.run ?? ""; + const renderNotes = step(prepareRelease, "Render canonical extended-stable release notes"); + const checkoutIndex = prepareStepNames.indexOf("Checkout trusted release tooling"); + const setupIndex = prepareStepNames.indexOf("Setup trusted release tooling"); + const renderIndex = prepareStepNames.indexOf("Render canonical extended-stable release notes"); + const uploadIndex = prepareStepNames.indexOf("Upload canonical extended-stable release notes"); 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({ - actions: "read", - contents: "write", - statuses: "read", - }); - expect(prepareRelease.outputs).toEqual({ - docker_already_published: "${{ steps.release.outputs.docker_already_published }}", - docker_status_run_id: "${{ steps.release.outputs.docker_status_run_id }}", - release_id: "${{ steps.release.outputs.release_id }}", - release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}", - }); - expect(setupNodeIndex).toBeGreaterThan(checkoutHarnessIndex); - expect(installHarnessIndex).toBeGreaterThan(setupNodeIndex); - expect(createDraftIndex).toBeGreaterThan(installHarnessIndex); - expect(step(prepareRelease, "Setup Node environment")).toMatchObject({ - uses: "./.github/actions/setup-node-env", - with: { - "install-bun": "false", - "install-deps": "false", - }, - }); - expect(step(prepareRelease, "Install trusted release tooling dependencies").run).toContain( - "--dir .release-harness", - ); - expect(step(prepareRelease, "Install trusted release tooling dependencies").run).toContain( - "ln -s .release-harness/node_modules node_modules", - ); + expect(prepareRelease.environment).toBeUndefined(); + expect(prepareRelease.permissions).toEqual({ contents: "read" }); + expect(setupIndex).toBeGreaterThan(checkoutIndex); + expect(renderIndex).toBeGreaterThan(setupIndex); + expect(uploadIndex).toBeGreaterThan(renderIndex); 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( - "node --import tsx .release-harness/scripts/render-github-release-notes.mts", - ); - expect(createDraft.run).toContain("node --import tsx --input-type=module"); - expect(createDraft.run).not.toContain("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("body !== expectedBody"); - expect(createDraft.run).toContain("release.assets.length !== 0"); - expect(createDraft.run).toContain('verify_release_resource "${release_id}" false true'); - expect(createDraft.run).toContain("wait_until_not_latest"); - expect(createDraft.run).toContain("Docker completion will be checked independently"); - expect(createDraft.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); - expect(createDraft.run).toContain("--find-statuses-file"); - expect(createDraft.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); - expect(createDraft.run).toContain("docker_already_published=true"); - expect(createDraft.run).toContain("public without Docker completion"); - expect(createDraft.run).toContain("wait_for_release_id"); - expect(createDraft.run).toContain('sha256sum "${notes_file}"'); + expect(step(prepareRelease, "Checkout trusted release tooling").with).toMatchObject({ + ref: "${{ github.sha }}", + "persist-credentials": false, + }); + expect(step(prepareRelease, "Setup trusted release tooling")).toMatchObject({ + uses: "./.github/actions/setup-node-env", + with: { "install-bun": "false" }, + }); + expect(renderNotes.run).toContain('git fetch --no-tags --depth=1 origin "${TARGET_SHA}"'); + expect(renderNotes.run).toContain('git show "${TARGET_SHA}:CHANGELOG.md"'); + expect(renderNotes.run).toContain("node --import tsx scripts/render-github-release-notes.mts"); + expect(renderNotes.run).not.toContain("render-github-release-notes.mjs"); + const uploadNotes = step(prepareRelease, "Upload canonical extended-stable release notes"); + expect(uploadNotes.with).toMatchObject({ + name: "extended-stable-release-notes-${{ inputs.tag }}", + "if-no-files-found": "error", + }); + + const finalizeRelease = job(releasePublish, "finalize_extended_stable_github_release"); + const downloadNotes = step(finalizeRelease, "Download canonical extended-stable release notes"); + const publishRelease = step(finalizeRelease, "Publish canonical extended-stable release"); + const publishReleaseRun = publishRelease.run ?? ""; - const verifyDockerCompletion = job(releasePublish, "verify_extended_stable_docker_completion"); - expect(verifyDockerCompletion.needs).toEqual([ - "resolve_release_target", - "prepare_extended_stable_release", - "publish_docker", - ]); - expect(verifyDockerCompletion.if).toContain( - "needs.prepare_extended_stable_release.outputs.docker_already_published == 'true'", - ); - const verifyDockerCompletionRun = step( - verifyDockerCompletion, - "Verify durable Docker completion status", - ).run; - expect(verifyDockerCompletionRun).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); - expect(verifyDockerCompletionRun).toContain("--find-statuses-file"); - expect(verifyDockerCompletionRun).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); expect(finalizeRelease.needs).toEqual([ "resolve_release_target", "prepare_extended_stable_release", - "verify_extended_stable_docker_completion", + "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.verify_extended_stable_docker_completion.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(downloadNotes.with).toMatchObject({ + name: "extended-stable-release-notes-${{ inputs.tag }}", }); - expect(publishDraftRun).toContain("repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}"); - expect(publishDraftRun).toContain("-F draft=false"); - expect(publishDraftRun).toContain("-F prerelease=false"); - expect(publishDraftRun).toContain("-f make_latest=false"); - expect(publishDraftRun).toContain("EXPECTED_BODY_SHA256"); - expect(publishDraftRun).toContain("release.assets.length !== 0"); - expect(publishDraftRun).toContain("releases/latest"); - expect(publishDraftRun).toContain("must not become GitHub Latest"); - expect(publishDraftRun).toContain("for attempt in $(seq 1 12)"); - expect(publishDraftRun).toContain( - "GITHUB_TOKEN publication intentionally does not fan out release.published", - ); - expect(publishDraftRun).toContain( - 'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"', - ); - expect(publishDraftRun.indexOf("verify_release_tag_target\n")).toBeLessThan( - publishDraftRun.indexOf('current_draft="$(gh api'), - ); + expect(publishReleaseRun).toContain("verify_release_tag_target"); + expect(publishReleaseRun).toContain('gh release create "${RELEASE_TAG}"'); + expect(publishReleaseRun).toContain("--verify-tag"); + expect(publishReleaseRun).not.toContain("--draft"); + expect(publishReleaseRun).not.toContain("--prerelease"); + expect(publishReleaseRun).toContain("--latest=false"); + expect(publishReleaseRun).toContain("release.assets.length !== 0"); + expect(publishReleaseRun).toContain('(release.body ?? "") !== expectedBody'); + expect(publishReleaseRun).toContain("verify_release_resource false"); + expect(publishReleaseRun).not.toContain("--draft=false"); + expect(publishReleaseRun).toContain("already public and canonical"); + expect(publishReleaseRun).toContain("releases/latest"); + expect(publishReleaseRun).toContain("must not be GitHub Latest"); + expect(publishReleaseRun).not.toContain("docker_already_published"); + expect(publishReleaseRun).not.toContain("/statuses"); + + const releasePublishText = readFileSync(releasePublishPath, "utf8"); + expect(releasePublishText).not.toContain("verify_extended_stable_docker_completion"); + expect(releasePublishText).not.toContain("Docker completion status"); + expect(JSON.stringify(dockerRelease)).not.toContain("statuses"); const identity = step( job(dockerRelease, "validate_release_identity"), @@ -1699,7 +1647,7 @@ describe("release validation no-push transport", () => { const releasePublish = readWorkflow(".github/workflows/openclaw-release-publish.yml"); const publishDraft = step( job(releasePublish, "finalize_extended_stable_github_release"), - "Publish the verified extended-stable draft", + "Publish canonical extended-stable release", ); const verifyTag = shellFunctionSource(publishDraft.run ?? "", "verify_release_tag_target"); const targetSha = "a".repeat(40); @@ -1738,7 +1686,7 @@ verify_release_tag_target const moved = runVerify(`${"c".repeat(40)}\trefs/tags/v2026.6.35\n`); expect(moved.status).toBe(1); - expect(moved.stderr).toContain(`must still resolve to ${targetSha}`); + expect(moved.stderr).toContain(`must resolve to ${targetSha}`); const missing = runVerify(""); expect(missing.status).toBe(1); diff --git a/test/scripts/upgrade-survivor-baselines.test.ts b/test/scripts/upgrade-survivor-baselines.test.ts index 3f8f61b195a5..d25e7651cf97 100644 --- a/test/scripts/upgrade-survivor-baselines.test.ts +++ b/test/scripts/upgrade-survivor-baselines.test.ts @@ -164,6 +164,32 @@ describe("scripts/resolve-upgrade-survivor-baselines", () => { }); }); + it("excludes extended-stable GitHub releases from regular stable baselines", () => { + const releases = [ + { + isPrerelease: false, + publishedAt: "2026-08-02T00:00:00Z", + tagName: "v2026.6.34", + }, + { + isPrerelease: false, + publishedAt: "2026-08-01T00:00:00Z", + tagName: "v2026.7.12", + }, + ]; + + withReleaseFixture(releases, (file) => { + expect( + resolveBaselines( + new Map([ + ["requested", "last-stable-1"], + ["releases-json", file], + ]), + ), + ).toEqual(["openclaw@2026.7.12"]); + }); + }); + it("rejects loose release-history count values", () => { withReleaseFixture([], (file) => { expect(() =>