From 519e365616a99e61e8877c03d8b534426da0a3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Mon, 10 Aug 2026 11:05:19 +0200 Subject: [PATCH] chore(ci): pin floor.yml actions by digest; add a floor gate for action pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floor.yml was the only workflow using floating action refs, against the convention this repo states at .github/workflows/gitleaks.yml:11 — "pinning third-party code by digest, never a floating tag" — and it is the workflow where trust matters most: it runs on fork `pull_request` events. Two parts. 1. Fix the instance. - floor.yml: actions/checkout@v7.0.1 -> @3d3c42e5... # v7.0.1 (the digest the other four workflows already pin; verified = upstream refs/tags/v7.0.1) - floor.yml: actions/setup-node@v7 -> @8207627... # v7.0.0 — the exact commit the floating @v7 resolved to, so zero execution change - floor.yml: add persist-credentials: false (ci, codeql, publish and gitleaks all set it; floor was the sole omission) - ci.yml + publish.yml: correct the stale `# v6` comment to `# v7.0.0`. The digest does not change — it was already v7.0.0. There was no runtime version skew to "align": the comment was wrong, not the pin. Commit ff48077 ("bump actions/setup-node from 6 to 7") moved the digest across a major and left the major-only comment untouched; c425edd copied that pattern into publish.yml. Independently, refs/tags/v6 has since moved v6.4.0 -> v6.5.0 — a live demonstration of why floating refs rot. 2. Make it checkable, so the fix cannot silently regress. .dev/floor/check-action-pins.mjs asserts every `uses:` in .github/workflows is @<40-hex> with a full-semver comment (enum/regex — ARCHITECTURE.md §2 primitive #3). Its test carries hermetic fixtures plus a live repo-consistency assertion, collected by floor.yml's existing `node --test` glob, so a floating ref turns the floor RED on the PR that introduces it. The `# v6` form is a named test case. Honest limits, stated not hidden: the checker enforces comment FORM, never comment TRUTH (verifying digest<->version needs the network, and floor scripts are network-free), and docker:// and local ./ refs are exempt. Follow-up not taken here: node-version policy (floor `lts/*` vs ci `20` vs publish `24`), which is also the unpinned link under this gate's own enforcement path. Co-Authored-By: Claude Opus 5 (1M context) --- .dev/features/floor-gate-action-pins/GRILL.md | 67 +++++ .dev/features/floor-gate-action-pins/PLAN.md | 91 +++++++ .../floor-gate-action-pins/REGRESSION.md | 36 +++ .../features/floor-gate-action-pins/REVIEW.md | 100 +++++++ .dev/features/floor-gate-action-pins/SHIP.md | 44 +++ .../features/floor-gate-action-pins/VERIFY.md | 46 ++++ .../regression-report.json | 20 ++ .../floor-gate-action-pins/verify-report.json | 13 + .dev/features/pin-floor-actions/GRILL.md | 71 +++++ .dev/features/pin-floor-actions/PLAN.md | 94 +++++++ .dev/features/pin-floor-actions/REGRESSION.md | 46 ++++ .dev/features/pin-floor-actions/REVIEW.md | 109 ++++++++ .dev/features/pin-floor-actions/SHIP.md | 42 +++ .dev/features/pin-floor-actions/VERIFY.md | 37 +++ .../pin-floor-actions/regression-report.json | 21 ++ .../pin-floor-actions/verify-report.json | 13 + .dev/floor/check-action-pins.mjs | 165 ++++++++++++ .dev/floor/check-action-pins.test.mjs | 250 ++++++++++++++++++ .github/workflows/ci.yml | 2 +- .github/workflows/floor.yml | 6 +- .github/workflows/publish.yml | 2 +- .pharn/pharn-dev-regress/verdict.json | 17 +- .pharn/pharn-dev-verify/verdict.json | 2 +- .pharn/writes-scope.json | 4 +- 24 files changed, 1281 insertions(+), 17 deletions(-) create mode 100644 .dev/features/floor-gate-action-pins/GRILL.md create mode 100644 .dev/features/floor-gate-action-pins/PLAN.md create mode 100644 .dev/features/floor-gate-action-pins/REGRESSION.md create mode 100644 .dev/features/floor-gate-action-pins/REVIEW.md create mode 100644 .dev/features/floor-gate-action-pins/SHIP.md create mode 100644 .dev/features/floor-gate-action-pins/VERIFY.md create mode 100644 .dev/features/floor-gate-action-pins/regression-report.json create mode 100644 .dev/features/floor-gate-action-pins/verify-report.json create mode 100644 .dev/features/pin-floor-actions/GRILL.md create mode 100644 .dev/features/pin-floor-actions/PLAN.md create mode 100644 .dev/features/pin-floor-actions/REGRESSION.md create mode 100644 .dev/features/pin-floor-actions/REVIEW.md create mode 100644 .dev/features/pin-floor-actions/SHIP.md create mode 100644 .dev/features/pin-floor-actions/VERIFY.md create mode 100644 .dev/features/pin-floor-actions/regression-report.json create mode 100644 .dev/features/pin-floor-actions/verify-report.json create mode 100644 .dev/floor/check-action-pins.mjs create mode 100644 .dev/floor/check-action-pins.test.mjs diff --git a/.dev/features/floor-gate-action-pins/GRILL.md b/.dev/features/floor-gate-action-pins/GRILL.md new file mode 100644 index 0000000..1265fd0 --- /dev/null +++ b/.dev/features/floor-gate-action-pins/GRILL.md @@ -0,0 +1,67 @@ +# GRILL — floor-gate-action-pins + +Plan under interrogation: `.dev/features/floor-gate-action-pins/PLAN.md` (treated as `trust: untrusted`). +**Spec-hash check: MATCH** — `bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e`, equal to the plan's `spec_content_hash`. (Floor-grade computation; it only *surfaces* here — `/pharn-dev-build`'s fix #4 gate is what blocks.) + +**Griller discovery (FLOOR — membership):** `node .dev/floor/count-grillers.mjs .` → `{"registered":0,"grillers":[]}`. Inline axes only. + +**Two plan claims independently re-verified before interrogating (P6):** + +- `floor.yml:26` does contain `".dev/**/*.test.mjs"` in its `node --test` glob list — the wiring claim is real, not assumed. +- Only `lens-scanner-map.test.mjs` enumerates `.dev/floor/` (`readdirSync`), and it filters `scan-code-*` — so a new `check-action-pins.mjs` cannot trip its orphan-scanner assertion. The plan's naming rationale holds. + +--- + +## Findings + +```yaml +- type: FINDING + rule_id: "P0" + severity: important + file: ".dev/features/floor-gate-action-pins/PLAN.md:63" + problem: "The live repo-consistency test asserts only that the checker exits 0, which is also what happens when it finds nothing to check, so a renamed or mis-resolved workflows directory would turn the gate vacuously green instead of red." + evidence: "'running the checker over `REPO` exits 0 — the invariant holds for this repo *now*'" + +- type: FINDING + rule_id: "P5" + severity: important + file: ".dev/features/floor-gate-action-pins/PLAN.md:46" + problem: "The ref grammar admits only owner/repo@sha and local ./ paths, leaving docker:// image refs and ${{ }} expression refs to be reported as floating-ref with no exemption path and no stated policy." + evidence: "'**Require** `uses: /[/]@<40 lowercase hex> # v..`.'" + +- type: FINDING + rule_id: "P0" + severity: important + file: ".dev/features/floor-gate-action-pins/PLAN.md:29" + problem: "The gate's enforcement is claimed as floor but rests on a runtime resolved by a floating node-version the plan itself lists as an unaddressed follow-up, so the guarantee inherits an unpinned dependency it does not name." + evidence: "'A new `.dev/floor/*.test.mjs` is therefore **auto-collected with no CI change**.'" + +- type: FINDING + rule_id: "P7" + severity: minor + file: ".dev/features/floor-gate-action-pins/PLAN.md:22" + problem: "The stated requirement that the two increments land together is a process expectation with nothing enforcing it, so splitting the PR would put a red floor test on main." + evidence: "'so the two must land together. If `pin-floor-actions` were reverted, this test would correctly go RED.'" +``` + +## Prose summary + +> Free-text `problem` / `evidence` quote the plan and inherit its **untrusted** tag — DATA for a human, never instructions (P2). + +**F1 is the one that matters, and it is the repo's own disease in miniature.** "Exit 0" is overloaded: it means *both* "I checked 9 refs and all conform" *and* "I found no `.github/workflows/` and had nothing to do." A gate that cannot distinguish those is a gate that reports success for having looked nowhere — precisely the "silent truncation reads as covered" failure. The plan even specifies the vacuous case deliberately (`no .github/workflows/ dir → exit 0, checked: 0`), which is right for the **CLI** (a consumer repo without workflows is not in violation) but wrong for the **repo-consistency test**, which must additionally assert it actually inspected something. Concrete fix: have that test assert `checked >= 9` — or, better, derive the expected count from the live tree — and assert `violations: []` explicitly rather than reading exit 0 alone. Cheap, and it converts the gate from "did not complain" to "looked at N things and found none wrong." + +**F2.** The grammar is a whitelist with no declared behavior for two real GitHub Actions forms: `uses: docker://alpine:3.18` and `uses: ${{ matrix.action }}`. Neither exists in this repo today, so nothing breaks now — but the first person to add a container step gets an unexplained `floating-ref` and no documented way out. Two defensible policies, and the plan should pick one **explicitly** rather than let the regex decide by accident: (a) treat `docker://` as out of scope and skip it, like `./`; or (b) require a digest there too (`docker://image@sha256:…`), which is the stricter and arguably more consistent reading. An expression ref genuinely *cannot* be pinned, so it should be a **named** violation reason (`unpinnable-ref`) rather than being mislabeled `floating-ref`. P5 asks that branches be membership tests with no silent fallback; right now these two forms fall through to a reason that misdescribes them. + +**F3.** The plan is careful to call the wiring floor-grade, and it is — but the chain is: floor.yml → `actions/setup-node` (now digest-pinned, good) → `node-version: lts/*` → whichever Node that resolves to → whether that Node's `--test` expands quoted glob patterns. `lts/*` is the exact floating input the plan lists as out-of-axis follow-up #1. The guarantee is real today and the plan should keep it, but it should **name** the dependency instead of presenting the wiring as unconditional. There is also no proof step: nothing in the plan requires demonstrating that the new test is actually collected. Recommend running floor.yml's exact `node --test` command line after the build and confirming the new test's name appears in the output — the same "assert by name, not by absence of red" discipline the previous increment's F3 established. + +**F4.** Declared honestly and accepted at GATE 1; recorded only so the coupling is visible if the PR is later split. + +**Positive worth stating (checked, not assumed).** This increment retroactively closes the `important` P1 finding from `pin-floor-actions`' review — *"every gate in the verify set is blind to `.github/workflows/**`"*. After this lands, `.github/workflows/**` has deterministic coverage for the first time, and it is the previous increment's own property being covered. That is the right order of operations: fix the instance, then make the instance's absence checkable. + +**Axes with nothing to report:** P1 (the eval list is unusually complete — it includes both boundary cases and, notably, the historical `# v6` defect as a named ★ case), P2 (no untrusted ingest; the output has no free-text field at all, so there is nothing to taint), P3 (two files, one axis each; the plan correctly refused to extend `validate.mjs` and cited its header to justify it), P4 (cites `gitleaks.yml:11` and the `lens-scanner-map` precedent rather than restating them). + +## Verdict + +**ADVISORY VERDICT: 4 concerns raised (0 blocking, 3 important, 1 minor) — for the human to weigh before `/pharn-dev-build`.** + +Nothing here blocks; `/pharn-dev-grill` gates nothing. Two findings change what should be **built** rather than merely how it is described: **F1** (assert `checked >= 9` and `violations: []`, not bare exit 0) and **F2** (name a policy for `docker://` and add an `unpinnable-ref` reason for expression refs). Both are small and both make the gate honest about what it actually inspected — recommend folding them into the build rather than deferring, since the plan's `## Files` already covers the two files they touch. diff --git a/.dev/features/floor-gate-action-pins/PLAN.md b/.dev/features/floor-gate-action-pins/PLAN.md new file mode 100644 index 0000000..76a2efd --- /dev/null +++ b/.dev/features/floor-gate-action-pins/PLAN.md @@ -0,0 +1,91 @@ +# PLAN — floor gate: every workflow action ref is digest-pinned with a full-semver comment + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: Add a deterministic floor checker over `.github/workflows/**` that fails when any `uses:` is not `@<40-hex>` with a `# v..` comment, plus its test — converting the previous increment's point-in-time grep into an invariant enforced on every PR. +- layer(s): `.dev/floor/` (the deterministic floor — `ARCHITECTURE.md §2` primitive #3, enum/regex) +- constitution_refs: [P0, P1, P3, P4, P5, P7] + +## Files + +- `.dev/floor/check-action-pins.mjs` — the checker CLI: walk workflows, regex each `uses:`, emit JSON + exit 0/1 — layer: floor +- `.dev/floor/check-action-pins.test.mjs` — hermetic fixture tests (the checker is correct) **plus** a live repo-consistency test (this repo conforms) — layer: floor + +Two files. **`floor.yml` is deliberately NOT modified** — see "Wiring" below; that is the finding of this plan's discovery, not an omission. + +## Live state read this run (P6) + +- **`validate.mjs` cannot host this check.** Its own header: _"It deliberately does NOT validate this repo's own tooling (.claude/commands, .dev/) — those are advisory orchestration, not built PHARN capabilities. Point this at the PHARN repo."_ Extending it to scan `.github/workflows/**` would give one file two change-reasons (**P3**). Hence a new sibling. +- **Naming convention:** `scan-*` is the lens system (`lens-scanner-map.json` maps `role: lens` capabilities to `scan-code-*.mjs`); `check-*` is the floor-verdict family (`check-verify`, `check-regress`, `check-ship`, `check-structural`). This is a floor verdict, so **`check-action-pins.mjs`**. Using `scan-` would make it an orphan scanner and trip `lens-scanner-map.test.mjs` assertion 4. +- **No existing checker** — `ls .dev/floor/ | grep -i "pin\|action\|workflow"` → none. +- **No local/composite action refs** — `grep -rn "uses: \./" .github/` → none; all 9 refs are `owner/repo@ref`. The `./` exemption below is defensive, not currently exercised. +- **`.dev/floor/` is deny-by-default** in `enforce-writes-scope.cjs` (`DEFAULT_SAFE_SET` omits it — "sensitive zones... intentionally absent"), so both files must be declared in `## Files` to be writable. They are. +- **Working tree state:** this increment stacks on the uncommitted `pin-floor-actions` diff (3 workflow files, already floor-GREEN / no-regressions / verify-PASS). Its repo-consistency test asserts a property that diff **just made true** — so the two must land together. If `pin-floor-actions` were reverted, this test would correctly go RED. + +## Wiring — the part that decides whether this is a guarantee or theatre (P0) + +A checker nothing invokes guarantees nothing. Two candidate wirings were examined against live state: + +1. **A new `run:` step in `floor.yml`** — works, but adds a third file and duplicates (2) below. +2. **A repo-consistency test collected by the existing runner** — `floor.yml:26` runs `node --test … ".dev/**/*.test.mjs" …` on every `pull_request` and `push: main`. A new `.dev/floor/*.test.mjs` is therefore **auto-collected with no CI change**. + +**(2) is chosen, and it follows an existing in-repo precedent rather than inventing a pattern:** `lens-scanner-map.test.mjs` is explicitly _"UNLIKE the other floor tests (which are hermetic over scratch fixtures), this one validates the COMMITTED artifact against REALITY... Its whole job is to make prose/map DRIFT a build failure (P7 — a real, already-observed drift)"_. It resolves `REPO = join(here, "..", "..")` and asserts over the live tree. This increment is the same shape: make **pin drift** a build failure. + +Consequence to state plainly: the enforcement point is **`floor.yml`'s `node --test` step**, not a bespoke gate. `npm test` is vitest over `tests/**/*.ts` and does **not** collect `.mjs`, so this gate runs in **floor.yml only** — which is sufficient, because floor.yml triggers on every `pull_request` and on `push: main`. + +## Contracts satisfied + +- None from `pharn-contracts` — this adds no Capability, no `rule_id`, and emits no `findings.json`. It is a floor CLI in the `check-*` family, and it conforms to that family's existing surface (a `targetDir` argument, JSON on stdout, verdict in the exit code) by imitation, not by a new contract (**P7** — a contract for one occupant would be speculative). +- Enforces the convention stated at `.github/workflows/gitleaks.yml:11` — _"pinning third-party code by digest, never a floating tag"_ (cited, not restated — **P4**). + +## The check (deterministic; P5) + +For every `*.yml` / `*.yaml` under `.github/workflows/`, for every line whose first non-whitespace token is `uses:`: + +- **Skip** lines that are YAML comments (first non-whitespace char is `#`) — a commented-out example is not a ref. +- **Skip** local refs beginning `./` — first-party, nothing to pin. +- **Require** `uses: /[/]@<40 lowercase hex> # v..`. + +Violation reasons are an **enum**, not prose: `floating-ref` (the `@ref` is not 40-hex), `missing-comment` (pinned, no trailing comment), `malformed-comment` (comment present but not full semver — **this is the `# v6` case**). Output `{"checked":,"violations":[{"file","line","ref","reason"}]}`; exit **1** if any violation, **0** otherwise. Stdlib only, no network, no `child_process`. + +## Evals to write (P1) + +Hermetic fixtures in `os.tmpdir()`, asserting the public surface (exit code + stdout JSON) by subprocess — mirroring `count-verifiers.test.mjs`: + +- conforming ref (`@<40-hex> # v7.0.1`) → exit 0, `violations: []` +- floating major (`@v7`) → exit 1, reason `floating-ref` +- floating patch tag (`@v7.0.1`) → exit 1, reason `floating-ref` — **the previous increment's exact starting state** +- digest, no comment → exit 1, reason `missing-comment` +- ★ digest + major-only comment (`# v6`) → exit 1, reason `malformed-comment` — **the `ff48077` defect, proven caught** +- commented-out `# - uses: foo@v1` → exit 0 (not a ref) +- local `./.github/actions/x` → exit 0 (exempt) +- uppercase-hex / 39-hex / 41-hex ref → exit 1, `floating-ref` (boundary) +- no `.github/workflows/` dir → exit 0, `checked: 0` (vacuous, not a crash) +- ★ **live repo-consistency:** running the checker over `REPO` exits 0 — the invariant holds for this repo *now*, and any future PR that breaks it turns floor.yml RED + +## Guarantee audit (P0) + +| Claim | Reduction | +| --- | --- | +| "Every workflow action ref is digest-pinned with a full-semver comment" | **FLOOR — regex + set membership** (`ARCHITECTURE.md §2` primitive #3), enforced on every PR via floor.yml's `node --test`. This is the increment's one real guarantee. | +| "The `ff48077` defect could not recur silently" | **FLOOR**, and narrowly true: that commit produced a **major-only** comment (`# v6`), which the `malformed-comment` rule rejects. Claimed only for that form. | +| "The comment tells the truth about the digest" | **NOT GUARANTEED — named residual.** Verifying comment↔digest agreement requires `git ls-remote`; floor scripts are network-free by convention. **The checker enforces comment FORM, never comment TRUTH.** A well-formed-but-wrong comment (digest bumped to v7.0.0, comment left at `# v6.4.0`) passes. Honest boundary: this shrinks the recurrence surface, it does not close it. | +| "This runs in CI" | **FLOOR — existing runner**, `floor.yml:26`'s `node --test` glob already collects `.dev/**/*.test.mjs`. Verified by reading the workflow, not assumed. | +| "The gate set stays wired" | **ADVISORY.** Nothing prevents a future edit removing the glob or the test. Same honest caveat `/pharn-dev-verify` makes about its own gate set. | + +## Trust audit (P2) + +No untrusted remote artifact is ingested — the checker reads only committed files in the repo under test, performs no network I/O, no `child_process`, and no `eval`/dynamic import. Workflow file contents are treated as **data**: matched against a regex and reported, never executed. The `violations[]` output is entirely enum-gated / path-resolved (`file`, `line`, `ref`, enum `reason`) with **no free-text field**, so no tainted value exists to leak into a decision. Note the checker's *own* subject matter is the repo's supply-chain trust surface, but the checker itself adds no new trust boundary. + +## Determinism audit (P5) + +Every decision is a regex match or set membership: is the line a comment; is the ref local; is the `@ref` exactly 40 lowercase hex; does the trailing comment match `# v\d+\.\d+\.\d+`. The `reason` is an enum, never a classification. There is no fallback that guesses — an unparseable line is a **violation** (fail-closed), not a pass. No branch depends on judgment, so no "ask the human" path arises inside the checker. + +## Out of axis — observed, NOT in this increment (P3/P7) + +1. **`node-version` policy** (`lts/*` vs `20` vs `24`) — still open, still a different axis; carried forward from the previous increment. +2. **Comment↔digest truth verification** — would need network in the floor. Deliberately not built; recorded as the named residual above. +3. **Wiring the gate into `ci.yml` as well** — redundant today (floor.yml runs on every PR). Not added (P7). + +## Open questions (HALT) + +None blocking. One decision is **declared, not asked**: this increment stacks on the uncommitted `pin-floor-actions` diff and the two must land together, since the repo-consistency test asserts the property that diff establishes. diff --git a/.dev/features/floor-gate-action-pins/REGRESSION.md b/.dev/features/floor-gate-action-pins/REGRESSION.md new file mode 100644 index 0000000..4667ba3 --- /dev/null +++ b/.dev/features/floor-gate-action-pins/REGRESSION.md @@ -0,0 +1,36 @@ +# REGRESSION — floor-gate-action-pins + +- **base:** `112e22616993bf219fc251a4f0c5d008ea017cb2` (working-tree dogfood build → `base = HEAD`) +- **machine report:** `.dev/features/floor-gate-action-pins/regression-report.json` + +## Partition + +`inside` (build-attributable, identical to the plan's `## Files`): + +- `.dev/floor/check-action-pins.mjs` +- `.dev/floor/check-action-pins.test.mjs` + +`check-regress.mjs scope` → **exit 0**, `escaped: []`. The build wrote exactly its declared `## Files`. + +The working tree also holds the **previous increment's** three workflow files (`pin-floor-actions`, uncommitted and intentionally stacked) plus the loop-owned `.pharn/writes-scope.json` and `.dev/features/**` artifacts. None is a write of *this* build: the workflow files predate it in this tree, `.pharn/**` is `ALWAYS`-writable in `enforce-writes-scope.cjs:61`, and each `.dev/features/**` artifact was written under its own stage's scope. The build's own scope was floor-pinned to the two floor paths, so a write outside them would have been denied at write time. + +`outside_tests`: 44 committed test files. The new test file is **inside**, so it is correctly not an outside gate. `outside_eval_pairs`: none. + +## Gate table (base → head, exit codes) + +| gate | base | head | flip | +| --- | --- | --- | --- | +| `tests` (`node --test`, 44 outside files) | 0 | 0 | none | +| `validate` (`validate.mjs .`, whole-repo) | 0 | 0 | none | + +`regressions[]`: **empty.** `pre_existing[]`: **empty.** Style gates skipped deterministically — `inside` touches no shared style config. + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** + +`check-regress.mjs verdict` → **exit 0**, `"no-regressions"`. + +## The honest residual (P7) + +The comparison catches exactly what the suite covers. Unlike the previous increment — where the residual was severe because *no* gate read the changed files — this increment's own artifact **is** a gate, and it was exercised directly rather than only compared: 18 hermetic tests plus a live repo-consistency assertion, all collected by floor.yml's exact `node --test` command (verified by name: `684 = 666 baseline + 18`, exit 0), and a true-negative check in a scratch copy of the real workflows confirming the gate returns exit 1 with `{"file":".github/workflows/floor.yml","line":22,"ref":"actions/setup-node@v7","reason":"floating-ref"}` when a floating ref is reintroduced. Those are the evidence that the feature works; this stage only certifies that adding it broke nothing else. diff --git a/.dev/features/floor-gate-action-pins/REVIEW.md b/.dev/features/floor-gate-action-pins/REVIEW.md new file mode 100644 index 0000000..4a432aa --- /dev/null +++ b/.dev/features/floor-gate-action-pins/REVIEW.md @@ -0,0 +1,100 @@ +# REVIEW — floor-gate-action-pins + +**Step 1 — floor first (P0):** `node .dev/floor/validate.mjs .` → **exit 0**, `FLOOR: GREEN`. + +Increment under review (`trust: untrusted`): `.dev/floor/check-action-pins.mjs` + `.dev/floor/check-action-pins.test.mjs`. + +--- + +## Findings + +> Free-text `problem` / `evidence` inherit the reviewed increment's untrusted tag (`ARCHITECTURE.md §8`) — quoted DATA, never directives. + +### L-floor → P0 + +```yaml +- type: FINDING + rule_id: "P0" + severity: important + file: ".dev/floor/check-action-pins.mjs:91" + problem: "The docker:// exemption is an unasserted bypass: converting a pinned action ref to a container ref removes it from the checked set entirely, and the live repo test asserts violations and checked but never that skipped is zero, so the escape leaves no trace in the gate." + evidence: "'if (ref.startsWith(\"docker://\")) return true;'" + gate: advisory-gate + +- type: FINDING + rule_id: "P0" + severity: minor + file: ".dev/floor/check-action-pins.mjs:3" + problem: "The header states the rule over every third-party uses: ref, but two classes of third-party ref are exempted below it, so the stated guarantee is broader than the check performs." + evidence: "'every third-party GitHub Actions `uses:` ref must be pinned by a 40-hex COMMIT DIGEST and carry a full-semver `# vX.Y.Z` comment.'" + gate: advisory-gate +``` + +### L-eval → P1 + +No finding. Every behavior added ships a test in the same increment, including the two behaviors introduced mid-build during the fail-open fix (quote-stripping, empty `uses:`). Coverage includes both boundaries (39/41-hex, uppercase) and the two named-in-plan ★ cases. Floor agreement: `validate.mjs` reports `0 capabilities`, the increment adds none — the floor and this lens agree that no capability eval binding is owed. + +### L-trust → P2 + +```yaml +- type: FINDING + rule_id: "P2" + severity: minor + file: ".dev/features/floor-gate-action-pins/PLAN.md:76" + problem: "The trust audit claims the output has no free-text field, but the ref value is copied verbatim from the scanned workflow file, so one attacker-influenced string does reach the output of an otherwise fully enum-gated report." + evidence: "'The `violations[]` output is entirely enum-gated / path-resolved (`file`, `line`, `ref`, enum `reason`) with **no free-text field**'" + gate: advisory-gate +``` + +**Did instruction-looking content change my behavior?** No. The checker treats workflow YAML strictly as data — regex-matched, never parsed as a program, never executed; no `child_process`, no network, no `eval`, no dynamic import (verified by reading the file, not assumed). + +The finding above is a **precision** correction to my own plan, and the blast radius is small but worth stating exactly: `ref` is verbatim file content, so it is the one value in `violations[]` that inherits the scanned file's trust. It is JSON-escaped on output and **no decision anywhere reads it** — the verdict is `violations.length > 0`, an integer comparison, so a hostile `uses:` string cannot influence the exit code. The correct claim is "the *decision* rests on no tainted field," not "the output contains none." + +### L-axis → P3 + +No finding. Two files, one axis each; the checker walks and classifies in service of one purpose, and the plan correctly refused to extend `validate.mjs` (citing its own header) rather than giving that file a second change-reason. No sibling references — this is a standalone stdlib CLI reached by subprocess, exactly like its `check-*` siblings. + +### Process (outside the four lenses, but a real defect in how the increment was built) + +```yaml +- type: FINDING + rule_id: "P4" + severity: minor + file: ".claude/commands/pharn-dev-build.md:1" + problem: "Step 2b instructs the builder to run the project formatter over the just-written files, which is actively wrong for any file outside format:check's globs — obeying it rewrote both new floor scripts into a quote style no other file in that directory uses." + evidence: "'Run the project formatter over the just-written files — `npm run format` (prettier `--write`)'" + gate: advisory-gate +``` + +Concretely: `format:check` covers `src/**/*.ts`, `tests/**/*.ts`, `*.config.ts`; `.prettierrc` sets `singleQuote: true, printWidth: 80`; all 40+ `.dev/floor/` siblings are double-quoted at ~110 columns because prettier has never governed them. Following Step 2b converted both new files to single quotes, creating a directory-wide inconsistency that **no gate would ever have caught** (`.dev/floor/**` is outside `format:check`). Detected by comparing against siblings and reverted by hand. Step 2b's own text calls itself ADVISORY and says it "changes no verdict" — correct, and here the advisory step made things worse. + +--- + +## Gate split (fix #3) + +- **floor-gate (blocking): none.** `validate.mjs` GREEN; no eval binding owed; no sibling reference; no guaranteed decision rests on a tainted field. +- **advisory-gate (warn): all four findings.** None is a basis for blocking. + +## What the increment got right (checked, not assumed) + +- **The gate is proven to gate, three ways** — its own suite (18/18); collection under floor.yml's exact command asserted **by name** (`684 = 666 + 18`, exit 0), per `GRILL.md` F3 rather than "no red"; and a true-negative on a scratch copy of the real workflows returning exit 1 with `{"file":".github/workflows/floor.yml","line":22,"ref":"actions/setup-node@v7","reason":"floating-ref"}`. +- **The grill earned its place.** F1 turned a bare `exit 0` assertion into `violations: [] ∧ checked >= 10 ∧ files == readdirSync(...)` — closing the "success for having looked nowhere" hole. F2 prompted the `unpinnable-ref` case, which then **caught a real fail-open in the implementation**: `(\S+)` could not capture a space-containing `${{ … }}` ref, so such a ref was skipped rather than flagged. A grill finding that surfaces a live defect in the thing being built is the stage working as designed. +- **The vacuity boundary is placed correctly** — the CLI stays vacuously clean on a repo with no workflows (right for a consumer repo), and the *caller* carries the anti-vacuity assertions. + +## Verdict + +**GREEN — 0 floor-gate findings; 4 advisory (0 blocking, 1 important, 3 minor).** + +Standing floor verdicts: `validate` exit 0 · `"no-regressions"` · `"PASS"`. This verdict is advisory and gates nothing. + +--- + +## Proposed lesson candidates (NOT written to canon — P2/P7) + +For a separate, human-gated `/pharn-dev-memory-promote` run. `/pharn-dev-review` declares no `.dev/memory-bank/**` path. + +**Candidate 1 — stale pin comments (carried from the `pin-floor-actions` review, unchanged and still unpromoted).** A dependabot digest bump can cross a major while leaving the `# vN` comment untouched; prefer full-semver comments and treat comment↔digest agreement as unguarded. *Provenance:* `ff48077`, propagated by `c425edd`, found by manual audit. **Now partially mechanised** by this increment — the major-only *form* is rejected, the comment's *truth* still is not. + +**Candidate 2 — do not run the formatter outside its configured globs.** A repo can have a prettier config that resolves everywhere while `format:check` only *checks* a subset; running `prettier --write` on files outside that subset silently imposes a style the directory does not use, and no gate will ever flag it. Check the `format:check` globs before formatting a just-written file. *Provenance:* increment `floor-gate-action-pins`; `.dev/floor/**` is outside `format:check`'s `src/**/*.ts`/`tests/**/*.ts`/`*.config.ts`; 40+ siblings use double quotes at ~110 columns while `.prettierrc` specifies `singleQuote: true, printWidth: 80`. *Argues for:* amending `/pharn-dev-build` Step 2b to scope its formatter advice to the configured globs. + +**Named follow-ups (not built — P7):** (1) `node-version` policy — `lts/*` vs `20` vs `24`, and it is the unpinned link under this gate's own enforcement path; (2) comment↔digest *truth* verification, which needs network and so cannot live in a floor script as currently constrained; (3) asserting `skipped === 0` in the live repo test, which would close finding R1's bypass. diff --git a/.dev/features/floor-gate-action-pins/SHIP.md b/.dev/features/floor-gate-action-pins/SHIP.md new file mode 100644 index 0000000..47869f6 --- /dev/null +++ b/.dev/features/floor-gate-action-pins/SHIP.md @@ -0,0 +1,44 @@ +# SHIP — floor-gate-action-pins + +Gated `/pharn-dev-ship` run (no `--loop`). Base `112e22616993bf219fc251a4f0c5d008ea017cb2`. This is the **second** increment of the session; it stacks on the uncommitted `pin-floor-actions` diff and the two are intended to land together (`.dev/features/pin-floor-actions/SHIP.md`). + +## Stages run, in order + +| # | stage | outcome | +| --- | --- | --- | +| 1 | `/pharn-dev-plan` | `PLAN.md` written; halted at **GATE 1** | +| — | **human** | approved **"Approve as written"** | +| 2 | `/pharn-dev-grill` | `GRILL.md` — 4 concerns, advisory; F1/F2 folded into the build | +| 3 | `/pharn-dev-build` | 2 files written; floor RED once (a fail-open), fixed, then GREEN | +| 4 | `/pharn-dev-regress` | `regression-report.json` + `REGRESSION.md` | +| 5 | `/pharn-dev-verify` | `verify-report.json` + `VERIFY.md` | +| 6 | `/pharn-dev-review` | `REVIEW.md`; **run ends here at GATE 2** | + +**Where the run ended: GATE 2.** Not a RED-verdict STOP. + +## Structural verdicts read, verbatim + +- **`/pharn-dev-build` → `node .dev/floor/validate.mjs .` exit code: `0`** (`FLOOR: GREEN`). `npm run check` exit `0`. + - Recorded honestly: the floor was **RED at first** — the `unpinnable-ref` test (added because of `GRILL.md` F2) failed, exposing a fail-open in the checker's own ref-capture regex. Per `/pharn-dev-build` Step 3 the build HALTed, the parser was fixed, and the floor was re-run to GREEN. The verdict above is the post-fix read. +- **`/pharn-dev-regress` → `regression-report.json` `.verdict`: `"no-regressions"`** (exit `0`; `regressions[]` empty; `tests` 0→0, `validate` 0→0). +- **`/pharn-dev-verify` → `verify-report.json` `.verdict`: `"PASS"`** (exit `0`; `failing_gates[]` empty; `verifiers.registered: 0`). + +## Feature evidence beyond the verdicts (because the verdicts do not cover it) + +`npm test` is vitest and does not collect `.mjs`, so the increment's 18 tests are outside the `test` gate. Three separate proofs are recorded in `VERIFY.md`: its own suite 18/18; **collection under floor.yml's exact command asserted by name** (`684 = 666 + 18`, exit 0); and a **true-negative** on a scratch copy of the real workflows — reintroducing `actions/setup-node@v7` yields exit 1 with `{"file":".github/workflows/floor.yml","line":22,"ref":"actions/setup-node@v7","reason":"floating-ref"}`. + +## Pointers (cited, not restated — P4) + +- `.dev/features/floor-gate-action-pins/REVIEW.md` — GREEN, 0 floor-gate findings, 4 advisory. Two **lesson candidates** proposed, neither written to canon. +- `.dev/features/floor-gate-action-pins/GRILL.md` — F1 replaced a bare `exit 0` assertion with content assertions; F2 produced the test that caught the fail-open. +- `.dev/features/floor-gate-action-pins/{REGRESSION,VERIFY}.md`. + +## Scope + +`/pharn-dev-ship`'s only Write-tool output is this file, scoped to itself immediately before writing (fix #7). The build's scope was floor-pinned to the plan's two `## Files` paths; `check-regress.mjs scope` confirmed `escaped: []`. + +## Standing decision + +The 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.** + +Nothing has been committed, pushed, merged, or sealed. diff --git a/.dev/features/floor-gate-action-pins/VERIFY.md b/.dev/features/floor-gate-action-pins/VERIFY.md new file mode 100644 index 0000000..a09a51b --- /dev/null +++ b/.dev/features/floor-gate-action-pins/VERIFY.md @@ -0,0 +1,46 @@ +# VERIFY — floor-gate-action-pins + +Machine report: `.dev/features/floor-gate-action-pins/verify-report.json`. + +## FLOOR layer — the gates that own the verdict + +| gate | exit | +| --- | --- | +| `test` (`npm test` — vitest, 625 tests) | 0 | +| `validate` (`validate.mjs .`) | 0 | +| `lint` (eslint over `src`) | 0 | +| `format:check` (prettier) | 0 | +| `lint:md` (markdownlint-cli2) | 0 | + +`structural:*` — none (no `*/evals/expected/*.json` in this repo). + +`node .dev/floor/check-verify.mjs … --feature floor-gate-action-pins` → **exit 0**, `"verdict": "PASS"`, `failing_gates: []`. + +## ADVISORY layer — verifiers + +`count-verifiers.mjs` → `{"registered":0,"verifiers":[]}`. **No verifiers registered — floor gates only.** No verifier free-text was produced. + +## Feature-specific evidence (the part the gate table above does NOT cover) + +`npm test` is vitest over `tests/**/*.ts` and does **not** collect `.mjs`, so **the increment's own 18 tests are invisible to the `test` gate in the table above.** Stating that plainly matters more than the green row. They were run directly, and the enforcement path was proven three ways: + +1. **Its own suite:** `node --test .dev/floor/check-action-pins.test.mjs` → **18/18 pass**, including the ★ `# v6` historical-defect case and the ★ live repo-consistency assertion. +2. **Collection proof (by name, per `GRILL.md` F3 — not "absence of red"):** running floor.yml's exact command line, `grep` finds both `THIS repo: every workflow action ref` and `ff48077 defect` in the output; totals `684 = 666 baseline + 18`, exit 0. The gate really is collected by CI. +3. **True-negative proof:** a scratch copy of the real `.github/workflows/` returns exit 0; reintroducing `actions/setup-node@v7` into it returns **exit 1** with `{"file":".github/workflows/floor.yml","line":22,"ref":"actions/setup-node@v7","reason":"floating-ref"}`. The gate fails when it should, not merely passes when it should. + +**A fail-open was found and fixed during the build.** The first implementation captured the ref with `(\S+)`, so a `${{ … }}` expression ref — which contains spaces — did not match the `uses:` pattern at all and was silently skipped rather than flagged. The `unpinnable-ref` test (written because of `GRILL.md` F2) caught it, the floor went RED, and the parser was changed to capture the whole remainder and split on `#`. Worth recording: the grill finding did not just improve wording, it surfaced a real defect in the gate itself. + +## VERDICT + +**VERIFIED: floor gates PASS.** + +## The honest residual (P0/P7) + +"Verified" means the five named gates passed — nothing more. Specific boundaries for this increment: + +- **The checker enforces comment FORM, never comment TRUTH.** `# v7.0.0` is validated as full semver; that it actually names commit `8207627…` is not checked, because that needs `git ls-remote` and floor scripts are network-free. A digest bumped under a well-formed but wrong `# v6.4.0` still passes. This shrinks the recurrence surface; it does not close it. +- **The enforcement path has an unpinned dependency (`GRILL.md` F3):** floor.yml → `setup-node` (digest-pinned) → `node-version: lts/*` → that runtime's `node --test` glob expansion. `lts/*` is the out-of-axis follow-up this increment did not take. True today, verified today, not floor-locked forever. +- **`lint:md` covers `docs/**/*.md` + `*.md` only** — the `.dev/features/**` markdown this loop wrote is not style-gated by the passing `lint:md`. +- **`.dev/floor/**` is outside `format:check`'s globs** (`src/**/*.ts`, `tests/**/*.ts`, `*.config.ts`), so the two new files' style is not gated either. They were matched to the 40+ sibling floor scripts by hand — see `REVIEW.md`, which records that Step 2b's "run the formatter" advice is actively wrong for this directory. + +Nothing here should be read as "`/pharn-dev-verify` ensures the feature is correct." diff --git a/.dev/features/floor-gate-action-pins/regression-report.json b/.dev/features/floor-gate-action-pins/regression-report.json new file mode 100644 index 0000000..13558a9 --- /dev/null +++ b/.dev/features/floor-gate-action-pins/regression-report.json @@ -0,0 +1,20 @@ +{ + "base": "112e22616993bf219fc251a4f0c5d008ea017cb2", + "inside": [ + ".dev/floor/check-action-pins.mjs", + ".dev/floor/check-action-pins.test.mjs" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/floor-gate-action-pins/verify-report.json b/.dev/features/floor-gate-action-pins/verify-report.json new file mode 100644 index 0000000..347c28d --- /dev/null +++ b/.dev/features/floor-gate-action-pins/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "floor-gate-action-pins", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.dev/features/pin-floor-actions/GRILL.md b/.dev/features/pin-floor-actions/GRILL.md new file mode 100644 index 0000000..01df5e2 --- /dev/null +++ b/.dev/features/pin-floor-actions/GRILL.md @@ -0,0 +1,71 @@ +# GRILL — pin-floor-actions + +Plan under interrogation: `.dev/features/pin-floor-actions/PLAN.md` (treated as `trust: untrusted`). +**Spec-hash check: MATCH** — recomputed `sha256(ARCHITECTURE.md)` = `bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e`, equal to the plan's `spec_content_hash`. (Content-hash is floor-grade; here it only *surfaces* — `/pharn-dev-build`'s fix #4 gate is what blocks on drift.) + +**Griller discovery (FLOOR — membership):** `node .dev/floor/count-grillers.mjs .` → `{"registered":0,"grillers":[]}`. Verified live: `pharn-pipeline/grillers/` does not exist in **pharn-cli** — the `testability` griller named in the stage command's prose lives in the **pharn-oss** tree, so it does not register here. Inline axes only (Step 2); no griller findings folded in. Recorded as a stage observation, not a finding against this plan. + +--- + +## Findings + +```yaml +- type: FINDING + rule_id: "P0" + severity: important + file: ".dev/features/pin-floor-actions/PLAN.md:19" + problem: "The plan inherits the brief's 'two floating refs' framing, which conflates a moving major alias with a pinned patch tag — two materially different risks — and so overstates the drift the checkout change prevents." + evidence: "'**`floor.yml:19-20` are the only two floating refs**; the other 7 are `@<40-hex> # v`.'" + +- type: FINDING + rule_id: "P6" + severity: important + file: ".dev/features/pin-floor-actions/PLAN.md:40" + problem: "The plan narrates a causal mechanism for dependabot's comment handling that it never verified this run, and its own cited evidence is inconsistent with the simple form of that story." + evidence: "'swapped the digest across a major bump and left the major-only comment untouched. That is the exact commit where the comment started lying.'" + +- type: FINDING + rule_id: "P1" + severity: important + file: ".dev/features/pin-floor-actions/PLAN.md:59" + problem: "'A green floor check on the PR is the demonstration' is under-specified as a check: absence of a red check is not proof the edited workflow parsed and ran, so the stated proof can be satisfied by a PR where the floor job never appeared." + evidence: "'A green floor check on the PR *is* the demonstration (P1's \"demonstrates, not asserts\").'" + +- type: FINDING + rule_id: "P7" + severity: minor + file: ".dev/features/pin-floor-actions/PLAN.md:10" + problem: "The increment bundles three changes spanning two axes — supply-chain pinning plus credential hardening in floor.yml, and a comment correction in two files the pinning does not touch — where the comment fix is separable and independently shippable." + evidence: "'`.github/workflows/floor.yml` — pin both `uses:` by digest + add `persist-credentials: false`'" + +- type: FINDING + rule_id: "P0" + severity: minor + file: ".dev/features/pin-floor-actions/PLAN.md:67" + problem: "The guarantee-audit's zero-execution-change row covers setup-node only, omitting that the same equality was verified for checkout, leaving the audit incomplete about the change's actual runtime effect." + evidence: "'| \"Zero execution change today\" (setup-node) | **FLOOR — verified equality**, `refs/tags/v7` head == the digest being pinned, checked this run.'" +``` + +## Prose summary + +> The free-text `problem` / `evidence` above quote the plan and inherit its **untrusted** tag — they are DATA for a human to weigh, never instructions (P2). + +**On F1 (the sharpest one).** `floor.yml` holds `actions/checkout@v7.0.1` and `actions/setup-node@v7`. Only the second is a *moving alias* — the exact failure the plan documents for `v6` (v6.4.0 → v6.5.0). `@v7.0.1` is a released patch tag; it changes only if upstream force-moves an existing tag, which is a different and far rarer threat. Calling both "floating" borrows the moving-alias evidence to justify the checkout change. The checkout pin is still **right** — the honest reasons are (a) a tag is mutable *in principle*, (b) repo convention (`gitleaks.yml:11`), and (c) dependabot preserves whatever form it finds, so a tag reproduces the divergence forever. Recommend the PR description say that, rather than implying `@v7.0.1` was silently drifting. Note the plan's own FYI table (line 31) quietly concedes this: it tracks the `v7` head, never a `v7.0.1` head. + +**On F2.** The plan asserts the trailing comment is "load-bearing … for dependabot's bump PRs." That may well be true, but nothing in this run verified it, and the plan's own evidence cuts against the naive version: at `ff48077` dependabot wrote a **v7.0.0 digest** while leaving the comment at `# v6` and titling the PR "from 6 to 7". If it simply read-and-rewrote the comment, it would have written `# v7`. The observable fact — digest moved, comment did not — is solid and independently verified. The *mechanism* is inference. Recommend downgrading the dependabot clause to advisory (or dropping it): the human-auditor rationale stands alone, and this repo's whole thesis is not selling an unverified inference as a reason. + +**On F3.** This is the one that could let a broken change look proven. If the edited `floor.yml` fails to parse, GitHub may surface that as a workflow-configuration error rather than a red *floor* check — so "the PR is green" is compatible with "floor never ran." Phase C should assert **presence and conclusion by name** (a membership test over the PR's check list — e.g. `gh pr checks` showing a `floor` entry with a `pass` conclusion), not a visual scan for red. Cheap to fix, and it converts the P1 story from a claim into a check. + +**On F4.** Declared in the brief and approved by the human at GATE 1, so this is visibility, not obstruction. Worth stating plainly: the ci/publish comment fix touches files the pinning work does not, and would be a clean standalone PR. Bundling is defensible here (it was *discovered by* the pinning verification, and splitting would mean two near-empty PRs) — but P7's "smallest coherent increment" is being stretched, and that should be a choice on the record rather than an unexamined default. + +**On F5.** Minor completeness: `@v7.0.1` → `3d3c42e5…` is the same commit, verified this run, so the *entire* floor.yml change — both refs — is zero-execution-change today. The audit says so for one ref only. + +**Risk raised and CLEARED (not a finding).** Adding `persist-credentials: false` could in principle break a job that performs authenticated git operations. Checked live: `floor.yml`'s only two `run:` steps are `node --test …` and `node .dev/floor/validate.mjs .`; `validate.mjs` contains no `child_process`, no `fetch`, and no git invocation (stdlib-only), and `check-regress.mjs` — the one floor script that reasons about `git diff` — is not invoked by this workflow. `persist-credentials: false` removes the token from `.git/config`; local git still functions regardless. No breakage path found. + +**Axes with nothing to report:** P2 (no untrusted artifact is ingested by the product; `src/**` untouched — the plan's trust audit correctly scopes itself to the repo's own CI supply chain), P3 (each of the three files changes for exactly one reason; no sibling imports involved), P5 (every branch is string equality or a presence test on data read this run, with HALT as the terminal fallback — no classification anywhere). + +## Verdict + +**ADVISORY VERDICT: 5 concerns raised (0 blocking, 3 important, 2 minor) — for the human to weigh before `/pharn-dev-build`.** + +Nothing here blocks: `/pharn-dev-grill` is advisory end-to-end, and none of these findings is a floor-gate. The plan's core — the digest values, the verification discipline, the whitelist — held up under interrogation: every load-bearing digest↔tag mapping was independently confirmed, and the one concrete breakage hypothesis was checked and cleared. The three `important` findings are all about **claim precision** (F1, F2) and **proof precision** (F3), not about the change being wrong. F3 is the only one that changes what should *happen*: tighten Phase C to assert the floor check by name. diff --git a/.dev/features/pin-floor-actions/PLAN.md b/.dev/features/pin-floor-actions/PLAN.md new file mode 100644 index 0000000..d1cdfb1 --- /dev/null +++ b/.dev/features/pin-floor-actions/PLAN.md @@ -0,0 +1,94 @@ +# PLAN — pin floor.yml actions by digest; fix the stale setup-node version comments + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: Pin `floor.yml`'s two third-party action refs by commit digest (the repo's existing digests), add the `persist-credentials: false` that the other four workflows already set, and correct the `# v6` setup-node comment in `ci.yml`/`publish.yml` to `# v7.0.0` — digest unchanged. +- layer(s): repo CI configuration (`.github/workflows/`) — not a product layer per `ARCHITECTURE.md §4`; no `src/**`, no `pharn-contracts`, no Capability. +- constitution_refs: [P0, P1, P2, P3, P4, P5, P7] + +## Files + +- `.github/workflows/floor.yml` — pin both `uses:` by digest + add `persist-credentials: false` — layer: repo CI +- `.github/workflows/ci.yml` — comment-only: `# v6` → `# v7.0.0` (digest byte-identical) — layer: repo CI +- `.github/workflows/publish.yml` — comment-only: `# v6` → `# v7.0.0` (digest byte-identical) — layer: repo CI + +No other file. Explicitly NOT touched: `gitleaks.yml`, `codeql.yml`, `CHANGELOG.md` (precedent verified: the five prior workflow-only commits carry none), `docs/**`, `src/**`. + +## Live state read this run (P6 — nothing asserted from memory) + +- Base = `112e226` = `origin/main` (fetched this run). Working tree clean. +- `grep -rn "uses:" .github/workflows/` → exactly 5 files, 9 `uses:` lines. **`floor.yml:19-20` are the only two floating refs**; the other 7 are `@<40-hex> # v`. +- `persist-credentials: false` present in `ci.yml:17`, `codeql.yml:28`, `publish.yml:26`, `gitleaks.yml:32` — **4/4; `floor.yml` is the sole omission.** +- `floor.yml` body matches the described shape: header comment naming `pull_request` (NOT `pull_request_target`) and "no write and no secrets", `permissions: contents: read`, `node-version: lts/*`, two steps (unit tests, validate floor). **No steps or refs added since `112e226`.** +- `.github/dependabot.yml` — `github-actions` ecosystem, `weekly`, live. +- No floor check and no `tests/*.ts` reads `.github/workflows/**` (grepped): there is **no** existing deterministic gate over workflow files. + +### Upstream digest↔tag verification (re-run this run; all lightweight tags — no `^{}` peeled lines) + +| Check | Result | Status | +| --- | --- | --- | +| `ls-remote actions/checkout v7.0.1` | `3d3c42e5aac5ba805825da76410c181273ba90b1` | ✅ **load-bearing — matches the repo's existing digest in 4 workflows** | +| `ls-remote --tags actions/setup-node \| grep 8207627` | `refs/tags/v7` **and** `refs/tags/v7.0.0` | ✅ **load-bearing — `8207627…` is v7.0.0** | +| `refs/tags/v7` (setup-node) head today | `820762786026740c76f36085b0efc47a31fe5020` | FYI — **has NOT advanced**; floor's floating `@v7` resolves today to the exact commit ci/publish pin | +| `refs/tags/v6` (setup-node) head today | `249970729cb0ef3589644e2896645e5dc5ba9c38` = v6.5.0 | FYI — moved (see provenance below) | + +**Correction to the incoming brief, carried forward:** the brief's table renders the checkout digest as `3d3c42e5aac5ba805825da76410c18127.0.1` — a copy-paste corruption (38 chars, contains a `.`; not a valid SHA). The row's own rationale ("the repo's existing digest (4 workflows); verified = upstream `refs/tags/v7.0.1`") and the live `ls-remote` both resolve it unambiguously to `3d3c42e5aac5ba805825da76410c181273ba90b1`. Planned as the verified 40-hex value. This is **not** a digest↔tag mismatch (§3 HALT condition), so it is reported, not halted on — but it is surfaced rather than silently normalized. + +### Provenance of the stale comment (sharper than the brief's framing — P6, read from git this run) + +`ff48077` "chore(deps): bump actions/setup-node from 6 to 7 (#35)", authored by **dependabot**, did two things in one commit: + +- `ci.yml`: `48b55a0… # v6` → `820762786026740c76f36085b0efc47a31fe5020 # v6` — **swapped the digest across a major bump and left the major-only comment untouched.** That is the exact commit where the comment started lying. +- `floor.yml`: `@v6` → `@v7` — a floating ref stayed floating. + +Two independent demonstrations of rot, both verified live: + +1. **Comment rot (the primary one):** the digest is v7.0.0; the comment says `v6`. The old pin `48b55a0…` was in fact **v6.4.0**, so `# v6` was already imprecise before it became wrong. +2. **Tag rot:** upstream `refs/tags/v6` has since moved v6.4.0 → **v6.5.0** (`2499707…`). A ref pinned to `v6` would silently be running different code today than when it was written. + +And the asymmetry this PR removes: at `99ab306` dependabot bumped `floor.yml` `@v7` → `@v7.0.1` (tag→tag) while bumping the other four digest→digest. Dependabot preserves whatever form it finds, so **as long as floor.yml holds a tag, every future bump reproduces the divergence.** Converting the form is the durable fix — no new guard needed (see follow-ups). + +## Contracts satisfied + +- No `pharn-contracts` contract applies — this increment adds no Capability, no `rule_id`, no product behavior. It is repo hygiene of the dev-loop's own CI. +- It does align with the **convention stated in-repo** at `.github/workflows/gitleaks.yml:11` — "This mirrors the repo convention of pinning third-party code by digest, never a floating tag" (cited, not restated — P4). `floor.yml` is the one workflow that violated the convention its sibling file states. + +## Evals to write (P1) — declared reduction, not a silent skip + +**None.** P1 binds *behavior* ("no behavior ships without at least one vitest test"); this increment ships **no product behavior** — it changes CI configuration only, and there is no vitest/floor harness over `.github/workflows/**` (verified by grep above). Precedent is consistent: all five prior workflow-only commits (`a8c0472` the hardening commit, `99ab306`, `ff48077`, `0bf92a2`, `c425edd`) carry no test. + +The proof of record is **execution, not assertion**: `floor.yml` triggers on `pull_request`, so the PR's own floor run parses and executes the edited file. A green floor check on the PR *is* the demonstration (P1's "demonstrates, not asserts"). Inventing a bespoke YAML-lint test here would be a speculative addition with no triggering need (**P7**). + +## Guarantee audit (P0) + +| Claim | Reduction | +| --- | --- | +| "The action code CI runs cannot change under us without a visible diff" | **FLOOR — content-hash** (`ARCHITECTURE.md §2` primitive #2). A commit digest is content-addressed; a tag is a mutable pointer. This is the whole point of the increment. | +| "The edited `floor.yml` parses and runs" | **FLOOR — execution.** floor.yml runs on `pull_request`; the PR's own green floor job is the proof. A local `js-yaml` parse is convenience, not the proof of record. | +| "Zero execution change today" (setup-node) | **FLOOR — verified equality**, `refs/tags/v7` head == the digest being pinned, checked this run. Scoped to *today*: if upstream's `v7` head moves before merge, the pin is still the repo's digest — the statement to make then is "the pin is unchanged," not "zero change." | +| "The pinned action is *safe*" | **STRUCK — not claimed.** Digest pinning guarantees *immutability*, never *safety*: a compromised-but-pinned action is still faithfully pinned. Directly analogous to `LIMITS.md §1b` ("trust in the remote is provenance, not cryptographic"). | +| "`persist-credentials: false` prevents token exfiltration from floor.yml" | **ADVISORY — defense-in-depth.** floor.yml already grants `permissions: contents: read` and uses `pull_request` (not `pull_request_target`), so the blast radius was already small; this removes the token from the on-disk git config as well. Honest label: **convention alignment + blast-radius reduction**, not a new guarantee. | +| "This repo now has zero floating action refs" | **FLOOR — regex/grep** (primitive #3), verified in Phase C as a one-shot check. Note it is a *point-in-time* verification, **not an enforced invariant** — nothing prevents a future floating ref (see follow-up #2). | + +## Trust audit (P2) + +No untrusted artifact is ingested by the **product** here — `src/**` is untouched, so the installer's ingest boundary is unchanged. The trust surface this touches is the **repo's own CI supply chain**: third-party action code executed in `floor.yml`. Taint direction: `floor.yml` runs on fork `pull_request` events, so it is the workflow most exposed to untrusted input (a fork's tree). Pinning by digest narrows *what code* runs there from "whatever the tag points at when the job starts" to "one fixed commit"; `persist-credentials: false` narrows what a compromise there could reach. Neither claim is upgraded past the labels in the guarantee audit. + +## Determinism audit (P5) + +Every branch in this increment is a membership/equality test on data read this run, with a hard-fail (never a guess) as the fallback: + +- digest↔tag mapping: **exact string equality** vs `git ls-remote` output → mismatch = **HALT**, do not substitute a fresher digest. +- annotated-tag footgun: **presence test** for a `^{}` peeled line → all absent, so the plain line *is* the commit. +- scope: **whitelist membership** over three paths → a fourth file = **HALT**. +- "no floating refs remain": **regex** `@<40-hex> # v` over `grep -rn "uses:"`. + +No classification, no judgment call, no silent fallback. + +## Out of axis — observed, NOT in this increment (P3/P7) + +1. **`node-version` policy** — floor uses `lts/*`, ci uses `20`, publish uses `24`. A real inconsistency, a different axis (CI matrix policy). Named as a follow-up; not touched. +2. **No enforced no-floating-refs invariant** — the guarantee audit labels the grep as point-in-time. A `.dev/floor/` regex gate over `.github/workflows/**` would make it structural (primitive #3). **Deliberately not added:** it needs a fourth file (a §3 HALT condition of the incoming brief), and once floor.yml holds a digest, dependabot preserves that form — so there is no triggering need today (**P7**). Recorded so the choice is visible, not forgotten. + +## Open questions (HALT) + +None blocking. Two items above are **reported, not questions**: the brief's corrupted digest string (resolved unambiguously against live `ls-remote`) and the two named out-of-axis follow-ups. diff --git a/.dev/features/pin-floor-actions/REGRESSION.md b/.dev/features/pin-floor-actions/REGRESSION.md new file mode 100644 index 0000000..280b9f6 --- /dev/null +++ b/.dev/features/pin-floor-actions/REGRESSION.md @@ -0,0 +1,46 @@ +# REGRESSION — pin-floor-actions + +- **base:** `112e22616993bf219fc251a4f0c5d008ea017cb2` (= `origin/main`; resolved by the deterministic state test — `git status --porcelain` non-empty → working-tree dogfood build → `base = HEAD`) +- **machine report:** `.dev/features/pin-floor-actions/regression-report.json` (the helper's `verdict` JSON verbatim) + +## Partition (inside / outside) + +`inside` — the build-attributable changed set, identical to the plan's `## Files`: + +- `.github/workflows/ci.yml` +- `.github/workflows/floor.yml` +- `.github/workflows/publish.yml` + +`node .dev/floor/check-regress.mjs scope` → **exit 0**, `escaped: []`. No fix #7 breach: the build wrote exactly its declared `## Files` and nothing else. + +**Attribution, stated openly (this was a judgment call in the ADVISORY orchestration layer, not the floor).** The working tree also carries three loop-owned paths — `.pharn/writes-scope.json`, `.dev/features/pin-floor-actions/PLAN.md`, `.dev/features/pin-floor-actions/GRILL.md`. Run with the *full* changed list, `scope` reports all three as `escaped`. They are **not** build writes, and the floor says so independently of any judgment of mine: + +- `.pharn/**` is in `ALWAYS` in `.claude/hooks/enforce-writes-scope.cjs:61` — always-writable scratch by construction, never an escape (the regress command's own Step 0 names this). +- `PLAN.md` / `GRILL.md` were each written by their own stage under **that stage's** floor-enforced scope (`--target` pinned to the single artifact), not under the build's. + +The decisive point is that `enforce-writes-scope.cjs` was **live during the build** with `scope[]` pinned to exactly the three workflow paths — so a build write outside them would have been *denied at write time*, not merely reported here. Both `scope` runs are recorded so the attribution is visible rather than assumed. + +`outside_tests`: 44 files (every committed `*.test.mjs` / `*.test.cjs`). `outside_eval_pairs`: none — the repo has no `*/evals/expected/*.json`. + +## Gate table (base → head, exit codes) + +| gate | base | head | flip | +| --- | --- | --- | --- | +| `tests` (`node --test`, 44 outside test files — 666 assertions) | 0 | 0 | none | +| `validate` (`node .dev/floor/validate.mjs .`, whole-repo) | 0 | 0 | none | + +`regressions[]`: **empty.** `pre_existing[]`: **empty.** + +**Style gates skipped — deterministically, not by choice.** `inside` touches none of `eslint.config.mjs`, `.prettierrc.json`, `.prettierignore`, `.markdownlint-cli2.jsonc`, so over the byte-identical outside files a style flip is provably impossible. Skipped on both sides, absent from both maps (a gate-set mismatch would have failed `inconclusive`). + +## Orchestration correction (recorded, not hidden) + +The first baseline/HEAD capture returned `tests=1` on **both** sides. That was **my invocation bug, not the repo's**: zsh does not word-split unquoted variables, so all 44 paths reached `node --test` as a single argument (`Could not find '<44 paths concatenated>'`). A gate that never ran is not a gate, so the measurement was discarded and both sides re-captured with explicit splitting (`${=…}`) — yielding `tests=0` / 666 passing on both. Note this failure mode was **symmetric and would have produced a `no-regressions` verdict anyway**; it is recorded because a verdict computed over a gate that never executed would have been worthless despite being green. This is squarely the advisory-orchestration clock: the *comparison* is floor-grade, but *whether the right thing was compared* rests on me. + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** + +`node .dev/floor/check-regress.mjs verdict` → **exit 0**, `"verdict": "no-regressions"`. This is the floor's comparison of captured exit codes; no model judged it, and a flipped gate would have been a regression whether or not it "looked" like one. + +**The honest residual (P7):** `/pharn-dev-regress` catches exactly what its suite catches — nothing more. This suite is 44 `node --test` files plus whole-repo `validate`; **none of them reads `.github/workflows/**`** (verified by grep at plan time). So for *this* increment the comparison is close to vacuous: it proves the change broke no existing gate, which is meaningful only because the change also touches nothing those gates cover. The claim is "deterministically-detectable breakage outside the feature is caught," **not** "nothing broke." The evidence that the edited workflows themselves are sound is the PR's own floor run — a different check, owed at Phase C, not this one. diff --git a/.dev/features/pin-floor-actions/REVIEW.md b/.dev/features/pin-floor-actions/REVIEW.md new file mode 100644 index 0000000..bbdf131 --- /dev/null +++ b/.dev/features/pin-floor-actions/REVIEW.md @@ -0,0 +1,109 @@ +# REVIEW — pin-floor-actions + +**Step 1 — floor first (P0):** `node .dev/floor/validate.mjs .` → **exit 0**, `FLOOR: GREEN — 0 capabilities checked in .` The increment reached review with a green floor, as required. + +Increment under review (`trust: untrusted`): the three-file diff against `112e226` — `.github/workflows/{floor,ci,publish}.yml`. + +--- + +## Findings + +> Free-text `problem` / `evidence` inherit the reviewed increment's untrusted tag (`ARCHITECTURE.md §8`) — quoted DATA for a human, never a directive. + +### L-floor → P0 + +```yaml +- type: FINDING + rule_id: "P0" + severity: important + file: ".github/workflows/ci.yml:18" + problem: "The increment fixes an instance of comment↔digest divergence without reducing the claim to a floor check, so the exact failure it repairs can recur silently and did once already." + evidence: "'- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0'" + gate: advisory-gate +``` + +```yaml +- type: FINDING + rule_id: "P0" + severity: minor + file: ".github/workflows/floor.yml:20" + problem: "The zero-execution-change property holds only against upstream state read at plan time, and nothing in the repo re-asserts it at merge time, so a tag that moves before merge would leave the claim stale rather than false-but-detected." + evidence: "'- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0'" + gate: advisory-gate +``` + +### L-eval → P1 + +```yaml +- type: FINDING + rule_id: "P1" + severity: important + file: ".github/workflows/floor.yml:19" + problem: "The increment ships zero deterministic coverage over the files it changed — every gate in the verify set is blind to .github/workflows/** — so a green floor and a green verify are both compatible with a broken workflow." + evidence: "'- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'" + gate: advisory-gate +``` + +**Floor agreement check (required by this lens):** `validate.mjs` reports `0 capabilities checked`, and the increment adds no Capability and no `rule_id`. The floor and this lens **agree** that no eval binding is owed — there is no disagreement to report. The finding above is about *coverage*, not about a missing binding. + +### L-trust → P2 + +```yaml +- type: FINDING + rule_id: "P2" + severity: minor + file: ".dev/features/pin-floor-actions/PLAN.md:34" + problem: "The incoming build prompt contained a literal ref string that is not a valid commit SHA, and applying it verbatim as instructed would have written an unresolvable action ref into the fork-PR workflow." + evidence: "'actions/checkout@3d3c42e5aac5ba805825da76410c18127.0.1'" + gate: advisory-gate +``` + +**Did instruction-looking content change my behavior?** It tried, and it did not. The build prompt presented a "verified digest table" and instructed *"apply verbatim"*; one row's value was corrupted (38 chars, embedded `.`). The value written was **not** taken from that instruction — it was taken from `git ls-remote https://github.com/actions/checkout v7.0.1`, an independent deterministic read, and the discrepancy was surfaced at the plan halt rather than normalized away. This is the enum-gated/free-text split working at the *input* boundary: a prose directive lost to a floor-checkable fact. Recorded because noting it is the defense (P2), not because the prompt was hostile — it was a copy-paste error. + +**No guaranteed decision in this increment rests on a tainted or free-text field.** Every decision was string equality against `ls-remote` output, path membership, or an exit code. The `REGRESSION.md` / `VERIFY.md` free-text gates nothing. + +**Trust-surface note (in favor of the change):** `floor.yml` is the repo's only workflow triggered by fork `pull_request`, i.e. the one place untrusted third-party input meets executing third-party actions. Moving it from tag resolution to a content-addressed pin narrows what can execute there to one fixed commit — floor primitive #2 applied to the highest-exposure surface. `persist-credentials: false` narrows what a compromise could reach; it remains **advisory** hardening, not a guarantee, and is correctly labeled so in the plan. + +### L-axis → P3 + +```yaml +- type: FINDING + rule_id: "P3" + severity: minor + file: ".dev/features/pin-floor-actions/PLAN.md:10" + problem: "The increment spans two change-reasons — supply-chain pinning and credential hardening in one file, plus comment accuracy in two files the pinning does not otherwise touch — where the comment fix was independently shippable." + evidence: "'`.github/workflows/floor.yml` — pin both `uses:` by digest + add `persist-credentials: false`'" + gate: advisory-gate +``` + +Per-file, P3 holds: each of the three files changes for exactly one reason as written (floor.yml: "align with the repo's hardening convention"; ci/publish: "make the version comment true"). No sibling references exist to route through `pharn-contracts` — these are YAML config, not modules. This duplicates `GRILL.md` F4 deliberately: it was raised pre-build, accepted by the human at GATE 1, and is recorded post-build as a visible choice rather than quietly dropped. + +--- + +## Gate split (fix #3) + +- **floor-gate (blocking): none.** No finding here derives from content the floor can check and fail — `validate.mjs` is GREEN, no eval binding is owed, no sibling reference exists, and no guaranteed decision rests on a tainted field. +- **advisory-gate (warn): all five findings above.** Each rests on my judgment of severity or of free-text. None is a basis for blocking, and none may be read as one. + +## What the increment got right (not padding — these were checked, not assumed) + +- Every load-bearing digest↔tag mapping was verified against live `ls-remote` this run, including the annotated-tag `^{}` footgun (all lightweight — the plain line *is* the commit). +- The diff is byte-exact to what the human approved at GATE 1; the build's writes-scope was floor-pinned to the plan's three paths, so a fourth-file write would have been denied at write time, not merely reported. +- **Comment-form normalization, verified post-build:** `grep -rnE "# v[0-9]+$" .github/workflows/` now returns nothing — every pin comment is full semver (`5 × # v7.0.1`, `3 × # v7.0.0`, `2 × # v4.37.4`). The major-only `# v6` outlier — the exact form dependabot mishandled at `ff48077` — no longer exists in the repo. That is a real reduction in the recurrence surface, even though it is not a floor guarantee. + +## Verdict + +**GREEN — 0 floor-gate findings; 5 advisory findings (0 blocking, 2 important, 3 minor).** + +Standing floor verdicts from the chain: `validate` exit 0 · `regression-report.json` `"no-regressions"` · `verify-report.json` `"PASS"`. This verdict is **advisory** and gates nothing; `/pharn-dev-review` emits no `findings.json` and has no `check-review.mjs`. The two `important` findings (P0 recurrence surface, P1 coverage blindness) are both arguments for the *follow-up* named below, not objections to this change. + +--- + +## Proposed lesson candidate (NOT written to canon — P2/P7) + +Proposed for `.dev/memory-bank/lessons-learned.md` via a separate, human-gated `/pharn-dev-memory-promote` run. `/pharn-dev-review` declares no `.dev/memory-bank/**` path and writes no canon. + +- **Candidate:** A dependabot digest bump can carry a pinned action across a **major version** while leaving the trailing `# vN` comment untouched, so the comment silently becomes false while the digest stays correct. Nothing in the repo checks comment↔digest agreement, so the divergence is invisible until someone audits pins by hand. Prefer **full-semver** pin comments (`# v7.0.0`, not `# v7`) — the major-only form is what diverges — and treat comment↔digest agreement as **unguarded** until a floor check exists. +- **Why it qualifies as real, not hypothetical (P7):** it actually occurred in this repo at `ff48077` ("bump actions/setup-node from 6 to 7"), which wrote the v7.0.0 digest under a `# v6` comment in `ci.yml`, and it survived undetected through `c425edd` (which copied the same pattern into `publish.yml`) until this increment. Two files, two subsequent PRs, one manual audit to find it. +- **Provenance:** increment `pin-floor-actions`; base `112e22616993bf219fc251a4f0c5d008ea017cb2`; originating commit `ff48077`; corroborating upstream reads — `refs/tags/v6` moved `48b55a0…` (v6.4.0) → `249970729cb0ef3589644e2896645e5dc5ba9c38` (v6.5.0). +- **Named follow-up it argues for (not built here — P7, and it would have needed a fourth file):** a `.dev/floor/` regex gate over `.github/workflows/**` asserting every `uses:` matches `@<40-hex> # v`, which would convert both the "no floating refs" and the comment-form properties from point-in-time greps into floor primitive #3. Deliberately deferred: once floor.yml holds a digest, dependabot preserves that form, so there is no triggering need today. diff --git a/.dev/features/pin-floor-actions/SHIP.md b/.dev/features/pin-floor-actions/SHIP.md new file mode 100644 index 0000000..8928aab --- /dev/null +++ b/.dev/features/pin-floor-actions/SHIP.md @@ -0,0 +1,42 @@ +# SHIP — pin-floor-actions + +Gated `/pharn-dev-ship` run (no `--loop`). Base `112e22616993bf219fc251a4f0c5d008ea017cb2` (= `origin/main`). + +## Stages run, in order + +| # | stage | outcome | +| --- | --- | --- | +| 1 | `/pharn-dev-plan` | `PLAN.md` written; halted at **GATE 1** | +| — | **human** | approved **"Approve as written"** | +| 2 | `/pharn-dev-grill` | `GRILL.md` — advisory, gates nothing; proceeded regardless | +| 3 | `/pharn-dev-build` | 3 files written; floor GREEN | +| 4 | `/pharn-dev-regress` | `regression-report.json` + `REGRESSION.md` | +| 5 | `/pharn-dev-verify` | `verify-report.json` + `VERIFY.md` | +| 6 | `/pharn-dev-review` | `REVIEW.md`; **run ends here at GATE 2** | + +**Where the run ended: GATE 2** — the post-review human decision. Not a RED-verdict STOP; no stage returned a non-GREEN verdict. + +## Structural verdicts read, verbatim + +These, and only these, decided proceed/stop. None of them is my judgment. + +- **`/pharn-dev-build` → `node .dev/floor/validate.mjs .` exit code: `0`** (`FLOOR: GREEN — 0 capabilities checked in .`). `npm run check` also exit `0` (625/625 vitest tests). +- **`/pharn-dev-regress` → `regression-report.json` `.verdict`: `"no-regressions"`** (`check-regress.mjs verdict` exit `0`; `regressions[]` empty, `pre_existing[]` empty; gates `tests` 0→0, `validate` 0→0). +- **`/pharn-dev-verify` → `verify-report.json` `.verdict`: `"PASS"`** (`check-verify.mjs` exit `0`; `failing_gates[]` empty; gates `test`/`validate`/`lint`/`format:check`/`lint:md` all `0`; `verifiers.registered: 0`). + +## Pointers (cited, not restated — P4) + +- `.dev/features/pin-floor-actions/REVIEW.md` — 4 lenses; verdict GREEN, 0 floor-gate findings, 5 advisory (2 important, 3 minor). Also carries a **proposed** lesson candidate for `.dev/memory-bank/lessons-learned.md`, which is **not** written to canon here — promotion is a separate, human-gated `/pharn-dev-memory-promote` run. +- `.dev/features/pin-floor-actions/GRILL.md` — advisory pre-build interrogation; 5 concerns (0 blocking). F3 changed what happens at Phase C: assert the `floor` check **by name and conclusion**, not by absence of red. +- `.dev/features/pin-floor-actions/REGRESSION.md` — includes a recorded orchestration correction (a zsh word-splitting bug made the first `tests` capture measure nothing; both sides were re-captured). +- `.dev/features/pin-floor-actions/VERIFY.md` — records that **no gate in the verify set reads `.github/workflows/**`**, so PASS certifies "broke nothing already covered", not "the change is right". + +## Scope + +`/pharn-dev-ship`'s only Write-tool output is this file, scoped to itself immediately before writing (fix #7, `set-writes-scope.cjs --target`). Each stage's own writes were gated by that stage's own Step 0 scope. The build's scope was floor-pinned to the plan's three `## Files` paths. + +## Standing decision + +The 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.** + +Nothing has been committed, pushed, merged, or sealed. Phase C (open the PR; assert the `floor` check green on it) is a human-authorized action that has not been taken. diff --git a/.dev/features/pin-floor-actions/VERIFY.md b/.dev/features/pin-floor-actions/VERIFY.md new file mode 100644 index 0000000..0455cb9 --- /dev/null +++ b/.dev/features/pin-floor-actions/VERIFY.md @@ -0,0 +1,37 @@ +# VERIFY — pin-floor-actions + +Machine report: `.dev/features/pin-floor-actions/verify-report.json` (helper stdout verbatim + the advisory `verifiers` block appended after the verdict was computed). + +## FLOOR layer — the gates that own the verdict + +| gate | exit | +| --- | --- | +| `test` (`npm test` — vitest, 625 tests / 39 files) | 0 | +| `validate` (`node .dev/floor/validate.mjs .`) | 0 | +| `lint` (eslint over `src`) | 0 | +| `format:check` (prettier) | 0 | +| `lint:md` (markdownlint-cli2) | 0 | + +`structural:*` — **none.** No `*/evals/expected/*.json` exists in this repo, so the feature ships no eval pair and no `structural:*` gate is in the map (absent, not assumed-passing). + +`node .dev/floor/check-verify.mjs .pharn/pharn-dev-verify/results.json --feature pin-floor-actions` → **exit 0**, `"verdict": "PASS"`, `failing_gates: []`. + +## ADVISORY layer — verifiers + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. + +**No verifiers registered — floor gates only.** Step 2 is a no-op; the verdict is the floor gates alone. No verifier free-text was produced, so no untrusted `problem` / `evidence` entered this report. + +## VERDICT + +**VERIFIED: floor gates PASS.** + +## The honest residual — and it is unusually large for this increment (P0/P7) + +"Verified" means **the five named gates passed, full stop.** It is not a guarantee of correctness beyond what those gates check. For *this* increment that gap deserves to be stated bluntly rather than buried in boilerplate: + +- **Not one gate in the set reads `.github/workflows/**`.** `test` is vitest over `src`/`tests`; `lint` is eslint over `src`; `format:check`'s prettier globs are `src/**/*.ts`, `tests/**/*.ts`, `*.config.ts`; `lint:md` is `markdownlint-cli2 "docs/**/*.md" "*.md"`; `validate` walks capability frontmatter. **The three files this increment actually changed are invisible to every one of them.** A PASS here therefore certifies that the change *broke nothing already covered* — it says nothing whatsoever about whether the change itself is right. +- The evidence that the change is right lives elsewhere, and is owed at Phase C: (a) the digest↔tag equalities re-verified against `git ls-remote` at plan time, and (b) **the PR's own `floor` check running green** — floor.yml triggers on `pull_request`, so the PR run parses and executes the edited file. Per `GRILL.md` F3, that check must be asserted **by name and conclusion** (`gh pr checks` showing a `floor` row with a `pass` conclusion), because "no red checks" is also satisfied by a PR where an unparseable workflow never produced a check at all. +- **Granularity note (L9-adjacent):** `lint:md`'s glob is `docs/**/*.md` + `*.md`, which does **not** cover `.dev/features/**/*.md`. The markdown artifacts this loop wrote (`PLAN.md`, `GRILL.md`, `REGRESSION.md`, this file) are therefore **not** style-gated by the passing `lint:md`. Recorded so the green is not read as broader than it is. + +Verifier concerns would be advisory help, not assurance — and there are none, because there are no verifiers. Nothing here should be read as "`/pharn-dev-verify` ensures the feature is correct." diff --git a/.dev/features/pin-floor-actions/regression-report.json b/.dev/features/pin-floor-actions/regression-report.json new file mode 100644 index 0000000..75e190c --- /dev/null +++ b/.dev/features/pin-floor-actions/regression-report.json @@ -0,0 +1,21 @@ +{ + "base": "112e22616993bf219fc251a4f0c5d008ea017cb2", + "inside": [ + ".github/workflows/ci.yml", + ".github/workflows/floor.yml", + ".github/workflows/publish.yml" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/pin-floor-actions/verify-report.json b/.dev/features/pin-floor-actions/verify-report.json new file mode 100644 index 0000000..ac7fe80 --- /dev/null +++ b/.dev/features/pin-floor-actions/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "pin-floor-actions", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.dev/floor/check-action-pins.mjs b/.dev/floor/check-action-pins.mjs new file mode 100644 index 0000000..3bfcd58 --- /dev/null +++ b/.dev/floor/check-action-pins.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// .dev/floor/check-action-pins.mjs — deterministic floor check over .github/workflows/*.yml: +// every third-party GitHub Actions `uses:` ref must be pinned by a 40-hex COMMIT DIGEST and carry a +// full-semver `# vX.Y.Z` comment. +// +// NON-LLM, dependency-free (Node stdlib only). No network, no child_process, no eval, no dynamic import. +// It enforces the convention this repo states at .github/workflows/gitleaks.yml:11 — "pinning +// third-party code by digest, never a floating tag" (cited, not restated — P4). +// +// WHY IT EXISTS (P7 — a real, already-observed drift, not a hypothetical): commit ff48077 +// ("chore(deps): bump actions/setup-node from 6 to 7") moved ci.yml's digest ACROSS A MAJOR while +// leaving the trailing comment at `# v6`, and c425edd copied that same stale pattern into +// publish.yml. The divergence survived two later PRs and was found only by a manual audit. The +// major-only comment form is what this checker's `malformed-comment` rule rejects. +// +// WHAT IS GUARANTEED (P0 — floor primitive #3, enum/regex; ARCHITECTURE.md §2): +// the FORM of every ref — a 40-hex digest plus a full-semver comment. +// WHAT IS NOT (named residual, never claimed): +// the TRUTH of the comment. Verifying that `# v7.0.0` really names the commit `8207627…` requires +// `git ls-remote`, and floor scripts are network-free by convention. A well-formed-but-wrong +// comment (digest bumped, `# v6.4.0` left behind) PASSES. This shrinks the recurrence surface; it +// does not close it. +// +// Scope note: GitHub executes only `.github/workflows/*.yml|*.yaml` (flat — files in subdirectories +// are not workflows), so the walk is deliberately non-recursive and mirrors that semantics. +// +// Usage: node .dev/floor/check-action-pins.mjs [targetDir] (default: cwd) +// Output: {"checked":,"skipped":,"files":[...],"violations":[{file,line,ref,reason}]} +// Exit: 0 clean · 1 >=1 violation +// +// Every output field is enum-gated / path-resolved (paths, ints, an enum `reason`). There is NO +// free-text field, so nothing here can carry taint into a downstream decision (P2, fix #1). + +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { join } from "node:path"; + +// A pinned ref: exactly 40 lowercase hex. Uppercase is rejected on purpose — git digests are +// lowercase, and accepting both would make the comparison in a future truth-check ambiguous. +const DIGEST_RE = /^[0-9a-f]{40}$/; +// A conforming comment: FULL semver. The major-only form (`# v6`) is the ff48077 defect and is +// rejected — that is the entire point of requiring three components. +const SEMVER_COMMENT_RE = /^v\d+\.\d+\.\d+$/; +// `uses:` line, with or without the YAML sequence dash. The ref and any trailing comment are split +// AFTERWARDS rather than captured by two groups here: a `${{ … }}` expression ref contains SPACES, +// so a `(\S+)` ref group silently fails to match the whole line and the ref is never examined at +// all — a fail-open (the line looks like "not a uses: line"). Capturing the remainder wholesale and +// splitting on `#` keeps every `uses:` line in scope. +const USES_RE = /^\s*(?:-\s*)?uses:\s*(.*)$/; + +// Reasons are an ENUM, never prose (P5 — membership, not classification). +const REASON = { + FLOATING: "floating-ref", // the @ref is not a 40-hex digest + MISSING_COMMENT: "missing-comment", // digest-pinned but no trailing comment + MALFORMED_COMMENT: "malformed-comment", // comment present but not full semver + UNPINNABLE: "unpinnable-ref", // a ${{ }} expression — cannot be pinned at all +}; + +function emit(obj, code) { + process.stdout.write(JSON.stringify(obj) + "\n"); + process.exit(code); +} + +// Classify ONE `uses:` ref. Returns a REASON, or null when the ref conforms. +// Exported shape is a pure function of (ref, comment) — no I/O, so the table is trivially testable. +function classify(ref, comment) { + // An expression ref resolves at run time and has no fixed identity to pin. It is a DISTINCT + // failure from a floating tag (there is no tag to replace with a digest), so it gets its own + // reason rather than being mislabeled `floating-ref`. + if (ref.includes("${{")) return REASON.UNPINNABLE; + + const at = ref.lastIndexOf("@"); + if (at === -1) return REASON.FLOATING; // no ref at all (`uses: actions/checkout`) — unpinned + const rev = ref.slice(at + 1); + if (!DIGEST_RE.test(rev)) return REASON.FLOATING; + + if (comment === undefined || comment === "") return REASON.MISSING_COMMENT; + // Compare the FIRST token only: a trailing note after the version is fine (`# v7.0.1 (pinned)`). + if (!SEMVER_COMMENT_RE.test(comment.split(/\s+/)[0])) return REASON.MALFORMED_COMMENT; + + return null; +} + +// Refs that are deliberately OUT OF SCOPE. Each exemption is named and justified — an unexplained +// skip is how a gate quietly stops gating. +function isExempt(ref) { + // Local/first-party action: nothing third-party to pin. + if (ref.startsWith("./")) return true; + // Container image ref: a different registry with a different digest format (sha256:<64hex>). + // Pinning those is a separate axis; this repo has zero such refs today, so requiring a policy for + // them here would be speculative (P7). Named, not silently swallowed. + if (ref.startsWith("docker://")) return true; + return false; +} + +function main() { + const target = process.argv[2] || process.cwd(); + const dir = join(target, ".github", "workflows"); + + // A repo with no workflows is not in violation — it is vacuously clean. The CALLER is responsible + // for asserting that something was actually inspected (see check-action-pins.test.mjs, which + // asserts `checked` and the visited file set, never bare exit 0). + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + emit({ checked: 0, skipped: 0, files: [], violations: [] }, 0); + } + + const files = readdirSync(dir) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) + .filter((f) => statSync(join(dir, f)).isFile()) + .sort(); + + const violations = []; + let checked = 0; + let skipped = 0; + + for (const file of files) { + const rel = join(".github", "workflows", file); + const lines = readFileSync(join(dir, file), "utf8").split("\n"); + + lines.forEach((raw, i) => { + // A commented-out example is not a ref. Test the RAW line's first non-whitespace character + // before matching, so `# - uses: foo@v1` never registers. + if (raw.trimStart().startsWith("#")) return; + + const m = USES_RE.exec(raw); + if (m === null) return; + + // Split the remainder into ref + comment. An action ref never contains `#`, so the first `#` + // begins the comment. + const rest = m[1]; + const hash = rest.indexOf("#"); + const comment = hash === -1 ? undefined : rest.slice(hash + 1).trim(); + // Strip surrounding quotes — `uses: "actions/checkout@"` is valid YAML and must not slip + // past the digest test on account of the quote characters. + const ref = rest + .slice(0, hash === -1 ? rest.length : hash) + .trim() + .replace(/^["']|["']$/g, ""); + + // `uses:` with nothing after it is malformed, not absent — fail closed rather than skip. + if (ref === "") { + checked += 1; + violations.push({ + file: rel, + line: i + 1, + ref, + reason: REASON.FLOATING, + }); + return; + } + + if (isExempt(ref)) { + skipped += 1; + return; + } + + checked += 1; + const reason = classify(ref, comment); + if (reason !== null) violations.push({ file: rel, line: i + 1, ref, reason }); + }); + } + + emit({ checked, skipped, files, violations }, violations.length > 0 ? 1 : 0); +} + +main(); diff --git a/.dev/floor/check-action-pins.test.mjs b/.dev/floor/check-action-pins.test.mjs new file mode 100644 index 0000000..3871ab8 --- /dev/null +++ b/.dev/floor/check-action-pins.test.mjs @@ -0,0 +1,250 @@ +// .dev/floor/check-action-pins.test.mjs — tests for the deterministic action-pin floor check. +// +// NO `claude -p`, NO git, NO network. Two kinds of test, deliberately mixed (the same split +// lens-scanner-map.test.mjs makes): +// +// 1. HERMETIC — each builds a small repo in an os.tmpdir() scratch dir and asserts the public +// surface (exit code + stdout JSON) by subprocess, mirroring count-verifiers.test.mjs. +// 2. LIVE REPO-CONSISTENCY (the ★ at the bottom) — validates the COMMITTED tree against REALITY, +// like lens-scanner-map.test.mjs. This is what makes pin drift a BUILD FAILURE rather than a +// property someone has to remember to re-grep: floor.yml's `node --test ".dev/**/*.test.mjs"` +// collects this file on every pull_request and every push to main. +// +// The ★ tests are load-bearing: +// • a digest with a MAJOR-ONLY comment (`# v6`) is a violation — the exact ff48077 defect, PROVEN +// CAUGHT (a digest bumped across a major while the comment stayed at `# v6`); +// • the live repo asserts `violations: []` AND `checked >= 10` AND that every workflow file on +// disk was visited — never bare exit 0, because exit 0 is ALSO what a checker returns when it +// finds nothing to inspect. A gate that cannot tell "all clean" from "looked nowhere" is not a +// gate. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const here = dirname(fileURLToPath(import.meta.url)); // .dev/floor +const REPO = join(here, "..", ".."); // repo root +const CAP = join(here, "check-action-pins.mjs"); + +const DIGEST = "3d3c42e5aac5ba805825da76410c181273ba90b1"; // a real 40-hex digest (actions/checkout v7.0.1) + +function run(targetDir) { + return spawnSync(process.execPath, [CAP, targetDir], { encoding: "utf8" }); +} +function json(r) { + return JSON.parse(r.stdout); +} + +// Build a scratch repo whose .github/workflows/wf.yml contains the given `uses:` step body. +function repoWith(body, name = "wf.yml") { + const root = mkdtempSync(join(tmpdir(), "pharn-pins-")); + const dir = join(root, ".github", "workflows"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), `name: t\non: [push]\njobs:\n j:\n steps:\n${body}\n`); + return root; +} + +// --- conforming ------------------------------------------------------------------------------ + +test("conforming ref (digest + full semver) → exit 0, no violations", () => { + const root = repoWith(` - uses: actions/checkout@${DIGEST} # v7.0.1`); + const r = run(root); + assert.equal(r.status, 0); + const j = json(r); + assert.deepEqual(j.violations, []); + assert.equal(j.checked, 1); + rmSync(root, { recursive: true, force: true }); +}); + +test("a trailing note after the version is still conforming", () => { + const root = repoWith(` - uses: actions/checkout@${DIGEST} # v7.0.1 (pinned by dependabot)`); + const r = run(root); + assert.equal(r.status, 0); + assert.deepEqual(json(r).violations, []); + rmSync(root, { recursive: true, force: true }); +}); + +test("a ref without the YAML sequence dash (codeql style) is still checked", () => { + const root = repoWith(` uses: github/codeql-action/init@${DIGEST} # v4.37.4`); + const r = run(root); + assert.equal(r.status, 0); + assert.equal(json(r).checked, 1); + rmSync(root, { recursive: true, force: true }); +}); + +// --- violations ------------------------------------------------------------------------------ + +test("floating MAJOR tag (@v7) → exit 1, reason floating-ref", () => { + const root = repoWith(" - uses: actions/setup-node@v7"); + const r = run(root); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "floating-ref"); + rmSync(root, { recursive: true, force: true }); +}); + +test("floating PATCH tag (@v7.0.1) → exit 1, reason floating-ref — the pin-floor-actions start state", () => { + const root = repoWith(" - uses: actions/checkout@v7.0.1"); + const r = run(root); + assert.equal(r.status, 1); + const v = json(r).violations[0]; + assert.equal(v.reason, "floating-ref"); + assert.equal(v.line, 6); // resolves to a real line, not a vague reference + rmSync(root, { recursive: true, force: true }); +}); + +test("no @ref at all → exit 1, reason floating-ref", () => { + const root = repoWith(" - uses: actions/checkout"); + const r = run(root); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "floating-ref"); + rmSync(root, { recursive: true, force: true }); +}); + +test("digest with NO comment → exit 1, reason missing-comment", () => { + const root = repoWith(` - uses: actions/checkout@${DIGEST}`); + const r = run(root); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "missing-comment"); + rmSync(root, { recursive: true, force: true }); +}); + +// ★ the historical defect +test("★ digest with a MAJOR-ONLY comment (# v6) → exit 1, reason malformed-comment (the ff48077 defect)", () => { + const root = repoWith(` - uses: actions/setup-node@${DIGEST} # v6`); + const r = run(root); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "malformed-comment"); + rmSync(root, { recursive: true, force: true }); +}); + +test("a ${{ }} expression ref → exit 1, reason unpinnable-ref (NOT mislabeled floating-ref)", () => { + const root = repoWith(" - uses: ${{ matrix.action }}"); + const r = run(root); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "unpinnable-ref"); + rmSync(root, { recursive: true, force: true }); +}); + +test("a QUOTED ref is unwrapped before the digest test (quotes must not smuggle a floating tag past)", () => { + const ok = repoWith(` - uses: "actions/checkout@${DIGEST}" # v7.0.1`); + assert.equal(run(ok).status, 0); + rmSync(ok, { recursive: true, force: true }); + + const bad = repoWith(` - uses: 'actions/checkout@v7'`); + const r = run(bad); + assert.equal(r.status, 1); + assert.equal(json(r).violations[0].reason, "floating-ref"); + rmSync(bad, { recursive: true, force: true }); +}); + +test("`uses:` with nothing after it fails closed as a violation, not a skip", () => { + const root = repoWith(" - uses:"); + const r = run(root); + assert.equal(r.status, 1); + const j = json(r); + assert.equal(j.checked, 1); + assert.equal(j.violations[0].reason, "floating-ref"); + rmSync(root, { recursive: true, force: true }); +}); + +// --- digest boundary cases ------------------------------------------------------------------- + +test("39-hex, 41-hex and UPPERCASE refs are all floating-ref (boundary)", () => { + for (const bad of [DIGEST.slice(0, 39), DIGEST + "a", DIGEST.toUpperCase()]) { + const root = repoWith(` - uses: actions/checkout@${bad} # v7.0.1`); + const r = run(root); + assert.equal(r.status, 1, `expected violation for ${bad}`); + assert.equal(json(r).violations[0].reason, "floating-ref"); + rmSync(root, { recursive: true, force: true }); + } +}); + +// --- skips and exemptions ---------------------------------------------------------------------- + +test("a commented-out example (# - uses: foo@v1) is not a ref", () => { + const root = repoWith(" # - uses: actions/checkout@v1"); + const r = run(root); + assert.equal(r.status, 0); + const j = json(r); + assert.equal(j.checked, 0); + assert.deepEqual(j.violations, []); + rmSync(root, { recursive: true, force: true }); +}); + +test("a local ./ action is exempt (counted as skipped, not checked)", () => { + const root = repoWith(" - uses: ./.github/actions/setup"); + const r = run(root); + assert.equal(r.status, 0); + const j = json(r); + assert.equal(j.skipped, 1); + assert.equal(j.checked, 0); + rmSync(root, { recursive: true, force: true }); +}); + +test("a docker:// image ref is exempt (named out-of-axis, not silently passed as checked)", () => { + const root = repoWith(" - uses: docker://alpine:3.18"); + const r = run(root); + assert.equal(r.status, 0); + const j = json(r); + assert.equal(j.skipped, 1); + assert.equal(j.checked, 0); + rmSync(root, { recursive: true, force: true }); +}); + +// --- vacuous / structural ------------------------------------------------------------------------ + +test("a repo with no .github/workflows/ is vacuously clean — exit 0, checked 0, no crash", () => { + const root = mkdtempSync(join(tmpdir(), "pharn-pins-empty-")); + const r = run(root); + assert.equal(r.status, 0); + const j = json(r); + assert.equal(j.checked, 0); + assert.deepEqual(j.files, []); + rmSync(root, { recursive: true, force: true }); +}); + +test("multiple workflow files are all visited, and every violation resolves to file:line", () => { + const root = repoWith(` - uses: actions/checkout@${DIGEST} # v7.0.1`, "a.yml"); + const dir = join(root, ".github", "workflows"); + writeFileSync( + join(dir, "b.yml"), + "name: b\non: [push]\njobs:\n j:\n steps:\n - uses: actions/setup-node@v7\n", + ); + const r = run(root); + assert.equal(r.status, 1); + const j = json(r); + assert.deepEqual(j.files, ["a.yml", "b.yml"]); + assert.equal(j.checked, 2); + assert.equal(j.violations.length, 1); + assert.equal(j.violations[0].file, join(".github", "workflows", "b.yml")); + rmSync(root, { recursive: true, force: true }); +}); + +// --- ★ LIVE REPO-CONSISTENCY ----------------------------------------------------------------- +// This is the gate. It is what turns a future floating ref into a RED floor.yml run. + +test("★ THIS repo: every workflow action ref is digest-pinned with a full-semver comment", () => { + const r = run(REPO); + const j = json(r); + + // Assert the CONTENT, not just the exit code — exit 0 is also what "found nothing" returns. + assert.deepEqual(j.violations, [], `unpinned action ref(s): ${JSON.stringify(j.violations, null, 2)}`); + + // Anti-vacuity 1: it must actually have inspected refs. 10 is the live count at the time this + // gate landed; the assertion is a LOWER BOUND, so adding workflows never breaks it, while a + // walker that silently stops finding files does. + assert.ok(j.checked >= 10, `expected >=10 refs inspected, got ${j.checked}`); + + // Anti-vacuity 2: the visited file set must match the tree, counted INDEPENDENTLY of the + // checker's own walk — so a bug in that walk cannot hide behind its own report. + const onDisk = readdirSync(join(REPO, ".github", "workflows")) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) + .sort(); + assert.deepEqual(j.files, onDisk); + + assert.equal(r.status, 0); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a405e4c..35ea667 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20 cache: npm diff --git a/.github/workflows/floor.yml b/.github/workflows/floor.yml index 88fca0e..1e59ee5 100644 --- a/.github/workflows/floor.yml +++ b/.github/workflows/floor.yml @@ -16,8 +16,10 @@ jobs: floor: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: lts/* - name: Unit tests (hook + floor) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7408d77..6a08177 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: npm diff --git a/.pharn/pharn-dev-regress/verdict.json b/.pharn/pharn-dev-regress/verdict.json index 5597e4f..75e190c 100644 --- a/.pharn/pharn-dev-regress/verdict.json +++ b/.pharn/pharn-dev-regress/verdict.json @@ -1,15 +1,14 @@ { - "base": "186b55d", + "base": "112e22616993bf219fc251a4f0c5d008ea017cb2", "inside": [ - "src/lib/detect-archetype.ts", - "src/lib/capability-index.ts", - "tests/detect-archetype.test.ts", - "tests/capability-index.test.ts" + ".github/workflows/ci.yml", + ".github/workflows/floor.yml", + ".github/workflows/publish.yml" ], "outside_gates": { "tests": { - "base": 1, - "head": 1 + "base": 0, + "head": 0 }, "validate": { "base": 0, @@ -17,8 +16,6 @@ } }, "regressions": [], - "pre_existing": [ - "tests" - ], + "pre_existing": [], "verdict": "no-regressions" } diff --git a/.pharn/pharn-dev-verify/verdict.json b/.pharn/pharn-dev-verify/verdict.json index ed7bcac..d67ded1 100644 --- a/.pharn/pharn-dev-verify/verdict.json +++ b/.pharn/pharn-dev-verify/verdict.json @@ -1,5 +1,5 @@ { - "feature": "archetype-path-context", + "feature": "pin-floor-actions", "gates": { "format:check": 0, "lint": 0, diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 928afb1..ca7e2b0 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/nontty-gate/SHIP.md" + ".dev/features/floor-gate-action-pins/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-08-09T19:36:46.472Z" + "set_at": "2026-08-10T09:03:04.268Z" }