diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index d5bb6566ef17..411e1896bf56 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -292,7 +292,9 @@ on pinned current `main` as the exact command and validation contract. 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, + 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. diff --git a/.agents/skills/verify-release/SKILL.md b/.agents/skills/verify-release/SKILL.md index 14a3cce2610e..c29c4651ea4f 100644 --- a/.agents/skills/verify-release/SKILL.md +++ b/.agents/skills/verify-release/SKILL.md @@ -101,8 +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. Repair aliases through current-main `Docker Channel -Promotion` for the exact tag, without rebuilding. + 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. 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 f2e52d49ecd8..6f3f5e2d64a4 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -756,6 +756,7 @@ jobs: permissions: contents: read packages: write + statuses: write steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -763,6 +764,14 @@ 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 @@ -912,3 +921,32 @@ jobs: --version "${VERSION}" \ --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 7f7bc28de967..5d901bbc21f8 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -103,12 +103,14 @@ jobs: timeout-minutes: 20 outputs: sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }} + expected_validation_branch: ${{ steps.inputs.outputs.expected_validation_branch }} preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }} preflight_tarball_sha256: ${{ steps.manifest.outputs.tarball_sha256 }} full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }} windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }} steps: - name: Validate inputs + id: inputs env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} @@ -247,6 +249,16 @@ jobs: exit 1 ;; esac + expected_validation_branch="${WORKFLOW_REF#refs/heads/}" + 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 + exit 1 + fi + expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33" + fi + echo "expected_validation_branch=${expected_validation_branch}" >> "$GITHUB_OUTPUT" - name: Validate stable Windows source release id: windows_source @@ -486,7 +498,7 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} EXPECTED_SHA: ${{ steps.ref.outputs.sha }} EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }} - EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} + EXPECTED_WORKFLOW_BRANCH: ${{ steps.inputs.outputs.expected_validation_branch }} RUN_JSON_FILE: ${{ runner.temp }}/full-release-validation-run.json TRUSTED_MAIN_REF: refs/remotes/origin/main VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs @@ -537,6 +549,8 @@ jobs: - name: Validate release tag is reachable from a trusted release branch env: + EXPECTED_VALIDATION_BRANCH: ${{ steps.inputs.outputs.expected_validation_branch }} + PUBLISH_DOCKER_ONLY: ${{ inputs.publish_docker_only && 'true' || 'false' }} RELEASE_TAG: ${{ inputs.tag }} WORKFLOW_REF_NAME: ${{ github.ref_name }} run: | @@ -545,6 +559,15 @@ jobs: +refs/heads/main:refs/remotes/origin/main \ '+refs/heads/release/*:refs/remotes/origin/release/*' \ '+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*' + if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then + expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}" + if git show-ref --verify --quiet "${expected_ref}" && \ + git merge-base --is-ancestor HEAD "${expected_ref}"; then + exit 0 + fi + echo "Extended-stable tag ${RELEASE_TAG} must be reachable from ${EXPECTED_VALIDATION_BRANCH}." >&2 + exit 1 + fi if git merge-base --is-ancestor HEAD origin/main; then exit 0 fi @@ -2385,11 +2408,14 @@ jobs: 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 }} - release_already_public: ${{ steps.release.outputs.release_already_public }} steps: - name: Verify exact npm and selector readback matches preflight bytes env: @@ -2550,7 +2576,7 @@ jobs: - Scope: \`openclaw@${release_version}\`, official npm plugins, and Gateway container images only. - Channel: npm and container \`extended-stable\`; regular \`latest\` is unchanged. - - Container publication: this draft is made public only after Docker publication succeeds. + - Container publication: this closeout requires durable Docker completion on the immutable release SHA. - Excluded: ClawHub, macOS, Windows, mobile, website, and private dist-tags. EOF node .release-harness/scripts/render-github-release-notes.mjs \ @@ -2560,7 +2586,7 @@ jobs: --verification-file "${verification_file}" \ --output "${notes_file}" - release_already_public=false + release_is_public=false release_id="$(resolve_release_id)" if [[ -n "${release_id}" ]]; then verify_release_resource "${release_id}" @@ -2591,8 +2617,8 @@ jobs: elif [[ "${release_draft}" == "false" ]]; then verify_release_resource "${release_id}" false true wait_until_not_latest - release_already_public=true - echo "Existing canonical public release is already complete; Docker will not be rebuilt." + 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 @@ -2600,11 +2626,39 @@ jobs: 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}/status" > "${docker_status_file}" + docker_status_run_id="$(node .release-harness/scripts/docker-channel-promote.mjs \ + --find-status-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 "release_already_public=${release_already_public}" >> "$GITHUB_OUTPUT" - if [[ "${release_already_public}" == "true" ]]; then - echo "- Extended-stable GitHub release: already public; Docker rebuild skipped" >> "$GITHUB_STEP_SUMMARY" + 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 @@ -2612,7 +2666,7 @@ jobs: 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.release_already_public != 'true')) }} + 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')) }} uses: ./.github/workflows/docker-release.yml with: tag: ${{ inputs.tag }} @@ -2623,6 +2677,7 @@ jobs: permissions: contents: read packages: write + statuses: write finalize_github_release: name: Finalize GitHub release @@ -2651,10 +2706,76 @@ 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}/status" > "${status_file}" + docker_status_run_id="$(node scripts/docker-channel-promote.mjs \ + --find-status-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, publish_docker] - if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.publish_docker.result == 'success' }} + 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' }} runs-on: ubuntu-latest environment: npm-release permissions: diff --git a/docs/ci.md b/docs/ci.md index e2fd3f486cd0..387a2d7717c6 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -317,9 +317,12 @@ 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. +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. ```bash gh workflow run openclaw-release-publish.yml \ diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 13b1cebd1503..20c934e1a7da 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -204,7 +204,11 @@ saved npm preflight artifact, exact `Full Release Validation` evidence, exact np 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 +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: ```bash diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 4538bf87d1e3..9d0f993bd25b 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -52,6 +52,11 @@ gh workflow run full-release-validation.yml \ -f release_profile=stable ``` +The Docker-only 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. + 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, diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs index 41e3cb6993f3..8268ef1d5429 100644 --- a/scripts/docker-channel-promote.mjs +++ b/scripts/docker-channel-promote.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import process from "node:process"; import { parseArgs } from "node:util"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; @@ -18,6 +19,104 @@ const VARIANTS = Object.freeze([ { aliasKey: "slim", suffix: "-slim" }, { aliasKey: "browser", suffix: "-browser" }, ]); +const DOCKER_PUBLICATION_STATUS_DESCRIPTION = + "Verified GHCR + Docker Hub images, attestations, platforms, and channel aliases."; +const DOCKER_PUBLICATION_STATUS_PREFIX = "openclaw/docker-release"; + +/** @typedef {{ repository: string; sourceSha: string; version: string }} DockerPublicationIdentity */ +/** @typedef {DockerPublicationIdentity & { runId: number | string }} DockerPublicationStatusParams */ +/** @typedef {{ context: string; description: string; state: "success"; target_url: string }} DockerPublicationStatus */ +/** + * @typedef {object} GitHubCommitStatus + * @property {unknown} [context] + * @property {{ login?: unknown }} [creator] + * @property {unknown} [description] + * @property {unknown} [state] + * @property {unknown} [target_url] + */ +/** @typedef {{ sha?: unknown; statuses?: GitHubCommitStatus[] }} GitHubCombinedStatus */ + +/** @param {DockerPublicationIdentity} params */ +function requireExtendedStableStatusIdentity({ version, repository, sourceSha }) { + const policy = resolveDockerReleasePolicy(version); + if (policy.channel !== "extended-stable") { + throw new Error(`Docker completion status is only valid for extended-stable; got ${version}.`); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error(`Invalid GitHub repository identity ${JSON.stringify(repository)}.`); + } + if (!/^[a-f0-9]{40}$/u.test(sourceSha)) { + throw new Error("Docker completion status requires a full lowercase source SHA."); + } + return policy; +} + +/** + * Build the durable commit status written only after Docker verification completes. + * + * @param {DockerPublicationStatusParams} params + * @returns {DockerPublicationStatus} + */ +export function createDockerPublicationStatus({ version, repository, sourceSha, runId }) { + const policy = requireExtendedStableStatusIdentity({ version, repository, sourceSha }); + if (!/^[1-9][0-9]*$/u.test(String(runId))) { + throw new Error("Docker completion status requires a positive workflow run ID."); + } + return { + context: `${DOCKER_PUBLICATION_STATUS_PREFIX}/${policy.version}`, + description: DOCKER_PUBLICATION_STATUS_DESCRIPTION, + state: "success", + target_url: `https://github.com/${repository}/actions/runs/${runId}`, + }; +} + +/** + * Resolve a canonical Docker completion status from GitHub's combined-status response. + * + * @param {DockerPublicationIdentity & { combinedStatus: unknown }} params + * @returns {{ runId: string; targetUrl: string } | null} + */ +export function findDockerPublicationStatus({ combinedStatus, version, repository, sourceSha }) { + const expected = createDockerPublicationStatus({ + version, + repository, + sourceSha, + runId: 1, + }); + const response = /** @type {GitHubCombinedStatus} */ (combinedStatus); + if (response?.sha !== sourceSha || !Array.isArray(response?.statuses)) { + throw new Error("GitHub combined status is not bound to the expected release SHA."); + } + const matches = response.statuses.filter( + (status) => String(status?.context ?? "").toLowerCase() === expected.context.toLowerCase(), + ); + if (matches.length === 0) { + return null; + } + if (matches.length !== 1) { + throw new Error( + `GitHub returned duplicate Docker completion statuses for ${expected.context}.`, + ); + } + const status = matches[0]; + const targetMatch = new RegExp( + `^https://github\\.com/${repository.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/actions/runs/([1-9][0-9]*)$`, + "u", + ).exec(String(status.target_url ?? "")); + if ( + status.state !== expected.state || + status.context !== expected.context || + status.description !== expected.description || + status.creator?.login !== "github-actions[bot]" || + !targetMatch + ) { + throw new Error(`Docker completion status ${expected.context} is not canonical.`); + } + return { + runId: targetMatch[1], + targetUrl: String(status.target_url), + }; +} /** @typedef {{ images: string[]; version: string }} DockerPromotionParams */ /** @@ -277,6 +376,8 @@ export function promoteDockerChannel({ version, images }, options = {}) { function printHelp() { console.log( "Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]", + " node scripts/docker-channel-promote.mjs --status-payload --version YYYY.M.P --repository OWNER/REPO --source-sha SHA --run-id ID", + " node scripts/docker-channel-promote.mjs --find-status-file FILE --version YYYY.M.P --repository OWNER/REPO --source-sha SHA", ); } @@ -285,8 +386,13 @@ function main() { args: process.argv.slice(2), options: { "allow-rollback": { type: "boolean" }, + "find-status-file": { type: "string" }, help: { type: "boolean", short: "h" }, image: { type: "string", multiple: true }, + repository: { type: "string" }, + "run-id": { type: "string" }, + "source-sha": { type: "string" }, + "status-payload": { type: "boolean" }, version: { type: "string" }, }, strict: true, @@ -299,6 +405,28 @@ 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-status-file"]) { + const match = findDockerPublicationStatus({ + combinedStatus: JSON.parse(readFileSync(values["find-status-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/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts index 83adb6796acc..6ff1907a722d 100644 --- a/test/scripts/docker-channel-promote.test.ts +++ b/test/scripts/docker-channel-promote.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import { createDockerChannelPromotionPlan, + createDockerPublicationStatus, + findDockerPublicationStatus, promoteDockerChannel, } from "../../scripts/docker-channel-promote.mjs"; @@ -80,6 +82,72 @@ function requireJob(workflow: Workflow, name: string): WorkflowJob { } describe("Docker channel promotion", () => { + it("binds durable extended-stable completion to the release SHA and workflow run", () => { + 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({ + combinedStatus: { + sha: sourceSha, + statuses: [{ ...payload, creator: { login: "github-actions[bot]" } }], + }, + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + }), + ).toEqual({ runId: "12345", targetUrl: payload.target_url }); + }); + + it("does not accept release visibility or malformed status as Docker completion", () => { + const sourceSha = "a".repeat(40); + expect( + findDockerPublicationStatus({ + combinedStatus: { sha: sourceSha, statuses: [] }, + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + }), + ).toBeNull(); + + const payload = createDockerPublicationStatus({ + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + runId: "12345", + }); + for (const status of [ + { ...payload, state: "pending", creator: { login: "github-actions[bot]" } }, + { ...payload, creator: { login: "someone-else" } }, + { + ...payload, + description: "images probably published", + creator: { login: "github-actions[bot]" }, + }, + ]) { + expect(() => + findDockerPublicationStatus({ + combinedStatus: { sha: sourceSha, statuses: [status] }, + 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", @@ -379,7 +447,11 @@ describe("Docker channel promotion", () => { "cancel-in-progress": false, queue: "max", }); - expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" }); + expect(verifyAttestations.permissions).toEqual({ + contents: "read", + packages: "write", + statuses: "write", + }); const manifestTagStep = createManifest.steps?.find( (step) => step.name === "Resolve manifest tags", @@ -406,9 +478,21 @@ 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((step) => step.run?.includes("docker-channel-promote.mjs")), + (job.steps ?? []).filter( + (step) => + step.run?.includes("docker-channel-promote.mjs") && step.run.includes("--image"), + ), ), ).toHaveLength(1); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b834f56d0ff0..b3b15de1d26a 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -632,11 +632,13 @@ function runReleasePublishInputValidation(overrides: Record) { if (!script) { throw new Error("Expected release publish input validation script"); } + const githubOutput = resolve(tempDirs.make("release-publish-inputs-"), "github-output"); return spawnSync("bash", ["-c", script], { encoding: "utf8", env: { FULL_RELEASE_VALIDATION_RUN_ATTEMPT: "1", FULL_RELEASE_VALIDATION_RUN_ID: "222", + GITHUB_OUTPUT: githubOutput, OPENCLAW_NPM_RESUME_RUN_ID: "", PATH: process.env.PATH, PLUGINS: "", @@ -5284,6 +5286,7 @@ describe("package artifact reuse", () => { ); expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}"); expect(validateManifest.env).toMatchObject({ + EXPECTED_WORKFLOW_BRANCH: "${{ steps.inputs.outputs.expected_validation_branch }}", RUN_JSON_FILE: "${{ runner.temp }}/full-release-validation-run.json", TRUSTED_MAIN_REF: "refs/remotes/origin/main", VALIDATOR_FILE: @@ -5352,6 +5355,14 @@ describe("package artifact reuse", () => { extendedPrepareJob, "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", @@ -5365,15 +5376,26 @@ describe("package artifact reuse", () => { expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes"); expect(extendedPrepare.run).toContain("body !== expectedBody"); expect(extendedPrepare.run).toContain("release.assets.length !== 0"); - expect(extendedPrepare.run).toContain("release_already_public=true"); + expect(extendedPrepare.run).toContain("--find-status-file"); + 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(extendedFinalizeJob.needs).toEqual([ + expect(extendedDockerCompletionJob.needs).toEqual([ "resolve_release_target", "prepare_extended_stable_release", "publish_docker", ]); - expect(extendedFinalizeJob.if).toContain("needs.publish_docker.result == 'success'"); + expect(extendedDockerCompletion.run).toContain("--find-status-file"); + 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(extendedFinalize.run).toContain("-f make_latest=false"); expect(extendedFinalize.run).toContain("EXPECTED_BODY_SHA256"); expect(extendedFinalize.run).toContain("release.assets.length !== 0"); diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index dd2525ab1c5e..d11d336d4663 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1013,6 +1013,13 @@ describe("release validation no-push transport", () => { const releasePublishPath = ".github/workflows/openclaw-release-publish.yml"; const releasePublish = readWorkflow(releasePublishPath); const dockerCall = job(releasePublish, "publish_docker"); + const resolveTarget = job(releasePublish, "resolve_release_target"); + const validateInputs = step(resolveTarget, "Validate inputs"); + const validateEvidence = step(resolveTarget, "Validate full release validation manifest"); + const validateReleaseBranch = step( + resolveTarget, + "Validate release tag is reachable from a trusted release branch", + ); expect(dockerRelease.on?.push).toBeUndefined(); expect(dockerRelease.on?.workflow_dispatch).toBeUndefined(); @@ -1035,6 +1042,20 @@ describe("release validation no-push transport", () => { .toSorted(); expect(callers).toEqual(["openclaw-release-publish.yml"]); + expect(validateInputs.id).toBe("inputs"); + expect(validateInputs.run).toContain( + 'expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"', + ); + expect(validateEvidence.env?.EXPECTED_WORKFLOW_BRANCH).toBe( + "${{ steps.inputs.outputs.expected_validation_branch }}", + ); + expect(validateReleaseBranch.run).toContain( + 'expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}"', + ); + expect(validateReleaseBranch.run).toContain( + "must be reachable from ${EXPECTED_VALIDATION_BRANCH}", + ); + expect(dockerCall.needs).toEqual([ "resolve_release_target", "publish", @@ -1043,7 +1064,7 @@ 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.release_already_public != 'true'", + "needs.prepare_extended_stable_release.outputs.docker_already_published != 'true'", ); expect(dockerCall.with).toEqual({ tag: "${{ inputs.tag }}", @@ -1085,11 +1106,16 @@ describe("release validation no-push transport", () => { 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.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 }}", - release_already_public: "${{ steps.release.outputs.release_already_public }}", }); expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version'); expect(verifyNpm.run).toContain("Published npm tarball does not match"); @@ -1104,21 +1130,37 @@ describe("release validation no-push transport", () => { 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("release_already_public=true"); - expect(createDraft.run).toContain("Docker will not be rebuilt"); + expect(createDraft.run).toContain("Docker completion will be checked independently"); + expect(createDraft.run).toContain("--find-status-file"); + 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(finalizeRelease.needs).toEqual([ + 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'", + ); + expect(step(verifyDockerCompletion, "Verify durable Docker completion status").run).toContain( + "--find-status-file", + ); + expect(finalizeRelease.needs).toEqual([ + "resolve_release_target", + "prepare_extended_stable_release", + "verify_extended_stable_docker_completion", + ]); 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.if).toContain( + "needs.verify_extended_stable_docker_completion.result == 'success'", + ); expect(finalizeRelease.environment).toBe("npm-release"); expect(finalizeRelease.permissions).toEqual({ contents: "write" }); expect(publishDraft.env).toMatchObject({ diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index c5476ec9e0da..484be191109e 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -154,6 +154,25 @@ describe("full release validation evidence", () => { } }); + it("accepts direct evidence from the derived canonical extended-stable branch", () => { + const branch = "extended-stable/2026.6.33"; + const result = validateFullReleaseValidationEvidence({ + run: releaseRun({ head_branch: branch }), + manifest: releaseManifest({ + workflowRef: branch, + workflowFullRef: `refs/heads/${branch}`, + targetRef: "v2026.6.35", + }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedWorkflowBranch: branch, + isTrustedMainAncestor: () => false, + }); + + expect(result.source).toBe("direct"); + }); + it("rejects direct main evidence outside current main", () => { expect(() => validate(