Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dev/features/publish-split-oidc/GRILL.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions .dev/features/publish-split-oidc/PLAN.md
Original file line number Diff line number Diff line change
@@ -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 <tgz> --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)
30 changes: 30 additions & 0 deletions .dev/features/publish-split-oidc/REGRESSION.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions .dev/features/publish-split-oidc/REVIEW.md
Original file line number Diff line number Diff line change
@@ -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-<version>.tgz`, where `<version>` 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.
17 changes: 17 additions & 0 deletions .dev/features/publish-split-oidc/SHIP.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions .dev/features/publish-split-oidc/VERIFY.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .dev/features/publish-split-oidc/regression-report.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions .dev/features/publish-split-oidc/verify-report.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
34 changes: 28 additions & 6 deletions .dev/floor/check-run-pins.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading