From 3a5465d74fb3d2e6d6230218ace05081176b56ba Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Thu, 20 Aug 2026 00:48:13 -0400 Subject: [PATCH 1/4] fix(publish): remove scan-skip footgun, pin republish checkout, harden CI guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an end-to-end adversarial review of the npm staged-publish pipeline (stage → verify → Socket scan → human 2FA approve → tag/release): - The Socket scan gate can no longer be skipped: `--no-scan` is removed from both `pipeline.mts` and `npm/approve.mts`. A missing/invalid SOCKET_API_TOKEN now fails closed and is reported as BLOCKED (distinct from "scanned clean") rather than silently proceeding. - `pipeline.mts` required an explicit mode flag; running it with zero arguments (or a bare `npm run publish:pipeline`) silently dispatched a REAL staged publish. A mode flag is now mandatory and unknown flags are rejected outright, instead of being silently ignored. - `release-packages.yml`'s `republish` mode never pinned its checkout to the tag being republished, so a `main` that drifted past the tag could silently publish a different npm version than the tag/release label. Every downstream checkout (build, build-cross, npm-publish) now pins to the tag's exact commit for republish, and preflight cross-checks the tag's own Cargo.toml against the tag name before proceeding. - `npm-stage-publish.yml`'s `build-run-id` reuse had no provenance check; a stale or wrong-workflow run-id could stage old binaries under today's version with no error. It's now verified (workflow identity, success, exact commit) before its artifacts are trusted. - Neither workflow had a `concurrency:` group; two dispatches for the same tag/dist-tag could race each other's idempotency checks. Both now serialize on tag/dist-tag without cancelling an in-flight run. - The stage-upload step's "idempotent" comment wasn't backed by code — a partial failure's re-run re-attempted every package. It now skips packages a prior run already staged. - `verifyAndScan` (the --stage-only/--scan-only status path) now surfaces a partial staged set immediately instead of only at approve time. Added regression tests: the isMainModule guard is asserted to be the last top-level statement in every entry-point module (the exact incident class that once fired a real `cargo publish` from a bare import), the no-mode usage error, and that no `--no-scan`/`noScan` surface remains anywhere in the pipeline. --- .github/workflows/npm-stage-publish.yml | 71 ++++++++++++++++++-- .github/workflows/release-packages.yml | 49 +++++++++++++- scripts/publish/npm/approve.mts | 73 ++++++++++---------- scripts/publish/pipeline.mts | 89 +++++++++++++++++-------- scripts/publish/publish.test.mts | 81 ++++++++++++++++++++++ 5 files changed, 292 insertions(+), 71 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index da3cb59a58..f0c50c59d2 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -22,6 +22,12 @@ name: npm stage publish # as a trusted publisher on npmjs.com (same one-time setup npm/README.md # describes for release-packages.yml). 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: +# below); a caller-supplied build-run-id is verified (workflow identity, +# success, exact commit) before its artifacts are trusted; and the stage +# step is idempotent — it skips packages a prior partial run already staged +# instead of re-attempting all 9. on: workflow_dispatch: @@ -42,6 +48,14 @@ on: permissions: contents: read +# Serialize staged publishes for the same dist-tag — two concurrent dispatches +# could otherwise both pass resolve-build's run-matching and both attempt +# `npm stage publish` for the same version. `cancel-in-progress: false` so a +# real staged publish already in flight is never cancelled out from under it. +concurrency: + group: npm-stage-publish-${{ inputs.dist-tag }} + cancel-in-progress: false + jobs: # 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 @@ -66,7 +80,30 @@ jobs: set -euo pipefail if [ -n "$EXISTING" ]; then RUN_ID="$EXISTING" - echo "Reusing build run $RUN_ID." + # Validate the reused run before trusting its artifacts: it must + # actually be a release-packages.yml run, it must have succeeded, + # and it must have built the commit we're dispatching from — a + # stale or wrong-workflow run-id would otherwise stage old/wrong + # binaries under today's version and dist-tag with no error. + CUR_SHA=$(git rev-parse HEAD) + RUN_JSON=$(gh run view "$RUN_ID" -R "$REPO" --json workflowName,conclusion,status,headSha) + RUN_WORKFLOW=$(echo "$RUN_JSON" | jq -r '.workflowName') + RUN_STATUS=$(echo "$RUN_JSON" | jq -r '.status') + RUN_CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion') + RUN_SHA=$(echo "$RUN_JSON" | jq -r '.headSha') + if [ "$RUN_WORKFLOW" != "Release Packages" ]; then + echo "::error::build-run-id $RUN_ID is a '$RUN_WORKFLOW' run, not Release Packages — refusing to stage its artifacts." >&2 + exit 1 + fi + if [ "$RUN_STATUS" != "completed" ] || [ "$RUN_CONCLUSION" != "success" ]; then + echo "::error::build-run-id $RUN_ID is status=$RUN_STATUS conclusion=$RUN_CONCLUSION, not a successful completed run." >&2 + exit 1 + fi + if [ "$RUN_SHA" != "$CUR_SHA" ]; then + echo "::error::build-run-id $RUN_ID built $RUN_SHA, but this dispatch is at $CUR_SHA — refusing to stage a build of a different commit." >&2 + exit 1 + fi + echo "Reusing build run $RUN_ID (verified: Release Packages, success, sha $RUN_SHA)." else # Dispatch release-packages.yml in stage mode. Stage mode is the # DEFAULT workflow_dispatch (no inputs = build-only smoke run; the @@ -191,10 +228,30 @@ jobs: exit 1 fi done + # Snapshot what's already staged ONCE so a re-run after a partial + # failure skips the packages that made it through last time instead + # of re-attempting all 9 (npm rejects a re-stage of an already-staged + # version, which used to surface as "N failures" for a single + # genuine failure). + already_staged() { + node -e ' + const raw = require("fs").readFileSync(0, "utf8") + let list + try { list = JSON.parse(raw) } catch { list = [] } + const arr = Array.isArray(list) ? list : Object.values(list || {}) + const [n, v] = process.argv.slice(1) + process.exit(arr.some(e => (e.name || e.packageName) === n && e.version === v) ? 0 : 1) + ' "$1" "$2" <<<"$STAGED_JSON" + } + STAGED_JSON=$(npm stage list --json 2>/dev/null || echo '[]') failed="" for pkg in ./npm/perry-*; do name=$(node -p "require('$pkg/package.json').name") ver=$(node -p "require('$pkg/package.json').version") + if already_staged "$name" "$ver"; then + echo "=== $name@$ver already staged — skipping ===" + continue + fi echo "=== staging $name@$ver ===" if ! npm stage publish "$pkg" --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then echo "::error::npm stage publish failed for $name@$ver — continuing" >&2 @@ -204,10 +261,14 @@ jobs: # Wrapper LAST (optionalDependencies must be staged first). name=$(node -p "require('./npm/perry/package.json').name") ver=$(node -p "require('./npm/perry/package.json').version") - echo "=== staging $name@$ver (wrapper, last) ===" - if ! npm stage publish ./npm/perry --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then - echo "::error::npm stage publish failed for $name@$ver" >&2 - failed="$failed $name@$ver" + if already_staged "$name" "$ver"; then + echo "=== $name@$ver (wrapper) already staged — skipping ===" + else + echo "=== staging $name@$ver (wrapper, last) ===" + if ! npm stage publish ./npm/perry --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then + echo "::error::npm stage publish failed for $name@$ver" >&2 + failed="$failed $name@$ver" + fi fi if [ -n "$failed" ]; then echo "::error::staging failures:$failed" >&2 diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 1f74aa995c..8bc28f8d23 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -27,6 +27,17 @@ permissions: # push event, so those must be fired explicitly. actions: write +# Serialize runs that target the same tag/version, so two dispatches can't +# race npm-publish's `npm view` idempotency check (a TOCTOU otherwise: both +# can pass the check before either publishes) or create-release's tag +# creation. A plain stage-mode dispatch (build-only, no publish) has no tag +# to key on and falls back to its own run id, so unrelated stage builds are +# never blocked. `cancel-in-progress: false` — never cancel a release/publish +# leg that's already running. +concurrency: + group: release-packages-${{ github.event.release.tag_name || inputs.existing_tag || (inputs.cut_release && 'cut-release-in-flight') || github.run_id }} + cancel-in-progress: false + jobs: # --------------------------------------------------------------------------- # Resolve the run mode + release tag ONCE, up front. Every downstream job @@ -53,8 +64,17 @@ jobs: mode: ${{ steps.resolve.outputs.mode }} tag: ${{ steps.resolve.outputs.tag }} version: ${{ steps.resolve.outputs.version }} + # Pinned commit every downstream job must check out. Empty for + # release/cut-release/stage (the triggering ref is already correct — + # cut-release in particular has NO tag yet to pin to); set to the tag + # for republish, so a `main` that has drifted since the tag was cut + # cannot silently republish a different version under the old tag's + # label (#confirmed republish version-drift finding). + checkout-ref: ${{ steps.resolve.outputs.checkout-ref }} steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 # republish mode needs `git show :Cargo.toml` below - name: Resolve run mode + tag id: resolve @@ -67,6 +87,7 @@ jobs: EXISTING_TAG: ${{ inputs.existing_tag }} run: | set -euo pipefail + CHECKOUT_REF="" if [ "$EVENT" = "release" ]; then MODE=release TAG="$RELEASE_TAG" @@ -99,6 +120,19 @@ jobs: echo "::error::existing_tag=$TAG has no published GitHub release." exit 1 fi + git fetch --quiet --force origin "refs/tags/$TAG:refs/tags/$TAG" + TAG_CARGO_VERSION=$(git show "$TAG:Cargo.toml" | grep -m1 '^version' | sed -E 's/.*"([^"]+)".*/\1/') + if [ "$TAG_CARGO_VERSION" != "${TAG#v}" ]; then + echo "::error::Cargo.toml at $TAG reads version $TAG_CARGO_VERSION, but the tag name implies ${TAG#v} — refusing to republish a mistagged version." >&2 + exit 1 + fi + # Pin every downstream checkout to the tag's exact commit. Without + # this, every job checks out whatever ref the dispatch ran on — + # if `main` has advanced past the tag, the rebuild silently + # compiles a DIFFERENT Cargo.toml version than the tag/release + # label says, and npm (which reads the version from Cargo.toml on + # disk) publishes that drifted version attributed to this tag. + CHECKOUT_REF="$TAG" else MODE=stage TAG="" @@ -108,8 +142,9 @@ jobs: echo "mode=$MODE" echo "tag=$TAG" echo "version=$VERSION" + echo "checkout-ref=$CHECKOUT_REF" } >> "$GITHUB_OUTPUT" - echo "mode=$MODE tag=${TAG:-}" + echo "mode=$MODE tag=${TAG:-} checkout-ref=${CHECKOUT_REF:-}" # --------------------------------------------------------------------------- # Gate: wait for Tests + Simulator Tests to pass on this commit before we @@ -355,6 +390,8 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v7 + with: + ref: ${{ needs.preflight.outputs.checkout-ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -1102,7 +1139,7 @@ jobs: # this workflow. # --------------------------------------------------------------------------- build-cross: - needs: await-tests + needs: [preflight, await-tests] strategy: # Each leg is independent; let one Tier-3 failure not cancel the # other targets so we still ship the bundles that did build. @@ -1210,6 +1247,8 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v7 + with: + ref: ${{ needs.preflight.outputs.checkout-ref }} - name: Install Rust stable + cross target uses: dtolnay/rust-toolchain@stable @@ -2156,6 +2195,12 @@ jobs: id-token: write # REQUIRED for OIDC + npm --provenance steps: - uses: actions/checkout@v7 + with: + # republish mode pins this to the tag's exact commit (see + # preflight's checkout-ref) — without it, npm publishes whatever + # Cargo.toml reads on the dispatch ref, which can have drifted past + # the tag being republished. + ref: ${{ needs.preflight.outputs.checkout-ref }} # DELIBERATE EXEMPTION from the repo-wide .node-version pin: this Node is a # *publishing* toolchain (npm registry auth), not a test oracle — it never diff --git a/scripts/publish/npm/approve.mts b/scripts/publish/npm/approve.mts index 6aa7436cb8..40b05df0f3 100644 --- a/scripts/publish/npm/approve.mts +++ b/scripts/publish/npm/approve.mts @@ -8,7 +8,7 @@ * Gate order (every slow gate BEFORE the OTP prompt, because TOTP is ~30s): * 1. list staged entries, filter to @perryts/* * 2. verify each (sha1 vs staged shasum) — refuse on any mismatch - * 3. Socket full-scan each (unless --no-scan) — failed/blocked entries drop + * 3. Socket full-scan each (mandatory — no flag skips this) — failed/blocked entries drop * 4. OTP resolution: --otp | --yes (browser web-OTP) | prompt * 5. npm stage approve per entry (PTY-wrapped for web-OTP) * 6. registry liveness: npm view @ before minting a receipt @@ -27,8 +27,6 @@ import { fetchPublishedVersion, listStagedEntries, type StagedEntry } from './sh import { verifyStagedEntry } from './staged.mts' export interface ApproveOptions { - /** Skip the in-approve socket scan gate (recorded as deferred in receipt). */ - noScan?: boolean /** TOTP code (CI / scripted). */ otp?: string /** Approve without prompting; browser web-OTP drives 2FA. */ @@ -132,41 +130,46 @@ export async function runApprove(opts: ApproveOptions): Promise } } - // 2. Socket scan gate (unless --no-scan). + // 2. Socket scan gate — mandatory. There is no flag or option that skips + // this: an approve that promotes unscanned bytes to the public registry is + // exactly the failure mode this gate exists to prevent. A missing/invalid + // SOCKET_API_TOKEN fails closed (refuses to approve) rather than proceeding + // without a scan. const scanResults: ScanResult[] = [] - if (!opts.noScan) { - const ctx = await preflightSocketScanAuth() - if (!ctx) { - logger.fail('Socket scan auth failed — refusing to approve unscanned bytes.') - return { approved: [], failed: verified.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [] } - } - const passed: StagedEntry[] = [] - for (const entry of verified) { - const res = await scanTarball(ctx, entry.name, entry.version, entry.shasum) - scanResults.push(res) - if (res.status === 'passed') passed.push(entry) - else logger.fail(`scan ${res.status}: ${entry.name}@${entry.version} dropped from approve`) - } - if (passed.length === 0) { - logger.fail('No staged entries passed the scan gate — not approving.') - return { approved: [], failed: verified.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults } - } - if (passed.length !== verified.length) { - const dropped = verified.filter(e => !passed.includes(e)) - logger.fail( - `Partial scan — refusing to approve. ${dropped.length} of ${verified.length} entries did not pass the scan gate: ` + - `${dropped.map(e => `${e.name}@${e.version}`).join(', ')}.`, - ) - return { - approved: [], - failed: verified.map(e => `${e.name}@${e.version}`), - registryLive: false, - scanResults, - } + const ctx = await preflightSocketScanAuth() + if (!ctx) { + logger.fail( + 'Socket scan auth failed — refusing to approve unscanned bytes.\n' + + ' Fix: set SOCKET_API_TOKEN (ask a maintainer if it is not already provisioned) and re-run.', + ) + return { approved: [], failed: verified.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults: [] } + } + const passed: StagedEntry[] = [] + for (const entry of verified) { + const res = await scanTarball(ctx, entry.name, entry.version, entry.shasum) + scanResults.push(res) + if (res.status === 'passed') passed.push(entry) + else logger.fail(`scan ${res.status}: ${entry.name}@${entry.version} dropped from approve`) + } + if (passed.length === 0) { + logger.fail('No staged entries passed the scan gate — not approving.') + return { approved: [], failed: verified.map(e => `${e.name}@${e.version}`), registryLive: false, scanResults } + } + if (passed.length !== verified.length) { + const dropped = verified.filter(e => !passed.includes(e)) + logger.fail( + `Partial scan — refusing to approve. ${dropped.length} of ${verified.length} entries did not pass the scan gate: ` + + `${dropped.map(e => `${e.name}@${e.version}`).join(', ')}.`, + ) + return { + approved: [], + failed: verified.map(e => `${e.name}@${e.version}`), + registryLive: false, + scanResults, } - verified.length = 0 - verified.push(...passed) } + verified.length = 0 + verified.push(...passed) // 3. OTP resolution (last, after every slow gate). if (!opts.otp && !opts.yes && !process.stdin.isTTY) { diff --git a/scripts/publish/pipeline.mts b/scripts/publish/pipeline.mts index 89bb733003..de76d82964 100644 --- a/scripts/publish/pipeline.mts +++ b/scripts/publish/pipeline.mts @@ -45,6 +45,8 @@ export interface PipelineState { staged: string[] verified: string[] scanResults: ScanResult[] + /** True when the scan gate could not run at all (e.g. bad/missing SOCKET_API_TOKEN) — distinct from "ran and found nothing". */ + scanBlocked?: boolean approved?: string[] registryLive?: boolean released?: boolean @@ -133,32 +135,45 @@ async function dispatchStageWorkflow( async function verifyAndScan( version: string, state: PipelineState, - config: { noScan?: boolean }, ): Promise { const staged = (await listStagedEntries(rootPath)).filter(e => 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 + // later refuses it — the @perryts/* packages are a fixed release set and a + // partial stage ships a broken install if promoted. + const stagedNames = new Set(staged.map(e => e.name)) + const missing = ALL_PACKAGES.filter(n => !stagedNames.has(n)) + if (missing.length > 0) { + logger.fail(`Partial staged set — missing: ${missing.join(', ')}. Not all 9 @perryts/* packages are staged.`) + } const verified: StagedEntry[] = [] for (const entry of staged) { if (await verifyStagedEntry(entry)) verified.push(entry) } state.verified = verified.map(e => `${e.name}@${e.version}`) - if (!config.noScan) { - const ctx = await preflightSocketScanAuth() - if (ctx) { - const results: ScanResult[] = [] - for (const entry of verified) { - results.push(await scanTarball(ctx, entry.name, entry.version, entry.shasum)) - } - state.scanResults = results - const failed = results.filter(r => r.status !== 'passed') - if (failed.length > 0) { - logger.fail(`scan gate: ${failed.length} entry/entries did not pass — fix before approve.`) - } - } else { - logger.fail('Socket scan auth failed — scan skipped. Re-run with SOCKET_API_TOKEN set.') + // The scan gate is mandatory — there is no flag to skip it. A missing/invalid + // SOCKET_API_TOKEN fails closed (state.scanResults stays empty and the + // pipeline refuses to report a clean scan) rather than silently proceeding. + const ctx = await preflightSocketScanAuth() + if (ctx) { + const results: ScanResult[] = [] + for (const entry of verified) { + results.push(await scanTarball(ctx, entry.name, entry.version, entry.shasum)) } + state.scanResults = results + state.scanBlocked = false + const failed = results.filter(r => r.status !== 'passed') + if (failed.length > 0) { + logger.fail(`scan gate: ${failed.length} entry/entries did not pass — fix before approve.`) + } + } else { + state.scanBlocked = true + logger.fail( + 'Socket scan auth failed — the scan did not run and cannot be skipped.\n' + + ' Fix: set SOCKET_API_TOKEN (ask a maintainer if it is not already provisioned) and re-run.', + ) } state.updatedAt = new Date().toISOString() writeState(state) @@ -169,7 +184,11 @@ function printStatus(state: PipelineState): void { logger.log(`=== publish pipeline: v${state.version} ===`) logger.log(` staged: ${state.staged.length ? state.staged.join(', ') : '(none)'}`) logger.log(` verified: ${state.verified.length ? state.verified.join(', ') : '(none)'}`) - logger.log(` scan: ${state.scanResults.length} scanned, ${state.scanResults.filter(r => r.status !== 'passed').length} not-passed`) + logger.log( + state.scanBlocked + ? ' scan: BLOCKED — did not run (SOCKET_API_TOKEN missing/invalid); re-run publish:scan once it is set' + : ` scan: ${state.scanResults.length} scanned, ${state.scanResults.filter(r => r.status !== 'passed').length} not-passed`, + ) logger.log(` approved: ${state.approved?.length ? state.approved.join(', ') : '(not approved)'}`) logger.log(` registry: ${state.registryLive ? 'live' : 'not live'}`) logger.log(` released: ${state.released ? 'yes' : 'no'}`) @@ -181,6 +200,11 @@ async function main(): Promise { // that happens to equal a flag name (e.g. `--tag --approve`) can't mis-route // either, because `--approve` is consumed as `--tag`'s value here. const VALUE_OPTIONS = new Set(['--tag', '--otp']) + // A mode flag is required — there is no implicit default mode. Running the + // script with zero (or only --dry-run) arguments used to silently dispatch a + // REAL staged publish; now it falls through to the usage error below instead. + const MODE_FLAGS = new Set(['--stage-only', '--scan-only', '--approve', '--release-only', '--status']) + const MODIFIER_FLAGS = new Set(['--dry-run', '--yes']) const flags = new Set() let tag = 'latest' let otp: string | undefined @@ -198,7 +222,20 @@ async function main(): Promise { } } const dryRun = flags.has('--dry-run') - const noScan = flags.has('--no-scan') + const mode = [...flags].find(f => MODE_FLAGS.has(f)) + const unknown = [...flags].filter(f => !MODE_FLAGS.has(f) && !MODIFIER_FLAGS.has(f)) + + if (!mode || unknown.length > 0) { + logger.fail( + (!mode + ? 'No mode flag given — refusing to guess. ' + : `Unknown flag(s): ${unknown.join(', ')}. `) + + 'Usage: publish:pipeline <--stage-only | --scan-only | --approve | --release-only | --status> ' + + '[--dry-run] [--yes] [--otp ] [--tag ]', + ) + process.exitCode = 1 + return + } // Resolve the version from the gate. const gate = await checkVersionGate(rootPath) @@ -228,26 +265,25 @@ async function main(): Promise { return } - if (flags.has('--stage-only') || flags.size === 0 || (flags.size === 1 && dryRun)) { + if (mode === '--stage-only') { if (!(await dispatchStageWorkflow(gate.version, { dryRun, tag }))) { process.exitCode = 1 return } - state = await verifyAndScan(gate.version, state, { noScan }) + state = await verifyAndScan(gate.version, state) printStatus(state) logger.log(formatApproveGate({ version: gate.version, repoPath: rootPath })) return } - if (flags.has('--scan-only')) { - state = await verifyAndScan(gate.version, state, { noScan }) + if (mode === '--scan-only') { + state = await verifyAndScan(gate.version, state) printStatus(state) return } - if (flags.has('--approve')) { + if (mode === '--approve') { const receipt = await runApprove({ - noScan, yes: flags.has('--yes'), otp, }) @@ -280,7 +316,7 @@ async function main(): Promise { return } - if (flags.has('--release-only')) { + if (mode === '--release-only') { if (!state.registryLive) { logger.fail(`No approved+live receipt for v${gate.version} — run publish:approve first.`) process.exitCode = 1 @@ -293,11 +329,6 @@ async function main(): Promise { if (!released) process.exitCode = 1 return } - - logger.fail( - 'Unknown flags. Usage: publish:pipeline [--stage-only | --scan-only | --approve | --release-only | --status] [--dry-run] [--no-scan] [--yes] [--otp ] [--tag ]', - ) - process.exitCode = 1 } // Only run when invoked directly (node scripts/publish/pipeline.mts …), not diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 22effd2ea9..40fabfcdd1 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -9,6 +9,9 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { renderPerryFormula } from './brew/formula.mts' import { summarizePolicyAlerts, normalizeFullScanArtifacts } from './scan.mts' @@ -18,6 +21,8 @@ import { compareSemver, extractFirstJson } from './shared.mts' import { parseStageListJson } from './npm/shared.mts' import { NPM_MIN_VERSION } from './constants.mts' +const PUBLISH_DIR = path.dirname(fileURLToPath(import.meta.url)) + test('renderPerryFormula: macos arm64/x86_64 binaries + linux source build', () => { const f = renderPerryFormula({ version: '0.5.1510', @@ -213,6 +218,82 @@ test('compareSemver: major/minor/patch ordering', () => { assert.ok(compareSemver('11.15.0', '11.17.0') < 0) }) +// Regression coverage for the incident class this repo has already been +// bitten by once: a bare `import` of an entry-point module firing a REAL +// side-effecting command (a real `cargo publish` fired from an import-test +// with no isMainModule guard). Static, not a live import — asserting this by +// actually importing these modules would itself risk running `main()` again +// if a future edit ever breaks the guard, which is exactly the bug this test +// exists to catch before it can do that. +const MAIN_GUARDED_ENTRYPOINTS = [ + 'pipeline.mts', + 'auth-posture.mts', + 'brew/tap-publish.mts', + 'cargo/ffi-publish.mts', +] as const + +function assertMainGuardIsTrailing(relPath: string): void { + const src = readFileSync(path.join(PUBLISH_DIR, relPath), 'utf8') + const guardRe = /if\s*\(\s*(?:isMainModule|process\.argv\[1\]\s*===\s*fileURLToPath\(new URL\(import\.meta\.url\)\))\s*\)\s*\{/ + const m = guardRe.exec(src) + assert.ok(m, `${relPath}: missing the isMainModule guard around its entry call`) + // Walk brace depth from the guard's opening `{` to find its matching close, + // then require everything after that close to be blank/whitespace — i.e. + // the guard is the LAST top-level statement, so nothing side-effecting can + // be reintroduced below it. + let depth = 0 + let i = m.index + m[0].length - 1 + for (; i < src.length; i += 1) { + if (src[i] === '{') depth += 1 + else if (src[i] === '}') { + depth -= 1 + if (depth === 0) break + } + } + assert.ok(i < src.length, `${relPath}: unbalanced braces while scanning the guard block`) + const trailing = src.slice(i + 1) + assert.equal( + trailing.trim(), + '', + `${relPath}: code follows the isMainModule guard — a bare import would run it as a side effect`, + ) +} + +for (const entry of MAIN_GUARDED_ENTRYPOINTS) { + test(`${entry}: main() only runs behind the isMainModule guard, which is the last statement`, () => { + assertMainGuardIsTrailing(entry) + }) +} + +test('pipeline.mts: no mode flag is a usage error, not a default action', () => { + const src = readFileSync(path.join(PUBLISH_DIR, 'pipeline.mts'), 'utf8') + // The zero-flag footgun this guards against: running the script with no + // arguments used to silently dispatch a REAL staged publish (flags.size + // === 0 fell into the --stage-only branch). There must be no code path + // that treats an empty/absent mode as --stage-only. + assert.doesNotMatch( + src, + /flags\.size\s*===\s*0/, + 'pipeline.mts must not special-case zero flags into a default mode', + ) + assert.match( + src, + /No mode flag given/, + 'pipeline.mts must refuse to run without an explicit mode flag', + ) +}) + +test('pipeline.mts / approve.mts: the socket scan gate has no skip flag', () => { + for (const rel of ['pipeline.mts', 'npm/approve.mts']) { + const src = readFileSync(path.join(PUBLISH_DIR, rel), 'utf8') + assert.doesNotMatch( + src, + /no-scan|noScan/, + `${rel}: the Socket scan gate must not have a skip flag/option — it is mandatory`, + ) + } +}) + test('NPM_MIN_VERSION floor covers staged publishing + min-release-age', () => { // `npm stage` landed in 11.15.0; min-release-age (DAYS) needs >= 11.17. // The floor must be at least both (and OIDC's 11.5.1). From 1431f6aa9f11469dd0b92a833955c1073dc1f6ae Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Thu, 20 Aug 2026 00:56:02 -0400 Subject: [PATCH 2/4] changelog: add fragment for #8444 --- changelog.d/8444-publish-pipeline-hardening.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8444-publish-pipeline-hardening.md diff --git a/changelog.d/8444-publish-pipeline-hardening.md b/changelog.d/8444-publish-pipeline-hardening.md new file mode 100644 index 0000000000..71300557c3 --- /dev/null +++ b/changelog.d/8444-publish-pipeline-hardening.md @@ -0,0 +1 @@ +Hardened the npm staged-publish pipeline after an end-to-end adversarial review: the Socket scan gate can no longer be skipped (`--no-scan` removed, missing/invalid `SOCKET_API_TOKEN` now fails closed and reports `BLOCKED` instead of looking like a clean scan); `scripts/publish/pipeline.mts` no longer silently dispatches a real staged publish when run with no arguments (a mode flag is now required, unknown flags are rejected); `release-packages.yml`'s `republish` mode now pins every checkout to the tag being republished and cross-checks the tag's `Cargo.toml` before proceeding, closing a version-drift path where a `main` that advanced past the tag could silently publish a different version under the old tag's label; `npm-stage-publish.yml`'s `build-run-id` reuse is now verified (workflow identity, success, exact commit) before its artifacts are trusted; both workflows gained a `concurrency:` group keyed on tag/dist-tag; and the stage-upload step is now actually idempotent, skipping packages a prior partial run already staged instead of re-attempting all 9. From 3147a9dce21f75bb8cb126f7a7f6d0b6b8524b1f Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Thu, 20 Aug 2026 01:14:53 -0400 Subject: [PATCH 3/4] fix(publish): address CodeRabbit findings on PR 8444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm-stage-publish.yml: pin the fresh release-packages.yml dispatch to this run's exact commit via --ref, and match the resulting run by headSha (not just createdAt) — otherwise the dispatch could target the default branch's tip instead of the commit this workflow actually checked out, or the run-matching could grab a concurrent dispatch of the same workflow from a different ref. - pipeline.mts: reject two conflicting mode flags (e.g. --stage-only --approve) instead of silently picking whichever came first in argv. Both were flagged by CodeRabbit's automated review. Added a regression test spawning the real CLI with conflicting mode flags to prove it fails closed. --- .github/workflows/npm-stage-publish.yml | 34 +++++++++++++++++-------- scripts/publish/pipeline.mts | 11 +++++--- scripts/publish/publish.test.mts | 23 +++++++++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index f0c50c59d2..ce81e6ec2b 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -78,6 +78,11 @@ jobs: EXISTING: ${{ inputs.build-run-id }} run: | set -euo pipefail + # The commit THIS workflow run is dispatching from — every downstream + # check (reused-run headSha match, or the fresh dispatch's --ref and + # run-selection below) pins to this, never to "whatever the default + # branch happens to be at the moment we call gh". + CUR_SHA=$(git rev-parse HEAD) if [ -n "$EXISTING" ]; then RUN_ID="$EXISTING" # Validate the reused run before trusting its artifacts: it must @@ -85,7 +90,6 @@ jobs: # and it must have built the commit we're dispatching from — a # stale or wrong-workflow run-id would otherwise stage old/wrong # binaries under today's version and dist-tag with no error. - CUR_SHA=$(git rev-parse HEAD) RUN_JSON=$(gh run view "$RUN_ID" -R "$REPO" --json workflowName,conclusion,status,headSha) RUN_WORKFLOW=$(echo "$RUN_JSON" | jq -r '.workflowName') RUN_STATUS=$(echo "$RUN_JSON" | jq -r '.status') @@ -105,30 +109,38 @@ jobs: fi echo "Reusing build run $RUN_ID (verified: Release Packages, success, sha $RUN_SHA)." else - # Dispatch release-packages.yml in stage mode. Stage mode is the + # Dispatch release-packages.yml in stage mode, pinned to OUR exact + # commit via --ref. 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 + # the gap between the two dispatches) — and would build/stage the + # wrong commit's binaries under today's version. Stage mode is the # DEFAULT workflow_dispatch (no inputs = build-only smoke run; the - # preflight job's else-branch sets MODE=stage), so no input is needed. + # 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" + gh workflow run release-packages.yml -R "$REPO" --ref "$CUR_SHA" # Poll for the workflow_dispatch run we just created. Match by - # event + createdAt after the dispatch timestamp — NOT just the - # newest run, which could be a concurrent or unrelated dispatch and - # would stage the wrong artifacts. + # event + createdAt AFTER the dispatch timestamp AND headSha == + # our commit — createdAt alone could still match a concurrent + # dispatch of the SAME workflow from a different ref that happened + # to land in the same window, which would stage the wrong commit. sleep 5 RUN_ID="" for i in $(seq 1 20); do RUN_ID=$(gh run list --workflow release-packages.yml -R "$REPO" \ --event workflow_dispatch --limit 5 \ - --json databaseId,createdAt \ - --jq "[.[] | select(.createdAt >= \"$DISPATCHED_AT\")][0].databaseId" 2>/dev/null || true) + --json databaseId,createdAt,headSha \ + --jq "[.[] | select(.createdAt >= \"$DISPATCHED_AT\" and .headSha == \"$CUR_SHA\")][0].databaseId" 2>/dev/null || true) if [ -n "$RUN_ID" ]; then break; fi sleep 3 done if [ -z "$RUN_ID" ]; then - echo "::error::could not resolve a release-packages.yml run id after dispatch." >&2 + echo "::error::could not resolve a release-packages.yml run id (dispatched at $CUR_SHA) after dispatch." >&2 exit 1 fi - echo "Dispatched release-packages.yml stage build: run $RUN_ID." + echo "Dispatched release-packages.yml stage build: run $RUN_ID (sha $CUR_SHA)." fi echo "build-run-id=$RUN_ID" >> "$GITHUB_OUTPUT" - name: Await the stage-mode build diff --git a/scripts/publish/pipeline.mts b/scripts/publish/pipeline.mts index de76d82964..0e853524bf 100644 --- a/scripts/publish/pipeline.mts +++ b/scripts/publish/pipeline.mts @@ -222,20 +222,23 @@ async function main(): Promise { } } const dryRun = flags.has('--dry-run') - const mode = [...flags].find(f => MODE_FLAGS.has(f)) + const modesGiven = [...flags].filter(f => MODE_FLAGS.has(f)) const unknown = [...flags].filter(f => !MODE_FLAGS.has(f) && !MODIFIER_FLAGS.has(f)) - if (!mode || unknown.length > 0) { + if (modesGiven.length !== 1 || unknown.length > 0) { logger.fail( - (!mode + (modesGiven.length === 0 ? 'No mode flag given — refusing to guess. ' - : `Unknown flag(s): ${unknown.join(', ')}. `) + + : modesGiven.length > 1 + ? `Conflicting mode flags: ${modesGiven.join(', ')} — refusing to guess which one wins. ` + : `Unknown flag(s): ${unknown.join(', ')}. `) + 'Usage: publish:pipeline <--stage-only | --scan-only | --approve | --release-only | --status> ' + '[--dry-run] [--yes] [--otp ] [--tag ]', ) process.exitCode = 1 return } + const mode = modesGiven[0]! // Resolve the version from the gate. const gate = await checkVersionGate(rootPath) diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 40fabfcdd1..226ae56558 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -11,6 +11,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { renderPerryFormula } from './brew/formula.mts' @@ -283,6 +285,27 @@ test('pipeline.mts: no mode flag is a usage error, not a default action', () => ) }) +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 + // --approve` ran --stage-only (or, with the args reversed, --approve) + // instead of refusing an ambiguous invocation. This spawns the real CLI: + // the conflict must be caught before any gh/network call, so this is safe + // to exercise directly rather than only asserting against the source text. + const result = spawnSync( + process.execPath, + [path.join(PUBLISH_DIR, 'pipeline.mts'), '--stage-only', '--approve'], + { encoding: 'utf8' }, + ) + assert.equal(result.status, 1, 'conflicting mode flags must exit non-zero') + const output = `${result.stdout}${result.stderr}` + assert.match( + output, + /Conflicting mode flags/, + 'pipeline.mts must name the conflict rather than silently choosing a mode', + ) +}) + test('pipeline.mts / approve.mts: the socket scan gate has no skip flag', () => { for (const rel of ['pipeline.mts', 'npm/approve.mts']) { const src = readFileSync(path.join(PUBLISH_DIR, rel), 'utf8') From 3382a8d3e68f7a619f825d684b59f093ee71c221 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Thu, 20 Aug 2026 01:30:20 -0400 Subject: [PATCH 4/4] fix(publish): wire SOCKET_API_TOKEN as a CI secret and gate stage-upload on it npm-stage-publish.yml now references secrets.SOCKET_API_TOKEN and adds a "Socket scan the staged tarballs" step right after staging, using pipeline.mts's --scan-only path (same verify+scan code the local flow uses, not a duplicate). Previously the Socket scan gate only ran when a human later remembered to run `publish:approve` correctly on their own machine with SOCKET_API_TOKEN set locally -- CI staged all 9 packages with zero scanning in between. Now every staged upload is scanned immediately in CI, and the workflow fails if it isn't clean. Also fixes --scan-only itself: it printed the scan status but never set a failing exit code on a blocked/incomplete/not-passed scan, which would have made the new CI step unable to fail no matter what the scan found. --- .github/workflows/npm-stage-publish.yml | 11 +++++++++++ scripts/publish/pipeline.mts | 13 +++++++++++++ scripts/publish/publish.test.mts | 15 +++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/.github/workflows/npm-stage-publish.yml b/.github/workflows/npm-stage-publish.yml index ce81e6ec2b..167eb13b33 100644 --- a/.github/workflows/npm-stage-publish.yml +++ b/.github/workflows/npm-stage-publish.yml @@ -172,6 +172,7 @@ jobs: DIST_TAG: ${{ inputs.dist-tag }} BUILD_RUN_ID: ${{ needs.resolve-build.outputs.build-run-id }} PUBLISH: ${{ inputs.publish }} + SOCKET_API_TOKEN: ${{ secrets.SOCKET_API_TOKEN }} steps: - uses: actions/checkout@v7 @@ -287,3 +288,13 @@ jobs: exit 1 fi echo "All 9 packages staged (not public). Next: npm run publish:approve locally." + + - name: Socket scan the staged tarballs + # Mandatory, same as the local pipeline — CI scans what it just staged + # immediately, rather than leaving the ONLY scan gate to whichever + # human later remembers to run `publish:approve` correctly. Reuses + # pipeline.mts's --scan-only path (list staged entries, sha1-verify, + # Socket full scan) instead of duplicating that logic here — same + # code, same gate, whether it runs in CI or locally. + if: env.PUBLISH == 'true' + run: node scripts/publish/pipeline.mts --scan-only diff --git a/scripts/publish/pipeline.mts b/scripts/publish/pipeline.mts index 0e853524bf..820c324795 100644 --- a/scripts/publish/pipeline.mts +++ b/scripts/publish/pipeline.mts @@ -282,6 +282,19 @@ async function main(): Promise { if (mode === '--scan-only') { state = await verifyAndScan(gate.version, state) printStatus(state) + // Unlike --stage-only (where a human sees the printed status and + // 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) { + process.exitCode = 1 + } return } diff --git a/scripts/publish/publish.test.mts b/scripts/publish/publish.test.mts index 226ae56558..de1c83a6c7 100644 --- a/scripts/publish/publish.test.mts +++ b/scripts/publish/publish.test.mts @@ -285,6 +285,21 @@ test('pipeline.mts: no mode flag is a usage error, not a default action', () => ) }) +test('pipeline.mts: --scan-only sets a failing exit code on an incomplete/blocked/not-passed scan', () => { + // --scan-only is what CI's "Socket scan the staged tarballs" step relies on + // as a gate (npm-stage-publish.yml) — unlike --stage-only, where a human + // reads the printed status and publish:approve is the real enforcement + // point, a CI caller only sees the exit code. Assert the branch actually + // sets process.exitCode = 1 rather than always returning 0. + const src = readFileSync(path.join(PUBLISH_DIR, 'pipeline.mts'), 'utf8') + const scanOnlyBranch = src.slice(src.indexOf("mode === '--scan-only'")) + assert.match( + scanOnlyBranch, + /process\.exitCode = 1/, + '--scan-only must set a non-zero exit code when the scan did not fully pass', + ) +}) + 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