From ed32585895cb8a0ab4051ec64d91af4c0e38c0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:05:27 +0200 Subject: [PATCH 1/8] fix(release): pin staged publish to tested commit --- .github/workflows/npm-stage-publish.yml | 73 ++++++- .github/workflows/release-packages.yml | 13 +- .gitignore | 4 + docs/src/contributing/releasing.md | 172 ++++++++++++----- scripts/pre-tag-check.sh | 6 +- scripts/publish/npm/approve.mts | 12 +- scripts/publish/npm/bump.mts | 24 ++- scripts/publish/pipeline.mts | 244 +++++++++++++++++++++--- scripts/publish/publish.test.mts | 52 ++++- scripts/publish/release.mts | 35 +++- 10 files changed, 528 insertions(+), 107 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index d8fcf804cb..4bfe39da0e 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -19,8 +19,8 @@ name: npm stage publish # # Auth: OIDC trusted publishing only — id-token: write, NO long-lived NPM_TOKEN. # Each @perryts/* package must list this workflow + the `npm-publish` environment -# as a trusted publisher on npmjs.com (same one-time setup npm/README.md -# describes for release-packages.yml). The auth-posture gate in +# as a trusted publisher on npmjs.com (see docs/src/contributing/releasing.md). +# The auth-posture gate in # scripts/publish/auth-posture.mts refuses any long-lived token present here. # # Guardrails: dispatches for the same dist-tag are serialized (concurrency: @@ -44,6 +44,10 @@ on: description: 'Reuse an existing release-packages.yml stage-mode build run instead of dispatching a new one.' type: string default: '' + candidate-sha: + description: 'Expected release-candidate commit. Required when publish=true; protects a branch-tip race.' + type: string + default: '' permissions: contents: read @@ -57,11 +61,61 @@ concurrency: cancel-in-progress: false jobs: + # A real registry stage is a release operation even though it is not public + # yet. Require the same exact-SHA gates as the release workflow before + # spending the cross-platform build or writing staged registry entries. + release-gates: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Require full Tests + Simulator Tests on this commit + if: inputs.publish + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + EXPECTED_SHA: ${{ inputs.candidate-sha }} + run: | + set -euo pipefail + if [ -z "$EXPECTED_SHA" ]; then + echo "::error::candidate-sha is required for a real staged publish. Use the local npm run publish:stage command." >&2 + exit 1 + fi + if [ "$SHA" != "$EXPECTED_SHA" ]; then + echo "::error::Candidate branch moved: workflow is on $SHA, but the dispatcher pinned $EXPECTED_SHA." >&2 + exit 1 + fi + tests=$(gh api "/repos/$REPO/actions/workflows/test.yml/runs?head_sha=$SHA&per_page=20") + full_run="" + for run_id in $(echo "$tests" | jq -r '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id'); do + if gh api "/repos/$REPO/actions/runs/$run_id/jobs?per_page=100" \ + --jq '.jobs[] | select(.name == "full-suite-gate" and .conclusion == "success") | .name' \ + | grep -q full-suite-gate; then + full_run="$run_id" + break + fi + done + if [ -z "$full_run" ]; then + echo "::error::No successful full-tier test.yml run (full-suite-gate) exists on $SHA. Dispatch test.yml with tier=full first." >&2 + exit 1 + fi + sim_run=$(gh api "/repos/$REPO/actions/workflows/simctl-tests.yml/runs?head_sha=$SHA&per_page=20" \ + --jq '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id' \ + | head -1) + if [ -z "$sim_run" ]; then + echo "::error::No successful simctl-tests.yml run exists on $SHA. Dispatch it first." >&2 + exit 1 + fi + echo "Release gates passed on $SHA: test run $full_run, simulator run $sim_run." + # Resolve the build run to stage from: either a caller-supplied build-run-id # (re-use), or dispatch release-packages.yml in stage mode and capture its # run id. The build matrix itself lives in release-packages.yml — single # source of truth, no drift. resolve-build: + needs: release-gates runs-on: ubuntu-latest permissions: actions: write # dispatch release-packages.yml (stage mode) + watch it @@ -76,6 +130,7 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} EXISTING: ${{ inputs.build-run-id }} + REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail # The commit THIS workflow run is dispatching from — every downstream @@ -109,8 +164,9 @@ jobs: fi echo "Reusing build run $RUN_ID (verified: Release Packages, success, sha $RUN_SHA)." else - # Dispatch release-packages.yml in stage mode, pinned to OUR exact - # commit via --ref. Without this, `gh workflow run` dispatches + # Dispatch release-packages.yml in stage mode on our named branch, + # after proving that its remote tip is OUR exact commit. Without + # this, `gh workflow run` dispatches # against the repo's default branch tip at call time — which can # differ from the commit this npm-stage-publish.yml run itself # checked out (a different ref triggered it, or main advanced in @@ -120,7 +176,12 @@ jobs: # preflight job's else-branch sets MODE=stage), so no other input # is needed. DISPATCHED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) - gh workflow run release-packages.yml -R "$REPO" --ref "$CUR_SHA" + TIP_SHA=$(gh api "/repos/$REPO/git/ref/heads/$REF_NAME" --jq '.object.sha') + if [ "$TIP_SHA" != "$CUR_SHA" ]; then + echo "::error::$REF_NAME moved to $TIP_SHA, but this staging run is on $CUR_SHA — re-dispatch from a pinned candidate branch." >&2 + exit 1 + fi + gh workflow run release-packages.yml -R "$REPO" --ref "$REF_NAME" # Poll for the workflow_dispatch run we just created. Match by # event + createdAt AFTER the dispatch timestamp AND headSha == # our commit — createdAt alone could still match a concurrent @@ -128,7 +189,7 @@ jobs: # to land in the same window, which would stage the wrong commit. sleep 5 RUN_ID="" - for i in $(seq 1 20); do + for _attempt in $(seq 1 20); do RUN_ID=$(gh run list --workflow release-packages.yml -R "$REPO" \ --event workflow_dispatch --limit 5 \ --json databaseId,createdAt,headSha \ diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 33baa6cd0c..26d41ebb9c 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: cut_release: - description: "Tag-last release: gate on tests, build every leg, then create the vX.Y.Z tag (version from Cargo.toml) + GitHub Release and publish. Dispatch on a branch pinned at the release-candidate commit." + description: "Legacy build-before-tag route: gates/builds, then tags before npm. Normal releases use npm run publish:stage/approve for registry-first tag-last ordering." type: boolean default: false existing_tag: @@ -48,10 +48,10 @@ jobs: # Modes: # release release: published event (legacy tag-first path — a human # created the tag + release; still fully supported) - # cut-release workflow_dispatch with cut_release=true — tag-LAST: nothing - # is tagged or published until the gate + every build leg is - # green; create-release then makes the tag + GH release and - # the publish legs run in this same workflow run + # cut-release workflow_dispatch with cut_release=true — legacy route: + # nothing is tagged until gate + build legs are green, but + # the tag still precedes npm. The staged local pipeline is + # canonical when registry-first/tag-last ordering is required. # republish workflow_dispatch with existing_tag=vX.Y.Z — rebuild and # re-run the publish legs for an already-published release # (the old "bypass" lever, now with an explicit tag) @@ -1973,7 +1973,8 @@ jobs: # # Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). Each of the 9 # package names must be registered on npmjs.com with this repo + workflow - # as a Trusted Publisher. See npm/README.md for the one-time setup. + # as a Trusted Publisher. See docs/src/contributing/releasing.md for the + # one-time setup. # --------------------------------------------------------------------------- npm-publish: # The irreversible leg. build-cross is in `needs` (completed, not diff --git a/.gitignore b/.gitignore index f1a078cbc8..ee0230acfa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ __pycache__/ # Pinned Node oracle for the builtin-module compat matrix # (scripts/node_compat_matrix.mjs downloads + SRI-verifies + caches here). .cache/node-pin/ +# Local receipts for the staged npm release pipeline. These must survive +# between `publish:stage` and `publish:approve`, but must never make a clean +# release-candidate checkout appear dirty or be committed. +.cache/perry/publish-pipeline/ # Android Gradle: caches and build outputs are regenerable. Source under # android-build/ that we DO track: build.gradle.kts files, gradle wrapper, diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index 4c9d71462a..6290bd0f75 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -1,54 +1,115 @@ # Releasing Perry -Maintainer runbook. Release cadence: patch releases (`0.5.118 → 0.5.119`) ship -weekly-ish behind the macOS CI gate. **Major releases** — any bump of the major -or minor number (e.g. `0.5.x → 0.6.0`, and the upcoming `1.0.0`) — **must be -verified on every supported platform** before the tag is pushed. Patch releases -only require the default CI gate. +Maintainer runbook. Every release, including a patch, is gated on the exact +release-candidate commit by the full Tests workflow, Simulator Tests, and the +complete package-build matrix. A PR-tier or ordinary push-to-main run is not a +release gate. ## 1. Pre-release checklist (every release) -Run on macOS (the canonical dev host): +Start from a clean checkout that contains current `origin/main`. Do not release +from a detached HEAD or from a moving `main` branch. The staged pipeline records +the candidate branch, commit SHA, and build run, then refuses to approve or tag +if any of them changes. ```bash -# Full rebuild — runtime/stdlib/UI libs must match the compiler version. -cargo build --release - -# Core gates. +# Confirm the checkout is current and clean. +git fetch origin +git rev-list --count HEAD..origin/main # must print 0 +git status --short # must print nothing + +# Fast policy and script checks. +python3 scripts/ci_plan.py --self-test +BASE_SHA=origin/main scripts/run_lint_gates.sh +npm ci --ignore-scripts --no-audit --no-fund +npm run test:scripts +./scripts/regen_api_docs.sh +git diff --exit-code -- docs/src/api/reference.md docs/api/perry.d.ts + +# Host-runnable behavioral checks. These improve turnaround, but do not +# replace the required cross-platform CI jobs. cargo test --workspace --exclude perry-ui-ios --exclude perry-ui-tvos \ --exclude perry-ui-watchos --exclude perry-ui-gtk4 \ --exclude perry-ui-android --exclude perry-ui-windows -./run_parity_tests.sh # Perry vs node stdout parity -./scripts/run_doc_tests.sh # Compile + run every docs/examples/*.ts +./run_parity_tests.sh +./scripts/run_doc_tests.sh +``` + +Prepare the release candidate. Perry's normal merges already advance the +workspace version, so use the version at the chosen commit; do not add another +bump merely to release it. The version must exist in the source before builds +start because it is embedded in Cargo and npm artifacts. `Cargo.toml` and +`CLAUDE.md` must agree, release-note fragments must exist, and the Git tag must +not exist locally or on origin. If that version was already tagged, land a new +version bump through the normal merge process first. + +```bash +# Substitute the version already present in Cargo.toml. +VERSION=0.x.y +grep -m1 '^version' Cargo.toml +grep -F "**Current Version:** $VERSION" CLAUDE.md +find changelog.d -maxdepth 1 -type f -name '[0-9]*.md' | grep . +git ls-remote --tags origin "refs/tags/v$VERSION" # must print nothing +git switch -c "release/v$VERSION" +git push -u origin "release/v$VERSION" ``` -Then bump and tag: +Run the two exact-SHA CI gates on that pinned branch and wait for both to pass: ```bash -# Bump [workspace.package] version in Cargo.toml AND the "Current Version" -# line in CLAUDE.md (the two must move together), then add a changelog -# entry in CHANGELOG.md. -git commit -am "release: v0.x.y" -git tag v0.x.y && git push origin v0.x.y +gh workflow run test.yml --ref "release/v$VERSION" -f tier=full +gh workflow run simctl-tests.yml --ref "release/v$VERSION" ``` -The tag push runs the test workflows, but does **not** publish packages on -its own: `release-packages.yml` triggers on a **published GitHub Release** -(or a manual `workflow_dispatch`). After pushing the tag, create and publish -the GitHub Release for `v0.x.y`; that fires the cross-platform package -matrix (see [§3](#3-what-ci-does-on-the-release)). +Then use the staged pipeline: -## 2. Major-release verification (all platforms) +The local publish/approve process requires npm 11.17 or newer and a valid +`SOCKET_API_TOKEN` for the mandatory tarball scan. CI uses OIDC for staging; +do not set a long-lived npm publish token. -Before tagging a major/minor bump, these must all pass: +```bash +npm --version # must be >= 11.17.0 +# If needed: npm install -g npm@latest +``` + +Before the first nine-package release, an npm organization owner must confirm +that every name in `scripts/publish/constants.mts` exists and has +`npm-stage-publish.yml` with environment `npm-publish` configured as a Trusted +Publisher. In particular, verify the ARM64 Windows package: + +```bash +npm view @perryts/perry-win32-arm64 name +``` + +If that returns `E404`, reserve/provision the package and configure its Trusted +Publisher before continuing. The pipeline intentionally refuses a partial set. + +```bash +npm run publish:stage # CI builds all platforms, stages 9 npm packages, + # verifies sha1, and runs the mandatory Socket scan +npm run publish:status # inspect the commit/run/package receipt +npm run publish:approve # explicit 2FA promote; waits for registry liveness + # and only then creates v0.x.y + the GitHub Release +``` + +Do not run `git tag`, manually publish a GitHub Release, or use the legacy +`release-packages.yml cut_release=true` route for a normal release. That older +route creates the tag before npm publication; it does not satisfy the stricter +registry-first/tag-last contract. + +## 2. Additional major-release verification + +The automated gates above apply to every release. For a major/minor release, +also perform the product-level platform checks that are not fully represented +by the automated suites: | Platform | What to run | Runs in CI? | |---|---|---| -| **macOS** (arm64 + x86_64) | `cargo test` + `run_parity_tests.sh` + `scripts/run_doc_tests.sh` | Yes, `test.yml` (arm64 only) | -| **Linux glibc** (x86_64 + aarch64) | Same, under `xvfb-run -a` for UI; `apt install libgtk-4-dev libadwaita-1-dev xvfb` first | Partial — release build only | -| **Linux musl** (x86_64 + aarch64) | Release build via `release-packages.yml`; spot-check a compiled `hello.ts` runs on Alpine | Build only | -| **Windows** (x86_64 MSVC) | `scripts/run_doc_tests.ps1`; smoke-test `perry compile hello.ts -o hello.exe && .\hello.exe` | Build only | -| **iOS Simulator** | `perry compile --target ios-simulator examples/widget_demo.ts && xcrun simctl install booted out.app` | No (Xcode required) | +| **macOS** (arm64 + x86_64) | Smoke-test installed archives on both architectures | Builds in release matrix; full tests on macOS arm64 | +| **Linux glibc** (x86_64 + aarch64) | Smoke-test the packaged binary on the oldest supported glibc | Builds in release matrix | +| **Linux musl** (x86_64 + aarch64) | Spot-check a compiled `hello.ts` on Alpine | Builds in release matrix | +| **Windows** (x86_64 + ARM64 MSVC) | Smoke-test installed archives on both architectures | Build plus full-tier Windows checks | +| **iOS Simulator** | Exercise a representative app with `xcrun simctl` | Required `simctl-tests.yml` | | **visionOS Simulator** | `perry compile --target visionos-simulator ...`, launch in Apple Vision Pro Simulator | No (Xcode required) | | **tvOS Simulator** | `perry compile --target tvos-simulator ...`, launch in Simulator | No (Xcode required) | | **watchOS Simulator** | `perry compile --target watchos-simulator ...` — requires `rustup toolchain install nightly` + `cargo +nightly -Zbuild-std` | No (Xcode + nightly required) | @@ -56,11 +117,8 @@ Before tagging a major/minor bump, these must all pass: | **Web / WASM** | `perry compile --target web examples/wasm_ui_demo.ts`, open `out.html` in a browser | No | | **Home-screen widgets** | `perry compile --target widgetkit ... && perry publish ios` | No | -For v1.0, expect to spend half a day spinning through the four OS VMs locally. -Only the macOS doc-tests lane currently runs in `test.yml` — the Linux (gtk4) -and Windows matrix entries are disabled pending testkit fixes (see the -commented-out entries in the `doc-tests` job), so run those manually, as with -the mobile/watch/web lanes. +Record the manual results in the release issue. These checks supplement CI; +they never waive a red required workflow. ### 2a. Simulator-run recipe (iOS / tvOS) @@ -104,29 +162,28 @@ runners build: afterward (glibc 2.31 compiler floor; keep `GLIBC_BUILD_FLOOR` in `npm/perry/bin/detect.cjs` synchronized) - `ubuntu-24.04` / `ubuntu-24.04-arm` — musl x86_64 + aarch64 (fully static) -- `windows-latest` — x86_64 MSVC +- `windows-latest` / `windows-11-arm` — x86_64 + ARM64 MSVC Artifacts are published to: -1. **npm** (`@perryts/perry` + seven per-platform optional-deps) — via OIDC +1. **npm** (`@perryts/perry` + eight per-platform optional-deps) — via OIDC Trusted Publisher 2. **Homebrew** — formula auto-update 3. **APT** (Debian/Ubuntu) — GPG-signed repository 4. **winget** — manifest auto-update 5. **hub.perryts.com** — worker notification so cloud build workers refresh -A release with a failing platform build aborts the publish step for that -platform only; fix-forward with a new patch tag (e.g. `v0.6.1`) rather than -amending the existing one. +In the canonical staged flow, any failing host or cross build prevents all npm +staging, so no partial package set is promoted. Once a version has become public, +fix-forward with a new patch version rather than amending an existing tag. ## 4. Release gates (what blocks a release) -`release-packages.yml`'s `await-tests` job dispatches `test.yml` with `tier=full` -on the pinned release branch and waits for a run whose **`full-suite-gate`** job -succeeded (a green PR-tier or push-to-main sweep run on the same SHA does *not* -count — only the full tier carries the release-grade suites; see -[CI tiers](../testing/ci-tiers.md)). It also waits for `simctl-tests.yml`. The -full tier is: +`npm-stage-publish.yml` rejects a real stage unless `test.yml` has a successful +**`full-suite-gate`** and `simctl-tests.yml` has a successful run on the exact +candidate SHA. A green PR-tier or push-to-main sweep does *not* count. The same +two gates are enforced by `release-packages.yml`'s legacy release path. See +[CI tiers](../testing/ci-tiers.md). The full tier is: - everything the PR gate and the post-merge sweep run (`lint`, `check`, `warnings`, `cargo test --workspace`, the gap suite, `gc-stress`, Windows x64 + ARM64 builds, @@ -142,8 +199,8 @@ full tier is: `docs/examples/` - the package smokes (`drizzle-mysql-smoke`, `ink-link-smoke`, `effect-basic-smoke`) and `native-abi-evidence-packet` -- Benchmark regressions in `benchmark.yml` hard-fail on release tags (warn only - on main-branch pushes) +- `full-suite-gate`, the fan-in which proves every required full-tier job above + succeeded None of these carries `continue-on-error` any more: a red suite in the full tier blocks the release. If a suite is red for a reason that is not the release @@ -151,6 +208,12 @@ candidate's fault, fix it on `main` first (or open an issue and consciously re-add a job-level `continue-on-error: true` with that issue number) — do not publish past it. +The staging workflow then requires every host/cross package build, all nine npm +stages, sha1 verification, and the Socket scan. `benchmark.yml`, docs, container +tests, Homebrew, APT, winget, and worker refresh are tag riders or distribution +steps: monitor them after the GitHub Release is created, but do not mistake them +for pre-tag gates. + ## 4a. What tells you a release is overdue Nothing in the sections above fires if a release simply never happens. That is @@ -188,8 +251,11 @@ a release has been cut is not something a PR author can fix. - **Wrong artifact published**: tag a new patch release with the fix; npm rejects re-publishes of the same version anyway. -- **Broken binary on one platform**: the `release-packages.yml` matrix is not - `fail-fast: true`, so other platforms still publish. Ship a follow-up patch - for the broken one. -- **CI hook failed after tag**: run `workflow_dispatch` with - `publish_npm: true` to retry the npm step. +- **Broken build before approval**: fix it and stage the complete nine-package + set again; the canonical flow will not promote a partial set. +- **Broken binary discovered after approval**: ship a follow-up patch version; + neither npm versions nor release tags are mutable. +- **A post-tag distribution hook failed**: re-run the failed workflow. To retry + the legacy release-packages distribution legs, dispatch it with + `existing_tag=vX.Y.Z`; add `publish_npm=true` only when the idempotent npm leg + itself also needs retrying. diff --git a/scripts/pre-tag-check.sh b/scripts/pre-tag-check.sh index ac6c34121f..ddfb504585 100755 --- a/scripts/pre-tag-check.sh +++ b/scripts/pre-tag-check.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# Run the same fast lint gates that the Tests workflow's `lint` and -# `api-docs-drift` jobs run. Designed to be invoked manually before +# Legacy convenience subset. The authoritative local mirror is +# `BASE_SHA=origin/main scripts/run_lint_gates.sh`; release candidates must also +# run the commands in docs/src/contributing/releasing.md and the exact-SHA full +# CI + simulator workflows. Designed to be invoked manually before # `git tag vX.Y.Z` or wired into a `pre-push` git hook for branches # that push to main / tags. # diff --git a/scripts/publish/npm/approve.mts b/scripts/publish/npm/approve.mts index 40b05df0f3..bc2733c018 100644 --- a/scripts/publish/npm/approve.mts +++ b/scripts/publish/npm/approve.mts @@ -27,6 +27,8 @@ import { fetchPublishedVersion, listStagedEntries, type StagedEntry } from './sh import { verifyStagedEntry } from './staged.mts' export interface ApproveOptions { + /** The one release version this approval is allowed to promote. */ + version: string /** TOTP code (CI / scripted). */ otp?: string /** Approve without prompting; browser web-OTP drives 2FA. */ @@ -44,10 +46,11 @@ export interface ApproveReceipt { /** Filter staged entries to Perry's 9 packages, in publish order. */ export function perryStagedEntries( entries: readonly StagedEntry[], + version?: string, ): StagedEntry[] { const order = new Map(ALL_PACKAGES.map((n, i) => [n, i])) return entries - .filter(e => order.has(e.name)) + .filter(e => order.has(e.name) && (version === undefined || e.version === version)) .sort((a, b) => (order.get(a.name)! - order.get(b.name)!)) } @@ -79,10 +82,13 @@ export async function approveEntry( * release stage on `registryLive && approved.length > 0`. */ export async function runApprove(opts: ApproveOptions): Promise { - const staged = perryStagedEntries(await listStagedEntries(process.cwd())) + const staged = perryStagedEntries( + await listStagedEntries(process.cwd()), + opts.version, + ) if (staged.length === 0) { logger.fail( - 'No staged @perryts/* entries to approve. Run `npm run publish:stage` first.', + `No staged @perryts/* entries for v${opts.version} to approve. Run \`npm run publish:stage\` first.`, ) return { approved: [], failed: [], registryLive: false, scanResults: [] } } diff --git a/scripts/publish/npm/bump.mts b/scripts/publish/npm/bump.mts index 39e2510848..627ad691ee 100644 --- a/scripts/publish/npm/bump.mts +++ b/scripts/publish/npm/bump.mts @@ -54,10 +54,18 @@ export function hasChangelogFragments(cwd: string = rootPath): boolean { return readdirSync(dir).some(f => /^\d+-.*\.md$/.test(f)) } -/** True when the vX.Y.Z tag already exists on the repo. */ +/** True when the vX.Y.Z tag exists locally or on origin. */ export async function tagExists(version: string, cwd: string = rootPath): Promise { - const { code } = await runCapture('git', ['rev-parse', '-q', '--verify', `refs/tags/v${version}`], cwd) - return code === 0 + const tag = `refs/tags/v${version}` + const local = await runCapture('git', ['rev-parse', '-q', '--verify', tag], cwd) + if (local.code === 0) return true + const remote = await runCapture('git', ['ls-remote', '--tags', 'origin', tag], cwd) + if (remote.code !== 0) { + throw new Error( + `could not query origin for ${tag} (git ls-remote exited ${remote.code})`, + ) + } + return remote.stdout.includes(tag) } export interface VersionGate { @@ -85,9 +93,15 @@ export async function checkVersionGate(cwd: string = rootPath): Promise { + const { requireCurrentMain = true } = config + const status = await runCapture( + 'git', + ['status', '--porcelain', '--untracked-files=all'], + rootPath, + ) + if (status.code !== 0 || status.stdout.trim()) { + logger.fail( + 'Release candidate is not clean — commit or remove every tracked/untracked change before staging.', + ) + return undefined + } + const branch = await runCapture( + 'git', + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + rootPath, + ) + const head = await runCapture('git', ['rev-parse', 'HEAD'], rootPath) + const ref = branch.stdout.trim() + const sha = head.stdout.trim() + if (branch.code !== 0 || !ref || head.code !== 0 || !sha) { + logger.fail('Release staging requires a named branch, not a detached HEAD.') + return undefined + } + if (ref === 'main') { + logger.fail( + 'Release staging refuses the moving main branch — create and push a release/vX.Y.Z candidate branch.', + ) + return undefined + } + const remote = await runCapture( + 'git', + ['ls-remote', '--heads', 'origin', `refs/heads/${ref}`], + rootPath, + ) + const remoteSha = remote.stdout.trim().split(/\s+/)[0] ?? '' + if (remote.code !== 0 || remoteSha !== sha) { + logger.fail( + `origin/${ref} is ${remoteSha || ''}, but local HEAD is ${sha}. ` + + 'Push a release-candidate branch pinned to this commit before staging.', + ) + return undefined + } + if (requireCurrentMain) { + const remoteMain = await runCapture( + 'git', + ['ls-remote', '--heads', 'origin', 'refs/heads/main'], + rootPath, + ) + const mainSha = remoteMain.stdout.trim().split(/\s+/)[0] ?? '' + if (remoteMain.code !== 0 || !mainSha) { + logger.fail('Could not resolve origin/main — refusing to stage an unverifiable candidate.') + return undefined + } + const containsMain = await runCapture( + 'git', + ['merge-base', '--is-ancestor', mainSha, sha], + rootPath, + ) + if (containsMain.code !== 0) { + logger.fail( + `Candidate ${sha} does not contain current origin/main ${mainSha}. ` + + 'Fetch/rebase the release branch before staging.', + ) + return undefined + } + } + return { ref, sha } +} + +/** Revalidate the staging receipt before approval or tag creation. */ +async function requirePinnedCandidate( + state: PipelineState, +): Promise { + if (!state.candidateSha || !state.candidateRef || !state.stageRunId) { + logger.fail( + 'The local receipt is not commit-pinned — re-run `npm run publish:stage` with the hardened pipeline.', + ) + return undefined + } + // Main may advance while a staged candidate is being reviewed. Approval is + // tied to the already-tested receipt and must not chase that moving target. + const candidate = await resolveReleaseCandidate({ requireCurrentMain: false }) + if (!candidate) return undefined + if ( + candidate.sha !== state.candidateSha || + candidate.ref !== state.candidateRef + ) { + logger.fail( + `Staging run ${state.stageRunId} used ${state.candidateRef}@${state.candidateSha}, ` + + `but this checkout is ${candidate.ref}@${candidate.sha}. Re-stage this exact candidate.`, + ) + return undefined + } + return candidate +} + +function isCompleteVersionSet(items: readonly string[], version: string): boolean { + return ( + items.length === ALL_PACKAGES.length && + ALL_PACKAGES.every(name => items.includes(`${name}@${version}`)) + ) +} + /** Dispatch the CI stage workflow and watch it to completion. */ async function dispatchStageWorkflow( version: string, + candidate: Candidate, config: { dryRun?: boolean; tag?: string }, -): Promise { +): Promise { const { dryRun = false, tag = 'latest' } = config + const dispatchedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') const args = [ 'workflow', 'run', STAGE_WORKFLOW, '-R', 'PerryTS/perry', + '--ref', + candidate.ref, '-f', `publish=${dryRun ? 'false' : 'true'}`, '-f', `dist-tag=${tag}`, + '-f', + `candidate-sha=${candidate.sha}`, ] const run = await runCapture('gh', args, rootPath) if (run.code !== 0) { logger.fail(`gh workflow run ${STAGE_WORKFLOW} failed (${run.code}).`) - return false + return undefined } logger.log(`Dispatched ${STAGE_WORKFLOW} (publish=${!dryRun}, tag=${tag}). Waiting for the run to start…`) - // Match the workflow_dispatch run we just created by event + createdAt, - // not just the newest run — a concurrent dispatch could otherwise be watched. - const dispatchedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') - await new Promise(r => setTimeout(r, 4000)) - const list = await runCapture( - 'gh', - ['run', 'list', '--workflow', STAGE_WORKFLOW, '-R', 'PerryTS/perry', '--event', 'workflow_dispatch', '--limit', '5', '--json', 'databaseId,createdAt,status'], - rootPath, - ) let runId = '' - try { - const arr = JSON.parse(list.stdout) as Array<{ databaseId: number; createdAt: string; status: string }> - const match = arr.find(r => r.createdAt >= dispatchedAt) - if (match) runId = String(match.databaseId) - else if (arr.length > 0) runId = String(arr[0]!.databaseId) - } catch { - /* fall through */ + for (let attempt = 1; attempt <= 20 && !runId; attempt += 1) { + await new Promise(r => setTimeout(r, 3000)) + const list = await runCapture( + 'gh', + [ + 'run', 'list', '--workflow', STAGE_WORKFLOW, '-R', 'PerryTS/perry', + '--event', 'workflow_dispatch', '--limit', '20', '--json', + 'databaseId,createdAt,headBranch,headSha,status', + ], + rootPath, + ) + try { + const arr = JSON.parse(list.stdout) as Array<{ + databaseId: number + createdAt: string + headBranch: string + headSha: string + }> + const match = arr.find(r => + r.createdAt >= dispatchedAt && + r.headSha === candidate.sha && + r.headBranch === candidate.ref, + ) + if (match) runId = String(match.databaseId) + } catch { + /* poll again */ + } } if (!runId) { logger.fail( `Could not resolve the ${STAGE_WORKFLOW} run id to watch it. Check ` + `https://github.com/PerryTS/perry/actions/workflows/${STAGE_WORKFLOW} manually.`, ) - return false + return undefined } logger.log(`Watching run ${runId}…`) const watch = await runCapture('gh', ['run', 'watch', runId, '-R', 'PerryTS/perry', '--exit-status'], rootPath) if (watch.code !== 0) { logger.fail(`CI stage workflow run ${runId} did not succeed (${watch.code}).`) - return false + return undefined } logger.log(`CI stage workflow run ${runId} succeeded — staged @perryts/* v${version}.`) - return true + return { ...candidate, runId } } /** Verify + scan the currently-staged @perryts/* entries; update state. */ @@ -136,8 +274,10 @@ async function verifyAndScan( version: string, state: PipelineState, ): Promise { - const staged = (await listStagedEntries(rootPath)).filter(e => - ALL_PACKAGES.includes(e.name as (typeof ALL_PACKAGES)[number]), + const staged = (await listStagedEntries(rootPath)).filter( + e => + e.version === version && + ALL_PACKAGES.includes(e.name as (typeof ALL_PACKAGES)[number]), ) state.staged = staged.map(e => `${e.name}@${e.version}`) // Surface a partial staged set immediately, not only when publish:approve @@ -182,6 +322,10 @@ async function verifyAndScan( function printStatus(state: PipelineState): void { logger.log(`=== publish pipeline: v${state.version} ===`) + logger.log( + ` candidate: ${state.candidateSha ? `${state.candidateRef}@${state.candidateSha}` : '(not pinned)'}`, + ) + logger.log(` stage run: ${state.stageRunId ?? '(none)'}`) logger.log(` staged: ${state.staged.length ? state.staged.join(', ') : '(none)'}`) logger.log(` verified: ${state.verified.length ? state.verified.join(', ') : '(none)'}`) logger.log( @@ -269,10 +413,30 @@ async function main(): Promise { } if (mode === '--stage-only') { - if (!(await dispatchStageWorkflow(gate.version, { dryRun, tag }))) { + const candidate = await resolveReleaseCandidate() + if (!candidate) { + process.exitCode = 1 + return + } + const receipt = await dispatchStageWorkflow( + gate.version, + candidate, + { dryRun, tag }, + ) + if (!receipt) { process.exitCode = 1 return } + state.candidateSha = receipt.sha + state.candidateRef = receipt.ref + state.stageRunId = receipt.runId + state.updatedAt = new Date().toISOString() + writeState(state) + if (dryRun) { + logger.log('Dry-run build succeeded; no registry stage was created or scanned.') + printStatus(state) + return + } state = await verifyAndScan(gate.version, state) printStatus(state) logger.log(formatApproveGate({ version: gate.version, repoPath: rootPath })) @@ -299,7 +463,13 @@ async function main(): Promise { } if (mode === '--approve') { + const candidate = await requirePinnedCandidate(state) + if (!candidate) { + process.exitCode = 1 + return + } const receipt = await runApprove({ + version: gate.version, yes: flags.has('--yes'), otp, }) @@ -308,7 +478,10 @@ async function main(): Promise { state.scanResults = receipt.scanResults state.updatedAt = new Date().toISOString() writeState(state) - if (!receipt.registryLive || receipt.approved.length === 0) { + if ( + !receipt.registryLive || + !isCompleteVersionSet(receipt.approved, gate.version) + ) { process.exitCode = 1 return } @@ -324,7 +497,7 @@ async function main(): Promise { process.exitCode = 1 return } - const released = await ensureTagAndRelease(gate.version) + const released = await ensureTagAndRelease(gate.version, candidate.sha) state.released = released state.updatedAt = new Date().toISOString() writeState(state) @@ -333,12 +506,25 @@ async function main(): Promise { } if (mode === '--release-only') { - if (!state.registryLive) { - logger.fail(`No approved+live receipt for v${gate.version} — run publish:approve first.`) + const candidate = await requirePinnedCandidate(state) + if (!candidate) { + process.exitCode = 1 + return + } + if ( + !state.registryLive || + !isCompleteVersionSet(state.approved ?? [], gate.version) + ) { + logger.fail(`No complete approved+live receipt for v${gate.version} — run publish:approve first.`) + process.exitCode = 1 + return + } + const packages = ALL_PACKAGES.map(name => ({ name, version: gate.version })) + if (!(await requireRegistryLive(packages))) { process.exitCode = 1 return } - const released = await ensureTagAndRelease(gate.version) + const released = await ensureTagAndRelease(gate.version, candidate.sha) state.released = released state.updatedAt = new Date().toISOString() writeState(state) diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index de1c83a6c7..8f7f84a419 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -9,7 +9,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' import process from 'node:process' import { spawnSync } from 'node:child_process' @@ -21,6 +22,8 @@ import { formatHumanGate } from './human-gate.mts' import { publishAuthPreflight } from './auth-posture.mts' import { compareSemver, extractFirstJson } from './shared.mts' import { parseStageListJson } from './npm/shared.mts' +import { perryStagedEntries } from './npm/approve.mts' +import { tagExists } from './npm/bump.mts' import { NPM_MIN_VERSION } from './constants.mts' const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -111,6 +114,44 @@ test('parseStageListJson: array of entries parses to staged entries', () => { assert.equal(entries2[0]!.name, '@perryts/perry-darwin-arm64') }) +test('perryStagedEntries: approval is restricted to the candidate version', () => { + const entries = [ + { + name: '@perryts/perry-darwin-arm64', + version: '0.5.1519', + stageId: 'candidate', + }, + { + name: '@perryts/perry-darwin-arm64', + version: '0.5.1518', + stageId: 'stale', + }, + { + name: '@somewhere/else', + version: '0.5.1519', + stageId: 'foreign', + }, + ] + assert.deepEqual( + perryStagedEntries(entries, '0.5.1519').map(e => e.stageId), + ['candidate'], + ) +}) + +test('tagExists: inability to query origin fails closed', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'perry-tag-gate-')) + try { + const init = spawnSync('git', ['init', '--quiet'], { cwd, encoding: 'utf8' }) + assert.equal(init.status, 0) + await assert.rejects( + tagExists('0.5.1519', cwd), + /could not query origin for refs\/tags\/v0\.5\.1519/, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + test('formatHumanGate: the 🖐 block shape with both lanes', () => { const block = formatHumanGate({ name: 'approve', @@ -300,6 +341,15 @@ test('pipeline.mts: --scan-only sets a failing exit code on an incomplete/blocke ) }) +test('pipeline.mts: stage dispatch and release receipt are pinned to one commit', () => { + const src = readFileSync(path.join(PUBLISH_DIR, 'pipeline.mts'), 'utf8') + assert.match(src, /'--ref',\s*candidate\.ref/) + assert.match(src, /`candidate-sha=\$\{candidate\.sha\}`/) + assert.match(src, /r\.headSha === candidate\.sha/) + assert.match(src, /requirePinnedCandidate\(state\)/) + assert.match(src, /ensureTagAndRelease\(gate\.version, candidate\.sha\)/) +}) + test('pipeline.mts: two conflicting mode flags fail closed instead of picking one by argument order', () => { // Mode resolution used to be `flags.find(...)`, which silently picked // whichever mode flag argv happened to list first — so `--stage-only diff --git a/scripts/publish/release.mts b/scripts/publish/release.mts index 1b09d87c66..01e0c97431 100644 --- a/scripts/publish/release.mts +++ b/scripts/publish/release.mts @@ -89,9 +89,28 @@ export function writeChecksums(files: readonly string[], cwd: string = rootPath) * uploaded by a follow-up CI leg. Returns true only when the tag is on origin * AND the release is published (or already existed). */ -export async function ensureTagAndRelease(version: string): Promise { +export async function ensureTagAndRelease( + version: string, + expectedCommit: string, +): Promise { const tagName = `v${version}` + // The staged artifacts and approval receipt are tied to one commit. Refuse + // to let a later checkout (or an old local tag) turn that receipt into a tag + // for different source bytes. + const head = await runCapture('git', ['rev-parse', 'HEAD'], rootPath) + const headCommit = head.stdout.trim() + if (head.code !== 0 || !headCommit) { + logger.fail('could not resolve HEAD — refusing to create a release tag.') + return false + } + if (headCommit !== expectedCommit) { + logger.fail( + `release receipt is for ${expectedCommit}, but HEAD is ${headCommit} — refusing to tag different source bytes.`, + ) + return false + } + // 1. Assets FIRST. install.sh is a REQUIRED release asset (the release // advertises `curl ... install.sh | sh`), and the tag is immutable once // pushed — so it must not be cut when a required asset is absent. Fail @@ -117,7 +136,19 @@ export async function ensureTagAndRelease(version: string): Promise { ['rev-parse', '-q', '--verify', `refs/tags/${tagName}`], rootPath, ) - if (tagCheck.code !== 0) { + if (tagCheck.code === 0) { + const localTag = await runCapture( + 'git', + ['rev-parse', `refs/tags/${tagName}^{commit}`], + rootPath, + ) + if (localTag.code !== 0 || localTag.stdout.trim() !== headCommit) { + logger.fail( + `local tag ${tagName} does not point to release-candidate commit ${headCommit} — refusing to push it.`, + ) + return false + } + } else { const created = await runCapture('git', ['tag', tagName], rootPath) if (created.code !== 0) { logger.fail(`could not create tag ${tagName}`) From 8038cd0e629610791dc9dd53923af153f289eeb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:07:58 +0200 Subject: [PATCH 2/8] fix(release): reset receipt on restage --- scripts/publish/pipeline.mts | 31 +++++++++++++++++++++++++------ scripts/publish/publish.test.mts | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/publish/pipeline.mts b/scripts/publish/pipeline.mts index f3accbde48..e74b1137a0 100644 --- a/scripts/publish/pipeline.mts +++ b/scripts/publish/pipeline.mts @@ -66,6 +66,26 @@ interface StageWorkflowReceipt extends Candidate { runId: string } +/** A successful real stage invalidates every receipt from an earlier attempt. */ +export function freshStageState( + version: string, + receipt: StageWorkflowReceipt, +): PipelineState { + return { + version, + candidateSha: receipt.sha, + candidateRef: receipt.ref, + stageRunId: receipt.runId, + staged: [], + verified: [], + scanResults: [], + approved: [], + registryLive: false, + released: false, + updatedAt: new Date().toISOString(), + } +} + function statePath(version: string): string { return path.join(rootPath, PIPELINE_STATE_DIR, `${version}.json`) } @@ -427,16 +447,15 @@ async function main(): Promise { process.exitCode = 1 return } - state.candidateSha = receipt.sha - state.candidateRef = receipt.ref - state.stageRunId = receipt.runId - state.updatedAt = new Date().toISOString() - writeState(state) if (dryRun) { logger.log('Dry-run build succeeded; no registry stage was created or scanned.') - printStatus(state) + // A dry build is evidence about CI only. It must not replace the real + // staging receipt or carry old approval/liveness fields to a new SHA. + printStatus(freshStageState(gate.version, receipt)) return } + state = freshStageState(gate.version, receipt) + writeState(state) state = await verifyAndScan(gate.version, state) printStatus(state) logger.log(formatApproveGate({ version: gate.version, repoPath: rootPath })) diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 8f7f84a419..36b71052a2 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -24,6 +24,7 @@ import { compareSemver, extractFirstJson } from './shared.mts' import { parseStageListJson } from './npm/shared.mts' import { perryStagedEntries } from './npm/approve.mts' import { tagExists } from './npm/bump.mts' +import { freshStageState } from './pipeline.mts' import { NPM_MIN_VERSION } from './constants.mts' const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -152,6 +153,23 @@ test('tagExists: inability to query origin fails closed', async () => { } }) +test('freshStageState: a new stage cannot inherit an old approval receipt', () => { + const state = freshStageState('0.5.1519', { + sha: 'a'.repeat(40), + ref: 'release/v0.5.1519', + runId: '12345', + }) + assert.deepEqual(state.staged, []) + assert.deepEqual(state.verified, []) + assert.deepEqual(state.scanResults, []) + assert.deepEqual(state.approved, []) + assert.equal(state.registryLive, false) + assert.equal(state.released, false) + assert.equal(state.candidateSha, 'a'.repeat(40)) + assert.equal(state.candidateRef, 'release/v0.5.1519') + assert.equal(state.stageRunId, '12345') +}) + test('formatHumanGate: the 🖐 block shape with both lanes', () => { const block = formatHumanGate({ name: 'approve', From 7b00feb3d9e66ea35c57e62ecde778f2e74f9a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:12:32 +0200 Subject: [PATCH 3/8] docs(release): require staged OIDC permission --- .github/workflows/npm-stage-publish.yml | 3 ++- .github/workflows/release-packages.yml | 10 ++++--- docs/src/contributing/releasing.md | 35 +++++++++++++++++++++---- scripts/publish/publish.test.mts | 11 ++++++++ 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index 4bfe39da0e..65437aca79 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -19,7 +19,8 @@ name: npm stage publish # # Auth: OIDC trusted publishing only — id-token: write, NO long-lived NPM_TOKEN. # Each @perryts/* package must list this workflow + the `npm-publish` environment -# as a trusted publisher on npmjs.com (see docs/src/contributing/releasing.md). +# as its one trusted publisher on npmjs.com, with the `npm stage publish` +# action explicitly allowed (see docs/src/contributing/releasing.md). # The auth-posture gate in # scripts/publish/auth-posture.mts refuses any long-lived token present here. # diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 26d41ebb9c..ab3920dcdf 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -1971,10 +1971,12 @@ jobs: # --------------------------------------------------------------------------- # Publish npm packages (@perryts/perry + 8 per-platform packages) # - # Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). Each of the 9 - # package names must be registered on npmjs.com with this repo + workflow - # as a Trusted Publisher. See docs/src/contributing/releasing.md for the - # one-time setup. + # Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). This direct npm + # leg is legacy: npm permits only one trusted publisher per package, while + # Perry's canonical posture assigns that slot to npm-stage-publish.yml with + # the `npm stage publish` action. A normal registry-first release reaches + # this release-event job only after all versions are live, so its idempotency + # checks skip every direct publish. See the release runbook. # --------------------------------------------------------------------------- npm-publish: # The irreversible leg. build-cross is in `needs` (completed, not diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index 6290bd0f75..d975441367 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -72,17 +72,42 @@ npm --version # must be >= 11.17.0 # If needed: npm install -g npm@latest ``` +One-time GitHub setup: create the environment named in the OIDC identity and +store the Socket credential at environment scope (the secret value is entered +interactively and must never be committed): + +```bash +gh api --method PUT repos/PerryTS/perry/environments/npm-publish +gh secret set SOCKET_API_TOKEN --repo PerryTS/perry --env npm-publish +``` + Before the first nine-package release, an npm organization owner must confirm -that every name in `scripts/publish/constants.mts` exists and has -`npm-stage-publish.yml` with environment `npm-publish` configured as a Trusted -Publisher. In particular, verify the ARM64 Windows package: +that every name in `scripts/publish/constants.mts` exists and has this single +Trusted Publisher configuration: + +- provider: GitHub Actions +- organization/repository: `PerryTS/perry` +- workflow filename: `npm-stage-publish.yml` +- environment: `npm-publish` +- allowed action: **`npm stage publish`** + +npm permits only one trusted publisher per package. Configurations created +before May 20, 2026 were carried forward with only direct **`npm publish`** +allowed, so edit every existing package and explicitly enable +**`npm stage publish`**; merely seeing a trusted publisher entry is not enough. +The old `release-packages.yml` direct-publish path cannot occupy a second +trusted-publisher slot and is not part of the canonical release. + +In particular, verify the ARM64 Windows package: ```bash npm view @perryts/perry-win32-arm64 name ``` -If that returns `E404`, reserve/provision the package and configure its Trusted -Publisher before continuing. The pipeline intentionally refuses a partial set. +If that returns `E404`, an `@perryts` npm owner must make the initial public +name-reservation publish (the repository permits version `0.0.0` only for this +bootstrap), then configure the same Trusted Publisher fields above. The +pipeline intentionally refuses a partial set. ```bash npm run publish:stage # CI builds all platforms, stages 9 npm packages, diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 36b71052a2..d143315857 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -368,6 +368,17 @@ test('pipeline.mts: stage dispatch and release receipt are pinned to one commit' assert.match(src, /ensureTagAndRelease\(gate\.version, candidate\.sha\)/) }) +test('release runbook pins the staged-publish OIDC identity and action', () => { + const runbook = readFileSync( + path.join(PUBLISH_DIR, '../../docs/src/contributing/releasing.md'), + 'utf8', + ) + assert.match(runbook, /workflow filename: `npm-stage-publish\.yml`/) + assert.match(runbook, /environment: `npm-publish`/) + assert.match(runbook, /allowed action: \*\*`npm stage publish`\*\*/) + assert.match(runbook, /npm permits only one trusted publisher per package/) +}) + test('pipeline.mts: two conflicting mode flags fail closed instead of picking one by argument order', () => { // Mode resolution used to be `flags.find(...)`, which silently picked // whichever mode flag argv happened to list first — so `--stage-only From ac96fe583a5862dfdf41a4e2ae4cc9c4264c4465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:17:06 +0200 Subject: [PATCH 4/8] fix(release): attach oversized release notes --- docs/src/contributing/releasing.md | 4 ++ scripts/publish/publish.test.mts | 18 ++++++++ scripts/publish/release.mts | 67 +++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index d975441367..3d8c6ca212 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -117,6 +117,10 @@ npm run publish:approve # explicit 2FA promote; waits for registry liveness # and only then creates v0.x.y + the GitHub Release ``` +If the accumulated changelog fragments exceed the inline release-note budget, +the publisher keeps the GitHub Release body concise and uploads the complete +notes as the checksummed `release-notes-full.md` asset. No fragment is dropped. + Do not run `git tag`, manually publish a GitHub Release, or use the legacy `release-packages.yml cut_release=true` route for a normal release. That older route creates the tag before npm publication; it does not satisfy the stricter diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index d143315857..cb4ce02f7d 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -25,6 +25,10 @@ import { parseStageListJson } from './npm/shared.mts' import { perryStagedEntries } from './npm/approve.mts' import { tagExists } from './npm/bump.mts' import { freshStageState } from './pipeline.mts' +import { + INLINE_RELEASE_NOTES_MAX_BYTES, + planReleaseNotes, +} from './release.mts' import { NPM_MIN_VERSION } from './constants.mts' const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -379,6 +383,20 @@ test('release runbook pins the staged-publish OIDC identity and action', () => { assert.match(runbook, /npm permits only one trusted publisher per package/) }) +test('planReleaseNotes: oversized notes move to a stable release asset', () => { + const inline = planReleaseNotes('0.5.1519', 'small notes') + assert.deepEqual(inline, { body: 'small notes', attachFullNotes: false }) + + const large = planReleaseNotes( + '0.5.1519', + 'x'.repeat(INLINE_RELEASE_NOTES_MAX_BYTES + 1), + ) + assert.equal(large.attachFullNotes, true) + assert.match(large.body, /release-notes-full\.md/) + assert.match(large.body, /releases\/download\/v0\.5\.1519\/release-notes-full\.md/) + assert.ok(Buffer.byteLength(large.body, 'utf8') < INLINE_RELEASE_NOTES_MAX_BYTES) +}) + test('pipeline.mts: two conflicting mode flags fail closed instead of picking one by argument order', () => { // Mode resolution used to be `flags.find(...)`, which silently picked // whichever mode flag argv happened to list first — so `--stage-only diff --git a/scripts/publish/release.mts b/scripts/publish/release.mts index 01e0c97431..f94d95fc8a 100644 --- a/scripts/publish/release.mts +++ b/scripts/publish/release.mts @@ -4,7 +4,8 @@ * fragments (via scripts/cut_release_notes.sh --notes-only — Perry froze * CHANGELOG.md at v0.5.1264), then creates the git tag + the IMMUTABLE * (draft → upload → undraft) GitHub release carrying packaging/install.sh + - * a checksums file. + * a checksums file (plus the complete notes as an asset when they are too + * large for a useful inline release body). * * The platform tarballs (perry-macos-aarch64.tar.gz, …) are built in CI, not * locally — a follow-up CI leg uploads them to this release. The local cut @@ -16,7 +17,7 @@ */ import { createHash } from 'node:crypto' -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -25,6 +26,36 @@ import { INSTALL_SH, RELEASE_REPO, rootPath } from './constants.mts' import { logger, runCapture } from './shared.mts' import { fetchPublishedVersion } from './npm/shared.mts' +/** Keep the rendered GitHub page responsive; larger notes become an asset. */ +export const INLINE_RELEASE_NOTES_MAX_BYTES = 120_000 + +export interface ReleaseNotesPlan { + body: string + attachFullNotes: boolean +} + +/** Decide whether release notes are safe to send as the inline release body. */ +export function planReleaseNotes( + version: string, + notes: string, + maxBytes: number = INLINE_RELEASE_NOTES_MAX_BYTES, +): ReleaseNotesPlan { + const body = notes.trim() || `Release ${version}.` + const bytes = Buffer.byteLength(body, 'utf8') + if (bytes <= maxBytes) return { body, attachFullNotes: false } + const mib = (bytes / (1024 * 1024)).toFixed(2) + const tagName = `v${version}` + return { + body: + `# ${tagName}\n\n` + + `This release accumulated ${mib} MiB of changelog notes. ` + + `The complete, checksummed notes are available as ` + + `[\`release-notes-full.md\`](` + + `https://github.com/${RELEASE_REPO}/releases/download/${tagName}/release-notes-full.md).\n`, + attachFullNotes: true, + } +} + /** Concatenate changelog.d/ fragments into the release body. */ export async function extractReleaseNotes(cwd: string = rootPath): Promise { const { stdout, code } = await runCapture( @@ -126,8 +157,22 @@ export async function ensureTagAndRelease( process.exitCode = 1 return false } - const checksums = writeChecksums([installSh]) - const assets: string[] = [installSh, checksums] + const fullNotes = await extractReleaseNotes() + const notesPlan = planReleaseNotes(version, fullNotes) + const tempDir = mkdtempSync(path.join(os.tmpdir(), `perry-release-${version}-`)) + const notesFile = path.join(tempDir, 'release-notes.md') + writeFileSync(notesFile, notesPlan.body) + const assets: string[] = [installSh] + if (notesPlan.attachFullNotes) { + const fullNotesFile = path.join(tempDir, 'release-notes-full.md') + writeFileSync(fullNotesFile, fullNotes.endsWith('\n') ? fullNotes : `${fullNotes}\n`) + assets.push(fullNotesFile) + logger.log( + `Release notes exceed ${INLINE_RELEASE_NOTES_MAX_BYTES} bytes; attaching the complete notes as release-notes-full.md.`, + ) + } + const checksums = writeChecksums(assets) + assets.push(checksums) try { // 2. Tag (on HEAD — the bump commit is already on main). @@ -247,12 +292,7 @@ export async function ensureTagAndRelease( return true } - // 5. Release notes from changelog.d fragments. - const notes = await extractReleaseNotes() - const notesFile = path.join(os.tmpdir(), `release-notes-${version}.md`) - writeFileSync(notesFile, notes || `Release ${version}.`) - - // 6. Immutable release: draft → upload → undraft. + // 5. Immutable release: draft → upload → undraft. const create = await runCapture( 'gh', ['release', 'create', tagName, '--draft', '--verify-tag', '--title', tagName, '--notes-file', notesFile], @@ -281,7 +321,7 @@ export async function ensureTagAndRelease( ) return true } finally { - // checksums.txt is written into the repo tree solely for the upload. + // Generated files exist solely for the upload. for (const a of assets) { if (path.basename(a) === 'checksums.txt') { try { @@ -291,5 +331,10 @@ export async function ensureTagAndRelease( } } } + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + /* best-effort */ + } } } From 39a70fa2fec7137bb3b3adc24b1747e352178d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:25:07 +0200 Subject: [PATCH 5/8] fix(release): retain exact staged tarballs --- .github/workflows/npm-stage-publish.yml | 24 +++++ docs/src/contributing/releasing.md | 8 +- scripts/publish/npm/approve.mts | 12 ++- scripts/publish/npm/staged.mts | 51 ++++++--- scripts/publish/pipeline.mts | 131 +++++++++++++++++++++--- scripts/publish/publish.test.mts | 67 +++++++++++- scripts/publish/scan.mts | 23 ++--- 7 files changed, 269 insertions(+), 47 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index 65437aca79..0bf48c94ab 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -360,3 +360,27 @@ jobs: # code, same gate, whether it runs in CI or locally. if: env.PUBLISH == 'true' run: node scripts/publish/pipeline.mts --scan-only + + - name: Bundle exact staged-package proofs + if: env.PUBLISH == 'true' + run: | + set -euo pipefail + mapfile -t proofs < <( + find npm -mindepth 2 -maxdepth 2 -type f -name '*.tgz' | sort + ) + if [ "${#proofs[@]}" -ne 9 ]; then + echo "::error::Expected 9 exact tarballs after verify + scan; found ${#proofs[@]}." >&2 + printf ' %s\n' "${proofs[@]}" >&2 + exit 1 + fi + tar -cf npm-staged-package-proofs.tar "${proofs[@]}" + + - name: Retain exact staged-package proofs for local 2FA approval + if: env.PUBLISH == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: npm-staged-package-proofs + path: npm-staged-package-proofs.tar + if-no-files-found: error + retention-days: 7 + compression-level: 0 # the nine .tgz payloads are already compressed diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index 3d8c6ca212..0abb523999 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -65,11 +65,14 @@ Then use the staged pipeline: The local publish/approve process requires npm 11.17 or newer and a valid `SOCKET_API_TOKEN` for the mandatory tarball scan. CI uses OIDC for staging; -do not set a long-lived npm publish token. +do not set a long-lived npm publish token. The local npm account must be a +maintainer of all nine packages because it lists and approves their staged +entries. ```bash npm --version # must be >= 11.17.0 # If needed: npm install -g npm@latest +npm whoami # must succeed as an @perryts package maintainer ``` One-time GitHub setup: create the environment named in the OIDC identity and @@ -111,7 +114,8 @@ pipeline intentionally refuses a partial set. ```bash npm run publish:stage # CI builds all platforms, stages 9 npm packages, - # verifies sha1, and runs the mandatory Socket scan + # verifies sha1, runs the mandatory Socket scan, + # and downloads the exact proof tarballs locally npm run publish:status # inspect the commit/run/package receipt npm run publish:approve # explicit 2FA promote; waits for registry liveness # and only then creates v0.x.y + the GitHub Release diff --git a/scripts/publish/npm/approve.mts b/scripts/publish/npm/approve.mts index bc2733c018..e7a672e6d3 100644 --- a/scripts/publish/npm/approve.mts +++ b/scripts/publish/npm/approve.mts @@ -33,6 +33,8 @@ export interface ApproveOptions { otp?: string /** Approve without prompting; browser web-OTP drives 2FA. */ yes?: boolean + /** Root containing the exact tarballs downloaded from the staging CI run. */ + proofRoot?: string } export interface ApproveReceipt { @@ -116,7 +118,7 @@ export async function runApprove(opts: ApproveOptions): Promise // 1. Verify each staged entry (sha1 gate). const verified: StagedEntry[] = [] for (const entry of staged) { - if (await verifyStagedEntry(entry)) verified.push(entry) + if (await verifyStagedEntry(entry, opts.proofRoot)) verified.push(entry) } if (verified.length === 0) { logger.fail('No staged entries passed the verify gate — not approving.') @@ -152,7 +154,13 @@ export async function runApprove(opts: ApproveOptions): Promise } const passed: StagedEntry[] = [] for (const entry of verified) { - const res = await scanTarball(ctx, entry.name, entry.version, entry.shasum) + const res = await scanTarball( + ctx, + entry.name, + entry.version, + entry.shasum, + opts.proofRoot, + ) scanResults.push(res) if (res.status === 'passed') passed.push(entry) else logger.fail(`scan ${res.status}: ${entry.name}@${entry.version} dropped from approve`) diff --git a/scripts/publish/npm/staged.mts b/scripts/publish/npm/staged.mts index 2a9a299f73..de3ec9382e 100644 --- a/scripts/publish/npm/staged.mts +++ b/scripts/publish/npm/staged.mts @@ -11,7 +11,8 @@ * runDirect — escape hatch: classic `npm publish` (no stage/approve). */ -import { existsSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { createReadStream, existsSync, readdirSync } from 'node:fs' import path from 'node:path' import { ALL_PACKAGES, npmPackageDir, rootPath } from '../constants.mts' @@ -44,12 +45,41 @@ export async function packTarball(pkgDir: string): Promise<{ return { path: tarball, sha1: shasum } } +/** + * Resolve the exact tarball to verify/scan. In CI, pack the materialized npm + * directory. In the local approval handoff, consume the short-lived tarball + * proof downloaded from the successful staging run. + */ +export async function resolveStagedTarball( + entry: Pick, + proofRoot?: string, +): Promise<{ path: string; sha1: string } | undefined> { + const pkgDir = path.join(proofRoot ?? rootPath, npmPackageDir(entry.name)) + if (!proofRoot) return packTarball(pkgDir) + if (!existsSync(pkgDir)) return undefined + const tarballs = readdirSync(pkgDir).filter(f => f.endsWith('.tgz')) + if (tarballs.length !== 1) return undefined + const tarball = path.join(pkgDir, tarballs[0]!) + const sha1 = await new Promise(resolve => { + const hash = createHash('sha1') + createReadStream(tarball) + .on('data', chunk => hash.update(chunk)) + .on('error', () => resolve(undefined)) + .on('end', () => resolve(hash.digest('hex'))) + }) + if (!sha1) return undefined + return { path: tarball, sha1 } +} + /** * Verify a staged entry: the local pack's sha1 equals the shasum npm recorded * when the tarball was staged. LOUD refusal on mismatch or missing shasum — * never approve unverified bytes. */ -export async function verifyStagedEntry(entry: StagedEntry): Promise { +export async function verifyStagedEntry( + entry: StagedEntry, + proofRoot?: string, +): Promise { const { name, version, shasum: stagedShasum, stageId } = entry if (!stagedShasum) { logger.fail( @@ -59,19 +89,12 @@ export async function verifyStagedEntry(entry: StagedEntry): Promise { ) return false } - const pkgDir = path.join(rootPath, npmPackageDir(name)) - if (!existsSync(pkgDir)) { - logger.fail( - `Pre-approve verify: no package dir for ${name}@${version} at ${pkgDir}.\n` + - ` Fix: run scripts/stage-npm.sh to materialize the npm/ package dirs first.`, - ) - return false - } - const local = await packTarball(pkgDir) + const local = await resolveStagedTarball(entry, proofRoot) if (!local) { logger.fail( - `Pre-approve verify: npm pack failed for ${name}@${version} in ${pkgDir}.\n` + - ` Saw vs wanted: no local tarball; wanted one to hash against npm's staged shasum.`, + `Pre-approve verify: no unique tarball for ${name}@${version}.\n` + + ` Saw vs wanted: ${proofRoot ? `no proof archive under ${path.join(proofRoot, npmPackageDir(name))}` : 'npm pack failed'}; ` + + `wanted one tarball to hash against npm's staged shasum.`, ) return false } @@ -83,7 +106,7 @@ export async function verifyStagedEntry(entry: StagedEntry): Promise { `Pre-approve verify: shasum mismatch for ${name}@${version}.\n` + ` local pack: ${local.sha1}\n` + ` npm staging: ${stagedShasum}\n` + - ` Fix: check npm auth (npm stage download ${stageId}), or reject + re-stage. Not approving unverified bytes.`, + ` Fix: reject stage ${stageId} and re-run publish:stage; the retained CI proof and npm staging record disagree. Not approving unverified bytes.`, ) return false } diff --git a/scripts/publish/pipeline.mts b/scripts/publish/pipeline.mts index e74b1137a0..9e710aef6b 100644 --- a/scripts/publish/pipeline.mts +++ b/scripts/publish/pipeline.mts @@ -15,18 +15,26 @@ * approved — so a release cut earlier can mark a version that never shipped). */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' import path from 'node:path' import process from 'node:process' import { ALL_PACKAGES, + npmPackageDir, PIPELINE_STATE_DIR, rootPath, } from './constants.mts' import { logger, runCapture, checkNpmFloor } from './shared.mts' import { checkVersionGate } from './npm/bump.mts' -import { runStaged, verifyStagedEntry } from './npm/staged.mts' +import { verifyStagedEntry } from './npm/staged.mts' import { listStagedEntries, type StagedEntry } from './npm/shared.mts' import { runApprove } from './npm/approve.mts' import { @@ -46,6 +54,8 @@ export interface PipelineState { candidateSha?: string candidateRef?: string stageRunId?: string + /** Cache-relative root containing CI's exact nine staged tarballs. */ + stageProofDir?: string staged: string[] verified: string[] scanResults: ScanResult[] @@ -64,6 +74,7 @@ interface Candidate { interface StageWorkflowReceipt extends Candidate { runId: string + stageProofDir?: string } /** A successful real stage invalidates every receipt from an earlier attempt. */ @@ -76,6 +87,7 @@ export function freshStageState( candidateSha: receipt.sha, candidateRef: receipt.ref, stageRunId: receipt.runId, + stageProofDir: receipt.stageProofDir, staged: [], verified: [], scanResults: [], @@ -106,6 +118,58 @@ function writeState(state: PipelineState): void { writeFileSync(statePath(state.version), JSON.stringify(state, null, 2) + '\n') } +/** Download and validate the exact tarballs retained by a successful stage run. */ +async function downloadStageProof(runId: string): Promise { + const relativeDir = path.join(PIPELINE_STATE_DIR, 'artifacts', runId) + const artifactDir = path.join(rootPath, relativeDir) + rmSync(artifactDir, { recursive: true, force: true }) + mkdirSync(artifactDir, { recursive: true }) + const download = await runCapture( + 'gh', + [ + 'run', 'download', runId, '-R', 'PerryTS/perry', + '--name', 'npm-staged-package-proofs', '--dir', artifactDir, + ], + rootPath, + ) + const archive = path.join(artifactDir, 'npm-staged-package-proofs.tar') + if (download.code !== 0 || !existsSync(archive)) { + logger.fail( + `Could not download the exact staged-package proofs from run ${runId}. ` + + 'Do not approve without those tarballs.', + ) + return undefined + } + const extract = await runCapture( + 'tar', + ['-xf', archive, '-C', artifactDir], + rootPath, + ) + rmSync(archive, { force: true }) + if (extract.code !== 0) { + logger.fail(`Could not extract the staged-package proofs from run ${runId}.`) + return undefined + } + const missing = ALL_PACKAGES.filter(name => { + const packageDir = path.join( + artifactDir, + npmPackageDir(name), + ) + return ( + !existsSync(packageDir) || + readdirSync(packageDir).filter(file => file.endsWith('.tgz')).length !== 1 + ) + }) + if (missing.length > 0) { + logger.fail( + `Stage run ${runId} did not retain one proof tarball for every package. ` + + `Missing/ambiguous: ${missing.join(', ')}.`, + ) + return undefined + } + return relativeDir +} + /** Resolve a clean, pushed branch tip. workflow_dispatch accepts a branch/tag, not a raw SHA. */ async function resolveReleaseCandidate( config: { requireCurrentMain?: boolean } = {}, @@ -214,6 +278,23 @@ function isCompleteVersionSet(items: readonly string[], version: string): boolea ) } +export function isCompleteScanReceipt(state: PipelineState): boolean { + return ( + isCompleteVersionSet(state.staged, state.version) && + isCompleteVersionSet(state.verified, state.version) && + !state.scanBlocked && + state.scanResults.length === ALL_PACKAGES.length && + ALL_PACKAGES.every(name => + state.scanResults.some( + result => + result.name === name && + result.version === state.version && + result.status === 'passed', + ), + ) + ) +} + /** Dispatch the CI stage workflow and watch it to completion. */ async function dispatchStageWorkflow( version: string, @@ -285,8 +366,10 @@ async function dispatchStageWorkflow( logger.fail(`CI stage workflow run ${runId} did not succeed (${watch.code}).`) return undefined } + const stageProofDir = dryRun ? undefined : await downloadStageProof(runId) + if (!dryRun && !stageProofDir) return undefined logger.log(`CI stage workflow run ${runId} succeeded — staged @perryts/* v${version}.`) - return { ...candidate, runId } + return { ...candidate, runId, stageProofDir } } /** Verify + scan the currently-staged @perryts/* entries; update state. */ @@ -294,6 +377,9 @@ async function verifyAndScan( version: string, state: PipelineState, ): Promise { + const proofRoot = state.stageProofDir + ? path.join(rootPath, state.stageProofDir) + : undefined const staged = (await listStagedEntries(rootPath)).filter( e => e.version === version && @@ -310,7 +396,7 @@ async function verifyAndScan( } const verified: StagedEntry[] = [] for (const entry of staged) { - if (await verifyStagedEntry(entry)) verified.push(entry) + if (await verifyStagedEntry(entry, proofRoot)) verified.push(entry) } state.verified = verified.map(e => `${e.name}@${e.version}`) // The scan gate is mandatory — there is no flag to skip it. A missing/invalid @@ -320,7 +406,15 @@ async function verifyAndScan( if (ctx) { const results: ScanResult[] = [] for (const entry of verified) { - results.push(await scanTarball(ctx, entry.name, entry.version, entry.shasum)) + results.push( + await scanTarball( + ctx, + entry.name, + entry.version, + entry.shasum, + proofRoot, + ), + ) } state.scanResults = results state.scanBlocked = false @@ -346,6 +440,7 @@ function printStatus(state: PipelineState): void { ` candidate: ${state.candidateSha ? `${state.candidateRef}@${state.candidateSha}` : '(not pinned)'}`, ) logger.log(` stage run: ${state.stageRunId ?? '(none)'}`) + logger.log(` proof: ${state.stageProofDir ?? '(none)'}`) logger.log(` staged: ${state.staged.length ? state.staged.join(', ') : '(none)'}`) logger.log(` verified: ${state.verified.length ? state.verified.join(', ') : '(none)'}`) logger.log( @@ -458,6 +553,13 @@ async function main(): Promise { writeState(state) state = await verifyAndScan(gate.version, state) printStatus(state) + if (!isCompleteScanReceipt(state)) { + logger.fail( + 'The local exact-tarball verification/scan receipt is incomplete — fix npm login or Socket auth, then run `npm run publish:scan`.', + ) + process.exitCode = 1 + return + } logger.log(formatApproveGate({ version: gate.version, repoPath: rootPath })) return } @@ -469,13 +571,7 @@ async function main(): Promise { // publish:approve is the real enforcement point), --scan-only is what CI // uses as a gate — a caller checking only the exit code must see a // failure for a blocked/incomplete/not-passed scan, not just a log line. - const complete = state.staged.length === ALL_PACKAGES.length - const allVerified = state.verified.length === state.staged.length - const allScanned = - !state.scanBlocked && - state.scanResults.length === state.verified.length && - state.scanResults.every(r => r.status === 'passed') - if (!complete || !allVerified || !allScanned) { + if (!isCompleteScanReceipt(state)) { process.exitCode = 1 } return @@ -487,10 +583,21 @@ async function main(): Promise { process.exitCode = 1 return } + const proofRoot = state.stageProofDir + ? path.join(rootPath, state.stageProofDir) + : undefined + if (!proofRoot || !existsSync(proofRoot)) { + logger.fail( + 'The exact staged-package proof is missing — re-run `npm run publish:stage`; refusing to repack template directories.', + ) + process.exitCode = 1 + return + } const receipt = await runApprove({ version: gate.version, yes: flags.has('--yes'), otp, + proofRoot, }) state.approved = receipt.approved state.registryLive = receipt.registryLive diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index cb4ce02f7d..acb870b07e 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -9,7 +9,14 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -24,12 +31,13 @@ import { compareSemver, extractFirstJson } from './shared.mts' import { parseStageListJson } from './npm/shared.mts' import { perryStagedEntries } from './npm/approve.mts' import { tagExists } from './npm/bump.mts' -import { freshStageState } from './pipeline.mts' +import { verifyStagedEntry } from './npm/staged.mts' +import { freshStageState, isCompleteScanReceipt } from './pipeline.mts' import { INLINE_RELEASE_NOTES_MAX_BYTES, planReleaseNotes, } from './release.mts' -import { NPM_MIN_VERSION } from './constants.mts' +import { ALL_PACKAGES, NPM_MIN_VERSION } from './constants.mts' const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -162,6 +170,7 @@ test('freshStageState: a new stage cannot inherit an old approval receipt', () = sha: 'a'.repeat(40), ref: 'release/v0.5.1519', runId: '12345', + stageProofDir: '.cache/perry/publish-pipeline/artifacts/12345', }) assert.deepEqual(state.staged, []) assert.deepEqual(state.verified, []) @@ -172,6 +181,56 @@ test('freshStageState: a new stage cannot inherit an old approval receipt', () = assert.equal(state.candidateSha, 'a'.repeat(40)) assert.equal(state.candidateRef, 'release/v0.5.1519') assert.equal(state.stageRunId, '12345') + assert.equal( + state.stageProofDir, + '.cache/perry/publish-pipeline/artifacts/12345', + ) +}) + +test('verifyStagedEntry: local approval verifies the exact CI proof tarball', async () => { + const proofRoot = mkdtempSync(path.join(os.tmpdir(), 'perry-stage-proof-')) + try { + const packageDir = path.join(proofRoot, 'npm/perry-win32-arm64') + mkdirSync(packageDir, { recursive: true }) + const tarball = path.join(packageDir, 'perryts-perry-win32-arm64-0.5.1519.tgz') + writeFileSync(tarball, 'exact staged bytes') + const shasum = createHash('sha1') + .update(readFileSync(tarball)) + .digest('hex') + const entry = { + name: '@perryts/perry-win32-arm64', + version: '0.5.1519', + stageId: 'stage-proof', + shasum, + } + assert.equal(await verifyStagedEntry(entry, proofRoot), true) + assert.equal( + await verifyStagedEntry({ ...entry, shasum: '0'.repeat(40) }, proofRoot), + false, + ) + } finally { + rmSync(proofRoot, { recursive: true, force: true }) + } +}) + +test('isCompleteScanReceipt: every exact package/version must pass', () => { + const version = '0.5.1519' + const packages = [...ALL_PACKAGES] + const state = { + version, + staged: packages.map(name => `${name}@${version}`), + verified: packages.map(name => `${name}@${version}`), + scanResults: packages.map(name => ({ + name, + version, + status: 'passed' as const, + summary: { error: [], total: 0, warn: [] }, + })), + updatedAt: new Date().toISOString(), + } + assert.equal(isCompleteScanReceipt(state), true) + state.scanResults[0] = { ...state.scanResults[0]!, status: 'blocked' } + assert.equal(isCompleteScanReceipt(state), false) }) test('formatHumanGate: the 🖐 block shape with both lanes', () => { @@ -368,6 +427,8 @@ test('pipeline.mts: stage dispatch and release receipt are pinned to one commit' assert.match(src, /'--ref',\s*candidate\.ref/) assert.match(src, /`candidate-sha=\$\{candidate\.sha\}`/) assert.match(src, /r\.headSha === candidate\.sha/) + assert.match(src, /npm-staged-package-proofs/) + assert.match(src, /downloadStageProof\(runId\)/) assert.match(src, /requirePinnedCandidate\(state\)/) assert.match(src, /ensureTagAndRelease\(gate\.version, candidate\.sha\)/) }) diff --git a/scripts/publish/scan.mts b/scripts/publish/scan.mts index 610f39ce25..edc9e3ec04 100644 --- a/scripts/publish/scan.mts +++ b/scripts/publish/scan.mts @@ -14,19 +14,13 @@ */ import { existsSync } from 'node:fs' -import path from 'node:path' import process from 'node:process' import { SocketSdk } from '@socketsecurity/sdk' -import { - npmPackageDir, - rootPath, - SOCKET_ORG_SLUG, - SOCKET_SCAN_REPO, -} from './constants.mts' +import { SOCKET_ORG_SLUG, SOCKET_SCAN_REPO } from './constants.mts' import { logger } from './shared.mts' -import { packTarball } from './npm/staged.mts' +import { resolveStagedTarball } from './npm/staged.mts' export const SOCKET_TOKEN_ENV_VAR = 'SOCKET_API_TOKEN' export const SOCKET_TOKEN_MINT_URL = 'https://socket.dev/dashboard' @@ -164,10 +158,11 @@ export interface ScanResult { } /** - * Scan one package's tarball. Packs locally and — when the caller passes the - * staged shasum — verifies the packed tarball's sha1 matches the staged bytes - * BEFORE submitting, so a worktree change between verify and scan can never - * make Socket scan different bytes from the artifact that promotion releases. + * Scan one package's tarball. CI packs the materialized package; local approval + * consumes the exact tarball proof retained by that CI run. When the caller + * passes the staged shasum, verify the tarball's sha1 matches the staged bytes + * BEFORE submitting, so a worktree change can never make Socket scan different + * bytes from the artifact that promotion releases. * Submits as a tmp full scan, reads results + org security policy in parallel, * and buckets alerts. `error`-action alerts → failed; an unreachable/empty * scan → blocked (never a pass). @@ -177,10 +172,10 @@ export async function scanTarball( name: string, version: string, stagedShasum?: string, + proofRoot?: string, ): Promise { const { orgSlug, sdk } = ctx - const pkgDir = path.join(rootPath, npmPackageDir(name)) - const packed = await packTarball(pkgDir) + const packed = await resolveStagedTarball({ name }, proofRoot) if (!packed || !existsSync(packed.path)) { logger.fail(`scan: no local tarball for ${name}@${version} — run verify first.`) return { name, version, status: 'blocked', summary: { error: [], total: 0, warn: [] } } From f23db4a149e9d9c48734fcf23d56bf80bdc496c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:27:57 +0200 Subject: [PATCH 6/8] fix(release): constrain real stage dispatches --- .github/workflows/npm-stage-publish.yml | 18 +++++++++++++----- scripts/publish/publish.test.mts | 7 +++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index 0bf48c94ab..0c4ba32bb9 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -76,10 +76,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} SHA: ${{ github.sha }} EXPECTED_SHA: ${{ inputs.candidate-sha }} run: | set -euo pipefail + if [[ "$REF" != refs/heads/* ]] || [ "$REF_NAME" = "main" ]; then + echo "::error::A real staged publish requires a named non-main candidate branch; got $REF." >&2 + exit 1 + fi if [ -z "$EXPECTED_SHA" ]; then echo "::error::candidate-sha is required for a real staged publish. Use the local npm run publish:stage command." >&2 exit 1 @@ -88,9 +94,10 @@ jobs: echo "::error::Candidate branch moved: workflow is on $SHA, but the dispatcher pinned $EXPECTED_SHA." >&2 exit 1 fi - tests=$(gh api "/repos/$REPO/actions/workflows/test.yml/runs?head_sha=$SHA&per_page=20") + tests=$(gh api --paginate --slurp \ + "/repos/$REPO/actions/workflows/test.yml/runs?head_sha=$SHA&per_page=100") full_run="" - for run_id in $(echo "$tests" | jq -r '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id'); do + for run_id in $(echo "$tests" | jq -r '.[] | .workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id'); do if gh api "/repos/$REPO/actions/runs/$run_id/jobs?per_page=100" \ --jq '.jobs[] | select(.name == "full-suite-gate" and .conclusion == "success") | .name' \ | grep -q full-suite-gate; then @@ -102,9 +109,10 @@ jobs: echo "::error::No successful full-tier test.yml run (full-suite-gate) exists on $SHA. Dispatch test.yml with tier=full first." >&2 exit 1 fi - sim_run=$(gh api "/repos/$REPO/actions/workflows/simctl-tests.yml/runs?head_sha=$SHA&per_page=20" \ - --jq '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id' \ - | head -1) + sim_runs=$(gh api --paginate --slurp \ + "/repos/$REPO/actions/workflows/simctl-tests.yml/runs?head_sha=$SHA&per_page=100") + sim_run=$(echo "$sim_runs" | jq -r \ + 'first(.[] | .workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id) // empty') if [ -z "$sim_run" ]; then echo "::error::No successful simctl-tests.yml run exists on $SHA. Dispatch it first." >&2 exit 1 diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index acb870b07e..e6e3c15c37 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -424,6 +424,10 @@ test('pipeline.mts: --scan-only sets a failing exit code on an incomplete/blocke test('pipeline.mts: stage dispatch and release receipt are pinned to one commit', () => { const src = readFileSync(path.join(PUBLISH_DIR, 'pipeline.mts'), 'utf8') + const workflow = readFileSync( + path.join(PUBLISH_DIR, '../../.github/workflows/npm-stage-publish.yml'), + 'utf8', + ) assert.match(src, /'--ref',\s*candidate\.ref/) assert.match(src, /`candidate-sha=\$\{candidate\.sha\}`/) assert.match(src, /r\.headSha === candidate\.sha/) @@ -431,6 +435,9 @@ test('pipeline.mts: stage dispatch and release receipt are pinned to one commit' assert.match(src, /downloadStageProof\(runId\)/) assert.match(src, /requirePinnedCandidate\(state\)/) assert.match(src, /ensureTagAndRelease\(gate\.version, candidate\.sha\)/) + assert.match(workflow, /\[ "\$REF_NAME" = "main" \]/) + assert.match(workflow, /"\$REF" != refs\/heads\/\*/) + assert.match(workflow, /gh api --paginate --slurp/) }) test('release runbook pins the staged-publish OIDC identity and action', () => { From 03536ddad5edacd79898b8b04bf7d6eacb19322c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:34:57 +0200 Subject: [PATCH 7/8] fix(release): recover partial staged approval --- docs/src/contributing/releasing.md | 6 ++ scripts/publish/npm/approve.mts | 93 ++++++++++++++++++++++-------- scripts/publish/npm/shared.mts | 26 +++++++++ scripts/publish/npm/staged.mts | 3 +- scripts/publish/publish.test.mts | 39 ++++++++++++- 5 files changed, 140 insertions(+), 27 deletions(-) diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index 0abb523999..bf225f9036 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -121,6 +121,12 @@ npm run publish:approve # explicit 2FA promote; waits for registry liveness # and only then creates v0.x.y + the GitHub Release ``` +Approval promotes nine packages sequentially. If 2FA or the network interrupts +that loop after some packages are already public, re-run `publish:approve` with +the same retained CI proof. It resumes only when each already-public package's +immutable registry shasum matches the exact CI tarball; otherwise use a new +version. Do not discard the proof directory until the tag and release exist. + If the accumulated changelog fragments exceed the inline release-note budget, the publisher keeps the GitHub Release body concise and uploads the complete notes as the checksummed `release-notes-full.md` asset. No fragment is dropped. diff --git a/scripts/publish/npm/approve.mts b/scripts/publish/npm/approve.mts index e7a672e6d3..e4487241c9 100644 --- a/scripts/publish/npm/approve.mts +++ b/scripts/publish/npm/approve.mts @@ -23,7 +23,12 @@ import { scanTarball, type ScanResult, } from '../scan.mts' -import { fetchPublishedVersion, listStagedEntries, type StagedEntry } from './shared.mts' +import { + fetchPublishedShasum, + fetchPublishedVersion, + listStagedEntries, + type StagedEntry, +} from './shared.mts' import { verifyStagedEntry } from './staged.mts' export interface ApproveOptions { @@ -56,6 +61,34 @@ export function perryStagedEntries( .sort((a, b) => (order.get(a.name)! - order.get(b.name)!)) } +/** + * Complete a partially promoted staging set with exact-version registry + * entries. The caller still has to verify every shasum against the CI proof. + */ +export async function completeCandidatesWithPublished( + staged: readonly StagedEntry[], + version: string, + lookup: (name: string, version: string) => Promise = + fetchPublishedShasum, +): Promise { + const stagedNames = new Set(staged.map(e => e.name)) + const alreadyLive: StagedEntry[] = [] + for (const name of ALL_PACKAGES) { + if (stagedNames.has(name)) continue + const shasum = await lookup(name, version) + if (shasum) { + alreadyLive.push({ + name, + version, + stageId: `already-live:${name}@${version}`, + shasum, + alreadyLive: true, + }) + } + } + return perryStagedEntries([...staged, ...alreadyLive], version) +} + /** Run `npm stage approve ` for one entry, PTY-wrapped for web-OTP. */ export async function approveEntry( entry: StagedEntry, @@ -88,28 +121,28 @@ export async function runApprove(opts: ApproveOptions): Promise await listStagedEntries(process.cwd()), opts.version, ) - if (staged.length === 0) { - logger.fail( - `No staged @perryts/* entries for v${opts.version} to approve. Run \`npm run publish:stage\` first.`, - ) - return { approved: [], failed: [], registryLive: false, scanResults: [] } - } + + // Approval is nine sequential 2FA operations. If one fails after earlier + // entries became public, npm removes those successful entries from staging. + // Recover only when the immutable public dist.shasum matches this run's exact + // CI proof; this cannot turn an old same-version artifact into a new receipt. + const candidates = await completeCandidatesWithPublished(staged, opts.version) // 0. Reject a partial package set. The 9 @perryts/* packages are a fixed // release set (the wrapper's optionalDependencies are the platform binaries); - // promoting a subset ships a broken install. Require every ALL_PACKAGES - // member to be staged before any verification or approval proceeds. - const stagedNames = new Set(staged.map(e => e.name)) - const missing = ALL_PACKAGES.filter(n => !stagedNames.has(n)) + // promoting a subset ships a broken install. An entry may be pending in + // staging or already live with a registry SHA that the proof verifies. + const candidateNames = new Set(candidates.map(e => e.name)) + const missing = ALL_PACKAGES.filter(n => !candidateNames.has(n)) if (missing.length > 0) { logger.fail( - `Partial staged set — refusing to approve. Missing: ${missing.join(', ')}.\n` + + `Partial staged/live set — refusing to approve. Missing: ${missing.join(', ')}.\n` + ` Why: the @perryts/* packages are a fixed release set; a partial promote ships a broken install.\n` + - ` Fix: re-run publish:stage so every package is staged, then re-approve.`, + ` Fix: re-run publish:stage so every missing package is staged, then re-approve.`, ) return { approved: [], - failed: staged.map(e => `${e.name}@${e.version}`), + failed: candidates.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [], } @@ -117,22 +150,22 @@ export async function runApprove(opts: ApproveOptions): Promise // 1. Verify each staged entry (sha1 gate). const verified: StagedEntry[] = [] - for (const entry of staged) { + for (const entry of candidates) { if (await verifyStagedEntry(entry, opts.proofRoot)) verified.push(entry) } if (verified.length === 0) { logger.fail('No staged entries passed the verify gate — not approving.') - return { approved: [], failed: staged.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [] } + return { approved: [], failed: candidates.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [] } } if (verified.length !== ALL_PACKAGES.length) { - const failedVerify = staged.filter(e => !verified.includes(e)) + const failedVerify = candidates.filter(e => !verified.includes(e)) logger.fail( `Partial verify — refusing to approve. ${failedVerify.length} of ${ALL_PACKAGES.length} entries failed the sha1 gate: ` + `${failedVerify.map(e => `${e.name}@${e.version}`).join(', ')}.`, ) return { approved: [], - failed: staged.map(e => `${e.name}@${e.version}`), + failed: candidates.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [], } @@ -185,8 +218,14 @@ export async function runApprove(opts: ApproveOptions): Promise verified.length = 0 verified.push(...passed) - // 3. OTP resolution (last, after every slow gate). - if (!opts.otp && !opts.yes && !process.stdin.isTTY) { + const pending = verified.filter(entry => + !entry.alreadyLive, + ) + + // 3. OTP resolution (last, after every slow gate). A fully promoted release + // can be recovered and receipted non-interactively because no mutation or + // 2FA operation remains; its exact public shasums were verified above. + if (pending.length > 0 && !opts.otp && !opts.yes && !process.stdin.isTTY) { logger.fail( 'approve needs an interactive terminal, or --yes / --otp.\n' + ' What: the promote is a 2FA action (browser web-OTP or TOTP).\n' + @@ -196,9 +235,14 @@ export async function runApprove(opts: ApproveOptions): Promise } // 4. npm stage approve each entry. - const approvedEntries: StagedEntry[] = [] + const approvedEntries: StagedEntry[] = verified.filter(entry => + entry.alreadyLive, + ) const failed: string[] = [] - for (const entry of verified) { + for (const entry of approvedEntries) { + logger.info(`already live with matching proof: ${entry.name}@${entry.version}`) + } + for (const entry of pending) { const ok = await approveEntry(entry, opts) if (ok) approvedEntries.push(entry) else failed.push(`${entry.name}@${entry.version}`) @@ -210,13 +254,14 @@ export async function runApprove(opts: ApproveOptions): Promise // before the caller can gate a release on the surviving subset. if (failed.length > 0) { logger.fail( - `Partial approve — ${failed.length} of ${verified.length} entries failed the promote: ${failed.join(', ')}. ` + + `Partial approve — ${failed.length} of ${pending.length} pending entries failed the promote: ${failed.join(', ')}. ` + 'Not cutting the release — re-run publish:approve after fixing the failures.', ) } // 5. Registry liveness — exit 0 is NOT proof. - let registryLive = failed.length === 0 + let registryLive = + failed.length === 0 && approvedEntries.length === ALL_PACKAGES.length for (const entry of approvedEntries) { if (!(await fetchPublishedVersion(entry.name, entry.version))) { logger.fail(`registry liveness: ${entry.name}@${entry.version} not resolvable after approve — do NOT cut the release.`) diff --git a/scripts/publish/npm/shared.mts b/scripts/publish/npm/shared.mts index acd4567cec..5a67c89c0a 100644 --- a/scripts/publish/npm/shared.mts +++ b/scripts/publish/npm/shared.mts @@ -18,6 +18,8 @@ export interface StagedEntry { shasum?: string | undefined /** The staging id `npm stage approve ` promotes. */ stageId: string + /** Synthetic recovery entry: this exact tarball is already public. */ + alreadyLive?: boolean | undefined } /** Read the subject package.json. */ @@ -134,3 +136,27 @@ export async function fetchPublishedVersion( const { code } = await runCapture('npm', ['view', `${name}@${version}`, 'version'], rootPath) return code === 0 } + +/** Normalize `npm view ... dist.shasum` without accepting noisy output. */ +export function normalizePublishedShasum( + stdout: string, + code: number, +): string | undefined { + const shasum = stdout.trim() + return code === 0 && /^[0-9a-f]{40}$/i.test(shasum) + ? shasum.toLowerCase() + : undefined +} + +/** Read the immutable registry sha1 for an already-public exact version. */ +export async function fetchPublishedShasum( + name: string, + version: string, +): Promise { + const { code, stdout } = await runCapture( + 'npm', + ['view', `${name}@${version}`, 'dist.shasum'], + rootPath, + ) + return normalizePublishedShasum(stdout, code) +} diff --git a/scripts/publish/npm/staged.mts b/scripts/publish/npm/staged.mts index de3ec9382e..6263dabf89 100644 --- a/scripts/publish/npm/staged.mts +++ b/scripts/publish/npm/staged.mts @@ -106,7 +106,8 @@ export async function verifyStagedEntry( `Pre-approve verify: shasum mismatch for ${name}@${version}.\n` + ` local pack: ${local.sha1}\n` + ` npm staging: ${stagedShasum}\n` + - ` Fix: reject stage ${stageId} and re-run publish:stage; the retained CI proof and npm staging record disagree. Not approving unverified bytes.`, + ` Fix: reject/re-stage pending entry ${stageId}; if this version is already public, ship a new version. ` + + `The retained CI proof and npm registry record disagree. Not approving unverified bytes.`, ) return false } diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index e6e3c15c37..7337fa46c2 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -28,8 +28,14 @@ import { summarizePolicyAlerts, normalizeFullScanArtifacts } from './scan.mts' import { formatHumanGate } from './human-gate.mts' import { publishAuthPreflight } from './auth-posture.mts' import { compareSemver, extractFirstJson } from './shared.mts' -import { parseStageListJson } from './npm/shared.mts' -import { perryStagedEntries } from './npm/approve.mts' +import { + normalizePublishedShasum, + parseStageListJson, +} from './npm/shared.mts' +import { + completeCandidatesWithPublished, + perryStagedEntries, +} from './npm/approve.mts' import { tagExists } from './npm/bump.mts' import { verifyStagedEntry } from './npm/staged.mts' import { freshStageState, isCompleteScanReceipt } from './pipeline.mts' @@ -127,6 +133,16 @@ test('parseStageListJson: array of entries parses to staged entries', () => { assert.equal(entries2[0]!.name, '@perryts/perry-darwin-arm64') }) +test('normalizePublishedShasum: accepts only a successful bare sha1', () => { + assert.equal( + normalizePublishedShasum(` ${'A'.repeat(40)}\n`, 0), + 'a'.repeat(40), + ) + assert.equal(normalizePublishedShasum('not found', 1), undefined) + assert.equal(normalizePublishedShasum(`warning\n${'a'.repeat(40)}`, 0), undefined) + assert.equal(normalizePublishedShasum('a'.repeat(39), 0), undefined) +}) + test('perryStagedEntries: approval is restricted to the candidate version', () => { const entries = [ { @@ -151,6 +167,25 @@ test('perryStagedEntries: approval is restricted to the candidate version', () = ) }) +test('completeCandidatesWithPublished: safely resumes a partial promotion', async () => { + const version = '0.5.1519' + const first = ALL_PACKAGES[0]! + const lookedUp: string[] = [] + const candidates = await completeCandidatesWithPublished( + [{ name: first, version, stageId: 'still-staged', shasum: '1'.repeat(40) }], + version, + async name => { + lookedUp.push(name) + return '2'.repeat(40) + }, + ) + assert.deepEqual(candidates.map(entry => entry.name), [...ALL_PACKAGES]) + assert.deepEqual(lookedUp, ALL_PACKAGES.slice(1)) + assert.equal(candidates[0]!.alreadyLive, undefined) + assert.equal(candidates[1]!.alreadyLive, true) + assert.equal(candidates[1]!.shasum, '2'.repeat(40)) +}) + test('tagExists: inability to query origin fails closed', async () => { const cwd = mkdtempSync(path.join(os.tmpdir(), 'perry-tag-gate-')) try { From 5cae5b2ebc5a9514a60f1f2e9f33b22b2c495aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 18:44:47 +0200 Subject: [PATCH 8/8] fix(release): pin npm registry operations --- docs/src/contributing/releasing.md | 5 +++-- scripts/publish/constants.mts | 3 +++ scripts/publish/npm/approve.mts | 10 ++++++++-- scripts/publish/npm/publish-command.mts | 11 ++++++++++- scripts/publish/npm/shared.mts | 21 ++++++++++++++++++--- scripts/publish/publish.test.mts | 15 ++++++++++++++- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/src/contributing/releasing.md b/docs/src/contributing/releasing.md index bf225f9036..5333bbb1bc 100644 --- a/docs/src/contributing/releasing.md +++ b/docs/src/contributing/releasing.md @@ -72,7 +72,8 @@ entries. ```bash npm --version # must be >= 11.17.0 # If needed: npm install -g npm@latest -npm whoami # must succeed as an @perryts package maintainer +npm whoami --registry=https://registry.npmjs.org + # must succeed as an @perryts package maintainer ``` One-time GitHub setup: create the environment named in the OIDC identity and @@ -104,7 +105,7 @@ trusted-publisher slot and is not part of the canonical release. In particular, verify the ARM64 Windows package: ```bash -npm view @perryts/perry-win32-arm64 name +npm view @perryts/perry-win32-arm64 name --registry=https://registry.npmjs.org ``` If that returns `E404`, an `@perryts` npm owner must make the initial public diff --git a/scripts/publish/constants.mts b/scripts/publish/constants.mts index 495510bf43..3f99384019 100644 --- a/scripts/publish/constants.mts +++ b/scripts/publish/constants.mts @@ -42,6 +42,9 @@ export const ALL_PACKAGES: readonly string[] = [ /** npm dist-tag floor — `latest` is what an untagged install resolves to. */ export const DEFAULT_DIST_TAG = 'latest' +/** Canonical public registry; release liveness must never resolve a mirror. */ +export const NPM_PUBLIC_REGISTRY = 'https://registry.npmjs.org' + /** The GitHub release repo. */ export const RELEASE_REPO = 'PerryTS/perry' diff --git a/scripts/publish/npm/approve.mts b/scripts/publish/npm/approve.mts index e4487241c9..a1af936a2e 100644 --- a/scripts/publish/npm/approve.mts +++ b/scripts/publish/npm/approve.mts @@ -16,7 +16,7 @@ import process from 'node:process' -import { ALL_PACKAGES } from '../constants.mts' +import { ALL_PACKAGES, NPM_PUBLIC_REGISTRY } from '../constants.mts' import { logger, NON_INTERACTIVE_RENDER_ENV, runInheritTty } from '../shared.mts' import { preflightSocketScanAuth, @@ -94,7 +94,13 @@ export async function approveEntry( entry: StagedEntry, opts: ApproveOptions, ): Promise { - const args = ['stage', 'approve', entry.stageId] + const args = [ + 'stage', + 'approve', + entry.stageId, + '--registry', + NPM_PUBLIC_REGISTRY, + ] // Pass the OTP through NPM_CONFIG_OTP (npm's `npm_config_*` env channel) // rather than `--otp `, so the ~30s-expiring code is not visible in // the process argument list. npm reads this env var at the enforced floor diff --git a/scripts/publish/npm/publish-command.mts b/scripts/publish/npm/publish-command.mts index f95cc3c760..ea0dd9ea7d 100644 --- a/scripts/publish/npm/publish-command.mts +++ b/scripts/publish/npm/publish-command.mts @@ -22,6 +22,7 @@ import { publishAuthPostflight, publishAuthPreflight, } from '../auth-posture.mts' +import { NPM_PUBLIC_REGISTRY } from '../constants.mts' import { logger, provenanceAllowed, runInheritTee } from '../shared.mts' export type NpmUploadMode = 'direct' | 'staged' @@ -51,7 +52,15 @@ export function npmUploadArgs(config: { tag = 'latest', } = config const args = mode === 'staged' ? ['stage', 'publish'] : ['publish'] - args.push('--access', 'public', '--tag', tag, '--ignore-scripts') + args.push( + '--access', + 'public', + '--tag', + tag, + '--ignore-scripts', + '--registry', + NPM_PUBLIC_REGISTRY, + ) if (provenance) args.push('--provenance') if (dryRun) args.push('--dry-run') return args diff --git a/scripts/publish/npm/shared.mts b/scripts/publish/npm/shared.mts index 5a67c89c0a..63f68985b5 100644 --- a/scripts/publish/npm/shared.mts +++ b/scripts/publish/npm/shared.mts @@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs' import path from 'node:path' +import { NPM_PUBLIC_REGISTRY } from '../constants.mts' import { extractFirstJson, rootPath, runCapture } from '../shared.mts' /** A normalized staged entry from `npm stage list --json`. */ @@ -108,7 +109,11 @@ export function parseStageListJson(text: string): StagedEntry[] { /** Run `npm stage list --json` and return normalized staged entries. */ export async function listStagedEntries(cwd: string): Promise { - const { stdout, code } = await runCapture('npm', ['stage', 'list', '--json'], cwd) + const { stdout, code } = await runCapture( + 'npm', + ['stage', 'list', '--json', '--registry', NPM_PUBLIC_REGISTRY], + cwd, + ) if (code !== 0) return [] return parseStageListJson(stdout) } @@ -133,7 +138,11 @@ export async function fetchPublishedVersion( name: string, version: string, ): Promise { - const { code } = await runCapture('npm', ['view', `${name}@${version}`, 'version'], rootPath) + const { code } = await runCapture( + 'npm', + ['view', `${name}@${version}`, 'version', '--registry', NPM_PUBLIC_REGISTRY], + rootPath, + ) return code === 0 } @@ -155,7 +164,13 @@ export async function fetchPublishedShasum( ): Promise { const { code, stdout } = await runCapture( 'npm', - ['view', `${name}@${version}`, 'dist.shasum'], + [ + 'view', + `${name}@${version}`, + 'dist.shasum', + '--registry', + NPM_PUBLIC_REGISTRY, + ], rootPath, ) return normalizePublishedShasum(stdout, code) diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 7337fa46c2..7acc624dac 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -37,13 +37,18 @@ import { perryStagedEntries, } from './npm/approve.mts' import { tagExists } from './npm/bump.mts' +import { npmUploadArgs } from './npm/publish-command.mts' import { verifyStagedEntry } from './npm/staged.mts' import { freshStageState, isCompleteScanReceipt } from './pipeline.mts' import { INLINE_RELEASE_NOTES_MAX_BYTES, planReleaseNotes, } from './release.mts' -import { ALL_PACKAGES, NPM_MIN_VERSION } from './constants.mts' +import { + ALL_PACKAGES, + NPM_MIN_VERSION, + NPM_PUBLIC_REGISTRY, +} from './constants.mts' const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -143,6 +148,14 @@ test('normalizePublishedShasum: accepts only a successful bare sha1', () => { assert.equal(normalizePublishedShasum('a'.repeat(39), 0), undefined) }) +test('npmUploadArgs: uploads are pinned to the public npm registry', () => { + const args = npmUploadArgs({ mode: 'staged' }) + assert.deepEqual( + args.slice(args.indexOf('--registry')), + ['--registry', NPM_PUBLIC_REGISTRY], + ) +}) + test('perryStagedEntries: approval is restricted to the candidate version', () => { const entries = [ {