feat(release): add non-Latest extended-stable releases (#154515)

* feat(release): add non-Latest extended-stable releases

Reconcile #120522 with current main while preserving qualified artifacts, publication approvals, active-line checks, and supported recovery routes.

* feat(release): add non-Latest extended-stable releases

OpenClaw-Publication: c2238e25-fc84-40bf-ba4d-a6d9d11f4a5b

---------

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
RoboClaw 2026-09-21 00:29:24 -07:00 • committed by GitHub
parent 3187f50238
commit ca6a7d9818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1313 additions and 334 deletions

View file

@ -289,9 +289,9 @@ of this skill; initial generation must never overwrite them.
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check
- add one `--release-tag` for every beta, stable, and extended-stable page in
the train; a `### Release verification` tail is permitted, but any other
body drift fails the check
- `scripts/render-github-release-notes.mts` is the canonical release-body
renderer used by candidate validation, publish, and verification. When the
complete `## YYYY.M.PATCH` section fits GitHub's 125,000-character limit and
@ -372,11 +372,14 @@ workflow for their verification.
## Extended-Stable Variant
Extended-stable has one release commit and no GitHub Release body. After version
prep and approved backports, regenerate `## YYYY.M.P` with the regular manifest
and original-main-PR provenance rules. Land it by PR, then validate the final
branch tip before tagging. Re-audit after a product backport; a tooling-only
repair needs no changelog entry. Never rewrite a published tag or changelog.
Extended-stable has one release commit and one canonical GitHub Release body.
After version prep and approved backports, regenerate `CHANGELOG/YYYY.M.P.md`
with the regular manifest and original-main-PR provenance rules. Land it by PR, then
validate the final branch tip before tagging. The release closeout renders that
tag-owned section into the shared draft before the parent pipeline publishes
the non-Latest release page. Re-audit after a product
backport; a tooling-only repair needs no changelog entry. Never rewrite a
published tag or changelog.
## Quota / API Outage Rule

View file

@ -13,7 +13,8 @@ user.
Before drafting focus areas, read real release evidence:
1. GitHub release body, or the immutable tag and publish run for extended-stable.
1. GitHub release body and immutable tag; for extended-stable, also confirm the
npm/container-only scope and non-Latest classification.
2. The released base version's `CHANGELOG/<version>.md` and contribution
record, resolved with `node scripts/release-changelog.mjs read --version <version> [--ref <sha-or-tag>]`
(add `--record` for accounting). The shared
@ -84,8 +85,8 @@ openclaw --version
Do not add `--yes`: users moving from newer regular stable must see the downgrade
warning because older versions may not understand newer configuration. Link the
tag or changelog; do not imply a GitHub Release or inherit regular stable
macOS, Windows, ClawHub, `latest`, or website claims.
GitHub Release, but do not inherit regular stable macOS, Windows, ClawHub,
`latest`, or website claims.
## Style

View file

@ -1,4 +1,4 @@
interface:
display_name: "OpenClaw Release Announcement"
short_description: "Draft Discord beta/stable release announcements from evidence."
default_prompt: "Use this skill to draft an OpenClaw beta or stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."
short_description: "Draft evidence-backed Discord release announcements."
default_prompt: "Use $release-openclaw-announcement to draft an OpenClaw beta, stable, or extended-stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."

View file

@ -8,8 +8,8 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
This is a regular stable-release skill. Do not invoke it for extended-stable;
that track does not inherit macOS assets, appcast promotion, or a GitHub Release
unless the current extended-stable release policy explicitly adds them.
that track's GitHub Release carries shared validation evidence but does not
inherit macOS assets or appcast promotion.
## Release authorization

View file

@ -16,7 +16,7 @@ Read only the references needed for the selected phase:
- Regular beta/stable preparation or publication: [regular release](references/regular-release.md), which routes preparation and phase-specific proof. If the request does not specify stable/full, default to beta; beta authorization does not authorize later stable promotion.
- Backport discovery: [candidate inventory](references/backport-discovery.md). For extended-stable also read [backport preparation](references/extended-stable-backports.md); SDK/config changes need a visible maintenance-risk warning and maintainer decision.
- Extended-stable `.33+` Gateway publication: [extended-stable publication](references/extended-stable-publish.md). Do not use the regular release sequence or inherit GitHub Release/native-app publication.
- Extended-stable `.33+` Gateway publication: [extended-stable publication](references/extended-stable-publish.md). Use the shared publisher with extended-stable inputs; its non-Latest GitHub Release carries evidence without native-app or ClawHub publication.
- Validation selection or failed proof: [validation and confidence](references/validation.md), with `$release-openclaw-ci` for workflow execution and immutable manifests.
- Interrupted publication or registry promotion: [publication recovery](references/publication-recovery.md).
- Native assets: [platform publication](references/platform-publication.md), with `$release-openclaw-mac` for macOS operations.

View file

@ -24,8 +24,9 @@ extended-stable package and publication constraints.
- Carry the complete current-main Docker release-channel unit in the tagged
tree: workflow, promoter, policy, shared release-version classifier, tests,
and workflow validation. GitHub evaluates tag-push workflows from that tree.
- Exclude ClawHub publication, GitHub Releases, the macOS app, Windows Hub,
mobile apps, website downloads, and private-repository dist-tags.
- Exclude ClawHub publication, native-app artifacts, website downloads, npm
`latest`, and private-repository dist-tags. The shared release pipeline
attaches dependency and validation evidence to the non-Latest GitHub Release.
- 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.
@ -161,7 +162,8 @@ fi
```
Do not use GitHub's latest nonprerelease Release as the source of truth. The
extended-stable lane intentionally creates no GitHub Release. In bootstrap
npm `extended-stable` selector remains authoritative for the active line; its
evidence-bearing GitHub Release is always created with `latest=false`. In bootstrap
mode, record the approving maintainer and approved base commit. Stop before
discovery or mutation if npm, the canonical branch, tags, package versions,
approved base, or protected `main` disagree.
@ -238,6 +240,8 @@ path alone.
out of scope.
- Treat macOS-app-only, Windows-Hub-only, mobile-only, website-only, and GitHub
Release-only fixes as `skip` for this Gateway extended-stable line.
- Keep GitHub Release automation repairs in trusted current-main release
tooling; they are not product backports for the maintenance branch.
- Treat cross-repository or package-topology uncertainty as `blocked` until the
shipped npm surface and release owner are proven.
@ -369,8 +373,8 @@ Report:
harness compatibility repair, and superseded validation runs;
- remaining security, release, or maintainer approvals;
- the coordinated PR URL or why no PR was opened;
- exact intended Docker images and aliases, plus explicit confirmation that no
other non-npm publication is planned.
- exact intended Docker images and aliases, GitHub Release evidence assets, and
explicit confirmation that no native or ClawHub artifacts are planned.
Then follow the parent skill's publish and recovery sequence. Keep exact
branch/tag/package/run identity, never republish for selector repair, and move

View file

@ -8,8 +8,8 @@ or publication work. Treat backport discovery and preparation as an ability of
this release skill, not as a separate release workflow.
The backport flow covers mainline inventory, private-security reconciliation,
approval, the staging PR, and proof handoff. After it lands, use the sequence
below. Never route `.33+` through regular beta/stable release steps.
approval, the staging PR, and proof handoff. After it lands, use the shared
release pipeline with the extended-stable track inputs below.
Extended-stable requires a visible **SDK/config backport warning** whenever a
candidate changes the public plugin SDK or a config/default/schema/migration
@ -29,7 +29,7 @@ on pinned current `main` as the exact command and validation contract.
1. On `extended-stable/YYYY.M.33`, verify the root and every publishable official
plugin have the intended version. Generate and commit the complete
`## YYYY.M.P` changelog section with `### Highlights`, `### Changes`, and
`CHANGELOG/YYYY.M.P.md` entry with `### Highlights`, `### Changes`, and
`### Fixes`. Carry the full current-main Docker
release-channel unit: workflow, promoter, policy, shared classifier, tests,
and workflow validation. Run focused checks and freeze the untagged tip SHA.
@ -37,37 +37,50 @@ on pinned current `main` as the exact command and validation contract.
Release Validation derives `npm_dist_tag=extended-stable` from the version.
3. Run complete Full Release Validation against the canonical branch with
`release_profile=stable`; save its run ID and successful `run_attempt`.
Prefer the trusted main-pinned harness, which attests the immutable target
SHA in its manifest. Current manifests include qualified npm and prepared
Docker artifacts; use that same run ID for npm preflight evidence. Historical
manifests without them still need a separate npm preflight. Any candidate
Use the trusted main-pinned helper's canonical `release-ci/*` producer,
which attests the immutable target SHA in its manifest. Direct branch/main
producers do not satisfy protected-tag shared publication. Current manifests
include qualified npm and prepared Docker artifacts; use that same run ID
and attempt for npm preflight publication evidence. Also run the supplemental
trusted-main preflight described in `release-openclaw-ci`; that validation-only
run does not replace the integrated publication artifact. Any candidate
branch change invalidates both gates.
4. Require the tip still equals the frozen SHA, then create signed `vYYYY.M.P`.
Never move or delete a final tag; later source changes need a new patch.
5. Require the saved validation run to be complete and successful, bind its
manifest target SHA and attempt to the tag, and accept a direct run from the
canonical branch, a direct current-`main` run whose workflow SHA is still
reachable from main, or a trusted main-pinned `release-ci/*` harness. Reject
narrow reruns.
6. Dispatch `plugin-npm-release.yml` from the same branch with
`publish_scope=all-publishable`, the full release SHA as `ref`, and
`npm_dist_tag=extended-stable`. Require complete exact-version and selector
readback, then save the successful plugin run ID. For a tooling-only failure,
use [trusted-main recovery](#trusted-main-npm-recovery) below.
7. Publish core with the tag, `npm_dist_tag=extended-stable`, all three run IDs,
and `full_release_validation_run_attempt=<saved-attempt>`. Normally dispatch
from the canonical branch. For a workflow-only recovery after the candidate
is immutable, dispatch trusted current `main` with
`release_candidate_branch=extended-stable/YYYY.M.33`; it still publishes the
tag checkout and accepts canonical-branch, current-main, or trusted-pinned
validation evidence; the prepared tarball and every evidence identity must
still match the candidate SHA. A trusted-main plugin recovery run requires
this trusted-main core route; pass its successful ID as `plugin_npm_run_id`.
manifest target SHA and attempt to the tag, and require the canonical
`release-ci/<sha12>-<epoch>` producer with trusted tooling identity. Reject
direct canonical-branch/main producers and narrow reruns.
6. With publication/tag-push authority, create and push a protected lightweight
`release-publish/<tooling-sha12>-<epoch>` tag at the frozen trusted-main
Tooling SHA, using the commands in `docs/reference/RELEASING.md`. Dispatch
`OpenClaw Release Publish` with `--ref` set to that tooling tag, the product
release tag as `tag`, `npm_dist_tag=extended-stable`,
`publish_openclaw_npm=true`, the saved
preflight and Full Release Validation run IDs, and the saved validation run
attempt. The parent derives `release_candidate_branch`, creates the draft,
publishes every official npm plugin and core under `extended-stable`,
attaches release evidence, skips ClawHub/native publication, publishes
Docker, and finalizes the release with `latest=false`.
7. If core npm already published, resume the parent from the same protected
tooling tag with `openclaw_npm_resume_run_id` bound to the successful original core publish.
It verifies the registry tarball against preflight before resuming evidence,
Docker, and finalization. Docker-only recovery may dispatch from `main` with
`publish_openclaw_npm=false` and `publish_docker_only=true`; that path does
not attach evidence or finalize the release.
8. From a clean current-`main` checkout, run
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.P`.
Verify signatures, provenance, inventories, exact versions, and selectors.
Use the generated repair only for the root selector; repair other selectors
with approved credential-isolated tooling. Never republish a version.
To promote an already-published core version to `extended-stable`, use
`promote_extended_stable` in the `openclaw/releases` dist-tag workflow
from that repository's `main`, after openclaw/releases#27 is merged. Follow
[registry selector recovery](publication-recovery.md#registry-selectors),
not the publication/resume path. The target must be a final extended-stable
version with patch `33` or higher and no suffix; fixes increment the patch.
Stable/beta promotion and sync reject that
patch range. The same action can select an older extended-stable version
for rollback. Repair other selectors separately with
approved credential-isolated tooling. Never republish a version.
9. Require `Docker Release` to verify default, slim, browser, and architecture
images in GHCR and Docker Hub, including attestations and platform versions.
It must advance only
@ -75,8 +88,9 @@ on pinned current `main` as the exact command and validation contract.
digest and refuse automatic rollback. For alias repair, dispatch the
approval-gated `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 macOS, Windows, mobile, website,
ClawHub, or private dist-tag artifacts from this path.
10. Verify the non-Latest GitHub Release and its dependency, validation, and
postpublish evidence. Do not publish macOS, Windows, mobile, website,
ClawHub, regular npm `latest`, or private dist-tag artifacts from this path.
## Trusted-main npm recovery
@ -86,7 +100,12 @@ for example an obsolete check rejecting validated dependency pins because npm
A product defect, known vulnerable dependency, or changed candidate needs its
own repair and fresh qualification; workflow recovery does not waive those gates.
This route uses existing inputs; #151282 added no workflow-dispatch inputs.
Use this lower-level route only for an approved workflow recovery, not normal
shared publication. It does not itself attach evidence or finalize the GitHub
Release. Retain both child identities and their evidence for approved closeout;
a direct-main recovery run is not automatically interchangeable with the
protected parent's core-resume receipt.
In `gh workflow run`, `--ref main` selects trusted publishing **tooling**.
The plugin input `-f ref=<release-sha>` selects the exact **package source**;
never replace it with `main`, a branch name, or the tooling SHA.
@ -111,7 +130,7 @@ Keep final tags immutable and use a new patch for source changes after tagging.
Save the successful plugin publication run ID after exact-version and selector
readback. Dispatch `openclaw-npm-release.yml` with `--ref main` and the existing
core recovery inputs from `docs/reference/RELEASING.md`:
core recovery inputs:
- `tag=vYYYY.M.P`, `preflight_only=false`, and `npm_dist_tag=extended-stable`.
- `release_candidate_branch=extended-stable/YYYY.M.33`, including for patches

View file

@ -6,14 +6,15 @@ GitHub OIDC trusted publishing; never substitute `NPM_TOKEN` or plugin OTP
commands. GitHub's `npm-release` environment must be approved by
`@openclaw/openclaw-release-managers`.
The regular publish parent runs from the protected
The regular and extended-stable publish parent runs from the protected
`release-publish/<tooling-sha12>-<epoch>` tag minted at the pinned Tooling SHA;
use the candidate helper's printed command. Do not dispatch npm/plugin/ClawHub
use the regular candidate helper's printed command or the extended-stable
publication reference for that track. Do not dispatch npm/plugin/ClawHub
publication from a moving main parent. Docker-only recovery may use main.
Extended-stable direct npm workflow recovery is a separate supported main route;
follow [trusted-main npm recovery](extended-stable-publish.md#trusted-main-npm-recovery)
for plugin source inputs and the matching core evidence handoff. It does not use
the regular publish parent or authorize ClawHub publication.
the shared publish parent or authorize ClawHub publication.
Tideclaw alpha uses its matching alpha branch and its owning skill.
Publication promotes previously qualified bytes. Bind the successful Full
@ -67,9 +68,41 @@ packaging recovery keeps the original tag and follows
Promote through the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow.
Unlike package publication, npm selector management requires `NPM_TOKEN`.
Prefer repairing that workflow's token path. Point `latest` or `beta` only at
the operator-approved already-published version, then verify cache-bypassed
registry readback.
Prefer repairing that workflow's token path. Point `latest`, `beta`, or
`extended-stable` only at the operator-approved already-published version, then
verify cache-bypassed registry readback.
To promote an already-published core version to `extended-stable`, use
`mode=promote_extended_stable` with an exact public final release tag after
[openclaw/releases#27](https://github.com/openclaw/releases/pull/27) is merged
and available on the release repository's `main`:
```bash
gh workflow run openclaw-npm-dist-tags.yml \
--repo openclaw/releases --ref main \
-f mode=promote_extended_stable -f tag=vYYYY.M.PATCH
```
Replace `vYYYY.M.PATCH` with the approved final extended-stable release tag
(patch `33` or higher, without a suffix). Extended-stable fixes increment the
patch (`33`, `34`, `35`, and so on), never a correction suffix. Regular stable/beta
promotion and sync reject patch `33`
or higher, including the scheduled beta floor. Promotion can
select a newer version or roll back to an older one, including historical
unsuffixed extended-stable final versions; new-publication eligibility does not
apply, but the channel/patch boundary still does. This mode
writes only core `openclaw`'s
`extended-stable` selector, leaving `latest`, `beta`, plugins, other prepared-core
packages, Docker, Git tags, and GitHub Releases untouched. It neither republishes
nor changes installed clients. Do not use publish resume to roll back a rejected
release. Coordinate separately with any active publisher before retagging.
Wait for successful readback and retain the run's previous/target summary. An
already-correct selector is a no-op; readback retries never repeat the write.
If a write is unconfirmed or readback fails, inspect the live registry before
retrying. Docker channel promotion remains a separate approval-gated
`docker-channel-promote.yml` dispatch from `openclaw/openclaw` main with an
existing extended-stable image tag; its channel is derived from that version.
Immediately after publishing or promoting to `latest`, dispatch that same
release-ledger workflow to repair the beta floor: raise missing or older beta

View file

@ -37,6 +37,7 @@ operator steering. Do not preserve superseded scope.
- Plugin NPM Release: `<run id / URL or none>`
- publish parent: `<run id / URL or none>`
- Docker release/repair: `<run ids / tag / aliases or none>`
- GitHub Release: `<public URL / non-Latest readback or none>`
- immutable successful children: `<run ids / artifacts or none>`
- registry/provenance readback: `<artifact or command result>`
@ -45,7 +46,8 @@ operator steering. Do not preserve superseded scope.
Keep one row per selected surface, with its exact run/attempt or immutable
receipt, current state, and next action. Remove unselected rows rather than
reporting them as passed. Stable/full includes macOS unless explicitly scoped
out; extended-stable does not inherit ClawHub, GitHub Release, or native apps.
out; extended-stable carries non-Latest GitHub Release evidence but does not inherit
ClawHub or native apps.
| Surface | Evidence and state | Next action or blocker |
| ------------------------- | ------------------------------------------------------------------------ | ---------------------- |

View file

@ -115,8 +115,10 @@ Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
`workflow_ref` as trusted current harness code unless the release process says
otherwise.
For extended-stable, branch-owned Full Release Validation is publication
evidence; Package Acceptance is a post-publish selector smoke:
For extended-stable shared publication, require complete exact-target Full
Release Validation from the trusted main-pinned `release-ci/*` harness. Direct
canonical-branch or `main` producers do not satisfy the protected publisher.
Package Acceptance is a post-publish selector smoke:
```bash
gh workflow run package-acceptance.yml \

View file

@ -13,9 +13,10 @@ publish skill; use `$release-openclaw-maintainer` before changing release state.
- Resolve short suffixes like `.27` to the concrete CalVer version from the
current date/context, then say the resolved version.
- Resolve the track first. Regular beta/stable uses a GitHub Release and the
platform graph; extended-stable uses its canonical branch, npm selector, and
Gateway surfaces. Do not require one track's artifacts from the other.
- Resolve the track first. Both tracks use the shared GitHub Release evidence
ledger. Regular beta/stable also uses the platform graph; extended-stable
uses its canonical branch, npm selector, and Gateway surfaces. Do not require
one track's native or ClawHub artifacts from the other.
- Verify live state. Do not trust local checkout state, release notes, or old
memory as current truth.
- If the checkout is dirty or divergent, use it only for scripts/reference.
@ -84,15 +85,21 @@ Use these checks only for the regular orchestrated release track.
## Extended-stable checks
Extended-stable has no GitHub Release ledger. Verify live tag, workflow,
registry, provenance, and image state directly.
Extended-stable has a GitHub Release with shared release evidence but no native
or ClawHub artifacts. Verify it alongside
the live tag, workflow, registry, provenance, and image state.
1. **Identity:** require final `v<VERSION>` at patch `33+`, with no suffix,
contained in `extended-stable/YYYY.M.33`. Only an active candidate must equal
the tip. Root and every publishable official plugin must declare `<VERSION>`.
Require the Git tag and no GitHub Release.
2. **Workflow chain:** find successful preflight, complete validation, plugin
npm, and core publish runs on the canonical branch and SHA. Validation must
Require the Git tag and a public, non-prerelease GitHub Release whose title
and canonical body match the tag. Require `isLatest=false`, the dependency
evidence, immutable Full Release Validation manifest, postpublish evidence,
and their checksums. Require no native or ClawHub assets.
2. **Workflow chain:** find the successful parent release run plus its
preflight, complete validation, plugin npm, and core publish children.
Require a protected `release-publish/*` parent and canonical `release-ci/*`
validation producer with verified workflow SHA provenance. Validation must
use `rerun_group=all`, `release_profile=stable`, blocking soak/performance,
and the saved attempt. Core publish must reference all three run IDs and bind
its manifest, workflow ref, and tarball digest to the release SHA.
@ -106,12 +113,17 @@ registry, provenance, and image state directly.
digest binding to the release SHA. Preserve output and workflow URLs.
5. **Docker:** verify exact default, slim, browser, and architecture images and
attestations in both registries. Only the three `extended-stable*` aliases may
resolve to those digests. Repair aliases through current-main `Docker Channel
Promotion` for the exact tag, without rebuilding.
6. **Recovery:** never republish. Use the generated command only for the root
selector and approved credential-isolated tooling for others, then repeat
resolve to those digests. Require the successful `OpenClaw Release Publish`
parent run and its completed Docker verification. The normal route finalizes
afterward; an explicitly requested fast path may activate GitHub first. Repair
aliases through current-main `Docker Channel Promotion` for the exact tag,
without rebuilding.
6. **Recovery:** never republish. Use `promote_extended_stable` in the
`openclaw/releases` dist-tag workflow for the root selector (an unsuffixed
final patch `33+`) and approved credential-isolated tooling for others, then repeat
complete readback. Do not require ClawHub, native/mobile apps, website,
private dist-tags, regular `latest`, or a GitHub Release.
private dist-tags, or regular `latest`. Require shared release evidence, but
do not require regular native or ClawHub assets.
## Shared live smoke

View file

@ -73,7 +73,7 @@ on:
required: false
type: string
release_candidate_branch:
description: Canonical extended-stable branch when a trusted main workflow promotes its immutable tag
description: Canonical extended-stable branch when protected release tooling or trusted-main recovery promotes its immutable tag
required: false
default: ""
type: string
@ -173,8 +173,8 @@ jobs:
extended_stable_publish=true
fi
if [[ -n "${release_candidate_branch}" ]]; then
if [[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" || "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "release_candidate_branch is only valid for an extended-stable publish dispatched from main." >&2
if [[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" ]] || { [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$ ]]; }; then
echo "release_candidate_branch requires extended-stable publication from trusted main or protected release-publish tooling." >&2
exit 1
fi
if [[ ! "${RELEASE_TAG}" =~ ^v([0-9]{4})\.([1-9][0-9]*)\.[1-9][0-9]*$ ]]; then
@ -589,6 +589,8 @@ jobs:
PLUGIN_NPM_RUN_ID: ${{ inputs.plugin_npm_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
EXPECTED_EXTENDED_STABLE_BRANCH: ${{ inputs.release_candidate_branch || github.ref_name }}
EXPECTED_ORCHESTRATOR_BRANCH: ${{ inputs.release_candidate_branch != '' && startsWith(github.ref, 'refs/tags/release-publish/') && github.ref_name || '' }}
EXPECTED_ORCHESTRATOR_SHA: ${{ inputs.release_candidate_branch != '' && startsWith(github.ref, 'refs/tags/release-publish/') && github.workflow_sha || '' }}
RUN_KIND: plugin
WORKFLOW_REF: ${{ github.ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}

View file

@ -365,6 +365,7 @@ jobs:
PARENT_WORKFLOW_SHA="$(jq -er '.tooling.sha' <<< "$RELEASE_REQUEST")"
WINDOWS_NODE_TAG="$(jq -r '.inputs.windows_node_tag // ""' <<< "$RELEASE_REQUEST")"
WINDOWS_NODE_INSTALLER_DIGESTS="$(jq -r '.inputs.windows_node_installer_digests // ""' <<< "$RELEASE_REQUEST")"
RELEASE_NPM_DIST_TAG="$(jq -er '.inputs.npm_dist_tag' <<< "$RELEASE_REQUEST")"
source scripts/lib/release-publish-children.sh
jq -n --arg tag "$RELEASE_TAG" --arg source "$WINDOWS_NODE_TAG" \
--arg digests "$WINDOWS_NODE_INSTALLER_DIGESTS" \

View file

@ -66,7 +66,7 @@ on:
required: false
type: string
npm_dist_tag:
description: npm dist-tag for the OpenClaw package
description: npm dist-tag passed to the plugin and core publishers; extended-stable leaves npm latest unchanged
required: true
default: beta
type: choice
@ -88,7 +88,7 @@ on:
required: false
type: string
publish_openclaw_npm:
description: Publish the OpenClaw npm package after plugin npm succeeds; ClawHub may still run
description: Publish the OpenClaw npm package under npm_dist_tag after plugin npm succeeds; ClawHub may still run
required: true
default: true
type: boolean
@ -138,6 +138,7 @@ jobs:
timeout-minutes: 20
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
expected_validation_branch: ${{ steps.inputs.outputs.expected_validation_branch }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
preflight_artifact_run_id: ${{ steps.preflight_artifact.outputs.run_id }}
preflight_tarball_sha256: ${{ steps.manifest.outputs.tarball_sha256 }}
@ -157,6 +158,7 @@ jobs:
android_release_note: ${{ steps.ref.outputs.android_release_note }}
steps:
- name: Validate inputs
id: inputs
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
@ -277,10 +279,6 @@ jobs:
echo "Docker-only latest recovery requires a regular stable release tag." >&2
exit 1
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "Extended-stable core npm publication stays on the canonical extended-stable release flow; use publish_docker_only=true only after its registry readback." >&2
exit 1
fi
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
@ -329,6 +327,16 @@ jobs:
exit 1
;;
esac
expected_validation_branch="${WORKFLOW_REF#refs/*/}"
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v([0-9]{4})\.([1-9][0-9]*)\.([1-9][0-9]*)$ ]] || \
(( 10#${BASH_REMATCH[3]:-0} < 33 )); then
echo "Extended-stable publication requires a final .33+ release tag." >&2
exit 1
fi
expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"
fi
echo "expected_validation_branch=${expected_validation_branch}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -348,6 +356,7 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PUBLISH_DOCKER_ONLY: ${{ inputs.publish_docker_only && 'true' || 'false' }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
release_version="${RELEASE_TAG#v}"
@ -362,7 +371,7 @@ jobs:
android_release_note=""
if [[ "${android_pin_version}" == "${release_version%%-*}" ]]; then
android_pin_matches=true
elif [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PUBLISH_DOCKER_ONLY}" != "true" && "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]; then
elif [[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" && "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PUBLISH_DOCKER_ONLY}" != "true" && "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]; then
android_release_note="- Android APK: skipped — apps/android/version.json is ${android_pin_version}, release train is ${release_version%%-*}; run the shared mobile cutter (scripts/mobile-release-version.ts --prepare) before the next tag."
echo "${android_release_note}" >> "$GITHUB_STEP_SUMMARY"
fi
@ -879,6 +888,8 @@ jobs:
- name: Validate release tag is reachable from a trusted release branch
env:
EXPECTED_VALIDATION_BRANCH: ${{ steps.inputs.outputs.expected_validation_branch }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
run: |
@ -887,6 +898,15 @@ jobs:
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*' \
'+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*'
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}"
if git show-ref --verify --quiet "${expected_ref}" && \
git merge-base --is-ancestor HEAD "${expected_ref}"; then
exit 0
fi
echo "Extended-stable tag ${RELEASE_TAG} must be reachable from ${EXPECTED_VALIDATION_BRANCH}." >&2
exit 1
fi
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
@ -972,7 +992,7 @@ jobs:
qualify_android_native:
name: Qualify native Android release source
needs: [resolve_release_target]
if: ${{ !inputs.publish_docker_only && inputs.publish_openclaw_npm && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') && needs.resolve_release_target.outputs.android_pin_matches == 'true' && needs.resolve_release_target.outputs.coverage_policy == 'npm-stable-v1' }}
if: ${{ !inputs.publish_docker_only && inputs.publish_openclaw_npm && inputs.npm_dist_tag != 'extended-stable' && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') && needs.resolve_release_target.outputs.android_pin_matches == 'true' && needs.resolve_release_target.outputs.coverage_policy == 'npm-stable-v1' }}
# Native failure blocks its approval receipt, while core publication proceeds.
continue-on-error: true
permissions:
@ -1062,7 +1082,7 @@ jobs:
publish_android:
name: Approve and dispatch qualified Android
needs: [resolve_release_target, publish, qualify_android_native]
if: ${{ always() && !cancelled() && !inputs.publish_docker_only && inputs.publish_openclaw_npm && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') && needs.resolve_release_target.outputs.android_pin_matches == 'true' && needs.resolve_release_target.result == 'success' && needs.publish.result == 'success' && (needs.resolve_release_target.outputs.coverage_policy != 'npm-stable-v1' || needs.qualify_android_native.outputs.qualified == 'true') }}
if: ${{ always() && !cancelled() && !inputs.publish_docker_only && inputs.publish_openclaw_npm && inputs.npm_dist_tag != 'extended-stable' && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') && needs.resolve_release_target.outputs.android_pin_matches == 'true' && needs.resolve_release_target.result == 'success' && needs.publish.result == 'success' && (needs.resolve_release_target.outputs.coverage_policy != 'npm-stable-v1' || needs.qualify_android_native.outputs.qualified == 'true') }}
continue-on-error: true
permissions:
actions: write
@ -1147,6 +1167,7 @@ jobs:
PARENT_WORKFLOW_FULL_REF: ${{ github.ref }}
PARENT_WORKFLOW_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
working-directory: .release-harness
run: |
@ -1324,6 +1345,7 @@ jobs:
PARENT_WORKFLOW_FULL_REF: ${{ github.ref }}
PARENT_WORKFLOW_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
WORKFLOW_FULL_REF: ${{ github.ref }}
@ -1364,6 +1386,9 @@ jobs:
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
plan_args+=(--skip-clawhub)
fi
if [[ -n "${PREPARED_PLUGINS}" ]]; then
plan_args+=(--prepared-artifact "$(jq -cer '.clawhub' <<< "$PREPARED_PLUGINS")")
fi
@ -1554,18 +1579,18 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/.release-harness/scripts/lib/release-publish-children.sh"
bootstrap_summary_ref="$(jq -er '.bootstrap.ref | select(type == "string" and length > 0)' "${CLAWHUB_PLAN_PATH}")"
bootstrap_summary_sha="$(jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}")"
{
echo "### Publish sequence"
echo "### Publish"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Normal ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- ClawHub bootstrap workflow ref: \`${bootstrap_summary_ref}\` at \`${bootstrap_summary_sha}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
echo "- ClawHub: skipped by release track"
else
echo "- ClawHub: dispatched in parallel"
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
else
@ -1584,6 +1609,11 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
PACKAGE_VERSION="${RELEASE_TAG#v}" \
node "${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-npm-extended-stable-release.mjs" validate-active-line
fi
prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md"
prepared_release_notes_metadata_file="${RUNNER_TEMP}/release-notes-prepublish.json"
verify_release_tag_target
@ -1613,6 +1643,12 @@ jobs:
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
npm_args+=(
-f npm_dist_tag=extended-stable
-f release_candidate_branch="${{ needs.resolve_release_target.outputs.expected_validation_branch }}"
)
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id=""
@ -1737,6 +1773,12 @@ jobs:
-f full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}"
)
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then
evidence_args+=(
-f release_candidate_branch="${{ needs.resolve_release_target.outputs.expected_validation_branch }}"
-f plugin_npm_run_id="${plugin_npm_run_id}"
)
fi
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
-f tag="${RELEASE_TAG}" \
-f preflight_only=false \
@ -2278,7 +2320,7 @@ jobs:
publish_windows:
name: Dispatch Windows assets after publication
needs: [resolve_release_target, finalize_github_release]
if: ${{ !cancelled() && needs.finalize_github_release.result == 'success' && (inputs.windows_node_tag != '' || inputs.windows_node_installer_digests != '') && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }}
if: ${{ !cancelled() && needs.finalize_github_release.result == 'success' && inputs.npm_dist_tag != 'extended-stable' && (inputs.windows_node_tag != '' || inputs.windows_node_installer_digests != '') && !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }}
# App failures are reported by their own workflow; neither npm nor the
# published GitHub release depends on native asset availability.
continue-on-error: true
@ -2301,6 +2343,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
PARENT_WORKFLOW_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}

View file

@ -74,8 +74,7 @@ jobs:
}
BASH
- name: Checkout pushed main
if: ${{ github.event_name == 'push' }}
- name: Checkout trusted release tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
@ -99,9 +98,25 @@ jobs:
. "$RUNNER_TEMP/github-api-backoff.sh"
if [[ "$EVENT_NAME" == "push" ]]; then
main_ref="$TRIGGER_SHA"
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt \
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
releases_file="$RUNNER_TEMP/published-releases.json"
gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt > "$releases_file"
tag="$(RELEASES_FILE="$releases_file" node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs";
const releases = JSON.parse(readFileSync(process.env.RELEASES_FILE, "utf8"));
const candidates = releases
.filter((release) => release?.isPrerelease === false)
.filter((release) => {
const tag = typeof release?.tagName === "string" ? release.tagName : "";
const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null;
return parsed !== null && classifyReleaseTrain(parsed) === "stable";
})
.toSorted((left, right) => String(left.publishedAt).localeCompare(String(right.publishedAt)));
process.stdout.write(candidates.at(-1)?.tagName ?? "");
NODE
)"
if [[ -z "$tag" ]]; then
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
@ -109,12 +124,21 @@ jobs:
else
tag="$MANUAL_TAG"
fi
if [[ ! "$tag" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
if ! RELEASE_TAG="$tag" node --input-type=module <<'NODE'
import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs";
const tag = process.env.RELEASE_TAG ?? "";
const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null;
if (parsed === null || classifyReleaseTrain(parsed) !== "stable") {
process.exitCode = 1;
}
NODE
then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable main closeout accepts only a stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag, got $tag." >&2
echo "Stable main closeout accepts only a regular stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag below the extended-stable .33 boundary, got $tag." >&2
exit 1
fi
release_asset_version="${tag#v}"

View file

@ -29,6 +29,7 @@ on:
- "scripts/lib/plugin-publication-collector.ts"
- "scripts/lib/plugin-publication-target.mjs"
- "scripts/lib/actions-artifact-archive.mjs"
- "scripts/openclaw-npm-extended-stable-release.mjs"
- "scripts/plugin-npm-publish.sh"
- "scripts/plugin-npm-prepared-release.mjs"
- "scripts/plugin-publication-artifact.mjs"
@ -83,6 +84,10 @@ on:
required: false
default: ""
type: string
release_candidate_branch:
description: Canonical extended-stable branch when protected release tooling publishes its immutable target
required: false
type: string
preflight_only:
description: Prepare and verify immutable plugin npm artifacts without publishing
required: true
@ -164,7 +169,7 @@ jobs:
PYTHON
- name: Verify trusted preflight or recovery tooling identity
if: github.event_name == 'workflow_dispatch' && (inputs.preflight_only || (inputs.npm_dist_tag == 'extended-stable' && github.ref == 'refs/heads/main'))
if: github.event_name == 'workflow_dispatch' && (inputs.preflight_only || inputs.release_candidate_branch != '' || (inputs.npm_dist_tag == 'extended-stable' && github.ref == 'refs/heads/main'))
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_FULL_REF: ${{ github.ref }}
@ -188,6 +193,7 @@ jobs:
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
RELEASE_PUBLISH_RUN_ATTEMPT: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_attempt || '' }}
RELEASE_PUBLISH_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}
RELEASE_CANDIDATE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.release_candidate_branch || '' }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
WORKFLOW_REF: ${{ github.ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
@ -215,9 +221,9 @@ jobs:
return False
return left == right
def is_ancestor(ref):
def is_ancestor(ref, source="HEAD"):
try:
run_git(workspace, "merge-base", "--is-ancestor", "HEAD", ref)
run_git(workspace, "merge-base", "--is-ancestor", source, ref)
return True
except GitFailure as error:
if error.code == 1:
@ -234,6 +240,7 @@ jobs:
fail("Prepared npm publication requires the exact source SHA.")
run_id = os.environ["RELEASE_PUBLISH_RUN_ID"]
run_attempt = os.environ["RELEASE_PUBLISH_RUN_ATTEMPT"]
candidate_branch = os.environ["RELEASE_CANDIDATE_BRANCH"]
if preflight:
if not re.fullmatch(r"[0-9a-fA-F]{40}", source_ref) or not exact_ref_match(
"HEAD",
@ -242,6 +249,8 @@ jobs:
fail("Plugin npm preflight requires ref to be the exact 40-character source SHA.")
if run_id.strip() or run_attempt.strip():
fail("Plugin npm preflight must not include a release publish parent run tuple.")
if candidate_branch.strip():
fail("Plugin npm preflight must not include release_candidate_branch.")
if run_id.strip() and not re.fullmatch(r"[1-9][0-9]*", run_attempt):
fail("release_publish_run_id requires the exact positive release_publish_run_attempt.")
@ -283,6 +292,31 @@ jobs:
timeout=120,
reclaim_locks=True,
)
if candidate_branch.strip():
if candidate_branch != extended_branch or not re.fullmatch(
r"refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*",
os.environ["WORKFLOW_REF"],
):
fail(
f"release_candidate_branch must be {extended_branch} and requires "
"protected release-publish workflow tooling."
)
# The preceding identity check binds the protected tag to WORKFLOW_SHA.
# Source and tooling ancestry are separate release admission boundaries.
run_git(
workspace,
"fetch",
"--no-tags",
"origin",
"+refs/heads/main:refs/remotes/origin/main",
timeout=120,
reclaim_locks=True,
)
if not is_ancestor("origin/main", os.environ["WORKFLOW_SHA"]):
fail("Extended-stable plugin workflow revision is not reachable from current main.")
if is_ancestor(f"refs/remotes/origin/{extended_branch}"):
raise SystemExit(0)
fail(f"Extended-stable plugin target must be reachable from {extended_branch}.")
if not exact_ref_match(
"HEAD",
f"refs/remotes/origin/{extended_branch}",
@ -297,6 +331,9 @@ jobs:
"or trusted main, targeting the exact canonical branch tip."
)
if candidate_branch.strip():
fail("release_candidate_branch is only valid for extended-stable publication.")
run_git(
workspace,
"fetch",
@ -1797,6 +1834,7 @@ jobs:
--workflow-full-ref "refs/heads/$candidate_branch" \
--workflow-sha "$RELEASE_TARGET_SHA" \
--allow-prevalidated-ref
node scripts/openclaw-npm-extended-stable-release.mjs validate-active-line
fi
NPM_CONFIG_USERCONFIG="$npmrc" \
NPM_CONFIG_GLOBALCONFIG=/dev/null \

View file

@ -33,13 +33,19 @@ gh workflow run full-release-validation.yml --ref main \
-f expected_sha="$VALIDATION_SHA"
```
Gateway extended-stable runs npm preflight, Full Release Validation, and plugin
npm release from `extended-stable/YYYY.M.33`; core publish consumes those three
run IDs plus the validation attempt. `release-ci/*` evidence is invalid because
publish binds every run to the canonical branch and release SHA. The tag
publishes Gateway images and only the `extended-stable*` aliases; the path skips
the regular orchestrator and its ClawHub, native-app, GitHub Release, website,
and private dist-tag surfaces. See [Monthly Gateway extended-stable
Gateway extended-stable shared publication requires complete exact-target Full
Release Validation from the trusted main-pinned `release-ci/*` harness targeting
the frozen `extended-stable/YYYY.M.33` tip. Direct canonical-branch and `main`
producers do not satisfy the protected publisher. Current
manifests also supply qualified npm preflight artifacts. The shared
`OpenClaw Release Publish` parent dispatches from a protected lightweight
`release-publish/<sha12>-<epoch>` tag at the frozen trusted-main Tooling SHA and
uses `npm_dist_tag=extended-stable` to publish official npm plugins and core, attach evidence, publish Docker, and
finalize a non-Latest GitHub Release. Only `extended-stable*` container aliases
advance; ClawHub, native-app, website, regular npm `latest`, and private
dist-tag surfaces are excluded. Core-resume recovery verifies existing registry
bytes before resuming evidence and finalization; Docker-only recovery leaves
GitHub finalization untouched. See [Monthly Gateway extended-stable
publication](/reference/RELEASING#monthly-gateway-extended-stable-publication)
for commands and recovery.

View file

@ -17,7 +17,9 @@ OpenClaw exposes four user-facing update channels:
- dev: the moving head of `main`
Extended-stable ships the trailing month's Gateway, official npm plugins, and
Docker images without moving regular `latest` or `main` selectors.
Docker images without moving regular `latest` or `main` selectors. Each release
also has a GitHub Release with shared validation evidence that is never marked
Latest.
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).
@ -165,6 +167,10 @@ validation must identify one commit. Before `.33`, protected `main` must contain
a final version below patch `33` exactly one calendar month later, making the
release the trailing completed month. Maintenance patches remain eligible only
while that holds; the older line retires when `main` advances another month.
The shared publisher checks live `main` before dispatching publication children;
each plugin checks it again immediately before npm publication, including
trusted-main recovery. Saved qualification does not authorize a retired line.
A missing or unreadable current-main version blocks publication.
### Prepare and stabilize the candidate
@ -246,75 +252,41 @@ equals `VALIDATION_SHA`, then push signed `vYYYY.M.P`. Later changes need the ne
patch; never move or delete the tag. Tagging fixes the immutable release
identity; it does not publish Docker images.
### Publish the npm packages
### Publish the release
Publish every npm-publishable official plugin from the same SHA and save the
successful run ID:
Run the shared release orchestrator from a protected lightweight tooling tag
at the frozen trusted-main Tooling SHA, selecting the extended-stable npm track.
With publication/tag-push authority, create and push that tooling tag before
dispatch; keep it distinct from the immutable product release tag:
```bash
RELEASE_SHA="$(git rev-parse HEAD)"
gh workflow run plugin-npm-release.yml \
--ref extended-stable/YYYY.M.33 \
-f publish_scope=all-publishable \
-f ref="$RELEASE_SHA" \
-f npm_dist_tag=extended-stable
```
The workflow covers all `all-publishable` packages, including unchanged ones,
and verifies every exact version and selector. Reruns reuse published versions.
For a plugin workflow-only recovery, use the same command with `--ref main`.
The trusted workflow still requires `ref` to equal the canonical monthly branch
tip and runs its tooling against that frozen source. This retains validated
dependency pins when the candidate's older tooling rejects later npm `latest`
drift. Save the successful recovery run ID and use the trusted-main core
recovery command below; it verifies the plugin workflow's main ancestry and
exact candidate-bound run identity.
Then publish the prepared core tarball with all three saved run identities:
```bash
gh workflow run openclaw-npm-release.yml \
--ref extended-stable/YYYY.M.33 \
TOOLING_SHA="<recorded-full-main-ancestor-sha>"
PUBLISH_REF="release-publish/$(printf '%s' "$TOOLING_SHA" | cut -c1-12)-$(date +%s)"
git tag "$PUBLISH_REF" "$TOOLING_SHA"
git push origin "refs/tags/$PUBLISH_REF"
gh workflow run openclaw-release-publish.yml \
--ref "$PUBLISH_REF" \
-f tag=vYYYY.M.P \
-f preflight_only=false \
-f npm_dist_tag=extended-stable \
-f preflight_run_id=<npm-preflight-run-id> \
-f full_release_validation_run_id=<full-validation-run-id> \
-f full_release_validation_run_attempt=<full-validation-run-attempt> \
-f plugin_npm_run_id=<plugin-npm-run-id>
```
If the immutable candidate has already passed its saved preflight and Full
Release Validation but core publication needs a workflow-only recovery, dispatch
the trusted current-`main` workflow instead. Keep the same tag and evidence
identities; do not move the tag or republish plugins:
```bash
gh workflow run openclaw-npm-release.yml \
--ref main \
-f tag=vYYYY.M.P \
-f preflight_only=false \
-f npm_dist_tag=extended-stable \
-f release_candidate_branch=extended-stable/YYYY.M.33 \
-f preflight_run_id=<npm-preflight-run-id> \
-f full_release_validation_run_id=<full-validation-run-id> \
-f full_release_validation_run_attempt=<full-validation-run-attempt> \
-f plugin_npm_run_id=<plugin-npm-run-id>
-f plugin_publish_scope=all-publishable \
-f publish_openclaw_npm=true
```
This recovery path checks out and publishes the immutable tag and requires the
canonical branch implied by that tag. It accepts Full Release Validation
evidence from the canonical candidate branch directly, from current `main`
directly when its workflow SHA is reachable from current `main`, or from the
trusted main-pinned harness. Every accepted form must attest the immutable
tag's SHA. Use it only when the candidate source and recorded evidence are
unchanged.
The parent derives the canonical `extended-stable/YYYY.M.33` branch from the
tag and passes it to both npm children. It creates the draft GitHub Release,
publishes every `all-publishable` official plugin and core under the
`extended-stable` selector, verifies registry bytes, attaches dependency and
validation evidence, publishes Docker, then finalizes the release with
`latest=false`. ClawHub and native-app stages are disabled by the selected
track. Use the lower-level plugin/core workflows only for an approved recovery;
never republish an immutable version.
For non-production rehearsal only, add
`-f bypass_extended_stable_guard=true` to preflight and publish. It bypasses the
month guard only, never canonical-ref, SHA/tag/version equality, provenance,
approval, or readback checks. Never use it for production.
For non-production child-workflow rehearsal only, the lower-level npm workflow
has `bypass_extended_stable_guard=true`. The normal parent publish does not
expose that bypass. Never use it for production.
### Verify and recover
@ -331,24 +303,15 @@ preflight, and tarball-digest binding to the release SHA. Both commands must
return `YYYY.M.P`. Verify every prepared core package and `all-publishable`
official plugin at its exact version and selector.
If only the root selector fails, use the generated
`npm dist-tag add openclaw@YYYY.M.P extended-stable` repair command printed in
the workflow summary. Repair existing plugin or other prepared-core selectors
through approved credential-isolated tooling; the OIDC source cannot mutate
them. Never republish an immutable version.
If core npm published but the parent failed afterward, repeat the same
`OpenClaw Release Publish` command with
`-f openclaw_npm_resume_run_id=<successful-core-publish-run-id>`. The parent
must prove the live registry tarball is the preflight artifact before it resumes
release evidence, Docker, and the shared finalizer.
Require `Docker Release` to verify exact default, slim, browser, and architecture
images in GHCR and Docker Hub, including attestations and platform versions. It
must advance only
`extended-stable`, `extended-stable-slim`, and `extended-stable-browser` by
digest; regular aliases remain unchanged and automatic rollback is rejected.
After that core registry readback succeeds, start Docker publication only through
`OpenClaw Release Publish`. Its Docker-only extended-stable path rechecks the
saved npm preflight artifact, exact `Full Release Validation` evidence, exact npm
version and `extended-stable` selector, and published tarball digest before it
calls the reusable `Docker Release` workflow. A tag push never publishes Docker
images by itself:
If npm publication and its selector are already complete but only Docker
publication needs recovery, use the narrower Docker-only path from current
`main`:
```bash
gh workflow run openclaw-release-publish.yml \
@ -362,14 +325,73 @@ gh workflow run openclaw-release-publish.yml \
-f publish_docker_only=true
```
This path rechecks the exact npm version, `extended-stable` selector, preflight
tarball digest, and validation evidence before invoking `Docker Release`. It
does not run the shared GitHub Release finalizer; use the core-resume path when
the draft release also needs evidence attachment or publication.
To promote an already-published core version to `extended-stable`, use
**OpenClaw NPM Dist-Tag Operations** in
`openclaw/releases`, not the publish or resume path. The `promote_extended_stable`
mode requires [openclaw/releases#27](https://github.com/openclaw/releases/pull/27)
to be merged and available on that repository's `main`:
```bash
gh workflow run openclaw-npm-dist-tags.yml \
--repo openclaw/releases --ref main \
-f mode=promote_extended_stable \
-f tag=vYYYY.M.PATCH
```
Replace `vYYYY.M.PATCH` with the exact approved final extended-stable release tag
(patch `33` or higher, without a suffix). Extended-stable fixes increment the
patch (`33`, `34`, `35`, and so on), never a correction suffix. Regular stable/beta
promotion and sync reject patch `33`
or higher, including the scheduled beta floor. Promotion can select a newer version or roll back to an older one. The action checks
that the public Git tag and exact npm version exist, permits older monthly lines
and historical unsuffixed final versions, and changes only core `openclaw`'s
`extended-stable` selector. It uses the release repository's `NPM_TOKEN`; no local
npm login or source-repository publish credentials are needed. It does not write
`latest`, `beta`, plugin or other prepared-core selectors, Docker aliases, Git
tags, or GitHub Releases, and does not republish packages.
Wait for the run to succeed and verify the intended target:
```bash
npm view openclaw dist-tags --json --prefer-online --registry=https://registry.npmjs.org/
```
The job summary records the previous and target versions. The action skips an
already-correct selector and retries registry readback, not the tag write. After
an unconfirmed write or exhausted readback, inspect the live selector before
retrying. Repair plugin or other prepared-core selectors separately through
approved credential-isolated tooling. A selector rollback neither repairs the
bad version's published bytes nor downgrades existing installations. Do not
resume publication of a rejected release as part of rollback.
Require `Docker Release` to verify exact default, slim, browser, and architecture
images in GHCR and Docker Hub, including attestations and platform versions. It
must advance only `extended-stable`, `extended-stable-slim`, and
`extended-stable-browser` by digest; regular aliases remain unchanged and
automatic rollback is rejected. Confirm the GitHub Release contains the shared
dependency, Full Release Validation, and postpublish evidence assets but no
native-app assets.
For alias repair, run approval-gated `Docker Channel Promotion` from current
`main` with the tag. It repeats digest, attestation, and platform checks, allows
an explicit rollback, and never rebuilds images.
an explicit rollback, and never rebuilds images. npm retagging does not invoke
this action; if Docker aliases must also move, dispatch it separately with an
existing extended-stable image tag and verify all three aliases on both
registries. Docker derives the channel from the target version,
so a historical regular-stable tag is not an extended-stable Docker rollback.
Slack, Discord, and Codex are the initial documented support surfaces, not a
release allowlist: every npm-publishable official plugin ships. The regular
checklist alone owns beta/`latest`, GitHub Releases, ClawHub, native apps, mobile,
website, and private dist-tags; do not run those steps for this Gateway path.
release allowlist: every npm-publishable official plugin ships. The shared
pipeline attaches dependency, Full Release Validation, and postpublish evidence
to the extended-stable GitHub Release. The selected npm tag is
`extended-stable`, so npm `latest` remains unchanged. Do not publish ClawHub
packages, native apps, website artifacts, or private dist-tags from this Gateway
track.
## Regular release operator checklist
@ -987,8 +1009,9 @@ For package-candidate Telegram proof, enable `telegram_mode=mock-openai` or `tel
Run the read-only publish preflight before regular beta or stable publication
through the protected `OpenClaw Release Publish` route, including after a failed
attempt. Alpha uses its matching Tideclaw workflow branch; extended-stable retains
its separate owner workflows and is not admitted by this command. Use the same
attempt. Alpha uses its matching Tideclaw workflow branch; extended-stable uses
the shared publisher with its dedicated track inputs but is not admitted by
this regular-release preflight command. Use the same
tag, validation run and attempt, channel, plugin selection, waiver, and frozen
publication tooling ref as the intended dispatch:
@ -1121,8 +1144,9 @@ This button covers core and plugin npm, ClawHub, the existing Docker/Windows
contracts, and GitHub release visibility. It does **not** claim that independent
macOS signing/feed promotion, Android completion, app-store submission, or
website publication is ready. Those owners retain their existing release steps.
Alpha, extended-stable, selected-plugin repairs, and historical releases without
a readiness receipt continue to use their existing owner workflows.
Alpha, selected-plugin repairs, and historical releases without a readiness
receipt continue to use their existing owner workflows. Extended-stable uses
the shared direct publisher with its dedicated track inputs, not this button.
### Recover a failed download
@ -1230,9 +1254,9 @@ non-publishing child, then rerun only the outer seal.
For beta, `latest`, plugin, GitHub Release, and platform publication,
`OpenClaw Release Publish` remains the protected mutating owner. The monthly
`.33+` Gateway extended-stable path does not use this orchestrator. The
regular workflow orchestrates the trusted-publisher workflows in the order the
release needs. Linux cross-OS validation remains blocking; Windows/macOS
`.33+` Gateway extended-stable path uses this same publisher with its own
track inputs, non-Latest GitHub release, and no ClawHub or native publication.
The workflow orchestrates the trusted publishers for the selected track. Linux cross-OS validation remains blocking; Windows/macOS
cross-OS conclusions are advisory and cannot block the saved validation
evidence. macOS app signing, notarization, appcast updates, and Windows Hub asset
promotion can run in parallel with or after npm publication and never delay
@ -1240,13 +1264,13 @@ npm. Their artifact contracts still govern platform readiness and GitHub
release closeout. Full Release Validation and qualified package artifacts must already be green; no app artifact is a prerequisite:
1. Check out the release tag and resolve its commit SHA.
2. Verify the tag is reachable from `main` or `release/*` (or a Tideclaw alpha branch for alpha prereleases).
2. Verify the tag is reachable from `main` or `release/*`, a Tideclaw alpha branch for alpha prereleases, or the canonical `extended-stable/YYYY.M.33` branch for extended-stable.
3. Run `pnpm plugins:sync:check`.
4. Dispatch `Plugin NPM Release` with `publish_scope=all-publishable` and `ref=<release-sha>`.
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA.
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA, except for extended-stable.
6. After plugin npm succeeds, dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved `full_release_validation_run_id` and exact run attempt. ClawHub proceeds in parallel.
7. Verify the published npm package and selector readback, then call reusable `Docker Release` with the immutable tag and SHA. By default, finalize the draft GitHub release after npm and Docker evidence succeeds. The explicitly requested `finalize_release_before_docker=true` fast path activates after npm verification and evidence uploads, then publishes Docker; Docker remains part of the Gateway distribution.
8. For stable, optionally dispatch `Windows Node Release` after finalization with both `windows_node_tag` and candidate-approved `windows_node_installer_digests`. It attaches signed installers and checksums to the public release as a detached child. Omit both inputs to skip Windows dispatch. When the tagged `apps/android/version.json` matches the release train, qualify and dispatch `Android Release` independently for its exact-tag signed APK, checksum, and provenance; run macOS validation/preflight/publish through `openclaw/releases` in parallel or afterward. No app workflow delays npm or GitHub release finalization. Track app failures through their summaries and evidence, then recover only the failed platform.
7. Verify the published npm package and selector readback, then call reusable `Docker Release` with the immutable tag and SHA. By default, finalize the draft GitHub release after npm and Docker evidence succeeds. The explicitly requested `finalize_release_before_docker=true` fast path activates after npm verification and evidence uploads, then publishes Docker; Docker remains part of the Gateway distribution. Extended-stable finalization uses the shared finalizer with `latest=false` and skips native stages.
8. For regular stable, optionally dispatch `Windows Node Release` after finalization with both `windows_node_tag` and candidate-approved `windows_node_installer_digests`. It attaches signed installers and checksums to the public release as a detached child. Omit both inputs to skip Windows dispatch. When the tagged `apps/android/version.json` matches the release train, qualify and dispatch `Android Release` independently for its exact-tag signed APK, checksum, and provenance; run macOS validation/preflight/publish through `openclaw/releases` in parallel or afterward. No app workflow delays npm or GitHub release finalization. Track app failures through their summaries and evidence, then recover only the failed platform.
The Android train is pinned independently. If its tagged version differs from
the stable tag's base version, the parent skips both native qualification and
@ -1551,8 +1575,11 @@ SHA-256, and npm integrity. A mismatch requires a new package version.
behavior or `npm_dist_tag=extended-stable` for the guarded monthly path. The
extended-stable option requires `publish_scope=all-publishable`, an empty
`plugins` input, a final patch at or above `33`, and the canonical
`extended-stable/YYYY.M.33` branch at its exact tip. The workflow may run from
that branch or trusted `main` for workflow-only recovery. It never moves plugin
`extended-stable/YYYY.M.33` branch at its exact tip, or the same immutable
target dispatched by `OpenClaw Release Publish` from its protected
`release-publish/<sha12>-<epoch>` tooling tag with that canonical branch named
in `release_candidate_branch`. The direct workflow may also run from trusted
`main` for approved workflow-only recovery. It never moves plugin
`latest` or `beta`. New package versions receive `extended-stable` atomically
through OIDC trusted publication (`npm publish --tag extended-stable`); this
source workflow does not use token-authenticated `npm dist-tag add`. Retries
@ -1568,6 +1595,7 @@ readback confirms that every exact package and `extended-stable` tag converged.
- `windows_node_tag`: optional exact non-prerelease `openclaw/openclaw-windows-node` release tag for detached Windows promotion after stable GitHub publication; omit both Windows inputs to skip dispatch
- `windows_node_installer_digests`: candidate-approved compact JSON map of the current Windows installer names to pinned `sha256:` digests; required only when `windows_node_tag` is supplied
- `npm_telegram_run_id`: optional successful `NPM Telegram Beta E2E` run id to include in final release evidence
- `openclaw_npm_resume_run_id`: successful original core publish run ID; verifies the registry tarball against preflight before resuming release evidence, Docker, and finalization without republishing core
- `npm_dist_tag`: npm target tag for the OpenClaw package, one of `alpha`, `beta`, `latest`, or `extended-stable`
- `finalize_release_before_docker`: explicit direct-publication fast path; default `false`. Activates the verified GitHub release before Docker, preserving the same environment approval and latest policy. Requires `publish_openclaw_npm=true` and no `prepared_plugins`. Docker failure leaves the release public for Docker-only recovery.
- `publish_docker_only`: beta, regular stable (`latest`), or extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selected dist-tag, and tarball digest before invoking Docker publication.
@ -1592,7 +1620,9 @@ 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+` Gateway extended-stable path documented at the top of this page.
This sequence uses the same orchestrator as extended-stable. Its `beta` or
`latest` track additionally enables ClawHub and the applicable native/platform
stages.
When cutting a regular orchestrated stable release:

View file

@ -27,7 +27,7 @@ its exact prepared publication artifacts for both roles. For a later
changelog-only Release SHA using evidence reuse, also record the reuse policy,
complete changed-path set, green Code SHA parent run, and Release SHA parent
run. For extended-stable, record the canonical branch, exact release SHA,
fresh parent run id and attempt, workflow ref, every child run, and any
accepted producer identity, parent run id and attempt, workflow ref, every child run, and any
frozen-target compatibility repair or intentional omission.
Useful artifacts:

View file

@ -41,6 +41,15 @@ canonical-branch dispatch is valid only when its head is also the trusted
workflow implementation. Current extended-stable validation uses distinct
trusted-main tooling and therefore requires the immutable helper.
The shared publisher requires this canonical `release-ci/*` producer and binds
its trusted workflow SHA separately from the exact candidate SHA. Its protected
`release-publish/*` ref does not replace the canonical candidate branch. Retain
the complete `rerun_group=all` manifest, exact run ID and successful attempt;
reject direct canonical-branch/main producers, narrow runs, stale attempts, and
mismatched targets. If a reviewed tooling repair changes the publication SHA,
the validation tooling must remain reachable from current `main` and all
candidate and evidence identities must still match.
Backport product failures; make the smallest behavior-preserving repair for
frozen-target tooling; retry provider, approval, or runner failures without a
source change. Any branch change needs a complete new run. Do not omit required

View file

@ -34,6 +34,7 @@ type OpenClawReleaseClawHubPlanArgs = {
releasePublishRunId: string;
pluginPublishScope: PluginReleaseSelectionMode;
plugins: string[];
skipClawHub?: boolean;
preparedArtifact?: string;
};
@ -266,6 +267,7 @@ export function parseOpenClawReleaseClawHubPlanArgs(
let pluginPublishScope: PluginReleaseSelectionMode | undefined;
let plugins: string[] = [];
let pluginsFlagProvided = false;
let skipClawHub = false;
let preparedArtifact: string | undefined;
for (let index = 0; index < values.length; index += 1) {
@ -314,6 +316,9 @@ export function parseOpenClawReleaseClawHubPlanArgs(
plugins = parsePluginReleaseSelection(next());
pluginsFlagProvided = true;
break;
case "--skip-clawhub":
skipClawHub = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
@ -344,6 +349,7 @@ export function parseOpenClawReleaseClawHubPlanArgs(
releasePublishRunId: requireArg(releasePublishRunId, "--release-publish-run-id"),
pluginPublishScope: resolvedPluginPublishScope,
plugins,
skipClawHub,
...(preparedArtifact ? { preparedArtifact } : {}),
};
}
@ -367,34 +373,37 @@ export async function buildOpenClawReleaseClawHubPlan(
"releasePublishRunAttempt",
);
const releasePublishRunId = requireArg(args.releasePublishRunId, "releasePublishRunId");
const prepared = args.preparedArtifact
? await resolvePreparedClawHubMatrix({
descriptor: JSON.parse(args.preparedArtifact),
candidateSha: releaseSha,
toolingSha: bootstrapWorkflowSha,
selectionMode: args.pluginPublishScope,
plugins: args.plugins,
sourceRoot: options.rootDir ?? resolve("."),
token: process.env.GH_TOKEN,
fetchImpl: options.fetchImpl,
})
: undefined;
const plan = prepared
? {
// Prepared publication requires established normal trusted publishers;
// the resolver rejects bootstrap/repair needs before this routing.
candidates: prepared,
bootstrapCandidates: [],
missingTrustedPublisher: [],
warnings: [],
}
: await collectPluginClawHubReleasePlan({
rootDir: options.rootDir ?? resolve("."),
selection: args.plugins,
selectionMode: args.pluginPublishScope,
fetchImpl: options.fetchImpl,
registryBaseUrl: options.registryBaseUrl,
});
const prepared =
!args.skipClawHub && args.preparedArtifact
? await resolvePreparedClawHubMatrix({
descriptor: JSON.parse(args.preparedArtifact),
candidateSha: releaseSha,
toolingSha: bootstrapWorkflowSha,
selectionMode: args.pluginPublishScope,
plugins: args.plugins,
sourceRoot: options.rootDir ?? resolve("."),
token: process.env.GH_TOKEN,
fetchImpl: options.fetchImpl,
})
: undefined;
const plan = args.skipClawHub
? { candidates: [], bootstrapCandidates: [], missingTrustedPublisher: [], warnings: [] }
: prepared
? {
// Prepared publication requires established normal trusted publishers;
// the resolver rejects bootstrap/repair needs before this routing.
candidates: prepared,
bootstrapCandidates: [],
missingTrustedPublisher: [],
warnings: [],
}
: await collectPluginClawHubReleasePlan({
rootDir: options.rootDir ?? resolve("."),
selection: args.plugins,
selectionMode: args.pluginPublishScope,
fetchImpl: options.fetchImpl,
registryBaseUrl: options.registryBaseUrl,
});
const normalPackages = packageNames(plan.candidates);
const bootstrapPackages = [

View file

@ -34,6 +34,7 @@ export const PLUGIN_NPM_RELEASE_AUTHORITY_PATHS = [
"scripts/lib/plugin-npm-release.ts",
"scripts/lib/tsx-cli-shim.mjs",
"scripts/tsx.mjs",
"scripts/openclaw-npm-extended-stable-release.mjs",
"scripts/plugin-npm-publish.sh",
"scripts/plugin-npm-prepared-release.mjs",
"scripts/plugin-npm-release-check.ts",

View file

@ -28,11 +28,11 @@ print_release_resume_command() {
}
is_stable_release() {
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
[[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" && "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
}
is_android_release() {
[[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]
[[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" && "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]
}
resolve_child_workflow_ref() {
@ -765,7 +765,7 @@ write_clawhub_runtime_state() {
local output_path="$1"
local force_skip_clawhub=false
# Verification and release notes project the same joined child outcomes.
if [[ "${clawhub_failed}" != "0" ]]; then
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" || "${clawhub_failed}" != "0" ]]; then
force_skip_clawhub=true
fi
node --import tsx \

View file

@ -732,13 +732,18 @@ function mirror(github, publicKey, target) {
function finalizeCore(github, options) {
const version = options.tag.slice(1);
const parsed = parseReleaseVersion(version);
const train = parsed && classifyReleaseTrain(parsed);
assert(
parsed &&
parsed.version === version &&
["stable", "alpha", "beta"].includes(classifyReleaseTrain(parsed)),
["stable", "alpha", "beta", "extended-stable"].includes(train),
"Unsupported core GitHub release train",
);
assert(["true", "false"].includes(options.latest), "Expected explicit core latest intent");
assert(
train !== "extended-stable" || options.latest === "false",
"Extended-stable releases cannot become core latest",
);
const prerelease = parsed.channel !== "stable";
assert(!prerelease || options.latest === "false", "Prereleases cannot become core latest");
github.authorize();

View file

@ -186,7 +186,14 @@ export function validateExtendedStableNpmReleaseRequest(request) {
};
}
const mainVersion = parseReleaseVersion(request.mainPackageVersion);
validateActiveExtendedStableLine(releaseVersion, request.mainPackageVersion);
return { extendedStable: true, releaseVersion, extendedStableBranch };
}
// Core admission, parent dispatch, and plugin mutation share one retirement policy.
export function validateActiveExtendedStableLine(releaseVersion, mainPackageVersion) {
const releaseVersionParsed = validateNpmPublishBoundary(releaseVersion, "extended-stable");
const mainVersion = parseReleaseVersion(mainPackageVersion);
if (
mainVersion === null ||
mainVersion.channel !== "stable" ||
@ -195,19 +202,18 @@ export function validateExtendedStableNpmReleaseRequest(request) {
throw new Error("Protected main package version must be an exact final YYYY.M.P version.");
}
const mainCalendarMonth = mainVersion.year * 12 + mainVersion.month;
const releaseCalendarMonth = taggedVersion.year * 12 + taggedVersion.month;
const releaseCalendarMonth = releaseVersionParsed.year * 12 + releaseVersionParsed.month;
// Keep one active trailing-month line; advancing main another month retires the older line.
if (mainCalendarMonth - releaseCalendarMonth !== 1) {
const expectedYear = mainVersion.month === 1 ? mainVersion.year - 1 : mainVersion.year;
const expectedMonth = mainVersion.month === 1 ? 12 : mainVersion.month - 1;
throw new Error(
`Extended-stable publishes only the trailing completed month: protected main ${request.mainPackageVersion} allows ${expectedYear}.${expectedMonth}.PATCH, not ${releaseVersion}. Retire the older line or dispatch with BYPASS_EXTENDED_STABLE_GUARD for an explicitly approved exception.`,
`Extended-stable publishes only the trailing completed month: protected main ${mainPackageVersion} allows ${expectedYear}.${expectedMonth}.PATCH, not ${releaseVersion}. Retire the older line; publishing a retired line requires an explicit maintainer decision.`,
);
}
if (classifyReleaseTrain(mainVersion) !== "stable") {
throw new Error("Protected main must remain on a daily patch below 33.");
}
return { extendedStable: true, releaseVersion, extendedStableBranch };
}
export function validateExtendedStableRunIdentity({
@ -221,6 +227,8 @@ export function validateExtendedStableRunIdentity({
fullReleaseRunId = "",
fullReleaseRunAttempt = "",
workflowPath = "",
expectedOrchestratorBranch = "",
expectedOrchestratorSha = "",
trustedPluginWorkflowSha = "",
}) {
const fullReleasePreflight =
@ -260,6 +268,15 @@ export function validateExtendedStableRunIdentity({
);
}
}
const directTargetIdentity = run.headBranch === expectedBranch && run.headSha === expectedSha;
const orchestratedPluginIdentity =
kind === "plugin" &&
typeof expectedOrchestratorBranch === "string" &&
expectedOrchestratorBranch.length > 0 &&
typeof expectedOrchestratorSha === "string" &&
expectedOrchestratorSha.length > 0 &&
run.headBranch === expectedOrchestratorBranch &&
run.headSha === expectedOrchestratorSha;
// FRV runs trusted tooling against a separately pinned release source; its
// qualified manifest, not the workflow head, binds that source SHA.
// A main-branch plugin recovery likewise separates tooling from source. The
@ -277,7 +294,8 @@ export function validateExtendedStableRunIdentity({
!fullReleasePreflight &&
!trustedPluginRecovery &&
npmDistTag === "extended-stable" &&
(run.headBranch !== expectedBranch || run.headSha !== expectedSha)
!directTargetIdentity &&
!orchestratedPluginIdentity
) {
throw new Error(
`Referenced extended-stable ${kind} run must have headBranch=${expectedBranch} and headSha=${expectedSha}; got ${run.headBranch ?? "<missing>"} and ${run.headSha ?? "<missing>"}.`,
@ -539,6 +557,20 @@ function appendOutput(values) {
async function main() {
const command = process.argv[2];
if (command === "validate-active-line") {
const repository = process.env.GITHUB_REPOSITORY ?? "";
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
throw new Error("GITHUB_REPOSITORY must identify the publication repository.");
}
const content = execFileSync(
"gh",
["api", `repos/${repository}/contents/package.json?ref=refs/heads/main`, "--jq", ".content"],
{ encoding: "utf8", timeout: 30_000 },
);
const mainPackageVersion = JSON.parse(Buffer.from(content, "base64").toString("utf8")).version;
validateActiveExtendedStableLine(process.env.PACKAGE_VERSION ?? "", mainPackageVersion);
return;
}
if (command === "validate-request") {
const result = validateRequestFromRepository();
console.log(
@ -573,6 +605,8 @@ async function main() {
fullReleaseRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID,
fullReleaseRunAttempt: process.env.FULL_RELEASE_VALIDATION_RUN_ATTEMPT,
workflowPath: process.env.RUN_WORKFLOW_PATH,
expectedOrchestratorBranch: process.env.EXPECTED_ORCHESTRATOR_BRANCH,
expectedOrchestratorSha: process.env.EXPECTED_ORCHESTRATOR_SHA,
trustedPluginWorkflowSha: process.env.TRUSTED_PLUGIN_WORKFLOW_SHA,
});
console.log(`Verified referenced ${process.env.RUN_KIND} run.`);

View file

@ -4,7 +4,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { normalizeUpgradeSurvivorBaselineSpec } from "./lib/docker-e2e-plan.mts";
import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts";
import { compareReleaseVersions, parseReleaseVersion } from "./lib/release-version.mjs";
import {
classifyReleaseTrain,
compareReleaseVersions,
parseReleaseVersion,
} from "./lib/release-version.mjs";
import { OLDEST_SUPPORTED_UPGRADE_SURVIVOR_BASELINE } from "./lib/upgrade-survivor-policy.mjs";
type ReleaseRecord = Partial<Record<"isPrerelease" | "publishedAt" | "tagName", unknown>>;
@ -85,16 +89,16 @@ function readPublishedVersions(file: string | undefined) {
function stableVersionFromTag(tagName: unknown) {
const version = typeof tagName === "string" ? tagName.replace(/^v/u, "") : "";
return parseStableVersion(version) ? version : undefined;
return parseVersionForTrain(version) ? version : undefined;
}
function parseStableVersion(version: unknown) {
function parseVersionForTrain(version: unknown, train: "stable" | "extended-stable" = "stable") {
const parsed = parseReleaseVersion(typeof version === "string" ? version : "");
return parsed?.channel === "stable" ? parsed : undefined;
return parsed && classifyReleaseTrain(parsed) === train ? parsed : undefined;
}
function compareStableVersions(left: string, right: string) {
if (!parseStableVersion(left) || !parseStableVersion(right)) {
if (!parseVersionForTrain(left) || !parseVersionForTrain(right)) {
throw new Error(`cannot compare release versions: ${left} ${right}`);
}
const comparison = compareReleaseVersions(left, right);
@ -220,11 +224,13 @@ function resolveSupportedLines(args: Map<string, string>) {
throw new Error("npm dist-tags must be a JSON object");
}
const latest = "latest" in tags ? tags.latest : undefined;
if (typeof latest !== "string" || !parseStableVersion(latest) || !versions.has(latest)) {
if (typeof latest !== "string" || !parseVersionForTrain(latest) || !versions.has(latest)) {
throw new Error("npm latest must name a published stable version");
}
const previous = [...versions]
.filter((version) => parseStableVersion(version) && compareStableVersions(version, latest) < 0)
.filter(
(version) => parseVersionForTrain(version) && compareStableVersions(version, latest) < 0,
)
.toSorted((left, right) => compareStableVersions(right, left))[0];
if (!previous) {
throw new Error(`no previous stable npm version before ${latest}`);
@ -237,9 +243,13 @@ function resolveSupportedLines(args: Map<string, string>) {
const extended = "extended-stable" in tags ? tags["extended-stable"] : undefined;
if (
extended !== undefined &&
(typeof extended !== "string" || !parseStableVersion(extended) || !versions.has(extended))
(typeof extended !== "string" ||
!parseVersionForTrain(extended, "extended-stable") ||
!versions.has(extended))
) {
throw new Error("npm extended-stable must name a published stable version when present");
throw new Error(
"npm extended-stable must name a published extended-stable version when present",
);
}
return omitUnpublishedCandidateBaseline(
args,
@ -276,7 +286,7 @@ export function resolveBaselines(args: Map<string, string>) {
resolved.push(...resolveLastStable(args, count));
} else if (token.startsWith("all-since-")) {
const minimumVersion = token.slice("all-since-".length);
if (!parseStableVersion(minimumVersion)) {
if (!parseVersionForTrain(minimumVersion)) {
throw new Error(`invalid all-since baseline token: ${token}`);
}
resolved.push(...resolveAllSince(args, minimumVersion));

View file

@ -1496,6 +1496,65 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
});
});
it.each([undefined, '{"unusedPreparedArtifact":true}'])(
"returns a zero-dispatch plan without reading ClawHub when the release track excludes it (%s)",
async (preparedArtifact) => {
const plan = await buildOpenClawReleaseClawHubPlan(
{
bootstrapWorkflowRef: "main",
bootstrapWorkflowSha: "d".repeat(40),
releaseTag: "v2026.6.35",
releaseSha: "a".repeat(40),
releasePublishBranch: "main",
releasePublishFullRef: "refs/heads/main",
releasePublishRunAttempt: "1",
releasePublishRunId: "12345",
pluginPublishScope: "all-publishable",
plugins: [],
skipClawHub: true,
...(preparedArtifact ? { preparedArtifact } : {}),
},
{
fetchImpl: () => {
throw new Error("ClawHub must not be queried for an excluded release track.");
},
},
);
expect(plan.normal).toMatchObject({ shouldDispatch: false, packages: [] });
expect(plan.bootstrap).toMatchObject({ shouldDispatch: false, packages: [] });
expect(plan.summary).toEqual({
normalCount: 0,
bootstrapCount: 0,
missingTrustedPublisherCount: 0,
normalPlugins: "",
bootstrapPlugins: "",
missingTrustedPlugins: "",
});
expect(
parseOpenClawReleaseClawHubPlanArgs([
"--bootstrap-workflow-ref",
"main",
"--bootstrap-workflow-sha",
"d".repeat(40),
"--release-tag",
"v2026.6.35",
"--release-sha",
"a".repeat(40),
"--release-publish-branch",
"main",
"--release-publish-full-ref",
"refs/heads/main",
"--release-publish-run-attempt",
"1",
"--release-publish-run-id",
"12345",
"--skip-clawhub",
]).skipClawHub,
).toBe(true);
},
);
it("rejects incompatible all-publishable plugin selection args", () => {
expect(() =>
parseOpenClawReleaseClawHubPlanArgs([

View file

@ -0,0 +1,123 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { validateActiveExtendedStableLine } from "../../scripts/openclaw-npm-extended-stable-release.mjs";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const nodeExecutable = resolveTestNodeExecPath();
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("extended-stable live publication eligibility", () => {
it("retires the same qualified candidate when main advances, including the year boundary", () => {
expect(() => validateActiveExtendedStableLine("2026.12.34", "2027.1.1")).not.toThrow();
expect(() => validateActiveExtendedStableLine("2026.12.34", "2027.2.1")).toThrow(
"only the trailing completed month",
);
});
it.skipIf(process.platform === "win32").each([
{ mainVersion: "2026.8.1", expectedStatus: 42, expectedError: "" },
{
mainVersion: "2026.9.1",
expectedStatus: 1,
expectedError: "only the trailing completed month",
},
{
mainVersion: "unavailable",
expectedStatus: 1,
expectedError: "fixture main API unavailable",
},
])(
"checks live main $mainVersion before entering the parent's mutation-capable publication phase",
({ mainVersion, expectedStatus, expectedError }) => {
const root = tempDirs.make("extended-stable-dispatch-");
const bin = join(root, "bin");
const harness = join(root, ".release-harness/scripts");
mkdirSync(bin);
mkdirSync(join(harness, "lib"), { recursive: true });
for (const script of [
"openclaw-npm-extended-stable-release.mjs",
"lib/release-version.mjs",
]) {
writeFileSync(join(harness, script), readFileSync(join("scripts", script)));
}
// Keep the real sourced helper and stop at the next phase boundary. This
// prevents all publishing while proving the active case crosses admission.
writeFileSync(
join(harness, "lib/release-publish-children.sh"),
`${readFileSync("scripts/lib/release-publish-children.sh", "utf8")}\nverify_release_tag_target() { echo admitted >> "$EVENTS"; exit 42; }\n`,
);
const events = join(root, "events");
writeFileSync(events, "");
writeFileSync(join(bin, "node"), `#!/bin/sh\nexec "${nodeExecutable}" "$@"\n`, {
mode: 0o755,
});
writeFileSync(
join(bin, "gh"),
`#!${nodeExecutable}
const fs = require("node:fs");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.EVENTS, JSON.stringify(args) + "\\n");
if (JSON.stringify(args) !== JSON.stringify(["api", "repos/openclaw/openclaw/contents/package.json?ref=refs/heads/main", "--jq", ".content"])) {
process.stderr.write("Unexpected GitHub operation");
process.exit(90);
}
if (${JSON.stringify(mainVersion)} === "unavailable") {
process.stderr.write("fixture main API unavailable");
process.exit(1);
}
process.stdout.write(${JSON.stringify(Buffer.from(JSON.stringify({ version: mainVersion })).toString("base64"))});
`,
{ mode: 0o755 },
);
const workflow = parse(
readFileSync(".github/workflows/openclaw-release-publish.yml", "utf8"),
) as { jobs: Record<string, { steps?: { name?: string; run?: string }[] }> };
const dispatch = Object.values(workflow.jobs)
.flatMap((job) => job.steps ?? [])
.find((step) => step.name === "Dispatch publish workflows");
expect(dispatch?.run).toBeTruthy();
// GitHub resolves expressions before passing the run body to bash.
const run = dispatch!.run!.replaceAll(/\$\{\{[^}]*\}\}/gu, "fixture");
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-c", run], {
cwd: root,
encoding: "utf8",
timeout: 15_000,
env: {
PATH: `${bin}:/usr/bin:/bin`,
EVENTS: events,
GITHUB_WORKSPACE: root,
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_REF: "refs/tags/release-publish/bbbbbbbbbbbb-123",
GITHUB_STEP_SUMMARY: join(root, "summary"),
PARENT_WORKFLOW_SHA: "b".repeat(40),
CHILD_WORKFLOW_REF: "release-publish/bbbbbbbbbbbb-123",
RELEASE_TAG: "v2026.7.34",
TARGET_SHA: "a".repeat(40),
RELEASE_NPM_DIST_TAG: "extended-stable",
PUBLISH_OPENCLAW_NPM: "true",
WAIT_FOR_CLAWHUB: "false",
RUNNER_TEMP: root,
BYPASS_EXTENDED_STABLE_GUARD: "true",
},
});
expect(result.status, result.stderr).toBe(expectedStatus);
if (expectedError) {
expect(result.stderr).toContain(expectedError);
}
const calls = readFileSync(events, "utf8").trim().split("\n");
expect(calls).toEqual([
JSON.stringify([
"api",
"repos/openclaw/openclaw/contents/package.json?ref=refs/heads/main",
"--jq",
".content",
]),
...(expectedStatus === 42 ? ["admitted"] : []),
]);
},
);
});

View file

@ -1571,6 +1571,8 @@ it.each([null, tag])(
it.each([
{ releaseTag: nextTag, prerelease: false },
{ releaseTag: "v2026.6.33", prerelease: false },
{ releaseTag: "v2026.8.35", prerelease: false },
{ releaseTag: "v2026.9.4-alpha.1", prerelease: true },
{ releaseTag: "v2026.9.4-beta.1", prerelease: true },
])("honors explicit non-latest finalization of $releaseTag", ({ releaseTag, prerelease }) => {
@ -1641,8 +1643,8 @@ it("refuses non-latest finalization that would demote the current latest", () =>
it.each([
{
releaseTag: "v2026.6.33",
latest: "false",
message: "Unsupported core GitHub release train",
latest: "true",
message: "Extended-stable releases cannot become core latest",
},
{
releaseTag: "v2026.9.04",

View file

@ -257,7 +257,7 @@ describe("extended-stable npm release request", () => {
["main a year-plus ahead", "2028.12.32", "2028.11"],
])("rejects %s", (_label, mainPackageVersion, expectedMonth) => {
expect(() => validateExtendedStableNpmReleaseRequest({ ...valid, mainPackageVersion })).toThrow(
`Extended-stable publishes only the trailing completed month: protected main ${mainPackageVersion} allows ${expectedMonth}.PATCH, not 2026.6.33. Retire the older line or dispatch with BYPASS_EXTENDED_STABLE_GUARD for an explicitly approved exception.`,
`Extended-stable publishes only the trailing completed month: protected main ${mainPackageVersion} allows ${expectedMonth}.PATCH, not 2026.6.33. Retire the older line; publishing a retired line requires an explicit maintainer decision.`,
);
});
@ -494,6 +494,48 @@ describe("extended-stable npm run identity", () => {
}
});
it("accepts a plugin run dispatched by the trusted protected-tag orchestrator for the exact target", () => {
const workflowSha = "c".repeat(40);
const toolingRef = `release-publish/${workflowSha.slice(0, 12)}-123`;
const pluginRun = {
workflowName: "Plugin NPM Release",
displayTitle: `Plugin NPM Release [extended-stable] ${sha}`,
event: "workflow_dispatch",
status: "completed",
conclusion: "success",
headBranch: toolingRef,
headSha: workflowSha,
};
expect(() =>
validateExtendedStableRunIdentity({
run: pluginRun,
kind: "plugin",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
expectedOrchestratorBranch: toolingRef,
expectedOrchestratorSha: workflowSha,
}),
).not.toThrow();
for (const changes of [
{ headBranch: "release/2026.6.35" },
{ headSha: "not-a-sha" },
{ displayTitle: `Plugin NPM Release [extended-stable] ${"b".repeat(40)}` },
]) {
expect(() =>
validateExtendedStableRunIdentity({
run: { ...pluginRun, ...changes },
kind: "plugin",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
expectedOrchestratorBranch: toolingRef,
expectedOrchestratorSha: workflowSha,
}),
).toThrow();
}
});
it("accepts plugin recovery only with authenticated main tooling and the exact source identity", () => {
const toolingSha = "b".repeat(40);
const run = {

View file

@ -383,7 +383,7 @@ describe("minimal npm extended-stable workflow", () => {
expect(summary.run).toContain("Extended-stable guard bypass: ${BYPASS_EXTENDED_STABLE_GUARD}");
});
it("lets main promote only the canonical immutable extended-stable candidate", () => {
it("lets protected tooling promote only the canonical immutable extended-stable candidate", () => {
const parsed = workflow();
const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8");
const input = parsed.on?.workflow_dispatch?.inputs?.release_candidate_branch;
@ -404,20 +404,97 @@ describe("minimal npm extended-stable workflow", () => {
);
expect(trustedRef.env?.RELEASE_CANDIDATE_BRANCH).toBe("${{ inputs.release_candidate_branch }}");
expect(trustedRef.run).toContain('release_candidate_branch="${RELEASE_CANDIDATE_BRANCH:-}"');
expect(trustedRef.run).toContain('"${WORKFLOW_REF}" != "refs/heads/main"');
expect(trustedRef.run).toContain(
'! "${WORKFLOW_REF}" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$',
);
expect(trustedRef.run).toContain(
'expected_candidate_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"',
);
const recheck = step(parsed.jobs?.publish_openclaw_npm, "Recheck npm release request");
expect(recheck.env?.NPM_WORKFLOW_REF).toBe(validate.env?.NPM_WORKFLOW_REF);
expect(releaseDocs).toContain("--ref main");
expect(releaseDocs).toContain("-f release_candidate_branch=extended-stable/YYYY.M.33");
expect(releaseDocs).toContain("canonical candidate branch directly");
expect(releaseDocs).toContain("workflow SHA is reachable from current `main`");
expect(releaseDocs).toContain("trusted main-pinned harness");
expect(releaseDocs).toContain('--ref "$PUBLISH_REF"');
expect(releaseDocs).toContain(
"The parent derives the canonical `extended-stable/YYYY.M.33` branch",
);
expect(releaseDocs).toContain('git tag "$PUBLISH_REF" "$TOOLING_SHA"');
expect(releaseDocs).toContain("The helper dispatches from an immutable `release-ci/*` ref");
});
it.each([
{ label: "trusted-main recovery", workflowRef: "refs/heads/main", status: 0 },
{
label: "protected publisher",
workflowRef: "refs/tags/release-publish/bbbbbbbbbbbb-123",
status: 0,
},
{ label: "feature branch", workflowRef: "refs/heads/feature/recovery", status: 1 },
{
label: "release branch with candidate override",
workflowRef: "refs/heads/release/2026.8.1",
status: 1,
},
{ label: "ordinary tag", workflowRef: "refs/tags/v2026.8.34", status: 1 },
{
label: "wrong candidate month",
workflowRef: "refs/heads/main",
candidate: "extended-stable/2026.7.33",
status: 1,
},
{
label: "noncanonical candidate branch",
workflowRef: "refs/heads/main",
candidate: "extended-stable/2026.8.34",
status: 1,
},
{ label: "latest selector", workflowRef: "refs/heads/main", npmDistTag: "latest", status: 1 },
{ label: "beta selector", workflowRef: "refs/heads/main", npmDistTag: "beta", status: 1 },
{ label: "correction suffix", workflowRef: "refs/heads/main", tag: "v2026.8.34-1", status: 1 },
{ label: "non-tag candidate", workflowRef: "refs/heads/main", tag: "a".repeat(40), status: 1 },
{
label: "wrong protected SHA prefix",
workflowRef: "refs/tags/release-publish/aaaaaaaaaaaa-123",
status: 1,
},
{
label: "moved protected tag",
workflowRef: "refs/tags/release-publish/bbbbbbbbbbbb-123",
remoteSha: "c".repeat(40),
status: 1,
},
])(
"checks the actual publication admission shell for $label",
({ workflowRef, candidate, npmDistTag, tag, remoteSha, status }) => {
const guard = step(
workflow().jobs?.validate_publish_request,
"Require trusted workflow ref for publish",
);
const result = spawnSync(
"bash",
[
"--noprofile",
"--norc",
"-c",
`gh() { printf '%s\\n' "$REMOTE_WORKFLOW_SHA"; }\n${guard.run}`,
],
{
encoding: "utf8",
env: {
PATH: process.env.PATH,
GITHUB_REPOSITORY: "openclaw/openclaw",
RELEASE_TAG: tag ?? "v2026.8.34",
RELEASE_NPM_DIST_TAG: npmDistTag ?? "extended-stable",
RELEASE_CANDIDATE_BRANCH: candidate ?? "extended-stable/2026.8.33",
WORKFLOW_REF: workflowRef,
WORKFLOW_SHA: "b".repeat(40),
REMOTE_WORKFLOW_SHA: remoteSha ?? "b".repeat(40),
},
},
);
expect(result.status, result.stderr).toBe(status);
},
);
it("accepts arbitrary SHA preflight targets and exercises every publishable plugin package", () => {
const parsed = workflow(preflightWorkflowPath);
const preflight = parsed.jobs?.check_contents_npm;
@ -582,6 +659,12 @@ describe("minimal npm extended-stable workflow", () => {
"Verify plugin npm release run metadata",
);
expect(verify.env?.RUN_KIND).toBe("plugin");
expect(verify.env?.EXPECTED_ORCHESTRATOR_BRANCH).toBe(
"${{ inputs.release_candidate_branch != '' && startsWith(github.ref, 'refs/tags/release-publish/') && github.ref_name || '' }}",
);
expect(verify.env?.EXPECTED_ORCHESTRATOR_SHA).toBe(
"${{ inputs.release_candidate_branch != '' && startsWith(github.ref, 'refs/tags/release-publish/') && github.workflow_sha || '' }}",
);
expect(verify.run).toContain(
"node trusted-workflow/scripts/openclaw-npm-extended-stable-release.mjs verify-run",
);

View file

@ -2158,22 +2158,24 @@ process.exitCode = 1;
describe("prepared Windows handoff", () => {
it.each([
["stable", "v2026.9.2", "success", true, true, false, true],
["absent", "v2026.9.2", "success", false, false, false, false],
["incomplete", "v2026.9.2", "success", true, false, false, true],
["beta", "v2026.9.2-beta.1", "success", true, true, false, false],
["alpha", "v2026.9.2-alpha.1", "success", true, true, false, false],
["failed activation", "v2026.9.2", "failure", true, true, false, false],
["skipped activation", "v2026.9.2", "skipped", true, true, false, false],
["dispatch failed", "v2026.9.2", "success", true, true, true, true],
["stable on beta", "v2026.9.2", "beta", "success", true, true, false, true],
["stable on latest", "v2026.9.2", "latest", "success", true, true, false, true],
["absent", "v2026.9.2", "beta", "success", false, false, false, false],
["incomplete", "v2026.9.2", "beta", "success", true, false, false, true],
["beta", "v2026.9.2-beta.1", "beta", "success", true, true, false, false],
["alpha", "v2026.9.2-alpha.1", "alpha", "success", true, true, false, false],
["failed activation", "v2026.9.2", "beta", "failure", true, true, false, false],
["skipped activation", "v2026.9.2", "beta", "skipped", true, true, false, false],
["dispatch failed", "v2026.9.2", "beta", "success", true, true, true, true],
] as const)(
"uses the frozen optional selection after activation: %s",
(_label, tag, activation, selected, digests, dispatchFailure, scheduled) => {
(_label, tag, channel, activation, selected, digests, dispatchFailure, scheduled) => {
const fixture = finalizationFixture({ windowsDispatchFailure: dispatchFailure });
const ready = readyRelease();
ready.inputs = {
...ready.inputs,
tag,
npm_dist_tag: channel,
windows_node_tag: selected ? "v1.2.3" : "",
windows_node_installer_digests: digests
? JSON.stringify({

View file

@ -3973,11 +3973,13 @@ function runReleasePublishInputValidation(overrides: Record<string, string>) {
writeFileSync(join(binDir, "gh"), '#!/bin/sh\nprintf "%s\\n" "$WORKFLOW_SHA"\n', {
mode: 0o755,
});
return spawnSync("bash", ["-c", script], {
const githubOutput = resolve(tempDirs.make("release-publish-inputs-"), "github-output");
return spawnSync("bash", ["--noprofile", "--norc", "-c", script], {
encoding: "utf8",
env: {
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: "1",
FULL_RELEASE_VALIDATION_RUN_ID: "222",
GITHUB_OUTPUT: githubOutput,
OPENCLAW_NPM_RESUME_RUN_ID: "",
GITHUB_REPOSITORY: "openclaw/openclaw",
PATH: `${binDir}:${process.env.PATH}`,
@ -4040,7 +4042,7 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
`#!/bin/sh\n[ "$1" = "--signal=TERM" ] && [ "$2" = "--kill-after=10s" ] && [ "$3" = "120s" ] || exit 2\nshift 3\nexec "$@"\n`,
);
chmodSync(timeoutPath, 0o755);
return spawnSync("bash", ["-c", script], {
return spawnSync("bash", ["--noprofile", "--norc", "-c", script], {
encoding: "utf8",
env: {
GITHUB_REPOSITORY: "openclaw/openclaw",
@ -5031,7 +5033,7 @@ describe("package acceptance workflow", () => {
expect(verifyStep.run).not.toContain("npm view openclaw@extended-stable version");
});
it("accepts only exact protected SHA-pinned release publish tags", () => {
it("accepts only main-reachable protected SHA-pinned release publish tags", () => {
const workflowSha = "a".repeat(40);
const binDir = tempDirs.make("release-publish-gh-");
const ghPath = `${binDir}/gh`;
@ -5289,6 +5291,7 @@ dispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" plugin-clawhub-r
RELEASE_PLUGINS: plugin.packageName,
BASE_REF: "",
NPM_DIST_TAG: "default",
RELEASE_NPM_DIST_TAG: "latest",
PREFLIGHT_ONLY: "false",
DRY_RUN: "false",
PREPARED_ARTIFACT: "",
@ -5418,7 +5421,7 @@ const fs=require("node:fs");fs.writeFileSync("install-proof.json",JSON.stringify
expect(planner["working-directory"]).toBeUndefined();
}
expect(identity.if).toBe(
"github.event_name == 'workflow_dispatch' && (inputs.preflight_only || (inputs.npm_dist_tag == 'extended-stable' && github.ref == 'refs/heads/main'))",
"github.event_name == 'workflow_dispatch' && (inputs.preflight_only || inputs.release_candidate_branch != '' || (inputs.npm_dist_tag == 'extended-stable' && github.ref == 'refs/heads/main'))",
);
expect(identity.env).toMatchObject({
GH_TOKEN: "${{ github.token }}",
@ -6565,6 +6568,7 @@ wait_for_run openclaw-npm-release.yml 404 "$EXPECTED_SHA" "$STARTED_JOB" "$APPRO
env: {
PATH: `${root}:${process.env.PATH}`,
RELEASE_TAG: "v2026.9.1",
RELEASE_NPM_DIST_TAG: "latest",
PUBLISH_OPENCLAW_NPM: "true",
PUBLISH_DOCKER_ONLY: "false",
GITHUB_OUTPUT: join(root, "output"),
@ -6602,6 +6606,11 @@ wait_for_run openclaw-npm-release.yml 404 "$EXPECTED_SHA" "$STARTED_JOB" "$APPRO
env: {
PATH: `${root}:${process.env.PATH}`,
RELEASE_TAG: tag,
RELEASE_NPM_DIST_TAG: tag.includes("-alpha.")
? "alpha"
: tag.includes("-beta.")
? "beta"
: "latest",
PUBLISH_OPENCLAW_NPM: "true",
PUBLISH_DOCKER_ONLY: "false",
GITHUB_OUTPUT: join(root, "output"),
@ -7264,6 +7273,9 @@ NODE
expect(workflow).toContain("main_ref: ${{ steps.inputs.outputs.main_ref }}");
expect(workflow).toContain("TRIGGER_SHA: ${{ github.sha }}");
expect(workflow).toContain('main_ref="$TRIGGER_SHA"');
expect(workflow).toContain('classifyReleaseTrain(parsed) === "stable"');
expect(workflow).toContain('classifyReleaseTrain(parsed) !== "stable"');
expect(workflow).toContain("below the extended-stable .33 boundary");
expect(workflow).toContain("ref: ${{ needs.resolve.outputs.main_ref }}");
expect(
workflowStep(
@ -13876,6 +13888,7 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
"sparse-checkout": "scripts",
});
expect(validateManifest.env).toMatchObject({
EXPECTED_WORKFLOW_BRANCH: "${{ github.ref_name }}",
PUBLICATION_CONSUMER:
"${{ inputs.publish_docker_only && !inputs.publish_openclaw_npm && 'docker-only' || 'publisher' }}",
PUBLISH_DOCKER_ONLY: "${{ inputs.publish_docker_only }}",
@ -14186,13 +14199,33 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
});
it.each([
["omitted", false, false, false, 0],
["selected", true, true, false, 0],
["dispatch failure", true, true, true, 1],
["missing digests", true, false, false, 1],
{ scenario: "omitted", selected: false, hasDigests: false, dispatchFailure: false, exit: 0 },
{ scenario: "selected", selected: true, hasDigests: true, dispatchFailure: false, exit: 0 },
{
scenario: "dispatch failure",
selected: true,
hasDigests: true,
dispatchFailure: true,
exit: 1,
},
{
scenario: "missing digests",
selected: true,
hasDigests: false,
dispatchFailure: false,
exit: 1,
},
{
scenario: "extended-stable",
selected: true,
hasDigests: true,
dispatchFailure: false,
exit: 0,
},
])(
"dispatches optional Windows promotion without waiting: %s",
(_scenario, selected, hasDigests, dispatchFailure, exit) => {
"dispatches optional Windows promotion without waiting: $scenario",
({ scenario, selected, hasDigests, dispatchFailure, exit }) => {
const shouldDispatch = selected && hasDigests && scenario !== "extended-stable";
const source = readFileSync("scripts/lib/release-publish-children.sh", "utf8");
const root = tempDirs.make("windows-detached-publish-");
const dispatchPath = join(root, "dispatch");
@ -14230,6 +14263,7 @@ promote_windows_release_assets
GITHUB_STEP_SUMMARY: summaryPath,
GITHUB_REPOSITORY: "openclaw/openclaw",
RELEASE_TAG: "v2026.9.1",
RELEASE_NPM_DIST_TAG: scenario === "extended-stable" ? "extended-stable" : "latest",
CHILD_WORKFLOW_REF: workflowRef,
PARENT_WORKFLOW_SHA: workflowSha,
WINDOWS_NODE_TAG: selected ? "v0.6.3" : "",
@ -14239,8 +14273,8 @@ promote_windows_release_assets
);
expect(result.status, result.stderr).toBe(exit);
expect(result.stderr).not.toContain("unexpected-windows-wait");
expect(existsSync(dispatchPath)).toBe(selected && hasDigests);
if (selected && hasDigests) {
expect(existsSync(dispatchPath)).toBe(shouldDispatch);
if (shouldDispatch) {
expect(readFileSync(dispatchPath, "utf8").trim().split("\n")).toEqual([
workflowRef,
workflowSha,
@ -14254,7 +14288,7 @@ promote_windows_release_assets
]);
}
expect(readFileSync(summaryPath, "utf8").includes("actions/runs/456")).toBe(
selected && hasDigests && !dispatchFailure,
shouldDispatch && !dispatchFailure,
);
},
);

View file

@ -14,6 +14,7 @@ import { runInNewContext } from "node:vm";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { PLUGIN_NPM_RELEASE_AUTHORITY_PATHS } from "../../scripts/lib/plugin-publication-candidates.ts";
import { validateActiveExtendedStableLine } from "../../scripts/openclaw-npm-extended-stable-release.mjs";
import { createStablePluginNpmBootstrapApproval } from "../../scripts/plugin-npm-bootstrap-approval.mjs";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { requireNodeTool } from "../helpers/node-toolchain.js";
@ -144,7 +145,12 @@ function runStableBootstrapAdmission(
);
return spawnSync(
"/bin/bash",
["-c", step(workflow().jobs?.publish_plugins_npm, "Authorize bootstrap release").run!],
[
"--noprofile",
"--norc",
"-c",
step(workflow().jobs?.publish_plugins_npm, "Authorize bootstrap release").run!,
],
{
encoding: "utf8",
timeout: 15_000,
@ -420,6 +426,12 @@ describe("plugin npm extended-stable workflow", () => {
expect(inputs?.ref?.description).toBe(
"Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches",
);
expect(inputs?.release_candidate_branch).toEqual({
description:
"Canonical extended-stable branch when protected release tooling publishes its immutable target",
required: false,
type: "string",
});
});
it("uses one override for check, plan, pack, and publish", () => {
@ -463,10 +475,38 @@ describe("plugin npm extended-stable workflow", () => {
{ publishTag: "beta", toolingTrusted: true, candidateMoved: false },
{ publishTag: "extended-stable", toolingTrusted: true, candidateMoved: false },
{ publishTag: "extended-stable", toolingTrusted: true, candidateMoved: true },
{
publishTag: "extended-stable",
toolingTrusted: true,
candidateMoved: false,
mainVersion: "2026.9.1",
expectedFailure: "only the trailing completed month",
},
{
publishTag: "extended-stable",
toolingTrusted: true,
candidateMoved: false,
mainApiUnavailable: true,
expectedFailure: "fixture main API unavailable",
},
{
publishTag: "extended-stable",
toolingTrusted: true,
candidateMoved: false,
mainVersion: "invalid",
expectedFailure: "Protected main package version",
},
{ publishTag: "latest", toolingTrusted: false, candidateMoved: false },
])(
"publishes sealed bytes only with current authority: $publishTag / trusted $toolingTrusted / moved $candidateMoved",
({ publishTag, toolingTrusted, candidateMoved }) => {
"publishes sealed bytes only with current authority: $publishTag / trusted $toolingTrusted / moved $candidateMoved / main $mainVersion / unavailable $mainApiUnavailable",
({
publishTag,
toolingTrusted,
candidateMoved,
mainVersion = "2026.8.1",
mainApiUnavailable = false,
expectedFailure,
}) => {
const nodeExecutable = requireNodeTool("node");
const npmCli = realpathSync(requireNodeTool("npm"));
const root = mkdtempSync(join(tmpdir(), "plugin-oidc-artifact-"));
@ -474,7 +514,12 @@ describe("plugin npm extended-stable workflow", () => {
const bin = join(root, "bin");
mkdirSync(bin);
mkdirSync(join(root, "scripts/lib"), { recursive: true });
for (const script of ["release-tooling-identity.mjs", "lib/record-shared.mjs"]) {
for (const script of [
"release-tooling-identity.mjs",
"lib/record-shared.mjs",
"openclaw-npm-extended-stable-release.mjs",
"lib/release-version.mjs",
]) {
writeFileSync(join(root, "scripts", script), readFileSync(join("scripts", script)));
}
writeFileSync(
@ -487,13 +532,16 @@ describe("plugin npm extended-stable workflow", () => {
writeFileSync(tarball, "sealed preflight bytes");
const targetSha = "a".repeat(40);
const toolingSha = "b".repeat(40);
// Qualification succeeded while main was August. Publication must reread
// main even when the qualified candidate and monthly branch remain unchanged.
expect(() => validateActiveExtendedStableLine("2026.7.33", "2026.8.1")).not.toThrow();
const candidateRef = "refs/heads/extended-stable/2026.7.33";
// The artifact was admitted at targetSha; the approval wait may advance the branch.
const currentRef = {
ref: candidateRef,
object: { type: "commit", sha: candidateMoved ? "c".repeat(40) : targetSha },
};
writeFileSync(join(bin, "node"), `#!/bin/bash\nexec "${nodeExecutable}" "$@"\n`, {
writeFileSync(join(bin, "node"), `#!/bin/sh\nexec "${nodeExecutable}" "$@"\n`, {
mode: 0o755,
});
writeFileSync(
@ -506,6 +554,12 @@ if (endpoint === "repos/openclaw/openclaw/compare/${toolingSha}...main") {
process.stdout.write(${JSON.stringify(JSON.stringify({ status: toolingTrusted ? "ahead" : "diverged" }))});
} else if (endpoint === "repos/openclaw/openclaw/git/ref/heads/extended-stable/2026.7.33") {
process.stdout.write(${JSON.stringify(JSON.stringify(currentRef))});
} else if (endpoint === "repos/openclaw/openclaw/contents/package.json?ref=refs/heads/main") {
if (${mainApiUnavailable}) {
process.stderr.write("fixture main API unavailable");
process.exit(1);
}
process.stdout.write(${JSON.stringify(Buffer.from(JSON.stringify({ version: mainVersion })).toString("base64"))});
} else {
process.stderr.write("Unexpected GitHub request: " + endpoint);
process.exit(90);
@ -524,40 +578,47 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
`,
{ mode: 0o755 },
);
writeFileSync(join(bin, "timeout"), '#!/bin/bash\nshift 3\nexec "$@"\n', {
writeFileSync(join(bin, "timeout"), '#!/bin/sh\nshift 3\nexec "$@"\n', {
mode: 0o755,
});
const publish = step(
workflow().jobs?.publish_plugins_npm,
"Publish with trusted publisher",
);
const result = spawnSync("/bin/bash", ["-e", "-o", "pipefail", "-c", publish.run!], {
cwd: root,
encoding: "utf8",
timeout: 15_000,
env: {
PATH: `${bin}:/usr/bin:/bin`,
EVENTS: events,
NPM_CLI: npmCli,
RUNNER_TEMP: root,
TARBALL_PATH: tarball,
PUBLISH_TAG: publishTag,
PACKAGE_VERSION: "2026.7.33",
RELEASE_TARGET_SHA: targetSha,
OPENCLAW_RELEASE_TOOLING_REPOSITORY: "openclaw/openclaw",
OPENCLAW_RELEASE_TOOLING_FULL_REF: "refs/heads/main",
OPENCLAW_RELEASE_TOOLING_REF: "main",
OPENCLAW_RELEASE_TOOLING_SHA: toolingSha,
OPENCLAW_RELEASE_PUBLISH_RUN_ID: "",
OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT: "",
OPENCLAW_RELEASE_PUBLISH_REF: "",
OPENCLAW_RELEASE_PUBLISH_FULL_REF: "",
OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY: "",
NPM_TOKEN: "fixture-token-must-not-reach-npm",
NODE_AUTH_TOKEN: "fixture-token-must-not-reach-npm",
const result = spawnSync(
"/bin/bash",
["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publish.run!],
{
cwd: root,
encoding: "utf8",
timeout: 15_000,
env: {
PATH: `${bin}:/usr/bin:/bin`,
EVENTS: events,
NPM_CLI: npmCli,
RUNNER_TEMP: root,
TARBALL_PATH: tarball,
PUBLISH_TAG: publishTag,
PACKAGE_VERSION: "2026.7.33",
GITHUB_REPOSITORY: "openclaw/openclaw",
// No environment bypass may override the live pre-mutation guard.
BYPASS_EXTENDED_STABLE_GUARD: "true",
RELEASE_TARGET_SHA: targetSha,
OPENCLAW_RELEASE_TOOLING_REPOSITORY: "openclaw/openclaw",
OPENCLAW_RELEASE_TOOLING_FULL_REF: "refs/heads/main",
OPENCLAW_RELEASE_TOOLING_REF: "main",
OPENCLAW_RELEASE_TOOLING_SHA: toolingSha,
OPENCLAW_RELEASE_PUBLISH_RUN_ID: "",
OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT: "",
OPENCLAW_RELEASE_PUBLISH_REF: "",
OPENCLAW_RELEASE_PUBLISH_FULL_REF: "",
OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY: "",
NPM_TOKEN: "fixture-token-must-not-reach-npm",
NODE_AUTH_TOKEN: "fixture-token-must-not-reach-npm",
},
},
});
const allowed = toolingTrusted && !candidateMoved;
);
const allowed = toolingTrusted && !candidateMoved && !expectedFailure;
expect(result.status, result.stderr).toBe(allowed ? 0 : 1);
expect(readdirSync(root).filter((name) => name.startsWith("plugin-npm-oidc."))).toEqual([]);
const calls = readFileSync(events, "utf8")
@ -567,7 +628,8 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
const npmCalls = calls.filter((call) => call.command === "npm");
if (!allowed) {
expect(result.stderr).toContain(
candidateMoved ? "branch is missing or moved" : "not reachable from current main",
expectedFailure ??
(candidateMoved ? "branch is missing or moved" : "not reachable from current main"),
);
expect(npmCalls).toEqual([]);
} else {
@ -589,9 +651,11 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
},
]);
if (publishTag === "extended-stable") {
expect(calls.at(-2)?.endpoint).toBe(
expect(calls.slice(-3).map((call) => call.endpoint ?? call.command)).toEqual([
"repos/openclaw/openclaw/git/ref/heads/extended-stable/2026.7.33",
);
"repos/openclaw/openclaw/contents/package.json?ref=refs/heads/main",
"npm",
]);
}
}
} finally {
@ -600,7 +664,7 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
},
);
it("keeps main recovery bound to the canonical monthly source tip", () => {
it("admits exact monthly tips for recovery or canonical candidates from protected tooling", () => {
const trusted = step(
workflow().jobs?.preview_plugins_npm,
"Validate ref is on a trusted publish branch",
@ -615,6 +679,19 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
expect(trusted.run).toContain(
'exact_ref_match(\n "HEAD",\n f"refs/remotes/origin/{extended_branch}"',
);
expect(trusted.env?.RELEASE_CANDIDATE_BRANCH).toBe(
"${{ github.event_name == 'workflow_dispatch' && inputs.release_candidate_branch || '' }}",
);
expect(trusted.run).toContain("candidate_branch != extended_branch");
expect(trusted.run).toContain('r"refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*"');
expect(trusted.run).toContain('is_ancestor(f"refs/remotes/origin/{extended_branch}")');
expect(trusted.run).toContain('is_ancestor("origin/main", os.environ["WORKFLOW_SHA"])');
expect(
step(
workflow().jobs?.preview_plugins_npm,
"Verify trusted preflight or recovery tooling identity",
).if,
).toContain("inputs.release_candidate_branch != ''");
});
it("binds preflight to an exact source SHA without release-publish approval", () => {
@ -651,16 +728,27 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
expect(toolingIdentity.run).toContain('--workflow-ref "$WORKFLOW_REF"');
expect(toolingIdentity.run).toContain('--workflow-full-ref "$WORKFLOW_FULL_REF"');
expect(toolingIdentity.run).toContain('--workflow-sha "$WORKFLOW_SHA"');
for (const [preflight, distTag, ref, expected] of [
[true, "default", "refs/heads/main", true],
[false, "extended-stable", "refs/heads/main", true],
[false, "extended-stable", "refs/heads/extended-stable/2026.8.33", false],
[false, "default", "refs/heads/main", false],
for (const [preflight, distTag, ref, candidateBranch, expected] of [
[true, "default", "refs/heads/main", "", true],
[false, "extended-stable", "refs/heads/main", "", true],
[false, "extended-stable", "refs/heads/extended-stable/2026.8.33", "", false],
[false, "default", "refs/heads/main", "", false],
[
false,
"extended-stable",
`refs/tags/release-publish/${"d".repeat(12)}-12345`,
"extended-stable/2026.8.33",
true,
],
] as const) {
expect(
runInNewContext(toolingIdentity.if!, {
github: { event_name: "workflow_dispatch", ref },
inputs: { preflight_only: preflight, npm_dist_tag: distTag },
inputs: {
preflight_only: preflight,
npm_dist_tag: distTag,
release_candidate_branch: candidateBranch,
},
}),
).toBe(expected);
}
@ -714,6 +802,7 @@ fs.appendFileSync(process.env.EVENTS, JSON.stringify({ command: "npm", args, byt
expect(trusted.run).toContain(
"Plugin npm preflight must not include a release publish parent run tuple.",
);
expect(trusted.run).toContain("preflight must not include release_candidate_branch");
const preflightBranchRejection = trusted.run?.indexOf(
"Plugin npm preflight target must be reachable from main or release/*.",
);

View file

@ -72,6 +72,7 @@ const modes: Record<
step: "Validate ref is on a trusted publish branch",
},
env: {
RELEASE_CANDIDATE_BRANCH: "",
NPM_DIST_TAG: "default",
PREFLIGHT_ONLY: "false",
PREPARED_ARTIFACT: "",
@ -394,6 +395,95 @@ posixIt.each(["refs/heads/extended-stable/2026.8.33", "refs/heads/main"])(
55_000,
);
const candidateAdmissionCases: Array<{
name: string;
env: Record<string, string>;
commands: RunOptions["commandResults"];
code: number;
message: string;
}> = [
{ name: "qualified protected tooling", env: {}, commands: {}, code: 0, message: "" },
{
name: "wrong candidate month",
env: { RELEASE_CANDIDATE_BRANCH: "extended-stable/2026.7.33" },
commands: {},
code: 1,
message: "release_candidate_branch must be extended-stable/2026.8.33",
},
{
name: "mutable main tooling",
env: { WORKFLOW_REF: "refs/heads/main" },
commands: {},
code: 1,
message: "protected release-publish workflow tooling",
},
{
name: "tooling outside main",
env: {},
commands: { [`merge-base --is-ancestor ${workflowSha} origin/main`]: { code: 1 } },
code: 1,
message: "workflow revision is not reachable from current main",
},
{
name: "candidate outside monthly branch",
env: {},
commands: {
"merge-base --is-ancestor HEAD refs/remotes/origin/extended-stable/2026.8.33": { code: 1 },
},
code: 1,
message: "target must be reachable from extended-stable/2026.8.33",
},
{
name: "tooling ancestry Git failure",
env: {},
commands: { [`merge-base --is-ancestor ${workflowSha} origin/main`]: { code: 23 } },
code: 23,
message: "",
},
{
name: "preflight candidate override",
env: { PREFLIGHT_ONLY: "true" },
commands: {},
code: 1,
message: "preflight must not include release_candidate_branch",
},
{
name: "non-extended candidate override",
env: { NPM_DIST_TAG: "default" },
commands: {},
code: 1,
message: "release_candidate_branch is only valid for extended-stable publication",
},
];
posixIt.each(candidateAdmissionCases)(
"npm canonical candidate admission: $name",
async ({ env, commands, code, message }) => {
const report = await pluginRun("npm-trust", {
env: {
NPM_DIST_TAG: "extended-stable",
PUBLISH_SCOPE: "all-publishable",
RELEASE_CANDIDATE_BRANCH: "extended-stable/2026.8.33",
WORKFLOW_REF: `refs/tags/release-publish/${workflowSha.slice(0, 12)}-123`,
...env,
},
revisions: { [`${sha}^{commit}`]: sha },
commandResults: commands,
});
expect(report.code, report.output).toBe(code);
if (message) {
expect(report.output).toContain(message);
}
if (code === 0) {
expect(gitCommands(report).slice(-2)).toEqual([
["merge-base", "--is-ancestor", workflowSha, "origin/main"],
["merge-base", "--is-ancestor", "HEAD", "refs/remotes/origin/extended-stable/2026.8.33"],
]);
}
},
55_000,
);
posixIt.each([
["moved canonical tip", "refs/heads/main", "c".repeat(40)],
["untrusted workflow branch", "refs/heads/topic", sha],

View file

@ -121,6 +121,7 @@ type WorkflowStep = {
type WorkflowJob = {
"continue-on-error"?: boolean | string;
"runs-on"?: string;
environment?: string;
env?: Record<string, string>;
if?: string;
needs?: string | string[];
@ -1965,6 +1966,18 @@ describe("release validation no-push transport", () => {
const releasePublishPath = ".github/workflows/openclaw-release-publish.yml";
const releasePublish = readWorkflow(releasePublishPath);
const dockerCall = job(releasePublish, "publish_docker");
const resolveTarget = job(releasePublish, "resolve_release_target");
const validateInputs = step(resolveTarget, "Validate inputs");
const validateEvidence = step(resolveTarget, "Validate full release validation manifest");
const validateReleaseBranch = step(
resolveTarget,
"Validate release tag is reachable from a trusted release branch",
);
const publishJob = job(releasePublish, "publish");
const resolveClawHubPlan = step(publishJob, "Resolve ClawHub release plan");
const dispatchPublish = step(publishJob, "Dispatch publish workflows");
const dispatchRun = dispatchPublish.run ?? "";
const coreStart = step(publishJob, "Start core npm publication");
expect(dockerRelease.on?.push).toBeUndefined();
expect(dockerRelease.on?.workflow_dispatch).toBeUndefined();
@ -1990,13 +2003,34 @@ describe("release validation no-push transport", () => {
// approval; its own guard test covers those safety properties.
expect(callers).toEqual(["docker-image-refresh.yml", "openclaw-release-publish.yml"]);
expect(validateInputs.id).toBe("inputs");
expect(validateInputs.run).toContain(
'expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"',
);
expect(validateInputs.run).not.toContain(
"Extended-stable core npm publication stays on the canonical extended-stable release flow",
);
expect(validateInputs.run).toContain(
'if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" ]]; then',
);
expect(publishJob.if).toBe("${{ !inputs.publish_docker_only }}");
expect(validateEvidence.env?.EXPECTED_WORKFLOW_BRANCH).toBe("${{ github.ref_name }}");
expect(validateReleaseBranch.run).toContain(
'expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}"',
);
expect(validateReleaseBranch.run).toContain(
"must be reachable from ${EXPECTED_VALIDATION_BRANCH}",
);
expect(dockerCall.needs).toEqual([
"resolve_release_target",
"publish",
"verify_core_npm_registry",
"finalize_github_release_before_docker",
]);
expect(dockerCall.if).toContain("inputs.publish_openclaw_npm");
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("inputs.publish_docker_only");
expect(dockerCall.if).toContain("needs.verify_core_npm_registry.result == 'success'");
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
@ -2031,6 +2065,19 @@ describe("release validation no-push transport", () => {
job(releasePublish, "resolve_release_target"),
"Validate full release validation manifest",
);
expect(resolveClawHubPlan.run).toContain("plan_args+=(--skip-clawhub)");
expect(dispatchRun).toContain("-f npm_dist_tag=extended-stable");
expect(coreStart.run).toContain('-f plugin_npm_run_id="${plugin_npm_run_id}"');
for (const nativeJob of [
"qualify_android_native",
"publish_android",
"publish_linux",
"publish_windows",
]) {
expect(job(releasePublish, nativeJob).if).toContain(
"inputs.npm_dist_tag != 'extended-stable'",
);
}
expect(validation.env?.EXPECTED_SHA).toBe("${{ steps.ref.outputs.sha }}");
expect(validation.run).toContain('--consumer publisher --manifest "$manifest"');
expect(job(releasePublish, "finalize_github_release").needs).toEqual([
@ -2053,6 +2100,64 @@ describe("release validation no-push transport", () => {
expect(reusablePermissionViolations(DOCKER_RELEASE, "prepare")).toEqual([]);
});
it.each([
["v2026.9.1", "latest", true],
["v2026.9.1-beta.1", "beta", false],
["v2026.8.35", "extended-stable", false],
] as const)(
"finalizes %s through the shared owner with explicit latest intent",
(tag, distTag, latest) => {
const workflow = readWorkflow(".github/workflows/openclaw-release-publish.yml");
const script = step(
job(workflow, "finalize_github_release"),
"Publish the verified draft release",
).run;
const root = tempDirs.make("release-finalize-track-");
const calls = join(root, "calls");
for (const finalizerExit of [0, 1]) {
writeFileSync(calls, "");
const result = spawnSync(
"bash",
[
"-c",
`
gh() { printf '%s\\n' "$SOURCE_SHA"; }
node() {
if [[ "$1 $2" == "scripts/linux-app-channel.mjs finalize-core" ]]; then
printf '%s\\n' "$*" >> "$CALLS"
return "$FINALIZER_EXIT"
fi
}
${script}
`,
],
{
encoding: "utf8",
env: {
PATH: process.env.PATH,
CALLS: calls,
FINALIZER_EXIT: String(finalizerExit),
RUNNER_TEMP: root,
GITHUB_REPOSITORY: "openclaw/openclaw",
RELEASE_TAG: tag,
RELEASE_NPM_DIST_TAG: distTag,
SOURCE_SHA: "a".repeat(40),
GITHUB_WORKFLOW_SHA: "b".repeat(40),
GITHUB_REF_NAME: "release-publish/bbbbbbbbbbbb-123",
GITHUB_REF: "refs/tags/release-publish/bbbbbbbbbbbb-123",
GITHUB_RUN_ID: "456",
GITHUB_RUN_ATTEMPT: "1",
},
},
);
expect(readFileSync(calls, "utf8")).toContain(
`finalize-core --tag ${tag} --source-sha ${"a".repeat(40)} --latest ${latest}`,
);
expect(result.status, result.stderr).toBe(finalizerExit);
}
},
);
it("keeps Docker required by default and activates early only after explicit approval", () => {
const workflow = readWorkflow(".github/workflows/openclaw-release-publish.yml");
expect(workflow.on?.workflow_dispatch?.inputs?.finalize_release_before_docker).toMatchObject({

View file

@ -51,11 +51,15 @@ describe("scripts/resolve-upgrade-survivor-baselines", () => {
)?.run;
assert(run);
// Mixed prereleases put the requested cutoff beyond the first 100 records.
const releases = Array.from({ length: 130 }, (_, index) => ({
tagName: `v2026.5.${130 - index}${index % 3 === 0 ? "-beta.1" : ""}`,
publishedAt: new Date(Date.UTC(2026, 8, 1) - index * 86_400_000).toISOString(),
isPrerelease: index % 3 === 0,
}));
const releases = Array.from({ length: 130 }, (_, index) => {
const publishedAt = new Date(Date.UTC(2026, 8, 1) - index * 86_400_000);
const version = `${publishedAt.getUTCFullYear()}.${publishedAt.getUTCMonth() + 1}.${publishedAt.getUTCDate()}`;
return {
tagName: `v${version}${index % 3 === 0 ? "-beta.1" : ""}`,
publishedAt: publishedAt.toISOString(),
isPrerelease: index % 3 === 0,
};
});
const versions = releases
.filter((release) => !release.isPrerelease && release.tagName !== "v2026.5.26")
.map((release) => release.tagName.slice(1));
@ -127,7 +131,13 @@ if (process.env.FAIL_API === "true") process.exit(75);
expect(readFileSync(output, "utf8")).toBe("");
} else {
invoke();
const expected = versions.filter((version) => Number(version.split(".")[2]) >= 24);
const expected = releases
.filter(
(release) =>
release.publishedAt >= "2026-05-24T00:00:00.000Z" &&
versions.includes(release.tagName.slice(1)),
)
.map((release) => release.tagName.slice(1));
expect(readFileSync(output, "utf8")).toBe(
`baselines=${expected.map((version) => `openclaw@${version}`).join(" ")}\nbaseline_scope=all-scenarios\nbaseline=openclaw@2026.5.24\n`,
);
@ -401,9 +411,14 @@ console.log(JSON.stringify(process.argv[4] === "dist-tags"
},
{
tags: { latest: "2026.9.2", "extended-stable": "2026.6.99" },
versions: ["2026.6.34", "2026.9.2"],
error: "npm extended-stable must name a published stable version",
versions: ["2026.6.34", "2026.9.1", "2026.9.2"],
error: "npm extended-stable must name a published extended-stable version",
},
...["2026.9.1", "2026.6.35-1", "2026.6.35-beta.1"].map((extended) => ({
tags: { latest: "2026.9.2", "extended-stable": extended },
versions: ["2026.6.34", "2026.9.1", "2026.9.2", extended],
error: "npm extended-stable must name a published extended-stable version",
})),
{
tags: { latest: "2026.9.2" },
versions: ["2026.9.1", "2026.9.2"],
@ -582,6 +597,32 @@ console.log(JSON.stringify(process.argv[4] === "dist-tags"
});
});
it("excludes extended-stable GitHub releases from regular stable baselines", () => {
const releases = [
{
isPrerelease: false,
publishedAt: "2026-08-02T00:00:00Z",
tagName: "v2026.6.34",
},
{
isPrerelease: false,
publishedAt: "2026-08-01T00:00:00Z",
tagName: "v2026.7.12",
},
];
withReleaseFixture(releases, (file) => {
expect(
resolveBaselines(
new Map([
["requested", "last-stable-1"],
["releases-json", file],
]),
),
).toEqual(["openclaw@2026.7.12"]);
});
});
it("preserves the last-stable count when the unpublished candidate is newest", () => {
const releases = ["2026.9.4", "2026.9.3", "2026.9.2", "2026.9.1", "2026.8.30"].map(
(version, index) => ({

View file

@ -548,6 +548,27 @@ describe("full release validation evidence", () => {
}
});
it("rejects direct monthly-branch evidence under a protected publisher", () => {
const branch = "extended-stable/2026.6.33";
expect(() =>
validateFullReleaseValidationEvidence({
run: releaseRun({ head_branch: branch }),
manifest: releaseManifest({
workflowRef: branch,
workflowFullRef: `refs/heads/${branch}`,
targetRef: "v2026.6.35",
}),
expectedRepository: "openclaw/openclaw",
expectedRunId: "123",
expectedTargetSha: targetSha,
expectedWorkflowBranch: branch,
expectedTrustedWorkflowFullRef: `refs/tags/release-publish/${workflowSha.slice(0, 12)}-123`,
expectedTrustedWorkflowSha: workflowSha,
isTrustedMainAncestor: () => false,
}),
).toThrow("must use a canonical release-ci producer branch");
});
it("rejects direct main evidence outside current main", () => {
expect(() =>
validate(