From 74cc99f2d117556fb3f8470a60a721b6e7e23ab7 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 23 Jul 2026 10:03:11 +0900 Subject: [PATCH] fix(release): publish extended-stable Docker channels safely (#112494) * fix(release): isolate extended-stable Docker aliases * fix(release): harden Docker channel promotion * docs(release): pin Docker policy into tagged tree * refactor(release): isolate Docker channel promotion * fix(release): queue Docker publications * fix(release): harden docker channel promotion * docs(release): tighten extended-stable guidance * fix(release): promote Docker aliases after verification * chore(release): format maintainer instructions * refactor(release): separate release version policy * docs(release): clarify extended-stable gateway scope * fix(release): harden Docker channel promotion --- .../release-openclaw-maintainer/SKILL.md | 31 +- .../references/extended-stable-backports.md | 35 +- .github/actionlint.yaml | 7 + .github/workflows/docker-channel-promote.yml | 182 +++++++ .github/workflows/docker-release.yml | 104 ++-- .github/workflows/plugin-npm-release.yml | 1 + docs/ci.md | 11 +- docs/docs_map.md | 2 +- docs/install/docker.md | 2 +- docs/reference/RELEASING.md | 48 +- scripts/ci-changed-scope.mjs | 2 +- scripts/docker-channel-promote.d.mts | 37 ++ scripts/docker-channel-promote.mjs | 288 +++++++++++ .../lib/upgrade-survivor/config-recipe.mjs | 2 +- scripts/lib/android-version.ts | 2 +- scripts/lib/docker-release-policy.d.mts | 15 + scripts/lib/docker-release-policy.mjs | 80 ++++ scripts/lib/ios-version.ts | 2 +- scripts/lib/npm-publish-plan.d.mts | 16 - scripts/lib/npm-publish-plan.mjs | 192 +------- scripts/lib/plugin-clawhub-release.ts | 1 + scripts/lib/plugin-npm-release.ts | 7 +- scripts/lib/release-upgrade-baseline.mjs | 2 +- scripts/lib/release-version.d.mts | 23 + scripts/lib/release-version.mjs | 203 ++++++++ ...openclaw-npm-extended-stable-release.d.mts | 2 +- .../openclaw-npm-extended-stable-release.mjs | 16 +- scripts/openclaw-npm-postpublish-verify.ts | 5 +- scripts/openclaw-npm-release-check.ts | 27 +- scripts/release-preflight.mjs | 2 +- scripts/release-prepare.ts | 2 +- scripts/release-version.ts | 2 +- .../resolve-upgrade-survivor-baselines.mjs | 2 +- scripts/test-projects.test-support.mjs | 24 + scripts/verify-docker-attestations.d.mts | 10 + scripts/verify-docker-attestations.mjs | 71 +-- src/dockerfile.test.ts | 16 +- src/scripts/ci-changed-scope.test.ts | 2 +- test/npm-publish-plan.test.ts | 18 - test/release-version.test.ts | 45 ++ test/scripts/docker-channel-promote.test.ts | 449 ++++++++++++++++++ test/scripts/docker-release-policy.test.ts | 47 ++ ...enclaw-npm-extended-stable-release.test.ts | 5 + test/scripts/openclaw-npm-publish.test.ts | 4 + test/scripts/test-projects.test.ts | 23 + 45 files changed, 1683 insertions(+), 384 deletions(-) create mode 100644 .github/workflows/docker-channel-promote.yml create mode 100644 scripts/docker-channel-promote.d.mts create mode 100644 scripts/docker-channel-promote.mjs create mode 100644 scripts/lib/docker-release-policy.d.mts create mode 100644 scripts/lib/docker-release-policy.mjs create mode 100644 scripts/lib/release-version.d.mts create mode 100644 scripts/lib/release-version.mjs create mode 100644 test/release-version.test.ts create mode 100644 test/scripts/docker-channel-promote.test.ts create mode 100644 test/scripts/docker-release-policy.test.ts diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 225db27a5e25..c59e66108eec 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -224,20 +224,26 @@ this release skill, not as a separate release workflow. The backport ability owns the complete mainline inventory, private-security reconciliation, candidate decisions, maintainer approval, coordinated staging -PR, and proof handoff. After that PR lands, use the dedicated npm-only sequence +PR, and proof handoff. After that PR lands, use the dedicated extended-stable sequence below. Never route `.33+` through the regular beta/stable release sequence. -## Publish extended-stable releases +## Publish Gateway extended-stable releases -Use this path only for the trailing completed month's `.33+` line. Treat +Use this path only for the trailing completed month's `.33+` Gateway +distribution: the `openclaw` npm package, official npm plugins, and matching +Docker Gateway images. Treat `docs/reference/RELEASING.md`, `scripts/openclaw-npm-extended-stable-release.mjs`, and the release workflows on pinned current `main` as the exact command and validation contract. 1. Check out the canonical `extended-stable/YYYY.M.33` branch after the approved backport PR lands. Freeze its full 40-character SHA after verifying - the root and every publishable official plugin have the intended version. Do - not create the final tag yet. + the root and every publishable official plugin have the intended version. + Backport the complete current-main Docker release-channel change, including + its workflow, promoter, policy, shared release-version classifier, tests, + and workflow validation changes. Do not tag yet; tag-push workflows use + that code, which must not route `.33+` to regular stable aliases or fail + from a partial copy. 2. Dispatch `openclaw-npm-release.yml` from that canonical branch with the frozen SHA as `tag`, `preflight_only=true`, and `npm_dist_tag=extended-stable`. A full SHA is a validation-only candidate @@ -253,7 +259,7 @@ on pinned current `main` as the exact command and validation contract. not create, delete, or move a final `vYYYY.M.P` tag for candidate validation. 5. Only after the candidate gates are green, re-resolve the canonical branch tip and require it still equals the validated SHA. Create and push the - immutable final `vYYYY.M.P` tag at that SHA. Never move or delete a final + signed final `vYYYY.M.P` tag at that SHA. Never move or delete a final extended-stable tag: a post-tag code change needs a new patch version and a new candidate. 6. Dispatch `plugin-npm-release.yml` from the same branch with @@ -271,9 +277,16 @@ on pinned current `main` as the exact command and validation contract. an official-plugin selector is missing or stale for an already-published version, use the approved credential-isolated release tooling for manual plugin tag repair; the OIDC source workflow cannot mutate that tag. Never - republish an immutable version. -9. Do not create a GitHub Release or publish macOS, Windows, Docker, mobile, - website, ClawHub, or private dist-tag artifacts from this path. + republish the same version. +9. Require `Docker Release` to publish and verify the exact default, slim, + browser, and architecture images in both registries before its final step + promotes the three extended-stable aliases through the shared promotion + script. For alias repair, dispatch + `docker-channel-promote.yml` from current `main` with the exact tag; never + rebuild or move the release tag. +10. Do not create a GitHub Release or publish the macOS app, Windows Hub, + mobile apps, website downloads, ClawHub packages, 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 5e8ae5f706a3..2fe2bc0dbcda 100644 --- a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md @@ -1,9 +1,10 @@ # Extended-Stable Backport Preparation -Prepare the next npm maintenance patch for the active `extended-stable` line. -Discover the complete candidate set, obtain maintainer approval, and prepare -the approved commits as one coordinated PR. Treat commits as canonical; use -PRs, issues, ClawSweeper reports, and advisories as supporting context. +Prepare the next Gateway patch for the active `extended-stable` line: the +`openclaw` npm package, official npm plugins, and matching Docker Gateway +images. Discover the complete candidate set, obtain approval, and prepare one +coordinated PR. Commits are canonical; PRs, issues, ClawSweeper reports, and +advisories provide context. Read `backport-discovery.md` first. Its evidence-driven inventory, detached baseline applicability probes, advisory reconciliation, and durable unreleased @@ -15,15 +16,16 @@ extended-stable package and publication constraints. - Read `docs/reference/RELEASING.md`, `scripts/openclaw-npm-extended-stable-release.mjs`, and the relevant release workflows from a pinned current `origin/main` before resolving the line. -- Target npm `extended-stable` and the canonical - `extended-stable/YYYY.M.33` branch. The user-facing `extended-stable` update - channel resolves that selector; user-facing `stable` continues to resolve - npm `latest`. +- Target npm and Docker `extended-stable` on + `extended-stable/YYYY.M.33`; user-facing `stable` remains npm `latest`. - Cover the core `openclaw` package and every npm-publishable official plugin included by the canonical `all-publishable` release inventory at the same exact version. -- Exclude ClawHub publication, GitHub Releases, native apps, Docker images, - mobile artifacts, website downloads, and private-repository dist-tags. +- Carry the complete current-main Docker release-channel change in the tagged + tree, including its workflow, promoter, policy, shared release-version + classifier, tests, and workflow validation changes. +- Exclude ClawHub publication, GitHub Releases, the macOS app, Windows Hub, + mobile apps, website downloads, and private-repository dist-tags. - 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. @@ -148,9 +150,10 @@ contracts. ## Filter by Publication Surface -Include only fixes that affect the core package or an npm-publishable official -plugin in the exact release inventory. Prove package inclusion rather than -inferring it from the source path alone. +Include only fixes that affect the core package, an npm-publishable official +plugin in the exact release inventory, or the official Docker image/runtime +path. Prove package or image inclusion rather than inferring it from the source +path alone. - Do not exclude `extensions/**` by path. Determine whether the package appears in the canonical `all-publishable` inventory. @@ -158,8 +161,8 @@ inferring it from the source path alone. at the same intended version and can verify its exact package and selector. - Treat ClawHub-only, external, private, or otherwise unlisted plugin changes as out of scope. -- Treat native-only, Docker-only, mobile-only, website-only, and GitHub - Release-only fixes as `skip` for this npm-only line. +- Treat macOS-app-only, Windows-Hub-only, mobile-only, website-only, and GitHub + Release-only fixes as `skip` for this Gateway extended-stable line. - Treat cross-repository or package-topology uncertainty as `blocked` until the shipped npm surface and release owner are proven. @@ -281,5 +284,5 @@ exact package and `extended-stable` selector; and preserve the generated core `openclaw` selector-repair command. Repair missing or stale official- plugin selectors on already-published versions with the approved credential- isolated release tooling for manual tag repair; the OIDC source workflow cannot -mutate those tags. Never republish an immutable version when only a selector +mutate those tags. Never republish the same version when only a selector needs repair. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index e0cf95b4e97a..29b4c9ad13f5 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -29,3 +29,10 @@ paths: - 'constant expression "false" in condition' # actionlint's built-in runner label allowlist lags Blacksmith additions. - 'label "blacksmith-16vcpu-[^"]+" is unknown\.' + # GitHub Actions supports concurrency.queue, but actionlint does not yet model it. + .github/workflows/docker-release.yml: + ignore: + - 'unexpected key "queue" for "concurrency" section' + .github/workflows/docker-channel-promote.yml: + ignore: + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/docker-channel-promote.yml b/.github/workflows/docker-channel-promote.yml new file mode 100644 index 000000000000..d8670f7143fd --- /dev/null +++ b/.github/workflows/docker-channel-promote.yml @@ -0,0 +1,182 @@ +name: Docker Channel Promotion + +on: + workflow_dispatch: + inputs: + tag: + description: Existing stable or extended-stable release tag + required: true + type: string + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DOCKERHUB_REGISTRY: docker.io + DOCKERHUB_IMAGE_NAME: openclaw/openclaw + +jobs: + resolve: + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + version: ${{ steps.policy.outputs.version }} + channel: ${{ steps.policy.outputs.channel }} + default_aliases: ${{ steps.policy.outputs.default_aliases }} + slim_aliases: ${{ steps.policy.outputs.slim_aliases }} + browser_aliases: ${{ steps.policy.outputs.browser_aliases }} + steps: + - name: Require a main-branch dispatch + env: + WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then + echo "::error::Docker channel promotion must be dispatched from main; got ${WORKFLOW_REF}." + exit 1 + fi + + - name: Checkout trusted promotion tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve release channel policy + id: policy + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then + echo "::error::Expected a final stable or extended-stable release tag; got ${RELEASE_TAG}." + exit 1 + fi + git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}" >/dev/null + version="${RELEASE_TAG#v}" + policy="$(node scripts/lib/docker-release-policy.mjs "${version}")" + channel="$(jq -r '.channel' <<< "${policy}")" + default_aliases="$(jq -r '.movingAliases.default | join(" ")' <<< "${policy}")" + slim_aliases="$(jq -r '.movingAliases.slim | join(" ")' <<< "${policy}")" + browser_aliases="$(jq -r '.movingAliases.browser | join(" ")' <<< "${policy}")" + { + echo "version=${version}" + echo "channel=${channel}" + echo "default_aliases=${default_aliases}" + echo "slim_aliases=${slim_aliases}" + echo "browser_aliases=${browser_aliases}" + } >> "$GITHUB_OUTPUT" + { + echo "## Docker channel promotion plan" + echo "- Version: ${version}" + echo "- Channel: ${channel}" + echo "- Default aliases: ${default_aliases}" + echo "- Slim aliases: ${slim_aliases}" + echo "- Browser aliases: ${browser_aliases}" + } >> "$GITHUB_STEP_SUMMARY" + + approve: + name: Approve ${{ inputs.tag }} to ${{ needs.resolve.outputs.channel }} (${{ needs.resolve.outputs.default_aliases }}) + needs: resolve + # Keep human approval outside the queued writer so waiting for approval + # cannot block a tag-driven Docker release in docker-release-publish. + # WARNING: KEEP CHANNEL PROMOTION GATED BY THE docker-release ENVIRONMENT. + runs-on: ubuntu-24.04 + environment: docker-release + permissions: {} + steps: + - name: Record approval + env: + RELEASE_TAG: ${{ inputs.tag }} + DEFAULT_ALIASES: ${{ needs.resolve.outputs.default_aliases }} + SLIM_ALIASES: ${{ needs.resolve.outputs.slim_aliases }} + BROWSER_ALIASES: ${{ needs.resolve.outputs.browser_aliases }} + run: | + echo "Approved Docker channel promotion for ${RELEASE_TAG}" + echo "Default aliases: ${DEFAULT_ALIASES}" + echo "Slim aliases: ${SLIM_ALIASES}" + echo "Browser aliases: ${BROWSER_ALIASES}" + + promote: + needs: [resolve, approve] + runs-on: ubuntu-24.04 + concurrency: + group: docker-release-publish + cancel-in-progress: false + queue: max + permissions: + contents: read + packages: write + steps: + - name: Validate Docker Hub publish credentials + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::error::Docker Hub publishing requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets." + exit 1 + fi + + - name: Checkout trusted promotion tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Pre-pull BuildKit image + shell: bash + env: + BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1 + run: | + set -euo pipefail + for attempt in 1 2 3 4; do + if docker pull "${BUILDKIT_IMAGE}"; then + exit 0 + fi + if [[ "${attempt}" -eq 4 ]]; then + echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts." + exit 1 + fi + sleep "$((attempt * 5))" + done + + - name: Set up Docker Builder + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + registry: ${{ env.DOCKERHUB_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Promote and verify channel aliases + env: + VERSION: ${{ needs.resolve.outputs.version }} + GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} + run: | + set -euo pipefail + node scripts/docker-channel-promote.mjs \ + --version "${VERSION}" \ + --image "${GHCR_IMAGE}" \ + --image "${DOCKERHUB_IMAGE}" \ + --allow-rollback + { + echo "## Docker channel promotion" + echo "- Version: ${VERSION}" + echo "- Registries: ${GHCR_IMAGE}, ${DOCKERHUB_IMAGE}" + echo "- Rollback: explicitly approved" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 0cfdd26bc691..a0f99539190f 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -14,13 +14,14 @@ on: workflow_dispatch: inputs: tag: - description: Existing stable or beta release tag to backfill (for example v2026.3.22 or v2026.3.22-beta.1) + description: Existing stable, extended-stable, or beta release tag required: true type: string concurrency: - group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }} + group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || 'docker-release-publish' }} cancel-in-progress: false + queue: max env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -45,7 +46,7 @@ jobs: echo "Docker alpha image publishing is disabled." exit 1 fi - if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(beta\.)?[1-9][0-9]*)?$ ]]; then echo "Invalid release tag: ${RELEASE_TAG}" exit 1 fi @@ -56,9 +57,51 @@ jobs: ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 - approve_manual_backfill: - if: github.event_name == 'workflow_dispatch' + resolve_release_policy: needs: validate_manual_backfill + if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.validate_manual_backfill.result == 'success') }} + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + version: ${{ steps.policy.outputs.version }} + channel: ${{ steps.policy.outputs.channel }} + steps: + - name: Checkout trusted workflow helpers + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.sha }} + path: workflow-source + persist-credentials: false + + - name: Resolve release channel policy + id: policy + shell: bash + env: + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + run: | + set -euo pipefail + if [[ "${SOURCE_REF}" != refs/tags/v* ]]; then + echo "::error::Docker releases require a v-prefixed release tag; got ${SOURCE_REF}." + exit 1 + fi + version="${SOURCE_REF#refs/tags/v}" + policy="$(node workflow-source/scripts/lib/docker-release-policy.mjs "${version}")" + channel="$(jq -r '.channel' <<< "${policy}")" + { + echo "version=${version}" + echo "channel=${channel}" + } >> "$GITHUB_OUTPUT" + { + echo "## Docker release policy" + echo "- Version: ${version}" + echo "- Channel: ${channel}" + } >> "$GITHUB_STEP_SUMMARY" + + approve_manual_backfill: + name: Approve Docker backfill ${{ inputs.tag }} + if: github.event_name == 'workflow_dispatch' + needs: [validate_manual_backfill, resolve_release_policy] # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT. runs-on: ubuntu-24.04 environment: docker-release @@ -67,7 +110,8 @@ jobs: - name: Approve Docker backfill env: RELEASE_TAG: ${{ inputs.tag }} - run: echo "Approved Docker backfill for $RELEASE_TAG" + run: | + echo "Approved immutable Docker image backfill for ${RELEASE_TAG}" validate_publish_config: runs-on: ubuntu-24.04 @@ -88,8 +132,8 @@ jobs: echo "Docker Hub publishing configured for ${DOCKERHUB_IMAGE}." resolve_build_provenance: - needs: [approve_manual_backfill, validate_publish_config] - if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} + needs: [approve_manual_backfill, resolve_release_policy, validate_publish_config] + if: ${{ always() && needs.resolve_release_policy.result == 'success' && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} runs-on: ubuntu-24.04 permissions: contents: read @@ -555,6 +599,7 @@ jobs: needs: [ approve_manual_backfill, + resolve_release_policy, validate_publish_config, resolve_build_provenance, build-amd64, @@ -594,7 +639,6 @@ jobs: GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} - IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} run: | set -euo pipefail tags=() @@ -617,18 +661,6 @@ jobs: browser_tags+=("${GHCR_IMAGE}:${version}-browser") dockerhub_browser_tags+=("${DOCKERHUB_IMAGE}:${version}-browser") fi - # Beta releases and manual backfills publish only immutable version tags; - # do not advance latest/main aliases from those flows. - if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then - tags+=("${GHCR_IMAGE}:latest" "${GHCR_IMAGE}:main") - slim_tags+=("${GHCR_IMAGE}:slim" "${GHCR_IMAGE}:main-slim") - dockerhub_tags+=("${DOCKERHUB_IMAGE}:latest" "${DOCKERHUB_IMAGE}:main") - dockerhub_slim_tags+=("${DOCKERHUB_IMAGE}:slim" "${DOCKERHUB_IMAGE}:main-slim") - if [[ "${browser_supported}" == "1" ]]; then - browser_tags+=("${GHCR_IMAGE}:latest-browser" "${GHCR_IMAGE}:main-browser") - dockerhub_browser_tags+=("${DOCKERHUB_IMAGE}:latest-browser" "${DOCKERHUB_IMAGE}:main-browser") - fi - fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No manifest tags resolved for ref ${SOURCE_REF}" @@ -697,12 +729,13 @@ jobs: fi verify-attestations: - needs: [resolve_build_provenance, create-manifest] + name: Verify attestations and promote channel + needs: [resolve_release_policy, resolve_build_provenance, create-manifest] if: ${{ always() && needs.create-manifest.result == 'success' }} runs-on: ubuntu-24.04 permissions: contents: read - packages: read + packages: write steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -735,7 +768,6 @@ jobs: GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} - IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} run: | set -euo pipefail multi_refs=() @@ -784,18 +816,6 @@ jobs: arm64_refs+=("${GHCR_IMAGE}:${version}-browser-arm64") dockerhub_arm64_refs+=("${DOCKERHUB_IMAGE}:${version}-browser-arm64") fi - # Beta releases and manual backfills publish only immutable version tags; - # do not advance latest/main aliases from those flows. - if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then - multi_refs+=("${GHCR_IMAGE}:latest" "${GHCR_IMAGE}:main") - slim_multi_refs+=("${GHCR_IMAGE}:slim" "${GHCR_IMAGE}:main-slim") - dockerhub_multi_refs+=("${DOCKERHUB_IMAGE}:latest" "${DOCKERHUB_IMAGE}:main") - dockerhub_slim_multi_refs+=("${DOCKERHUB_IMAGE}:slim" "${DOCKERHUB_IMAGE}:main-slim") - if [[ "${browser_supported}" == "1" ]]; then - multi_refs+=("${GHCR_IMAGE}:latest-browser" "${GHCR_IMAGE}:main-browser") - dockerhub_multi_refs+=("${DOCKERHUB_IMAGE}:latest-browser" "${DOCKERHUB_IMAGE}:main-browser") - fi - fi fi if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 || ${#dockerhub_multi_refs[@]} -eq 0 || ${#dockerhub_amd64_refs[@]} -eq 0 || ${#dockerhub_arm64_refs[@]} -eq 0 ]]; then echo "::error::No Docker image refs resolved for ref ${SOURCE_REF}" @@ -860,3 +880,15 @@ jobs: node scripts/verify-docker-attestations.mjs \ --platform linux/arm64 \ "${dockerhub_arm64_refs[@]}" + + - name: Promote and verify channel aliases + if: ${{ github.event_name != 'workflow_dispatch' && needs.resolve_release_policy.outputs.channel != 'beta' }} + env: + VERSION: ${{ needs.resolve_release_policy.outputs.version }} + GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }} + run: | + node scripts/docker-channel-promote.mjs \ + --version "${VERSION}" \ + --image "${GHCR_IMAGE}" \ + --image "${DOCKERHUB_IMAGE}" diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 12dc26ef287e..74ad670cdc5d 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -10,6 +10,7 @@ on: - "extensions/**" - "package.json" - "scripts/lib/npm-publish-plan.mjs" + - "scripts/lib/release-version.mjs" - "scripts/lib/plugin-npm-package-manifest.mjs" - "scripts/lib/plugin-npm-release.ts" - "scripts/lib/actions-artifact-archive.mjs" diff --git a/docs/ci.md b/docs/ci.md index 7fa36e704411..d521ecd18d03 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -164,14 +164,15 @@ gh workflow run ci.yml --ref main -f target_ref= -f include_andro gh workflow run full-release-validation.yml --ref main -f ref= ``` -The monthly npm-only extended-stable path is the exception: dispatch both `OpenClaw NPM +The monthly Gateway extended-stable path is the exception: dispatch both `OpenClaw NPM Release` preflight and `Full Release Validation` from the exact `extended-stable/YYYY.M.33` branch, preserve their run IDs, and pass both IDs to the -direct npm publish run. See [Monthly npm-only extended-stable -publication](/reference/RELEASING#monthly-npm-only-extended-stable-publication) for +direct npm publish run. See [Monthly Gateway extended-stable +publication](/reference/RELEASING#monthly-gateway-extended-stable-publication) for the commands, exact identity requirements, registry readback, and selector -repair procedure. This path does not dispatch plugin, macOS, Windows, GitHub -Release, private dist-tag, or other platform publication. +repair procedure. It publishes the `openclaw` npm package, official npm plugins, +and Gateway Docker images. It does not publish the macOS app, Windows Hub, +mobile apps, a GitHub Release, ClawHub packages, or website artifacts. ## Runners diff --git a/docs/docs_map.md b/docs/docs_map.md index 28ff91b2121a..847b28a5f678 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -8755,7 +8755,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Version naming - H2: Release cadence - - H2: Monthly npm-only extended-stable publication + - H2: Monthly Gateway extended-stable publication - H2: Regular release operator checklist - H2: Stable main closeout - H2: Release preflight diff --git a/docs/install/docker.md b/docs/install/docker.md index 925bcbe31bbf..471848375169 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -43,7 +43,7 @@ Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting ./scripts/docker/setup.sh ``` - Use `ghcr.io/openclaw/openclaw` or `openclaw/openclaw` and avoid unofficial mirrors, which don't share OpenClaw's release timing or retention policy. Official tags: `main`, `latest`, `` (e.g. `2026.2.26`), and beta tags such as `2026.2.26-beta.1` (betas never move `latest`/`main`). The default `main`/`latest`/`` image bundles the `codex` and `diagnostics-otel` plugins. A `-browser` variant (e.g. `latest-browser`) also ships with Chromium baked in, useful for the [sandboxed browser](/gateway/sandboxing#sandboxed-browser) tool without a first-run Playwright install. + Use `ghcr.io/openclaw/openclaw` or `openclaw/openclaw` and avoid unofficial mirrors, which don't share OpenClaw's release timing or retention policy. Version-specific tags include releases such as `2026.2.26` and prereleases such as `2026.2.26-beta.1`. Stable releases move `latest` and `main`; trailing-month Gateway releases move only `extended-stable`. Variants include `slim`, `main-slim`, `extended-stable-slim`, `latest-browser`, `main-browser`, and `extended-stable-browser`. The default images bundle the `codex` and `diagnostics-otel` plugins. A `-browser` variant also ships with Chromium baked in, useful for the [sandboxed browser](/gateway/sandboxing#sandboxed-browser) tool without a first-run Playwright install. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 5d02c6b501c8..26691b4e9fa8 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -13,16 +13,18 @@ OpenClaw currently exposes three user-facing update channels: - beta: prerelease tags that publish to npm `beta` - dev: the moving head of `main` -Separately, release operators can publish the trailing completed month's core -package to npm `extended-stable`, beginning at patch `33`. The current-month -regular final line continues on npm `latest`; this operator-side publication -split does not by itself change CLI update-channel resolution. +Operators can publish the trailing completed month's Gateway distribution to +`extended-stable` beginning at patch `33`. This includes the `openclaw` npm +package, official npm plugins, and matching Docker Gateway images. It does not +publish the macOS app, Windows Hub, mobile apps, a GitHub Release, ClawHub, or +website downloads, and it does not move npm `latest`, Docker `latest`/`main`, +or the CLI update channel. Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `alpha`), covered under [NPM workflow inputs](#npm-workflow-inputs) and [Release test boxes](#release-test-boxes). ## Version naming -- Monthly npm extended-stable release version: `YYYY.M.PATCH`, with `PATCH >= 33`, git tag `vYYYY.M.PATCH` +- Monthly Gateway extended-stable release version: `YYYY.M.PATCH`, with `PATCH >= 33`, git tag `vYYYY.M.PATCH` - Daily/regular final release version: `YYYY.M.PATCH`, with `PATCH < 33`, git tag `vYYYY.M.PATCH` - Regular fallback correction release version: `YYYY.M.PATCH-N`, git tag `vYYYY.M.PATCH-N` - Beta prerelease version: `YYYY.M.PATCH-beta.N`, git tag `vYYYY.M.PATCH-beta.N` @@ -32,9 +34,9 @@ Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `al - Alpha/nightly builds use the next unreleased patch train and increment only `alpha.N` for repeated builds. Once that patch has a beta, new alpha builds move to the following patch. - npm versions are immutable: never delete, republish, or reuse a published tag. Cut the next prerelease number or the next monthly patch instead. - `latest` continues to follow the current regular/daily npm line; `beta` is the current beta install target -- `extended-stable` means the supported trailing-month npm package, beginning at patch `33`; patch `34` and later are maintenance releases on that monthly line +- `extended-stable` means the supported trailing-month Gateway distribution, beginning at patch `33`; patch `34` and later are maintenance releases on that monthly line - Regular final and regular correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later -- The dedicated monthly extended-stable path publishes the core npm package and every npm-publishable official plugin at the same exact version. It does not publish plugins to ClawHub or publish macOS or Windows artifacts, a GitHub Release, private-repository dist-tags, Docker images, mobile artifacts, or website downloads. +- The monthly Gateway extended-stable path publishes the `openclaw` npm package and every npm-publishable official plugin at one version. Its tag publishes Docker Gateway images to GHCR and Docker Hub and moves only `extended-stable`, `extended-stable-slim`, and `extended-stable-browser`. It excludes the macOS app, Windows Hub, mobile apps, ClawHub, GitHub Releases, private dist-tags, and website downloads. - Every regular final release ships the npm package, macOS app, signed standalone Android APK, and signed Windows Hub installers together. Beta releases normally validate and publish the npm/package path first, with native app build/sign/notarize/promote reserved for regular final unless explicitly requested. ## Release cadence @@ -44,7 +46,7 @@ Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `al - If a beta tag has been pushed or published and needs a fix, maintainers cut the next `-beta.N` tag instead of deleting or recreating the old one - Detailed release procedure, approvals, credentials, and recovery notes are maintainer-only -## Monthly npm-only extended-stable publication +## Monthly Gateway extended-stable publication This is a dedicated exception to the regular release procedure below. For a completed month `YYYY.M`, create `extended-stable/YYYY.M.33`; publish @@ -61,6 +63,16 @@ same version. Commit and push all generated changes, then freeze and record the resulting full SHA. The workflows consume this prepared tree; they do not bump or synchronize versions for you. Do not create the final tag for a candidate. +Before running candidate gates, backport the complete Docker release-channel +change from current `main` as one tested unit. Its runtime files include +`.github/workflows/docker-release.yml`, +`scripts/docker-channel-promote.mjs`, +`scripts/lib/docker-release-policy.mjs`, and +`scripts/lib/release-version.mjs`; include the matching tests and workflow +validation changes too. Tag-push workflows run from the tagged commit, so a +partial or older copy can fail after building images or incorrectly move +regular `latest`/`main` aliases. + Run the npm preflight and Full Release Validation against that frozen SHA, then save both run IDs and the successful Full Release Validation run attempt: @@ -87,10 +99,18 @@ If either candidate gate fails or another backport is needed, update the branch, freeze a new SHA, and rerun the affected candidate gates. Do not create, delete, or move a final tag during candidate validation. Once both gates are green, re-resolve the branch tip, require it still equals `RELEASE_SHA`, then create -and push immutable `vYYYY.M.P` at that SHA. A post-tag source change requires a +and push signed `vYYYY.M.P` at that SHA. A post-tag source change requires a new patch version and new candidate; final extended-stable tags are never moved or deleted. +Pushing the tag starts `Docker Release`, which publishes version-specific +default, slim, browser, and architecture tags to both registries. It verifies their +attestations before promoting the three extended-stable aliases through the +shared promotion path. For alias-only repair, dispatch `Docker Channel +Promotion` from current `main` with the exact tag. It requires `docker-release` +approval and verifies source manifests, SBOMs, and provenance without +rebuilding images. + After both runs succeed, publish every npm-publishable official plugin from the same exact branch tip. Patch `P` must be `33` or greater. Pass the full release SHA as `ref`, wait for the complete matrix and registry readback, then save the @@ -160,9 +180,9 @@ covered extended-stable plugin surfaces. That list is a support statement, not a release-code allowlist: every npm-publishable official plugin follows the same exact-version publication path. -The regular checklist below continues to own beta, `latest`, GitHub Release, -plugins, macOS, Windows, and other platform publication. Do not run those -steps for this npm-only extended-stable path. +The regular checklist below continues to own beta, `latest`, GitHub Releases, +ClawHub, macOS, Windows, mobile apps, and the other platform release surfaces. +Do not run it for this Gateway extended-stable path. ## Regular release operator checklist @@ -472,7 +492,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+` npm-only extended-stable path does not use this orchestrator. The +`.33+` Gateway extended-stable path does not use this orchestrator. The regular workflow orchestrates the trusted-publisher workflows in the order the release needs: @@ -631,7 +651,7 @@ Rules: ## Regular beta/latest stable release sequence -This legacy sequence is for the regular orchestrated release that also owns plugins, GitHub Release, Windows, and other platform work. It is not the monthly `.33+` npm-only extended-stable path documented at the top of this page. +This legacy sequence is for the regular orchestrated release that also owns plugins, GitHub Release, Windows, and other platform work. It is not the monthly `.33+` Gateway extended-stable path documented at the top of this page. When cutting a regular orchestrated stable release: diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index be9abf4c3750..d1c4be92ecaa 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -44,7 +44,7 @@ const MACOS_NATIVE_RE = const MACOS_SCRIPT_SCOPE_RE = /^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|install-xcodegen|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/; const IOS_BUILD_RE = - /^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|npm-publish-plan\.mjs|version-script-args\.ts)$)/; + /^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/; const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/; const NODE_SCOPE_RE = /^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/; diff --git a/scripts/docker-channel-promote.d.mts b/scripts/docker-channel-promote.d.mts new file mode 100644 index 000000000000..b689d1cccdd8 --- /dev/null +++ b/scripts/docker-channel-promote.d.mts @@ -0,0 +1,37 @@ +import type { DockerReleaseChannel } from "./lib/docker-release-policy.mjs"; + +export type DockerChannelPromotion = { + image: string; + sourceRef: string; + targetRefs: string[]; +}; + +export type DockerChannelPromotionPlan = { + channel: DockerReleaseChannel; + promotions: DockerChannelPromotion[]; + version: string; +}; + +export function createDockerChannelPromotionPlan(params: { + version: string; + images: string[]; +}): DockerChannelPromotionPlan; + +export function promoteDockerChannel( + params: { version: string; images: string[] }, + options?: { + allowRollback?: boolean; + execFileSyncImpl?: (command: string, args: string[], options: object) => string; + log?: (message: string) => void; + verifyAttestationsImpl?: (params: { + imageRefs: string[]; + requiredPlatforms: Array<{ + architecture: string; + os: string; + variant?: string; + }>; + execFileSyncImpl: (command: string, args: string[], options: object) => string; + log: (message: string) => void; + }) => void; + }, +): DockerChannelPromotionPlan; diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs new file mode 100644 index 000000000000..89579e255219 --- /dev/null +++ b/scripts/docker-channel-promote.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs"; +import { compareReleaseVersions } from "./lib/release-version.mjs"; +import { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs"; + +const DOCKER_TIMEOUT_MS = 120_000; +const REQUIRED_PLATFORMS = Object.freeze([ + parsePlatform("linux/amd64"), + parsePlatform("linux/arm64"), +]); +const VARIANTS = Object.freeze([ + { aliasKey: "default", suffix: "" }, + { aliasKey: "slim", suffix: "-slim" }, + { aliasKey: "browser", suffix: "-browser" }, +]); + +/** Build the version-specific source to moving-alias promotion plan. */ +export function createDockerChannelPromotionPlan({ version, images }) { + if (images.length === 0) { + throw new Error("At least one --image is required."); + } + const policy = resolveDockerReleasePolicy(version); + const promotions = []; + for (const image of images) { + for (const { aliasKey, suffix } of VARIANTS) { + const aliases = policy.movingAliases[aliasKey]; + if (aliases.length === 0) { + continue; + } + promotions.push({ + image, + sourceRef: `${image}:${version}${suffix}`, + targetRefs: aliases.map((alias) => `${image}:${alias}`), + }); + } + } + if (promotions.length === 0) { + throw new Error(`Docker ${policy.channel} releases have no moving aliases to promote.`); + } + return { channel: policy.channel, promotions, version: policy.version }; +} + +function runDocker(args, execFileSyncImpl) { + return execFileSyncImpl("docker", args, { + encoding: "utf8", + killSignal: "SIGKILL", + maxBuffer: 20 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: DOCKER_TIMEOUT_MS, + }); +} + +function inspectManifestDigest(imageRef, execFileSyncImpl) { + const raw = runDocker( + ["buildx", "imagetools", "inspect", imageRef, "--format", "{{json .Manifest}}"], + execFileSyncImpl, + ); + let digest; + try { + digest = JSON.parse(raw).digest; + } catch (error) { + throw new Error(`Could not parse the manifest for ${imageRef}.`, { cause: error }); + } + if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(digest)) { + throw new Error(`The manifest for ${imageRef} did not contain a valid sha256 digest.`); + } + return digest; +} + +function formatCommandError(error) { + if (!(error instanceof Error)) { + return String(error); + } + const output = [error.message]; + for (const field of ["stderr", "stdout"]) { + const value = error[field]; + if (typeof value === "string") { + output.push(value); + } else if (Buffer.isBuffer(value)) { + output.push(value.toString("utf8")); + } + } + return output.join("\n"); +} + +function isMissingManifestError(error) { + const message = formatCommandError(error); + return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(message); +} + +function formatPlatform(platform) { + const suffix = platform.variant ? `/${platform.variant}` : ""; + return `${platform.os}/${platform.architecture}${suffix}`; +} + +function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) { + const versions = new Map(); + for (const [index, platform] of REQUIRED_PLATFORMS.entries()) { + const platformName = formatPlatform(platform); + let raw; + try { + // In formatted multi-platform inspection, Buildx keys .Image by os/arch. + // Read every promoted platform rather than trusting one config label. + raw = runDocker( + [ + "buildx", + "imagetools", + "inspect", + imageRef, + "--format", + `{{json (index .Image "${platformName}")}}`, + ], + execFileSyncImpl, + ); + } catch (error) { + if (allowMissing && index === 0 && isMissingManifestError(error)) { + return null; + } + throw error; + } + + let version; + try { + version = JSON.parse(raw)?.config?.Labels?.["org.opencontainers.image.version"]; + } catch (error) { + throw new Error(`Could not parse the ${platformName} image config for ${imageRef}.`, { + cause: error, + }); + } + if (typeof version !== "string" || version.trim().length === 0) { + throw new Error( + `${imageRef} does not have an org.opencontainers.image.version label for ${platformName}.`, + ); + } + versions.set(platformName, version.trim()); + } + const uniqueVersions = new Set(versions.values()); + if (uniqueVersions.size !== 1) { + const details = [...versions].map(([platform, version]) => `${platform}=${version}`).join(", "); + throw new Error(`${imageRef} has inconsistent platform versions: ${details}.`); + } + return uniqueVersions.values().next().value; +} + +function verifySourceVersions(resolved, version, execFileSyncImpl) { + for (const promotion of resolved) { + const sourceVersion = inspectImageVersion(promotion.sourceDigestRef, execFileSyncImpl); + if (sourceVersion !== version) { + throw new Error( + `${promotion.sourceDigestRef} reports version ${sourceVersion}, expected ${version}.`, + ); + } + } +} + +function preventChannelRollback(resolved, version, execFileSyncImpl) { + for (const promotion of resolved) { + for (const targetRef of promotion.targetRefs) { + const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, { + allowMissing: true, + }); + if (currentVersion === null) { + continue; + } + const comparison = compareReleaseVersions(version, currentVersion); + if (comparison === null) { + throw new Error( + `Cannot compare candidate version ${version} with ${targetRef} version ${currentVersion}.`, + ); + } + if (comparison < 0) { + throw new Error( + `Refusing to move ${targetRef} backward from ${currentVersion} to ${version}. ` + + "An approved repair may rerun with --allow-rollback.", + ); + } + } + } +} + +/** Promote every planned alias and verify the registry result. */ +export function promoteDockerChannel({ version, images }, options = {}) { + const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync; + const log = options.log ?? console.log; + const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations; + const plan = createDockerChannelPromotionPlan({ version, images }); + + // Resolve every version-specific source before the first alias write. A missing + // release variant must not leave the channel partially promoted. + const resolved = plan.promotions.map((promotion) => { + const sourceDigest = inspectManifestDigest(promotion.sourceRef, execFileSyncImpl); + return { + ...promotion, + sourceDigest, + sourceDigestRef: `${promotion.image}@${sourceDigest}`, + }; + }); + + // Attestation checks and writes share these digest refs so a concurrent tag + // rewrite cannot swap the content between verification and promotion. + verifyAttestationsImpl({ + imageRefs: resolved.map((promotion) => promotion.sourceDigestRef), + requiredPlatforms: REQUIRED_PLATFORMS, + execFileSyncImpl, + log, + }); + verifySourceVersions(resolved, plan.version, execFileSyncImpl); + if (!options.allowRollback) { + preventChannelRollback(resolved, plan.version, execFileSyncImpl); + } + + for (const promotion of resolved) { + const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]); + runDocker( + [ + "buildx", + "imagetools", + "create", + "--prefer-index=false", + ...targetArgs, + promotion.sourceDigestRef, + ], + execFileSyncImpl, + ); + for (const targetRef of promotion.targetRefs) { + const targetDigest = inspectManifestDigest(targetRef, execFileSyncImpl); + if (targetDigest !== promotion.sourceDigest) { + throw new Error( + `${targetRef} resolved to ${targetDigest}, expected ${promotion.sourceDigest}.`, + ); + } + log(`Verified ${targetRef} -> ${promotion.sourceDigest}.`); + } + } + return plan; +} + +function printHelp() { + console.log( + "Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]", + ); +} + +function main() { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "allow-rollback": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + image: { type: "string", multiple: true }, + version: { type: "string" }, + }, + strict: true, + }); + if (values.help) { + printHelp(); + return; + } + const version = values.version?.trim(); + if (!version) { + throw new Error("--version is required."); + } + 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."); + } + const plan = promoteDockerChannel( + { version, images }, + { allowRollback: values["allow-rollback"] }, + ); + console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`); +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + try { + main(); + } catch (error) { + console.error( + `docker-channel-promote: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index b26b98003159..c8535c6821a6 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseReleaseVersion } from "../../../lib/npm-publish-plan.mjs"; +import { parseReleaseVersion } from "../../../lib/release-version.mjs"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../../windows-cmd-helpers.mjs"; const args = process.argv.slice(2); diff --git a/scripts/lib/android-version.ts b/scripts/lib/android-version.ts index 2bfd93b1bbf9..69465f433bfe 100644 --- a/scripts/lib/android-version.ts +++ b/scripts/lib/android-version.ts @@ -1,7 +1,7 @@ // Android Version script supports OpenClaw repository automation. import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { parseReleaseVersion } from "./npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./release-version.mjs"; const ANDROID_VERSION_FILE = "apps/android/version.json"; const ANDROID_CHANGELOG_FILE = "apps/android/CHANGELOG.md"; diff --git a/scripts/lib/docker-release-policy.d.mts b/scripts/lib/docker-release-policy.d.mts new file mode 100644 index 000000000000..8a8eb4be0cfc --- /dev/null +++ b/scripts/lib/docker-release-policy.d.mts @@ -0,0 +1,15 @@ +export type DockerReleaseChannel = "stable" | "extended-stable" | "beta"; + +export type DockerReleaseAliases = { + default: readonly string[]; + slim: readonly string[]; + browser: readonly string[]; +}; + +export type DockerReleasePolicy = { + version: string; + channel: DockerReleaseChannel; + movingAliases: DockerReleaseAliases; +}; + +export function resolveDockerReleasePolicy(version: string): DockerReleasePolicy; diff --git a/scripts/lib/docker-release-policy.mjs b/scripts/lib/docker-release-policy.mjs new file mode 100644 index 000000000000..66f4fcf68144 --- /dev/null +++ b/scripts/lib/docker-release-policy.mjs @@ -0,0 +1,80 @@ +import { isDirectRunUrl } from "./direct-run.mjs"; +import { classifyReleaseTrain, parseReleaseVersion } from "./release-version.mjs"; + +const STABLE_ALIASES = Object.freeze({ + default: Object.freeze(["latest", "main"]), + slim: Object.freeze(["slim", "main-slim"]), + browser: Object.freeze(["latest-browser", "main-browser"]), +}); + +const EXTENDED_STABLE_ALIASES = Object.freeze({ + default: Object.freeze(["extended-stable"]), + slim: Object.freeze(["extended-stable-slim"]), + browser: Object.freeze(["extended-stable-browser"]), +}); + +const NO_MOVING_ALIASES = Object.freeze({ + default: Object.freeze([]), + slim: Object.freeze([]), + browser: Object.freeze([]), +}); + +/** + * @typedef {object} DockerReleasePolicy + * @property {string} version + * @property {"stable" | "extended-stable" | "beta"} channel + * @property {{default: readonly string[], slim: readonly string[], browser: readonly string[]}} movingAliases + */ + +/** + * Keep Docker's moving channels aligned with the release-version contract. + * Patch 33+ finals belong to the trailing-month extended-stable line; they + * must never move the regular latest/main aliases. + * + * @param {string} version + * @returns {DockerReleasePolicy} + */ +export function resolveDockerReleasePolicy(version) { + const parsed = parseReleaseVersion(version); + if (parsed === null) { + throw new Error(`Unsupported Docker release version "${version}".`); + } + const releaseTrain = classifyReleaseTrain(parsed); + if (releaseTrain === "alpha") { + throw new Error("Docker alpha image publishing is disabled."); + } + if (releaseTrain === "beta") { + return { version: parsed.version, channel: "beta", movingAliases: NO_MOVING_ALIASES }; + } + if (releaseTrain === "extended-stable") { + return { + version: parsed.version, + channel: "extended-stable", + movingAliases: EXTENDED_STABLE_ALIASES, + }; + } + if (releaseTrain === "unsupported-extended-stable-correction") { + throw new Error( + `Extended-stable Docker publication requires a final YYYY.M.PATCH version; found "${version}".`, + ); + } + return { version: parsed.version, channel: "stable", movingAliases: STABLE_ALIASES }; +} + +function main() { + const version = process.argv[2]?.trim(); + if (!version) { + throw new Error("Usage: node scripts/lib/docker-release-policy.mjs "); + } + process.stdout.write(`${JSON.stringify(resolveDockerReleasePolicy(version))}\n`); +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`docker-release-policy: ${message}`); + process.exitCode = 1; + } +} diff --git a/scripts/lib/ios-version.ts b/scripts/lib/ios-version.ts index 3c704eeb5009..e719c79896c5 100644 --- a/scripts/lib/ios-version.ts +++ b/scripts/lib/ios-version.ts @@ -1,7 +1,7 @@ // Ios Version script supports OpenClaw repository automation. import { readFileSync } from "node:fs"; import path from "node:path"; -import { parseReleaseVersion } from "./npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./release-version.mjs"; const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md"; diff --git a/scripts/lib/npm-publish-plan.d.mts b/scripts/lib/npm-publish-plan.d.mts index 3d35638bb2a0..0a74136a00e7 100644 --- a/scripts/lib/npm-publish-plan.d.mts +++ b/scripts/lib/npm-publish-plan.d.mts @@ -1,14 +1,3 @@ -export type ParsedReleaseVersion = { - version: string; - baseVersion: string; - channel: "stable" | "alpha" | "beta"; - year: number; - month: number; - patch: number; - alphaNumber?: number; - betaNumber?: number; - correctionNumber?: number; -}; export type NpmPublishPlan = { channel: "stable" | "alpha" | "beta"; publishTag: "latest" | "alpha" | "beta" | "extended-stable"; @@ -29,11 +18,6 @@ export function fetchNpmRegistryPackumentWithRetry(params: { sleep?: (delayMs: number) => Promise; createSignal?: (timeoutMs: number) => AbortSignal; }): Promise; -export function parseReleaseVersion(version: string): ParsedReleaseVersion | null; -export function collectReleaseVersionFloorErrors( - version: string | ParsedReleaseVersion | null, -): string[]; -export function compareReleaseVersions(left: string, right: string): number | null; export function resolveNpmPublishPlan( version: string, currentBetaVersion?: string | null, diff --git a/scripts/lib/npm-publish-plan.mjs b/scripts/lib/npm-publish-plan.mjs index 5c1917153f4a..f6f4b11ca7c0 100644 --- a/scripts/lib/npm-publish-plan.mjs +++ b/scripts/lib/npm-publish-plan.mjs @@ -1,25 +1,8 @@ -// Parses OpenClaw monthly patch release versions and npm dist-tag publish plans. -const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)$/; -const ALPHA_VERSION_REGEX = - /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-alpha\.(?[1-9]\d*)$/; -const BETA_VERSION_REGEX = - /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-beta\.(?[1-9]\d*)$/; -const CORRECTION_VERSION_REGEX = - /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-(?[1-9]\d*)$/; -const JUNE_2026_PATCH_FLOOR = 5; - -/** - * @typedef {object} ParsedReleaseVersion - * @property {string} version - * @property {string} baseVersion - * @property {"stable" | "alpha" | "beta"} channel - * @property {number} year - * @property {number} month - * @property {number} patch - * @property {number | undefined} [alphaNumber] - * @property {number | undefined} [betaNumber] - * @property {number | undefined} [correctionNumber] - */ +import { + classifyReleaseTrain, + compareReleaseVersions, + parseReleaseVersion, +} from "./release-version.mjs"; /** * @typedef {object} NpmPublishPlan @@ -146,164 +129,6 @@ export async function fetchNpmRegistryPackumentWithRetry(params) { ); } -/** - * @param {string} version - * @param {Record} groups - * @param {"stable" | "alpha" | "beta"} channel - * @returns {ParsedReleaseVersion | null} - */ -function parseVersionParts(version, groups, channel) { - const year = parseSafeIntegerPart(groups.year); - const month = parseSafeIntegerPart(groups.month); - const patch = parseSafeIntegerPart(groups.patch); - const alphaNumber = channel === "alpha" ? parseSafeIntegerPart(groups.alpha) : undefined; - const betaNumber = channel === "beta" ? parseSafeIntegerPart(groups.beta) : undefined; - - if ( - !Number.isSafeInteger(year) || - !Number.isSafeInteger(month) || - !Number.isSafeInteger(patch) || - month < 1 || - month > 12 || - patch < 1 - ) { - return null; - } - if (channel === "beta" && (!Number.isSafeInteger(betaNumber) || (betaNumber ?? 0) < 1)) { - return null; - } - if (channel === "alpha" && (!Number.isSafeInteger(alphaNumber) || (alphaNumber ?? 0) < 1)) { - return null; - } - - return { - version, - baseVersion: `${year}.${month}.${patch}`, - channel, - year, - month, - patch, - alphaNumber, - betaNumber, - }; -} - -function parseSafeIntegerPart(value) { - const raw = value ?? ""; - if (!/^[0-9]+$/.test(raw)) { - return null; - } - const parsed = Number(raw); - return Number.isSafeInteger(parsed) ? parsed : null; -} - -/** - * @param {string} version - * @returns {ParsedReleaseVersion | null} - */ -export function parseReleaseVersion(version) { - const trimmed = version.trim(); - if (!trimmed) { - return null; - } - - const stableMatch = STABLE_VERSION_REGEX.exec(trimmed); - if (stableMatch?.groups) { - return parseVersionParts(trimmed, stableMatch.groups, "stable"); - } - - const alphaMatch = ALPHA_VERSION_REGEX.exec(trimmed); - if (alphaMatch?.groups) { - return parseVersionParts(trimmed, alphaMatch.groups, "alpha"); - } - - const betaMatch = BETA_VERSION_REGEX.exec(trimmed); - if (betaMatch?.groups) { - return parseVersionParts(trimmed, betaMatch.groups, "beta"); - } - - const correctionMatch = CORRECTION_VERSION_REGEX.exec(trimmed); - if (correctionMatch?.groups) { - const parsedCorrection = parseVersionParts(trimmed, correctionMatch.groups, "stable"); - const correctionNumber = parseSafeIntegerPart(correctionMatch.groups.correction); - if ( - parsedCorrection === null || - !Number.isSafeInteger(correctionNumber) || - correctionNumber < 1 - ) { - return null; - } - - return { - ...parsedCorrection, - correctionNumber, - }; - } - - return null; -} - -/** - * @param {string | ParsedReleaseVersion | null} version - * @returns {string[]} - */ -export function collectReleaseVersionFloorErrors(version) { - const parsedVersion = - typeof version === "string" ? parseReleaseVersion(version) : (version ?? null); - if (parsedVersion === null) { - return []; - } - if ( - parsedVersion.year === 2026 && - parsedVersion.month === 6 && - parsedVersion.patch < JUNE_2026_PATCH_FLOOR && - parsedVersion.channel !== "alpha" - ) { - return [ - `June 2026 stable and beta release trains must use patch ${JUNE_2026_PATCH_FLOOR} or higher because 2026.6.5-beta.1 is already published; found "${parsedVersion.version}".`, - ]; - } - return []; -} - -/** - * @param {string} left - * @param {string} right - * @returns {number | null} - */ -export function compareReleaseVersions(left, right) { - const parsedLeft = parseReleaseVersion(left); - const parsedRight = parseReleaseVersion(right); - if (parsedLeft === null || parsedRight === null) { - return null; - } - - if (parsedLeft.year !== parsedRight.year) { - return Math.sign(parsedLeft.year - parsedRight.year); - } - if (parsedLeft.month !== parsedRight.month) { - return Math.sign(parsedLeft.month - parsedRight.month); - } - if (parsedLeft.patch !== parsedRight.patch) { - return Math.sign(parsedLeft.patch - parsedRight.patch); - } - - if (parsedLeft.channel !== parsedRight.channel) { - const rank = { alpha: 0, beta: 1, stable: 2 }; - return Math.sign(rank[parsedLeft.channel] - rank[parsedRight.channel]); - } - - if (parsedLeft.channel === "alpha" && parsedRight.channel === "alpha") { - return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0)); - } - - if (parsedLeft.channel === "beta" && parsedRight.channel === "beta") { - return Math.sign((parsedLeft.betaNumber ?? 0) - (parsedRight.betaNumber ?? 0)); - } - - return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0)); -} - /** * @param {string} version * @param {string | null} [currentBetaVersion] @@ -315,6 +140,7 @@ export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOve if (parsedVersion === null) { throw new Error(`Unsupported release version "${version}".`); } + const releaseTrain = classifyReleaseTrain(parsedVersion); const normalizedOverride = publishTagOverride?.trim(); if (normalizedOverride && normalizedOverride !== "extended-stable") { @@ -323,11 +149,7 @@ export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOve ); } if (normalizedOverride === "extended-stable") { - if ( - parsedVersion.channel !== "stable" || - parsedVersion.correctionNumber !== undefined || - parsedVersion.patch < 33 - ) { + if (releaseTrain !== "extended-stable") { throw new Error( `Extended-stable npm publication requires a final YYYY.M.PATCH version with PATCH >= 33; found "${version}".`, ); diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index a352affcfd65..2c409578372b 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -117,6 +117,7 @@ const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [ "packages/plugin-package-contract/src/index.ts", "scripts/lib/bounded-response.ts", "scripts/lib/npm-publish-plan.mjs", + "scripts/lib/release-version.mjs", "scripts/lib/plugin-npm-release.ts", "scripts/lib/plugin-clawhub-release.ts", "scripts/openclaw-npm-release-check.ts", diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 8e76e9bd68a1..3c1ec3fb9793 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -6,11 +6,8 @@ import { join, resolve } from "node:path"; import { expectDefined } from "../../packages/normalization-core/src/expect.js"; import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; -import { - collectReleaseVersionFloorErrors, - parseReleaseVersion, - resolveNpmPublishPlan, -} from "./npm-publish-plan.mjs"; +import { resolveNpmPublishPlan } from "./npm-publish-plan.mjs"; +import { collectReleaseVersionFloorErrors, parseReleaseVersion } from "./release-version.mjs"; type PluginPackageJson = { name?: string; diff --git a/scripts/lib/release-upgrade-baseline.mjs b/scripts/lib/release-upgrade-baseline.mjs index 5590669e1606..462b7a8ad289 100644 --- a/scripts/lib/release-upgrade-baseline.mjs +++ b/scripts/lib/release-upgrade-baseline.mjs @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { parseReleaseVersion } from "./npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./release-version.mjs"; function parseVersion(version) { return parseReleaseVersion(String(version ?? "").trim()) ?? undefined; diff --git a/scripts/lib/release-version.d.mts b/scripts/lib/release-version.d.mts new file mode 100644 index 000000000000..ab9ad0319c84 --- /dev/null +++ b/scripts/lib/release-version.d.mts @@ -0,0 +1,23 @@ +export type ParsedReleaseVersion = { + version: string; + baseVersion: string; + channel: "stable" | "alpha" | "beta"; + year: number; + month: number; + patch: number; + alphaNumber?: number; + betaNumber?: number; + correctionNumber?: number; +}; +export type ReleaseTrain = + | "alpha" + | "beta" + | "stable" + | "extended-stable" + | "unsupported-extended-stable-correction"; +export function parseReleaseVersion(version: string): ParsedReleaseVersion | null; +export function classifyReleaseTrain(parsedVersion: ParsedReleaseVersion): ReleaseTrain; +export function collectReleaseVersionFloorErrors( + version: string | ParsedReleaseVersion | null, +): string[]; +export function compareReleaseVersions(left: string, right: string): number | null; diff --git a/scripts/lib/release-version.mjs b/scripts/lib/release-version.mjs new file mode 100644 index 000000000000..c60bfc8316f5 --- /dev/null +++ b/scripts/lib/release-version.mjs @@ -0,0 +1,203 @@ +const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)$/; +const ALPHA_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-alpha\.(?[1-9]\d*)$/; +const BETA_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-beta\.(?[1-9]\d*)$/; +const CORRECTION_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d*)-(?[1-9]\d*)$/; +const JUNE_2026_PATCH_FLOOR = 5; +const EXTENDED_STABLE_PATCH_FLOOR = 33; + +/** + * @typedef {object} ParsedReleaseVersion + * @property {string} version + * @property {string} baseVersion + * @property {"stable" | "alpha" | "beta"} channel + * @property {number} year + * @property {number} month + * @property {number} patch + * @property {number | undefined} [alphaNumber] + * @property {number | undefined} [betaNumber] + * @property {number | undefined} [correctionNumber] + */ + +/** + * @typedef {"alpha" | "beta" | "stable" | "extended-stable" | "unsupported-extended-stable-correction"} ReleaseTrain + */ + +/** + * @param {string} version + * @param {Record} groups + * @param {"stable" | "alpha" | "beta"} channel + * @returns {ParsedReleaseVersion | null} + */ +function parseVersionParts(version, groups, channel) { + const year = parseSafeIntegerPart(groups.year); + const month = parseSafeIntegerPart(groups.month); + const patch = parseSafeIntegerPart(groups.patch); + const alphaNumber = channel === "alpha" ? parseSafeIntegerPart(groups.alpha) : undefined; + const betaNumber = channel === "beta" ? parseSafeIntegerPart(groups.beta) : undefined; + + if ( + !Number.isSafeInteger(year) || + !Number.isSafeInteger(month) || + !Number.isSafeInteger(patch) || + month < 1 || + month > 12 || + patch < 1 + ) { + return null; + } + if (channel === "beta" && (!Number.isSafeInteger(betaNumber) || (betaNumber ?? 0) < 1)) { + return null; + } + if (channel === "alpha" && (!Number.isSafeInteger(alphaNumber) || (alphaNumber ?? 0) < 1)) { + return null; + } + + return { + version, + baseVersion: `${year}.${month}.${patch}`, + channel, + year, + month, + patch, + alphaNumber, + betaNumber, + }; +} + +function parseSafeIntegerPart(value) { + const raw = value ?? ""; + if (!/^[0-9]+$/.test(raw)) { + return null; + } + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** + * @param {string} version + * @returns {ParsedReleaseVersion | null} + */ +export function parseReleaseVersion(version) { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const stableMatch = STABLE_VERSION_REGEX.exec(trimmed); + if (stableMatch?.groups) { + return parseVersionParts(trimmed, stableMatch.groups, "stable"); + } + + const alphaMatch = ALPHA_VERSION_REGEX.exec(trimmed); + if (alphaMatch?.groups) { + return parseVersionParts(trimmed, alphaMatch.groups, "alpha"); + } + + const betaMatch = BETA_VERSION_REGEX.exec(trimmed); + if (betaMatch?.groups) { + return parseVersionParts(trimmed, betaMatch.groups, "beta"); + } + + const correctionMatch = CORRECTION_VERSION_REGEX.exec(trimmed); + if (correctionMatch?.groups) { + const parsedCorrection = parseVersionParts(trimmed, correctionMatch.groups, "stable"); + const correctionNumber = parseSafeIntegerPart(correctionMatch.groups.correction); + if ( + parsedCorrection === null || + !Number.isSafeInteger(correctionNumber) || + correctionNumber < 1 + ) { + return null; + } + + return { + ...parsedCorrection, + correctionNumber, + }; + } + + return null; +} + +/** + * Patch 33 and later final releases belong to the trailing-month + * extended-stable line; correction suffixes are not valid on that line. + * + * @param {ParsedReleaseVersion} parsedVersion + * @returns {ReleaseTrain} + */ +export function classifyReleaseTrain(parsedVersion) { + if (parsedVersion.channel !== "stable") { + return parsedVersion.channel; + } + if (parsedVersion.patch < EXTENDED_STABLE_PATCH_FLOOR) { + return "stable"; + } + return parsedVersion.correctionNumber === undefined + ? "extended-stable" + : "unsupported-extended-stable-correction"; +} + +/** + * @param {string | ParsedReleaseVersion | null} version + * @returns {string[]} + */ +export function collectReleaseVersionFloorErrors(version) { + const parsedVersion = + typeof version === "string" ? parseReleaseVersion(version) : (version ?? null); + if (parsedVersion === null) { + return []; + } + if ( + parsedVersion.year === 2026 && + parsedVersion.month === 6 && + parsedVersion.patch < JUNE_2026_PATCH_FLOOR && + parsedVersion.channel !== "alpha" + ) { + return [ + `June 2026 stable and beta release trains must use patch ${JUNE_2026_PATCH_FLOOR} or higher because 2026.6.5-beta.1 is already published; found "${parsedVersion.version}".`, + ]; + } + return []; +} + +/** + * @param {string} left + * @param {string} right + * @returns {number | null} + */ +export function compareReleaseVersions(left, right) { + const parsedLeft = parseReleaseVersion(left); + const parsedRight = parseReleaseVersion(right); + if (parsedLeft === null || parsedRight === null) { + return null; + } + + if (parsedLeft.year !== parsedRight.year) { + return Math.sign(parsedLeft.year - parsedRight.year); + } + if (parsedLeft.month !== parsedRight.month) { + return Math.sign(parsedLeft.month - parsedRight.month); + } + if (parsedLeft.patch !== parsedRight.patch) { + return Math.sign(parsedLeft.patch - parsedRight.patch); + } + + if (parsedLeft.channel !== parsedRight.channel) { + const rank = { alpha: 0, beta: 1, stable: 2 }; + return Math.sign(rank[parsedLeft.channel] - rank[parsedRight.channel]); + } + + if (parsedLeft.channel === "alpha" && parsedRight.channel === "alpha") { + return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0)); + } + + if (parsedLeft.channel === "beta" && parsedRight.channel === "beta") { + return Math.sign((parsedLeft.betaNumber ?? 0) - (parsedRight.betaNumber ?? 0)); + } + + return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0)); +} diff --git a/scripts/openclaw-npm-extended-stable-release.d.mts b/scripts/openclaw-npm-extended-stable-release.d.mts index 6954213e3d04..f3397e575bbe 100644 --- a/scripts/openclaw-npm-extended-stable-release.d.mts +++ b/scripts/openclaw-npm-extended-stable-release.d.mts @@ -8,7 +8,7 @@ export function validateNpmPublishBoundary( }?: { bypassExtendedStableGuard?: boolean | undefined; }, -): import("./lib/npm-publish-plan.mjs").ParsedReleaseVersion; +): import("./lib/release-version.mjs").ParsedReleaseVersion; export function validateExtendedStableNpmReleaseRequest(request: unknown): | { extendedStable: boolean; diff --git a/scripts/openclaw-npm-extended-stable-release.mjs b/scripts/openclaw-npm-extended-stable-release.mjs index 2838f6b2a10e..3dcf38261168 100644 --- a/scripts/openclaw-npm-extended-stable-release.mjs +++ b/scripts/openclaw-npm-extended-stable-release.mjs @@ -3,7 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { appendFileSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; +import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs"; const SUPPORTED_DIST_TAGS = new Set(["alpha", "beta", "latest", "extended-stable"]); @@ -38,14 +38,15 @@ export function validateNpmPublishBoundary( if (parsed === null) { throw new Error(`Unsupported release version "${packageVersion}".`); } + const releaseTrain = classifyReleaseTrain(parsed); - if (parsed.channel === "alpha") { + if (releaseTrain === "alpha") { if (npmDistTag !== "alpha") { throw new Error("Alpha prereleases must publish to the alpha npm dist-tag."); } return parsed; } - if (parsed.channel === "beta") { + if (releaseTrain === "beta") { if (npmDistTag !== "beta") { throw new Error("Beta prereleases must publish to the beta npm dist-tag."); } @@ -56,12 +57,15 @@ export function validateNpmPublishBoundary( if (parsed.correctionNumber !== undefined) { throw new Error("Extended-stable npm publication does not allow correction suffixes."); } - if (!bypassExtendedStableGuard && parsed.patch < 33) { + if (!bypassExtendedStableGuard && releaseTrain !== "extended-stable") { throw new Error("Extended-stable npm publication requires release patch 33 or above."); } return parsed; } - if (parsed.patch >= 33) { + if ( + releaseTrain === "extended-stable" || + releaseTrain === "unsupported-extended-stable-correction" + ) { throw new Error( `Final or correction release patch 33 and above must publish to the extended-stable npm dist-tag; got ${npmDistTag}.`, ); @@ -159,7 +163,7 @@ export function validateExtendedStableNpmReleaseRequest(request) { `Protected main must be in a later calendar month than ${taggedVersion.year}.${taggedVersion.month}; got ${request.mainPackageVersion}.`, ); } - if (mainVersion.patch >= 33) { + if (classifyReleaseTrain(mainVersion) !== "stable") { throw new Error("Protected main must remain on a daily patch below 33."); } return { extendedStable: true, releaseVersion, extendedStableBranch }; diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index f75defb8b5a8..e7c6c6c6b2a2 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -35,6 +35,7 @@ import { collectRuntimeDependencySpecs, packageNameFromSpecifier, } from "./lib/plugin-package-dependencies.mjs"; +import { classifyReleaseTrain } from "./lib/release-version.mjs"; import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs"; import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; @@ -297,9 +298,7 @@ function resolveNpmProvenanceVerificationPolicy( const expectedReleaseRef = `refs/heads/release/${parsedVersion.baseVersion}`; // A month's final patch >=33 releases stay on its canonical .33 maintenance branch. const isExpectedExtendedStableRef = - parsedVersion.channel === "stable" && - parsedVersion.correctionNumber === undefined && - parsedVersion.patch >= 33 && + classifyReleaseTrain(parsedVersion) === "extended-stable" && workflowRef === `refs/heads/extended-stable/${parsedVersion.year}.${parsedVersion.month}.33`; const protectedReleasePublishMatch = /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u.exec(workflowRef ?? ""); diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index ee3415338aac..21147383d653 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -5,17 +5,18 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { - compareReleaseVersions as compareReleaseVersionsBase, - collectReleaseVersionFloorErrors as collectReleaseVersionFloorErrorsBase, - resolveNpmDistTagMirrorAuth as resolveNpmDistTagMirrorAuthBase, - parseReleaseVersion as parseReleaseVersionBase, -} from "./lib/npm-publish-plan.mjs"; +import { resolveNpmDistTagMirrorAuth as resolveNpmDistTagMirrorAuthBase } from "./lib/npm-publish-plan.mjs"; import { LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "./lib/package-dist-inventory.ts"; +import { + compareReleaseVersions as compareReleaseVersionsBase, + collectReleaseVersionFloorErrors as collectReleaseVersionFloorErrorsBase, + parseReleaseVersion as parseReleaseVersionBase, + type ParsedReleaseVersion, +} from "./lib/release-version.mjs"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "./lib/workspace-bootstrap-smoke.mjs"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; @@ -32,18 +33,6 @@ type PackageJson = { peerDependenciesMeta?: Record; }; -type ParsedReleaseVersion = { - version: string; - baseVersion: string; - channel: "stable" | "alpha" | "beta"; - year: number; - month: number; - patch: number; - alphaNumber?: number; - betaNumber?: number; - correctionNumber?: number; -}; - type ParsedReleaseTag = { version: string; packageVersion: string; @@ -197,7 +186,7 @@ function isLocalDependencySpec(value: string | undefined): boolean { } export function parseReleaseVersion(version: string): ParsedReleaseVersion | null { - return parseReleaseVersionBase(version) as ParsedReleaseVersion | null; + return parseReleaseVersionBase(version); } export function compareReleaseVersions(left: string, right: string): number | null { diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 70630ed5c700..c064307e50b2 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; -import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./lib/release-version.mjs"; const parsedArgs = parseArgs(process.argv.slice(2)); const fix = parsedArgs.fix; diff --git a/scripts/release-prepare.ts b/scripts/release-prepare.ts index b0637858a91f..e269d5193424 100644 --- a/scripts/release-prepare.ts +++ b/scripts/release-prepare.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./lib/release-version.mjs"; type ReleasePrepareMode = "check" | "shadow" | "write"; diff --git a/scripts/release-version.ts b/scripts/release-version.ts index 5b1c9af30921..23b101b9ceb2 100644 --- a/scripts/release-version.ts +++ b/scripts/release-version.ts @@ -9,7 +9,7 @@ import { renderAndroidReleaseNotes, renderAndroidVersionProperties, } from "./lib/android-version.ts"; -import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; +import { parseReleaseVersion } from "./lib/release-version.mjs"; const MACOS_INFO_PLIST = "apps/macos/Sources/OpenClaw/Resources/Info.plist"; const ANDROID_CHANGELOG_FILE = "apps/android/CHANGELOG.md"; diff --git a/scripts/resolve-upgrade-survivor-baselines.mjs b/scripts/resolve-upgrade-survivor-baselines.mjs index 353d807601d2..6e942e949fab 100644 --- a/scripts/resolve-upgrade-survivor-baselines.mjs +++ b/scripts/resolve-upgrade-survivor-baselines.mjs @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { normalizeUpgradeSurvivorBaselineSpec } from "./lib/docker-e2e-plan.mjs"; -import { compareReleaseVersions, parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; +import { compareReleaseVersions, parseReleaseVersion } from "./lib/release-version.mjs"; export function parseArgs(argv) { const args = new Map(); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 73b03b365e76..c994b3388b86 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1409,6 +1409,29 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "test/scripts/upgrade-survivor-config-recipe.test.ts", ], ], + [ + "scripts/lib/release-version.mjs", + [ + "test/release-version.test.ts", + "test/npm-publish-plan.test.ts", + "test/openclaw-npm-release-check.test.ts", + "test/openclaw-npm-postpublish-verify.test.ts", + "test/plugin-npm-release.test.ts", + "test/plugin-clawhub-release.test.ts", + "test/scripts/android-version.test.ts", + "test/scripts/android-pin-version.test.ts", + "test/scripts/docker-release-policy.test.ts", + "test/scripts/ios-version.test.ts", + "test/scripts/openclaw-npm-extended-stable-release.test.ts", + "test/scripts/openclaw-npm-publish.test.ts", + "test/scripts/release-preflight.test.ts", + "test/scripts/release-prepare.test.ts", + "test/scripts/release-upgrade-baseline.test.ts", + "test/scripts/release-version.test.ts", + "test/scripts/upgrade-survivor-baselines.test.ts", + "test/scripts/upgrade-survivor-config-recipe.test.ts", + ], + ], ["scripts/sync-codex-model-prompt-fixture.ts", ["test/scripts/prompt-snapshots.test.ts"]], [ "scripts/lib/npm-pack-budget.mjs", @@ -2230,6 +2253,7 @@ const TOOLING_DECLARATION_SOURCE_MIRRORS = [ ["scripts/lib/local-build-metadata-paths.d.mts", "scripts/lib/local-build-metadata-paths.mjs"], ["scripts/lib/local-build-metadata.d.mts", "scripts/lib/local-build-metadata.mjs"], ["scripts/lib/plugin-sdk-entries.d.mts", "scripts/lib/plugin-sdk-entries.mjs"], + ["scripts/lib/release-version.d.mts", "scripts/lib/release-version.mjs"], ["scripts/lib/vitest-local-scheduling.d.mts", "scripts/lib/vitest-local-scheduling.mjs"], ["scripts/run-node.d.mts", "scripts/run-node.mjs"], ["scripts/stage-bundled-plugin-runtime.d.mts", "scripts/stage-bundled-plugin-runtime.mjs"], diff --git a/scripts/verify-docker-attestations.d.mts b/scripts/verify-docker-attestations.d.mts index 540c20568345..8c248d89ea00 100644 --- a/scripts/verify-docker-attestations.d.mts +++ b/scripts/verify-docker-attestations.d.mts @@ -15,6 +15,16 @@ export function parsePlatform(value: unknown): { * Collects missing/mismatched attestation errors for required image platforms. */ export function collectDockerAttestationErrors(params: unknown): string[]; +export function verifyDockerAttestations(params: { + imageRefs: string[]; + requiredPlatforms: Array<{ + architecture: string; + os: string; + variant?: string; + }>; + execFileSyncImpl?: (command: string, args: string[], options: unknown) => string; + log?: (message: string) => void; +}): void; export function inspectRaw( imageRef: unknown, params?: { diff --git a/scripts/verify-docker-attestations.mjs b/scripts/verify-docker-attestations.mjs index 32f48fc966ad..145b81be40b5 100644 --- a/scripts/verify-docker-attestations.mjs +++ b/scripts/verify-docker-attestations.mjs @@ -40,6 +40,43 @@ function formatPlatform(platform) { : `${platform.os}/${platform.architecture}`; } +/** Verify required Docker attestations for every image reference. */ +export function verifyDockerAttestations(params) { + const { + imageRefs, + requiredPlatforms, + execFileSyncImpl = execFileSync, + log = console.log, + } = params; + const allErrors = []; + for (const imageRef of imageRefs) { + const index = parseJson(inspectRaw(imageRef, { execFileSyncImpl }), `${imageRef} index`); + const errors = collectDockerAttestationErrors({ + imageRef, + index, + requiredPlatforms, + inspectAttestation(digest) { + return parseJson( + inspectRaw(imageRefForDigest(imageRef, digest), { execFileSyncImpl }), + `${imageRef} attestation ${digest}`, + ); + }, + }); + if (errors.length === 0) { + log( + `Verified Docker attestations for ${imageRef}: ${requiredPlatforms + .map(formatPlatform) + .join(", ")}`, + ); + } + allErrors.push(...errors); + } + + if (allErrors.length > 0) { + throw new Error(allErrors.map((error) => `[docker-attestations] ${error}`).join("\n")); + } +} + function platformMatches(actual, expected) { return ( actual?.os === expected.os && @@ -184,36 +221,10 @@ async function main() { throw new Error("At least one --platform is required."); } - const allErrors = []; - for (const imageRef of parsed.imageRefs) { - const index = parseJson(inspectRaw(imageRef), `${imageRef} index`); - const errors = collectDockerAttestationErrors({ - imageRef, - index, - requiredPlatforms: parsed.requiredPlatforms, - inspectAttestation(digest) { - return parseJson( - inspectRaw(imageRefForDigest(imageRef, digest)), - `${imageRef} attestation ${digest}`, - ); - }, - }); - if (errors.length === 0) { - console.log( - `Verified Docker attestations for ${imageRef}: ${parsed.requiredPlatforms - .map(formatPlatform) - .join(", ")}`, - ); - } - allErrors.push(...errors); - } - - if (allErrors.length > 0) { - for (const error of allErrors) { - console.error(`[docker-attestations] ${error}`); - } - process.exit(1); - } + verifyDockerAttestations({ + imageRefs: parsed.imageRefs, + requiredPlatforms: parsed.requiredPlatforms, + }); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 6d6778179b1f..1a5e10275960 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -461,10 +461,6 @@ describe("Dockerfile", () => { expect(workflow).toContain("OPENCLAW_INSTALL_BROWSER=1"); expect(workflow).toContain('${GHCR_IMAGE}:${version}-browser"'); expect(workflow).toContain('${DOCKERHUB_IMAGE}:${version}-browser"'); - expect(workflow).toContain('${GHCR_IMAGE}:latest-browser"'); - expect(workflow).toContain('${DOCKERHUB_IMAGE}:latest-browser"'); - expect(workflow).toContain('${GHCR_IMAGE}:main-browser"'); - expect(workflow).toContain('${DOCKERHUB_IMAGE}:main-browser"'); expect(workflow).not.toContain("main-browser-amd64"); expect(workflow).not.toContain("main-browser-arm64"); expect(workflow).toContain("Smoke test amd64 browser image"); @@ -493,17 +489,19 @@ describe("Dockerfile", () => { expect(workflow).toContain("DOCKERHUB_MULTI_REFS: ${{ steps.refs.outputs.dockerhub_multi }}"); }); - it("publishes beta Docker tags without advancing latest aliases", async () => { + it("validates release tags before immutable Docker publication", async () => { const workflow = await readFile(dockerReleaseWorkflowPath, "utf8"); - expect(workflow).toContain("Existing stable or beta release tag to backfill"); + expect(workflow).toContain("Existing stable, extended-stable, or beta release tag"); expect(workflow).toContain('! "${RELEASE_TAG}" =~ ^v[0-9]{4}'); - expect(workflow).toContain("(-beta\\.[1-9][0-9]*)?"); + expect(workflow).toContain("(-(beta\\.)?[1-9][0-9]*)?"); expect(workflow).toContain("${DOCKERHUB_IMAGE}:${version}"); expect(workflow).toContain("${DOCKERHUB_IMAGE}:${version}-slim"); expect(workflow).toContain("${DOCKERHUB_IMAGE}:${version}-browser"); - expect(workflow.split("do not advance latest/main aliases from those flows")).toHaveLength(3); - expect(workflow.split('"$version" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9]+)?$')).toHaveLength(3); + expect(workflow).toContain("node workflow-source/scripts/lib/docker-release-policy.mjs"); + expect(workflow).not.toContain("needs.resolve_release_policy.outputs.default_aliases"); + expect(workflow).not.toContain("needs.resolve_release_policy.outputs.slim_aliases"); + expect(workflow).not.toContain("needs.resolve_release_policy.outputs.browser_aliases"); }); it("smokes runtime workspace templates before Docker release manifests publish", async () => { diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 14e67022f86f..f8c7e1d8e4bb 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -264,7 +264,7 @@ describe("detectChangedScope", () => { "scripts/ios-write-swift-filelist.mjs", "scripts/ios-version.ts", "scripts/lib/ios-version.ts", - "scripts/lib/npm-publish-plan.mjs", + "scripts/lib/release-version.mjs", "scripts/lib/version-script-args.ts", ]) { expect(detectChangedScope([helperPath])).toEqual({ diff --git a/test/npm-publish-plan.test.ts b/test/npm-publish-plan.test.ts index 7b01397b9a52..acd05a936f8b 100644 --- a/test/npm-publish-plan.test.ts +++ b/test/npm-publish-plan.test.ts @@ -1,7 +1,6 @@ // npm publish plan tests validate package publish planning rules. import { describe, expect, it } from "vitest"; import { - collectReleaseVersionFloorErrors, fetchNpmRegistryPackumentWithRetry, resolveNpmDistTagMirrorAuth, resolveNpmPublishPlan, @@ -190,23 +189,6 @@ describe("fetchNpmRegistryPackumentWithRetry", () => { }); }); -describe("collectReleaseVersionFloorErrors", () => { - it("blocks June 2026 stable and beta release trains below the published beta floor", () => { - expect(collectReleaseVersionFloorErrors("2026.6.4")).toEqual([ - 'June 2026 stable and beta release trains must use patch 5 or higher because 2026.6.5-beta.1 is already published; found "2026.6.4".', - ]); - expect(collectReleaseVersionFloorErrors("2026.6.4-beta.1")).toEqual([ - 'June 2026 stable and beta release trains must use patch 5 or higher because 2026.6.5-beta.1 is already published; found "2026.6.4-beta.1".', - ]); - }); - - it("keeps alpha compatibility and patch-floor release trains valid during the transition", () => { - expect(collectReleaseVersionFloorErrors("2026.6.4-alpha.1")).toEqual([]); - expect(collectReleaseVersionFloorErrors("2026.6.5-beta.2")).toEqual([]); - expect(collectReleaseVersionFloorErrors("2026.7.1")).toEqual([]); - }); -}); - describe("resolvePublishedNpmVersionRoute", () => { it.each([ { diff --git a/test/release-version.test.ts b/test/release-version.test.ts new file mode 100644 index 000000000000..884068541030 --- /dev/null +++ b/test/release-version.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + classifyReleaseTrain, + collectReleaseVersionFloorErrors, + compareReleaseVersions, + parseReleaseVersion, +} from "../scripts/lib/release-version.mjs"; + +describe("release version policy", () => { + it.each([ + ["2026.7.2-alpha.1", "alpha"], + ["2026.7.2-beta.1", "beta"], + ["2026.7.32", "stable"], + ["2026.6.33", "extended-stable"], + ["2026.6.34", "extended-stable"], + ["2026.6.33-1", "unsupported-extended-stable-correction"], + ] as const)("classifies %s as %s", (version, expected) => { + const parsed = parseReleaseVersion(version); + if (!parsed) { + throw new Error(`test version did not parse: ${version}`); + } + expect(classifyReleaseTrain(parsed)).toBe(expected); + }); + + it("blocks June 2026 stable and beta release trains below the published beta floor", () => { + expect(collectReleaseVersionFloorErrors("2026.6.4")).toEqual([ + 'June 2026 stable and beta release trains must use patch 5 or higher because 2026.6.5-beta.1 is already published; found "2026.6.4".', + ]); + expect(collectReleaseVersionFloorErrors("2026.6.4-beta.1")).toEqual([ + 'June 2026 stable and beta release trains must use patch 5 or higher because 2026.6.5-beta.1 is already published; found "2026.6.4-beta.1".', + ]); + }); + + it("keeps alpha compatibility and patch-floor release trains valid during the transition", () => { + expect(collectReleaseVersionFloorErrors("2026.6.4-alpha.1")).toEqual([]); + expect(collectReleaseVersionFloorErrors("2026.6.5-beta.2")).toEqual([]); + expect(collectReleaseVersionFloorErrors("2026.7.1")).toEqual([]); + }); + + it("orders prereleases, finals, and corrections", () => { + expect(compareReleaseVersions("2026.3.29-alpha.2", "2026.3.29-beta.1")).toBe(-1); + expect(compareReleaseVersions("2026.3.29-beta.1", "2026.3.29")).toBe(-1); + expect(compareReleaseVersions("2026.3.29-2", "2026.3.29")).toBe(1); + }); +}); diff --git a/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts new file mode 100644 index 000000000000..64bb1356e2ab --- /dev/null +++ b/test/scripts/docker-channel-promote.test.ts @@ -0,0 +1,449 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; +import { + createDockerChannelPromotionPlan, + promoteDockerChannel, +} from "../../scripts/docker-channel-promote.mjs"; + +const images = ["ghcr.io/openclaw/openclaw", "docker.io/openclaw/openclaw"]; +const digest = `sha256:${"1".repeat(64)}`; +const changedDigest = `sha256:${"2".repeat(64)}`; + +function imageConfig(version: string): string { + return JSON.stringify({ + config: { Labels: { "org.opencontainers.image.version": version } }, + }); +} + +function createDockerMock(params: { + candidateVersion: string; + currentVersion?: string; + wrongTargetDigest?: string; +}) { + const targetDigests = new Map(); + return vi.fn((_command: string, args: string[]) => { + if (args[2] === "inspect") { + const ref = args[3]!; + if (args.at(-1)?.includes(".Image")) { + return imageConfig(ref.includes("@") ? params.candidateVersion : params.currentVersion!); + } + if (params.wrongTargetDigest && ref.includes(":extended-stable")) { + return JSON.stringify({ digest: params.wrongTargetDigest }); + } + return JSON.stringify({ digest: targetDigests.get(ref) ?? digest }); + } + const sourceDigest = args.at(-1)!.split("@")[1]!; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--tag") { + targetDigests.set(args[index + 1]!, sourceDigest); + } + } + return ""; + }); +} + +const skipAttestationVerification = () => {}; + +type WorkflowStep = { + env?: Record; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; + environment?: string; + needs?: string | string[]; + permissions?: Record; + steps?: WorkflowStep[]; +}; + +type Workflow = { + concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; + jobs?: Record; +}; + +function readWorkflow(path: string): Workflow { + return parse(readFileSync(path, "utf8")) as Workflow; +} + +function requireJob(workflow: Workflow, name: string): WorkflowJob { + const job = workflow.jobs?.[name]; + if (!job) { + throw new Error(`Missing workflow job: ${name}`); + } + return job; +} + +describe("Docker channel promotion", () => { + it("plans every extended-stable image variant in both registries", () => { + expect(createDockerChannelPromotionPlan({ version: "2026.6.33", images })).toEqual({ + channel: "extended-stable", + promotions: images.flatMap((image) => [ + { + image, + sourceRef: `${image}:2026.6.33`, + targetRefs: [`${image}:extended-stable`], + }, + { + image, + sourceRef: `${image}:2026.6.33-slim`, + targetRefs: [`${image}:extended-stable-slim`], + }, + { + image, + sourceRef: `${image}:2026.6.33-browser`, + targetRefs: [`${image}:extended-stable-browser`], + }, + ]), + version: "2026.6.33", + }); + }); + + it("preflights every source before moving and verifying aliases", () => { + const calls: string[][] = []; + const docker = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.33", + }); + const execFileSyncImpl = vi.fn((command: string, args: string[]) => { + calls.push(args); + return docker(command, args); + }); + const verifyAttestationsImpl = vi.fn(); + + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl }, + ); + + const firstCreate = calls.findIndex((args) => args[2] === "create"); + expect(firstCreate).toBe(30); + expect(calls.slice(0, firstCreate).every((args) => args[2] === "inspect")).toBe(true); + expect(calls.filter((args) => args[2] === "create")).toHaveLength(6); + expect(verifyAttestationsImpl).toHaveBeenCalledWith( + expect.objectContaining({ + imageRefs: [ + `ghcr.io/openclaw/openclaw@${digest}`, + `ghcr.io/openclaw/openclaw@${digest}`, + `ghcr.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + `docker.io/openclaw/openclaw@${digest}`, + ], + requiredPlatforms: [ + { architecture: "amd64", os: "linux", variant: undefined }, + { architecture: "arm64", os: "linux", variant: undefined }, + ], + }), + ); + expect(execFileSyncImpl).toHaveBeenCalledWith( + "docker", + [ + "buildx", + "imagetools", + "create", + "--prefer-index=false", + "--tag", + "ghcr.io/openclaw/openclaw:extended-stable", + `ghcr.io/openclaw/openclaw@${digest}`, + ], + expect.objectContaining({ timeout: 120_000 }), + ); + }); + + it("fails without mutating when any version-specific source is missing", () => { + const calls: string[][] = []; + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + calls.push(args); + if (calls.length === 3) { + throw new Error("missing manifest"); + } + return JSON.stringify({ digest }); + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow("missing manifest"); + expect(calls.some((args) => args[2] === "create")).toBe(false); + }); + + it("fails when a promoted alias does not match its version-specific source", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.33", + wrongTargetDigest: changedDigest, + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow(`resolved to ${changedDigest}, expected ${digest}`); + }); + + it("refuses automatic channel rollback before writing aliases", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.34", + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow( + "Refusing to move ghcr.io/openclaw/openclaw:extended-stable backward from 2026.6.34 to 2026.6.33", + ); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); + }); + + it.each([ + ["same", "2026.6.33", "2026.6.33"], + ["newer", "2026.6.34", "2026.6.33"], + ])("allows an automatic %s-version promotion", (_label, candidateVersion, currentVersion) => { + const execFileSyncImpl = createDockerMock({ candidateVersion, currentVersion }); + + promoteDockerChannel( + { version: candidateVersion, images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ); + + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true); + }); + + it("allows an explicitly approved rollback", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.33", + currentVersion: "2026.6.34", + }); + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { + allowRollback: true, + execFileSyncImpl, + verifyAttestationsImpl: skipAttestationVerification, + }, + ); + + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true); + }); + + it("allows a first promotion when the target alias does not exist", () => { + let created = false; + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args[2] === "create") { + created = true; + return ""; + } + if (args.at(-1)?.includes(".Image")) { + if (!args[3]!.includes("@") && !created) { + const error = new Error("docker inspect failed"); + Object.assign(error, { stderr: `ERROR: ${args[3]}: not found` }); + throw error; + } + return imageConfig("2026.6.33"); + } + return JSON.stringify({ digest }); + }); + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ); + + expect(created).toBe(true); + }); + + it("fails closed when an existing alias cannot be inspected", () => { + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args.at(-1)?.includes(".Image") && !args[3]!.includes("@")) { + const error = new Error("unauthorized: authentication required"); + Object.assign(error, { stderr: "denied: requested access to the resource is denied" }); + throw error; + } + if (args.at(-1)?.includes(".Image")) { + return imageConfig("2026.6.33"); + } + return JSON.stringify({ digest }); + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow("unauthorized"); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); + }); + + it("promotes the same digests whose attestations were verified", () => { + let sourceDigest = digest; + const targetDigests = new Map(); + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args[2] === "create") { + const promotedDigest = args.at(-1)!.split("@")[1]!; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--tag") { + targetDigests.set(args[index + 1]!, promotedDigest); + } + } + return ""; + } + if (args.at(-1)?.includes(".Image")) { + return imageConfig("2026.6.33"); + } + const ref = args[3]!; + return JSON.stringify({ digest: targetDigests.get(ref) ?? sourceDigest }); + }); + const verifiedRefs: string[] = []; + + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { + execFileSyncImpl, + verifyAttestationsImpl({ imageRefs }) { + verifiedRefs.push(...imageRefs); + sourceDigest = changedDigest; + }, + }, + ); + + expect(verifiedRefs).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`)); + expect( + execFileSyncImpl.mock.calls + .filter(([, args]) => args[2] === "create") + .map(([, args]) => args.at(-1)), + ).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`)); + }); + + it("rejects a source whose version label does not match the requested release", () => { + const execFileSyncImpl = createDockerMock({ + candidateVersion: "2026.6.34", + currentVersion: "2026.6.33", + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow(`ghcr.io/openclaw/openclaw@${digest} reports version 2026.6.34, expected 2026.6.33`); + }); + + it("rejects a source whose platform version labels disagree", () => { + const execFileSyncImpl = vi.fn((_command: string, args: string[]) => { + if (args.at(-1)?.includes(".Image")) { + const version = args.at(-1)?.includes("linux/arm64") ? "2026.6.34" : "2026.6.33"; + return imageConfig(version); + } + return JSON.stringify({ digest }); + }); + + expect(() => + promoteDockerChannel( + { version: "2026.6.33", images: images.slice(0, 1) }, + { execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification }, + ), + ).toThrow("inconsistent platform versions: linux/amd64=2026.6.33, linux/arm64=2026.6.34"); + expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false); + }); + + it("rejects channels without moving aliases", () => { + expect(() => createDockerChannelPromotionPlan({ version: "2026.7.2-beta.3", images })).toThrow( + "no moving aliases", + ); + }); + + it("uses the digest-bound promotion path for releases and approved repairs", () => { + const workflow = readWorkflow(".github/workflows/docker-channel-promote.yml"); + const releaseWorkflow = readWorkflow(".github/workflows/docker-release.yml"); + const createManifest = requireJob(releaseWorkflow, "create-manifest"); + const verifyAttestations = requireJob(releaseWorkflow, "verify-attestations"); + const resolve = requireJob(workflow, "resolve"); + const approve = requireJob(workflow, "approve"); + const promote = requireJob(workflow, "promote"); + + expect(releaseWorkflow.concurrency).toEqual({ + group: + "${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || 'docker-release-publish' }}", + "cancel-in-progress": false, + queue: "max", + }); + expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" }); + + const manifestTagStep = createManifest.steps?.find( + (step) => step.name === "Resolve manifest tags", + ); + expect(manifestTagStep?.run).not.toContain("alias"); + expect(manifestTagStep?.env).not.toHaveProperty("DEFAULT_ALIASES"); + + const releaseSteps = verifyAttestations.steps ?? []; + const resolveRefsStep = releaseSteps.find((step) => step.name === "Resolve image refs"); + expect(resolveRefsStep?.run).not.toContain("alias"); + expect(resolveRefsStep?.env).not.toHaveProperty("DEFAULT_ALIASES"); + const releaseAttestationIndex = releaseSteps.findIndex( + (step) => step.name === "Verify Docker attestations", + ); + const releasePromotionIndex = releaseSteps.findIndex( + (step) => step.name === "Promote and verify channel aliases", + ); + expect(releaseAttestationIndex).toBeGreaterThan(-1); + expect(releasePromotionIndex).toBeGreaterThan(releaseAttestationIndex); + expect(releaseSteps[releasePromotionIndex]?.if).toBe( + "${{ github.event_name != 'workflow_dispatch' && needs.resolve_release_policy.outputs.channel != 'beta' }}", + ); + expect(releaseSteps[releasePromotionIndex]?.run).toContain( + "node scripts/docker-channel-promote.mjs", + ); + expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback"); + expect( + Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) => + (job.steps ?? []).filter((step) => step.run?.includes("docker-channel-promote.mjs")), + ), + ).toHaveLength(1); + + expect(resolve.permissions).toEqual({ contents: "read" }); + expect(resolve.steps?.find((step) => step.uses?.startsWith("actions/checkout@"))?.with).toEqual( + expect.objectContaining({ ref: "${{ github.sha }}", "persist-credentials": false }), + ); + expect(approve.needs).toBe("resolve"); + expect(approve.environment).toBe("docker-release"); + expect(approve.permissions).toEqual({}); + expect(promote.needs).toEqual(["resolve", "approve"]); + expect(promote.permissions).toEqual({ contents: "read", packages: "write" }); + expect(promote.concurrency).toEqual({ + group: "docker-release-publish", + "cancel-in-progress": false, + queue: "max", + }); + expect(promote.steps?.find((step) => step.uses?.startsWith("actions/checkout@"))?.with).toEqual( + expect.objectContaining({ ref: "${{ github.sha }}", "persist-credentials": false }), + ); + + const steps = promote.steps ?? []; + const promotionIndex = steps.findIndex( + (step) => step.name === "Promote and verify channel aliases", + ); + expect(steps.some((step) => step.run?.includes("verify-docker-attestations.mjs"))).toBe(false); + expect(promotionIndex).toBeGreaterThan(-1); + expect(steps[promotionIndex]?.run).toContain("node scripts/docker-channel-promote.mjs"); + expect(steps[promotionIndex]?.run).toContain("--allow-rollback"); + + const packageWriters = Object.entries(workflow.jobs ?? {}).filter( + ([, job]) => job.permissions?.packages === "write", + ); + expect(packageWriters.map(([name]) => name)).toEqual(["promote"]); + expect(packageWriters[0]?.[1].needs).toContain("approve"); + }); +}); diff --git a/test/scripts/docker-release-policy.test.ts b/test/scripts/docker-release-policy.test.ts new file mode 100644 index 000000000000..98d181a91c5b --- /dev/null +++ b/test/scripts/docker-release-policy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { resolveDockerReleasePolicy } from "../../scripts/lib/docker-release-policy.mjs"; + +describe("Docker release policy", () => { + it("advances regular stable aliases only for final and correction patches below 33", () => { + for (const version of ["2026.7.1", "2026.7.1-2"]) { + expect(resolveDockerReleasePolicy(version)).toEqual({ + version, + channel: "stable", + movingAliases: { + default: ["latest", "main"], + slim: ["slim", "main-slim"], + browser: ["latest-browser", "main-browser"], + }, + }); + } + }); + + it("keeps extended-stable releases on dedicated moving aliases", () => { + for (const version of ["2026.6.33", "2026.6.34", "2026.6.99"]) { + expect(resolveDockerReleasePolicy(version)).toEqual({ + version, + channel: "extended-stable", + movingAliases: { + default: ["extended-stable"], + slim: ["extended-stable-slim"], + browser: ["extended-stable-browser"], + }, + }); + } + }); + + it("publishes beta versions without moving a channel alias", () => { + expect(resolveDockerReleasePolicy("2026.7.2-beta.3")).toEqual({ + version: "2026.7.2-beta.3", + channel: "beta", + movingAliases: { default: [], slim: [], browser: [] }, + }); + }); + + it.each(["2026.6.33-1", "2026.6.33-alpha.1", "2026.0.33", "not-a-version"])( + "rejects unsupported release version %s", + (version) => { + expect(() => resolveDockerReleasePolicy(version)).toThrow(); + }, + ); +}); diff --git a/test/scripts/openclaw-npm-extended-stable-release.test.ts b/test/scripts/openclaw-npm-extended-stable-release.test.ts index 7928cedc4863..5eb471601b86 100644 --- a/test/scripts/openclaw-npm-extended-stable-release.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-release.test.ts @@ -85,6 +85,11 @@ describe("npm extended-stable publication boundary", () => { expect(() => validateNpmPublishBoundary("2026.6.11", "extended-stable")).toThrow( /patch 33 or above/u, ); + expect(() => + validateNpmPublishBoundary("2026.6.11-1", "extended-stable", { + bypassExtendedStableGuard: true, + }), + ).toThrow(/does not allow correction suffixes/u); }); it.each(["alpha", "beta", "latest"])( diff --git a/test/scripts/openclaw-npm-publish.test.ts b/test/scripts/openclaw-npm-publish.test.ts index 977ef0d28ab1..a0042ab4705f 100644 --- a/test/scripts/openclaw-npm-publish.test.ts +++ b/test/scripts/openclaw-npm-publish.test.ts @@ -41,6 +41,10 @@ function makeReleaseCheckout(root: string, version: string): string { "scripts/lib/npm-publish-plan.mjs", path.join(checkout, "scripts/lib/npm-publish-plan.mjs"), ); + copyFileSync( + "scripts/lib/release-version.mjs", + path.join(checkout, "scripts/lib/release-version.mjs"), + ); return checkout; } diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 3c6d99a5ba9a..b10716b1fb21 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2193,6 +2193,29 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/upgrade-survivor-config-recipe.test.ts", ], ], + [ + "scripts/lib/release-version.mjs", + [ + "test/release-version.test.ts", + "test/npm-publish-plan.test.ts", + "test/openclaw-npm-release-check.test.ts", + "test/openclaw-npm-postpublish-verify.test.ts", + "test/plugin-npm-release.test.ts", + "test/plugin-clawhub-release.test.ts", + "test/scripts/android-version.test.ts", + "test/scripts/android-pin-version.test.ts", + "test/scripts/docker-release-policy.test.ts", + "test/scripts/ios-version.test.ts", + "test/scripts/openclaw-npm-extended-stable-release.test.ts", + "test/scripts/openclaw-npm-publish.test.ts", + "test/scripts/release-preflight.test.ts", + "test/scripts/release-prepare.test.ts", + "test/scripts/release-upgrade-baseline.test.ts", + "test/scripts/release-version.test.ts", + "test/scripts/upgrade-survivor-baselines.test.ts", + "test/scripts/upgrade-survivor-config-recipe.test.ts", + ], + ], [ "scripts/lib/npm-pack-budget.mjs", ["test/release-check.test.ts", "test/scripts/test-install-sh-docker.test.ts"],