Skip to content

chore(ci): drop floating npm@latest from publish; assert the Trusted-Publishing npm floor - #81

Merged
PrzemekGalarowicz merged 2 commits into
mainfrom
chore/publish-npm-floor-assert
Aug 10, 2026
Merged

chore(ci): drop floating npm@latest from publish; assert the Trusted-Publishing npm floor#81
PrzemekGalarowicz merged 2 commits into
mainfrom
chore/publish-npm-floor-assert

Conversation

@PrzemekGalarowicz

@PrzemekGalarowicz PrzemekGalarowicz commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The finding

publish.yml ran npm install -g npm@latest inside the repo's highest-trust job — OIDC Trusted Publishing, id-token: write, the npm-publish environment. Whatever npm shipped that day executed the release.

Two facts made the fix delete, not pin:

1. The install is unnecessary. Its own comment gives the only reason — "Trusted Publishing requires npm >= 11.5.1" — but the digest-pinned setup-node with node-version: 24 resolves to the latest released v24.x, and that release (v24.19.0, checked 2026-08-10) bundles npm 11.17.0. Verified against the tag itself, not the branch head. npm's own docs confirm the floor number rather than us inheriting the workflow's claim:

Trusted publishing requires npm CLI version 11.5.1 or later and Node version 22.14.0 or higher.

Node 24 clears both halves. The publish job can run zero publish-time tool downloads.

2. The floating step had already crossed a major, silently. The registry latest dist-tag is npm 12.0.2 today — so every release since npm 12 shipped published with a major the workflow was never written against. Same shape as #79's moved v6 tag: a live demonstration of why @latest rots in a release path.

Repo scan: this was the only floating tool pull in any workflow. gitleaks' curl is version- and SHA-256-pinned; npm ci is lockfile-pinned. runs-on: ubuntu-latest still floats — platform-level, out of axis, observed.

The fix

Part 1 — delete + enforce. The step is replaced by an Assert npm floor step: pure node stdlib, no packages, no network, a three-part integer semver compare that exits non-zero naming both versions. The floor is now enforced instead of installed, and the release job downloads no third-party tool at all.

Fail-closed rests on the parse, not the shell. set -euo pipefail does not abort on a command substitution used as an argument — reproduced:

$ bash -c 'set -euo pipefail; echo "[$(nonexistent)]"; echo REACHED'; echo $?
[]
REACHED
0

So a failing npm --version arrives as "" and exits 1 as an unparseable version. The step comment says exactly this rather than crediting the shell.

Part 2 — the enforcement gate (.dev/floor/check-run-pins.mjs). check-action-pins scans uses: refs only; a run: line pulling @latest was outside its contract. The new gate classifies every package spec in a recognised install/exec invocation across workflows and local composite actions, requiring an exact semver. Enum reasons: floating-version / unpinned-package / unpinnable-version / unreadable-file. Verdict is violations.length > 0 — an integer test, so no decision reads the untrusted spec it copies verbatim.

Wired with no workflow change: floor.yml's existing node --test ".dev/**/*.test.mjs" glob collects its live repo-consistency test on every PR.

Named residuals, in the file's own header: non-package-manager pulls (curl | sh, and npm run <script> executing a package.json script), the walker duplicated from check-action-pins (backstopped by a same-files[] cross-check test), shell indirection, value-separated flags, and a symlink asymmetry inherited from the sibling gate (pinned by a test asserting both gates agree).

Proof of record

publish.yml fires only on release: published, so no gate in this repo can execute it. The assert program was therefore extracted from the shipped publish.yml bytes and run against fake inputs:

npm --version exit output
11.5.0 1 npm floor NOT met: found 11.5.0, need >= 11.5.1
11.5.1 0 npm floor OK — inclusive boundary
11.17.0 0 npm floor OK
12.0.2 0 npm floor OK
"" 1 npm floor: unparseable version ""
garbage 1 npm floor: unparseable version "garbage"

The whole step body also executes clean against real npm (npm floor OK: npm 11.12.1 >= 11.5.1). Actions parsing this workflow on the PR is the syntax proof.

41 tests ship with the gate, including a positive control that reads the live publish.yml, mutates one line back to npm install -g npm@latest, and asserts it is caught — because this repo's live numbers are checked: 0, and exit 0 is also what a checker returns when it finds nothing to inspect.

The gate also asserts the assert still exists: deleting - name: Assert npm floor fails the suite, so the guarantee is protected against reversion and deletion.

For the releaser — verification at the next release

Execution proof of record is the next release. In that run, look for the Assert npm floor step green, printing npm floor OK: npm <version> >= 11.5.1. Note the version it reports: that is the npm actually bundled with the node the runner installed. If it ever fails, the message names both the found version and the floor — the fix is a pinned exact npm install -g npm@<version> plus the assert, never @latest.

Not in this PR

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Publishing now verifies that npm 11.5.1 or newer is available before release.
    • Publishing no longer downloads an additional npm version when the required version is present.
    • Added automated checks to detect unpinned package-manager commands in workflows and local actions.
  • Bug Fixes

    • Publishing fails safely when npm is missing, malformed, or below the required version.
  • Documentation

    • Updated release instructions to describe the npm requirement and publishing behavior.
  • Tests

    • Added comprehensive validation for npm version checks and package pinning.

…Publishing npm floor

publish.yml ran `npm install -g npm@latest` inside the repo's highest-trust job
— OIDC Trusted Publishing, `id-token: write`, the `npm-publish` environment —
so whatever npm shipped that day executed the release.

Two facts made the fix "delete", not "pin".

1. The install is unnecessary. Its own comment gives the only reason
   ("Trusted Publishing requires npm >= 11.5.1"), but the digest-pinned
   setup-node with node-version: 24 resolves to the latest released v24.x, and
   that release — v24.19.0 as of 2026-08-10 — bundles npm 11.17.0, verified
   against the tag itself rather than the branch head. npm's own docs confirm
   the floor: "Trusted publishing requires npm CLI version 11.5.1 or later and
   Node version 22.14.0 or higher." The publish job can run zero publish-time
   tool downloads.

2. The floating step had already crossed a major, silently. The registry
   `latest` dist-tag is npm 12.0.2 today, so every release since npm 12 shipped
   published with a major the workflow was never written against — the live
   demonstration of why @latest rots in a release path.

The step is replaced by an "Assert npm floor" step: pure node stdlib, no
packages, no network, a three-part integer semver compare that exits non-zero
naming both versions. The floor is now enforced instead of installed, and the
release job downloads no third-party tool at all.

Fail-closed rests on the PARSE, not the shell. `set -euo pipefail` does NOT
abort on a command substitution used as an argument (reproduced:
`bash -c 'set -euo pipefail; echo "[$(nonexistent)]"; echo REACHED'` prints
both and exits 0), so a failing `npm --version` arrives as "" and exits 1 as an
unparseable version.

Also adds .dev/floor/check-run-pins.mjs so this cannot silently regress.
check-action-pins scans `uses:` refs only; a `run:` line pulling @latest was
outside its contract. The new gate classifies every package spec in a
recognised install/exec invocation across workflows and local composite
actions, requiring an exact semver — enum reasons floating-version /
unpinned-package / unpinnable-version / unreadable-file. It is wired with no
workflow change: floor.yml's existing `node --test ".dev/**/*.test.mjs"` glob
collects its live repo-consistency test on every PR.

Proof of record, since publish.yml only fires on `release: published` and no
gate in this repo can execute it: the assert program was extracted from the
shipped publish.yml bytes and run against 11.5.0 (exit 1), 11.5.1 (exit 0,
inclusive), 11.17.0 (0), 12.0.2 (0), "" (1) and "garbage" (1); the whole step
body also executes clean against real npm. Execution proof is the next release.

Named residual, not fixed here: node-version: 24 floats within the major, so
the bundled npm floats across Node-vetted 11.x releases. The assert catches any
dip below the floor; pinning node exactly is the node-version-policy follow-up
(third mention — it has earned its own ticket).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PrzemekGalarowicz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ad13bd89-c953-4b39-bb59-528e80d4cf7b

📥 Commits

Reviewing files that changed from the base of the PR and between 2534760 and 88af5fb.

📒 Files selected for processing (2)
  • .dev/floor/check-run-pins.mjs
  • .dev/floor/check-run-pins.test.mjs
📝 Walkthrough

Walkthrough

The publish workflow now validates the installed npm version instead of installing npm@latest. A new checker scans workflow commands for unpinned packages. Tests, documentation, verification reports, and release records cover the change.

Changes

Publish npm floor enforcement

Layer / File(s) Summary
Publish contract and workflow assertion
.dev/features/publish-npm-floor-assert/PLAN.md, .github/workflows/publish.yml, docs/RELEASING.md
The workflow removes the global npm@latest installation and accepts only npm 11.5.1 or newer. Release documentation describes the new assertion.
Run-line pin checker and conformance tests
.dev/floor/check-run-pins.mjs, .dev/floor/check-run-pins.test.mjs
The checker scans workflow and local action files for unpinned package-manager commands. Tests cover parsing, exclusions, enumeration, live repository behavior, and positive controls.
Verification and release records
.dev/features/publish-npm-floor-assert/*, .pharn/pharn-dev-verify/results.json, .pharn/writes-scope.json
Review, regression, verification, and ship records document passing gates, findings, scope, and the non-merge decision.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReleaseEvent
  participant PublishWorkflow
  participant Npm
  ReleaseEvent->>PublishWorkflow: start published-release job
  PublishWorkflow->>Npm: run npm --version
  Npm-->>PublishWorkflow: return installed version
  PublishWorkflow->>PublishWorkflow: enforce npm >= 11.5.1
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: removing floating npm@latest installation and asserting the Trusted Publishing npm version floor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/publish-npm-floor-assert

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
.dev/floor/check-run-pins.test.mjs (1)

46-48: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Scope cleanup to the directories this run created.

cleanup deletes every pharn-runpins-* directory in the shared tmpdir(). If two runs of this file overlap on one machine, for example a CI matrix or a local watch run, one run deletes the other run's scratch directories while they are still in use. Track the created paths and remove only those.

♻️ Proposed refactor
+const CREATED = [];
 function scratch() {
-  return mkdtempSync(join(tmpdir(), "pharn-runpins-"));
+  const dir = mkdtempSync(join(tmpdir(), "pharn-runpins-"));
+  CREATED.push(dir);
+  return dir;
 }
 test("cleanup", () => {
-  for (const d of readdirSync(tmpdir())) {
-    if (d.startsWith("pharn-runpins-")) rmSync(join(tmpdir(), d), { recursive: true, force: true });
-  }
+  for (const d of CREATED) rmSync(d, { recursive: true, force: true });
 });

readdirSync stays in use for the live workflow recount at line 358.

Also applies to: 428-432

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.dev/floor/check-run-pins.test.mjs around lines 46 - 48, Update the
scratch-directory lifecycle around scratch() and cleanup so each run tracks the
paths returned by mkdtempSync, and cleanup removes only those tracked
directories instead of scanning and deleting every pharn-runpins-* entry under
tmpdir(). Preserve the existing readdirSync usage for live workflow recounting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.dev/features/publish-npm-floor-assert/GRILL.md:
- Line 148: Update the sentence in GRILL.md so the issue references `#15` and `#79`
are wrapped in inline code spans, preventing markdownlint from interpreting the
line as an ATX heading while preserving the existing wording.

In @.dev/features/publish-npm-floor-assert/PLAN.md:
- Around line 45-88: The plan’s “literal diff” and related intent sections are
stale; update them to match the shipped workflow and checker. In Part 1, copy
the current publish.yml comment block wording, including the check-run-pins/R1
and fail-closed-parse paragraphs and revised ASSERTS sentence; change the
output-field reference from spec to ref; and revise the residual list to include
R1–R6 plus the YAML-fidelity bound, using the names emitted by
check-run-pins.mjs.

In @.dev/features/publish-npm-floor-assert/SHIP.md:
- Around line 37-45: Update the post-GATE-2 disposition in SHIP.md to list the
floor-tests repair as the third repaired REVIEW finding, then classify the
PLAN.md determinism correction separately as a GRILL finding. Keep the existing
workflow-comment and R5 entries, and align the wording with REVIEW.md’s P1/P2/P3
status.

In @.dev/features/publish-npm-floor-assert/VERIFY.md:
- Around line 65-67: Update the workflow validation test around the existing
presence-gate check to parse publish.yml structurally, then verify the npm-floor
assertion is within the release job, includes the 11.5.1 comparison, and appears
before the npm publish step. Replace the current independent regex checks with
these ordering and job-scope validations.

In @.dev/floor/check-run-pins.mjs:
- Around line 310-342: Update the per-file scanning flow around parseLine to
join shell continuation lines before parsing, removing the trailing backslash
and preserving the first physical line number for any findings or diagnostics.
Ensure commands such as a continued npm install are passed to parseLine as one
command so later package arguments are classified, and document or report the
continuation residual if the scanner remains strictly line-based.

In @.dev/floor/check-run-pins.test.mjs:
- Around line 317-328: Update the symlinked action.yml test to assert the
sibling gate’s expected status directly, rather than deriving it from
sibling.violations. Preserve the existing assertions for mine and the comparison
of violation lists so the sibling verdict is explicitly pinned.

---

Nitpick comments:
In @.dev/floor/check-run-pins.test.mjs:
- Around line 46-48: Update the scratch-directory lifecycle around scratch() and
cleanup so each run tracks the paths returned by mkdtempSync, and cleanup
removes only those tracked directories instead of scanning and deleting every
pharn-runpins-* entry under tmpdir(). Preserve the existing readdirSync usage
for live workflow recounting.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 776e5cbb-bee3-4031-8f2a-da49f676dfbf

📥 Commits

Reviewing files that changed from the base of the PR and between e08eb18 and 2534760.

📒 Files selected for processing (14)
  • .dev/features/publish-npm-floor-assert/GRILL.md
  • .dev/features/publish-npm-floor-assert/PLAN.md
  • .dev/features/publish-npm-floor-assert/REGRESSION.md
  • .dev/features/publish-npm-floor-assert/REVIEW.md
  • .dev/features/publish-npm-floor-assert/SHIP.md
  • .dev/features/publish-npm-floor-assert/VERIFY.md
  • .dev/features/publish-npm-floor-assert/regression-report.json
  • .dev/features/publish-npm-floor-assert/verify-report.json
  • .dev/floor/check-run-pins.mjs
  • .dev/floor/check-run-pins.test.mjs
  • .github/workflows/publish.yml
  • .pharn/pharn-dev-verify/results.json
  • .pharn/writes-scope.json
  • docs/RELEASING.md

Comment thread .dev/features/publish-npm-floor-assert/GRILL.md
Comment thread .dev/features/publish-npm-floor-assert/PLAN.md
Comment thread .dev/features/publish-npm-floor-assert/SHIP.md
Comment thread .dev/features/publish-npm-floor-assert/VERIFY.md
Comment thread .dev/floor/check-run-pins.mjs
Comment thread .dev/floor/check-run-pins.test.mjs
Backslash-continued run commands are merged before parseLine so package
args on the next line classify correctly, and the symlink parity test
pins the sibling gate exit code directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
@PrzemekGalarowicz
PrzemekGalarowicz merged commit 8ff7240 into main Aug 10, 2026
8 checks passed
@PrzemekGalarowicz
PrzemekGalarowicz deleted the chore/publish-npm-floor-assert branch August 10, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant