From 2ed1229b21351081034821d036477a2ff7446115 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:38:52 +0000 Subject: [PATCH] ci(publish): keep the dev toolchain out of the job that can mint the npm OIDC token (PHARN-07) publish.yml ran `npm ci` (dev-dependency install scripts) and `npm publish` (prepublishOnly: prettier, eslint, markdownlint, tsc, vitest; prepack: esbuild) in ONE job holding `id-token: write`, so any of ~280 dev dependencies could request the OIDC token and publish a malicious version with valid provenance. Now two jobs: - build (no id-token): strict `^vX.Y.Z$` tag == package.json version on a commit contained in main, checked BEFORE npm ci; then check + test:coverage, npm pack, smoke-install of the tarball, upload as an artifact. - publish (the only job with id-token + npm-publish env): Assert npm floor, download the tarball, `npm publish pkg/pharn-dev-pharn-.tgz --provenance --access public --ignore-scripts`. No checkout, no install. check-run-pins.test.mjs pins that the grant appears once, inside `publish`, which installs nothing; the live install count goes 9 -> 10. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TvcuVhk8hTeDskp5pAJhnc --- .dev/features/publish-split-oidc/GRILL.md | 29 ++++++ .dev/features/publish-split-oidc/PLAN.md | 68 ++++++++++++++ .../features/publish-split-oidc/REGRESSION.md | 30 ++++++ .dev/features/publish-split-oidc/REVIEW.md | 46 +++++++++ .dev/features/publish-split-oidc/SHIP.md | 17 ++++ .dev/features/publish-split-oidc/VERIFY.md | 27 ++++++ .../publish-split-oidc/regression-report.json | 22 +++++ .../publish-split-oidc/verify-report.json | 17 ++++ .dev/floor/check-run-pins.test.mjs | 34 +++++-- .github/workflows/publish.yml | 94 ++++++++++++++++--- .pharn/writes-scope.json | 4 +- CLAUDE.md | 4 +- docs/RELEASING.md | 36 ++++--- 13 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 .dev/features/publish-split-oidc/GRILL.md create mode 100644 .dev/features/publish-split-oidc/PLAN.md create mode 100644 .dev/features/publish-split-oidc/REGRESSION.md create mode 100644 .dev/features/publish-split-oidc/REVIEW.md create mode 100644 .dev/features/publish-split-oidc/SHIP.md create mode 100644 .dev/features/publish-split-oidc/VERIFY.md create mode 100644 .dev/features/publish-split-oidc/regression-report.json create mode 100644 .dev/features/publish-split-oidc/verify-report.json diff --git a/.dev/features/publish-split-oidc/GRILL.md b/.dev/features/publish-split-oidc/GRILL.md new file mode 100644 index 00000000..85cb678a --- /dev/null +++ b/.dev/features/publish-split-oidc/GRILL.md @@ -0,0 +1,29 @@ +# GRILL — publish-split-oidc + +Plan: `.dev/features/publish-split-oidc/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: '.dev/features/publish-split-oidc/PLAN.md:42' + problem: "The plan's central claim (token only in the publish job) is verified by reading the file, not by a floor script; a later edit that re-adds `id-token: write` at the top level would not be caught. Consider pinning it in the existing check-run-pins.test.mjs live-repo tests." + evidence: 'Verified by reading the file (advisory — no floor script parses job-level permissions).' +- type: FINDING + rule_id: 'P2' + severity: important + file: '.dev/features/publish-split-oidc/PLAN.md:52' + problem: 'Tarball integrity across jobs is not verified; at minimum the publish job should publish exactly one `.tgz` whose name matches the verified version, so a build job cannot hand over something else by name.' + evidence: 'a compromised dev dependency in `build` could still alter the tarball' +- type: FINDING + rule_id: 'P7' + severity: minor + file: '.dev/features/publish-split-oidc/PLAN.md:27' + problem: 'Refusing prerelease tags is a behavior change for maintainers; docs must say so.' + evidence: "tag must match `^v[0-9]+\\.[0-9]+\\.[0-9]+$`" +``` + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — for the human to weigh before /pharn-dev-build. diff --git a/.dev/features/publish-split-oidc/PLAN.md b/.dev/features/publish-split-oidc/PLAN.md new file mode 100644 index 00000000..4bd8f51f --- /dev/null +++ b/.dev/features/publish-split-oidc/PLAN.md @@ -0,0 +1,68 @@ +# PLAN — publish-split-oidc (PHARN-07: the dev toolchain must not run in the job that holds `id-token: write`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: split `publish.yml` into (1) `build` — NO `id-token` — which validates the tag strictly and + checks the tagged commit is on `main` BEFORE `npm ci`, runs `npm run check` + `npm run test:coverage`, + packs the tarball, smoke-installs it, and uploads it as an artifact; and (2) `publish` — the only job + with `id-token: write` and the `npm-publish` environment — which asserts the npm floor, downloads the + tarball and runs `npm publish --provenance --access public --ignore-scripts`, with no checkout, + no `npm ci`, and no lifecycle scripts. +- layer(s): CI tooling, floor test, docs +- constitution_refs: [P0, P2, P4, P7] + +## Discovery — verified this run (P6) + +`publish.yml` today: ONE job with top-level `id-token: write`, env `npm-publish`, `cache: npm`, runs +`npm ci` (install scripts of ~280 dev deps) and `npm publish`, whose `prepublishOnly` (`npm run check`: +prettier, eslint, markdownlint, tsc, vitest) and `prepack` (build) all run with the OIDC token +requestable. The tag guard is `${GITHUB_REF_NAME#v}` (accepts a tag without `v`) and runs after +`npm ci`; nothing checks the tag's commit is on `main`. Floor pins: `check-run-pins.test.mjs` requires +the `Assert npm floor` step + `11.5.1` in publish.yml and counts lockfile/path installs (9 on main +after PHARN-06); `check-action-pins.mjs` requires every `uses:` to be a 40-hex SHA. + +## Files + +- `.github/workflows/publish.yml` — the two-job split above; top-level `permissions: contents: read`, + `id-token: write` only on `publish`; tag must match `^v[0-9]+\.[0-9]+\.[0-9]+$` and equal + `package.json` `version`; `git merge-base --is-ancestor "$GITHUB_SHA" origin/main` (checkout with full + history); `actions/upload-artifact` / `actions/download-artifact` pinned by SHA — layer CI +- `.dev/floor/check-run-pins.test.mjs` — live install count 9 → 10 (publish.yml's build job adds the + path install of the packed tarball for the smoke), reason recorded; the `Assert npm floor` pins stay +- `docs/RELEASING.md` — step 5 rewritten for the two jobs; prerelease tags are refused (P4) +- `CLAUDE.md` — Releasing section (P4) + +## Contracts satisfied + +- CLAUDE.md "No npm tokens exist anywhere … auth is the short-lived OIDC id-token" — unchanged; the token + is now requestable only in a job that runs no third-party dev code. + +## Evals to write (P1) + +- `check-run-pins` / `check-action-pins` over the live repo stay green (no floating install, every + `uses:` SHA-pinned); the existing publish.yml pin tests keep passing against the new file. +- A workflow cannot be executed locally; its release-time behavior is ADVISORY until the next release. + +## Guarantee audit (P0) + +- "no dev dependency code runs where the OIDC token is requestable" → structural: `id-token: write` is + granted only to the `publish` job, whose steps are setup-node, the stdlib floor assert, + download-artifact and `npm publish --ignore-scripts` of a prebuilt tarball. Verified by reading the file + (advisory — no floor script parses job-level permissions). +- "only a strict `vX.Y.Z` tag on a commit contained in `main` is published" → regex + `merge-base` in the + unprivileged job, which `publish` `needs:`. +- The artifact action SHAs could not be verified from this environment (no GitHub access outside this + repo) — NAMED residual: a wrong SHA fails the first release run, publishing nothing. + +## Trust audit (P2) + +- The tarball crosses from the unprivileged job to the privileged one as an artifact; a compromised dev + dependency in `build` could still alter the tarball (content integrity), but can no longer mint a token + or publish by itself. Stated, not hidden. + +## Determinism audit (P5) + +- Regex + exact string equality + ancestry exit code. + +## Open questions (HALT) + +- none (the user chose the full split and to assume the artifact SHAs work) diff --git a/.dev/features/publish-split-oidc/REGRESSION.md b/.dev/features/publish-split-oidc/REGRESSION.md new file mode 100644 index 00000000..dde2b0aa --- /dev/null +++ b/.dev/features/publish-split-oidc/REGRESSION.md @@ -0,0 +1,30 @@ +# REGRESSION — publish-split-oidc + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `40bc115176a7e4ab587c88b9485984f563a14e1a` (`origin/main` at build time; the build is an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `.dev/floor/check-run-pins.test.mjs`, `.github/workflows/publish.yml`, `CLAUDE.md`, `docs/RELEASING.md`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and this + feature's own stage artifacts are not build output. +- **outside gates:** the stdlib `*.test.mjs` / `*.test.cjs` files + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / `lint:md` are absent from both maps. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` is +owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the comparison, never the increment. diff --git a/.dev/features/publish-split-oidc/REVIEW.md b/.dev/features/publish-split-oidc/REVIEW.md new file mode 100644 index 00000000..55e54410 --- /dev/null +++ b/.dev/features/publish-split-oidc/REVIEW.md @@ -0,0 +1,46 @@ +# REVIEW — publish-split-oidc + +Floor first: `node .dev/floor/validate.mjs .` → exit 0 (GREEN). Everything below is **advisory**. + +## Floor-gate findings (blocking) + +None. + +- **L-floor (P0):** grill concern #1 was taken into the build: `check-run-pins.test.mjs` now pins that + `id-token: write` appears exactly once, inside the `publish` job, and that this job has no checkout, + no `npm ci`/`install`, and uses `--ignore-scripts` (fails on the base file). `check-action-pins.mjs` + and `check-run-pins.mjs` report no violations over the live repo; the full stdlib floor suite passes + (749/749). +- **L-trust (P2):** grill concern #2 was taken in: the privileged job publishes exactly + `pharn-dev-pharn-.tgz`, where `` is the build job's output after the strict tag + check — not "whatever tarball arrived". Content integrity across jobs is still a named residual. +- **L-eval (P1):** a workflow cannot run locally; YAML was parsed to confirm the permission layout + (`build`: none beyond top-level `contents: read`; `publish`: `contents: read` + `id-token: write`). +- **L-axis (P3):** one workflow, two jobs, each with one purpose. + +## Advisory findings + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: '.github/workflows/publish.yml' + problem: "actions/upload-artifact@ea165f8… (v4.6.2) and actions/download-artifact@d3f86a1… (v4.3.0) are pinned from the author's knowledge and could not be verified from this environment; the first release run is their test (a wrong SHA fails before publishing anything)." + evidence: 'uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2' +- type: FINDING + rule_id: 'P7' + severity: minor + file: 'docs/RELEASING.md' + problem: 'Prerelease tags are now refused; publishing an rc requires a workflow change (and `--tag`).' + evidence: 'a prerelease tag (`v1.2.0-rc.1`) or a tag without the `v` is refused' +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CHANGELOG.md:8' + problem: "No CHANGELOG `[Unreleased]` entry (not in the plan's `## Files`)." + evidence: '## [Unreleased]' +``` + +## Verdict + +**GREEN** — 0 floor-gate findings, 3 advisory findings. No lesson proposed for canon. diff --git a/.dev/features/publish-split-oidc/SHIP.md b/.dev/features/publish-split-oidc/SHIP.md new file mode 100644 index 00000000..d0b59759 --- /dev/null +++ b/.dev/features/publish-split-oidc/SHIP.md @@ -0,0 +1,17 @@ +# SHIP — publish-split-oidc + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: **Approve as written**) → `/pharn-dev-grill` → +`/pharn-dev-build` → `/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- Run ended at **GATE 2**. The human's standing instruction for this batch: open a PR and merge it only if + its CI checks are green. + +chain ran; the named floor verdicts are as shown — this is NOT a judgment that the increment is good or +wise; that is the human's call at the post-review gate. diff --git a/.dev/features/publish-split-oidc/VERIFY.md b/.dev/features/publish-split-oidc/VERIFY.md new file mode 100644 index 00000000..eb709ea7 --- /dev/null +++ b/.dev/features/publish-split-oidc/VERIFY.md @@ -0,0 +1,27 @@ +# VERIFY — publish-split-oidc + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, as a non-root user on node 22 with the session +proxy variables unset (as root with the proxy set, 5 pre-existing tests in `init.test.ts` / +`update.test.ts` fail for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — floor gates only. + +Residual (P0/P7): "verified" means the named gates passed — not that the feature is correct in any sense +the suite does not encode. diff --git a/.dev/features/publish-split-oidc/regression-report.json b/.dev/features/publish-split-oidc/regression-report.json new file mode 100644 index 00000000..87441c80 --- /dev/null +++ b/.dev/features/publish-split-oidc/regression-report.json @@ -0,0 +1,22 @@ +{ + "base": "40bc115176a7e4ab587c88b9485984f563a14e1a", + "inside": [ + ".dev/floor/check-run-pins.test.mjs", + ".github/workflows/publish.yml", + "CLAUDE.md", + "docs/RELEASING.md" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/publish-split-oidc/verify-report.json b/.dev/features/publish-split-oidc/verify-report.json new file mode 100644 index 00000000..6f2c486d --- /dev/null +++ b/.dev/features/publish-split-oidc/verify-report.json @@ -0,0 +1,17 @@ +{ + "feature": "publish-split-oidc", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.dev/floor/check-run-pins.test.mjs b/.dev/floor/check-run-pins.test.mjs index 5935baa7..46a12b40 100644 --- a/.dev/floor/check-run-pins.test.mjs +++ b/.dev/floor/check-run-pins.test.mjs @@ -371,12 +371,13 @@ test("★ the live repo has NO floating install in any workflow run: line", () = // `npm ci` in ci.yml and publish.yml — asserted EXACTLY, so an exemption can never become a // silent hole. If this number changes, a lockfile install was added or removed on purpose. // - // 9 = six in ci.yml (one per gate job — each required status check installs for itself), one - // in publish.yml, and two in node-floor.yml (its `npm ci` build step, and the install of the - // locally packed tarball by PATH — `../pharn-dev-pharn-*.tgz` — onto the floor Node). It was 2 - // while ci.yml ran a single `check` job, 7 until the node-floor smoke job was added; each step is - // a deliberate change this count records. - assert.equal(d.skipped, 9); + // 10 = six in ci.yml (one per gate job — each required status check installs for itself), two + // in publish.yml's unprivileged `build` job (`npm ci`, and the PATH install of the packed tarball + // for its smoke — the privileged `publish` job installs nothing), and two in node-floor.yml (its + // `npm ci` build step, and the PATH install of the packed tarball onto the floor Node). It was 2 + // while ci.yml ran a single `check` job, 7 until the node-floor smoke job, 9 until publish.yml was + // split; each step is a deliberate change this count records. + assert.equal(d.skipped, 10); // Independent recount of the enumerated workflow files, case-insensitively — exit 0 is also what // a checker returns when it opened nothing. @@ -421,6 +422,27 @@ test("★ publish.yml still ENFORCES the npm floor — the assert step and its f // ★★ POSITIVE CONTROL — prove the scanner fires on THIS repo's own file shape. // The live assertions above pass with `checked: 0`, which is also what a broken scanner reports. +// PHARN-07: `id-token: write` lets EVERY step of its job request the OIDC token npm trades for +// publish rights, so it may appear only on publish.yml's `publish` job — never top-level, never on +// the job that runs `npm ci` and the dev toolchain. A text-level pin (no YAML parser in the floor): +// the grant appears exactly once, after the `publish:` job header, and that job runs no install. +test("★ publish.yml grants id-token ONLY to the publish job, which installs nothing", () => { + const text = readFileSync(join(REPO, ".github", "workflows", "publish.yml"), "utf8"); + const code = text + .split(/\r\n|\r|\n/) + .filter((l) => !l.trimStart().startsWith("#")) + .join("\n"); + const grants = code.match(/^\s*id-token:\s*write\b/gm) ?? []; + assert.equal(grants.length, 1, "id-token: write must appear exactly once"); + const publishAt = code.search(/^ publish:\s*$/m); + assert.ok(publishAt > 0, "the `publish` job header was not found"); + const publishJob = code.slice(publishAt); + assert.match(publishJob, /^\s*id-token:\s*write\b/m, "the grant is not inside the publish job"); + assert.ok(!/npm (ci|install|i)\b/.test(publishJob), "the privileged publish job installs packages"); + assert.ok(!/actions\/checkout@/.test(publishJob), "the privileged publish job checks out the repo"); + assert.match(publishJob, /--ignore-scripts/, "the privileged publish must not run lifecycle scripts"); +}); + test("★★ mutating the live publish.yml back to `npm install -g npm@latest` IS caught", () => { const root = scratch(); mkdirSync(join(root, ".github", "workflows"), { recursive: true }); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 162b8b78..6908e6a7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,26 +8,92 @@ name: publish # secrets or tokens appear anywhere in this workflow. The local FIRST publish # stays manual (`npm publish`, run once by a maintainer to create the package # before the Trusted Publisher can take over). +# +# TWO JOBS, and the split is the point (PHARN-07). Any step in a job that holds +# `id-token: write` can request the OIDC token npm trades for publish rights. +# The old single job ran `npm ci` (install scripts of ~280 dev dependencies) and +# `npm publish`, whose prepublishOnly/prepack ran prettier, eslint, markdownlint, +# tsc, vitest and esbuild — all with the token requestable, so one compromised +# dev dependency could publish a malicious version WITH a valid provenance +# attestation. Now: +# - `build` has NO id-token. It refuses anything but a strict vX.Y.Z tag on a +# commit contained in main (before `npm ci`), runs the gates, packs the +# tarball, smoke-installs it, and uploads it as an artifact. +# - `publish` is the ONLY job with id-token + the `npm-publish` environment. It +# checks out nothing, installs nothing, and publishes the prebuilt tarball +# with --ignore-scripts. +# Residual, named: the tarball is produced in `build`, so a compromised dev +# dependency there can still alter its CONTENT; it can no longer mint a token or +# publish on its own. on: release: types: [published] permissions: contents: read - id-token: write # required for OIDC: npm Trusted Publishing + provenance attestation jobs: - publish: + build: runs-on: ubuntu-latest - environment: npm-publish + outputs: + version: ${{ steps.tag.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 # the main-ancestry check below needs history - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: npm + # Before `npm ci`, so a bad tag costs no dependency install. Strict: a + # prerelease or `v`-less tag is refused rather than half-published. + - name: Verify tag + id: tag + run: | + set -euo pipefail + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "tag $GITHUB_REF_NAME is not vX.Y.Z"; exit 1; } + PKG="$(node -p "require('./package.json').version")" + [ "${GITHUB_REF_NAME#v}" = "$PKG" ] || { echo "tag $GITHUB_REF_NAME != package.json $PKG"; exit 1; } + git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { echo "$GITHUB_SHA is not on main"; exit 1; } + echo "version=$PKG" >> "$GITHUB_OUTPUT" + - name: Install + run: npm ci + - name: Gates + run: | + npm run check + npm run test:coverage + # `npm pack` runs prepack (the build) here, in the unprivileged job. + - name: Pack + run: npm pack --pack-destination "$RUNNER_TEMP/pkg" + - name: Smoke the packed CLI + working-directory: ${{ runner.temp }} + run: | + mkdir smoke && cd smoke + npm init -y > /dev/null + npm install --no-audit --no-fund ../pkg/pharn-dev-pharn-*.tgz + ./node_modules/.bin/pharn --version + # Unverified from the authoring environment (no GitHub access outside this + # repo): these two SHAs are the upstream v4.6.2 / v4.3.0 tags as known at + # authoring time. A wrong SHA fails this workflow before anything publishes. + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-tarball + path: ${{ runner.temp }}/pkg/pharn-dev-pharn-${{ steps.tag.outputs.version }}.tgz + if-no-files-found: error + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write # OIDC: npm Trusted Publishing + provenance — THIS job only + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 registry-url: https://registry.npmjs.org # Trusted Publishing requires npm >= 11.5.1 (npm's own docs; it also requires # node >= 22.14.0). node 24 already BUNDLES a satisfying npm — 11.17.0 at the @@ -64,16 +130,14 @@ jobs: } console.log(`npm floor OK: npm ${found.trim()} >= ${floor}`); ' "$(npm --version)" 11.5.1 - - name: Install - run: npm ci - - name: Verify tag matches package.json version - run: | - TAG="${GITHUB_REF_NAME#v}" - PKG="$(node -p "require('./package.json').version")" - [ "$TAG" = "$PKG" ] || { echo "tag v$TAG != package.json $PKG"; exit 1; } - # `npm publish` runs prepublishOnly (npm run check) + prepack (build) first, - # so a failing gate blocks the release. `--provenance` emits a signed - # attestation via the same OIDC id-token used for Trusted Publishing - # (needs id-token: write). Auth itself is OIDC — no token or secret. + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-tarball + path: pkg + # Exactly the tarball of the verified version, by name; --ignore-scripts so + # nothing from the package runs here. `--provenance` emits a signed + # attestation via the same OIDC id-token used for Trusted Publishing. - name: Publish - run: npm publish --provenance --access public + env: + VERSION: ${{ needs.build.outputs.version }} + run: npm publish "pkg/pharn-dev-pharn-${VERSION}.tgz" --provenance --access public --ignore-scripts diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index f43abaed..0bc4c769 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/engines-node-floor/SHIP.md" + ".dev/features/publish-split-oidc/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T08:31:05.706Z" + "set_at": "2026-09-24T08:38:52.408Z" } diff --git a/CLAUDE.md b/CLAUDE.md index 759aba39..138da640 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ CI (`.github/workflows/ci.yml`) runs six gates independently — format:check, l Canonical npm package: **`@pharn-dev/pharn`** (org-scoped) — the **installed binary stays `pharn`**. The unscoped `pharn` is **not publishable**: npm rejects it with **E403** as too similar to existing packages (`yarn`, `charm`, `sharp`), and a scoped name sidesteps that similarity check. An earlier `@pharn-dev/pharn@0.2.0` was published then **unpublished on 2026-07-22**, so `0.2.0` is permanently burned on this name — releases resume at `0.3.0`. -Release flow: bump `version` in `package.json` + update `CHANGELOG.md` → merge to `main` → cut a **GitHub Release** tagged `vX.Y.Z` (a guard step in `publish.yml` fails the run unless the tag, minus its leading `v`, equals `package.json` `version`). Publishing the Release triggers **`.github/workflows/publish.yml`**, which publishes via **npm Trusted Publishing (OIDC)** — workflow `publish.yml`, environment `npm-publish`, node 24 (npm >= 11.5.1), `npm publish --provenance` (the flag overrides `publishConfig.provenance: false`, so releases carry a signed provenance attestation). +Release flow: bump `version` in `package.json` + update `CHANGELOG.md` → merge to `main` → cut a **GitHub Release** tagged `vX.Y.Z` (a guard step in `publish.yml` fails the run unless the tag is exactly `v` + `package.json` `version` and its commit is on `main`). Publishing the Release triggers **`.github/workflows/publish.yml`**, which publishes via **npm Trusted Publishing (OIDC)** — workflow `publish.yml`, environment `npm-publish`, node 24 (npm >= 11.5.1), `npm publish --provenance` (the flag overrides `publishConfig.provenance: false`, so releases carry a signed provenance attestation). It is **two jobs**: `build` (no `id-token`) refuses anything but a strict `vX.Y.Z` tag equal to `package.json` `version` on a commit contained in `main` — before `npm ci` — then runs `check` + `test:coverage`, packs, smoke-installs and uploads the tarball; `publish` is the ONLY job with `id-token: write` + the environment, checks out and installs nothing, and runs `npm publish --ignore-scripts`. So no dev-dependency code runs where the OIDC token is requestable; `.dev/floor/check-run-pins.test.mjs` pins that the grant appears once, inside the `publish` job, which installs nothing. The upload/download-artifact SHAs were pinned without being verifiable from the authoring environment — the first release run is their test. **No npm tokens exist anywhere** — no repo secrets, none in Actions; auth is the short-lived OIDC id-token exchanged at publish time. If a token ever seems to be "needed," the Trusted Publisher config is broken — fix it on npmjs.com, **never** add a secret. @@ -43,7 +43,7 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten ## Architecture -`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. **Options are per-command, and an option a command does not take is REFUSED** (`Unsupported option for \`status\`: "--json"`, stderr + usage + exit 1) —`ALLOWED_FLAGS` is the per-command table beside the positional-arity `MAX_POSITIONALS`, a`Map`for the same reason (`cmd` is untrusted argv, and an object lookup would resolve `Object.prototype` keys), with the same absent-row-means-no-check semantics (so `pharn bogus --json` keeps the more useful "Unknown command"). Rows are derived from what each command READS: `init: archetype` (its retained no-op), `update: force, yes`,`list: json`,`status: strict, drift` (the canonical key, so `--no-drift` lives there), `add`/`remove`/`rm`: **none**;`help`/`version` are in every row. The **global `boolean:` declaration list is computed FROM that table**, so a flag declared for nobody is unrepresentable — the exact shape of the closed defect, where `status --json`,`list --strict` and `add --json`parsed, were dropped, and exited 0 (`pharn status --json | jq` got clack chrome and a success code; with no command word, `pharn --json`/`--force`/`--strict` ran a full install). The offender list comes from a SECOND minimist parse of the same argv under just that command's declarations, keeping only its `unknown` verdicts — never from `Object.keys(argv)`, which cannot distinguish passed from defaulted (minimist sets every declared boolean to`false`), nor from a value-differs test, which reads`--json=false` as absent. Only the flag NAME is checked; value shape is deliberately out of scope. Both refusals sit **above** the `--version`/`--help` short-circuits, so a genuine `--help`licenses neither an unknown nor a misapplied sibling (`pharn status --help --json` refuses; `pharn status --help` does not); the arity gate stays below them. `refuse()` de-duplicates offenders, since minimist names a short bundle once per unknown letter. Every non-init command loads the config via `loadArchetypeConfigOrExit` (which rejects a pre-archetype config, above) — except `list`, which does its own json-aware check so its error stays on stderr under`--json`.`list` is read-only (reads `pharn.config.json` only — no clone, no fetch, no writes); `--json` emits a single inventory object on stdout (diagnostics to stderr). `status` is also read-only (the read side of `update`) — see its addressing note below.`remove` (alias `rm`) is the inverse of`add`— see its addressing note below (`remove` has NO `--yes`: its NAMED path never confirms, and the bare picker's one confirm is unconditional). **The two prompting commands hard-fail off a TTY.**`init` and `update` each read `process.std*.isTTY` into the shared, already-exported `interactiveAllowed`(`lib/capability-picker.ts` — imported, never re-implemented; a static test pins that no `src/**` file reads `isTTY` outside an `interactiveAllowed({…})` argument, so this repo has exactly ONE such predicate) and `exit(1)` rather than rendering a prompt into a dead stream. Before this, the confirm cancelled on stream end through `cancelAndExit`'s **exit(0)** —`echo "" | pharn update` reported success having done nothing, and piped `init` paid for a full clone first, since `fetchRepo`precedes its first prompt. Each gate sits AFTER that command's promptless local step (`update`:`loadArchetypeConfigOrExit`;`init`:`runGitPrereq`) so the actionable error still wins, and BEFORE any network call, so a refusal costs zero round-trips.`update --yes`/`-y` is the way through: it skips **the confirm and nothing else** (the note still prints; plan/apply, drift-safe skips, the withheld version bump, and every exit code are byte-identical), works in a TTY too, and composes with `--force` — which does NOT imply it. `init` deliberately has **no** `--yes`: its second prompt is the destructive overwrite confirmation. TTY cancel semantics are unchanged — a human choosing Cancel is still a graceful exit 0; only EOF masquerading as that choice is now unreachable. +`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. **Options are per-command, and an option a command does not take is REFUSED** (`Unsupported option for \`status\`: "--json"`, stderr + usage + exit 1) —`ALLOWED_FLAGS`is the per-command table beside the positional-arity`MAX_POSITIONALS`, a`Map`for the same reason (`cmd`is untrusted argv, and an object lookup would resolve`Object.prototype`keys), with the same absent-row-means-no-check semantics (so`pharn bogus --json`keeps the more useful "Unknown command"). Rows are derived from what each command READS:`init: archetype`(its retained no-op),`update: force, yes`,`list: json`,`status: strict, drift`(the canonical key, so`--no-drift`lives there),`add`/`remove`/`rm`: **none**;`help`/`version`are in every row. The **global`boolean:`declaration list is computed FROM that table**, so a flag declared for nobody is unrepresentable — the exact shape of the closed defect, where`status --json`,`list --strict`and`add --json`parsed, were dropped, and exited 0 (`pharn status --json | jq`got clack chrome and a success code; with no command word,`pharn --json`/`--force`/`--strict`ran a full install). The offender list comes from a SECOND minimist parse of the same argv under just that command's declarations, keeping only its`unknown`verdicts — never from`Object.keys(argv)`, which cannot distinguish passed from defaulted (minimist sets every declared boolean to`false`), nor from a value-differs test, which reads`--json=false`as absent. Only the flag NAME is checked; value shape is deliberately out of scope. Both refusals sit **above** the`--version`/`--help`short-circuits, so a genuine`--help`licenses neither an unknown nor a misapplied sibling (`pharn status --help --json`refuses;`pharn status --help`does not); the arity gate stays below them.`refuse()`de-duplicates offenders, since minimist names a short bundle once per unknown letter. Every non-init command loads the config via`loadArchetypeConfigOrExit`(which rejects a pre-archetype config, above) — except`list`, which does its own json-aware check so its error stays on stderr under`--json`.`list`is read-only (reads`pharn.config.json`only — no clone, no fetch, no writes);`--json`emits a single inventory object on stdout (diagnostics to stderr).`status`is also read-only (the read side of`update`) — see its addressing note below.`remove`(alias`rm`) is the inverse of`add`— see its addressing note below (`remove`has NO`--yes`: its NAMED path never confirms, and the bare picker's one confirm is unconditional). **The two prompting commands hard-fail off a TTY.**`init`and`update`each read`process.std*.isTTY`into the shared, already-exported`interactiveAllowed`(`lib/capability-picker.ts`— imported, never re-implemented; a static test pins that no`src/**`file reads`isTTY`outside an`interactiveAllowed({…})`argument, so this repo has exactly ONE such predicate) and`exit(1)`rather than rendering a prompt into a dead stream. Before this, the confirm cancelled on stream end through`cancelAndExit`'s **exit(0)** —`echo "" | pharn update`reported success having done nothing, and piped`init`paid for a full clone first, since`fetchRepo`precedes its first prompt. Each gate sits AFTER that command's promptless local step (`update`:`loadArchetypeConfigOrExit`;`init`:`runGitPrereq`) so the actionable error still wins, and BEFORE any network call, so a refusal costs zero round-trips.`update --yes`/`-y`is the way through: it skips **the confirm and nothing else** (the note still prints; plan/apply, drift-safe skips, the withheld version bump, and every exit code are byte-identical), works in a TTY too, and composes with`--force`— which does NOT imply it.`init`deliberately has **no**`--yes`: its second prompt is the destructive overwrite confirmation. TTY cancel semantics are unchanged — a human choosing Cancel is still a graceful exit 0; only EOF masquerading as that choice is now unreachable. **`commands/init.ts` is the archetype install flow** — the only init flow (the legacy module/wizard step pipeline `runInitLegacy`/`runInitV2` and its `steps/*` were removed). `runInit` calls `runInitArchetype` unconditionally: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0ae8224a..4586a1e8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -43,18 +43,30 @@ credential is stored or passed. [`ci.yml`](../.github/workflows/ci.yml) gates plus `floor`, `gitleaks` and `Analyze (javascript-typescript)`. 4. **Cut a GitHub Release.** Tag it **`vX.Y.Z`**, where `X.Y.Z` **exactly - matches** `package.json` `version`. A guard step in `publish.yml` fails the - run if the tag (minus its leading `v`) does not equal the package version. -5. **Publishing the Release triggers `publish.yml`.** It runs on node 24, whose - bundled npm already satisfies the npm >= 11.5.1 that Trusted Publishing - needs — an **Assert npm floor** step enforces that and fails the run if it - ever stops being true. It then installs dependencies (`npm ci`), **verifies - the tag** against `package.json` `version`, and runs `npm publish --provenance - --access public`. The full check suite and build run **inside** that publish, - via the `prepublishOnly` + `prepack` hooks — so they happen after the tag - guard, not before it. The `--provenance` flag overrides - `publishConfig.provenance: false`, so the release carries a signed provenance - attestation. + matches** `package.json` `version`. The tag must be plain `vX.Y.Z` — a + prerelease tag (`v1.2.0-rc.1`) or a tag without the `v` is refused — and it + must point at a commit that is on `main`. +5. **Publishing the Release triggers `publish.yml`**, in two jobs: + - **`build`** holds no publish rights (`id-token` is not granted to it). Before + installing anything it checks the tag format, that the tag equals + `package.json` `version`, and that the tagged commit is contained in `main`. + It then runs `npm ci`, `npm run check` and `npm run test:coverage`, packs + the tarball (`npm pack`, whose `prepack` builds it), installs that tarball + into a scratch directory and runs `pharn --version`, and uploads it as an + artifact. + - **`publish`** is the only job with `id-token: write` and the `npm-publish` + environment. It checks out nothing and installs nothing: on node 24, whose + bundled npm already satisfies the npm >= 11.5.1 Trusted Publishing needs (an + **Assert npm floor** step enforces that), it downloads the tarball and runs + `npm publish --provenance --access public --ignore-scripts`. The + `--provenance` flag overrides `publishConfig.provenance: false`, so the + release carries a signed provenance attestation. + + Why the split: every step of a job that holds `id-token: write` can request + the token npm trades for publish rights. Keeping the dev toolchain (install + scripts, linters, tests, bundler) out of that job means a compromised dev + dependency can no longer publish on its own. It can still influence the + tarball's content in `build` — the residual this does not close. Note that `pharnVersion` (this package) and `skillsVersion` (upstream's `SKILLS_VERSION`, which an install records separately) are independent