diff --git a/.dev/features/publish-pack-destination/GRILL.md b/.dev/features/publish-pack-destination/GRILL.md new file mode 100644 index 00000000..89f5a0e9 --- /dev/null +++ b/.dev/features/publish-pack-destination/GRILL.md @@ -0,0 +1,38 @@ +# GRILL — publish-pack-destination + +Plan: `.dev/features/publish-pack-destination/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-pack-destination/PLAN.md:34' + problem: 'A `mkdir -p` anywhere earlier in the FILE does not create the directory for the pack: each job runs on its own runner, so a mkdir in another job (or in a later-defined job that YAML order puts first) would satisfy the scan while the Pack step still hits ENOENT. The scan must look only inside the same job, before the pack line.' + evidence: 'or a `mkdir -p ` precedes it in the same file' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/features/publish-pack-destination/PLAN.md:33' + problem: 'The same directory can be spelled `$RUNNER_TEMP/pkg`, `"$RUNNER_TEMP/pkg"`, `${{ runner.temp }}/pkg` or `${RUNNER_TEMP}/pkg`; matching the mkdir to the pack destination by raw text would call a correct workflow broken (or miss a broken one). Normalize quotes and the runner-temp spellings to one form before comparing.' + evidence: '`` is either exactly `$RUNNER_TEMP` / `${{ runner.temp }}`' +- type: FINDING + rule_id: 'P3' + severity: minor + file: '.dev/features/publish-pack-destination/PLAN.md:32' + problem: 'check-run-pins.test.mjs is the test of the run-line PIN checker; a pack-destination pin is a different reason to change. PHARN-07 already hosts publish.yml job-structure pins there (id-token, npm floor), so this follows precedent — say so in the test comment rather than leave the axis blur implicit.' + evidence: '`.dev/floor/check-run-pins.test.mjs` — ★ live test: for every `npm pack --pack-destination `' +``` + +## Summary + +The plan fixes a real, reproduced release blocker with a one-line workflow change and pins it with a +live-text test plus a positive control. The main concern is the scan's scope: "same file" is weaker +than "same job, earlier in it", and only the latter is what makes the directory exist on the runner +that packs. Normalizing the runner-temp spellings keeps the check from false alarms. The test's home +is a minor axis question with precedent on the plan's side. + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — for the human to weigh before +/pharn-dev-build; all three are foldable into the build without changing the plan's Files. diff --git a/.dev/features/publish-pack-destination/PLAN.md b/.dev/features/publish-pack-destination/PLAN.md new file mode 100644 index 00000000..3d65f13d --- /dev/null +++ b/.dev/features/publish-pack-destination/PLAN.md @@ -0,0 +1,68 @@ +# PLAN — publish-pack-destination (the release Pack step writes into a directory nobody created) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `publish.yml`'s unprivileged `build` job creates `$RUNNER_TEMP/pkg` before + `npm pack --pack-destination "$RUNNER_TEMP/pkg"` writes into it, and a live repo-consistency test + pins that every `--pack-destination` directory in every workflow exists before `npm pack` runs. +- layer(s): release CI (`.github/workflows/publish.yml`) + its floor test (`.dev/floor/`) +- constitution_refs: [P0, P1, P6] + +## Discovery — verified this run (P6) + +- `publish.yml:67-68` (added by PHARN-07, a2fe83e): `npm pack --pack-destination "$RUNNER_TEMP/pkg"`. + No earlier step in the `build` job creates `pkg/` (Verify tag → Install → Gates → Pack), and + `$RUNNER_TEMP` itself is the only directory the runner provides. +- npm does NOT create the destination: `npm pack --pack-destination /pkg` exits 254 with + `ENOENT … open '/pkg/-.tgz'` on npm 10.9.7 (local) and npm 11.20.0 (`npx npm@11`), + reproduced this run on a throwaway package. So every Release run fails at Pack, the `publish` job + (`needs: build`) never starts, and nothing can be released — fail-closed, but release-blocking. +- `node-floor.yml:44` packs into `"$RUNNER_TEMP"` itself (exists) — unaffected; the new test must + accept it without a `mkdir`. +- The live-workflow tests for publish.yml already live in `.dev/floor/check-run-pins.test.mjs` + (PHARN-07's ★ id-token test, the npm-floor test); floor.yml runs that file on every PR and push to + main (`node --test ".dev/**/*.test.mjs"`), so a pin there fails CI without any workflow change. +- CI on main is green at 0ca29b7 — this defect is invisible to every gate because publish.yml only + runs on a published Release. + +## Files + +- `.github/workflows/publish.yml` — the Pack step runs `mkdir -p "$RUNNER_TEMP/pkg"` before + `npm pack --pack-destination "$RUNNER_TEMP/pkg"`; a one-line comment says why (npm does not create + it) — layer release CI +- `.dev/floor/check-run-pins.test.mjs` — ★ live test: for every `npm pack --pack-destination ` + in every `.github/workflows/*.yml`, `` is either exactly `$RUNNER_TEMP` / `${{ runner.temp }}` + or a `mkdir -p ` precedes it in the same file; ★★ positive control: the live publish.yml with + its `mkdir -p` line deleted IS flagged (proves the scan fires on this repo's own file shape) — + layer floor test +- `CHANGELOG.md` — `[Unreleased]` → `### Fixed`: the release workflow's Pack step — layer docs + +## Contracts satisfied + +- `docs/RELEASING.md` step 5 ("packs the tarball … installs that tarball into a scratch directory … + and uploads it as an artifact") — becomes true; no wording change needed (P4: cite, don't restate). + +## Evals to write (P1) + +- ★ live pack-destination test → FAILS on 0ca29b7 (publish.yml packs into an uncreated `pkg/`), + PASSES after the fix; `node-floor.yml`'s `$RUNNER_TEMP` destination passes without a `mkdir`. +- ★★ positive control → the live publish.yml text minus its `mkdir -p` line is flagged. + +## Guarantee audit (P0) + +- "every workflow's `npm pack --pack-destination` directory exists before npm writes into it" → + floor: regex scan of the committed workflow text in a test floor.yml runs on every PR. +- "the first Release run now publishes" → advisory: the Pack failure is removed, but the + upload/download-artifact SHAs PHARN-07 pinned remain unverified from this environment (named in + publish.yml itself); the first real release run is still their test. + +## Trust audit (P2) + +- No untrusted input is ingested; the test reads the repo's own committed workflow files. + +## Determinism audit (P5) + +- Pure text match over committed files; no network, no npm invocation in the test. + +## Open questions (HALT) + +- none diff --git a/.dev/features/publish-pack-destination/REGRESSION.md b/.dev/features/publish-pack-destination/REGRESSION.md new file mode 100644 index 00000000..ec0f62b9 --- /dev/null +++ b/.dev/features/publish-pack-destination/REGRESSION.md @@ -0,0 +1,38 @@ +# REGRESSION — publish-pack-destination + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `0ca29b7ea8ce03d352bc6103fc35c42cf3149e47` (`HEAD` — the build is an uncommitted working tree on + top of it, so `git status --porcelain` is non-empty). +- **inside** (each declared in `PLAN.md` `## Files`): `.github/workflows/publish.yml`, + `.dev/floor/check-run-pins.test.mjs`, `CHANGELOG.md`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and + stage artifacts are not build output — this feature's own `PLAN.md` / `GRILL.md`, and the untracked + `PLAN.md` files of the three other increments accepted at the same GATE 1 + (`init-manual-carry`, `update-frozen-recheck`, `tar-entry-names`), which this build did not touch. +- **outside gates:** the 45 stdlib `*.test.mjs` / `*.test.cjs` files `scope` returned (704 tests) + 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. +- **environment:** both sides ran with no proxy variables and as root **without** `CAP_DAC_OVERRIDE` / + `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`), the CI-equivalent of this root sandbox. + +## 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-pack-destination/REVIEW.md b/.dev/features/publish-pack-destination/REVIEW.md new file mode 100644 index 00000000..bf74f725 --- /dev/null +++ b/.dev/features/publish-pack-destination/REVIEW.md @@ -0,0 +1,61 @@ +# REVIEW — publish-pack-destination + +Increment: `.github/workflows/publish.yml` (Pack step creates `$RUNNER_TEMP/pkg` first), +`.dev/floor/check-run-pins.test.mjs` (pack-destination scanner + ★ live pin + ★★ positive control + three +scanner cases), `CHANGELOG.md` (`[Unreleased]` → Fixed). Treated as `trust: untrusted`; nothing in it read +as an instruction. + +## Floor first (P0) + +`node .dev/floor/validate.mjs .` → `FLOOR: GREEN` (exit 0). `/pharn-dev-build`'s `npm run check` exit 0, +`/pharn-dev-regress` `no-regressions`, `/pharn-dev-verify` `PASS` (7 gates incl. `test:floor`). + +## Floor-gate findings (blocking) + +None. + +- L-floor (P0): the one new guarantee — "every workflow's pack destination is created earlier in the job + that packs" — reduces to a regex scan over the committed workflow text in a `node --test` file that + floor.yml runs on every PR and push to main. The CHANGELOG and the publish.yml comment claim nothing + beyond it; the end-to-end claim ("the next Release publishes") stays advisory in PLAN.md and VERIFY.md. +- L-eval (P1): the behavior ships with its tests — the ★ live pin fails on the base publish.yml and passes + after (checked this run), the ★★ control proves the scan fires on the live file's shape, and three + hermetic cases pin the same-job rule, the spelling normalization and the comment exclusion. +- L-trust (P2): no untrusted input is ingested; the scan reads the repo's own committed workflows. +- L-axis (P3): no sibling reference; the test-file axis note is below as advisory. + +## Advisory findings (warn — severity is this reviewer's judgment, fix #3) + +```yaml +- type: FINDING + rule_id: 'P3' + severity: minor + file: '.dev/floor/check-run-pins.test.mjs:474' + problem: 'The pack-destination scanner is a second reason for this file to change besides the run-line pin checker it tests; the comment names it and PHARN-07 set the precedent, but a third such concern should move the live publish.yml pins to their own test file.' + evidence: 'A different axis from the run-line pin checker this file tests; it sits beside PHARN-07''s other live publish.yml pins by precedent.' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/floor/check-run-pins.test.mjs:503' + problem: 'Comment stripping treats any whitespace-preceded `#` as a comment start, so a `#` inside a quoted shell string on a pack line would hide that pack from the scan (a false negative). No workflow line does this today; the scanner is a pin, not a shell parser.' + evidence: 'const line = raw.replace(/(^|\s)#.*$/, ""); // a YAML comment is never executed' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/floor/check-run-pins.test.mjs:480' + problem: 'A `${{ … }}` expression containing `}` (e.g. a `format(''{0}'', …)` call) would split the word early and could miss a match; acceptable for the pack/mkdir lines this repo writes, but it bounds what the pin can see.' + evidence: 'const WORD = String.raw`("[^"]*"|''[^'']*''|(?:\$\{\{[^}]*\}\}|\S)+)`;' +``` + +## Proposed lesson for canon (NOT written — for a human-gated `/pharn-dev-memory-promote`) + +- **Candidate:** "A workflow that only runs on a release/tag event gets no execution from PR CI, so a + defect in its mechanics ships silently; pin those mechanics with live-text tests in a file floor.yml + runs (or give the workflow a dry-run trigger)." +- **Provenance:** increment `publish-pack-destination`; the defect entered with PHARN-07 (a2fe83e, #201), + whose plan → grill → build → regress → verify → review chain passed while `npm pack` wrote into an + uncreated `$RUNNER_TEMP/pkg`; found by the 18-commit review on 2026-09-24; fixed by this diff. + +## Verdict + +**GREEN — 0 floor-gate findings, 3 advisory (minor).** The standing decision is the human's (GATE 2). diff --git a/.dev/features/publish-pack-destination/SHIP.md b/.dev/features/publish-pack-destination/SHIP.md new file mode 100644 index 00000000..260b73d2 --- /dev/null +++ b/.dev/features/publish-pack-destination/SHIP.md @@ -0,0 +1,18 @@ +# SHIP — publish-pack-destination + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: all four plans of this batch accepted — "deliver +all of them one by one using pharn-dev-ship") → `/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: after each increment, open a + pull request and merge it once its checks are green, then start the next plan. + +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-pack-destination/VERIFY.md b/.dev/features/publish-pack-destination/VERIFY.md new file mode 100644 index 00000000..aeee9881 --- /dev/null +++ b/.dev/features/publish-pack-destination/VERIFY.md @@ -0,0 +1,35 @@ +# VERIFY — publish-pack-destination + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, on node 22 with the session proxy variables unset +and as root **without** `CAP_DAC_OVERRIDE` / `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`) — the +CI-equivalent of this root sandbox (as plain root, 4 pre-existing chmod-based tests in `update.test.ts` fail +for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `test:floor` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +`test:floor` is floor.yml's existing `node --test` over the `*.test.mjs` / `*.test.cjs` globs (754 tests). It is +in the map because this increment's own test lives in `.dev/floor/check-run-pins.test.mjs`, which `npm test` +(vitest, `tests/**/*.test.ts`) never collects — without it the feature's own spec would not reach the verdict. +`test` is vitest (1472 tests). 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":[]}` — no verifiers registered, floor +gates only. + +Residual (P0/P7): verified = the named gates passed; this is NOT a guarantee of correctness beyond what those +gates check — verifier concerns are advisory help, not assurance. In particular, no gate here runs +`publish.yml` itself: the first real Release run remains the end-to-end test of the workflow (PLAN.md's +guarantee audit). diff --git a/.dev/features/publish-pack-destination/regression-report.json b/.dev/features/publish-pack-destination/regression-report.json new file mode 100644 index 00000000..9a65cc7a --- /dev/null +++ b/.dev/features/publish-pack-destination/regression-report.json @@ -0,0 +1,21 @@ +{ + "base": "0ca29b7ea8ce03d352bc6103fc35c42cf3149e47", + "inside": [ + ".github/workflows/publish.yml", + ".dev/floor/check-run-pins.test.mjs", + "CHANGELOG.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-pack-destination/verify-report.json b/.dev/features/publish-pack-destination/verify-report.json new file mode 100644 index 00000000..a47e1b49 --- /dev/null +++ b/.dev/features/publish-pack-destination/verify-report.json @@ -0,0 +1,18 @@ +{ + "feature": "publish-pack-destination", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "test:floor": 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 46a12b40..044f50ad 100644 --- a/.dev/floor/check-run-pins.test.mjs +++ b/.dev/floor/check-run-pins.test.mjs @@ -462,6 +462,122 @@ test("★★ mutating the live publish.yml back to `npm install -g npm@latest` I assert.deepEqual(refs(r), ["npm@latest"]); }); +// --------------------------------------------------------------------------- +// Pack destinations (publish-pack-destination). `npm pack --pack-destination ` +// does NOT create — it fails with ENOENT (measured on npm 10.9.7 and 11.20.0) — +// and publish.yml once packed into a `$RUNNER_TEMP/pkg` nothing had made, so every +// Release run died at Pack and never reached `publish`. publish.yml only runs on a +// published Release, so no PR gate would ever have seen it; this live pin is that gate. +// +// A destination other than the runner temp root itself must be made by a `mkdir -p` +// EARLIER IN THE SAME JOB: each job runs on its own runner, so a mkdir in another job +// creates nothing where the pack runs. A different axis from the run-line pin checker +// this file tests; it sits beside PHARN-07's other live publish.yml pins by precedent. + +// One shell word: a quoted string, or a run of non-space characters in which a +// `${{ … }}` expression (spaces and all — Actions substitutes it before the shell runs) +// counts as part of the word. +const WORD = String.raw`("[^"]*"|'[^']*'|(?:\$\{\{[^}]*\}\}|\S)+)`; +const MKDIR_RE = new RegExp(String.raw`\bmkdir\s+-p\s+` + WORD, "g"); +const PACK_DEST_RE = new RegExp(String.raw`--pack-destination(?:=|\s+)` + WORD, "g"); + +// One shell word naming a directory, normalized: quotes dropped, the runner temp dir's +// spellings folded onto `$RUNNER_TEMP`, a trailing slash dropped. +function normDir(word) { + return word + .replace(/^["']|["']$/g, "") + .replace(/\$\{\{\s*runner\.temp\s*\}\}|\$\{RUNNER_TEMP\}/g, "$RUNNER_TEMP") + .replace(/\/+$/, ""); +} + +// Scan one workflow's text. `seen` counts pack destinations (so a live assertion over +// zero of them cannot pass vacuously); `uncreated` lists `: ` for each one +// that is neither the runner temp root nor made earlier in its own job. +function packDestinations(text) { + let job = null; + let made = new Set(); + let inJobs = false; + let seen = 0; + const uncreated = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/(^|\s)#.*$/, ""); // a YAML comment is never executed + if (/^jobs:\s*$/.test(line)) { + inJobs = true; + continue; + } + if (inJobs && /^\S/.test(line)) inJobs = false; // the next top-level key ends `jobs:` + const header = inJobs ? /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line) : null; + if (header) { + job = header[1]; + made = new Set(); + continue; + } + for (const m of line.matchAll(MKDIR_RE)) made.add(normDir(m[1])); + for (const m of line.matchAll(PACK_DEST_RE)) { + seen += 1; + const dir = normDir(m[1]); + if (dir !== "$RUNNER_TEMP" && !made.has(dir)) uncreated.push(`${job}: ${dir}`); + } + } + return { seen, uncreated }; +} + +function workflowJobs(steps) { + return `name: t\non: push\njobs:\n${steps}\n`; +} + +test("pack destinations: a mkdir in ANOTHER job, or after the pack, does not count", () => { + const otherJob = workflowJobs( + ' prep:\n steps:\n - run: mkdir -p "$RUNNER_TEMP/pkg"\n' + + ' build:\n steps:\n - run: npm pack --pack-destination "$RUNNER_TEMP/pkg"', + ); + assert.deepEqual(packDestinations(otherJob).uncreated, ["build: $RUNNER_TEMP/pkg"]); + + const afterwards = workflowJobs( + ' build:\n steps:\n - run: npm pack --pack-destination "$RUNNER_TEMP/pkg"\n' + + ' - run: mkdir -p "$RUNNER_TEMP/pkg"', + ); + assert.deepEqual(packDestinations(afterwards).uncreated, ["build: $RUNNER_TEMP/pkg"]); +}); + +test("pack destinations: runner-temp spellings and quoting are one directory", () => { + const spelled = workflowJobs( + " build:\n steps:\n - run: |\n mkdir -p ${{ runner.temp }}/pkg/\n" + + " npm pack --pack-destination=\"${RUNNER_TEMP}/pkg\"", + ); + assert.deepEqual(packDestinations(spelled), { seen: 1, uncreated: [] }); +}); + +test("pack destinations: the runner temp root needs no mkdir; a comment is not a command", () => { + const root = workflowJobs( + ' smoke:\n steps:\n # npm pack --pack-destination "$RUNNER_TEMP/elsewhere"\n' + + ' - run: npm pack --pack-destination "$RUNNER_TEMP"', + ); + assert.deepEqual(packDestinations(root), { seen: 1, uncreated: [] }); +}); + +test("★ every live workflow creates its pack destination, in the packing job, before packing", () => { + const dir = join(REPO, ".github", "workflows"); + let seen = 0; + const uncreated = []; + for (const f of readdirSync(dir).filter((n) => /\.ya?ml$/i.test(n)).sort()) { + const scan = packDestinations(readFileSync(join(dir, f), "utf8")); + seen += scan.seen; + uncreated.push(...scan.uncreated.map((u) => `${f} ${u}`)); + } + assert.deepEqual(uncreated, []); + // publish.yml's build job and node-floor.yml's smoke both pack; zero would mean the + // scan read nothing, which is also what a passing assertion above looks like. + assert.ok(seen >= 2, `expected at least two pack destinations in the live workflows, saw ${seen}`); +}); + +test("★★ the live publish.yml without its mkdir IS flagged", () => { + const live = readFileSync(join(REPO, ".github", "workflows", "publish.yml"), "utf8"); + const mutated = live.replace(/^\s*mkdir -p "\$RUNNER_TEMP\/pkg"\n/m, ""); + assert.notEqual(mutated, live, "the mkdir line was not found — this control is not exercising anything"); + assert.deepEqual(packDestinations(mutated).uncreated, ["build: $RUNNER_TEMP/pkg"]); +}); + // --------------------------------------------------------------------------- // R2 backstop — the two duplicated walkers must not drift apart silently diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6908e6a7..598455c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,9 +63,12 @@ jobs: run: | npm run check npm run test:coverage - # `npm pack` runs prepack (the build) here, in the unprivileged job. + # `npm pack` runs prepack (the build) here, in the unprivileged job. npm does + # NOT create --pack-destination (ENOENT on npm 10 and 11), so make it first. - name: Pack - run: npm pack --pack-destination "$RUNNER_TEMP/pkg" + run: | + mkdir -p "$RUNNER_TEMP/pkg" + npm pack --pack-destination "$RUNNER_TEMP/pkg" - name: Smoke the packed CLI working-directory: ${{ runner.temp }} run: | diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index 97779742..4170d3bf 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0,"typecheck":0} \ No newline at end of file +{"test":0,"test:floor":0,"validate":0,"lint":0,"format:check":0,"lint:md":0,"typecheck":0} \ No newline at end of file diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 892bdfee..dc6569c7 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/tar-strict-framing/SHIP.md" + ".dev/features/publish-pack-destination/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T11:05:27.882Z" + "set_at": "2026-09-24T18:52:09.219Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1cd899..ba76948a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Releases could not publish: the `Pack` step wrote into a directory nothing had created.** Since the release workflow was split into an unprivileged `build` job and a `publish` job, `build` ran `npm pack --pack-destination "$RUNNER_TEMP/pkg"` without creating `pkg/`, and npm does not create it (`ENOENT` on npm 10 and 11). Every Release run would have failed at `Pack` and never reached `publish`. The step now runs `mkdir -p` first, and a live test pins that every workflow's pack destination is created earlier in the job that packs. + ## [0.5.0] - 2026-09-10 ### Changed — BREAKING