diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 225db27a5e25..2d64d1ecdd2c 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -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 diff --git a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md index 5e8ae5f706a3..af07ada84a15 100644 --- a/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-backports.md @@ -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. diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 0cfdd26bc691..b0552049bb04 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index e1b980c3acf3..748ac3f3efed 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/install/docker.md b/docs/install/docker.md index b5193b51260f..63d60fdde710 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -43,7 +43,7 @@ Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting ./scripts/docker/setup.sh ``` - Use `ghcr.io/openclaw/openclaw` or `openclaw/openclaw` and avoid unofficial mirrors, which don't share OpenClaw's release timing or retention policy. Official tags: `main`, `latest`, `` (e.g. `2026.2.26`), and beta tags such as `2026.2.26-beta.1` (betas never move `latest`/`main`). The default `main`/`latest`/`` image bundles the `codex` and `diagnostics-otel` plugins. A `-browser` variant (e.g. `latest-browser`) also ships with Chromium baked in, useful for the [sandboxed browser](/gateway/sandboxing#sandboxed-browser) tool without a first-run Playwright install. + Use `ghcr.io/openclaw/openclaw` or `openclaw/openclaw` and avoid unofficial mirrors, which don't share OpenClaw's release timing or retention policy. 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. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 5d02c6b501c8..6df43b9fe1b0 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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: diff --git a/scripts/lib/docker-release-policy.mjs b/scripts/lib/docker-release-policy.mjs new file mode 100644 index 000000000000..dd21d02178da --- /dev/null +++ b/scripts/lib/docker-release-policy.mjs @@ -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 "); + } + 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; + } +} diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 6d6778179b1f..a28cac9ad4e5 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -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 () => { diff --git a/test/scripts/docker-release-policy.test.ts b/test/scripts/docker-release-policy.test.ts new file mode 100644 index 000000000000..98d181a91c5b --- /dev/null +++ b/test/scripts/docker-release-policy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { resolveDockerReleasePolicy } from "../../scripts/lib/docker-release-policy.mjs"; + +describe("Docker release policy", () => { + it("advances regular stable aliases only for final and correction patches below 33", () => { + for (const version of ["2026.7.1", "2026.7.1-2"]) { + expect(resolveDockerReleasePolicy(version)).toEqual({ + version, + channel: "stable", + movingAliases: { + default: ["latest", "main"], + slim: ["slim", "main-slim"], + browser: ["latest-browser", "main-browser"], + }, + }); + } + }); + + it("keeps extended-stable releases on dedicated moving aliases", () => { + for (const version of ["2026.6.33", "2026.6.34", "2026.6.99"]) { + expect(resolveDockerReleasePolicy(version)).toEqual({ + version, + channel: "extended-stable", + movingAliases: { + default: ["extended-stable"], + slim: ["extended-stable-slim"], + browser: ["extended-stable-browser"], + }, + }); + } + }); + + it("publishes beta versions without moving a channel alias", () => { + expect(resolveDockerReleasePolicy("2026.7.2-beta.3")).toEqual({ + version: "2026.7.2-beta.3", + channel: "beta", + movingAliases: { default: [], slim: [], browser: [] }, + }); + }); + + it.each(["2026.6.33-1", "2026.6.33-alpha.1", "2026.0.33", "not-a-version"])( + "rejects unsupported release version %s", + (version) => { + expect(() => resolveDockerReleasePolicy(version)).toThrow(); + }, + ); +});