diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 16618613945..57257e7e508 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -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 diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index af851d80a59..06237d5a93a 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -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] ?? ""}.`); 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] ?? ""}.`); 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" diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index c84fd7d98d2..cef7297718f 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -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/- 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/- ref, or a Tideclaw alpha branch for alpha prereleases." >&2 exit 1 fi diff --git a/docs/ci.md b/docs/ci.md index e3a95ee530f..bc928824223 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -131,6 +131,15 @@ gh workflow run ci.yml --ref main -f target_ref= -f include_andro gh workflow run full-release-validation.yml --ref main -f ref= ``` +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 | diff --git a/docs/docs_map.md b/docs/docs_map.md index 72d641f6aff..9792881d92e 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 7f1a08a2581..e18e34b4b05 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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= \ + -f full_release_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 diff --git a/scripts/openclaw-npm-extended-stable-release.mjs b/scripts/openclaw-npm-extended-stable-release.mjs new file mode 100644 index 00000000000..0d719f492e9 --- /dev/null +++ b/scripts/openclaw-npm-extended-stable-release.mjs @@ -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] ?? ""}.`, + ); + } + } + 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 ?? ""} and ${run.headSha ?? ""}.`, + ); + } + 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 ?? ""}.`, + ); + } + if (manifest.targetSha !== expectedSha) { + throw new Error( + `Full release validation target SHA mismatch: expected ${expectedSha}, got ${manifest.targetSha ?? ""}.`, + ); + } + if (npmDistTag === "extended-stable" && manifest.workflowRef !== expectedWorkflowRef) { + throw new Error( + `Full release validation workflow ref mismatch: expected ${expectedWorkflowRef}, got ${manifest.workflowRef ?? ""}.`, + ); + } + 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 ?? ""}.`); +} + +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); + } +} diff --git a/scripts/openclaw-npm-publish.sh b/scripts/openclaw-npm-publish.sh index 95bf6d5b3ae..eb0eb4f8f27 100644 --- a/scripts/openclaw-npm-publish.sh +++ b/scripts/openclaw-npm-publish.sh @@ -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}") diff --git a/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts new file mode 100644 index 00000000000..5de1297fd60 --- /dev/null +++ b/test/scripts/openclaw-npm-extended-stable-full-validation-workflow.test.ts @@ -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 }; + +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 { + 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"); + } + }); +}); diff --git a/test/scripts/openclaw-npm-extended-stable-release.test.ts b/test/scripts/openclaw-npm-extended-stable-release.test.ts new file mode 100644 index 00000000000..36992ff8ea0 --- /dev/null +++ b/test/scripts/openclaw-npm-extended-stable-release.test.ts @@ -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.", + ); + }, + ); +}); diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts new file mode 100644 index 00000000000..e795a1ccee9 --- /dev/null +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -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; 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; +}; + +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'", + ); + }); +}); diff --git a/test/scripts/openclaw-npm-publish.test.ts b/test/scripts/openclaw-npm-publish.test.ts index db4c1d9adb8..2068bcad85d 100644 --- a/test/scripts/openclaw-npm-publish.test.ts +++ b/test/scripts/openclaw-npm-publish.test.ts @@ -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"'); + }); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index ebf78151487..a2bc68425e3 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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]+");