mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
+19
-16
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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$)/;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}".`,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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]}`) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user