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
This commit is contained in:
Dallin Romney
2026-07-23 10:03:11 +09:00
committed by GitHub
parent 79d4e4a3ad
commit 74cc99f2d1
45 changed files with 1683 additions and 384 deletions
@@ -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
@@ -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.
+7
View File
@@ -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'
@@ -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"
+68 -36
View File
@@ -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}"
+1
View File
@@ -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"
+6 -5
View File
@@ -164,14 +164,15 @@ gh workflow run ci.yml --ref main -f target_ref=<branch-or-sha> -f include_andro
gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
```
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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`, `<version>` (e.g. `2026.2.26`), and beta tags such as `2026.2.26-beta.1` (betas never move `latest`/`main`). The default `main`/`latest`/`<version>` 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.
</Step>
+34 -14
View File
@@ -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:
+1 -1
View File
@@ -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$)/;
+37
View File
@@ -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;
+288
View File
@@ -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;
}
}
@@ -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);
+1 -1
View File
@@ -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";
+15
View File
@@ -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;
+80
View File
@@ -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 <version>");
}
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;
}
}
+1 -1
View File
@@ -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";
-16
View File
@@ -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<void>;
createSignal?: (timeoutMs: number) => AbortSignal;
}): Promise<NpmRegistryPackumentResult>;
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,
+7 -185
View File
@@ -1,25 +1,8 @@
// Parses OpenClaw monthly patch release versions and npm dist-tag publish plans.
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
const ALPHA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-alpha\.(?<alpha>[1-9]\d*)$/;
const BETA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-beta\.(?<beta>[1-9]\d*)$/;
const CORRECTION_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-(?<correction>[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<string, string | undefined>} 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}".`,
);
+1
View File
@@ -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",
+2 -5
View File
@@ -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;
+1 -1
View File
@@ -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;
+23
View File
@@ -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;
+203
View File
@@ -0,0 +1,203 @@
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
const ALPHA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-alpha\.(?<alpha>[1-9]\d*)$/;
const BETA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-beta\.(?<beta>[1-9]\d*)$/;
const CORRECTION_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-(?<correction>[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<string, string | undefined>} 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));
}
@@ -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;
@@ -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 };
+2 -3
View File
@@ -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 ?? "");
+8 -19
View File
@@ -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<string, { optional?: boolean }>;
};
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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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();
+24
View File
@@ -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"],
+10
View File
@@ -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?: {
+39 -28
View File
@@ -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,
verifyDockerAttestations({
imageRefs: parsed.imageRefs,
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);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
+7 -9
View File
@@ -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 () => {
+1 -1
View File
@@ -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({
-18
View File
@@ -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([
{
+45
View File
@@ -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);
});
});
+449
View File
@@ -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<string, string>();
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<string, string>;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, boolean | string>;
};
type WorkflowJob = {
concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string };
environment?: string;
needs?: string | string[];
permissions?: Record<string, string>;
steps?: WorkflowStep[];
};
type Workflow = {
concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string };
jobs?: Record<string, WorkflowJob>;
};
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<string, string>();
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");
});
});
@@ -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();
},
);
});
@@ -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"])(
@@ -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;
}
+23
View File
@@ -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"],