feat(release): add monthly npm extended-stable publication (#99352)

* feat(release): add npm stable publication

* fix(release): allow stable full validation

* feat(release): add stable guard test bypass

* fix(release): allow stable maintenance after month rollover

* docs: refresh release documentation map

* refactor(release): rename monthly channel to extended-stable

* fix(release): repair extended-stable selector forward

* docs(release): fix extended-stable link
This commit is contained in:
Kevin Lin 2026-07-04 08:22:39 -07:00 committed by GitHub
parent c79c380358
commit ba7b5db74c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1565 additions and 77 deletions

View file

@ -1132,8 +1132,8 @@ jobs:
head_sha="$(jq -r '.headSha // ""' <<< "$run_json")"
echo "${label}: ${status}/${conclusion} attempt ${attempt} head ${head_sha}: ${url}"
if [[ "$CHILD_WORKFLOW_REF" == release-ci/* && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]; then
echo "::error::${label} child run used ${head_sha}, expected ${TARGET_SHA}. Dispatch Full Release Validation from a ref pinned to the target SHA, not a moving branch."
if [[ ( "$CHILD_WORKFLOW_REF" == release-ci/* || "$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\.([1-9]|1[0-2])\.33$ ) && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]; then
echo "::error::${label} child run used ${head_sha}, expected ${TARGET_SHA}. Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA, not a moving branch."
return 1
fi

View file

@ -33,6 +33,12 @@ on:
- alpha
- beta
- latest
- extended-stable
bypass_extended_stable_guard:
description: Testing only; relax the extended-stable patch and protected-main month policy on the canonical extended-stable branch
required: true
default: false
type: boolean
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && format('openclaw-npm-release-{0}-{1}-preflight', inputs.tag, inputs.npm_dist_tag) || github.event_name == 'workflow_dispatch' && format('openclaw-npm-release-{0}-{1}-publish-{2}', inputs.tag, inputs.npm_dist_tag, github.run_id) || format('openclaw-npm-release-{0}', github.ref) }}
@ -120,6 +126,14 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate npm release request
env:
BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
RELEASE_TAG: ${{ inputs.tag }}
NPM_WORKFLOW_REF: ${{ github.ref }}
run: node scripts/openclaw-npm-extended-stable-release.mjs validate-request
- name: Ensure version is not already published
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
@ -395,6 +409,14 @@ jobs:
with:
persist-credentials: false
- name: Validate npm release request
env:
BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
RELEASE_TAG: ${{ inputs.tag }}
NPM_WORKFLOW_REF: ${{ github.ref }}
run: node scripts/openclaw-npm-extended-stable-release.mjs validate-request
- name: Require trusted workflow ref for publish
env:
RELEASE_TAG: ${{ inputs.tag }}
@ -403,11 +425,15 @@ jobs:
run: |
set -euo pipefail
tideclaw_alpha_publish=false
extended_stable_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
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${WORKFLOW_REF}" == refs/heads/extended-stable/* ]]; then
extended_stable_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
exit 1
fi
@ -541,20 +567,30 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
EXPECTED_EXTENDED_STABLE_BRANCH: ${{ github.ref_name }}
RUN_KIND: preflight
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID} from ${run.headBranch}: ${run.url}`);'
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
export EXPECTED_RELEASE_SHA
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,headSha,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
- name: Verify full release validation run metadata
if: ${{ inputs.full_release_validation_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
EXPECTED_EXTENDED_STABLE_BRANCH: ${{ github.ref_name }}
RUN_KIND: validation
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} from ${run.headBranch}: ${run.url}`);'
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
export EXPECTED_RELEASE_SHA
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,headSha,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
- name: Download prepared npm tarball
env:
@ -636,6 +672,7 @@ jobs:
pnpm release:openclaw:npm:check
- name: Verify prepared tarball provenance
id: preflight_provenance
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
@ -653,6 +690,11 @@ jobs:
ARTIFACT_RELEASE_NPM_DIST_TAG="$(jq -r '.npmDistTag // ""' "$MANIFEST_FILE")"
ARTIFACT_TARBALL_NAME="$(jq -r '.tarballName // ""' "$MANIFEST_FILE")"
ARTIFACT_TARBALL_SHA256="$(jq -r '.tarballSha256 // ""' "$MANIFEST_FILE")"
if [[ -z "$ARTIFACT_TARBALL_NAME" || "$ARTIFACT_TARBALL_NAME" != "$(basename -- "$ARTIFACT_TARBALL_NAME")" || "$ARTIFACT_TARBALL_NAME" != *.tgz ]]; then
echo "Prepared preflight tarball name is unsafe: $ARTIFACT_TARBALL_NAME" >&2
exit 1
fi
ARTIFACT_TARBALL_PATH="preflight-tarball/$ARTIFACT_TARBALL_NAME"
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
exit 1
@ -665,20 +707,25 @@ jobs:
echo "Prepared preflight npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $ARTIFACT_RELEASE_NPM_DIST_TAG" >&2
exit 1
fi
if [[ -z "$ARTIFACT_TARBALL_NAME" || ! -f "preflight-tarball/$ARTIFACT_TARBALL_NAME" ]]; then
if [[ ! -f "$ARTIFACT_TARBALL_PATH" ]]; then
echo "Prepared preflight tarball named in manifest is missing: $ARTIFACT_TARBALL_NAME" >&2
exit 1
fi
actual_tarball_sha256="$(sha256sum "preflight-tarball/$ARTIFACT_TARBALL_NAME" | awk '{print $1}')"
actual_tarball_sha256="$(sha256sum "$ARTIFACT_TARBALL_PATH" | awk '{print $1}')"
if [[ "$actual_tarball_sha256" != "$ARTIFACT_TARBALL_SHA256" ]]; then
echo "Prepared preflight tarball digest mismatch." >&2
exit 1
fi
echo "tarball_name=$ARTIFACT_TARBALL_NAME" >> "$GITHUB_OUTPUT"
echo "tarball_sha256=$ARTIFACT_TARBALL_SHA256" >> "$GITHUB_OUTPUT"
echo "tarball_path=$ARTIFACT_TARBALL_PATH" >> "$GITHUB_OUTPUT"
- name: Verify full release validation target
if: ${{ inputs.full_release_validation_run_id != '' }}
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
EXPECTED_WORKFLOW_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
@ -688,19 +735,11 @@ jobs:
ls -la full-release-validation >&2 || true
exit 1
fi
WORKFLOW_NAME="$(jq -r '.workflowName // ""' "$MANIFEST_FILE")"
TARGET_SHA="$(jq -r '.targetSha // ""' "$MANIFEST_FILE")"
export EXPECTED_RELEASE_SHA MANIFEST_FILE
node scripts/openclaw-npm-extended-stable-release.mjs verify-manifest
RERUN_GROUP="$(jq -r '.rerunGroup // ""' "$MANIFEST_FILE")"
RUN_RELEASE_SOAK="$(jq -r '.runReleaseSoak // ""' "$MANIFEST_FILE")"
PERFORMANCE_BLOCKING="$(jq -r '.controls.performanceBlocking // false' "$MANIFEST_FILE")"
if [[ "$WORKFLOW_NAME" != "Full Release Validation" ]]; then
echo "Full release validation manifest workflow mismatch: $WORKFLOW_NAME" >&2
exit 1
fi
if [[ "$TARGET_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "Full release validation target SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $TARGET_SHA" >&2
exit 1
fi
if [[ "$RERUN_GROUP" != "all" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $RERUN_GROUP" >&2
exit 1
@ -714,23 +753,26 @@ jobs:
exit 1
fi
- name: Resolve publish tarball
id: publish_tarball
run: |
set -euo pipefail
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
if [[ -z "$TARBALL_PATH" ]]; then
echo "Prepared preflight tarball not found." >&2
ls -la preflight-tarball >&2 || true
exit 1
fi
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
- name: Recheck npm release request
env:
BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
RELEASE_TAG: ${{ inputs.tag }}
NPM_WORKFLOW_REF: ${{ github.ref }}
run: node scripts/openclaw-npm-extended-stable-release.mjs validate-request
- name: Capture previous extended-stable selector
id: previous_extended_stable
if: ${{ inputs.npm_dist_tag == 'extended-stable' }}
run: node scripts/openclaw-npm-extended-stable-release.mjs capture-selector
- name: Publish
id: publish
env:
BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }}
OPENCLAW_PREPACK_PREPARED: "1"
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
PUBLISH_TARBALL_PATH: ${{ steps.publish_tarball.outputs.path }}
PUBLISH_TARBALL_PATH: ${{ steps.preflight_provenance.outputs.tarball_path }}
run: |
set -euo pipefail
publish_target="${PUBLISH_TARBALL_PATH}"
@ -738,3 +780,54 @@ jobs:
publish_target="./${publish_target}"
fi
bash scripts/openclaw-npm-publish.sh --publish "${publish_target}"
- name: Verify extended-stable registry readback
id: extended_stable_readback
if: ${{ success() && inputs.npm_dist_tag == 'extended-stable' }}
env:
EXPECTED_VERSION: ${{ inputs.tag }}
run: node scripts/openclaw-npm-extended-stable-release.mjs verify-readback
- name: Summarize extended-stable npm publication
if: ${{ always() && inputs.npm_dist_tag == 'extended-stable' }}
env:
BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_SHA: ${{ github.sha }}
EXTENDED_STABLE_BRANCH: ${{ github.ref_name }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
TARBALL_NAME: ${{ steps.preflight_provenance.outputs.tarball_name }}
TARBALL_SHA256: ${{ steps.preflight_provenance.outputs.tarball_sha256 }}
SELECTOR_CAPTURE_OUTCOME: ${{ steps.previous_extended_stable.outcome }}
PREVIOUS_EXTENDED_STABLE: ${{ steps.previous_extended_stable.outputs.previous }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
EXACT_READBACK: ${{ steps.extended_stable_readback.outputs.exact_version }}
SELECTOR_READBACK: ${{ steps.extended_stable_readback.outputs.extended_stable_selector }}
READBACK_OUTCOME: ${{ steps.extended_stable_readback.outcome }}
run: |
set -euo pipefail
release_version="${RELEASE_TAG#v}"
{
echo "## npm extended-stable publication"
echo "- Package: openclaw@${release_version}"
echo "- Extended-stable guard bypass: ${BYPASS_EXTENDED_STABLE_GUARD}"
echo "- Extended-stable branch: ${EXTENDED_STABLE_BRANCH}"
echo "- Release tag: ${RELEASE_TAG}"
echo "- Release SHA: ${RELEASE_SHA}"
echo "- npm preflight run: ${PREFLIGHT_RUN_ID}"
echo "- Full Release Validation run: ${FULL_RELEASE_VALIDATION_RUN_ID}"
echo "- Tarball: ${TARBALL_NAME:-unavailable}"
echo "- Tarball SHA-256: ${TARBALL_SHA256:-unavailable}"
echo "- Previous openclaw@extended-stable: ${PREVIOUS_EXTENDED_STABLE:-unavailable}"
echo "- npm publish outcome: ${PUBLISH_OUTCOME}"
echo "- Exact-version readback: ${EXACT_READBACK:-unavailable}"
echo "- Extended-stable selector readback: ${SELECTOR_READBACK:-unavailable}"
echo "- Registry readback outcome: ${READBACK_OUTCOME}"
if [[ "$SELECTOR_CAPTURE_OUTCOME" != "success" ]]; then
echo "- Selector capture failed; npm publish did not run and no repair is required because npm was unchanged."
else
repair_command="$(EXPECTED_VERSION="$RELEASE_TAG" node scripts/openclaw-npm-extended-stable-release.mjs repair-command)"
echo "- Repair command: \`${repair_command}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"

View file

@ -137,8 +137,8 @@ jobs:
tideclaw_alpha_check=true
fi
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/extended-stable/[0-9]{4}\.([1-9]|1[0-2])\.33$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, extended-stable/YYYY.M.33, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
exit 1
fi

View file

@ -131,6 +131,15 @@ gh workflow run ci.yml --ref main -f target_ref=<branch-or-sha> -f include_andro
gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
```
The monthly npm-only extended-stable path is the exception: dispatch both `OpenClaw NPM
Release` preflight and `Full Release Validation` from the exact
`extended-stable/YYYY.M.33` branch, preserve their run IDs, and pass both IDs to the
direct npm publish run. See [Monthly npm-only extended-stable
publication](/reference/RELEASING#monthly-npm-only-extended-stable-publication) for
the commands, exact identity requirements, registry readback, and selector
repair procedure. This path does not dispatch plugin, macOS, Windows, GitHub
Release, private dist-tag, or other platform publication.
## Runners
| Runner | Jobs |

View file

@ -8058,7 +8058,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: Version naming
- H2: Release cadence
- H2: Release operator checklist
- H2: Monthly npm-only extended-stable publication
- H2: Regular release operator checklist
- H2: Stable main closeout
- H2: Release preflight
- H2: Release test boxes
@ -8066,9 +8067,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Docker
- H3: QA Lab
- H3: Package
- H2: Release publish automation
- H2: Regular release publish automation
- H2: NPM workflow inputs
- H2: Stable npm release sequence
- H2: Regular beta/latest stable release sequence
- H2: Public references
- H2: Related

View file

@ -7,17 +7,25 @@ read_when:
- Looking for version naming and cadence
---
OpenClaw has three public release lanes:
OpenClaw currently exposes three user-facing update channels:
- stable: tagged releases that publish to npm `beta` by default, or to npm `latest` when explicitly requested
- stable: the existing promoted release channel, which still resolves through
npm `latest` until the separate CLI/channel milestone lands
- beta: prerelease tags that publish to npm `beta`
- dev: the moving head of `main`
Separately, release operators can publish the trailing completed month's core
package to npm `extended-stable`, beginning at patch `33`. The current-month regular
final line continues on npm `latest`; this operator-side publication split does
not by itself change CLI update-channel resolution.
## Version naming
- Stable release version: `YYYY.M.PATCH`
- Monthly npm extended-stable release version: `YYYY.M.PATCH`, with `PATCH >= 33`
- Git tag: `vYYYY.M.PATCH`
- Stable correction release version: `YYYY.M.PATCH-N`
- Daily/regular final release version: `YYYY.M.PATCH`, with `PATCH < 33`
- Git tag: `vYYYY.M.PATCH`
- Regular fallback correction release version: `YYYY.M.PATCH-N`
- Git tag: `vYYYY.M.PATCH-N`
- Beta prerelease version: `YYYY.M.PATCH-beta.N`
- Git tag: `vYYYY.M.PATCH-beta.N`
@ -39,15 +47,17 @@ OpenClaw has three public release lanes:
transition, June 2026 release trains must use patch `5` or higher. Do not
publish new June 2026 stable or beta trains as `2026.6.2`, `2026.6.3`, or
`2026.6.4`.
- After stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even
- After regular final `2026.6.5`, the next new beta train is
`2026.6.6-beta.1`, even
if automated alpha-only tags with higher patch numbers already exist.
- `latest` means the current promoted stable npm release
- `latest` continues to follow the current regular/daily npm line
- `beta` means the current beta install target
- Stable and stable correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later
- Every stable OpenClaw release ships the npm package, macOS app, and signed
Windows Hub installers together; beta releases normally validate and publish
the npm/package path first, with native app build/sign/notarize/promote
reserved for stable unless explicitly requested
- `extended-stable` means the supported trailing-month npm package, beginning at patch
`33`; patch `34` and later are maintenance releases on that monthly line
- The dedicated monthly extended-stable path publishes only the core npm package. It
does not publish plugins, macOS or Windows artifacts, a GitHub Release,
private-repository dist-tags, Docker images, mobile artifacts, or website
downloads.
## Release cadence
@ -61,7 +71,79 @@ OpenClaw has three public release lanes:
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
## Release operator checklist
## Monthly npm-only extended-stable publication
This is a dedicated exception to the regular release procedure below. For a
completed month `YYYY.M`, create `extended-stable/YYYY.M.33`; publish `vYYYY.M.33` and
later maintenance patches from that same branch. The release tag, branch tip,
checkout, package version, npm preflight, and Full Release Validation run must
all identify the same commit. Protected `main` must already contain a strictly
later calendar month's final version below patch `33`; maintenance patches stay
eligible after `main` advances by more than one month.
Run the npm preflight and Full Release Validation from the exact extended-stable branch,
then save both run IDs:
```bash
gh workflow run openclaw-npm-release.yml \
--ref extended-stable/YYYY.M.33 \
-f tag=vYYYY.M.P \
-f preflight_only=true \
-f npm_dist_tag=extended-stable
gh workflow run full-release-validation.yml \
--ref extended-stable/YYYY.M.33 \
-f ref=extended-stable/YYYY.M.33 \
-f release_profile=stable
```
`release_profile=stable` is the existing validation-depth profile; it is
separate from the npm `extended-stable` dist-tag and is intentionally unchanged.
After both runs succeed and the npm release environment is ready, promote the
exact preflight tarball. Patch `P` must be `33` or greater:
```bash
gh workflow run openclaw-npm-release.yml \
--ref extended-stable/YYYY.M.33 \
-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>
```
For a fork or non-production rehearsal that intentionally cannot satisfy the
monthly `.33` or protected-`main` month policy, add
`-f bypass_extended_stable_guard=true` to both npm preflight and publish dispatches. The
default is `false`. The bypass is accepted only with `npm_dist_tag=extended-stable` and
is recorded in the workflow summary. It does not bypass the canonical
`extended-stable/YYYY.M.33` workflow ref, branch-tip/tag/checkout equality, final-tag
syntax, package/tag version equality, referenced run and manifest identity,
tarball provenance, environment approval, registry readback, or selector
repair evidence.
The publish workflow verifies the referenced run identities, the prepared
tarball digest, and both npm registry selectors. Independently confirm the
result after the workflow succeeds:
```bash
npm view openclaw@YYYY.M.P version --userconfig "$(mktemp)"
npm view openclaw@extended-stable version --userconfig "$(mktemp)"
```
Both commands must return `YYYY.M.P`. If publish succeeds but selector
readback fails, do not republish the immutable package version. Use the single
`npm dist-tag add openclaw@YYYY.M.P extended-stable` repair command printed in
the failed workflow's always-run summary, then repeat both independent
readbacks. Rollback to the prior selector is a separate operator decision, not
the readback repair path.
The regular checklist below continues to own beta, `latest`, GitHub Release,
plugins, macOS, Windows, and other platform publication. Do not run those steps
for this npm-only extended-stable path.
## Regular release operator checklist
This checklist is the public shape of the release flow. Private credentials,
signing, notarization, dist-tag recovery, and emergency rollback details stay in
@ -746,9 +828,11 @@ For package-candidate Telegram proof, enable `telegram_mode=mock-openai` or
resolved `package-under-test` tarball into the Telegram lane; the standalone
Telegram workflow still accepts a published npm spec for post-publish checks.
## Release publish automation
## Regular release publish automation
`OpenClaw Release Publish` is the normal mutating publish entrypoint. It
For beta, `latest`, plugin, GitHub Release, and platform publication,
`OpenClaw Release Publish` is the normal mutating entrypoint. The monthly
`.33+` npm-only extended-stable path does not use this orchestrator. The regular workflow
orchestrates the trusted-publisher workflows in the order the release needs:
1. Check out the release tag and resolve its commit SHA.
@ -821,7 +905,15 @@ package cannot ship without every publishable official plugin, including
real publish path
- `preflight_run_id`: required on the real publish path so the workflow reuses
the prepared tarball from the successful preflight run
- `npm_dist_tag`: npm target tag for the publish path; defaults to `beta`
- `full_release_validation_run_id`: required for real monthly extended-stable and regular
non-beta publication so the workflow authenticates the exact validation run
- `npm_dist_tag`: npm target tag for the publish path; accepts `alpha`, `beta`,
`latest`, or `extended-stable` and defaults to `beta`. Final patch `33` and later must
use `extended-stable`; by default, `extended-stable` rejects earlier patches, and it always
rejects non-final tags.
- `bypass_extended_stable_guard`: testing-only boolean, default `false`; with
`npm_dist_tag=extended-stable`, bypasses monthly extended-stable eligibility while preserving
release identity, artifact, approval, and readback checks.
`OpenClaw Release Publish` accepts these operator-controlled inputs:
@ -857,7 +949,9 @@ package cannot ship without every publishable official plugin, including
Rules:
- Stable and correction tags may publish to either `beta` or `latest`
- Regular final and correction versions below patch `33` may publish to either
`beta` or `latest`. Final versions at patch `33` or above must publish to
`extended-stable`, and correction-suffix versions at that boundary are rejected.
- Beta prerelease tags may publish only to `beta`
- For `OpenClaw NPM Release`, full commit SHA input is allowed only when
`preflight_only=true`
@ -866,9 +960,13 @@ Rules:
- The real publish path must use the same `npm_dist_tag` used during preflight;
the workflow verifies that metadata before publish continues
## Stable npm release sequence
## Regular beta/latest stable release sequence
When cutting a stable npm release:
This legacy sequence is for the regular orchestrated release that also owns
plugins, GitHub Release, Windows, and other platform work. It is not the
monthly `.33+` npm-only extended-stable path documented at the top of this page.
When cutting a regular orchestrated stable release:
1. Run `OpenClaw NPM Release` with `preflight_only=true`
- Before a tag exists, you may use the current full workflow-branch commit

View file

@ -0,0 +1,476 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs";
const SUPPORTED_DIST_TAGS = new Set(["alpha", "beta", "latest", "extended-stable"]);
export function parseExtendedStableGuardBypass(value = "") {
if (value === "" || value === "false") {
return false;
}
if (value === "true") {
return true;
}
throw new Error(`BYPASS_EXTENDED_STABLE_GUARD must be "true" or "false"; got "${value}".`);
}
function requireExtendedStableBypassTag(npmDistTag, bypassExtendedStableGuard) {
if (bypassExtendedStableGuard && npmDistTag !== "extended-stable") {
throw new Error(
"BYPASS_EXTENDED_STABLE_GUARD may only be used with the extended-stable npm dist-tag.",
);
}
}
export function validateNpmPublishBoundary(
packageVersion,
npmDistTag,
{ bypassExtendedStableGuard = false } = {},
) {
if (!SUPPORTED_DIST_TAGS.has(npmDistTag)) {
throw new Error(`Unsupported npm dist-tag "${npmDistTag}".`);
}
requireExtendedStableBypassTag(npmDistTag, bypassExtendedStableGuard);
const parsed = parseReleaseVersion(packageVersion);
if (parsed === null) {
throw new Error(`Unsupported release version "${packageVersion}".`);
}
if (parsed.channel === "alpha") {
if (npmDistTag !== "alpha") {
throw new Error("Alpha prereleases must publish to the alpha npm dist-tag.");
}
return parsed;
}
if (parsed.channel === "beta") {
if (npmDistTag !== "beta") {
throw new Error("Beta prereleases must publish to the beta npm dist-tag.");
}
return parsed;
}
if (npmDistTag === "extended-stable") {
if (parsed.correctionNumber !== undefined) {
throw new Error("Extended-stable npm publication does not allow correction suffixes.");
}
if (!bypassExtendedStableGuard && parsed.patch < 33) {
throw new Error("Extended-stable npm publication requires release patch 33 or above.");
}
return parsed;
}
if (parsed.patch >= 33) {
throw new Error(
`Final or correction release patch 33 and above must publish to the extended-stable npm dist-tag; got ${npmDistTag}.`,
);
}
return parsed;
}
export function validateExtendedStableNpmReleaseRequest(request) {
const bypassExtendedStableGuard = request.bypassExtendedStableGuard ?? false;
requireExtendedStableBypassTag(request.npmDistTag, bypassExtendedStableGuard);
const taggedVersion = request.releaseTag.startsWith("v")
? parseReleaseVersion(request.releaseTag.slice(1))
: null;
if (request.npmDistTag !== "extended-stable") {
if (taggedVersion !== null) {
validateNpmPublishBoundary(taggedVersion.version, request.npmDistTag, {
bypassExtendedStableGuard,
});
} else if (!SUPPORTED_DIST_TAGS.has(request.npmDistTag)) {
throw new Error(`Unsupported npm dist-tag "${request.npmDistTag}".`);
}
return { extendedStable: false };
}
if (
taggedVersion === null ||
request.releaseTag !== `v${taggedVersion.version}` ||
taggedVersion.channel !== "stable"
) {
throw new Error(
"Extended-stable npm publication requires an exact final vYYYY.M.P release tag.",
);
}
validateNpmPublishBoundary(taggedVersion.version, request.npmDistTag, {
bypassExtendedStableGuard,
});
const releaseVersion = taggedVersion.version;
const extendedStableBranch = `extended-stable/${taggedVersion.year}.${taggedVersion.month}.33`;
const expectedWorkflowRef = `refs/heads/${extendedStableBranch}`;
if (request.npmWorkflowRef !== expectedWorkflowRef) {
throw new Error(
`Extended-stable npm workflow ref mismatch: expected ${expectedWorkflowRef}, got ${request.npmWorkflowRef}.`,
);
}
if (request.packageVersion !== releaseVersion) {
throw new Error(
`Extended-stable npm package version mismatch: expected ${releaseVersion}, got ${request.packageVersion}.`,
);
}
const shaValues = [request.checkoutSha, request.tagSha, request.extendedStableBranchSha];
if (shaValues.some((sha) => !/^[0-9a-f]{40}$/iu.test(sha))) {
throw new Error("Extended-stable npm release identity requires full 40-character Git SHAs.");
}
if (new Set(shaValues.map((sha) => sha.toLowerCase())).size !== 1) {
throw new Error("Extended-stable npm checkout, tag, and branch tip SHAs must match exactly.");
}
if (bypassExtendedStableGuard) {
return {
extendedStable: true,
releaseVersion,
extendedStableBranch,
bypassExtendedStableGuard: true,
};
}
const mainVersion = parseReleaseVersion(request.mainPackageVersion);
if (
mainVersion === null ||
mainVersion.channel !== "stable" ||
mainVersion.correctionNumber !== undefined
) {
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;
if (mainCalendarMonth <= releaseCalendarMonth) {
throw new Error(
`Protected main must be in a later calendar month than ${taggedVersion.year}.${taggedVersion.month}; got ${request.mainPackageVersion}.`,
);
}
if (mainVersion.patch >= 33) {
throw new Error("Protected main must remain on a daily patch below 33.");
}
return { extendedStable: true, releaseVersion, extendedStableBranch };
}
export function validateExtendedStableRunIdentity({
run,
kind,
npmDistTag,
expectedBranch,
expectedSha,
}) {
const expectedWorkflowName =
kind === "preflight" ? "OpenClaw NPM Release" : "Full Release Validation";
const checks = [
["workflowName", expectedWorkflowName],
["event", "workflow_dispatch"],
...(kind === "validation" ? [["status", "completed"]] : []),
["conclusion", "success"],
];
for (const [key, expected] of checks) {
if (run[key] !== expected) {
throw new Error(
`Referenced ${kind} run must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`,
);
}
}
if (
npmDistTag === "extended-stable" &&
(run.headBranch !== expectedBranch || run.headSha !== expectedSha)
) {
throw new Error(
`Referenced extended-stable ${kind} run must have headBranch=${expectedBranch} and headSha=${expectedSha}; got ${run.headBranch ?? "<missing>"} and ${run.headSha ?? "<missing>"}.`,
);
}
return run;
}
export function validateFullReleaseValidationManifest({
manifest,
npmDistTag,
expectedWorkflowRef,
expectedSha,
}) {
if (manifest.workflowName !== "Full Release Validation") {
throw new Error(
`Full release validation manifest workflow mismatch: ${manifest.workflowName ?? "<missing>"}.`,
);
}
if (manifest.targetSha !== expectedSha) {
throw new Error(
`Full release validation target SHA mismatch: expected ${expectedSha}, got ${manifest.targetSha ?? "<missing>"}.`,
);
}
if (npmDistTag === "extended-stable" && manifest.workflowRef !== expectedWorkflowRef) {
throw new Error(
`Full release validation workflow ref mismatch: expected ${expectedWorkflowRef}, got ${manifest.workflowRef ?? "<missing>"}.`,
);
}
return manifest;
}
export function parsePriorExtendedStableSelector(stdout) {
let tags;
try {
tags = JSON.parse(stdout);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`npm dist-tags query returned invalid JSON: ${message}`, {
cause: error,
});
}
if (tags === null || typeof tags !== "object" || Array.isArray(tags)) {
throw new Error("npm dist-tags query did not return a JSON object.");
}
if (!Object.hasOwn(tags, "extended-stable")) {
return "absent";
}
if (typeof tags["extended-stable"] !== "string" || tags["extended-stable"].trim() === "") {
throw new Error("npm extended-stable dist-tag was not a non-empty version string.");
}
return tags["extended-stable"];
}
export function capturePriorExtendedStableSelector({ query }) {
const result = query();
if (result.status !== 0) {
throw new Error(`npm dist-tags query failed with exit code ${result.status ?? "unknown"}.`);
}
return parsePriorExtendedStableSelector(result.stdout);
}
export async function verifyExtendedStableRegistryReadback({
expectedVersion,
query,
sleep,
attempts = 12,
delayMs = 10_000,
}) {
let exactVersion = "missing";
let extendedStableSelector = "missing";
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const exactResult = await query(`openclaw@${expectedVersion}`);
const extendedStableResult = await query("openclaw@extended-stable");
exactVersion = exactResult.status === 0 ? exactResult.stdout.trim() : "missing";
extendedStableSelector =
extendedStableResult.status === 0 ? extendedStableResult.stdout.trim() : "missing";
if (exactVersion === expectedVersion && extendedStableSelector === expectedVersion) {
return { exactVersion, extendedStableSelector, attemptsUsed: attempt };
}
if (attempt < attempts) {
await sleep(delayMs);
}
}
throw new Error(
`npm registry did not converge to openclaw@${expectedVersion} and openclaw@extended-stable=${expectedVersion} after ${attempts} attempts (exact=${exactVersion}, extended-stable=${extendedStableSelector}).`,
);
}
export function extendedStableSelectorRepairCommand(expectedVersion) {
const normalizedVersion = (expectedVersion ?? "").replace(/^v/u, "");
const parsed = parseReleaseVersion(normalizedVersion);
if (parsed === null || parsed.channel !== "stable" || parsed.correctionNumber !== undefined) {
throw new Error("Extended-stable selector repair requires an exact final YYYY.M.P version.");
}
return `npm dist-tag add openclaw@${parsed.version} extended-stable`;
}
function git(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function packageVersionAt(ref) {
return JSON.parse(git(["show", `${ref}:package.json`])).version;
}
function validateRequestFromRepository() {
const npmDistTag = process.env.RELEASE_NPM_DIST_TAG ?? "";
const releaseTag = process.env.RELEASE_TAG ?? "";
const npmWorkflowRef = process.env.NPM_WORKFLOW_REF ?? "";
const bypassExtendedStableGuard = parseExtendedStableGuardBypass(
process.env.BYPASS_EXTENDED_STABLE_GUARD ?? "",
);
if (npmDistTag !== "extended-stable") {
return validateExtendedStableNpmReleaseRequest({
bypassExtendedStableGuard,
npmDistTag,
releaseTag,
npmWorkflowRef,
checkoutSha: "",
tagSha: "",
extendedStableBranchSha: "",
packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version,
mainPackageVersion: "",
});
}
const parsed = releaseTag.startsWith("v") ? parseReleaseVersion(releaseTag.slice(1)) : null;
if (!parsed || parsed.channel !== "stable" || parsed.correctionNumber !== undefined) {
return validateExtendedStableNpmReleaseRequest({
npmDistTag,
bypassExtendedStableGuard,
releaseTag,
npmWorkflowRef,
checkoutSha: "",
tagSha: "",
extendedStableBranchSha: "",
packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version,
mainPackageVersion: "",
});
}
const extendedStableBranch = `extended-stable/${parsed.year}.${parsed.month}.33`;
if (bypassExtendedStableGuard) {
execFileSync(
"git",
[
"fetch",
"--no-tags",
"origin",
`+refs/heads/${extendedStableBranch}:refs/remotes/origin/${extendedStableBranch}`,
],
{ stdio: "inherit" },
);
execFileSync(
"git",
["fetch", "--no-tags", "origin", `+refs/tags/${releaseTag}:refs/tags/${releaseTag}`],
{ stdio: "inherit" },
);
return validateExtendedStableNpmReleaseRequest({
npmDistTag,
bypassExtendedStableGuard,
releaseTag,
npmWorkflowRef,
checkoutSha: git(["rev-parse", "HEAD"]),
tagSha: git(["rev-parse", `${releaseTag}^{commit}`]),
extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]),
packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version,
mainPackageVersion: "",
});
}
execFileSync(
"git",
[
"fetch",
"--no-tags",
"origin",
`+refs/heads/${extendedStableBranch}:refs/remotes/origin/${extendedStableBranch}`,
"+refs/heads/main:refs/remotes/origin/main",
],
{ stdio: "inherit" },
);
execFileSync(
"git",
["fetch", "--no-tags", "origin", `+refs/tags/${releaseTag}:refs/tags/${releaseTag}`],
{ stdio: "inherit" },
);
return validateExtendedStableNpmReleaseRequest({
npmDistTag,
bypassExtendedStableGuard,
releaseTag,
npmWorkflowRef,
checkoutSha: git(["rev-parse", "HEAD"]),
tagSha: git(["rev-parse", `${releaseTag}^{commit}`]),
extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]),
packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version,
mainPackageVersion: packageVersionAt("refs/remotes/origin/main"),
});
}
function appendOutput(values) {
const output = process.env.GITHUB_OUTPUT;
if (!output) {
throw new Error("GITHUB_OUTPUT is required.");
}
appendFileSync(
output,
Object.entries(values)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
}
async function main() {
const command = process.argv[2];
if (command === "validate-request") {
const result = validateRequestFromRepository();
console.log(
result.extendedStable
? `Validated extended-stable npm release ${result.releaseVersion}.`
: "Validated regular npm release request.",
);
return;
}
if (command === "publish-plan") {
const npmDistTag = process.env.REQUESTED_PUBLISH_TAG ?? "";
const bypassExtendedStableGuard = parseExtendedStableGuardBypass(
process.env.BYPASS_EXTENDED_STABLE_GUARD ?? "",
);
const parsed = validateNpmPublishBoundary(process.env.PACKAGE_VERSION ?? "", npmDistTag, {
bypassExtendedStableGuard,
});
console.log(parsed.channel);
console.log(npmDistTag);
return;
}
if (command === "verify-run") {
const run = JSON.parse(readFileSync(0, "utf8"));
validateExtendedStableRunIdentity({
run,
kind: process.env.RUN_KIND,
npmDistTag: process.env.RELEASE_NPM_DIST_TAG,
expectedBranch: process.env.EXPECTED_EXTENDED_STABLE_BRANCH,
expectedSha: process.env.EXPECTED_RELEASE_SHA,
});
console.log(`Verified referenced ${process.env.RUN_KIND} run.`);
return;
}
if (command === "verify-manifest") {
const manifest = JSON.parse(readFileSync(process.env.MANIFEST_FILE, "utf8"));
validateFullReleaseValidationManifest({
manifest,
npmDistTag: process.env.RELEASE_NPM_DIST_TAG,
expectedWorkflowRef: process.env.EXPECTED_WORKFLOW_REF,
expectedSha: process.env.EXPECTED_RELEASE_SHA,
});
return;
}
if (command === "capture-selector") {
const previous = capturePriorExtendedStableSelector({
query: () =>
spawnSync("npm", ["view", "openclaw", "dist-tags", "--json"], { encoding: "utf8" }),
});
appendOutput({ previous });
return;
}
if (command === "verify-readback") {
const expectedVersion = (process.env.EXPECTED_VERSION ?? "").replace(/^v/u, "");
const result = await verifyExtendedStableRegistryReadback({
expectedVersion,
query: (target) => spawnSync("npm", ["view", target, "version"], { encoding: "utf8" }),
sleep: (delayMs) =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
}),
});
appendOutput({
exact_version: result.exactVersion,
extended_stable_selector: result.extendedStableSelector,
});
return;
}
if (command === "repair-command") {
console.log(extendedStableSelectorRepairCommand(process.env.EXPECTED_VERSION));
return;
}
throw new Error(`Unknown extended-stable npm release command: ${command ?? "<missing>"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`openclaw-npm-extended-stable-release: ${message}`);
process.exit(1);
}
}

View file

@ -40,25 +40,43 @@ if [[ -n "${publish_target}" && -f "${publish_target}" ]]; then
fi
package_version="$(node -p "require('./package.json').version")"
mapfile -t publish_plan < <(
if [[ -n "${publish_target}" ]]; then
if [[ ! -f "${publish_target}" ]]; then
echo "error: npm publish tarball not found: ${publish_target}" >&2
exit 2
fi
if ! tarball_package_json="$(tar -xOf "${publish_target}" package/package.json)"; then
echo "error: npm publish tarball is missing a readable package/package.json: ${publish_target}" >&2
exit 2
fi
if ! tarball_package_version="$(printf '%s' "${tarball_package_json}" | node -e '
let input = "";
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const pkg = JSON.parse(input);
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg) || typeof pkg.version !== "string" || pkg.version.trim() === "") {
throw new Error("package/package.json must contain a nonempty string version");
}
process.stdout.write(pkg.version.trim());
});
')"; then
echo "error: npm publish tarball package/package.json is malformed or has no valid version: ${publish_target}" >&2
exit 2
fi
if [[ "${tarball_package_version}" != "${package_version}" ]]; then
echo "error: npm publish tarball version mismatch: expected ${package_version}, got ${tarball_package_version}" >&2
exit 2
fi
fi
publish_plan="$(
PACKAGE_VERSION="${package_version}" REQUESTED_PUBLISH_TAG="${OPENCLAW_NPM_PUBLISH_TAG:-}" \
node --import tsx --input-type=module <<'EOF'
import { resolveNpmPublishPlan } from "./scripts/openclaw-npm-release-check.ts";
BYPASS_EXTENDED_STABLE_GUARD="${BYPASS_EXTENDED_STABLE_GUARD:-}" \
node scripts/openclaw-npm-extended-stable-release.mjs publish-plan
)"
const requestedPublishTag =
process.env.REQUESTED_PUBLISH_TAG === "latest"
? "latest"
: process.env.REQUESTED_PUBLISH_TAG === "alpha"
? "alpha"
: "beta";
const plan = resolveNpmPublishPlan(process.env.PACKAGE_VERSION ?? "", undefined, requestedPublishTag);
console.log(plan.channel);
console.log(plan.publishTag);
EOF
)
release_channel="${publish_plan[0]}"
publish_tag="${publish_plan[1]}"
release_channel="${publish_plan%%$'\n'*}"
publish_tag="${publish_plan#*$'\n'}"
publish_cmd=(npm publish)
if [[ -n "${publish_target}" ]]; then
publish_cmd+=("${publish_target}")

View file

@ -0,0 +1,110 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const fullValidationPath = ".github/workflows/full-release-validation.yml";
const releaseChecksPath = ".github/workflows/openclaw-release-checks.yml";
type Step = { name?: string; run?: string };
type Job = { steps?: Step[] };
type Workflow = { jobs?: Record<string, Job> };
function workflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
}
function stepRun(path: string, jobName: string, stepName: string): string {
const job = workflow(path).jobs?.[jobName];
const step = job?.steps?.find((candidate) => candidate.name === stepName);
if (!step?.run) {
throw new Error(`Missing workflow step: ${jobName} / ${stepName}`);
}
return step.run;
}
function runReleaseChecksTrustedRefGuard(workflowRef: string): ReturnType<typeof spawnSync> {
const guard = stepRun(
releaseChecksPath,
"resolve_target",
"Require trusted workflow ref for release checks",
);
return spawnSync("bash", ["-euo", "pipefail", "-c", guard], {
encoding: "utf8",
env: {
...process.env,
RELEASE_REF: "extended-stable/2026.6.33",
WORKFLOW_REF: workflowRef,
},
});
}
describe("extended-stable Full Release Validation workflow", () => {
it("lets the exact extended-stable branch reach every child at the target SHA", () => {
const fullValidation = readFileSync(fullValidationPath, "utf8");
const childDispatches = [
{
job: "normal_ci",
step: "Dispatch and monitor CI",
workflow: "ci.yml",
target: '-f target_ref="$TARGET_SHA"',
},
{
job: "plugin_prerelease",
step: "Dispatch and monitor plugin prerelease",
workflow: "plugin-prerelease.yml",
target: '-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA"',
},
{
job: "release_checks",
step: "Dispatch and monitor release checks",
workflow: "openclaw-release-checks.yml",
target: '-f expected_sha="$TARGET_SHA"',
},
{
job: "performance",
step: "Dispatch and monitor OpenClaw Performance",
workflow: "openclaw-performance.yml",
target: '-f target_ref="$TARGET_SHA"',
},
];
for (const child of childDispatches) {
const run = stepRun(fullValidationPath, child.job, child.step);
expect(run).toContain(child.workflow);
expect(run).toContain('--ref "$CHILD_WORKFLOW_REF"');
expect(run).toContain(child.target);
}
expect(fullValidation).toContain(
'"$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\\.([1-9]|1[0-2])\\.33$',
);
expect(fullValidation).toContain(
"Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA",
);
});
it("accepts only the exact extended-stable/YYYY.M.33 workflow-ref shape", () => {
for (const valid of [
"refs/heads/extended-stable/2026.6.33",
"refs/heads/extended-stable/2026.12.33",
]) {
const result = runReleaseChecksTrustedRefGuard(valid);
expect(result.status, result.stderr).toBe(0);
}
for (const invalid of [
"refs/heads/extended-stable/2026.0.33",
"refs/heads/extended-stable/2026.01.33",
"refs/heads/extended-stable/2026.13.33",
"refs/heads/extended-stable/2026.6.32",
"refs/heads/extended-stable/2026.6.34",
"refs/heads/extended-stable/2026.6.33/extra",
"refs/heads/extended-stable/not-a-release",
]) {
const result = runReleaseChecksTrustedRefGuard(invalid);
expect(result.status, invalid).not.toBe(0);
expect(result.stderr).toContain("extended-stable/YYYY.M.33");
}
});
});

View file

@ -0,0 +1,405 @@
import { spawnSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import {
capturePriorExtendedStableSelector,
extendedStableSelectorRepairCommand,
parseExtendedStableGuardBypass,
parsePriorExtendedStableSelector,
validateFullReleaseValidationManifest,
validateNpmPublishBoundary,
validateExtendedStableNpmReleaseRequest,
validateExtendedStableRunIdentity,
verifyExtendedStableRegistryReadback,
} from "../../scripts/openclaw-npm-extended-stable-release.mjs";
const sha = "a".repeat(40);
const branch = "extended-stable/2026.6.33";
describe("npm extended-stable publication boundary", () => {
it("parses only explicit boolean extended-stable guard values", () => {
expect(parseExtendedStableGuardBypass()).toBe(false);
expect(parseExtendedStableGuardBypass("")).toBe(false);
expect(parseExtendedStableGuardBypass("false")).toBe(false);
expect(parseExtendedStableGuardBypass("true")).toBe(true);
expect(() => parseExtendedStableGuardBypass("1")).toThrow(/must be "true" or "false"/u);
});
it.each([
["2026.6.11-alpha.1", "alpha"],
["2026.6.11-beta.1", "beta"],
["2026.6.11", "alpha"],
["2026.6.11", "beta"],
["2026.6.11", "latest"],
["2026.6.11-1", "alpha"],
["2026.6.11-1", "beta"],
["2026.6.11-1", "latest"],
["2026.6.33", "extended-stable"],
["2026.6.34", "extended-stable"],
])("accepts %s on %s", (version, distTag) => {
expect(() => validateNpmPublishBoundary(version, distTag)).not.toThrow();
});
it.each([
["2026.6.11", "extended-stable"],
["2026.6.11-alpha.1", "beta"],
["2026.6.11-alpha.1", "extended-stable"],
["2026.6.11-beta.1", "latest"],
["2026.6.11-beta.1", "extended-stable"],
["2026.6.33", "alpha"],
["2026.6.33", "beta"],
["2026.6.33", "latest"],
["2026.6.33-1", "alpha"],
["2026.6.33-1", "beta"],
["2026.6.33-1", "latest"],
["2026.6.33-1", "extended-stable"],
["2026.6.33", "stable"],
["2026.6.33", "nightly"],
])("rejects %s on %s", (version, distTag) => {
expect(() => validateNpmPublishBoundary(version, distTag)).toThrow();
});
it("prints exactly channel then publish tag from the dependency-free CLI", () => {
const result = spawnSync(
process.execPath,
["scripts/openclaw-npm-extended-stable-release.mjs", "publish-plan"],
{
encoding: "utf8",
env: {
...process.env,
PACKAGE_VERSION: "2026.6.33",
REQUESTED_PUBLISH_TAG: "extended-stable",
},
},
);
expect(result.status).toBe(0);
expect(result.stdout).toBe("stable\nextended-stable\n");
expect(result.stderr).toBe("");
});
it("allows a pre-.33 final extended-stable version only with the explicit bypass", () => {
expect(() =>
validateNpmPublishBoundary("2026.6.11", "extended-stable", {
bypassExtendedStableGuard: true,
}),
).not.toThrow();
expect(() => validateNpmPublishBoundary("2026.6.11", "extended-stable")).toThrow(
/patch 33 or above/u,
);
});
it.each(["alpha", "beta", "latest"])(
"rejects extended-stable guard bypass with the %s dist-tag",
(distTag) => {
expect(() =>
validateNpmPublishBoundary("2026.6.11", distTag, {
bypassExtendedStableGuard: true,
}),
).toThrow(/only be used with the extended-stable npm dist-tag/u);
},
);
it("preserves the unknown dist-tag rejection when bypass is requested", () => {
expect(() =>
validateNpmPublishBoundary("2026.6.11", "nightly", {
bypassExtendedStableGuard: true,
}),
).toThrow('Unsupported npm dist-tag "nightly"');
});
it.each([
["malformed bypass", "extended-stable", "sometimes", /must be "true" or "false"/u],
[
"non-extended-stable bypass",
"beta",
"true",
/only be used with the extended-stable npm dist-tag/u,
],
])("rejects %s in the dependency-free CLI", (_label, distTag, bypass, error) => {
const result = spawnSync(
process.execPath,
["scripts/openclaw-npm-extended-stable-release.mjs", "publish-plan"],
{
encoding: "utf8",
env: {
...process.env,
BYPASS_EXTENDED_STABLE_GUARD: bypass,
PACKAGE_VERSION: "2026.6.11",
REQUESTED_PUBLISH_TAG: distTag,
},
},
);
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(error);
});
});
describe("extended-stable npm release request", () => {
const valid = {
npmDistTag: "extended-stable",
releaseTag: "v2026.6.33",
npmWorkflowRef: "refs/heads/extended-stable/2026.6.33",
checkoutSha: sha,
tagSha: sha,
extendedStableBranchSha: sha,
packageVersion: "2026.6.33",
mainPackageVersion: "2026.7.2",
};
it("accepts .33, later patches, and any later protected-main calendar month", () => {
expect(validateExtendedStableNpmReleaseRequest(valid)).toEqual({
extendedStable: true,
releaseVersion: "2026.6.33",
extendedStableBranch: "extended-stable/2026.6.33",
});
expect(
validateExtendedStableNpmReleaseRequest({
...valid,
releaseTag: "v2026.6.34",
packageVersion: "2026.6.34",
}),
).toMatchObject({ extendedStable: true, releaseVersion: "2026.6.34" });
expect(
validateExtendedStableNpmReleaseRequest({
...valid,
releaseTag: "v2026.12.33",
npmWorkflowRef: "refs/heads/extended-stable/2026.12.33",
packageVersion: "2026.12.33",
mainPackageVersion: "2027.1.1",
}),
).toMatchObject({
extendedStable: true,
extendedStableBranch: "extended-stable/2026.12.33",
});
expect(() =>
validateExtendedStableNpmReleaseRequest({ ...valid, mainPackageVersion: "2026.8.1" }),
).not.toThrow();
expect(() =>
validateExtendedStableNpmReleaseRequest({ ...valid, mainPackageVersion: "2027.1.1" }),
).not.toThrow();
expect(() =>
validateExtendedStableNpmReleaseRequest({ ...valid, mainPackageVersion: "2028.12.32" }),
).not.toThrow();
});
it.each([
["patch below 33", { releaseTag: "v2026.6.32", packageVersion: "2026.6.32" }],
["beta prerelease", { releaseTag: "v2026.6.33-beta.1", packageVersion: "2026.6.33-beta.1" }],
["alpha prerelease", { releaseTag: "v2026.6.33-alpha.1", packageVersion: "2026.6.33-alpha.1" }],
["correction suffix", { releaseTag: "v2026.6.33-1", packageVersion: "2026.6.33-1" }],
["wrong branch", { npmWorkflowRef: "refs/heads/extended-stable/2026.6.34" }],
["checkout mismatch", { checkoutSha: "b".repeat(40) }],
["tag mismatch", { tagSha: "b".repeat(40) }],
["branch tip mismatch", { extendedStableBranchSha: "b".repeat(40) }],
["package mismatch", { packageVersion: "2026.6.34" }],
["main same month", { mainPackageVersion: "2026.6.1" }],
["main earlier month", { mainPackageVersion: "2026.5.32" }],
["main earlier year", { mainPackageVersion: "2025.12.32" }],
["main patch at monthly boundary", { mainPackageVersion: "2026.7.33" }],
])("rejects %s", (_label, changes) => {
expect(() => validateExtendedStableNpmReleaseRequest({ ...valid, ...changes })).toThrow();
});
it("preserves SHA-only regular preflight requests", () => {
expect(
validateExtendedStableNpmReleaseRequest({
...valid,
npmDistTag: "beta",
releaseTag: sha,
}),
).toEqual({ extendedStable: false });
});
it("bypasses patch and protected-main policy while preserving canonical branch identity", () => {
const bypassed = {
...valid,
bypassExtendedStableGuard: true,
releaseTag: "v2026.6.11",
packageVersion: "2026.6.11",
mainPackageVersion: "",
};
expect(validateExtendedStableNpmReleaseRequest(bypassed)).toEqual({
extendedStable: true,
releaseVersion: "2026.6.11",
extendedStableBranch: "extended-stable/2026.6.33",
bypassExtendedStableGuard: true,
});
expect(() =>
validateExtendedStableNpmReleaseRequest({ ...bypassed, packageVersion: "2026.6.12" }),
).toThrow(/package version mismatch/u);
expect(() =>
validateExtendedStableNpmReleaseRequest({
...bypassed,
npmWorkflowRef: "refs/heads/dev/extended-stable-publish-test",
}),
).toThrow(/workflow ref mismatch/u);
expect(() =>
validateExtendedStableNpmReleaseRequest({
...bypassed,
extendedStableBranchSha: "b".repeat(40),
}),
).toThrow(/branch tip SHAs must match/u);
});
it("rejects bypass on a regular npm release request", () => {
expect(() =>
validateExtendedStableNpmReleaseRequest({
...valid,
bypassExtendedStableGuard: true,
npmDistTag: "beta",
releaseTag: sha,
}),
).toThrow(/only be used with the extended-stable npm dist-tag/u);
});
});
describe("extended-stable npm run identity", () => {
const validPreflight = {
workflowName: "OpenClaw NPM Release",
event: "workflow_dispatch",
conclusion: "success",
headBranch: branch,
headSha: sha,
};
it("accepts exact extended-stable preflight and validation runs", () => {
expect(
validateExtendedStableRunIdentity({
run: validPreflight,
kind: "preflight",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
}),
).toBe(validPreflight);
expect(() =>
validateExtendedStableRunIdentity({
run: {
...validPreflight,
workflowName: "Full Release Validation",
status: "completed",
},
kind: "validation",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
}),
).not.toThrow();
});
it.each([
["wrong branch", { headBranch: "main" }],
["missing branch", { headBranch: undefined }],
["wrong SHA", { headSha: "b".repeat(40) }],
["missing SHA", { headSha: undefined }],
])("rejects %s", (_label, changes) => {
expect(() =>
validateExtendedStableRunIdentity({
run: { ...validPreflight, ...changes },
kind: "preflight",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
}),
).toThrow(/headBranch=.*headSha=/u);
});
});
describe("Full Validation manifest identity", () => {
const valid = { workflowName: "Full Release Validation", workflowRef: branch, targetSha: sha };
it("accepts the exact branch and target SHA", () => {
expect(
validateFullReleaseValidationManifest({
manifest: valid,
npmDistTag: "extended-stable",
expectedWorkflowRef: branch,
expectedSha: sha,
}),
).toBe(valid);
});
it.each([
["wrong workflow ref", { workflowRef: "main" }],
["missing workflow ref", { workflowRef: undefined }],
["wrong target SHA", { targetSha: "b".repeat(40) }],
["missing target SHA", { targetSha: undefined }],
])("rejects %s", (_label, changes) => {
expect(() =>
validateFullReleaseValidationManifest({
manifest: { ...valid, ...changes },
npmDistTag: "extended-stable",
expectedWorkflowRef: branch,
expectedSha: sha,
}),
).toThrow();
});
});
describe("extended-stable selector capture", () => {
it("distinguishes bootstrap absence from an existing selector", () => {
expect(parsePriorExtendedStableSelector('{"latest":"2026.7.1"}')).toBe("absent");
expect(parsePriorExtendedStableSelector('{"extended-stable":"2026.6.33"}')).toBe("2026.6.33");
});
it.each(["not json", "null", "[]", '"2026.6.33"'])("rejects invalid result %s", (value) => {
expect(() => parsePriorExtendedStableSelector(value)).toThrow();
});
it("rejects command failure rather than treating it as bootstrap", () => {
expect(() =>
capturePriorExtendedStableSelector({ query: () => ({ status: 1, stdout: "" }) }),
).toThrow(/query failed/u);
});
});
describe("extended-stable registry readback", () => {
it("accepts eventual convergence and sleeps 10 seconds between attempts", async () => {
let attempt = 0;
const sleep = vi.fn(async () => {});
const result = await verifyExtendedStableRegistryReadback({
expectedVersion: "2026.6.33",
query: async (target: string) => {
if (target === "openclaw@2026.6.33") {
attempt += 1;
}
return { status: 0, stdout: attempt >= 2 ? "2026.6.33\n" : "2026.6.32\n" };
},
sleep,
});
expect(result).toEqual({
exactVersion: "2026.6.33",
extendedStableSelector: "2026.6.33",
attemptsUsed: 2,
});
expect(sleep).toHaveBeenCalledOnce();
expect(sleep).toHaveBeenCalledWith(10_000);
});
it("exhausts exactly 12 dual-query attempts on mismatch or failure", async () => {
const query = vi.fn(async () => ({ status: 1, stdout: "" }));
const sleep = vi.fn(async () => {});
await expect(
verifyExtendedStableRegistryReadback({ expectedVersion: "2026.6.33", query, sleep }),
).rejects.toThrow(/after 12 attempts/u);
expect(query).toHaveBeenCalledTimes(24);
expect(sleep).toHaveBeenCalledTimes(11);
expect(sleep.mock.calls.every(([delay]) => delay === 10_000)).toBe(true);
});
});
describe("extended-stable selector repair", () => {
it("points the selector at the expected published version", () => {
expect(extendedStableSelectorRepairCommand("v2026.6.33")).toBe(
"npm dist-tag add openclaw@2026.6.33 extended-stable",
);
});
it.each([undefined, "absent", "2026.6.33-beta.1", "2026.6.33-1"])(
"rejects an invalid expected version: %s",
(expectedVersion) => {
expect(() => extendedStableSelectorRepairCommand(expectedVersion)).toThrow(
"Extended-stable selector repair requires an exact final YYYY.M.P version.",
);
},
);
});

View file

@ -0,0 +1,151 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowPath = ".github/workflows/openclaw-npm-release.yml";
type Step = { env?: Record<string, string>; id?: string; if?: string; name?: string; run?: string };
type Job = { environment?: string; steps?: Step[] };
type Workflow = {
on?: {
workflow_dispatch?: {
inputs?: {
bypass_extended_stable_guard?: { default?: boolean; type?: string };
npm_dist_tag?: { options?: string[] };
};
};
};
jobs?: Record<string, Job>;
};
function workflow(): Workflow {
return parse(readFileSync(workflowPath, "utf8")) as Workflow;
}
function step(job: Job | undefined, name: string): Step {
const found = job?.steps?.find((candidate) => candidate.name === name);
if (!found) {
throw new Error(`Missing workflow step: ${name}`);
}
return found;
}
describe("minimal npm extended-stable workflow", () => {
it("adds extended-stable without adding policy or verifier contracts", () => {
const raw = readFileSync(workflowPath, "utf8");
const parsed = workflow();
expect(parsed.on?.workflow_dispatch?.inputs?.npm_dist_tag?.options).toEqual([
"alpha",
"beta",
"latest",
"extended-stable",
]);
for (const forbidden of [
"release-policy",
"policyMode",
"release-operation-verifier",
"external_contract_revision",
"stable-lines.json",
]) {
expect(raw).not.toContain(forbidden);
}
});
it("reuses the v1 preflight tarball and guards all three extended-stable gates", () => {
const parsed = workflow();
const raw = readFileSync(workflowPath, "utf8");
expect(raw).toContain("version: 1");
expect(raw).toContain("openclaw-npm-preflight-${{ inputs.tag }}");
expect(raw.match(/openclaw-npm-extended-stable-release\.mjs validate-request/g)).toHaveLength(
3,
);
expect(step(parsed.jobs?.preflight_openclaw_npm, "Validate npm release request").run).toContain(
"openclaw-npm-extended-stable-release.mjs validate-request",
);
expect(
step(parsed.jobs?.validate_publish_request, "Validate npm release request").run,
).toContain("openclaw-npm-extended-stable-release.mjs validate-request");
expect(step(parsed.jobs?.publish_openclaw_npm, "Recheck npm release request").run).toContain(
"openclaw-npm-extended-stable-release.mjs validate-request",
);
expect(
parsed.jobs?.validate_publish_request?.steps?.map((candidate) => candidate.name),
).not.toContain("Setup Node environment");
});
it("threads an explicit, default-off extended-stable bypass through every policy gate", () => {
const parsed = workflow();
const input = parsed.on?.workflow_dispatch?.inputs?.bypass_extended_stable_guard;
expect(input).toMatchObject({ default: false, type: "boolean" });
const policySteps = [
step(parsed.jobs?.preflight_openclaw_npm, "Validate npm release request"),
step(parsed.jobs?.validate_publish_request, "Validate npm release request"),
step(parsed.jobs?.publish_openclaw_npm, "Recheck npm release request"),
step(parsed.jobs?.publish_openclaw_npm, "Publish"),
];
for (const policyStep of policySteps) {
expect(policyStep.env?.BYPASS_EXTENDED_STABLE_GUARD).toBe(
"${{ inputs.bypass_extended_stable_guard }}",
);
}
const trustedRef = step(
parsed.jobs?.validate_publish_request,
"Require trusted workflow ref for publish",
);
expect(trustedRef.env?.BYPASS_EXTENDED_STABLE_GUARD).toBeUndefined();
expect(trustedRef.run).not.toContain("BYPASS_EXTENDED_STABLE_GUARD");
expect(trustedRef.run).toContain('"${WORKFLOW_REF}" == refs/heads/extended-stable/*');
const summary = step(
parsed.jobs?.publish_openclaw_npm,
"Summarize extended-stable npm publication",
);
expect(summary.env?.BYPASS_EXTENDED_STABLE_GUARD).toBe(
"${{ inputs.bypass_extended_stable_guard }}",
);
expect(summary.run).toContain("Extended-stable guard bypass: ${BYPASS_EXTENDED_STABLE_GUARD}");
});
it("authenticates exact extended-stable run and Full Validation identities", () => {
const raw = readFileSync(workflowPath, "utf8");
expect(raw).toContain("--json workflowName,headBranch,headSha,event,conclusion,url");
expect(raw).toContain("--json workflowName,headBranch,headSha,event,status,conclusion,url");
expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(2);
expect(raw).toContain("openclaw-npm-extended-stable-release.mjs verify-manifest");
});
it("captures selector fail closed, publishes extended-stable, retries, and summarizes", () => {
const parsed = workflow();
const publish = parsed.jobs?.publish_openclaw_npm;
const capture = step(publish, "Capture previous extended-stable selector");
const readback = step(publish, "Verify extended-stable registry readback");
const summary = step(publish, "Summarize extended-stable npm publication");
expect(capture.run).toContain("openclaw-npm-extended-stable-release.mjs capture-selector");
expect(step(publish, "Publish").run).toContain("openclaw-npm-publish.sh");
expect(readback.run).toContain("openclaw-npm-extended-stable-release.mjs verify-readback");
expect(summary.if).toContain("always()");
expect(summary.run).toContain("openclaw-npm-extended-stable-release.mjs repair-command");
expect(summary.run).toContain('EXPECTED_VERSION="$RELEASE_TAG"');
expect(publish?.environment).toBe("npm-release");
});
it("publishes only the tarball path verified from the preflight manifest", () => {
const publish = workflow().jobs?.publish_openclaw_npm;
const provenance = step(publish, "Verify prepared tarball provenance");
const publishStep = step(publish, "Publish");
expect(provenance.run).toContain(
'ARTIFACT_TARBALL_PATH="preflight-tarball/$ARTIFACT_TARBALL_NAME"',
);
expect(provenance.run).toContain('echo "tarball_path=$ARTIFACT_TARBALL_PATH"');
expect(publishStep.env?.PUBLISH_TARBALL_PATH).toBe(
"${{ steps.preflight_provenance.outputs.tarball_path }}",
);
expect(publish?.steps?.map((candidate) => candidate.name)).not.toContain(
"Resolve publish tarball",
);
expect(readFileSync(workflowPath, "utf8")).not.toContain(
"find preflight-tarball -type f -name '*.tgz'",
);
});
});

View file

@ -1,6 +1,6 @@
// OpenClaw NPM Publish tests cover publish wrapper argument safety.
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@ -14,13 +14,27 @@ function makeTempDir(prefix: string): string {
return dir;
}
function runPublishWrapper(args: string[]) {
function runPublishWrapper(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync("bash", [scriptPath, ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...env },
});
}
function makePackageTarball(root: string, packageJson?: string): string {
const packageDir = path.join(root, "package");
const tarball = path.join(root, "openclaw.tgz");
mkdirSync(packageDir);
if (packageJson === undefined) {
writeFileSync(path.join(packageDir, "README.md"), "missing package metadata", "utf8");
} else {
writeFileSync(path.join(packageDir, "package.json"), packageJson, "utf8");
}
execFileSync("tar", ["-czf", tarball, "-C", root, "package"]);
return tarball;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
@ -67,4 +81,117 @@ describe("openclaw npm publish wrapper", () => {
expect(result.stdout).toBe("");
expect(result.stderr.trim()).toBe("error: unexpected npm publish argument: extra");
});
it.each(["beta", "latest"])("publishes the prepared tarball to the %s dist-tag", (distTag) => {
const tempRoot = makeTempDir("openclaw-npm-publish-");
const binDir = path.join(tempRoot, "bin");
const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version as string;
const tarball = makePackageTarball(tempRoot, JSON.stringify({ version: packageVersion }));
const npmLog = path.join(tempRoot, "npm.log");
mkdirSync(binDir);
writeFileSync(path.join(binDir, "npm"), `#!/bin/sh\nprintf '%s\\n' "$*" > "${npmLog}"\n`, {
mode: 0o755,
});
const result = runPublishWrapper(["--publish", tarball], {
OPENCLAW_NPM_PUBLISH_TAG: distTag,
PATH: `${binDir}:${process.env.PATH}`,
});
expect(result.status).toBe(0);
expect(readFileSync(npmLog, "utf8")).toContain(
`publish ${tarball} --access public --tag ${distTag} --provenance`,
);
expect(result.stdout).toContain(`Resolved publish tag: ${distTag}`);
});
it("rejects a tarball whose package version differs from the checkout", () => {
const tempRoot = makeTempDir("openclaw-npm-publish-");
const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version as string;
const tarballVersion = `${packageVersion}-mismatch`;
const tarball = makePackageTarball(tempRoot, JSON.stringify({ version: tarballVersion }));
const result = runPublishWrapper(["--publish", tarball], {
OPENCLAW_NPM_PUBLISH_TAG: "beta",
});
expect(result.status).toBe(2);
expect(result.stderr).toContain(
`npm publish tarball version mismatch: expected ${packageVersion}, got ${tarballVersion}`,
);
});
it.each([
["missing package.json", undefined, "missing a readable package/package.json"],
["malformed package.json", "{not-json", "package/package.json is malformed"],
["missing version", JSON.stringify({ name: "openclaw" }), "has no valid version"],
])("rejects a tarball with %s", (_label, packageJson, expectedError) => {
const tempRoot = makeTempDir("openclaw-npm-publish-");
const tarball = makePackageTarball(tempRoot, packageJson);
const result = runPublishWrapper(["--publish", tarball], {
OPENCLAW_NPM_PUBLISH_TAG: "beta",
});
expect(result.status).toBe(2);
expect(result.stderr).toContain(expectedError);
});
it("rejects publishing the current pre-.33 final version to extended-stable", () => {
const result = runPublishWrapper(["--publish"], {
OPENCLAW_NPM_PUBLISH_TAG: "extended-stable",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"Extended-stable npm publication requires release patch 33 or above",
);
});
it("publishes a pre-.33 final version to extended-stable with the explicit bypass", () => {
const tempRoot = makeTempDir("openclaw-npm-publish-");
const binDir = path.join(tempRoot, "bin");
const npmLog = path.join(tempRoot, "npm.log");
mkdirSync(binDir);
writeFileSync(path.join(binDir, "npm"), `#!/bin/sh\nprintf '%s\\n' "$*" > "${npmLog}"\n`, {
mode: 0o755,
});
const result = runPublishWrapper(["--publish"], {
BYPASS_EXTENDED_STABLE_GUARD: "true",
OPENCLAW_NPM_PUBLISH_TAG: "extended-stable",
PATH: `${binDir}:${process.env.PATH}`,
});
expect(result.status).toBe(0);
expect(readFileSync(npmLog, "utf8")).toContain(
"publish --access public --tag extended-stable --provenance",
);
expect(result.stdout).toContain("Resolved publish tag: extended-stable");
});
it.each([
["malformed bypass", "extended-stable", "sometimes", 'must be "true" or "false"'],
[
"non-extended-stable bypass",
"beta",
"true",
"only be used with the extended-stable npm dist-tag",
],
])("rejects %s before npm publish", (_label, distTag, bypass, expectedError) => {
const result = runPublishWrapper(["--publish"], {
BYPASS_EXTENDED_STABLE_GUARD: bypass,
OPENCLAW_NPM_PUBLISH_TAG: distTag,
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(expectedError);
});
it("rejects unknown requested dist-tags instead of falling back to beta", () => {
const result = runPublishWrapper(["--publish"], {
OPENCLAW_NPM_PUBLISH_TAG: "nightly",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Unsupported npm dist-tag "nightly"');
});
});

View file

@ -549,7 +549,7 @@ describe("package acceptance workflow", () => {
);
expect(workflow).toContain("--json status,conclusion,url,attempt,headSha,jobs");
expect(workflow).toContain(
'[[ "$CHILD_WORKFLOW_REF" == release-ci/* && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]',
'[[ ( "$CHILD_WORKFLOW_REF" == release-ci/* || "$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\\.([1-9]|1[0-2])\\.33$ ) && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]',
);
expect(workflow).toContain(
'gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"',
@ -569,7 +569,7 @@ describe("package acceptance workflow", () => {
);
expect(workflow).toContain("child run used ${head_sha}, expected ${TARGET_SHA}");
expect(workflow).toContain(
"Dispatch Full Release Validation from a ref pinned to the target SHA",
"Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA",
);
expect(workflow).toContain("| Child | Result | Minutes | Head SHA | Run |");
expect(releaseChecksWorkflow).toContain("refs/heads/release-ci/[0-9a-f]{12}-[0-9]+");