mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
c79c380358
commit
ba7b5db74c
13 changed files with 1565 additions and 77 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
153
.github/workflows/openclaw-npm-release.yml
vendored
153
.github/workflows/openclaw-npm-release.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
476
scripts/openclaw-npm-extended-stable-release.mjs
Normal file
476
scripts/openclaw-npm-extended-stable-release.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
405
test/scripts/openclaw-npm-extended-stable-release.test.ts
Normal file
405
test/scripts/openclaw-npm-extended-stable-release.test.ts
Normal 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.",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
151
test/scripts/openclaw-npm-extended-stable-workflow.test.ts
Normal file
151
test/scripts/openclaw-npm-extended-stable-workflow.test.ts
Normal 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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]+");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue