fix(release): isolate extended-stable Docker aliases

This commit is contained in:
Dallin Romney
2026-07-22 12:39:19 +09:00
parent 3946bcc20c
commit db9915fedb
9 changed files with 417 additions and 56 deletions
@@ -224,7 +224,7 @@ 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
@@ -272,8 +272,15 @@ on pinned current `main` as the exact command and validation contract.
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.
9. Require the tag-triggered `Docker Release` run to publish and verify the
exact default, slim, browser, and architecture images in GHCR and Docker
Hub. It must advance only `extended-stable`, `extended-stable-slim`, and
`extended-stable-browser`. If immutable images already exist but channel
aliases need repair, dispatch `docker-release.yml` from current `main` with
the exact tag and `operation=promote-channel`; never rebuild or move the
immutable release tag just to repair aliases.
10. Do not create a GitHub Release or publish macOS, Windows, mobile, website,
ClawHub, or private dist-tag artifacts from this path.
## Keep release channel naming aligned
@@ -1,6 +1,6 @@
# Extended-Stable Backport Preparation
Prepare the next npm maintenance patch for the active `extended-stable` line.
Prepare the next npm and Docker 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.
@@ -15,15 +15,15 @@ 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
- Target npm and Docker `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`.
- 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.
- Exclude ClawHub publication, GitHub Releases, native apps, mobile artifacts,
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 +148,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 +159,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 native-only, mobile-only, website-only, and GitHub Release-only fixes
as `skip` for this extended-stable line.
- Treat cross-repository or package-topology uncertainty as `blocked` until the
shipped npm surface and release owner are proven.
+231 -26
View File
@@ -14,9 +14,17 @@ 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
operation:
description: Build immutable images, or promote an existing immutable version to its moving channel aliases
required: true
type: choice
default: backfill
options:
- backfill
- promote-channel
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
@@ -45,7 +53,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,18 +64,69 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
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 }}
default_aliases: ${{ steps.policy.outputs.default_aliases }}
slim_aliases: ${{ steps.policy.outputs.slim_aliases }}
browser_aliases: ${{ steps.policy.outputs.browser_aliases }}
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 }}
RELEASE_OPERATION: ${{ github.event_name == 'workflow_dispatch' && inputs.operation || 'release' }}
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}")"
if [[ "${RELEASE_OPERATION}" == "promote-channel" && "${channel}" == "beta" ]]; then
echo "::error::Beta releases have no moving Docker channel aliases to promote."
exit 1
fi
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"
approve_manual_backfill:
if: github.event_name == 'workflow_dispatch'
needs: validate_manual_backfill
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
permissions: {}
steps:
- name: Approve Docker backfill
- name: Approve Docker operation
env:
RELEASE_TAG: ${{ inputs.tag }}
run: echo "Approved Docker backfill for $RELEASE_TAG"
RELEASE_OPERATION: ${{ inputs.operation }}
run: echo "Approved Docker ${RELEASE_OPERATION} for ${RELEASE_TAG}"
validate_publish_config:
runs-on: ubuntu-24.04
@@ -88,8 +147,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' && inputs.operation == 'backfill')) }}
runs-on: ubuntu-24.04
permissions:
contents: read
@@ -555,6 +614,7 @@ jobs:
needs:
[
approve_manual_backfill,
resolve_release_policy,
validate_publish_config,
resolve_build_provenance,
build-amd64,
@@ -595,6 +655,9 @@ jobs:
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' }}
DEFAULT_ALIASES: ${{ needs.resolve_release_policy.outputs.default_aliases }}
SLIM_ALIASES: ${{ needs.resolve_release_policy.outputs.slim_aliases }}
BROWSER_ALIASES: ${{ needs.resolve_release_policy.outputs.browser_aliases }}
run: |
set -euo pipefail
tags=()
@@ -617,16 +680,25 @@ 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")
# Manual backfills publish immutable tags only. Tag pushes advance
# only the moving aliases assigned by the release-version policy.
if [[ "${IS_MANUAL_BACKFILL}" != "1" ]]; then
read -r -a default_aliases <<< "${DEFAULT_ALIASES}"
read -r -a slim_aliases <<< "${SLIM_ALIASES}"
read -r -a browser_aliases <<< "${BROWSER_ALIASES}"
for alias in "${default_aliases[@]}"; do
tags+=("${GHCR_IMAGE}:${alias}")
dockerhub_tags+=("${DOCKERHUB_IMAGE}:${alias}")
done
for alias in "${slim_aliases[@]}"; do
slim_tags+=("${GHCR_IMAGE}:${alias}")
dockerhub_slim_tags+=("${DOCKERHUB_IMAGE}:${alias}")
done
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")
for alias in "${browser_aliases[@]}"; do
browser_tags+=("${GHCR_IMAGE}:${alias}")
dockerhub_browser_tags+=("${DOCKERHUB_IMAGE}:${alias}")
done
fi
fi
fi
@@ -696,8 +768,129 @@ jobs:
"${dockerhub_browser_tags[@]}"
fi
promote-channel-aliases:
needs: [approve_manual_backfill, resolve_release_policy, validate_publish_config]
if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.operation == 'promote-channel' && needs.approve_manual_backfill.result == 'success' && needs.resolve_release_policy.result == 'success' && needs.validate_publish_config.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
packages: write
contents: read
steps:
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 1
persist-credentials: false
- *buildkit_prepull_step
- 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
shell: bash
env:
VERSION: ${{ needs.resolve_release_policy.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve_release_policy.outputs.channel }}
DEFAULT_ALIASES: ${{ needs.resolve_release_policy.outputs.default_aliases }}
SLIM_ALIASES: ${{ needs.resolve_release_policy.outputs.slim_aliases }}
BROWSER_ALIASES: ${{ needs.resolve_release_policy.outputs.browser_aliases }}
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
run: |
set -euo pipefail
manifest_digest() {
local ref="$1"
docker buildx imagetools inspect "${ref}" --format '{{json .Manifest}}' |
jq -er '.digest | select(startswith("sha256:"))'
}
require_group_source() {
local image="$1"
local suffix="$2"
local aliases_text="$3"
if [[ -n "${aliases_text}" ]]; then
manifest_digest "${image}:${VERSION}${suffix}" >/dev/null
fi
}
promote_group() {
local image="$1"
local suffix="$2"
local aliases_text="$3"
if [[ -z "${aliases_text}" ]]; then
return
fi
local source_ref="${image}:${VERSION}${suffix}"
local source_digest
source_digest="$(manifest_digest "${source_ref}")"
local aliases=()
local create_args=()
read -r -a aliases <<< "${aliases_text}"
for alias in "${aliases[@]}"; do
create_args+=(--tag "${image}:${alias}")
done
docker buildx imagetools create --prefer-index=false \
"${create_args[@]}" "${image}@${source_digest}"
for alias in "${aliases[@]}"; do
local target_ref="${image}:${alias}"
local target_digest
target_digest="$(manifest_digest "${target_ref}")"
if [[ "${target_digest}" != "${source_digest}" ]]; then
echo "::error::${target_ref} resolved to ${target_digest}, expected ${source_digest}."
exit 1
fi
echo "Verified ${target_ref} -> ${source_digest}."
done
}
browser_supported=0
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
browser_supported=1
fi
# Resolve every immutable source before moving any alias so missing
# release images fail without leaving a partially promoted channel.
for image in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
require_group_source "${image}" "" "${DEFAULT_ALIASES}"
require_group_source "${image}" "-slim" "${SLIM_ALIASES}"
if [[ "${browser_supported}" == "1" ]]; then
require_group_source "${image}" "-browser" "${BROWSER_ALIASES}"
fi
done
for image in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
promote_group "${image}" "" "${DEFAULT_ALIASES}"
promote_group "${image}" "-slim" "${SLIM_ALIASES}"
if [[ "${browser_supported}" == "1" ]]; then
promote_group "${image}" "-browser" "${BROWSER_ALIASES}"
fi
done
{
echo "## Docker channel promotion"
echo "- Version: ${VERSION}"
echo "- Channel: ${RELEASE_CHANNEL}"
echo "- Registries: ${GHCR_IMAGE}, ${DOCKERHUB_IMAGE}"
} >> "$GITHUB_STEP_SUMMARY"
verify-attestations:
needs: [resolve_build_provenance, create-manifest]
needs: [resolve_release_policy, resolve_build_provenance, create-manifest]
if: ${{ always() && needs.create-manifest.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
@@ -736,6 +929,9 @@ jobs:
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' }}
DEFAULT_ALIASES: ${{ needs.resolve_release_policy.outputs.default_aliases }}
SLIM_ALIASES: ${{ needs.resolve_release_policy.outputs.slim_aliases }}
BROWSER_ALIASES: ${{ needs.resolve_release_policy.outputs.browser_aliases }}
run: |
set -euo pipefail
multi_refs=()
@@ -784,16 +980,25 @@ 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")
# Manual backfills verify immutable tags only. Tag pushes also
# verify the moving aliases selected by the release policy.
if [[ "${IS_MANUAL_BACKFILL}" != "1" ]]; then
read -r -a default_aliases <<< "${DEFAULT_ALIASES}"
read -r -a slim_aliases <<< "${SLIM_ALIASES}"
read -r -a browser_aliases <<< "${BROWSER_ALIASES}"
for alias in "${default_aliases[@]}"; do
multi_refs+=("${GHCR_IMAGE}:${alias}")
dockerhub_multi_refs+=("${DOCKERHUB_IMAGE}:${alias}")
done
for alias in "${slim_aliases[@]}"; do
slim_multi_refs+=("${GHCR_IMAGE}:${alias}")
dockerhub_slim_multi_refs+=("${DOCKERHUB_IMAGE}:${alias}")
done
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")
for alias in "${browser_aliases[@]}"; do
multi_refs+=("${GHCR_IMAGE}:${alias}")
dockerhub_multi_refs+=("${DOCKERHUB_IMAGE}:${alias}")
done
fi
fi
fi
+1 -1
View File
@@ -8739,7 +8739,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 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. Immutable tags include exact versions such as `2026.2.26` and prereleases such as `2026.2.26-beta.1`. Regular stable releases move `latest` and `main`; trailing-month releases move `extended-stable` without changing the regular stable aliases. Matching slim and browser channels are `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>
+14 -7
View File
@@ -14,8 +14,9 @@ OpenClaw currently exposes three user-facing update channels:
- 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
package to npm `extended-stable` and its official Docker images to dedicated
extended-stable aliases, beginning at patch `33`. The current-month regular
final line continues on npm `latest` and Docker `latest`/`main`; the publication
split does not by itself change CLI update-channel resolution.
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).
@@ -34,7 +35,7 @@ Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `al
- `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
- 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 dedicated monthly extended-stable path publishes the core npm package and every npm-publishable official plugin at the same exact version. Its release tag also publishes exact Docker images to GHCR and Docker Hub, then advances only `extended-stable`, `extended-stable-slim`, and `extended-stable-browser`. It does not publish plugins to ClawHub or publish macOS or Windows artifacts, a GitHub Release, private-repository dist-tags, mobile artifacts, or 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 +45,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 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
@@ -91,6 +92,12 @@ and push immutable `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`. For a final patch `33` or later, that
workflow publishes immutable default, slim, browser, and architecture tags to
GHCR and Docker Hub. It advances only the three extended-stable Docker aliases;
it must not move `latest`, `main`, or their variants. Require the Docker run and
its attestation verification to succeed before declaring the release complete.
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
@@ -162,7 +169,7 @@ 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.
steps for this extended-stable path.
## Regular release operator checklist
@@ -472,7 +479,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+` extended-stable path does not use this orchestrator. The
regular workflow orchestrates the trusted-publisher workflows in the order the
release needs:
@@ -631,7 +638,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+` extended-stable path documented at the top of this page.
When cutting a regular orchestrated stable release:
+80
View File
@@ -0,0 +1,80 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseReleaseVersion } from "./npm-publish-plan.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}".`);
}
if (parsed.channel === "alpha") {
throw new Error("Docker alpha image publishing is disabled.");
}
if (parsed.channel === "beta") {
return { version: parsed.version, channel: "beta", movingAliases: NO_MOVING_ALIASES };
}
if (parsed.patch >= 33) {
if (parsed.correctionNumber !== undefined) {
throw new Error(
`Extended-stable Docker publication requires a final YYYY.M.PATCH version; found "${version}".`,
);
}
return {
version: parsed.version,
channel: "extended-stable",
movingAliases: EXTENDED_STABLE_ALIASES,
};
}
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 (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === 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;
}
}
+23 -9
View File
@@ -461,10 +461,11 @@ 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).toContain(
"BROWSER_ALIASES: ${{ needs.resolve_release_policy.outputs.browser_aliases }}",
);
expect(workflow).toContain('browser_tags+=("${GHCR_IMAGE}:${alias}")');
expect(workflow).toContain('dockerhub_browser_tags+=("${DOCKERHUB_IMAGE}:${alias}")');
expect(workflow).not.toContain("main-browser-amd64");
expect(workflow).not.toContain("main-browser-arm64");
expect(workflow).toContain("Smoke test amd64 browser image");
@@ -493,17 +494,30 @@ describe("Dockerfile", () => {
expect(workflow).toContain("DOCKERHUB_MULTI_REFS: ${{ steps.refs.outputs.dockerhub_multi }}");
});
it("publishes beta Docker tags without advancing latest aliases", async () => {
it("keeps moving Docker aliases behind the release-version policy", 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.split("needs.resolve_release_policy.outputs.default_aliases")).toHaveLength(4);
expect(workflow.split("needs.resolve_release_policy.outputs.slim_aliases")).toHaveLength(4);
expect(workflow.split("needs.resolve_release_policy.outputs.browser_aliases")).toHaveLength(4);
});
it("promotes existing immutable Docker images without rebuilding them", async () => {
const workflow = await readFile(dockerReleaseWorkflowPath, "utf8");
expect(workflow).toContain("promote-channel-aliases:");
expect(workflow).toContain("inputs.operation == 'promote-channel'");
expect(workflow).toContain("docker buildx imagetools create --prefer-index=false");
expect(workflow).toContain('"${image}@${source_digest}"');
expect(workflow).toContain('if [[ "${target_digest}" != "${source_digest}" ]]; then');
expect(workflow).toContain('require_group_source "${image}" "-slim" "${SLIM_ALIASES}"');
});
it("smokes runtime workspace templates before Docker release manifests publish", async () => {
@@ -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();
},
);
});