diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index 62a60c7f1..789066f14 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -38,6 +38,7 @@ Two sibling sections cover git concerns and the split is deliberate: **`## Merge - **[way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) → `## Merge Strategy`** — the same section the merge consumers read (`/pair-process-review` Phase 6): `Method` (`squash` | `merge` | `rebase`, **default `squash`**) and the `Commit format` ([commit template](../../../.pair/knowledge/guidelines/collaboration/templates/commit-template.md)). Recorded on the PR as the intended merge strategy; **squash happens at merge, never here**. `branch-format` (to parse the branch id) comes from the [branch template](../../../.pair/knowledge/guidelines/collaboration/templates/branch-template.md). - **way-of-working.md → `## Git Workflow`** — `code-host` (the tool owning branches/PRs) and `base-branch` (default `main`; **a `base-branch` declared under `## Merge Strategy`, where this skill's ≤ 0.4.1 versions documented it, is still honored** — the resolution order is single-sourced in the convention's **`base-branch` resolution** — the same order `/pair-process-implement` applies, so the two readers cannot disagree on the target branch). **`code-host` absent ⇒ code host = PM tool** (single-tool; the zero-configuration default, not a degradation), and the same tool named in both places is treated exactly as omitted. Resolution, the PM↔code-host routing table, and the cross-linking convention live in one place: [way-of-working / PM-tool + code-host resolution](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md) — this skill states only which side each operation is on. +- **way-of-working.md → `## Quality Gates` → `Review identity`** — `none` (**the default**), `app`, or `bot-user`. Which **credential executes this skill's code-host writes** in Phase 5 (the `pair-review` registration, and any comment it posts there). Resolved through the shipped [`review-identity.sh`](../../../.pair/knowledge/assets/review-identity.sh) — never re-derived here — so `/pair-process-review` and this skill cannot disagree about who is acting on the same PR. Absent ⇒ `session`: the session token writes, exactly as before, which is the zero-configuration default and not a degradation. Model: [pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md) § Dedicated review identity; per-host setup: the code host's implementation guide (R2.12). - **way-of-working.md → `## State Mapping`** — board-column ↔ canonical-macrostate mapping (see [canonical-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/canonical-states.md)). Omitted ⇒ canonical names assumed. - **way-of-working.md → `## Assignment`** — the fallback when no `$assignee` is passed. This skill writes the **code-host** side, so it reads **`code-host-assignee` first and `default-assignee` second** — the split-configuration key exists because the same human often carries two identifiers, and resolving the PM-tool login against the code host is how a PR ends up rejected and published unassigned. **One rule, two callers**: the schema and the cascade live once, in the [resolution convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md), and both this skill (the PR, a **code-host** write) and `/pair-capability-write-issue` (the item, a **PM-tool** write) read them from there rather than each defining their own. Both omitted ⇒ no default; the PR is published unassigned with a warning. @@ -128,12 +129,15 @@ The PR is ready; it must now be **under review and mechanically blocked** — se 1. **Check**: Does the current head commit already carry a `pair-review` check (from an earlier run on the same head)? 2. **Skip**: If a `pair-review` check already exists on this head **and** a review has been submitted for it, do nothing here — the PR is already in the flow (idempotency). A new head commit always needs a fresh check + review. -3. **Act — register the check as pending, when enforcement is on**: read `Review enforcement` from [way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) — **`disabled` is the default**. Enabled: publish `pair-review` on the head commit with a pending status **before** dispatching the review, using the mechanism the host's implementation guide prescribes for an ordinary (non-App) agent token — on GitHub a **commit status**, not a check run. That is what blocks the merge from t0: a crashed or never-started review leaves the required check unsatisfied instead of leaving the PR mergeable. Disabled: publish nothing here and say so — `pair-review: advisory (Review enforcement disabled)` — then dispatch the review exactly as before. The review still runs and still reports; what it cannot do is block, and claiming otherwise would be the one failure mode worse than not enforcing at all. **Then read the status back** on the head commit: publishing it is a write, and the invariant this skill applies in Phase 4 does not stop at Phase 5 — a status the read does not show is `pair-review: NOT PUBLISHED — advisory`, never an assumed block, because claiming a merge is blocked when it is not is the one failure mode worse than not enforcing. If the publication is **refused** (missing token scope, no status API), do not claim enforcement: report `pair-review: NOT PUBLISHED — advisory` and continue (degradation below). -4. **Act — label the state**: apply `pr-state:to-be-reviewed`, removing any other `pr-state:*` label (exactly one at a time). **Then read the PR's labels back** and confirm the state label is on them — a label API that silently no-ops otherwise renders `PR state: pr-state:to-be-reviewed` on a PR carrying no such label, the same unverified claim Phase 4 forbids. The label family is provisioned once per repository (implementation guide) and does **not** auto-create; if the read does not show it, or the label is absent or the label API is unavailable, report `PR state: not applied — finding` and continue — **non-blocking** (degradation below). The label is a **view** — the required checks are the authority (pr-states.md). -5. **Act — hand the review dispatch to whoever can actually spawn it**: the review must run on a **clean context** — an **anonymous** subagent whose prompt is the PR reference only, no context from this session (mechanical isolation, D23: the reviewing context must not inherit the authoring context). **Who spawns it depends on where this skill is running**, because harnesses commonly forbid **nested** subagent dispatch — and this skill is itself frequently running *inside* a subagent (`/pair-process-implement` Step 3.3 publishes the PR from a handoff-only subagent), where spawning a second level would fail on the primary path: +3. **Act — resolve WHO writes, before writing anything**: source [`review-identity.sh`](../../../.pair/knowledge/assets/review-identity.sh) and call `resolve_identity_mode ` with the `Review identity` value read above. Read that value as **two questions**: is the key **present** (format-agnostic — an adopter may have written it without the bullet or the bold) and does its value **parse** (`review_identity_kind_ok `)? Present-but-unparseable is **configured-but-unusable ⇒ HALT**, never `none`: `none` means no identity, so the flow would resolve `session` silently and register the check under the session account on a repository that provisioned an identity. `healthy` is **computed, never assumed**: `review_identity_health ` answers `1`/`0` from the host guide's **per-run, artifact-free probes run on THIS publish** (`auth-ok`: the credential authenticated and is scoped to this repository; `perms-ok`: the required grants observed without writing — on GitHub the App's installation-token exchange requested with explicit `permissions`, or the bot account's repository-permission read — and, on both forms, that the identity is not this PR's author, since a PR-authoring identity cannot deliver a verdict at all), and any outcome that is not exactly `1`, including "not run", is not healthy. The guide's **artifact-leaving** probes (an undeletable check run, a scratch comment) are **setup-time only** — they cannot run per publish, and nothing persists their result, so reading them as this run's health would `halt` every publish on a correctly provisioned repository. `review_identity_health` folds in `review_identity_exclusion_ok `: a `bot-user` identity types as `user.type == "User"` on the reviews API, so it is excluded from the 🔴 explicit-approval predicate only by its login (`REVIEW_IDENTITY_LOGIN`), and an identity that is not mechanically excluded from that gate is not a healthy one. **`` has exactly one source: the value read back from the host on this run — on GitHub the repository variable `REVIEW_IDENTITY_LOGIN` (`gh api "repos///actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value`), which is what `pair-explicit-approval` resolves as `${{ vars.REVIEW_IDENTITY_LOGIN }}`** — never this session's ambient environment variable of the same name, and never a variable naming an account other than the one acting — the first would pass health here while the gate's clause compares against the empty string and matches every account, the second excludes an account nobody is acting under. Both are refused by the adapter: `` is the login the identity's own credential answers with on this run (on GitHub `gh api user --jq .login`), it must equal `` on the machine-user form, and not passing it is unknown ⇒ not excluded ⇒ not healthy. A `403`/`422` met **mid-write** (a grant revoked after the probe) is a **HALT** on the refused write, never a retry with the session token — **except the `pair-review` publication itself, the documented advisory-continue case**: step 4 reports `pair-review: NOT PUBLISHED — advisory` and the flow continues to the label and the dispatch (Graceful Degradation), so no single refusal is governed by both rules. `session` (nothing configured) ⇒ proceed with the session token exactly as before — the default, reported as `Identity: session`, never as a degradation. `identity` ⇒ this phase's host writes execute as the identity, and `pair_review_publication_mode ` decides whether `pair-review` is registered as a **check run** (an `app` identity) or a **commit status** (everything else). `halt` (configured but unusable — invalid credential, missing permission, unknown health) ⇒ **read `Review enforcement` (step 4's input, `disabled` by default) before deciding**, because the HALT is scoped to the case where this phase actually writes as the identity: + - **`Review enforcement` enabled** ⇒ **HALT** with the adapter's setup pointer (the host guide's *Dedicated review identity* section) and **never** fall back to the session user: a check published under a human's account by a flow that was told to use another identity is a misattribution, not a graceful degrade. The PR itself is already created and ready-for-review at this point, so the HALT is reported against the `pair-review` row and nothing is rolled back — steps 4–6 simply do not run, which leaves the PR with no `pr-state:*` label either (HALT Conditions says so, because an operator who reads only "the check did not register" will not think to look at the label). + - **`Review enforcement` disabled (the default)** ⇒ step 4 publishes **nothing**, so this phase performs **no identity host write at all** and there is nothing to misattribute. Do **not** HALT: report the unusable identity as a **finding** — `Identity: unusable: — no identity write in this phase (Review enforcement disabled)`, alongside step 4's `pair-review: advisory (Review enforcement disabled)` — and **continue to steps 5 and 6** (the `pr-state:to-be-reviewed` label and the review dispatch). Nothing is protected by stopping here: the review re-resolves this same identity at its own Step 5.4, where its writes genuinely happen, and HALTs there while it is still broken. Halting here instead would leave every story of an enforcement-disabled project unlabelled (invisible in the board view pr-states.md drives) and **un-dispatched**, indefinitely, over a credential this phase would never have used. +4. **Act — register the check as pending, when enforcement is on**: `Review enforcement` from [way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) — **`disabled` is the default** — is the value **step 3 already read**, used unchanged here (one read, two consumers; re-reading it could disagree with the branch step 3 took). Enabled: publish `pair-review` on the head commit with a pending status **before** dispatching the review, in the form step 3 resolved — a **check run** on an `app` identity, a **commit status** for every other mode (an ordinary agent token cannot write the Checks API). That is what blocks the merge from t0: a crashed or never-started review leaves the required check unsatisfied instead of leaving the PR mergeable. Disabled: publish nothing here and say so — `pair-review: advisory (Review enforcement disabled)` — then dispatch the review exactly as before. The review still runs and still reports; what it cannot do is block, and claiming otherwise would be the one failure mode worse than not enforcing at all. **Then read the status back** on the head commit: publishing it is a write, and the invariant this skill applies in Phase 4 does not stop at Phase 5 — a status the read does not show is `pair-review: NOT PUBLISHED — advisory`, never an assumed block, because claiming a merge is blocked when it is not is the one failure mode worse than not enforcing. If the publication is **refused** (missing token scope, no status API), do not claim enforcement: report `pair-review: NOT PUBLISHED — advisory` and continue (degradation below). **One producer per required context**: `/pair-process-review` resolves this same form again at Step 5.4, so on a PR whose head already carries a `pair-review` record in the **other** form — the case when `Review identity` changed after this PR was opened — publishing a second one leaves two independent producers on one required context and a merge that may stay blocked on the stale record. Supersede the other form with the same value, or (the exit that needs no extra grant) drain the open pull requests before switching `Review identity`: host guide, § Dedicated review identity — the enablement-transition rule. +5. **Act — label the state**: apply `pr-state:to-be-reviewed`, removing any other `pr-state:*` label (exactly one at a time). **Then read the PR's labels back** and confirm the state label is on them — a label API that silently no-ops otherwise renders `PR state: pr-state:to-be-reviewed` on a PR carrying no such label, the same unverified claim Phase 4 forbids. The label family is provisioned once per repository (implementation guide) and does **not** auto-create; if the read does not show it, or the label is absent or the label API is unavailable, report `PR state: not applied — finding` and continue — **non-blocking** (degradation below). The label is a **view** — the required checks are the authority (pr-states.md). +6. **Act — hand the review dispatch to whoever can actually spawn it**: the review must run on a **clean context** — an **anonymous** subagent whose prompt is the PR reference only, no context from this session (mechanical isolation, D23: the reviewing context must not inherit the authoring context). **Who spawns it depends on where this skill is running**, because harnesses commonly forbid **nested** subagent dispatch — and this skill is itself frequently running *inside* a subagent (`/pair-process-implement` Step 3.3 publishes the PR from a handoff-only subagent), where spawning a second level would fail on the primary path: - **This skill is running inside a subagent** (or nested dispatch is unavailable/refused) → do **not** attempt to nest. Emit the signal **`Review: review-dispatch-required — /pair-process-review $pr=`** and return it to the caller; **the caller dispatches** (for `/pair-process-implement`, Step 3.3 in the top-level session — the actor is named there). The `pair-review` check is already `pending`, so the merge stays blocked until the review lands: nothing is lost, and the dispatch happens one frame up where it is allowed. - **This skill is running at the top level** (invoked directly in a session that can spawn subagents) → spawn the anonymous review subagent here, now. -6. **The dispatch prompt** (identical for either actor) is the PR reference plus a **bounded** instruction — the review must not merge: +7. **The dispatch prompt** (identical for either actor) is the PR reference plus a **bounded** instruction — the review must not merge: ```text Run /review $pr= $dispatched=true. @@ -144,9 +148,9 @@ The PR is ready; it must now be **under review and mechanically blocked** — se `/pair-process-review`'s own non-interactive contract (its Arguments section) already forbids Phase 6 and defines what to do at its two human prompts (Step 1.4 "Proceed with review?" → continue without asking; Step 5.5 "Merge now?" → always "the author merges"), so a dispatched review neither stalls on an unanswerable question nor self-answers itself into a merge. The prompt states it too: belt and braces, since the constraint is an authorization boundary. -7. **Act — degraded path (no dispatch anywhere)**: if neither this skill nor its caller can spawn a subagent, do **not** run the review inline in this session (a self-context review defeats the isolation). Leave `pair-review` pending, note `Review: pending — dispatch unavailable, run /pair-process-review in a fresh session` in the output, and post the same instruction as a PR comment. The merge stays blocked meanwhile, so nothing is lost — only deferred. -8. **Act — dispatch failure**: if the subagent fails or times out, the pending `pair-review` check stays in place (merge blocked) and the re-run guidance is posted as a PR comment. Re-invoking this skill re-dispatches (idempotent). -9. **Verify**: A read of the head commit shows the `pair-review` check (or the advisory/NOT-PUBLISHED report), a read of the PR shows exactly one `pr-state:*` label (or the `not applied` finding), and either a dispatched/submitted review or a recorded reason why it is still pending (`review-dispatch-required` counts as recorded **only** because the caller's contract picks it up — see Composition Interface). This skill never publishes a verdict itself and never merges. +8. **Act — degraded path (no dispatch anywhere)**: if neither this skill nor its caller can spawn a subagent, do **not** run the review inline in this session (a self-context review defeats the isolation). Leave `pair-review` pending, note `Review: pending — dispatch unavailable, run /pair-process-review in a fresh session` in the output, and post the same instruction as a PR comment. The merge stays blocked meanwhile, so nothing is lost — only deferred. +9. **Act — dispatch failure**: if the subagent fails or times out, the pending `pair-review` check stays in place (merge blocked) and the re-run guidance is posted as a PR comment. Re-invoking this skill re-dispatches (idempotent). +10. **Verify**: A read of the head commit shows the `pair-review` check (or the advisory/NOT-PUBLISHED report), a read of the PR shows exactly one `pr-state:*` label (or the `not applied` finding), and either a dispatched/submitted review or a recorded reason why it is still pending (`review-dispatch-required` counts as recorded **only** because the caller's contract picks it up — see Composition Interface). This skill never publishes a verdict itself and never merges. ## Output Format @@ -163,6 +167,7 @@ PUBLISH-PR REPORT: ├── Cross-link: [n-a (single tool) | Refs: + PR URL posted on | already linked — comment present, not re-posted | back-link failed — manual link needed] ├── Conditional: [Services to Release: N deployable packages / n-a | Screenshots: UI touched / n-a] ├── Board: [→ Review — confirmed by read | n-a — no Review state on this board | not updated — reason] +├── Identity: [session (default — no identity configured) | identity: | HALTED — configured but unusable | unusable: — no identity write in this phase (Review enforcement disabled)] ├── PR state: [pr-state:to-be-reviewed — confirmed by read | not applied — finding] └── Review: [dispatched — subagent (clean context) | review-dispatch-required — /review $pr= (caller dispatches) | pending — dispatch unavailable, run /review | already submitted on this head] @@ -186,6 +191,7 @@ When invoked **independently** (hotfix, automation loop #212): - **Story id unresolvable** from handoff or branch (Phase 0). - **Quality gate red** (Phase 1) — report failing checks; no PR side effects. - **pr-template not found** (Phase 3) — cannot compose a PR without it. +- **A dedicated review identity is configured but unusable AND `Review enforcement` is enabled** (Phase 5 step 3) — invalid credential, missing permission, unknown health as `review_identity_health` computes it from this run's probes, or a `403`/`422` met mid-write after they passed on an identity write **other than the `pair-review` publication** (that one refusal is advisory — Graceful Degradation — and continues). Report the setup pointer on the `pair-review` row and stop; **never** fall back to the session token. The PR is already created and ready-for-review, so nothing is rolled back — but steps 4–6 do not run: the check registration, the `pr-state:to-be-reviewed` label **and** the dispatch do not happen. The PR is therefore left open, ready-for-review and carrying **no `pr-state:*` label at all**, which the board view is driven by (pr-states.md: exactly one at a time) — label it by hand or re-invoke once the identity is fixed. **`Review enforcement` `disabled` (the default) is NOT this case**: this phase then writes nothing as the identity, so the unusable identity is a finding on the `Identity:` row and the flow continues to the label and the dispatch (step 3; Graceful Degradation). No identity configured is not this case either (that is `session`, the default). - **Code host unreachable or unauthenticated** for create/update (Phase 4) — report with a setup pointer and stop; nothing partial is left ready. **PM-side work already done is not rolled back** (the board write is the PM tool's own state); re-invocation is idempotent and resumes at the code-host step. On HALT: report the blocker, propose resolution, make no PR side effects. @@ -199,13 +205,15 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **Back-link cannot be written** (item id not found, PM tool error, no comment mechanism, or `/pair-capability-write-issue` unavailable and no guide command): keep the PR, warn with the manual-link instruction; the `Refs:` line in the body still links PR → item. This is a warning by design, never a HALT. - **No classification tags on the story**: create the PR without tags and note it (edge case) — never invent tags. - **No assignee resolvable** (no `$assignee`, no `default-assignee`, or the host rejects the login): publish the PR **unassigned** and warn that it is invisible in an assignee-filtered view — a genuine degrade, **never a HALT**. The same cascade, and the same non-blocking outcome, as the item write. On an **existing** PR that already has an assignee this branch never fires as a clear: nothing resolvable means the current assignee stands (Phase 4 step 2). -- **A write the host reports as applied but a read does not show** (a tag, the assignee, ready-for-review, the `pair-review` status, the `pr-state:*` label — each read back where it is written: tags and assignee in Phase 4 step 3, ready-for-review in step 6, the check status and the state label in Phase 5 steps 3 and 4): report it as a finding on the corresponding output row and continue. The PR exists and is what matters; what must never happen is reporting the unapplied write as done. +- **A write the host reports as applied but a read does not show** (a tag, the assignee, ready-for-review, the `pair-review` status, the `pr-state:*` label — each read back where it is written: tags and assignee in Phase 4 step 3, ready-for-review in step 6, the check status and the state label in Phase 5 steps 4 and 5): report it as a finding on the corresponding output row and continue. The PR exists and is what matters; what must never happen is reporting the unapplied write as done. - **No board state maps to `Review`** (a minimal board, D4 — a project that reviews on the PR and merges straight to `Done`): **write no state field** in step 7 — membership is still established and confirmed — and report `Board: n-a — no Review state on this board`. The zero-configuration documented skip, **not** an error and not a degraded publish — the readiness signal is the PR itself. - **The direct board write cannot complete** (membership unconfirmable after the add and its one retry — the item writer's Step 7b; or a macrostate no board state can express — its Step 6): report the blocker verbatim on the `Board:` row as `not updated — ` and continue. The reasons are the item writer's, the write is **this skill's own** — it applies those beats by reference, it does not compose them. The PR is published and ready-for-review; a board write that did not happen is **reported, never absorbed into a green publish**, and this skill never HALTs on it (the code-host artifact is the work). - **`/pair-capability-checkpoint` not installed**: gather state from branch + story directly (Phase 0). - **`/pair-capability-write-issue` not installed**: only the **comment-mode back-link** (Phase 4 step 5) is affected — write it directly per the PM tool's implementation guide **and read the item's comments back to confirm it**, or warn with the manual-link instruction. A direct post the read does not show is reported `back-link failed — manual link needed`, **never as posted**: losing the composition must not lose the confirming read with it, or the degraded path becomes the one path that claims a write it never made. **The board write in step 7 is unaffected and still runs in full** (membership → confirming read → state field): it is direct, never a composition, so a missing item writer can never leave the story off the board. Skipping the board write here would re-create #384/#372 — green, ready-for-review, and invisible. - **Nested subagent dispatch unavailable** (Phase 5 — the common case: this skill is itself running in `/pair-process-implement`'s handoff subagent and the harness forbids a second level): return `Review: review-dispatch-required — /pair-process-review $pr=` and let the **caller** dispatch (`/pair-process-implement` Step 3.3). This is the primary path when nested, not a degradation — the review still runs, one frame up, on a clean context. - **Subagent spawning unavailable anywhere** (Phase 5 — the caller cannot spawn either, or this skill was invoked at top level in an environment without subagents): leave `pair-review` pending, post the re-run instruction as a PR comment, and note the deferral in the output. Never run the review inline in the authoring session (isolation, D23) and never mark the PR reviewed. Merge stays blocked — the outcome is deferred, not skipped. +- **No dedicated review identity configured** (Phase 5 step 3): `session` mode — the session token writes, `pair-review` is a commit status. The **zero-configuration default**, reported as `Identity: session`, never as a degradation. Does NOT HALT. +- **A configured identity is unusable and `Review enforcement` is `disabled`** (Phase 5 step 3 — the realistic ordering: the identity is provisioned before branch protection is applied, or its credential lives in CI and the run is on a laptop): with enforcement off this phase writes **nothing** as the identity, so report `Identity: unusable: — no identity write in this phase (Review enforcement disabled)` and continue to the label and the dispatch. A genuine degrade, **never a HALT** — the review re-resolves the identity where its writes actually happen and HALTs there. - **Code host has no check-run/required-check API** (Phase 5): publish the state as a `pr-state:*` label only, note `enforcement: advisory — see host manual setup` in the output, and continue. Documented degradation, never a silent claim of enforcement (pr-states.md). - **`pair-review` publication refused** (Phase 5 — token without the status scope, host API rejects it): the check is not published, so enforcement is **advisory**; report `pair-review: NOT PUBLISHED — advisory` (with the host error) and still dispatch the review. Never report a blocked merge that is not blocked. - **`pr-state:*` label absent / no label API, or the read-back does not show it** (Phase 5): report `PR state: not applied — finding` and continue — **non-blocking**, the required checks remain the merge authority. The labels are provisioned once per repository (host implementation guide); never invent a different label name. diff --git a/.claude/skills/pair-capability-setup-gates/SKILL.md b/.claude/skills/pair-capability-setup-gates/SKILL.md index f9a818a2a..845a635a9 100644 --- a/.claude/skills/pair-capability-setup-gates/SKILL.md +++ b/.claude/skills/pair-capability-setup-gates/SKILL.md @@ -150,7 +150,7 @@ The gate is only half the merge block. The judgment review must be **unskippable 3. **Act — prerequisites first (ordering is load-bearing)**: before writing any protection, (a) **provision the `pr-state:*` labels** from the host guide (they never auto-create; absence is non-blocking but the view is lost), (b) **write the `pair-explicit-approval` job** into the repository's workflows on the **target branch** — neither needs admin scope — and (c) **observe both contexts reporting on a real PR, twice**: at PR open, and again after a review submission (the approval-time re-evaluation), each time **on the PR's head commit** — that is the only commit the host's protection reads. Requiring a context that has never reported leaves **every** PR permanently unmergeable, and a context that reports only at PR open leaves a 🔴 PR blocked *after* the human approves; so protection is applied last, with the host's admin-bypass setting (`enforce_admins` on GitHub) enabled only after one PR has merged through the new rule. 4. **Act — required checks**: add both contexts to the protected branch's required status checks, alongside the gate jobs from Step 4: - **`pair-review`** — published by `/pair-process-review` on the head commit (pending at PR creation via `/pair-capability-publish-pr`). Required in **both** pipeline modes and at **every** tier: it is never tier-scoped away, exactly like `secret-scan`. A `pending`/absent check blocks the merge, so a review that never ran or crashed cannot yield a mergeable PR (R5.7). - - **`pair-explicit-approval`** — verifies a **non-author human** approval on the current head when the tier requires it (🔴, and untagged ⇒ 🔴 fail-safe); auto-passes at 🟢/🟡 (D10). Generate its job from the host guide's template **unmodified in three respects**, because all three are authorization properties rather than style: it must run from a **trusted ref** (the target branch's version of the job and of any projection it sources — never the pull request's own tree, or the change under review could rewrite the check that authorizes it); its verdict must be **pinned to the PR head commit** on every re-evaluation (including the approval-triggered one), since that is the commit protection evaluates; and it must publish a **pending result as its first step**, so a cancelled or aborted re-evaluation leaves a merge-blocking context instead of a stale `success` from a lower tier. When registering it, **pin the context's producer** where the host allows it (on GitHub: the `checks[].app_id` form, not the legacy `contexts` array) — an unpinned status context can be satisfied by any principal with push access, including the reviewing agent, which would void the 🔴 gate. `pair-review` stays unpinned by construction (no app publishes it): it is an anti-accident control, not an authorization one — see the host guide's "What each context proves". It reads the `risk:*` label only — no classification criteria (D18). **Single-maintainer repositories**: the host rejects a self-approval, so 🔴 PRs cannot satisfy this context without a second human account — say so explicitly and offer to leave the context out of the required list (🔴 rule advisory, recorded) rather than making every 🔴 PR unmergeable. + - **`pair-explicit-approval`** — verifies a **non-author human** approval on the current head when the tier requires it (🔴, and untagged ⇒ 🔴 fail-safe); auto-passes at 🟢/🟡 (D10). Generate its job from the host guide's template **unmodified in four respects**, because all four are authorization properties rather than style: it must run from a **trusted ref** (the target branch's version of the job and of any projection it sources — never the pull request's own tree, or the change under review could rewrite the check that authorizes it); its verdict must be **pinned to the PR head commit** on every re-evaluation (including the approval-triggered one), since that is the commit protection evaluates; it must publish a **pending result as its first step**, so a cancelled or aborted re-evaluation leaves a merge-blocking context instead of a stale `success` from a lower tier; and it must keep the **`REVIEW_IDENTITY_LOGIN: ${{ vars.REVIEW_IDENTITY_LOGIN }}` env thread** on the evaluating step — the repository variable that arms `human_approval_jq_filter`'s login clause. Dropping that line is not a style omission: unset, the clause reads `.user.login != ""`, true for every account, so on a repository declaring `Review identity: bot-user` the bot's **own** approving review satisfies this gate and a `risk:red` PR passes `pair-explicit-approval` with no human involvement — on the one surface where the 🔴 gate is actually binding. Emit it even where no identity is configured today (empty ⇒ inert, which is correct). When registering it, **pin the context's producer** where the host allows it (on GitHub: the `checks[].app_id` form, not the legacy `contexts` array) — an unpinned status context can be satisfied by any principal with push access, including the reviewing agent, which would void the 🔴 gate. `pair-review` stays unpinned by construction (no app publishes it): it is an anti-accident control, not an authorization one — see the host guide's "What each context proves". It reads the `risk:*` label only — no classification criteria (D18). **Single-maintainer repositories**: the host rejects a self-approval, so 🔴 PRs cannot satisfy this context without a second human account — say so explicitly and offer to leave the context out of the required list (🔴 rule advisory, recorded) rather than making every 🔴 PR unmergeable. - Also state the **approval count** explicitly in the protection payload when the host has one (GitHub: `required_approving_review_count: 0`) — the tier-scoped job is the approval authority; an unstated default of ≥1 would demand a human approval on **every** PR, contradicting the 🟢 self-merge row of quality-model §4. 5. **Act — stale-approval invalidation**: enable the host's dismiss-stale-reviews equivalent so a force-push invalidates a previous human approval (pr-states.md edge case). 6. **Act — degraded mode**: if the host has **no required-check / branch-protection API**, or the token lacks permission, do **not** silently continue: report `Required checks: DEGRADED — enforcement advisory` and emit the host guide's **manual** setup steps (which contexts to require, where). Record the gap in [way-of-working.md](../../../.pair/adoption/tech/way-of-working.md)'s Quality Gates section so it is visible until applied. diff --git a/.claude/skills/pair-process-implement/post-review-merge.md b/.claude/skills/pair-process-implement/post-review-merge.md index edef6dd73..42d9efd69 100644 --- a/.claude/skills/pair-process-implement/post-review-merge.md +++ b/.claude/skills/pair-process-implement/post-review-merge.md @@ -7,7 +7,7 @@ Disclosed from [SKILL.md](./SKILL.md) Phase 4 — only reached when `/pair-proce This is the author-side merge path, and it carries the **same** precondition as the reviewer-side one (`/pair-process-review` Phase 6, Step 6.0): the merge is permitted by the **synthesis**, never by "a review happened". An approving verdict alone is not the condition — a 🔴 PR with an approved review and no explicit human approval must not merge here either (D10). See [pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md). 1. **Act**: Re-read the **current** signals on the PR's head commit — gate checks, the review verdict, the `risk:*` tier, and non-author human approvals (they may have changed since the verdict: a new commit, a tier raise, a dismissed approval). -2. **Act**: Synthesize with the shipped evaluator [`pr-state.sh`](../../../.pair/knowledge/assets/pr-state.sh) — `resolve_pr_state `, then `merge_allowed `. Tier comes from the label via `resolve_tier`; the human-approval input uses `human_approval_jq_filter` (non-bot, non-author, on the current head), never a raw approval count. +2. **Act**: Synthesize with the shipped evaluator [`pr-state.sh`](../../../.pair/knowledge/assets/pr-state.sh) — `resolve_pr_state `, then `merge_allowed `. Tier comes from the label via `resolve_tier`; the human-approval input uses `human_approval_jq_filter` (non-bot, non-author, on the current head), never a raw approval count. **Evaluate it with `REVIEW_IDENTITY_LOGIN` EXPORTED into that shell** — read it from the host on this run (`gh api "repos///actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value`), the same value `pair-explicit-approval` resolves as `${{ vars.REVIEW_IDENTITY_LOGIN }}`. **The filter's login clause is inert without it**: unset, `.user.login != env.REVIEW_IDENTITY_LOGIN` compares against the empty string and is true for every account, so a `bot-user` review identity's own APPROVED review — cast outside the flow — counts here as the non-author human approval, `merge_allowed` passes, and a `risk:red` PR merges with no human approval. The CI job that carries the same clause is a different evaluation and only exists where `Review enforcement` is enabled and branch protection applied; it does not cover this path. 3. **Skip**: Review not submitted yet → **HALT**, wait for review completion. 4. **Verify**: State is `ready-to-merge` → continue to Step 4.2. Any other state → **HALT**, naming the unmet condition (red gate, review not approved / still pending, or 🔴 without an explicit human approval). Never bypass, dismiss, or re-run a required check to get a green merge button. diff --git a/.claude/skills/pair-process-review/SKILL.md b/.claude/skills/pair-process-review/SKILL.md index e07d8028a..5c85a4ff8 100644 --- a/.claude/skills/pair-process-review/SKILL.md +++ b/.claude/skills/pair-process-review/SKILL.md @@ -271,32 +271,73 @@ Based on compiled findings: | **APPROVED** | No open Critical, Major, or Minor issues. All AC met. Quality gates pass (a red gate caps the decision — Step 2.1). | | **CHANGES-REQUESTED** | Critical issues found, missing ADRs, any **introduced** red security finding from `/pair-capability-assess-security`, failing tests, AC not met. A bug-fix PR with **no reproducing test** and no recorded rationale (Step 2.7) is a Major finding and blocks on the same bar as any other Major. A **red** `cost:*` class does not itself block — it surfaces a **blocking human sign-off** requirement in the Verdict (the human, not the skill, gates on cost). | -### Step 5.3: Submit Review +### Step 5.3: Resolve the Acting Identity, then Submit the Review -The compiled report **is the body of the native review on the code host** — the verdict is the review action; there is **no separate PR comment** (decision Q5). +The compiled report **is the body of the native review on the code host** — the verdict is the review action; there is **no separate VERDICT comment** (decision Q5). That rule is about the verdict only: the **identity audit comment** of Step 5.4b is a distinct, required artifact and Q5 does not suppress it. The review is submitted on the **code host only** (where it gates the merge). It is **never mirrored** onto the PM tool: the board reaches the outcome through the linked PR reference, so no review state is duplicated. See the [routing table](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md). -1. **Act**: Submit the native review on the code host (for GitHub, per [github-implementation.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md); another host's implementation guide supplies the equivalent commands), passing the compiled verdict-first report as the review **body**: - - **APPROVED**: `event = APPROVE`. - - **CHANGES-REQUESTED**: `event = REQUEST_CHANGES`. - - MCP-first: `pull_request_review_write` with `method = create`, the report as `body`, and the appropriate `event`. - - CLI fallback: `gh pr review --approve|--request-changes --body-file `. - - **Self-authored PR** (solo/self-review): GitHub rejects `APPROVE` / `REQUEST_CHANGES` on your own PR. Submit the same verdict-first report with `event = COMMENT` (`gh pr review --comment --body-file `) — the verdict token (APPROVED / CHANGES-REQUESTED) still leads the body, so the decision and full report are recorded, never lost. See Graceful Degradation. -2. **Act**: On re-review, submit a **fresh** native review — both documented paths append (MCP `create`; `gh pr review` CLI), neither edits a submitted body. GitHub's latest-review-governs semantics mean the newest review carries the verdict while earlier reviews stay as visible history, so re-invocation is safe without editing in place (idempotency). -3. **Verify**: The native review is submitted with the verdict-first body — no separate review-comment artifact exists. +**Before submitting, resolve WHICH CREDENTIAL acts.** The verdict is judgment (Step 5.2); this is the separate question of which principal writes it to the host, and it is decided by the shipped adapter, not here — see [pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md) § Dedicated review identity for the model, and the host guide for the per-host setup (R2.12). + +1. **Act — resolve the identity mode**: read `Review identity` from [way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) (`none` is the **default**; `app` / `bot-user` name a configured identity) as **two questions, not one** — is the key **present** (a format-agnostic look, because an adopter may have hand-written it without the bullet or the bold), and does its value **parse** (`review_identity_kind_ok `)? A key that is present but whose value does not parse is **configured-but-unusable ⇒ HALT** with the setup pointer; only a genuinely absent key is `none`. Never degrade an unparseable value to `none`: `none` means _no identity_, so the flow would resolve `session`, raise no HALT, and write — possibly approve — the review with the **session token** on a repository that provisioned an identity precisely to prevent that. Then source the shipped [`review-identity.sh`](../../../.pair/knowledge/assets/review-identity.sh) and compute `healthy` — never assume or improvise it: `review_identity_health ` answers `1`/`0` from the host guide's **per-run, artifact-free probes run on THIS review** (`auth-ok`: the credential authenticated and is scoped to this repository; `perms-ok`: the required grants were observed without writing — on GitHub the App's installation-token exchange requested with explicit `permissions`, or the bot account's repository-permission read — and, on **both** forms, that the identity is not this PR's author, a provisioning error the probe catches before any write rather than at the host's `422`). It folds in `review_identity_exclusion_ok `, and any probe outcome that is not exactly `1` — including "not run" — is **not healthy**. **`` has exactly one source: the value read back from the host on this run — on GitHub the repository variable `REVIEW_IDENTITY_LOGIN` (`gh api "repos///actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value`), because that is the value `pair-explicit-approval` resolves as `${{ vars.REVIEW_IDENTITY_LOGIN }}`.** Never pass this session's ambient environment variable of the same name: a login exported in the shell but never `gh variable set` (or stored as a secret, or scoped to an Environment the gate job does not use) would pass health here while the gate's clause compares against the empty string — `.user.login != ""`, true for every account — so the bot's own approval would satisfy the 🔴 human gate. A variable that names a **different** account than the one acting is refused by the adapter itself, not only by the host's probe: `` is the login the identity's own credential answers with on this run (on GitHub `gh api user --jq .login`), and on the machine-user form it must equal `` — otherwise the predicate excludes the login the variable names while the account actually acting stays inside it, and its APPROVE satisfies the 🔴 human gate. Not passed ⇒ unknown ⇒ not excluded ⇒ not healthy. The host guide's **artifact-leaving** probes (a check run that cannot be deleted, a scratch comment) are **setup-time only**: re-running them per review brands every reviewed head with a `pair-identity-probe` entry, and treating their setup result as this run's health means `halt` on every review of a correctly provisioned repository, since nothing persists it. What a read probe cannot prove at run time is covered by one rule: a `403`/`422` met **mid-write is a HALT**, reported against the artifact that failed — never a retry with the session token, and never a `pair-review` publication implying a review that did not land. **The rule is scoped to two of the three writes the identity performs** — the **review** and the **audit comment**. The **`pair-review` publication is the documented advisory-continue exception**: a refusal there is reported `pair-review: NOT PUBLISHED — advisory` and the flow continues (Step 5.4 step 3, and Graceful Degradation), because the verdict still lives in the native review and stopping would leave the PR with neither the label nor the report. The `pr-state:*` label is not an identity write at all, and its refusal stays the documented **non-blocking** case (Step 5.4 step 5, and the host guide's degradation table row _Label API_). So no single event is ever governed by both rules. Then call `resolve_identity_mode `. That last one is not a formality: a `bot-user` identity is an ordinary machine account that types as `user.type == "User"` on the reviews API, so the 🔴 predicate's type clause does **not** exclude it — its login clause does, and only once the login is provisioned (`REVIEW_IDENTITY_LOGIN`, per the host guide). An identity that is not mechanically excluded from the 🔴 gate is **not healthy**, so it HALTs instead of running unguarded. Three outcomes, and no fourth: + - **`session`** — nothing configured. Proceed exactly as before: the session token writes. This is the default and **not a degradation**; report it as `Identity: session`. + - **`identity`** — configured and usable. The three **attributed** host writes in Steps 5.3–5.4 — the review, the `pair-review` publication, the audit comment — execute as the identity. **The `pr-state:*` label (Step 5.4 step 5) is not one of them**: it is a board **view**, not an artifact anyone is attributed for, so it is written by the **session token** exactly as before an identity existed. That is deliberate — it keeps the label out of the identity's grant surface (on GitHub the labels endpoint is the `issues` API, which the App baseline in the host guide does not request) and keeps the pre-existing rule that its absence or refusal is **non-blocking** (Step 5.4 step 5) intact. **Where acquiring the identity's credential overwrote the ambient one, the session token is the value saved before that** — on GitHub the App path exports the installation token into `GH_TOKEN`, and the host guide's mint step (§ Dedicated review identity, step 4) saves the session credential as `SESSION_GH_TOKEN` first, so the label write is scoped to it per command. Without that, the write the flow declares non-blocking is refused on every App-path review and the board view the labels drive stays empty. + - **`halt`** — configured but unusable (invalid credential, missing permission, unknown health) ⇒ **HALT** with the adapter's setup pointer (the host guide's _Dedicated review identity_ section). **Never fall back to the session user**: a review recorded against a human who did not perform it is worse than a stopped review. No host write happens on this path. +2. **Act — synthesize the PR state, once, here**: `resolve_pr_state ` from [`pr-state.sh`](../../../.pair/knowledge/assets/pr-state.sh), with the inputs already in hand (`gates` from Step 2.1, the tier from the PR's `risk:*` label via `resolve_tier`, `explicit-approval` = a **non-author human** approval recorded on the current head). **Resolve that input with `human_approval_jq_filter` in a shell where `REVIEW_IDENTITY_LOGIN` is EXPORTED** — the login step 1 already read back into `$RV` from the host (`gh api "repos///actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value`); read it there in `session` mode, where step 1 did not. **The filter's login clause is inert without it**: unset, `.user.login != env.REVIEW_IDENTITY_LOGIN` compares against the empty string and is true for every account, so a `bot-user` identity's own APPROVED review — cast outside this flow, the supervisor loop or a maintainer holding the bot PAT — counts as the non-author human approval and a `risk:red` PR reaches `ready-to-merge` with no human approval at all. Nothing else populates this variable agent-side: the CI `pair-explicit-approval` job resolves it from `${{ vars.REVIEW_IDENTITY_LOGIN }}` and is a different evaluation, in a different process, that only exists where `Review enforcement` is enabled and branch protection applied. This step is **unconditional**: it runs in **both modes that continue (`session` and `identity`) and for every verdict** — `halt` is not a third case here, because step 1 already ended the review on that path — because Step 5.4 applies exactly one `pr-state:*` label on every run and step 3 below needs the same value whenever there is an APPROVE authority to resolve. The point is that it is **not nested** inside the identity-only, APPROVED-only APPROVE-authority step. **Step 5.4 publishes this same value and does not re-synthesize it** — one call, one answer, so the event and the label can never disagree. +3. **Act — resolve whether an APPROVING review is authorized** (only in `identity` mode, and only when Step 5.2 decided APPROVED). A native `APPROVE` by the identity is what satisfies a host `required_approving_review_count >= 1` — i.e. it makes the PR mergeable with no human action — so it is granted by exactly **one** rule, the adoption-gated light row, and by nothing else: + - **Evaluate the row**: read whether the project's `## Tag Projection` (`tech/risk-matrix.md`) declares the `light` family — a **read of adoption**, not a judgment — then `light_auto_approve_allowed `, `state` being step 2's synthesis. `` is the PR's label **names**, read from the host **one name per line** — on GitHub `gh pr view --json labels -q '.labels[].name'`. That shape (and the comma-separated one) delimits whole names, so the helper matches whole fields: a label whose NAME merely contains the word — `ui: light theme`, alongside hosts' own `good first issue` / `help wanted` — is not the `light` tag. The space-joined `-q '[.labels[].name] | join(" ")'` is **ambiguous** by construction — a multi-word label name is indistinguishable from the tag once joined — so the helper **fails closed** on it: a string carrying neither delimiter is matched as one whole field, which authorizes nothing unless that whole string is the tag. Passing the joined shape therefore never approves a multi-label PR; use the line form. Exit 0 ⇒ `approve-authorized = 1`; anything else ⇒ `0`, and keep the condition the helper named on stderr for the report. The four conditions and their containments are Step 5.4b's. + - Not an approving verdict (CHANGES-REQUESTED or unresolved)? The row is **not consulted** — `approve-authorized = 0`, no stderr condition exists, and Step 5.4b reports `Light row: n-a — no approving verdict (row not consulted)`. + - In `session` mode skip this entirely: `approve-authorized = 0`. The row governs the review an **identity** signs on the project's behalf; where no identity acts there is none to authorize, and step 4 decides the session account's own event on authorship instead. +4. **Act — map the verdict onto the host event**: `identity_verdict_event ` (never hand-rolled here). `` is read in **both** modes — compare the PR's author with the account that will act (GitHub: `gh pr view --json author -q .author.login` against the acting login: `gh api user -q .login` in `session` mode, the identity's login in `identity` mode — for an App, compare against **both** shapes `gh` can return for one Bot actor, `app/` from the GraphQL read above and `[bot]` from the REST one (`gh api repos/{owner}/{repo}/pulls/{n} --jq .user.login`), since an App **can** author pull requests and a single-shape comparison is inert on the path that emits the other; the slug is the one the host guide's step 4 captures). Pass `0` only when they provably differ, `1` when they match, and leave it unset when a read failed. The adapter's **default differs by mode, deliberately**: unset ⇒ self-authored in `session` (the acting account routinely is the author), unset ⇒ **not** self-authored in `identity` (setup forbids a PR-authoring identity, and the other default would collapse every identity verdict to COMMENT): + - **`identity` + CHANGES-REQUESTED** ⇒ `event = REQUEST_CHANGES`, ungated. A block never unlocks a merge, so nothing needs to authorize it. A correctly provisioned identity is **not** the PR author, so the host accepts the real event — this is what retires the `--comment` degradation for the blocking direction. + - **`identity` where the identity IS the PR author** ⇒ `event = COMMENT`, whatever the verdict. The host rejects a self-authored review action (`422 Can not request changes on your own pull request`), so the native event cannot land — and the verdict must not be lost while Step 5.4 publishes `pair-review` regardless. Report it as a **finding** (the setup is wrong: the review identity must not open PRs in this repository — host guide) and continue with the COMMENT form. + - **`identity` + APPROVED + `approve-authorized = 1`** ⇒ `event = APPROVE`. This **is** the light row's approving review; it is the only path in this flow to an APPROVE the identity signs on the project's behalf. + - **`identity` + APPROVED + not authorized** ⇒ `event = COMMENT`, with the verdict token (APPROVED) leading the body. The judgment is published in full and the `pair-review` check still carries `success` (Step 5.4), so the PR reaches `ready-to-merge` exactly as before — what the identity does **not** do is sign the host's required approval on the project's behalf. The `Light row:` value of Step 5.4b is where the report says so. + - **`session` + a NON-self-authored PR** ⇒ the native `APPROVE` / `REQUEST_CHANGES`. This is the shipped default path (nothing configured, second maintainer reviewing) and this story does not change it: the reviewer is not the author, so the host accepts the event — a change request is recorded as one and blocks the merge button, an approval counts toward `required_approving_review_count`. Submitting a COMMENT here would silently drop both. + - **`session` + a SELF-authored PR (or unknown authorship)** ⇒ `event = COMMENT`. GitHub rejects a self-authored `APPROVE` / `REQUEST_CHANGES`, so this is the solo/self-review case and the fail-safe when the authorship read failed; the verdict token (APPROVED / CHANGES-REQUESTED) still leads the body, so the decision and full report are recorded, never lost. This form stays fully supported — the identity **upgrades** it, it does not replace it. See Graceful Degradation. + - **Any unresolved verdict, in either mode** ⇒ `event = COMMENT` (fail-safe: never an approval). +5. **Act**: Submit the native review on the code host with that event (for GitHub, per [github-implementation.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md); another host's implementation guide supplies the equivalent commands), passing the compiled verdict-first report as the review **body**: + - MCP-first: `pull_request_review_write` with `method = create`, the report as `body`, and the resolved `event`. + - CLI fallback: `gh pr review --approve|--request-changes|--comment --body-file `. + + **This submit precedes Step 5.4b's audit comment, deliberately** — every input that comment renders is already resolved here (step 2's state, step 3's row, step 4's action), so the order is a choice, not a dependency. The comment attributes an action that has **happened**: posting it first would leave a permanent comment claiming an `APPROVE` the host may then refuse (a `422` on a self-authored event, a `403` mid-write) on a PR carrying no such review. The **residual** the reverse order would not have, stated rather than assumed away: these are two HALT-bound identity writes seconds apart, and the HALT on the second does **not** roll the first back — a native `APPROVE` that already landed stays, so on a repository with `required_approving_review_count >= 1` the PR is merge-ready with that identity review unpaired. It is **reported, never silent**: Step 5.4b step 2's fifth `Light row:` value names exactly that outcome, and the HALT names the artifact that failed. +6. **Act**: On re-review, submit a **fresh** native review — both documented paths append (MCP `create`; `gh pr review` CLI), neither edits a submitted body. GitHub's latest-review-governs semantics mean the newest review carries the verdict while earlier reviews stay as visible history, so re-invocation is safe without editing in place (idempotency). +7. **Verify**: The native review is submitted with the verdict-first body, the submitted **event** matches what `identity_verdict_event` returned (an `APPROVE` on a PR the light row did not authorize is a defect, not a shortcut), **and a read of the PR's reviews shows which account authored it** — the identity when one was resolved, the session account otherwise. Report the resolved mode on the `Identity:` output row; never claim per-identity attribution the read does not show. No separate VERDICT-comment artifact exists (the Step 5.4b audit comment is a distinct, required one, and in `identity` mode it always ships), so the `Review:` row reports the submission itself — landed, or `NOT SUBMITTED`. **If the read shows NO review by the resolved actor** — the host refused the submission (a `403`/`422` on the write, a rejected self-authored event) — the verdict did not land: report `Review: NOT SUBMITTED — ` as a finding and **do not let Step 5.4 publish a resolved `pair-review`**; leave the pending check in place so the merge stays blocked. A `success` check on a PR carrying no review body is the one outcome this read-back exists to prevent. `halt` on a refused write, per the identity rule above. ### Step 5.4: Publish the `pair-review` Check & Synthesize the PR State The verdict is judgment; the **merge block is mechanical**. This step turns the verdict into the required check and the PR state — see [pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md) for the model and [github-implementation.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md) § PR state flow for the host commands. Nothing here re-derives criteria: the tier comes from the PR's `risk:*` label via `resolve_tier` and the per-tier requirements come from [quality-model.md](../../../.pair/knowledge/guidelines/quality-assurance/quality-model.md) §4 (**no classification criteria in this flow — D18**). 1. **Check**: Does the current head commit already carry a `pair-review` check whose conclusion matches this verdict, and a matching `pr-state:*` label? -2. **Skip**: If yes — nothing to publish, move to Step 5.5 (idempotent re-invocation). -3. **Act — publish the check**: source the shipped [`pr-state.sh`](../../../.pair/knowledge/assets/pr-state.sh) and map the verdict with `review_check_conclusion` (`approved` ⇒ `success`, `changes-requested` ⇒ `failure`, anything else ⇒ `pending`). Publish `pair-review` on the **head commit** with that conclusion, through the mechanism the host guide prescribes for an ordinary agent token — on GitHub a **commit status** (the Checks API is writable only by a GitHub App). A `pending` result is never published as resolved — the required check stays unsatisfied so the merge stays blocked. If publication is **refused** (missing scope, no status API), report `pair-review: NOT PUBLISHED — advisory` and continue: the verdict still lives in the native review, but never claim a merge is blocked when it is not. +2. **Skip**: If yes — the check and the label are already correct, so skip **steps 3–5 only** and go to **Step 5.4b**, which still runs. The skip covers the two _publication_ acts and nothing else: Step 5.3 submits a **fresh** native review on every re-invocation (step 6), so on a re-review of an unchanged head that identity action is new and still needs its audit comment, and the `Light row:` report line is unconditional in every mode. Jumping from here to Step 5.5 would leave an identity `APPROVE` on the PR with no paired audit comment — the reason no longer reconstructable from the PR alone, which is the property Step 5.4b exists to guarantee. +3. **Act — publish the check**: source the shipped [`pr-state.sh`](../../../.pair/knowledge/assets/pr-state.sh) and map the verdict with `review_check_conclusion` (`approved` ⇒ `success`, `changes-requested` ⇒ `failure`, anything else ⇒ `pending`). Then resolve **how** to publish with `pair_review_publication_mode ` from [`review-identity.sh`](../../../.pair/knowledge/assets/review-identity.sh), using the mode Step 5.3 already resolved — never re-resolved here: + - **`checks-api`** (an `app` identity, the only principal the Checks API accepts) ⇒ publish `pair-review` as a **check run** on the head commit. + - **`commit-status`** (everything else — a bot user, an identity of unknown kind, `session`) ⇒ publish the **commit status** exactly as before. This stays the documented form for an ordinary agent token. + **One producer per required context.** `/pair-capability-publish-pr` resolved this same form independently, at PR creation; if `Review identity` changed in between, the head carries a `pair-review` record in the **other** form (typically a `pending` commit status on a PR opened before an App was provisioned) that nothing here overwrites — two producers on one required context, and a merge that may stay blocked on the stale one. Read the head for a `pair-review` record in the form you are **not** publishing: if one exists, supersede it with the same conclusion (host guide, § Dedicated review identity — the enablement-transition rule) and, when the credential cannot, report it as a finding naming the stale record rather than leaving it unexplained. **The credential is the usual reason it cannot**: the Checks API is App-only, and on the App path the commit-status write needs `statuses` **in the token's own mint payload**, not merely granted on the App — an installation token carries only the permissions its exchange requested, so an unprepared identity meets `403 Resource not accessible by integration` here. + Either way the conclusion is the same and the host guide carries the concrete commands. **Precondition (Step 5.3 step 7): the review must have landed.** If its read-back showed no review by the resolved actor, publish nothing resolved here — the pending check stays, the merge stays blocked, and the failure is reported. A `pending` result is never published as resolved — the required check stays unsatisfied so the merge stays blocked. If publication is **refused** (missing scope, no status API), report `pair-review: NOT PUBLISHED — advisory` and continue: the verdict still lives in the native review, but never claim a merge is blocked when it is not. 4. **Act — resolve the requirements for the tier**: read the tier from the PR labels (`resolve_tier`, tags only, **untagged/malformed ⇒ 🔴 fail-safe**), then read that tier's row from quality-model §4 through its `Argument > Adoption > KB default` cascade: reviewer count, SLA, **checklist depth** — `standard` vs `extended` as [quality-model](../../../.pair/knowledge/guidelines/quality-assurance/quality-model.md) §4 defines it (there is no separate extended-checklist artifact: `extended` is the same code-review template with **no section skipped**, "not applicable" written out rather than omitted) — and whether **explicit approval** is required. Record them in the review output; never invent or hardcode a threshold here. At 🔴, state the explicit-approval requirement in the Verdict block so the human reading the PR knows what is still missing (D10). -5. **Act — synthesize the state**: `resolve_pr_state ` — `gates` from Step 2.1, `explicit-approval` = a **non-author human** approval recorded on the current head (the pair review itself never counts; on a single-maintainer repo 🔴 therefore needs a second human account — see pr-states.md). Apply the resulting `pr-state:to-be-reviewed` / `pr-state:ready-to-merge` / `pr-state:not-approved` label, removing any other `pr-state:*` label (exactly one at a time); the labels are provisioned once per repository and if absent this step is **non-blocking** (degradation below). A red gate or a 🔴 tier without explicit approval yields `to-be-reviewed`, never `ready-to-merge`. +5. **Act — apply the state Step 5.3 already synthesized**: the `resolve_pr_state ` call happened once, in Step 5.3 step 2 (its own unconditional step, run in both modes that continue and for every verdict) — **do not re-synthesize it here**; this step publishes that value. (`explicit-approval` there = a **non-author human** approval recorded on the current head: the pair review itself never counts, and neither does the dedicated review identity — `human_approval_jq_filter` excludes an App by account type and a bot user by login — **the login clause only where `REVIEW_IDENTITY_LOGIN` was exported into the shell that evaluated the filter** (step 2); unexported, it excludes nobody. On a single-maintainer repo 🔴 therefore needs a second human account — see pr-states.md.) Apply the resulting `pr-state:to-be-reviewed` / `pr-state:ready-to-merge` / `pr-state:not-approved` label, removing any other `pr-state:*` label (exactly one at a time) — **with the session token in every mode**, since the label is a view and not an attributed artifact (Step 5.3 step 1); where the identity's credential replaced the ambient one, that is the saved `SESSION_GH_TOKEN` (the host guide's mint step, § Dedicated review identity step 4), scoped per command: `GH_TOKEN=$SESSION_GH_TOKEN gh …`. The labels are provisioned once per repository and if absent — or if the write is **refused** — this step is **non-blocking** (degradation below): report `pr-state label: not applied` and continue. This is the one host write in Steps 5.3–5.4 that a `403` does not HALT, and it is exempt precisely because the identity is not the one writing it. A red gate or a 🔴 tier without explicit approval yields `to-be-reviewed`, never `ready-to-merge`. 6. **Verify**: The head commit carries the `pair-review` check (or the publication failure is reported), the PR carries exactly one `pr-state:*` label matching the synthesis (or its absence is reported), and the tier requirements are recorded in the output. The label is a **view** — enforcement is the required checks (R5.7); this skill never edits branch protection and never bypasses a check. +### Step 5.4b: Audit the Identity Action (and record what the light row decided) + +The light row itself was **evaluated in Step 5.3 step 3**, before the review was submitted — it is what authorized (or refused) the native `APPROVE`. Nothing is re-decided here: this step **records** the decision on the PR and reports it. In `session` mode no identity acted, so the **audit action** is skipped — post no audit comment (step 1 does not run) — and step 2 still runs, reporting `Light row: n-a — no identity (session mode)`. The report row is unconditional: every run emits it, in every mode. Model: [pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md) § Adoption-gated light auto-approval. + +1. **Act — audit, on every identity action, in all three directions** (`identity` mode only — in `session` mode there is no actor to attribute, so this step 1 is skipped): post the comment `identity_audit_comment ` renders — `action` = `approve` (the light row authorized the native APPROVE), `comment` (an approving verdict the row did **not** authorize, **or an unresolved verdict** — both published as a COMMENT-form review), or `block` (REQUEST_CHANGES). The audit is not optional and not summarized from memory: it is the adapter's projection of the same inputs, so the reason is reconstructable from the PR alone — which tag, which declaration, which tier, which state. This is the **second** of the two HALT-bound identity writes, the review of Step 5.3 step 5 being the first: a `403`/`422` here HALTs, and the review it audits has **already landed** — the HALT does not roll it back, so an authorized `APPROVE` stays on the PR unpaired. Report it with step 2's fifth value below (the reason the order is what it is, and the residual it carries, are stated at Step 5.3 step 5); never leave the pairing broken without naming it. +2. **Act — report the row's outcome, in every mode**, with the Output Format block's own strings and no paraphrase: `Light row: approved — native APPROVE by the identity + audit comment posted | approved — native APPROVE by the identity, audit comment NOT POSTED () — HALT, the APPROVE stands | not-authorized — , verdict submitted as COMMENT | n-a — no approving verdict (row not consulted) | n-a — no identity (session mode)`. **Five values, one per case, and the case picks the value** — never the nearest-looking string: + - **`approved`** — `identity` mode, APPROVED, and step 3 authorized it: the submitted event was `APPROVE` and step 1's audit comment landed. + - **`approved — … audit comment NOT POSTED () — HALT, the APPROVE stands`** — the same case, with step 1's write refused. The `APPROVE` is already on the PR and the HALT does not remove it, so this is the one case where an identity review carries **no** paired audit comment: name the host error and the unpaired review here, then HALT. + - **`not-authorized`** — `identity` mode, APPROVED, step 3 refused it: the event was `COMMENT`. The unmet condition filling `` is the one `light_auto_approve_allowed` named on stderr, not a re-derivation. + - **`n-a — no approving verdict (row not consulted)`** — `identity` mode with a verdict that is not APPROVED (CHANGES-REQUESTED, or any unresolved verdict). Step 5.3 step 3 is scoped to approving verdicts, so `light_auto_approve_allowed` never ran: there is no unmet condition to name and the event was `REQUEST_CHANGES` / `COMMENT` on its own rule, not the row's. This is the common case for a blocking review by a configured identity — reporting it as `not-authorized` would invent a stderr condition, and as `approved` or `n-a — no identity` would be false outright. + - **`n-a — no identity (session mode)`** — `session` mode: no identity acted. + This row is never omitted: the Output Format block below lists it unconditionally, so a report missing it is a defect regardless of mode. +3. **Verify**: In `session` mode, the `Light row:` line reads `n-a — no identity (session mode)` and **no** audit comment was posted. In `identity` mode the line is one of the other four and matches the event actually submitted — an `APPROVE` pairs with `approved` (in its posted or its `NOT POSTED` form, per what step 1's write did), a COMMENT-form approving verdict with `not-authorized`, and a `REQUEST_CHANGES` or unresolved verdict with `n-a — no approving verdict (row not consulted)`. In `identity` mode, a read of the PR shows every identity review paired with its audit comment — **except** the one case the `NOT POSTED` value reports, where the comment write was refused after the review had landed and the HALT left it unpaired — and **an `APPROVE` event only where the row authorized one**. The `pr-state:*` label from Step 5.4 is unchanged by this step (label mechanics are Step 5.4's, untouched). + +**The two containments, stated where someone would try to shortcut them:** + +- **Adoption is the gate, the label is not.** All four conditions live in `light_auto_approve_allowed` and the declaration is deliberately one of them, so a hand-applied `light` label on a project whose `## Tag Projection` declares no `light` family authorizes nothing. **No classification criteria** are read here or there — tags, the declaration, the gate result and the verdict, nothing else (D18). +- **It is the only authority for an APPROVE the identity signs.** Outside it an approving verdict by the identity is a COMMENT-form review, so on a repository with `required_approving_review_count >= 1` the identity never satisfies the host's approvals rule on the project's behalf. **At 🔴 the row never fires at all**, and even below 🔴 the identity's approval does not satisfy `pair-explicit-approval`: that predicate rejects an App by account type (`user.type == "User"`) and a bot user by login (`REVIEW_IDENTITY_LOGIN`), so the 🔴 gate stands whatever this row did. + ### Step 5.5: Determine Next Action 1. **Check**: What was the review decision? @@ -334,8 +375,10 @@ REVIEW COMPLETE: ├── DoD: [N/N criteria met] ├── Adoption: [Level N — summary] ├── Debt: [N items flagged] -├── Review: [Submitted as native review body — no separate comment] -├── Check: [pair-review → success | failure | pending (blocks merge)] +├── Review: [Submitted as native review body (verdict = the review action; no separate VERDICT comment) | NOT SUBMITTED — , check left pending] +├── Identity: [session (default — no identity configured) | identity: — native verdict, confirmed by read | HALTED — configured but unusable] +├── Check: [pair-review → success | failure | pending (blocks merge) — published as commit status | check run (App identity) | NOT PUBLISHED — advisory, ] +├── Light row: [n-a — no identity (session mode) | n-a — no approving verdict (row not consulted) | not-authorized — , verdict submitted as COMMENT | approved — native APPROVE by the identity + audit comment posted | approved — native APPROVE by the identity, audit comment NOT POSTED () — HALT, the APPROVE stands] ├── Tier req.: [🟢/🟡/🔴 — N reviewer(s) / SLA / standard|extended checklist / explicit approval: required-and-present | required-and-MISSING | n-a] └── PR state: [pr-state:to-be-reviewed | pr-state:ready-to-merge | pr-state:not-approved] ``` @@ -349,6 +392,7 @@ Review stops immediately when: - **PR not found or not open** (Phase 1) - **Missing ADR for new technical decision** (Phase 2, Step 2.3) — compose `/pair-capability-record-decision`, then resume - **Unresolved architectural non-conformity** (Phase 3) — must be addressed before decision +- **A dedicated review identity is configured but unusable** (Step 5.3 — invalid credential, missing permission, unknown health as `review_identity_health` computes it from this run's probes, **a `403`/`422` met mid-write** after the probes passed on the review or the audit comment (a refused `pair-review` publication is the advisory-continue exception — Step 5.3 step 1, Graceful Degradation), **or a `Review identity` key that is present in adoption but whose value does not parse**, which `review_identity_kind_ok` rejects): `resolve_identity_mode` returns `halt`. Report the adapter's setup pointer (the host guide's _Dedicated review identity_ section) and stop **before any host write** on the probe-failure paths, or **before any further host write** when the `403`/`422` arrived mid-write — **an artifact that already landed is not rolled back**: the native `APPROVE` submitted in Step 5.3 step 5 stays on the PR when the audit comment of Step 5.4b is the write that failed, and Step 5.4b step 2's `audit comment NOT POSTED` value reports that unpaired review. Never fall back to the session user — misattributing a review to the human whose token is loaded is the failure this HALT exists to prevent. No identity configured at all is **not** this case: that is `session` mode and it proceeds normally. - **PR state is not `ready-to-merge`** (Phase 6) — `merge_allowed` fails: gates red, review not approved, or a 🔴 PR without explicit human approval. Report which condition is unmet and stop; never bypass a required check. On HALT: report the blocker, compose the resolution skill if available, wait for developer. @@ -361,7 +405,8 @@ See [idempotency convention](../../../.pair/knowledge/guidelines/technical-stand 2. **Phases**: checks which phases completed (via session state or PR review comments). Resumes from first incomplete phase. 3. **Skill compositions**: /pair-capability-verify-quality, /pair-capability-verify-done, /pair-capability-assess-security results cached in session. Not re-run if already passing/current on current commit. 4. **New commits**: if PR updated since last check, re-validates affected phases only. -5. **Review report**: re-review appends a fresh native review (MCP `create` / `gh pr review`); GitHub's latest-review-governs semantics make the newest one carry the verdict while prior reviews remain as history. The report is always the review body, never a separate comment — so no duplicate comment artifact is created. +5. **Review report**: re-review appends a fresh native review (MCP `create` / `gh pr review`); GitHub's latest-review-governs semantics make the newest one carry the verdict while prior reviews remain as history. The **report** is always the review body, never a separate comment — so the report creates no duplicate comment artifact. The Step 5.4b audit comment is a different artifact and this sentence does not cover it: + - **Identity audit comment** (Step 5.4b, `identity` mode): re-posted on **every** re-invocation, deliberately — Step 5.4's already-published skip is narrowed to steps 3–5 precisely so Step 5.4b still runs, because Step 5.3 submits a fresh native review each time and every identity review must be paired with its own audit. So N re-invocations on an unchanged head leave N reviews **and** N audit comments: that is the pairing invariant holding, not a duplicate. The comments are byte-identical (the projection renders no head SHA, timestamp or review id), so the pairing is 1-to-1 by count and order, not reconstructable per pair from the comment text alone. 6. **PR state & check** (Step 5.4): re-publishing is a no-op when the head commit already carries a `pair-review` check matching the verdict and the `pr-state:*` label already matches the synthesis. A **new head commit** (including a force-push) has no check of its own, so the review re-runs on it and the merge stays blocked meanwhile. A tier raised between runs re-synthesizes on the new tier — raise-only, so a re-run never loosens a requirement. 7. **Merge**: detects already-merged PR. Skips Phase 6 if already merged. Resumes parent cascade if merge succeeded but status updates are incomplete. @@ -375,7 +420,12 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **/assess-security not installed**: Skip Step 2.4. The five Security sections (input validation, output handling, authentication, authorization, introduced vulnerabilities) read **not assessed** — never dropped. Does NOT HALT; a manual security read of the diff is still expected per [how-to-11](../../../.pair/knowledge/how-to/11-how-to-code-review.md). - **/assess-cost not installed**: Skip Step 2.5. The Cost section reads **not assessed**. Does NOT HALT. - **/assess-coupling not installed**: Skip Step 2.6. The Architecture (Coupling) section reads **not assessed**. Does NOT HALT. -- **Self-authored PR (self-review)**: GitHub blocks `APPROVE` / `REQUEST_CHANGES` on your own PR, so the native verdict action is rejected for solo authors. Fall back to `event = COMMENT` (`gh pr review --comment --body-file `), keeping the verdict token at the head of the body — the full verdict-first report is still recorded as a review, so nothing is lost (unlike a rejected APPROVE/REQUEST_CHANGES). Does NOT HALT. +- **No dedicated review identity configured** (Step 5.3): `session` mode — the session token writes, `pair-review` is a commit status, and the verdict is the **native** `APPROVE` / `REQUEST_CHANGES` unless the acting account authored the PR (self-review ⇒ `--comment`, next bullet but one). The **zero-configuration default**, reported as `Identity: session`, never as a degradation. Does NOT HALT. +- **A `bot-user` identity** (Step 5.4): the Checks API is App-only, so `pair_review_publication_mode` keeps `pair-review` on the commit status — the native verdict upgrade still applies. Not a failure; report the publication form. +- **Adoption declares no `light` projection** (Step 5.3 step 3 — the default everywhere): the row authorizes nothing, so an approving verdict by the identity is submitted as a COMMENT-form review; report `Light row: not-authorized — adoption declares no light family, verdict submitted as COMMENT` (the Output Format block's own shape, with the condition filling ``) and continue. A `light` label present without the declaration changes nothing. Does NOT HALT. +- **A `bot-user` identity with no `REVIEW_IDENTITY_LOGIN` provisioned** (Step 5.3 step 1): `review_identity_exclusion_ok` fails, so the identity is **not healthy** and `resolve_identity_mode` yields `halt`. This is a HALT, not a degradation: a machine account that types as `user.type == "User"` and is not excluded by login could sign the 🔴 human approval itself. Report the setup pointer (host guide § Dedicated review identity). +- **The review identity IS the PR author** (`identity` mode, Step 5.3 step 4): the host rejects a self-authored native event, so `identity_verdict_event` returns **COMMENT** — the verdict token leads the body and the full report is recorded as a review. The light row's `APPROVE` is unobtainable on that PR, so `Light row:` reports `not-authorized` only if the row itself refused; the rejected-event reason is reported separately as a **finding**: the setup is wrong (the identity must not be an account that opens PRs — host guide § Bot user). **Never publish `pair-review` without the review**: if the submission is refused outright, Step 5.3 step 7's read-back catches it and the check stays pending. Does NOT HALT — the judgment still lands and still blocks a bad merge. +- **Self-authored PR (self-review)** — `session` mode where the acting account **is** the PR author, and the fail-safe when that read fails: GitHub blocks `APPROVE` / `REQUEST_CHANGES` on your own PR, so the native verdict action is rejected for solo authors. `identity_verdict_event`'s `` argument is what selects it: `event = COMMENT` (`gh pr review --comment --body-file `), keeping the verdict token at the head of the body — the full verdict-first report is still recorded as a review, so nothing is lost (unlike a rejected APPROVE/REQUEST_CHANGES). A `session`-mode review of **someone else's** PR is not this case: the native event is submitted (Step 5.3 step 4). Does NOT HALT. - **Story not found**: Review proceeds with PR-only validation (no AC check). Phase 6 skips parent cascade. - **Code review template not found**: **HALT** — cannot produce review without template (a required dependency, not optional). - **PM tool not accessible**: the PR-side work (review, merge) still runs on the code host; the PM-side writes (issue close, parent cascade) are reported as not done rather than guessed. In a single-tool project this is the same tool, so the merge falls back to CLI only. @@ -392,6 +442,8 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - This skill **reads code, submits the native review (verdict = the review action), publishes the required `pair-review` check + the `pr-state:*` label, and optionally merges PRs** — it does not modify source code and posts no separate review comment. - **Gate ≠ review** ([pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md)): the mechanical gate is the first filter (Step 2.1) and the judgment verdict never overrides it; the merge block itself is the required check, not this skill's opinion. The `pr-state:*` label is a view — enforcement lives in branch protection (R5.7), which this skill never edits or bypasses. +- **Who decides and who acts are different questions.** The verdict is this skill's judgment; the credential that writes it to the host is resolved by the shipped [`review-identity.sh`](../../../.pair/knowledge/assets/review-identity.sh) (Step 5.3) — `session` by default, `identity` when a project provisions one, `halt` when one is configured but broken. Adopting an identity **never** relaxes the 🔴 rule, and the exclusion is mechanical in two forms: `human_approval_jq_filter` rejects an **App** identity by account type (`user.type == "User"`) and a **bot-user** identity — which does type as `"User"` — by its login (`REVIEW_IDENTITY_LOGIN`, whose absence makes the identity not healthy). A `risk:red` PR still needs a second human account (ADR-018, amendment 2026-08-28). +- **The light row is the only auto-approval path, and it is off unless declared** (Step 5.3 step 3): `light_auto_approve_allowed` reads a tag, an adoption declaration, the tier and the synthesis — **zero classification criteria** (D18) — and `resolve_pr_state`'s table is untouched by it. It is load-bearing because it is the **third argument of `identity_verdict_event`**: outside it an approving verdict is a COMMENT-form review, so the identity never satisfies a host `required_approving_review_count >= 1` on the project's behalf. - **Per-tier requirements are read, never invented** — reviewer count, SLA, checklist depth, and the 🔴 explicit-approval requirement come from [quality-model.md](../../../.pair/knowledge/guidelines/quality-assurance/quality-model.md) §4 through its `Argument > Adoption > KB default` cascade (D10). The tier comes from the PR's `risk:*` label (`resolve_tier`, tags only, untagged ⇒ 🔴 fail-safe): **this flow contains no classification criteria** (D18). - Review phases are sequential — each phase builds on findings from prior phases. - The reviewer can stop between phases; re-invoke to resume (see [idempotency convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/idempotency.md)). diff --git a/.claude/workflows/pair-implement-batch.js b/.claude/workflows/pair-implement-batch.js index f95817702..f18751020 100644 --- a/.claude/workflows/pair-implement-batch.js +++ b/.claude/workflows/pair-implement-batch.js @@ -795,7 +795,7 @@ const MAX_FIX_ROUNDS = PIPELINE.maxFixRounds // truncated structured output) — and the contentless shape is the one this repo // actually measured on #432 (the machine slept mid-response), i.e. the retry // missed the exact incident it was written for while covering its rarer sibling. -// The review step therefore passes `hasVerdict`, the SAME predicate its +// The review step therefore passes `hasReviewEvidence`, the SAME predicate its // convergence guard uses, so "did not review" means one thing at both sites: the // transient gets its second chance, and a step that comes back contentless twice // still fails closed. @@ -812,6 +812,11 @@ async function agentRetry(prompt, opts, isUsable = r => !!r) { // ONE predicate, asked by the retry and by the convergence guard, so the two // cannot drift into disagreeing about what a dead reviewer is. const hasVerdict = r => !!r && !!String(r.verdict ?? '').trim() +const REVIEWED_HEAD_PATTERN = /^[0-9a-f]{40}$/ +// A review also has to identify the immutable PR revision it actually inspected. +// Without that baseline a later reviewer cannot distinguish the fix delta from the +// already-audited PR surface, which turns each re-review into another full scan. +const hasReviewEvidence = r => hasVerdict(r) && REVIEWED_HEAD_PATTERN.test(String(r.reviewedHead ?? '')) // ── Schemas (orchestration return-value contracts) ───────────────────────── // These are the compact values agents RETURN for control-flow — NOT the artifact @@ -854,6 +859,9 @@ const LOOSE_REVIEW_SCHEMA = { // Control flow keys on `nonActionable` + actionable count, never on specific // verdict strings. verdict: { type: 'string' }, + // Immutable full SHA of the PR head reviewed. This is workflow evidence, not + // part of the human-facing review template vocabulary. + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, needsHumanDecision: { type: 'boolean' }, findings: { type: 'array', @@ -879,7 +887,7 @@ const LOOSE_REVIEW_SCHEMA = { }, }, }, - required: ['verdict'], + required: ['verdict', 'reviewedHead'], } const FIX_SCHEMA = { type: 'object', @@ -977,7 +985,17 @@ const contracts = STORIES.length ? await parallel(CONTRACT_SPECS.map((s) => () = const crContract = contracts.find((c) => c.name === 'code-review') // Schema the reviewer returns: template-derived when the contract is usable, // the loose skeleton otherwise. Control flow stays value-agnostic either way. -const REVIEW_SCHEMA = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +const REVIEW_SCHEMA_BASE = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +// Template contracts own human verdict/finding vocabulary. The orchestration-only +// baseline is layered on top so a template refresh cannot accidentally remove it. +const REVIEW_SCHEMA = { + ...REVIEW_SCHEMA_BASE, + properties: { + ...REVIEW_SCHEMA_BASE.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: [...new Set([...(REVIEW_SCHEMA_BASE.required ?? []), 'verdict', 'reviewedHead'])], +} // Reviewer prompt vocabulary: `verdictOptions` and `severities` are CANONICAL, // required contract keys (ensure-contract.mjs's validateContract rejects any // contract missing either) — so whenever a contract IS present, both are @@ -1006,6 +1024,16 @@ const TEXT_SHAPE = '(specific inputs/state -> the wrong output or the loss that follows) and the EVIDENCE it is real ' + '(what you ran, what it printed). Cut narration, never evidence.' +const AUTHORITATIVE_BOUNDARY_PROOF = + 'AUTHORITATIVE BOUNDARY PROOF (mandatory): when a table row, equivalence, normalization or remediation depends on an external command, service, file format or runtime, name the exact real producer/consumer that defines it and run a minimal isolated end-to-end probe for every such claim. Keep rows distinct until that boundary proves them equivalent. A unit test of the function being changed cannot establish external semantics or prove that user-facing repair advice works: apply the advice in a clean temporary environment and verify the promised postcondition.' + +const CONTRACT_INVENTORY = + 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review. ' + + AUTHORITATIVE_BOUNDARY_PROOF + +const FINITE_STATE_COMPLETENESS = + 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant. ' + + AUTHORITATIVE_BOUNDARY_PROOF const SEVERITIES = (REVIEW_VOCAB?.severities ?? DEFAULT_SEVERITIES).join(', ') const VERDICTS = (REVIEW_VOCAB?.verdictOptions ?? DEFAULT_VERDICTS).join(', ') @@ -1021,7 +1049,28 @@ const SEVERITY_SCALE = resolveSeverityScale(REVIEW_VOCAB?.severities, crContract // unranked one stays unranked until the template changes, and the next caller who does pass // a floor gets a hard stop. Better the operator sees it on the run that generated it. if (SEVERITY_SCALE.rankError) log(`contract:code-review: severities are NOT ranked (${SEVERITY_SCALE.rankError}) — \`severityFloor\` is unavailable until the contract is regenerated`) -const SEVERITY_FLOOR = parseFloor(PARSED.severityFloor) +// The floor DEFAULTS to `Minor`, so Major and Minor block and drive fix rounds while +// everything below them is carried to the merge gate. Measured on PR #477 across three +// cycles: the PR reached a zero-actionable APPROVED, the next round implemented review +// Questions the reviewer had marked "No change requested", and the re-review found new +// Minors inside the code that round had just added — three the first time, two the second. +// Questions are, by the review template's own definition, questions FOR THE HUMAN; putting +// them in the fix set contradicts what they are and makes convergence a moving target. +// An explicit `severityFloor` still wins, including a lower one that restores the old +// block-everything behaviour. +// +// The default is applied SOFTLY, unlike a caller-passed floor: a template whose vocabulary +// does not declare `Minor`, or whose contract carries no ranking, falls back to no floor +// rather than throwing. A default must never break a run that never asked for it; a floor +// the CALLER spelled wrong still throws, because that is a configuration error they made. +const DEFAULT_SEVERITY_FLOOR = 'Minor' +function defaultFloor() { + if (!SEVERITY_SCALE.ranks) return null + const key = normSeverity(DEFAULT_SEVERITY_FLOOR) + if (!Object.hasOwn(SEVERITY_SCALE.ranks, key)) return null + return { name: DEFAULT_SEVERITY_FLOOR, rank: SEVERITY_SCALE.ranks[key] } +} +const SEVERITY_FLOOR = String(PARSED.severityFloor ?? '').trim() ? parseFloor(PARSED.severityFloor) : defaultFloor() // ── Isolation convention ─────────────────────────────────────────────────── // The AUTHORING chain (implement -> PR -> fix) runs inside a dedicated, PERSISTENT @@ -1048,20 +1097,28 @@ function baseOf(story) { return String(story.base ?? '').trim() || PIPELINE.baseBranch } -function wtClause(story) { +function wtClauseBase(story) { const base = baseOf(story) return `ISOLATION (mandatory): do ALL git/file work inside a dedicated worktree at \`${PIPELINE.worktreeRoot}/${story.id}\` — create-or-reuse it: \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} -B ${story.branch} ${base}\` on first setup, or \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} ${story.branch}\` if the branch already has commits; if the path already exists, just \`cd\` into it. NEVER modify the repo's main working tree and NEVER switch its branch.${base === PIPELINE.baseBranch ? '' : ` This story is STACKED on \`${base}\`: that branch is its base, so its commits are already in your history and must NOT be reverted, duplicated or re-implemented — only ADD your own work on top. When you open the PR, target \`${base}\` as the PR base branch, not \`main\`, so the diff shows only this story's change.`}` } +function wtClause(story) { + return `${wtClauseBase(story)} ${FINITE_STATE_COMPLETENESS}` +} + // Reviewer isolation: read-only inspection in a DETACHED throwaway worktree pinned // to the PR's pushed head. Detached HEAD never occupies the branch, so it can't // collide with the authoring worktree (which holds it) or with other stories' // reviewers in a parallel batch — and it never touches the main checkout's branch. -function revWtClause(story) { +function revWtClauseBase(story) { const p = `${PIPELINE.worktreeRoot}/${story.id}-review` return `ISOLATION (mandatory, read-only): NEVER switch the main checkout's branch. Inspect the code in a DETACHED throwaway worktree pinned to the PR's current pushed head: \`git worktree remove --force ${p} 2>/dev/null; git fetch origin -q; git worktree add --detach ${p} origin/${story.branch}\`, then \`cd ${p}\`. Read the code there (the untracked checkpoint is absent here — good, stay blind to it). When finished, remove it: \`git worktree remove --force ${p}\`.` } +function revWtClause(story) { + return `${revWtClauseBase(story)} ${CONTRACT_INVENTORY}` +} + // #373 finding 3: the escalate-flush shared block — supersede-the-prior-flush + the manual // out-of-band CONVENTION + the untracked-worktree-persistence note — is identical across BOTH // escalation prompts (MAX_FIX_ROUNDS + needsHumanDecision). Authored ONCE here so a future @@ -1216,6 +1273,7 @@ async function driveStory(story) { // before honouring it, so the escalation is deferred by a round rather than dropped. let humanDecisionPending = false let prevFindings = [] + let prevReviewedHead = null // ACCUMULATES across rounds — never reassigned. A finding accepted in round 0 (by-design, or // below the floor) is not re-raised by the round-1 reviewer, because round 1 only sees the // fixed code and has no memory of what the human was already told would be carried. So a @@ -1230,7 +1288,10 @@ async function driveStory(story) { const acceptedKeys = new Set() const accept = (findings) => { for (const f of findings) { - const key = `${f.location ?? ''}${f.description ?? ''}` + // Keep a collision-free delimiter without embedding an invisible raw NUL in the shipped + // JavaScript source. A readable space collapses `(location, description)` pairs such as + // (`"a b"`, `"c"`) and (`"a"`, `"b c"`), silently dropping one accepted finding. + const key = `${f.location ?? ''}\u0000${f.description ?? ''}` if (acceptedKeys.has(key)) continue acceptedKeys.add(key) accepted.push(f) @@ -1249,10 +1310,14 @@ async function driveStory(story) { // in-flight log AND no first-review comment already on the PR. Either signal makes // round-0 a SILENT re-review, so a PR never accrues a second first-review. const first = round === 0 && !isContinuation && !firstReviewPosted + // An initial/resumed-without-history review establishes the whole-PR baseline. + // Once a fix is in flight, even the file inventory must start at that baseline; + // otherwise the pacing loop invites a second full audit before its delta rule. + const reviewBase = prevFindings.length ? prevReviewedHead : baseOf(story) const review = await agentRetry( - `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${baseOf(story)}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length - ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only. Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}.` - : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only.`} Return findings and a verdict.`, + `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${reviewBase}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length + ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}. The last complete review covered immutable head ${prevReviewedHead}. First inspect ONLY the fix delta with \`git diff ${prevReviewedHead}...origin/${story.branch} --name-status\`, then its directly changed producer/consumer contract boundaries. Do NOT re-audit the unchanged PR surface. A new finding is actionable only if it is in this delta or a contract boundary changed by this delta; otherwise report it as a Question for the human, not a new fix round.` + : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end).`} Return findings, verdict, and \`reviewedHead\`: the lower-case 40-character SHA printed by \`git rev-parse origin/${story.branch}\` after your inspection.`, // effort was 'xhigh'. The measured cause of the repeated kills was NOT effort and NOT a // stuck command: transcript timing showed the reviewer issuing a tool call every ~5s // (97 events, mean gap 4.9s, max 49s — zero gaps over 180s) yet still killed, because @@ -1263,10 +1328,9 @@ async function driveStory(story) { // narration reliable, restoring 'xhigh' is legitimate: it costs review depth, which is // the whole point of this gate. Do not read this line as "xhigh causes stalls". withModel({ agentType: 'pair-reviewer', phase: 'Review', label: `rev:${tag} r${round}`, effort: 'high', schema: REVIEW_SCHEMA }), - // A review is USABLE only if it carries a verdict — the same predicate the guard below - // converges on. Without it the retry covered the dead reviewer (`null`) and skipped the - // contentless one (`{}`), which is the shape actually measured on #432. - hasVerdict, + // A review is USABLE only with a verdict and its immutable reviewed head. Without the + // latter, the next pass cannot be an evidence-bounded re-review. + hasReviewEvidence, ) // A DEAD reviewer is not a clean review. `agent()` returns null when the subagent // dies, and `review?.findings ?? []` then yields zero findings — which the @@ -1286,15 +1350,16 @@ async function driveStory(story) { // So the test is inverted: a VERDICT must be present. Absence of findings is not evidence // that a review happened; presence of a verdict is. Every real review emits one — it is a // required field of the contract schema — so this costs a genuine clean review nothing. - // `hasVerdict` is the SAME function `agentRetry` was given above: the contentless return is - // retried once like any other dead step, and only then does it land here. - if (!hasVerdict(review)) + // `hasReviewEvidence` is the SAME function `agentRetry` was given above: a contentless or + // unanchored return is retried once like any other dead step, then lands here. + if (!hasReviewEvidence(review)) // `acceptedFindings` travels on EVERY terminal arm, this one included. A card whose // reviewer dies mid-cycle otherwise reports the by-design and below-floor findings of // every earlier round as if none had been raised — and those are precisely the findings // the fixer never receives, so they are recoverable from nowhere else. AC4 says an // accepted finding always reaches the human; a failure is not an exception to that. return { story, prNumber: pr.prNumber, status: 'failed-review', round, acceptedFindings: accepted, reviewLog: cycleHasRemediation ? reviewLog : undefined } + const reviewedHead = String(review.reviewedHead).toLowerCase() const findings = review.findings ?? [] const allActionable = findings.filter((f) => !f.nonActionable) // Below the floor: still reported, still shown to the human, just not blocking. Marked @@ -1357,11 +1422,12 @@ async function driveStory(story) { round++ prevFindings = actionable + prevReviewedHead = reviewedHead cycleHasRemediation = true // FIX — implementer resumes checkpoint (if present) + resolves actionable findings. // Logs the round to the working review log INSTEAD of posting a per-round PR comment. const fix = await agentRetry( - `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. Follow ${SK.implement} for the change itself (test-first where a finding describes a defect), verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, + `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. CONVERGENCE SWEEP (mandatory): the finding location is the starting point, not the contract boundary. Before changing code, make a finite map of the same observable contract: the reported case and its paired success/failure path; any state transition or resume path the contract owns; and the canonical source plus every distributed representation of that behavior (generated asset, dataset, installed copy, or documented command). Change every map cell required for that one contract, then stop — do not use the sweep for unrelated cleanup, new behavior, or speculative hardening. For a generated/distributed artifact, resolve the canonical source from the asset registry, edit only that source, then run the declared generator/installer and inspect its output; never hand-edit a derived copy. PROVISIONED ARTIFACT CONTRACT (mandatory when a change installs, builds, publishes, names, or invokes an executable/package): map \`producer -> published identity -> consumer\` — for example installer/release step -> package manifest/bin/file/export -> workflow or user command. Prove the exact path in a clean temporary environment using the real built or installed artifact. Never stub, alias, or fake the exact producer, published identity, or consumer boundary; external effects may be isolated only after that boundary is crossed. For each logic defect, write a test that executes the real function/script against a real or realistic fixture and asserts output/side effects, never a source-string regex. Re-run the finding's evidence command and the mapped boundary cases before commit. Follow ${SK.implement} for the change itself: its TDD discipline and adoption-compliance phase are mandatory. Verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, withModel({ agentType: 'pair-implementer', phase: 'Review', label: `fix:${tag} r${round}`, effort: 'high', schema: FIX_SCHEMA }), ) // failed-fix: the fixer died mid-round; a partial working log may exist. Surface diff --git a/.claude/workflows/pair-implement-batch.test.mjs b/.claude/workflows/pair-implement-batch.test.mjs index b3360499b..b75b2d8e5 100644 --- a/.claude/workflows/pair-implement-batch.test.mjs +++ b/.claude/workflows/pair-implement-batch.test.mjs @@ -25,12 +25,25 @@ const SRC = readFileSync(new URL('./pair-implement-batch.js', import.meta.url), '', ) const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor +const REVIEWED_HEAD = 'a'.repeat(40) async function runWorkflow({ args, dispatch }) { const calls = [] const agent = async (prompt, opts) => { calls.push({ prompt, opts }) - return dispatch(prompt, opts) + const result = await dispatch(prompt, opts) + // A real reviewer now returns the immutable revision it reviewed. Keep legacy + // fixtures concise while allowing focused tests to provide an invalid/missing + // value explicitly. + if ( + opts.agentType === 'pair-reviewer' && + result && + typeof result === 'object' && + String(result.verdict ?? '').trim() && + result.reviewedHead === undefined + ) + return { ...result, reviewedHead: REVIEWED_HEAD } + return result } // Mirrors the real primitive's contract: "a thunk that throws (or whose agent errors) // resolves to null in the result array — the call itself never rejects". The earlier @@ -105,7 +118,14 @@ test('valid contract: reviewer schema derives from contract.json (AC1) and cache dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - assert.deepEqual(rev.opts.schema, contract.schema) + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) assert.ok(rev.prompt.includes('Blocker'), 'severity vocabulary threaded from the contract') assert.ok(rev.prompt.includes('Rework'), 'verdict vocabulary threaded from the contract') assert.deepEqual(result.contracts, [{ name: 'code-review', status: 'cache-hit' }]) @@ -212,8 +232,16 @@ test('contract with usable schema but missing canonical vocabulary keys: prompt dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - // Schema is still enum-locked from the (structurally usable) contract... - assert.deepEqual(rev.opts.schema, contract.schema) + // Schema is still enum-locked from the (structurally usable) contract, with + // the orchestration-owned reviewed revision layered on top. + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) // ...but the prompt vocabulary text falls back to the documented defaults, // since verdictOptions/severities (the canonical keys it's threaded from) // are absent. In practice ensure-contract.mjs's validateContract now rejects @@ -1002,6 +1030,129 @@ test('the fix step is likewise barred from deferring a finding into a new issue' ) }) +test('the fix step sweeps the bounded contract surface before re-review', async () => { + const finding = { location: 'x.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.match(fix, /CONVERGENCE SWEEP/, 'the fixer must make the bounded contract explicit') + assert.match(fix, /location is the starting point/i, 'a finding location is not the contract boundary') + assert.match(fix, /success\/failure/i, 'paired execution paths are checked together') + assert.match(fix, /every distributed representation/i, 'source and shipped representations are checked together') + assert.match(fix, /PROVISIONED ARTIFACT CONTRACT/, 'a provisioned command has an explicit end-to-end check') + assert.match(fix, /producer.*published identity.*consumer/i, 'the provisioner, artifact metadata and invocation are mapped together') + assert.match(fix, /clean temporary environment/i, 'the actual installed or built artifact is exercised') + assert.match(fix, /never stub.*boundary/i, 'a stub cannot stand in for the published command boundary') + assert.match(fix, /unrelated cleanup/i, 'the sweep stays bounded and is not scope creep') + assert.doesNotMatch(fix, /touch ONLY what each finding's location names/, 'line-only scope discipline would recreate the gap') +}) + +test('review and fix exhaust finite protocol states before another round', async () => { + const finding = { location: 'state.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + const review = calls.find(c => c.opts.agentType === 'pair-reviewer').prompt + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.ok(review.includes('CONTRACT INVENTORY (mandatory)'), 'the reviewer inventories a contract before reporting its first hole') + assert.ok(review.includes('finite decision table of every supported state'), 'a finite protocol/state space is exhausted in the same review') + assert.ok(review.includes('AUTHORITATIVE BOUNDARY PROOF (mandatory)'), 'the reviewer must prove externally-defined state semantics at the real boundary') + assert.ok(fix.includes('FINITE-STATE COMPLETENESS (mandatory when'), 'the fixer must preserve that complete state model') + assert.ok(fix.includes('Do not implement one newly discovered row at a time'), 'the next re-review is not used to discover ordinary variants serially') + assert.ok(fix.includes('A unit test of the function being changed cannot establish external semantics'), 'the fixer cannot infer external-tool behavior from its own unit tests') +}) + +test('re-review is anchored to the reviewed revision and checks only the fix delta plus prior findings', async () => { + const finding = { location: 'workflow.yml:4', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') + return round++ === 0 + ? { verdict: 'Rework', findings: [finding] } + : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const reviews = calls.filter(c => c.opts.agentType === 'pair-reviewer') + assert.match(reviews[0].prompt, /reviewedHead/i, 'every review returns the immutable head it covered') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x --name-only`), 're-review inventories the fix delta, not the entire PR') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x`), 're-review starts from the previous review baseline') + assert.match(reviews[1].prompt, /only if it is in this delta or a contract boundary changed by this delta/i, 'unchanged PR surface is not repeatedly re-audited') +}) + +test('a review without an immutable baseline cannot converge', async () => { + const { result, calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'not-a-sha' } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') + assert.equal(calls.filter(c => c.opts.agentType === 'pair-reviewer').length, 2, 'missing review evidence is retried once') +}) + +test('a review baseline must be lower-case like the review contract declares', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'A'.repeat(40) } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') +}) + +test('accepted-findings key is collision-free for location and description pairs', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: stdDispatch({ + contractResult: { status: 'cache-hit', contract: validContract() }, + review: { + verdict: 'Approved', + findings: [ + { location: 'a b', severity: 'Minor', description: 'c', nonActionable: true }, + { location: 'a', severity: 'Minor', description: 'b c', nonActionable: true }, + ], + }, + }), + }) + + assert.equal(result.batch[0].acceptedFindings.length, 2) +}) + // ── A run that drove nothing must not report success ─────────────────────── // Observed: two workflows were launched concurrently on a saturated machine, every // implementer stalled past the supervisor's window, `parallel` returned six nulls, @@ -1247,6 +1398,68 @@ test('an unknown severity blocks regardless of the floor (fail safe), and a bad ) }) +// ── The DEFAULT floor ────────────────────────────────────────────────────── +// Measured across three cycles on PR #477: the PR reached APPROVED with zero actionable +// findings, the next round implemented review Questions the reviewer had marked "No change +// requested", and the re-review found new Minors INSIDE the code that round added (three the +// first time, two the second). Questions are, by the review template's own definition, +// questions for the human — putting them in the fix set contradicts what they are. The floor +// therefore defaults to `Minor`: Major and Minor block and drive fix rounds, everything below +// is carried to the merge gate. A caller can still pass a floor explicitly to override it. +function contractWithQuestions() { + const c = validContract() + c.vocabulary.severities = ['Blocker', 'Major', 'Minor', 'Questions'] + c.severityRanks = { Blocker: 4, Major: 3, Minor: 2, Questions: 1 } + return c +} +const QUESTION = { location: 'c.ts:3', severity: 'Questions', description: 'is this intended?', recommendation: 'no change requested' } + +test('by default a Questions-only review converges: carried to the gate, never fixed', async () => { + const { result, calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: stdDispatch({ + contractResult: { status: 'cache-hit', contract: contractWithQuestions() }, + review: { verdict: 'Rework', findings: [QUESTION, { ...QUESTION, location: 'c.ts:9' }] }, + }), + }) + const b = result.batch[0] + assert.equal(b.status, 'ready-for-merge', 'Questions alone do not keep the loop open') + assert.equal(b.acceptedFindings.length, 2, 'both Questions reach the human') + assert.match(b.acceptedFindings[0].disposition, /Below severity floor \(Minor\)/) + assert.ok(!calls.some(c => c.opts.label?.startsWith('fix:')), 'no fix round is spent on a Question') +}) + +test('by default a Minor still blocks and still drives a fix round', async () => { + let round = 0 + const { result, calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: contractWithQuestions() } + if (opts.agentType === 'pair-reviewer') + return round++ === 0 ? { verdict: 'Rework', findings: [MINOR, QUESTION] } : { verdict: 'Approved', findings: [QUESTION] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + assert.equal(result.batch[0].status, 'ready-for-merge') + assert.ok(calls.some(c => c.opts.label?.startsWith('fix:')), 'the Minor drove a fix round') +}) + +test('a vocabulary without Minor falls back to no floor instead of throwing', async () => { + const c = validContract() + c.vocabulary.severities = ['Severe', 'Trivial'] + c.severityRanks = { Severe: 2, Trivial: 1 } + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: stdDispatch({ + contractResult: { status: 'cache-hit', contract: c }, + review: { verdict: 'Rework', findings: [{ location: 'x:1', severity: 'Trivial', description: 'd', recommendation: 'r' }] }, + }), + }) + assert.equal(result.batch[0].status, 'escalate', 'without a usable default every actionable finding blocks, as before') +}) + // ── Options must survive a JSON-string `args` ─────────────────────────────── // Real bug: the runtime can hand this script `args` as a JSON STRING. parseBatchArgs // normalized it, but severityFloor was read off the RAW value, where diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 000000000..1bfc1fff8 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,114 @@ +# Formatting gate (story #413). +# +# Since #394 the pre-push hook CHECKS formatting instead of applying it — right +# shape for a hook, but it left the LOCAL HOOK AS THE ONLY ENFORCEMENT POINT: a +# push with `--no-verify`, or a contributor whose hooks are not installed, landed +# unformatted code with every CI check green. This workflow runs the identical +# `pnpm format:check` on every PR and on push to `main`. +# +# A DEDICATED WORKFLOW, not a job in ci.yml: that workflow carries +# `paths-ignore: ['.changeset/**']` at WORKFLOW level, which a job inherits — so a +# PR touching only `.changeset/**` would run no formatting check at all. A check +# with a hole in its TRIGGER reads as enforcement and is not. Its own workflow also +# means its own status context (`format`), separately requirable once branch +# protection is written. +# +# THE SHAPE OF THIS FILE IS ASSERTED, so a red `pnpm test` (or `pnpm +# gate:composition`) on an edit here is expected, not a flake: every trigger, filter, +# key, action, input, command and condition is an allow-list, read by +# packages/dev-tools/src/quality-gates/format-workflow-composition.ts. That guard +# PARSES this file with `yaml@2.8.2`, so the SPELLING is free (flow style, anchors, +# aliases, JSON steps, CRLF all resolve to the same document GitHub runs) and only +# the meaning is constrained. That module's header is the rule inventory and the +# reason for each rule (the exact checking command, the checkout's inputs, the +# workflow/job/step key allow-lists, the runner label below, the toolchain-only install +# shell, the say-only remedy, …); the per-block notes below say only what is not obvious +# from the YAML itself. + +name: Format + +on: + pull_request: + branches: + - main + push: + branches: + - main + # Manual runs on an arbitrary ref. Re-running the job from the Actions UI only + # replays the ref it already ran on; this is the escape hatch ci.yml already has. + workflow_dispatch: + +# SUPERSESSION, not de-duplication. Keyed on `github.ref` the two triggers never +# meet: a `pull_request` run is `refs/pull//merge`, a push to main is +# `refs/heads/main`, so they sit in different groups (and after a merge the PR is +# closed, so `pull_request` does not fire at all). What this buys is dropping a run +# whose ref has already moved on — push three commits to a PR branch, only the last +# verdict is worth a runner. +# +# Hence the CONDITIONAL cancel: two merges to main a minute apart share +# `format-refs/heads/main`, and an unconditional `true` would cancel the first +# commit's run, leaving that commit with no formatting verdict of its own. Main is +# linear, so the surviving run's tree still contains that commit and the drift is +# caught one commit later — this is the stronger of two acceptable spellings, not the +# only correct one (the guard allow-lists a bare `true` as the weaker). On main, runs +# queue. +concurrency: + group: format-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + format: + # GitHub-hosted, and asserted as such: `runs-on` picks the machine, which decides + # what `pnpm` and `prettier` are and who watches the run — the `container:` question + # spelled as a value. On a public repo a `pull_request` run executes the PR's own + # copy of this file, so `self-hosted` here would be a machine the PR chose. + runs-on: ubuntu-latest + # This job runs `pnpm install`, i.e. PR-authored lifecycle scripts. It takes the + # least token it can and a timeout that turns a hung install into a red instead + # of a six-hour default. + permissions: + contents: read + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '10.15.0' + - name: Ensure pnpm is available (fallback) + run: | + if ! command -v pnpm >/dev/null 2>&1; then + echo "pnpm not found in PATH; attempting to enable via corepack" + corepack enable || true + corepack prepare pnpm@10.15.0 --activate || true + fi + # Pinned to the same major ci.yml and release.yml pin — see the note on + # ci.yml's `Setup Node.js` step. Raising this means raising those together. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + # The identical script the pre-push hook and a developer run. It names the + # offending files and exits non-zero. + - name: Check formatting + id: format_check + run: pnpm format:check + # …but it does NOT name the remedy: `--list-different` prints the paths and + # suppresses prettier's own "Run Prettier with --write to fix" line. The + # contributor this workflow exists for (hooks not installed, or pushed with + # `--no-verify`) would otherwise read a bare filename and no instruction. The + # annotation surfaces on the PR's Checks tab, not only in the log. + # + # SCOPED to the check's own outcome, not a bare `if: failure()` — that is + # JOB-scoped and fires for ANY earlier failure. A `pnpm install` broken by a + # lockfile drift or a registry outage would otherwise be annotated "not + # formatted, run 'pnpm format'": the contributor runs it, nothing changes, and + # the real cause sits under a confident wrong diagnosis. + - name: Explain how to fix a formatting failure + if: failure() && steps.format_check.outcome == 'failure' + run: | + echo "::error title=Formatting check failed::The files listed in the previous step are not formatted. Run 'pnpm format' locally, commit the result, and push again. CI never rewrites your branch." diff --git a/.gitleaks.toml b/.gitleaks.toml index 5a0a429a6..840e4db92 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -3,7 +3,9 @@ title = "pair secret-scanning allowlist" [extend] useDefault = true # keep gitleaks' built-in detection rules — this file only adds exceptions -[allowlist] +# Repo-wide exceptions: strings that are false positives WHEREVER they appear, because +# each is a documented, synthetic, illustrative value with no real counterpart. +[[allowlists]] description = "Project-specific false-positive exceptions, confirmed one at a time" regexes = [ '''AKIAABCDEFGHIJKLMNOP''', # documented worked example in secret-scanning.md/ADR-015 — a synthetic, illustrative AWS-key-shaped string, not a real credential @@ -11,3 +13,18 @@ regexes = [ '''sess_xyz789uvw012''', # placeholder sessionToken example in the same file — not a real credential '''aBcD1234efGH5678ijKL''', # synthetic token-shaped value in web-cloud-environment.test.ts (credential-shape fixtures) — not a real credential ] + +# PATH-SCOPED exception, `condition = "AND"` so BOTH the path and the value must match. +# A 40-hex string is a git object id only where it is a smoke-test fixture; the same +# literal anywhere else must still be scanned, so this allowance cannot travel. +# (Measured against gitleaks 8.30.1: with this block the fixture path is allowed and the +# same literal in `apps/` is still reported; scoping it as a `[[rules]] id = +# "generic-api-key"` override instead DISABLES the built-in rule outright — 2 findings +# became 0 on the same fixtures.) +[[allowlists]] +description = "smoke-test HEAD SHA fixture — a git object id, not a credential" +condition = "AND" +paths = ['''scripts/smoke-tests/.*'''] +regexes = [ + '''cc1fba122f0c912ba01288fe90ab2632e7e41057''', # synthetic 40-hex HEAD SHA fixture in scripts/smoke-tests (pr-state-flow.sh, github-pr-reviews.json); matched generic-api-key only through the `TOKEN_HEAD=` variable name at commit 5e99d538 — a git object id, not a credential +] diff --git a/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md b/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md new file mode 100644 index 000000000..79afd4533 --- /dev/null +++ b/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md @@ -0,0 +1,58 @@ +# Decision: Review re-checks use an immutable baseline and prove provisioned artifacts + +## Date + +2026-08-31 + +## Status + +Active + +## Category + +Process Decision + +## Context + +PR #474 / story #217 ran on `89793d27`, which already required a convergence sweep. In round 4, +the fixer added a CLI installation step and retained a runner invocation, but tested a stub named +`pair` rather than the installed package's declared `pair-cli` bin. Round 5 therefore found the +new functional defect. The generic sweep named distributed representations but did not require an +end-to-end proof across installation, published identity, and invocation. Re-review also rescanned +the whole accumulated PR, so each fix expanded the next review surface. + +## Decision + +1. Every reviewer return includes the lower-case 40-character SHA it inspected (`reviewedHead`). + Missing or invalid evidence is retried once then fails closed; it never converges a PR. +2. The initial review remains complete. Each later re-review verifies prior findings plus + `git diff ...origin/` and directly changed producer/consumer boundaries. + A new blocking finding must come from that delta or boundary; an unchanged surface is not + re-audited as a new fix round. +3. A fix touching an installed, built, published, named, or invoked artifact maps + `producer -> published identity -> consumer` and proves it in a clean temporary environment + using the real artifact. The exact boundary cannot be stubbed, aliased, or faked. + +## Alternatives Considered + +- **Keep full-PR scans on each round**: rejected — a growing diff makes a re-review another + independent first review and creates unbounded new scope. +- **Accept findings after a fixed round cap**: rejected — it hides genuine defects rather than + bounding their cause. +- **Only strengthen source-string tests**: rejected — #217 passed such a test while the published + CLI contract was broken. + +## Consequences + +- Re-review is bounded without downgrading Major or Minor findings. +- A caller resuming an older cycle has one fresh full review to establish a new baseline. +- Workflow authors must keep dataset source and root mirror byte-identical; tests cover both. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: records the review baseline and provisioned-artifact + proof convention. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and its installed mirror: + enforce the convention. +- Both dry-run copies of `pair-implement-batch.test.mjs`: pin the schema, bounded re-review, and + real-artifact prompt requirements. diff --git a/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md b/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md new file mode 100644 index 000000000..1fa09a35a --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md @@ -0,0 +1,54 @@ +# Decision: External boundary proof prevents false equivalence + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Process Decision + +## Context + +The finite-state inventory rule exposed #416's CRLF problem, but the fixer extended the repair to +bare CR by treating all carriage-return forms as equivalent. Its unit tests proved only the drift +checker’s classification. A minimal real Git probe showed the promised remedy was false: Git +normalizes CRLF to LF, but preserves a lone CR blob through checkout. The next review therefore +found a major regression: its advice forbade the only repair that actually worked. + +## Decision + +Whenever a decision-table row, equivalence, normalization or user-facing repair depends on an +external command, service, file format or runtime, identify its authoritative producer/consumer +and prove the claim with a minimal isolated end-to-end probe. Keep variants distinct until that +boundary demonstrates equivalence. Apply any proposed repair in the probe and verify its stated +postcondition. Unit tests of the changed function remain required but cannot substitute for this +boundary evidence. + +## Alternatives Considered + +- **Trust the implementation unit tests**: rejected — they cannot establish Git’s checkout + semantics or prove user guidance outside the function’s process. +- **Treat all syntactically similar inputs as one state**: rejected — the external producer can + distinguish them, as CRLF and lone CR demonstrate. +- **Defer external proof to re-review**: rejected — that makes the reviewer discover a repair + regression after the fixer has already committed it. + +## Consequences + +- A finite-state table may contain an external-boundary probe per row or equivalence class. +- Fix reports include command, observed result and postcondition for external repair claims. +- Re-review may still stop on increased findings; it remains the signal that the required proof + was absent or incorrect. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: extends Review Convergence with the boundary-proof + requirement. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and installed mirror: + require reviewers and fixers to prove externally-defined semantics and repair advice. +- Both workflow test copies: pin the prompt requirement so it cannot silently disappear. diff --git a/.pair/adoption/decision-log/2026-09-01-quoted-echo-arguments-are-data-not-commands.md b/.pair/adoption/decision-log/2026-09-01-quoted-echo-arguments-are-data-not-commands.md new file mode 100644 index 000000000..df1b345a5 --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-quoted-echo-arguments-are-data-not-commands.md @@ -0,0 +1,92 @@ +# Decision: a workflow guard reads a quoted `echo` argument as data, never as a command + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Convention Adoption + +## Context + +`.github/workflows/format.yml` (#413) has to satisfy two rules that name the same +literal: + +- **AC1** — a failing check must print the remedy, and the remedy is `pnpm format`. +- **AC6 / ADL 2026-07-31** — no step reachable from the gate may WRITE, and + `pnpm format` expands (through the root scripts) to `prettier:fix`, a writer. + +The write-mode scan is a text scan over `run:` blocks, expanded transitively against +the root `package.json` scripts, shared with the pre-push guard next door +(`findWriteModeFormatters` + `expandScriptReferences`). Measured on the shipped +guard before this decision: + +| `run:` text | guard verdict | +| --- | --- | +| `pnpm format:check \|\| { echo "... Run pnpm format ..."; exit 1; }` | `ok=false` — "reaches 1 step(s) that WRITE files: prettier:fix" | +| same line with the literal `pnpm format` removed from the message | `ok=true` | + +So the guard rejected the obvious spelling of the remedy, and the workflow shipped +with no remedy at all: the CI run for commit `888391cc` printed the offending path +(`packages/dev-tools/src/quality-gates/__fixture-t3/unformatted.ts`) and nothing a +contributor could act on. + +## Decision + +For the format-workflow guard, a **quoted argument to `echo`/`printf` is data** and +is removed from the text before the "what does this step run" scans (the +`format:check`-is-invoked rule and the write-mode rule). Everything else — the whole +of an unquoted command, and any other step — is still scanned as a command. + +The exemption is bounded by execution, not by punctuation: a quoted string +containing `$` or a backtick can still execute (`echo "$(prettier --write .)"` really +does write), so those quotes are **not** stripped. The `${{ … }}` injection scan +reads the raw, unstripped text on purpose — `echo "${{ github.event.pull_request.title }}"` +is the classic sink precisely because it is quoted and echoed. + +A separate rule then REQUIRES the remedy: at least one step with an `if:` naming +`failure()` must mention `pnpm format` (word-bounded, so `pnpm format:check` does not +satisfy it). + +## Alternatives Considered + +- **Word around it in the workflow** (say "run the repo formatter" and never spell + `pnpm format`): the message stops being copy-pasteable, which is the entire value of + naming the remedy, and the next author who writes the real command gets a confusing + gate failure. +- **Exempt the whole `Explain how to fix it` step by name**: a guard keyed on a step's + NAME is defeated by renaming the step, and it would exempt that step's real commands + too. +- **Parse the shell** (a real POSIX parser to separate words from operators): correct, + and out of proportion for a twenty-line workflow — the same trade-off already recorded + for `stripComments` in this module. +- **Apply the same relaxation to `pre-push-gate-composition`**: not done. That guard + scans `package.json` script bodies, where there is no equivalent need — a script does + not print a remedy — and widening a security-adjacent scan with no driving case is how + the two copies drift. + +## Consequences + +- The format workflow can print `pnpm format` on the failure path (AC1 satisfied) while + the write-mode ban stays enforced: `run: pnpm format` still fails the guard, and so + does `echo "$(prettier --write .)"`. +- A guard asymmetry now exists between the two modules in + `packages/dev-tools/src/quality-gates/`: the format-workflow guard strips inert quoted + messages, the pre-push guard does not. Deliberate, and stated here so a later reader + does not "fix" it by copying the relaxation across. +- Residual gap, same class as the one ADL 2026-07-31 records for the offender list: an + inert-looking quoted string that a future shell feature makes executable would be + skipped. Bounded by the `$`/backtick exclusion, which covers every substitution form + POSIX sh has. + +## Adoption Impact + +None. This is guard-internal scanning semantics for one module; it changes no adopted +tool, process or gate composition. `adoption/tech/way-of-working.md` already lists +`format` in the required-check inventory (#413) and needs no edit — the check's identity, +trigger and command are unchanged. diff --git a/.pair/adoption/decision-log/2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md b/.pair/adoption/decision-log/2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md new file mode 100644 index 000000000..24f073f5a --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md @@ -0,0 +1,133 @@ +# Decision: a repo-wide guard is enforced by a `$TURBO_ROOT$` cache input, not necessarily by a thin CLI + root gate step + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Tooling Preference + +## Context + +Story #413 adds `format-workflow-composition` to `@pair/dev-tools`: a guard that reads +`.github/workflows/format.yml` and fails if that workflow ever declares `paths-ignore`, switches to +`pull_request_target`, drops the `push: main` trigger or the concurrency group, gains a secret, or +runs a write-mode formatter. Its input therefore lives **outside** the package that owns it. + +`way-of-working.md` § Quality Gates states the rule this fits: + +> **A guard whose only caller is a turbo task is not enforced.** `turbo ts:check test lint` are +> cacheable with package-scoped inputs, so a change OUTSIDE the guard's package replays a cached +> PASS and the guard never executes. A guard over repo-wide state therefore gets a thin CLI and a +> **root gate step** (`hygiene:check`, `smoke-modes:check`, `docs:staleness`, `skills:conformance`). + +Read literally, #413 owes the repo a `format-workflow:check` CLI, a root script, an entry in +`quality-gate` and a step in `ci.yml` — four wiring points for one twenty-line workflow. + +But the rule's **premise** ("cacheable with package-scoped inputs") stopped being universally true +in this repo. `turbo.json` already carries two entries — `@pair/knowledge-hub#test` and +`@pair/dev-tools#test` — that name repo-root paths as task `inputs` via `$TURBO_ROOT$` (turbo >= +2.1), precisely so a repo-wide artifact busts the cache of the package-local test that guards it. +`@pair/dev-tools#test` already lists `$TURBO_ROOT$/scripts/format-lib/**` for exactly this reason, +and `@pair/knowledge-hub#test` already lists `$TURBO_ROOT$/.github/workflows/**`. + +The two adoption statements disagree about the mechanism, not the goal. The goal is: **the guard +must actually execute when the thing it guards changes.** + +## Decision + +**Enforcement mechanism is chosen per guard by what invalidates it, not by a blanket "repo-wide ⇒ +CLI + root gate step" rule.** A guard over an artifact outside its package satisfies the rule when +its owning task declares that artifact as a `$TURBO_ROOT$` input — the cache can no longer serve a +stale PASS, which is the entire failure mode the CLI + root-step pattern was invented to avoid. + +For #413 this means: no new root script, no new `quality-gate` segment and no new `ci.yml` +step. The guard is a vitest file, and `@pair/dev-tools#test` / `#test:coverage` gain **two** +inputs: `$TURBO_ROOT$/.github/workflows/format.yml` and `$TURBO_ROOT$/package.json`. (The +first version of this decision also said "no `format-workflow:check` CLI"; see the Amendment +below — a thin CLI now runs under the EXISTING `gate:composition` segment, without changing +the reasoning above.) + +The second is not bookkeeping. Both guards in that folder resolve **script delegation** against the +root scripts — `checkThisRepoGate` parses `package.json` outright, and `checkFormatWorkflow` defaults +`rootScripts` to `readRootScripts()`, which is the only reason `pnpm format` in a workflow step +resolves to `prettier:fix` at all. MEASURED without it: `turbo run test --filter=@pair/dev-tools +--dry=json` hashed `7271baf2a672a276`, and rewriting the root `format:check` script to `pnpm +prettier:fix` — i.e. CI now WRITES — left the hash at `7271baf2a672a276`, a cached PASS with neither +guard running. With the entry the same mutation moves the hash and both guards fire. + +Two facts make this sufficient rather than merely lighter: + +1. **CI is cold on every run** — no remote cache, no `.turbo` restore in `ci.yml` — so `pnpm test` + already executes the guard on every PR. The turbo input closes the **local** false green (which + is also what the pre-push hook's `turbo test` sees), not a merge-gate hole. This is the same + analysis already recorded in `turbo.json`'s `@pair/knowledge-hub#test` note. +2. **The guarded file cannot change without the input firing** — `format.yml` is named exactly, so + there is no glob under- or over-reach to drift. + +The CLI + root-gate-step pattern stays mandatory for the case that produced it: a guard whose input +set cannot be expressed as task inputs, or whose failure must be diagnosable as its own CI status +context rather than inside `pnpm test`. + +## Amendment — 2026-09-02 (PR #477 review round 12) + +Story #413's AC6 reads: check-only holds in CI "guarded by `pnpm gate:composition`". As decided +above, the new guard was enforced through `pnpm test` only and `gate:composition` ran the +pre-push guard alone, so the AC's named mechanism was not literally true. Resolved by wiring the +thin CLI this ADL had declined: `@pair/dev-tools format-workflow:check` (a `main()` behind a +`require.main` guard in the same module — the ADR-014 shape `pre-push-gate-composition` uses), +run by the existing root `gate:composition` script beside `pre-push-gate:check`. Cost: one line +in `packages/dev-tools/package.json`, one `&&` in the root `gate:composition` — no new root +script, no new `quality-gate` segment, no new `ci.yml` step, so the "four wiring points" +objection above does not apply. What the decision keeps: the `$TURBO_ROOT$` input is still +what makes `pnpm test` (and the pre-push hook's `turbo test`) honest locally — the CLI is a +second enforcement point, not a replacement, and the per-guard rule ("choose by what +invalidates it") stands; a guard may use both. Smoke-tested: `pnpm gate:composition` exits 0 +on the shipped workflow and prints both guards' lines; with `with: ref: main` injected into +`format.yml`, `format-workflow:check` exits 1 naming the input. + +## Alternatives Considered + +- **Thin CLI + root script + `quality-gate` segment + dedicated `ci.yml` step** (the literal + reading of the way-of-working rule): four wiring points, a fifth root script and a second CI step + for a check already executed by `pnpm test` on a cold runner. It buys one real thing — a distinct + status context — which #413 does not need: the workflow it guards already has its own `format` + context, and a composition failure is a code failure, correctly reported by the test job. +- **Nothing at all — rely on `pnpm test` alone**: rejected. It is green in CI but stale locally, so + the developer who reintroduces `paths-ignore` gets a cached PASS on the pre-push hook and only + learns of it after pushing. That is the exact "guard that does not run" defect the way-of-working + rule names. +- **Add `$TURBO_ROOT$/.github/workflows/**` (the whole directory)** instead of the one file: + rejected as over-invalidation — every unrelated workflow edit would rerun the dev-tools suite, + and `@pair/knowledge-hub#test` already covers the directory for the conformance suite that + genuinely reads all of it. + +## Consequences + +- #413 ships two files plus two `turbo.json` input entries, plus (Amendment) one package script + and one `&&` in the existing `gate:composition`, instead of four wiring points; the root + `quality-gate` string and `ci.yml` are untouched by this story. +- A future guard in this repo must answer "what invalidates it?" before "does it need a CLI?" — + the way-of-working bullet now names both mechanisms, so the choice is explicit rather than + inferred from the older of two precedents. +- **Residual risk, accepted**: if CI ever gains a remote cache or a `.turbo` restore, the "CI is + cold" leg of this reasoning disappears, and every guard relying on task inputs starts depending + on those inputs being *correct* rather than merely helpful. The `$TURBO_ROOT$` entries are what + would keep it honest — which is why the exact-file spelling matters. +- The two `@pair/dev-tools` entries (`#test` and `#test:coverage`) must keep identical input lists, + the same hand-maintained duplication `turbo.json` already documents for the knowledge-hub pair. + +## Adoption Impact + +- `adoption/tech/way-of-working.md` § Quality Gates — the "A guard whose only caller is a turbo + task is not enforced" bullet gains the `$TURBO_ROOT$`-input mechanism as the second way to + satisfy it, and states when the CLI + root-gate-step form is still required. +- `turbo.json` — `@pair/dev-tools#test` and `@pair/dev-tools#test:coverage` declare + `$TURBO_ROOT$/.github/workflows/format.yml` and `$TURBO_ROOT$/package.json` (the change itself, + recorded here as the rationale). Trimming the `package.json` entry as "undocumented" reopens the + measured stale-cache false green above, for BOTH guards in that folder. diff --git a/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md b/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md new file mode 100644 index 000000000..ee73fa744 --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md @@ -0,0 +1,58 @@ +# Decision: Review contract inventory prevents serial findings + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Process Decision + +## Context + +The bounded delta re-review correctly exposed defects introduced by fixes, but it found them one +ordinary state at a time. On #419, fixes moved from an already-dirty path, to an unsupported +`git status --porcelain` shape, to a silent skip of another normal shape. The reviewer had one +concrete failure case per round and the fixer repaired that example, not the finite input domain. +Stopping on a count increase is therefore useful evidence for human investigation, not a reason +to hide the increase. + +## Decision + +Before reporting or fixing a changed observable contract, the reviewer/fixer maps its authoritative +producer, inputs, consumers and distributed representations. For a finite protocol, parser, +configuration, state transition or command-output domain, it builds a decision table containing +every supported state and invalid/boundary pair, and probes/tests each row against real behavior. +The first review inventories the full PR surface; a re-review inventories only its fix delta and +directly changed boundary. A fixer may not implement one newly found normal row and wait for a +later re-review to reveal the next one. + +## Alternatives Considered + +- **Keep only the generic convergence sweep**: rejected — it names paired paths but did not make + a finite protocol domain explicit, so a scalar repro still drove a scalar fix. +- **Rescan the full PR on every re-review**: rejected — it reopens unchanged scope; the immutable + baseline/delta rule remains in force. +- **Ignore a rise in findings**: rejected — the monitor's stop is the deliberate signal that a + fix may have introduced a defect and requires investigation. + +## Consequences + +- The initial review may front-load more findings, but ordinary variants are no longer deferred to + later rounds. +- Fixes touching finite state domains carry a complete test matrix before code changes. +- A count increase remains a valid human stop condition; it now points to a specific missing + inventory rather than being treated as the convergence mechanism itself. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: records the inventory requirement under Review + Convergence. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and its installed mirror: + require the inventory in reviewer/fixer prompts. +- Both dry-run copies of `pair-implement-batch.test.mjs`: pin the prompt path and finite-state + rule. diff --git a/.pair/adoption/decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md b/.pair/adoption/decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md new file mode 100644 index 000000000..98d70a9cf --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md @@ -0,0 +1,310 @@ +# Decision: the format-workflow guard PARSES `format.yml` with `yaml@2.8.2`, and every rule is an allow-list over the parsed document + +## Date + +2026-09-01 (amended 2026-09-03) + +## Status + +Active (amended 2026-09-03 — the decision this file recorded on 2026-09-01, "the guard REJECTS +the YAML spellings it cannot read", is **superseded in place**: the parser migration it listed as +a costed follow-on is TAKEN, and **has landed in PR #477** — the module imports `yaml@2.8.2`, +`@pair/dev-tools` declares the catalog entry, the line reader and the four spelling-rejection rule +families are deleted, and the suite is migrated. The superseded text is kept below, marked as +superseded, not deleted. Filename unchanged deliberately — history, the way-of-working link and +the `.pair/llms.txt` entry all point at this path.) + +## Category + +Convention Adoption + +## Context + +`packages/dev-tools/src/quality-gates/format-workflow-composition.ts` asserts the shape of +`.github/workflows/format.yml`. Until this decision it did so with a hand-rolled, line-based +reader (`blockUnder` collects the lines indented deeper than a key; `listValueOf`, `scalarAt`, +`keysAt`, `stepsOf`, `withoutBlockScalars` read inside what it collects). That reader understands +a subset of YAML — and the subset moved, round by round, every time a reviewer measured it. + +The evidence below is kept in full: it is the justification for the decision, and it is what a +future reader needs in order not to re-litigate it. What changed on 2026-09-03 is only the +conclusion drawn from it. + +### Round 5 — the reader failed OPEN on flow mappings (mapping-KEY position) + +Every trigger rule failed **open**. A flow mapping sits entirely on its key's own line, so it +yields an EMPTY block; `listValueOf` then finds no key inside it and returns `null`, which for a +trigger filter means "no filter, therefore every value". Four one-line edits, all valid YAML +GitHub honours, left `checkFormatWorkflow` returning `ok=true` on the shipped workflow (measured, +PR #477 review round 5): + +- `pull_request: { branches: [main], paths-ignore: ['**/*.md'] }` — a markdown-only PR runs no + formatting check, asserted green by the guard whose entire reason for existing is that key; +- `pull_request: { branches: [release] }` — no PR targeting `main` is ever checked and the + `format` context never reports; +- `pull_request: { branches: [main], types: [closed] }` — the check runs only once the PR is + closed; +- `push: { branches: [release] }` — post-merge drift on `main` invisible (AC7). + +Anchors, aliases and merge keys (`pull_request: *filters`, `<<: *filters`, `- *step`) are the same +class: they relocate content the line reader cannot follow, with the same "absent ⇒ no filter" +reading, and an alias under `steps:` hides a whole step from the write-mode scan. + +### Round 6 — the same class in YAML's OTHER node position (sequence ITEM) + +The round-5 pass rejected unreadable spellings on mapping KEYS only. A step is a sequence ITEM: +`- { name: Fix, run: npx prettier --write . }` is a step GitHub executes, `stepsOf` accepts the +line as a step, and then both readers that look INSIDE a step want their key at line start +(`scalarAt(step, 'uses', …)`, `extractRunBlocks`) and find none — so the step was invisible to +`usesProblems` and to the write-mode scan at once. Measured on the shipped workflow, `ok=true` on +each of `- { name: Fix, run: npx prettier --write . }`, `- {run: prettier --write .}`, +`- { uses: creyD/prettier_action@v4 }`, `- { uses: stefanzweifel/git-auto-commit-action@v5 }` and +the JSON spelling; also on `- [a, b]`, `- &fixer run: …` and a bare `-` with the node on the next +line. Placed before the checking step, each rewrites the runner's checkout and `pnpm format:check` +passes on unformatted code with the `format` context green — the AC6 loss both of those rules +exist to prevent. + +### Rounds 12–14 — the reader failed CLOSED, five times, on CORRECT workflows + +Once the rejection bound was in place the failures inverted: the reader started reporting +**correct** workflows red, or red with the wrong cause. Each is a spelling GitHub resolves +identically to the shipped one, and each was measured, not argued: + +- **Round 12** — an **indentless** block sequence (`branches:\n- main`, `steps:\n- name: …`) is + block style, parses identically to the indented form (`yaml@2.8.2`, measured) and is honoured by + GitHub (probe run on PR #477); the reader reported the shipped workflow as "does not cover + `main` (no branch)" and "no failure-path step names the remedy". A filter-level alias + (`branches: *shared`) fell through to the list reader and was reported as "no branch" — the + wrong cause. +- **Round 13** (probe [run 33676806439](https://github.com/foomakers/pair/actions/runs/33676806439) + on PR #477) — a quoted `run: "pnpm format:check"` (red, "runs `\"pnpm format:check\"`"); the file + with CRLF line endings (red with the wrong cause, "spells `on:` as a list of events"); + `permissions:` at workflow level with none on the job (red, "declares no `permissions:`" — the + token log shows the job inherits the workflow scope, and a job's own block replaces it). +- **Round 14** — `isSetupCommand` did not `unquote` while `normalizeCommand`, added in the same + round-13 commit, did: `run: "pnpm install"` on the shipped file went red with the "toolchain + install" cause. A correct workflow turned red by the guard's own previous round. + +Each was patched in place, one line at a time, because each finding's own recommendation offered +the patch as the first option. That is nine measured instances across five rounds, in **both** +directions — the reader is silently permissive on spellings it cannot see, and actively wrong on +spellings it half-sees. The property that keeps recurring is not any one rule: it is that the +guard re-implements a YAML reader, and the re-implementation is never finished. + +ADL [2026-07-29](./2026-07-29-yaml-parser-for-generated-yaml-tests.md) already adopted `yaml@2.8.2` +and already rejected hand-rolled YAML checkers, with an argument that lands squarely here: "a +parser written to the same (mis)understanding as the generator validates nothing". The same +sentence, read for a guard rather than a generator: a reader written to the same understanding as +the rule set asserts only what its author already imagined. + +## Decision + +**The guard PARSES `.github/workflows/format.yml` with `yaml@2.8.2`, and expresses every rule as an +allow-list over the parsed document. The hand-rolled line reader is retired.** + +**This has landed** (PR #477). The list below is the migration contract as it was written, and +each clause is now a statement about the shipped module rather than an obligation: + +- **One parse, at the top.** The workflow text is parsed once with `yaml@2.8.2`. A parse error is + itself a reported problem, so an unparseable file is red — the fail-closed direction the + superseded decision established survives the migration unchanged, now enforced by the parser + rather than by a rejection list. +- **Every rule reads nodes, not lines.** Indent arithmetic goes: `blockUnder`, `listValueOf`, + `scalarAt`, `keysAt`, `stepsOf`, `withoutBlockScalars`, `isIndentlessItem`, `blockItems` and + their callers are deleted. +- **The four spelling-rejection rule families go with the reader that needed them** — + `flowStyleProblems`, `relocationProblems`, `aliasProblems`, `eventListProblem`. Flow mappings, + JSON-spelled steps, indentless sequences, anchors, aliases and merge keys are resolved by the + parser to the same document GitHub runs, so they are READ and their resolved values are subject + to every semantic rule. They stop being problems because they stop being invisible. +- **Every SEMANTIC rule survives, restated over the parsed document, and stays an ALLOW-LIST.** + Trigger set and filters (`paths`/`paths-ignore` banned, base branch, `types`, `branches-ignore`, + tags-only `push`, `pull_request_target`); `concurrency.group` keyed on the ref and + `cancel-in-progress` scoped to `pull_request`; `permissions` at both levels with no write scope; + job identity — the host job, its display name, no `strategy:`, no `needs:`, no `if:`, no + `defaults:`; the step allow-lists — `uses:`, the checkout's `with:` inputs, the checking-command + EQUALITY on `pnpm format:check`, the remedy's scope-and-`decides()` contract, and + `SETUP_COMMAND_LINES`; and the write-mode formatter/auto-commit ban. None of these is relaxed by + the migration: a rule that was an equality stays an equality, on the value the parser resolves. +- **`run:` bodies stay shell, not YAML.** The parser hands each `run:` scalar over as a string and + `extractRunBlocks`' consumers scan it as shell — which is exactly the boundary + `withoutBlockScalars` was hand-maintaining, now drawn by the parser for free. (`#` comments + inside that body are still stripped quote-aware: that is the SHELL's comment rule, applied to + shell text, and YAML's own comments never reach it.) + +**One clause of this contract was WRONG, and the boundary probe is what corrected it.** The +contract said merge keys "are resolved by the parser to the same document GitHub runs". They are +not, in either direction: `yaml@2.8.2` leaves `<<` unmerged by default (it surfaces as a literal +`<<` key), and GitHub **refuses to run the file at all** — measured on PR #477, probe run +[33724280781](https://github.com/foomakers/pair/actions/runs/33724280781), a `jobs:` block using +`<<: *base`: zero jobs, "invalid workflow file". So `<<` stays rejected, now by the job/workflow +key allow-list and with the producer's own verdict behind it. The same probe series settled the +rest of the class empirically rather than by reading the spec — flow trigger mapping +([33724282425](https://github.com/foomakers/pair/actions/runs/33724282425)), anchors +([33724282478](https://github.com/foomakers/pair/actions/runs/33724282478)), an alias DECIDING a +trigger ([33724282535](https://github.com/foomakers/pair/actions/runs/33724282535)), a JSON step +([33724282504](https://github.com/foomakers/pair/actions/runs/33724282504)) and a job-level `env:` +reaching a step ([33724282486](https://github.com/foomakers/pair/actions/runs/33724282486)) all +RAN; an unknown top-level key +([33724281525](https://github.com/foomakers/pair/actions/runs/33724281525)) was rejected with the +merge key. The guard's allow-lists agree with the producer on every row. + +- **The test suite migrates with the module, it is not rewritten.** Every mutation case is the + contract and stays: a mutation of the shipped workflow that was RED stays RED with the same + cause. The cases that asserted the REJECTION of a legal spelling INVERT — a flow trigger mapping, + a JSON step, an anchored `cancel-in-progress`, an aliased `branches:`, a CRLF file, a quoted + `run:` are correct workflows and must go GREEN, with the underlying semantic rule still enforced + on the resolved value (e.g. `pull_request: { branches: [release] }` stays RED — for being off the + base branch, not for being flow). + +**Why now, and on whose authority.** The flip trigger the superseded version wrote down — +"anything that still fails OPEN, or the first rule needing real structure (nested `with:`, matrix +jobs)" — **FIRED at round 12**, when the guard gained a `with:` rule for the checkout's inputs. +That version recorded the firing but left the consequence as "the human's call at the merge gate". +The maintainer has now made that call, at the merge gate, on **2026-09-02 and again on +2026-09-03**: take the migration, in this PR. It is recorded here as taken and binding so that no +later reading of this file re-derives "defer" — the previous framing is exactly what produced two +rounds of re-deferral against a decision already made. + +### Superseded — the decision recorded here on 2026-09-01 (reject rather than parse) + +**No longer in force.** Kept verbatim in substance, because it is what shipped between rounds 5 and +14 and a reader of that code needs it: + +> ~~The guard **rejects** any spelling it does not read, rather than parsing it or defaulting it to +> "absent".~~ Concretely, on **both** of YAML's node positions: ~~every structural KEY it reads as a +> block (`on`, each trigger under `on`, `concurrency`, `jobs`, each job, `steps`) must carry a BLOCK +> value — nothing after the colon on its own line; every sequence ITEM it walks into must be a BLOCK +> MAPPING (`- key: value`) or a plain scalar (`- main`, under a trigger filter). `- {`, `- [`, +> `- *alias`, `- &anchor` and a bare `-` are rejected — the contents of `steps:` are read by walking +> into each item, so an item the reader cannot follow is a whole step out of view. Anchors, aliases +> and merge keys are rejected anywhere in the file.~~ +> +> ~~And "block style" means what YAML means by it: an **indentless** block sequence is block style +> and is read (round 12). The bound: a spelling the reader cannot follow is rejected by name; a +> spelling it can follow is read.~~ +> +> The part that SURVIVES the supersession, because it was never about the reader: **the guard's +> incompleteness must fail CLOSED**, and the module's rules are ALLOW-LISTS, not deny-lists — +> `if:`, `uses:`, `with:`, the checking command, the setup commands. Each is a narrow canonical +> spelling; everything else is a deliberate edit to the guard. The parser changes what "a spelling +> the guard can follow" means; it does not license a deny-list. +> +> ~~The bound is deliberate: spellings the reader DOES read correctly stay accepted — `branches: +> [main]`, `permissions: { contents: read }`, and the body of a `run:` block scalar. A guard that +> fails a correct workflow is the kind that gets weakened or deleted.~~ The last sentence stands on +> its own terms, and rounds 12–14 are five instances of the guard doing exactly that. + +## Alternatives Considered + +- **Keep the hand-rolled reader under the "reject what you cannot read" bound** (the superseded + 2026-09-01 decision): **rejected on 2026-09-03**, having been the adopted decision for nine + rounds. It closed the fail-open class at the cost of a rejection surface that then produced five + fail-closed findings of its own (rounds 12–14 above), and it required the reader to be extended + every time YAML was spelled differently. The trade it offered — ~~"the parser migration is a + known, costed follow-on rather than an open hole"~~, ~~"what remains open is a judgement the + human makes at the merge gate, not a new card"~~ — is **superseded**: the judgement was made, the + answer is the migration, and it lands in this PR. + + The round-by-round record of that framing is kept because it is the audit trail of how the call + was reached, and it is struck rather than deleted: + + - ~~**Round 6**: the round-5 trigger ("the NEXT finding of this class") fired and was answered by + closing the second node position instead of migrating, because that is what the finding + recommended.~~ + - **Rounds 7–9 did not fire it.** Their findings were RULE gaps — semantics GitHub defines (a + tags-only `push` filter fires for tag refs only; `concurrency.group` must be keyed on the ref; + the check context is the job's DISPLAY name, so `name:` and a matrix rename it) — read + correctly by the line reader and simply unasserted. **This observation survives the + supersession and is the honest bound on what the migration buys**: a parser would not have + caught any of them. Round 13's `SETUP_COMMAND_LINES` (the last deny-list in the module, + replaced by a toolchain allow-list) is the same kind. Parsing closes the spelling class; it + closes nothing in the rule class. + - ~~**Round 12, for the human at the merge gate**: the trigger text names "nested `with:`" as a + flip condition and round 12 added a `with:` rule; whether that counts as the trigger firing is + recorded as the human's call.~~ **It counted.** The human's call, recorded above: it fired. + - ~~**Round 13, for the same human**: three more reader false-fails, patched in place because + each was one line. The call stays where round 9 put it: accept the line reader, or require the + `yaml@2.8.2` migration before merge.~~ **The call is made: require the migration.** +- **Defer the migration to a follow-up card**: rejected. Per ADL + [2026-08-12](./2026-08-12-implementation-never-files-a-card-it-extends-the-story.md), + implementation extends the story rather than filing a card — and deferring would ship the guard + built on the reader whose incompleteness is the entire reason the rules exist, with the trigger + already fired. +- **Teach `blockUnder` to parse flow mappings**: rejected — a second reader to keep in step with the + first, and the next spelling neither knows fails open again. It is exactly the "hand-rolled + checker" ADL 2026-07-29 rejected, with the failure mode round 5 measured. +- **Keep parsing block style only, and pattern-match the flow spelling into the existing rules** + (e.g. widen the `paths`/`paths-ignore` regex to drop its `^\s*` anchor): rejected — it fixes the + one key a reviewer happened to name and leaves `branches`, `types` and every future trigger key + reading as "no filter". +- **A different parser (`js-yaml`, a GitHub-Actions-specific schema validator)**: rejected — + `yaml@2.8.2` is already this repo's adopted parser (ADL 2026-07-29) and already a catalog entry, + so a second one would be an unargued divergence. An Actions schema validator asserts that the + file is a valid workflow; it asserts nothing about the properties this guard exists to hold. + +## Consequences + +- **Tech-stack impact: `yaml` gains a second consumer.** `@pair/dev-tools` declares + `"yaml": "catalog:"` (the catalog already pins `yaml: 2.8.2` in `pnpm-workspace.yaml` for + `apps/pair-cli`), so no new package is resolved and the lockfile delta is a workspace entry, not + a download. It stays dev-only: `@pair/dev-tools` is `private: true` and the module runs under + `ts-node` from `format-workflow:check` and `pnpm test` — nothing ships to an adopting project. + This widens ADL 2026-07-29's rule ("generated YAML is asserted by parsing") to its mirror image: + **consumed YAML is asserted by parsing too**. +- **The module is expected to shrink.** It is 1984 lines at `37cf84b4`; the reader helpers and the + four spelling-rejection rule families come out with the migration. Shrinkage is an expectation, + not an acceptance criterion — the criterion is that every semantic rule keeps its mutation test + and every mutation that was RED stays RED. +- **A correct workflow stops being red for its spelling.** Flow style, anchors, merge keys, + indentless sequences, CRLF and quoted scalars become the contributor's choice again, in this file + as anywhere else. The workflow header comment and the module header stop advertising a + block-style requirement, because there is none. +- **The class of finding that closes is "spelling X is invisible / spelling X false-fails".** The + class that remains open is a MISSING RULE — rounds 7–9, round 12's rule gaps and round 13's + setup-command allow-list are all of that kind, and no parser prevents them. That is the honest + scope of this decision, and it is why the allow-list discipline in the superseded text is carried + forward rather than dropped. +- **The migration's own risk is carried by the test suite**, which is the reason it is migrated + rather than rewritten: 321 tests over the shipped workflow and its mutations, each naming the + property it holds. Measured after the migration: 362 tests, every RED mutation still RED with the + same cause, and the rows that asserted the rejection of a legal spelling inverted to GREEN with + the semantic rule asserted on the resolved value. +- **An allow-list over ELEMENTS is not an allow-list over the VALUE** (added 2026-09-03, review + round 17). `runs-on` accepted any sequence whose every label was allow-listed. GitHub **ANDs** + the labels of a list, and no hosted image carries two image labels, so `[ubuntu-latest, + ubuntu-22.04]` — every element allow-listed — names NO machine: probe run + [33782665948](https://github.com/foomakers/pair/actions/runs/33782665948) on PR #477 left + `d8-two-ubuntu` and `d8-three-ubuntu` `queued`, never started, while `[ubuntu-latest]` and + `[ubuntu-latest, ubuntu-latest]` (GitHub dedupes) completed. The rule now COUNTS: exactly one + label. It is deliberately narrower than the producer on one measured row — the duplicate-label + spelling runs on GitHub and is rejected here — and that narrowing is the one place this module + fails a workflow GitHub would honour. It is accepted because the direction is a false RED with a + message naming the one-label spelling, never a false green, and because the alternative (dedupe + before counting) buys a spelling nobody writes at the cost of a second acceptance path. +- **Two surfaces became allow-lists in the same change, both of them the "relocation" shape this + module keeps meeting.** (a) The WORKFLOW's own keys and every JOB's, because a job-level `env:` + reaches the checking step whatever the step-level allow-list says: measured end to end, + `NODE_OPTIONS=--require=` makes the repo's own pinned prettier 3.6.2 print the + offending filename and exit **0**, so `pnpm format:check` names the file and the `format` context + reports SUCCESS on unformatted code — the `with: ref: main` loss class spelled as a job key. + `container:`/`services:` are the `uses:` third-party-code argument one level up. (b) The REMEDY's + shell, the module's last deny-list: a formatter no offender list names (`npx dprint fmt`) and a + `git commit -am style && git push` beside the required message were both green. The remedy SAYS + what to run; its shell is now quoted `echo`/`printf` and nothing else. With those two, the module + header's claim that every surface is an allow-list is true as written. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md` — the `format` required-check bullet listed "the + block-style requirement" among the asserted properties. **Edited**: that clause is gone (there is + no spelling requirement), replaced by the key allow-lists, and the ADL reference now states that + the guard parses and that an unparseable file is itself a problem. +- `.pair/adoption/tech/tech-stack.md` — the `yaml v2.8.2` entry read "(devDependency) for parsing + generated YAML in tests". **Edited**: it now covers YAML this repo generates OR consumes, and + names both declaring packages. `packages/dev-tools/package.json` declares `"yaml": "catalog:"`; + the catalog already pinned 2.8.2, so no new package resolves, and `@pair/dev-tools` is + `private: true`, so it stays dev-only. +- ADL [2026-07-29](./2026-07-29-yaml-parser-for-generated-yaml-tests.md) is **extended, not + superseded** — same parser, same argument, second consumer. +- `.pair/llms.txt` — regenerated with the production generator (the title of this record changed). diff --git a/.pair/adoption/decision-log/2026-09-03-the-review-severity-floor-defaults-to-minor.md b/.pair/adoption/decision-log/2026-09-03-the-review-severity-floor-defaults-to-minor.md new file mode 100644 index 000000000..407c8a197 --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-03-the-review-severity-floor-defaults-to-minor.md @@ -0,0 +1,58 @@ +# Decision: the review severity floor defaults to Minor — Questions are carried, never fixed + +- **Status**: Active +- **Date**: 2026-09-03 +- **Category**: Process +- **Deciders**: maintainer + +## Context + +`pair-implement-batch` drives a card through a review<->fix loop that closes when the +independent review returns zero ACTIONABLE findings. `severityFloor` names the lowest +severity that blocks; findings below it are carried to the merge gate in `acceptedFindings` +with `disposition: 'Below severity floor'`. The parameter was optional and **defaulted to +no floor**, i.e. every finding the reviewer returned — Questions included — entered the set +the fixer must resolve. + +Measured on PR #477 (story #413), three times in one cycle: + +| head | review verdict | what the next round did | re-review | +| --- | --- | --- | --- | +| `b518ba6b` | APPROVED, 0 actionable, 2 Questions | implemented both Questions | 3 new Minor, all inside the added code | +| `88f852ab` | APPROVED, 0 actionable, 3 Questions | implemented them | 2 new Minor, again inside the added code | +| `0f804485` | CHANGES-REQUESTED | reverted wholesale to `88f852ab` | — | + +The pattern is not a fixer defect. The review template defines Questions as questions **for +the human** — items the reviewer explicitly marks "No change requested". Feeding them to an +agent whose instruction is "resolve EVERY finding, including minor/nit, do not defer any" +contradicts what they are, and each answer enlarges the diff, creating fresh surface for the +next review. Convergence becomes a moving target: the loop can only end by exhausting +`maxFixRounds`. + +## Decision + +The floor **defaults to `Minor`**. Work on a card is complete when Critical, Major and Minor +are closed. Questions are carried to the merge gate for the human, and are acted on only if +the maintainer asks. + +An explicitly passed `severityFloor` still wins, including a lower one that restores the +previous block-everything behaviour. + +The default is applied **softly**, unlike a caller-passed floor: a review template whose +vocabulary does not declare `Minor`, or whose contract carries no usable ranking, falls back +to no floor instead of throwing. A default must never break a run that never asked for it — +while a floor the caller spelled wrong still throws, because that is their configuration +error. + +## Consequences + +- The fix loop terminates on a bounded set. Sub-floor findings are neither discarded nor + silently accepted: they accumulate across every round and reach the human with their + disposition. +- Adopters whose template lacks `Minor` see no behaviour change. +- A caller who wants the old semantics passes the lowest severity their template declares. + +## Adoption Impact + +None beyond this record: `severityFloor` was already documented as caller-configurable, and +its meaning is unchanged — only the default moves. diff --git a/.pair/adoption/tech/adr/adr-018-pr-state-flow-required-checks.md b/.pair/adoption/tech/adr/adr-018-pr-state-flow-required-checks.md index 1c12e9082..b06a80baa 100644 --- a/.pair/adoption/tech/adr/adr-018-pr-state-flow-required-checks.md +++ b/.pair/adoption/tech/adr/adr-018-pr-state-flow-required-checks.md @@ -163,6 +163,205 @@ label is an advisory view that can never enable a merge. Concretely: carries a **non-interactive contract** for its two human prompts so a dispatched review neither stalls nor self-answers itself into a merge. The merge stays a human act at every tier. +### Amendment (2026-08-28) — Option 4 adopted: a dedicated review identity + an adoption-gated light auto-approval row + +Story [#218](https://github.com/foomakers/pair/issues/218) adopts **Option 4**, which this ADR deferred above +("A distinct reviewer identity stays out of scope, deliberately"). The deferral was correct for #234 — the +identity is project infrastructure — and what changes here is not that fact but who provides it: the flow now +**consumes** an identity a project provisions, instead of pretending none can exist. Option 3 is unchanged and +still the design; this amendment layers a credential resolution step and one adoption-gated row on top of it. + +**What is adopted.** + +1. **A dedicated review identity, resolved through a host-agnostic adapter.** The skills that write to the code + host (`/pair-process-review` Steps 5.3–5.4, `/pair-capability-publish-pr` Phase 5) no longer assume the + session token. They resolve **which credential executes the host write** through one executable projection, + `assets/review-identity.sh` — seven entry points, and a host adapter is wired from **all seven**: + `review_identity_kind_ok` (validates the adoption value, so present-but-unparseable HALTs instead of + degrading to `none`), `resolve_identity_mode`, `review_identity_exclusion_ok` (**security-critical**: an + adapter wired from a list that omits it lets a bot-user identity with no `REVIEW_IDENTITY_LOGIN` resolve to + `identity` and sign the 🔴 human approval), `review_identity_health` (**the runtime source of the `healthy` + flag** — see below), `identity_verdict_event`, + `pair_review_publication_mode`, `identity_audit_comment` — the same "one executable projection" pattern as + `tier-resolve.sh` and `pr-state.sh`, so the recipe, the skills and the tests read one text. Three modes, and + only three: `identity` (configured and usable), `session` (none configured — **today's behavior**, not an + error), `halt` (configured but unusable). +2. **A native verdict and, on the App path, the Checks API.** With an identity resolved the reviewer is not the + author, so GitHub's self-approval rejection no longer applies: the verdict is a **native APPROVE / + REQUEST_CHANGES** by the identity rather than the `--comment` degradation, and a GitHub App identity holds + `checks: write`, so `pair-review` publishes as a **check run**. Both are *upgrades of a degraded path*: the + `--comment` verdict (which `session` mode still produces on a **self-authored** PR) and the commit status + remain exactly what that mode does, and remain documented. +3. **An adoption-gated light auto-approval row.** `light_auto_approve_allowed` ships beside `resolve_pr_state` + in `pr-state.sh` and yields approve/no-op from four inputs — adoption declares `light` in + `## Tag Projection`, the PR carries the `light` tag, the tier is **below red**, and `resolve_pr_state` + already synthesized `ready-to-merge`. It reads tags, a declaration and the synthesis: **zero classification + criteria** (D18), no "lightness" is ever computed. Adoption is the gate, not the label: a hand-applied + `light` label on a repository that declares no projection triggers nothing. + +**What is NOT changed — stated because this is the part that is easy to get wrong.** + +- **The 🔴 human-approval requirement stands — and it is now enforced by two clauses, not one.** + `human_approval_jq_filter` still requires `user.type == "User"`, which excludes a GitHub **App** installation + (it types as `"Bot"`). It does **not** exclude a **bot user**: that is an ordinary account and types as + `"User"`, so the type clause alone would have let a machine account satisfy `pair-explicit-approval` on a + `risk:red` PR. The filter therefore also excludes the identity by **login** + (`.user.login != env.REVIEW_IDENTITY_LOGIN`), and `review_identity_exclusion_ok` makes an unprovisioned login + a **not-healthy** identity so the flow HALTs rather than running with the clause inert. This is the single + change to a shipped predicate in this amendment, and it is additive: with the variable unset the clause + matches nothing and every prior outcome is unchanged. A `risk:red` PR still requires an explicit approval + from a second human account; light applies **below red only**. This is the one line of Option 4's Cons that + is *not* being reversed here: we take the identity's auditability and native verdict, and we decline the rule + change it tempted. +- **A native `APPROVE` *by the identity* is authorized by the light row and by nothing else.** + `light_auto_approve_allowed` is an input to `identity_verdict_event`, so an approving verdict the identity + would sign outside the row is submitted as a comment-form review. The row governs the review cast **on the + project's behalf**; it is not read in `session` mode, where the acting account signs its own review exactly as + it did before this amendment. Without that wiring the row's four conditions would gate nothing: on a repository with + `required_approving_review_count >= 1` any approving native review satisfies the host's approvals rule on its + own, tagged `light` or not, declared or not. `REQUEST_CHANGES` is ungated — it blocks, it never unlocks. +- **`session` mode keeps the NATIVE verdict; the comment form is the self-review case, not the mode.** The + `--comment` verdict is the workaround for one host rule — GitHub rejects an APPROVE / REQUEST_CHANGES on a PR + you authored. With no identity configured and a *second* maintainer reviewing, the host accepts the native + event and always did: `identity_verdict_event` therefore takes a fourth argument, `self_authored`, and returns + COMMENT in `session` mode only when the acting account is the author or authorship could not be read + (fail-safe). Collapsing every `session` verdict to COMMENT would have removed a real change request (nothing + blocking the merge button) and a real approval (nothing counting toward `required_approving_review_count`) on + every two-human repository — i.e. it would have made the identity mandatory to keep behavior a project already + had. +- **The adoption read is two questions — presence, then value — and present-but-unparseable HALTs.** `none` + is not a neutral default: it means *no identity*, so it resolves `session` and the review is written (and, + where the host counts it, APPROVED) with the **session token**. An extraction that recognises only one + markdown shape and degrades everything else to `none` is therefore the session-user fallback this amendment + forbids, reached with **no HALT** because the flow never learns an identity was configured. Presence is now + detected format-agnostically, the value is validated by `review_identity_kind_ok` (the adapter owns the + vocabulary, so a host guide's snippet cannot drift from what `review_identity_exclusion_ok` and + `pair_review_publication_mode` accept), and only a genuinely absent key becomes `none`. Both consumer surfaces + enumerate every entry point, `review_identity_exclusion_ok` included, since a host adapter wired from a list + that omits it lets a bot-user identity with no `REVIEW_IDENTITY_LOGIN` resolve to `identity`. + **Presence is anchored to a KEY AT THE START OF A LINE — optional list/bold decoration, the phrase, then a + colon — not to the bare phrase and not to the phrase-then-colon anywhere in a line.** A + `grep -qi 'Review identity'` over the whole adoption file also matches *prose*, so a project that runs no + identity, deletes the key and keeps one explanatory sentence ("we use no dedicated review identity — reviews + run with the session token") is read as configured, extracts nothing, and HALTs every review and every publish + — being told to declare an identity it deliberately has not got. Requiring the colon narrows that but does not + close it: an unanchored `(^|[^[:alnum:]])` match still fires mid-sentence ("A note on review identity: we + deliberately run none"), with the same permanent-outage outcome. The **line**-anchored form still fires on both + unparseable shapes the design must HALT on (`- Review identity: app`, `**Review identity**: bot-user`), which + are line-leading, colon-terminated keys. +- **`healthy` is COMPUTED PER RUN, by `review_identity_health`, and the health probes are split in two.** It is + the single signal separating `identity` from `halt`, and it shipped with no runtime source at all: the host + guide's publication snippet carried an inert `PROBES_PASSED=0 # set to 1 by whatever ran step 5's probes` + that nothing in the corpus ever set, while step 5's probes are explicitly setup-time because they leave + artifacts (a check run cannot be deleted). Both readings were broken on a **correctly provisioned** + repository: taking the guide literally left `healthy` 0 forever ⇒ `resolve_identity_mode 1 0` ⇒ `halt` on + every review; re-running the setup probes per review branded every reviewed head with a neutral + `pair-identity-probe` check run plus a posted/deleted scratch comment. So the question is split — **per run**: + cheap, artifact-free probes (the credential authenticates and is scoped to this repository; the grants are + observed without writing — on GitHub the App's installation-token exchange requested with explicit + `permissions`, which GitHub 422s when the installation lacks one, or the bot account's repository-permission + read); **at setup, once**: the probes that must write to prove a write grant. What a read probe cannot prove + at run time is covered by one rule, stated on all three surfaces: a `403`/`422` met **mid-write is a HALT**, + reported against the artifact that failed — never a retry with the session token, and never a `pair-review` + publication implying a review that did not land. +- **The review identity must not be an account that opens pull requests, and the mechanism backs the rule up.** + Nothing forbade it, and `` was read in `session` mode only — so an unattended-delivery project + that implements and publishes as `acme-bot` and then declares that same `acme-bot` as its `bot-user` identity + passed health, resolved `identity`, and had every native event rejected by the host + (`422 Can not request changes on your own pull request`). The verdict never landed as a review while the + separate publication step still marked `pair-review` `success`: an approving verdict as a green required check + on a PR carrying no review body. `identity_verdict_event` therefore reads authorship in **both** modes, with + **per-mode defaults that are deliberately asymmetric** — unknown ⇒ self-authored in `session` (the acting + account routinely is the author), unknown ⇒ *not* self-authored in `identity` (setup forbids it, and the other + default would collapse every identity verdict to COMMENT and delete the feature). A wrong guess there is loud + (the host answers 422) rather than silent, and Step 5.3's read-back now has a defined action: a review the + read does not show is `Review: NOT SUBMITTED`, the resolved check is **not** published, and the pending one + stays in place. **The rule covers both forms and is now a health input, not only a verdict degradation**: a + GitHub App authors pull requests, so "an App is never a PR author" was false and the one-credential setup — + one App opening the PR and reviewing it — would have HALTed mid-write on every review it ever ran. The + per-run probe compares the acting principal against the PR author on **both** paths and answers *not healthy* + ⇒ `halt` **before any host write**; the COMMENT degradation stays as the in-flow fail-safe for a host adapter + that runs no such probe or whose authorship read failed. **The comparison carries two login shapes, not one**: + `gh` renders one Bot actor as `app/` through GraphQL (`gh pr view --json author`) and as + `[bot]` through REST (`.user.login`). A gate written against a single shape is inert on the path + that emits the other — measured on a public App-authored PR, `gh pr view 14276 --repo cli/cli --json author + -q .author.login` ⇒ `app/dependabot` while `gh api repos/cli/cli/pulls/14276 --jq .user.login` ⇒ + `dependabot[bot]` — so the containment above is only real because both are compared. +- **One producer per required context, including across a `Review identity` switch.** `pair-review` is now + dual-form (check run on `app`, commit status otherwise) and the form is resolved *independently* by + `/pair-capability-publish-pr` at PR creation and by `/pair-process-review` at Step 5.4. A pull request opened + under one value and reviewed under another would therefore carry two independent records for one required + context — the stale one cleared by nothing — and a merge that may stay blocked on it. The rule the ADR already + applied to `pair-explicit-approval` extends here: **drain** the open pull requests before changing + `Review identity` (the exit that always works), or supersede the outgoing form on the head with the same + conclusion. The supersede exit is the conditional one: going `none`/`bot-user` ➝ `app` it needs + `statuses: write` **granted on the App AND requested in the installation-token mint payload** — the token + carries only the subset that payload asks for, so the grant alone leaves the `POST /statuses` at + `403` and the stale pending status uncleared — and it must be added only while taking that exit, since + requesting an ungranted permission `422`s the mint for every run. Going `app` ➝ anything it needs the retired + App's own token. Both conditions are why **drain** is the normative exit. +- **The idempotency skip covers the publication acts only.** Step 5.3 submits a *fresh* native review on every + re-invocation, so a re-review on an unchanged head is a new identity action: skipping from Step 5.4 straight + past Step 5.4b would leave an identity `APPROVE` with no paired audit comment and no `Light row:` line — the + reason no longer reconstructable from the PR, which is the property the audit exists to guarantee. +- **The audit comment follows the review, and the HALT does not undo the review.** Every input the comment + renders (action, tag, declaration, tier, state) is resolved before the review is submitted, so the order is a + choice: the comment is posted **after** because it attributes an action that has happened, and the reverse + order would leave a permanent comment claiming an `APPROVE` the host may then refuse (`422` self-authored, + `403` mid-write) on a PR carrying no such review. The residual the chosen order carries, recorded rather than + assumed away: the two writes are both HALT-bound and seconds apart, and a `403` on the second does not roll + the first back — an `identity`-mode `APPROVE` authorized by the light row stays on a `ready-to-merge` PR with + no paired audit comment, mergeable with no human action wherever `required_approving_review_count >= 1`. It is + **loud, not silent**: the run HALTs and reports it as the fifth `Light row:` value + (`audit comment NOT POSTED () — HALT, the APPROVE stands`), the one documented exception to + "every identity review is paired with its audit comment". +- **The PR-state synthesis is unconditional.** `resolve_pr_state` is called once per review, in its own step, + in **both modes that continue (`session` and `identity`) and for every verdict** — `halt` ends the review + before any host write, so it is not a third case. Step 5.4 publishes exactly one `pr-state:*` label on every + run, so the call can never be nested inside the (identity-only, APPROVED-only) APPROVE-authority step. +- **`resolve_pr_state`'s table is untouched.** No row is added, removed, or reordered; the light row is a + **sibling** function that consumes its output. A green/yellow PR still reaches `ready-to-merge` and a human + still merges — the light row changes *who supplies the host's required approving review*, never what the + synthesis decides. +- **`pair-review` remains anti-accident, not authorization**, in `session` mode. On the App path it becomes a + check run whose `app_id` the repository's own workflows cannot present, which is precisely the residual this + ADR recorded as "eliminated only by the GitHub-App/check-run form" — so the residual shrinks for projects + that provision an App, and is unchanged for those that do not. +- **No identity configured is not an error.** `session` mode is the shipped behavior in full. The HALT applies + only to `identity`-configured-but-broken: a missing permission or an invalid credential stops the flow with a + pointer to the host guide's *Dedicated review identity* section, and **never** silently falls back to the + session user — a review silently attributed to the human whose token happened to be loaded is worse than a + stopped review. It also binds only a phase that **actually performs an identity host write**: where the phase + would write nothing as the identity (PR publication under `Review enforcement: disabled`, which produces no + `pair-review` record there), there is no misattribution to prevent, so the unusable identity is **reported and + the phase continues**. Otherwise an enforcement-disabled project — the default — could not open a pull request + at all while its identity was half-provisioned. +- **Host mechanics stay in the implementation guide** (R2.12): GitHub App vs bot user, permissions, install and + credential storage live in `github-implementation.md`; the skills and the adapter name no host. + +**Residual, recorded rather than assumed away — declaring the `light` family makes the label a +merge-authorizing capability.** "Adoption is the gate, the label is not" contains the mis-tagging abuse only on +repositories that never declared the family. On one that did, and that sets `required_approving_review_count >= 1`, +nothing in the flow verifies **who** applied the tag: any collaborator with write or triage access can label +their own sub-🔴 PR `light`, and the identity's authorized `APPROVE` then satisfies the host's approvals rule with +no second person. That is the row working as designed, not a defect — but it means the declaration is an +authorization decision, so `light` must be access-controlled (applied from classification; manual application +restricted and audited) wherever the family is declared. The 🔴 gate is unaffected: `light` is inert at red. +Stated on all three consumer surfaces (`pr-states.md`, `github-implementation.md`, the docs page) so an adopter +meets it before opting in. It is **inert in this repository** — `Active: risk` only. + +**Verification status.** Everything above is implemented and asserted against fixtures — the identity × +`light` × tier × verdict matrix runs offline in `scripts/smoke-tests/scenarios/review-identity.sh`, the contracts +in `packages/knowledge-hub/src/conformance/review-identity.test.ts`. What is **not** yet observed on a live host +is the end-to-end run with a real App (native APPROVE attributable to the identity, `pair-review` as a check +run, a `light` sub-red PR mergeable with no human action): that needs a maintainer-provisioned App and is +tracked as story #218's task T11. Because no live-host run has been observed, the **JWT → installation-token +exchange** is documented in the form GitHub itself documents — `curl -H "Authorization: Bearer $JWT"` — rather +than relying on `gh`'s own auth scheme being accepted for an App JWT: a `401` there is indistinguishable from a +bad signature, and an unverifiable snippet at step 4 makes the whole App path unprovisionable by following the +guide. Nothing in this repository enables the row — `tech/risk-matrix.md` declares +`Active: risk` only, so the light row is **inert here** and its absence is grep-verifiable. + ## Consequences - `publish-pr` registers `pair-review` as pending **before** dispatching the review to a clean-context @@ -193,11 +392,21 @@ label is an advisory view that can never enable a merge. Concretely: otherwise a required context that never reports blocks every merge with no escape hatch. - Adding a code host means adding an implementation-guide section, not touching the model or the evaluator. Hosts lacking required checks remain usable in advisory mode. -- **A distinct reviewer identity stays out of scope**, deliberately (Option 4): the dispatched reviewer +- ~~**A distinct reviewer identity stays out of scope**, deliberately (Option 4): the dispatched reviewer runs as the author's account, so its verdict is a `--comment` review by construction, and a second - human account remains the only way to satisfy 🔴. The identity question is recorded here so it is not - rediscovered per project; it is weighed in the same follow-up story as the solo-approval token - ([#398](https://github.com/foomakers/pair/issues/398)). + human account remains the only way to satisfy 🔴.~~ **Superseded by the 2026-08-28 amendment above** + (story [#218](https://github.com/foomakers/pair/issues/218)): Option 4 is adopted as a *consumed* + identity — the flow resolves a project-provisioned identity through a host-agnostic adapter, and falls + back to exactly the behavior described here when none is configured. The half of this bullet that + still holds is the last one: **a second human account remains the only way to satisfy 🔴** — the + identity's approval is excluded by **account type** for an App (`user.type == "User"`) and by **login** + (`REVIEW_IDENTITY_LOGIN`) for a bot user, which types as `"User"` like any other account. The amendment + **adds** that login clause — the one shipped-predicate change it makes, additive and inert while the + variable is unset — and provisioning it is a setup step (`review_identity_exclusion_ok` HALTs the flow + until it is). What the amendment does not do is relax the human requirement. + The solo-approval token stays a separate design question + ([#398](https://github.com/foomakers/pair/issues/398)), which needs this story's identity only for + forgery-resistance and is not needed by it. - **Merge blocking was verified end-to-end on a live code host** (throwaway repository, 2026-07-30): labels → workflow → contexts observed on the head commit → protection applied with `enforce_admins: true` → one PR per tier. Observed: a **pending** `pair-review` blocks @@ -226,3 +435,13 @@ label is an advisory view that can never enable a merge. Concretely: - KB: new `pr-states.md` + `assets/pr-state.sh`; `quality-model.md` §4 gains a pointer to the flow (criteria stay in the model); `github-implementation.md` gains the host recipe. - Docs site: new `concepts/pr-state-flow` page, cross-linked from `concepts/tag-driven-gates`. + +Added by the 2026-08-28 amendment (story #218): + +- KB: new `assets/review-identity.sh` (the identity adapter projection); `pr-state.sh` gains + `light_auto_approve_allowed`; `github-implementation.md` gains § *Dedicated review identity*; + `pr-states.md` gains the identity actor row and the one-line 🔴 rule. +- `way-of-working.md` (template + this repo): a `Review identity` line under Quality Gates — `none` by + default, so adopting the identity is an explicit project act. +- `tech/risk-matrix.md`: unchanged — `Active: risk`, so the light row ships **inert** on this repository. +- Docs site: new `concepts/review-identity` page, cross-linked from `concepts/pr-state-flow`. diff --git a/.pair/adoption/tech/tech-stack.md b/.pair/adoption/tech/tech-stack.md index 8b31531ad..101591c48 100644 --- a/.pair/adoption/tech/tech-stack.md +++ b/.pair/adoption/tech/tech-stack.md @@ -31,7 +31,7 @@ Use `turbo` from the repository root to run cross-workspace tasks (e.g. `turbo b - vite-tsconfig-paths v5.1.4 is adopted to enable TypeScript `paths` resolution for Vite and Vitest. - @testing-library/react and @testing-library/jest-dom are adopted for React component testing (peer dependencies of UI workspaces). - vitest-axe v1.0.0-pre.5 is adopted for automated accessibility testing in vitest unit tests. -- yaml v2.8.2 is adopted (devDependency) for parsing generated YAML in tests — generated YAML sinks are asserted by parsing, never by string-matching (see `.pair/adoption/decision-log/2026-07-29-yaml-parser-for-generated-yaml-tests.md`). +- yaml v2.8.2 is adopted (devDependency) for parsing YAML this repo generates OR consumes — generated YAML sinks are asserted by parsing, never by string-matching (`.pair/adoption/decision-log/2026-07-29-yaml-parser-for-generated-yaml-tests.md`), and the repo's own workflow guards parse the workflow they read rather than re-implementing a reader (`@pair/dev-tools`' `format-workflow-composition`, `.pair/adoption/decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md`). Declared by `apps/pair-cli` and `@pair/dev-tools`; both are dev-only, nothing ships to an adopting project. - @axe-core/playwright v4.11.1 is adopted for automated accessibility testing in Playwright CT. - jsdom v25.0.1 is adopted as the DOM environment for vitest unit tests in React/UI workspaces (`apps/website/`, `packages/brand/`). - Playwright is adopted for E2E and component testing: diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 3bbc3b8ed..bf500b4ed 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -54,17 +54,39 @@ Resolution order, the split-tool routing and why the fallback is never the authe - When a bug fix or feature changes behavior covered by an existing CP, the corresponding test case MUST be updated. - **CP5's docs page list is machine-asserted against the filesystem** — `packages/knowledge-hub/src/conformance/docs-page-coverage.test.ts` compares it to `apps/website/content/docs/**/*.mdx`, so adding a docs page without listing it in CP5 fails CI ([ADL](../decision-log/2026-08-20-cp5-page-list-is-asserted-against-the-filesystem.md)). +## Review Convergence + +- **Baseline then delta:** the first review is complete and returns the immutable 40-character + head it inspected. A re-review verifies prior findings plus only the diff from that head and + directly changed producer/consumer boundaries; an unchanged PR surface does not create another + fix round. Missing or invalid review-head evidence fails closed, never converges a PR. +- **Provisioned artifact proof:** a fix that installs, builds, publishes, names, or invokes an + artifact maps `producer -> published identity -> consumer` and proves the real path in a clean + temporary environment. The exact boundary is never stubbed, aliased, or faked. See ADL + [2026-08-31-review-baseline-and-provisioned-artifact-contract.md](../decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md). +- **Contract inventory before a loop:** before reporting or fixing a changed contract, inventory + its authoritative producer, inputs, consumers and representations. A finite protocol, parser, + configuration or state transition gets a complete decision table of supported and + invalid/boundary states, with a real probe/test per row. When a row, equivalence, normalization + or repair depends on an external tool/service/format, prove it at that authoritative boundary; + an internal unit test cannot prove external semantics or that repair advice works. Re-review + applies the same rule only to its delta and changed boundary. See ADLs + [2026-09-01-review-contract-inventory-prevents-serial-findings.md](../decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) + and [2026-09-01-external-boundary-proof-prevents-false-equivalence.md](../decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md). + ## Quality Gates - `pnpm quality-gate` is the adopted project-level quality gate command. -- Quality gate includes: type checking (`ts:check`), testing (`test`), linting (`lint`), formatting and markdown lint in **check mode** (`format:check`), plus a guard that the gate stays check-mode (`gate:composition`) and the smoke-scenario mode guard (`smoke-modes:check`). +- Quality gate includes: type checking (`ts:check`), testing (`test`), linting (`lint`), formatting and markdown lint in **check mode** (`format:check`), plus two composition guards under `gate:composition` (the gate stays check-mode — `pre-push-gate:check`; `format.yml` keeps its shape — `format-workflow:check`) and the smoke-scenario mode guard (`smoke-modes:check`). - **`format:check`/`format` coverage is whole-repo, derived from git, not turbo's per-workspace scope** (#414): `scripts/format-lib/run-format.sh` lists every path `git ls-files --cached --others --exclude-standard` reports (extension-filtered), so "gitignored ⇒ never checked" is git's own rule — nested `.gitignore` files and the user's global `core.excludesFile` apply by construction, with no re-implementation in the wrappers. Coverage excludes almost nothing: root-level and non-workspace files (`.claude/**`, `.pair/adoption/**`, `qa/**`, `scripts/**`) are checked exactly like workspace files. **One documented exception**: third-party skills installed under `.claude/skills/` (any directory not matching the `pair-*` prefix, e.g. a marketplace skill) are never checked — their formatting is not this project's to maintain. An empty derived file set is treated as a broken wrapper (exit 2), never a silent pass — see `scripts/format-lib/git-tracked-paths.sh`. The per-package, glob-based invocation (`pnpm --filter prettier:check`/`mdlint:check`) is unaffected and still uses the wrappers' own `_ignore-args.sh`/`_ignore-file.sh` ignore assembly. -- **A guard whose only caller is a turbo task is not enforced.** `turbo ts:check test lint` are cacheable with package-scoped inputs, so a change OUTSIDE the guard's package replays a cached PASS and the guard never executes. A guard over repo-wide state therefore gets a thin CLI and a **root gate step** (`hygiene:check`, `smoke-modes:check`, `docs:staleness`, `skills:conformance`), which run unconditionally — a unit test alone is the enforcement point only for logic whose inputs live inside its own package (#400). +- **A guard whose only caller is a turbo task is not enforced.** `turbo ts:check test lint` are cacheable with package-scoped inputs, so a change OUTSIDE the guard's package replays a cached PASS and the guard never executes. A guard over repo-wide state therefore gets a thin CLI and a **root gate step** (`hygiene:check`, `smoke-modes:check`, `docs:staleness`, `skills:conformance`), which run unconditionally — a unit test alone is the enforcement point only for logic whose inputs live inside its own package (#400). **Second, lighter mechanism for the same guarantee**: declare the guarded artifact as a `$TURBO_ROOT$` **task input** in `turbo.json` (turbo >= 2.1), so the cache can no longer serve a stale PASS — used by `@pair/knowledge-hub#test` (the KB/skills/docs artifacts), and by `@pair/dev-tools#test` for `scripts/format-lib/**`, `.github/workflows/format.yml` and **the root `package.json`** — that third entry is not optional bookkeeping: both guards in that folder resolve script delegation against the root scripts (`checkThisRepoGate` parses it, `checkFormatWorkflow` defaults `rootScripts` to it), so without it rewriting root `format:check` to `pnpm prettier:fix` left the task hash unchanged at `7271baf2a672a276` — a cached PASS with neither guard running. Sufficient because CI is **cold on every run** (no remote cache, no `.turbo` restore in `ci.yml`), so `pnpm test` already executes the guard on every PR and the input entry closes the LOCAL false green — which is also what the pre-push hook sees. Pick per guard by what invalidates it: the CLI + root-step form stays **required** when the input set is not expressible as task inputs, or when a failure must report as its own CI status context rather than inside `pnpm test` — and a guard may use BOTH, as `format-workflow-composition` does (the input entry keeps `pnpm test` honest; `format-workflow:check` under the existing `gate:composition` segment is the mechanism #413's AC6 names). See ADL [2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md](../decision-log/2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md). - **No step reachable from the gate writes files**: the gate reports, `pnpm format` / `pnpm lint:fix` fix deliberately. `gate:composition` enforces this through an **explicit offender list** — the two formatters, eslint autofix, and the repo's write scripts (`sync-version`, `test:perf`) — so **adding a new write-mode script to this repo means adding it to that list**; a differently named writer passes the guard green. See ADL [2026-07-31-pre-push-gate-is-check-only.md](../decision-log/2026-07-31-pre-push-gate-is-check-only.md). - **Pre-merge tiering**: `disabled` (default) — every PR runs the full pre-merge check suite. Set to `enabled` to opt into risk-tier-scoped pre-merge checks (lighter checks on lower-risk PRs) per [tier-aware-pipeline.md](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. - **Review enforcement**: `disabled` (default) — the pair review **runs and publishes its verdict**, but nothing it says blocks a merge: `pair-review` and `pair-explicit-approval` are not required status checks, and the 🔴 explicit-approval rule is advisory. Set to `enabled` to make them required and the rule binding, per [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md); `/pair-capability-setup-gates` reads this flag before touching branch protection, and `/pair-process-bootstrap` asks for it when no decision exists. Disabled is the default deliberately: a review that blocks by default turns a first install into a repository nobody can merge into — on a single-maintainer repo the 🔴 non-author approval is unobtainable outright. The tier requirements themselves (reviewer count, SLA, checklist depth, whether 🔴 needs explicit approval) are redefinable in this file; that the review **runs** is not. +- **Review identity**: `none` (default) — every code-host write (the native review verdict, the `pair-review` publication) executes with the **session token**, i.e. the human or agent running the flow. Set to `app` (a GitHub App — recommended: it unlocks the Checks API and makes "who reviewed" auditable per-identity) or `bot-user` (a second machine account) to have those writes execute as a **dedicated review identity**, per [github-implementation.md](../../knowledge/guidelines/collaboration/project-management-tool/github-implementation.md) § Dedicated review identity (the model and the actor table are in [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md)). Configured-but-broken is a **HALT**, never a silent fallback to the session user. It does **not** relax the 🔴 rule — and the exclusion is mechanical in two forms: an `app` identity is rejected by `pair-explicit-approval`'s account-type clause (`user.type == "User"`), while a `bot-user` identity **does** type as `"User"` and is rejected only by its login, so that form additionally **requires** the repository variable `REVIEW_IDENTITY_LOGIN` — unset, the identity is not healthy and the flow HALTs. Either way a `risk:red` PR still needs a second human account. A native `APPROVE` is submitted **only** where the adoption-gated light row authorizes it; every other approving verdict stays a comment-form review, so the identity never satisfies a host `required_approving_review_count` on the project's behalf. - **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). The step that runs it is the **shipped** KB asset `node .pair/knowledge/assets/coverage-ratchet.cjs` (ADR-023) — the same one an adopter's generated pipeline invokes, so this flag being `enabled` means the same thing here as anywhere else. - **Pair review required checks**: `pair-review` + `pair-explicit-approval` are the required status checks that make the judgment review unskippable (R5.7) and enforce the 🔴 explicit-human-approval rule (D10) — see [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md) and [ADR-018](adr/adr-018-pr-state-flow-required-checks.md). Status on this repo: **not yet applied** — writing branch protection needs admin scope, so it is a deliberate human step; until applied, enforcement here is advisory (the documented degraded mode). **Ordering constraint** (applies in this order, or every merge stops): 1. provision the `pr-state:*` labels + add the `pair-explicit-approval` workflow (neither needs admin scope — this repo has not added the workflow yet, so the context does not report today); 2. confirm on a real PR that `pair-review` and `pair-explicit-approval` both report on the head commit, **and** that the approval context re-reports on that same head SHA after a review submission; 3. only then `PUT` the branch protection, keeping `enforce_admins` off until one PR has merged through it. The whole sequence (including the merge-block outcomes per tier) was executed on a throwaway repository — see `github-implementation.md` § "Verified on a throwaway repository" — so what remains here is applying it, not discovering whether it works. **This repo is single-maintainer**, so a 🔴 PR cannot satisfy `pair-explicit-approval` (GitHub rejects a self-approval): a second human reviewer account is a prerequisite for making that context required here — otherwise leave it out of the required list and keep the 🔴 rule advisory. The solo-maintainer alternative (a verified human approval token instead of a second account) is tracked as [#398](https://github.com/foomakers/pair/issues/398). **When the protection is written here, use the `checks` form with `app_id` pinned** for `pair-explicit-approval` (an unpinned status context is satisfiable by any push-access token, including the agent's); `pair-review` stays unpinned and is an anti-accident control, not an authorization control — see `github-implementation.md` § "What each context proves". +- **`format` required check**: `format` is the third context in the "declared but not yet required" set, alongside the two above — CI runs `pnpm format:check` on every pull request and on push to `main` (plus `workflow_dispatch`, the same manual escape hatch `ci.yml` carries), via its own [`format.yml`](../../../.github/workflows/format.yml) workflow (#413), published as the status context `format`. A **dedicated workflow, not a job in `ci.yml`**: that workflow's workflow-level `paths-ignore: ['.changeset/**']` is inherited by every job, so a `.changeset/**`-only PR would run no formatting check — trigger coverage is part of check coverage. The workflow's shape — triggers and filters, concurrency, permissions, the exact command, the scoped remedy, the checkout's inputs, the workflow/job/step key allow-lists and every other allow-list — is asserted by [`format-workflow-composition`](../../../packages/dev-tools/src/quality-gates/format-workflow-composition.ts) in `@pair/dev-tools`: that module's header is the rule inventory (one source, not restated here), and ADL [2026-09-01-workflow-guard-rejects-what-it-cannot-read.md](../decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md) records that the guard PARSES the file with `yaml@2.8.2` (no spelling requirement: flow style, anchors, aliases, JSON steps and CRLF are read) and that a file the parser refuses is itself a problem. Enforced twice: by `pnpm test` (the `$TURBO_ROOT$` input, bullet above) and by `pnpm gate:composition` (`format-workflow:check`). Status on this repo: **not yet applied** as a required check — same pending admin-scope step, and the same ordering constraint, as the row above; until then it reports and is advisory. Unlike `pair-explicit-approval` it needs **no `app_id` pinning and no second human account**: it asserts a mechanical property of the tree, so it is an anti-accident control like `pair-review`, not an authorization control. - **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/.pair/knowledge/assets/pr-state.sh b/.pair/knowledge/assets/pr-state.sh index 8bde460cc..80821375d 100644 --- a/.pair/knowledge/assets/pr-state.sh +++ b/.pair/knowledge/assets/pr-state.sh @@ -71,6 +71,117 @@ resolve_pr_state() { echo "ready-to-merge" } +# light_auto_approve_allowed +# pr_labels : the pull request's label NAMES (TAGS ONLY). PREFER one name per LINE +# (on GitHub: `gh pr view --json labels -q '.labels[].name'`) or +# comma-separated: both delimit whole names, so a label whose NAME +# contains a space (`good first issue`) stays one label. The +# space-joined shape (`-q '[.labels[].name]|join(" ")'`) is accepted as +# a LEGACY input and is AMBIGUOUS by construction — see the match below. +# light_declared : 1 when the project's adoption declares the `light` family in +# `## Tag Projection` (tech/risk-matrix.md); anything else ⇒ not declared +# tier : green | yellow | red | +# state : the synthesis `resolve_pr_state` already produced +# +# Exit 0 = the dedicated review identity may submit a native approving review, so the +# pull request satisfies the host's required-approvals rule with no human action. +# Exit 1 = no-op, with the unmet condition on stderr. Never a silent yes. +# +# THIS ROW IS THE ONLY AUTHORITY FOR AN `APPROVE` EVENT THE IDENTITY SIGNS. It is the +# third argument of `identity_verdict_event` (review-identity.sh): in `identity` mode, +# outside this row an approving verdict is published as a COMMENT-form review, never as a +# native APPROVE. (In `session` mode no identity acts and the argument is not read: the +# account whose token is loaded signs its own review, as it did before this row existed.) That is what makes the +# gate below load-bearing rather than decorative — without it every approving verdict +# would satisfy a host `required_approving_review_count >= 1` on its own. +# +# A SIBLING, NOT A CHANGE: `resolve_pr_state` above is not modified and not consulted +# for anything but its already-computed output. This row does not decide the PR state; +# it decides only whether the identity signs the approving review the host asks for. +# +# ZERO CRITERIA (D18). "Light" is not computed here and is not computable here: this +# reads a TAG the classification produced upstream, a DECLARATION the project made in +# its adoption, the tier, and the synthesis. It never inspects the change. +# +# ADOPTION IS THE GATE, NOT THE LABEL. All four conditions must hold, and the +# declaration is deliberately one of them: a hand-applied `light` label on a repository +# whose adoption declares no `light` projection triggers nothing at all. That is the +# containment for the obvious abuse — mis-tagging a pull request to auto-approve it. +# +# BELOW RED ONLY. `explicit_approval_required` is the same per-tier row the synthesis +# reads, so an untagged or malformed tier fails this row exactly as it fails the rest of +# the flow: most restrictive wins, and light never bypasses the 🔴 human-approval rule +# (ADR-018, amendment 2026-08-28 — the identity's approval is excluded from +# `human_approval_jq_filter` mechanically: by the type clause for an App, by the +# `REVIEW_IDENTITY_LOGIN` clause for a bot user). +light_auto_approve_allowed() { + local labels="${1:-}" declared="${2:-0}" tier="${3:-}" state="${4:-}" + + if [ "$declared" != "1" ]; then + echo "pr-state: adoption declares no 'light' family in ## Tag Projection — no auto-approval (the label alone is inert)" >&2 + return 1 + fi + + # WHOLE-LABEL match: `lightweight` is not `light`. What "whole" can mean depends on the + # SHAPE of the read, and each shape carries ITS OWN delimiter — never another shape's: + # one name per LINE (`-q '.labels[].name'`) — EXACT for every label name whenever the + # string ACTUALLY carries a newline: ≥2 labels, or ONE label whose trailing newline the + # caller preserved. A code-host name cannot contain a newline, so that split never cuts + # a name. Split on newlines ALONE: a name may legally contain a COMMA (`theme, light`), + # and translating commas here would cut one whole name into two fields and match the + # tag against a fragment nobody applied. + # RESIDUAL, single label + a comma in its NAME. `LABELS="$(gh pr view --json labels + # -q '.labels[].name')"` strips the trailing newline, so a PR carrying the ONE label + # `theme, light` reaches this function as `theme, light` with NO newline, takes the + # comma branch below and matches on the fragment `light`. Nothing in that string says + # whether the comma delimits two names or belongs to one — the same irrecoverable + # ambiguity as the joined shape. It is not reachable through this row today (a sub-🔴 + # tier needs a `risk:*` label, hence a second field and a newline), but a caller that + # supplies the tier from another source meets it: preserve the trailing newline and the + # LINE branch is taken, which is exact. + # COMMA-separated — a field is a whole label name, spaces included, so `ui: light theme` + # is one label and never the tag. Exact only for names FREE OF COMMAS: once the host + # joined the names with commas, a name containing one is indistinguishable from two + # labels, the same way the space-joined shape below loses names containing spaces. + # NO DELIMITER AT ALL — the shape a ONE-label line read produces (`ui: light theme`), + # and the shape the LEGACY space-joined read (`-q '[.labels[].name] | join(" ")'`) + # produces for every PR. It is matched as ONE whole trimmed field, and therefore FAILS + # CLOSED: `light` alone still matches, `ui: light theme` is a no-op, and the joined + # `risk:green light` is a no-op too. That last one is the deliberate cost: a code-host + # label NAME may itself contain spaces (`good first issue`, `help wanted`), so the + # joined shape is irrecoverably AMBIGUOUS — nothing in it distinguishes the `light` TAG + # from a label merely containing the word — and an ambiguous input must never authorize + # an APPROVE. Pass the line form, which is exact; the joined form is accepted only in + # the degenerate single-label case where it IS the line form. + local matched=0 line fields="" + case "$labels" in + *$'\n'*) fields="$labels" ;; + *,*) fields="${labels//,/$'\n'}" ;; + *) fields="$labels" ;; + esac + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" # trim leading blanks + line="${line%"${line##*[![:space:]]}"}" # trim trailing blanks + [ "$line" = light ] && matched=1 + done <<<"$fields" + if [ "$matched" != 1 ]; then + echo "pr-state: the pull request does not carry the 'light' tag — no auto-approval" >&2 + return 1 + fi + + if explicit_approval_required "$tier"; then + echo "pr-state: tier '${tier:-unknown}' requires an explicit human approval — light applies below red only, and never bypasses that rule" >&2 + return 1 + fi + + if [ "$state" != "ready-to-merge" ]; then + echo "pr-state: state is '${state:-unknown}', not merge-enabling — no auto-approval (this row never overrides the synthesis)" >&2 + return 1 + fi + + return 0 +} + # explicit_approval_required — exit 0 (required) for red and for any # unknown/absent tier (fail-safe), exit 1 (not required) for green/yellow. # The requirement itself is the quality model's §4 row, not a rule invented here. @@ -109,16 +220,29 @@ merge_allowed() { # projection", as for tier-resolve.sh). # # Input : a REST `GET /repos/{owner}/{repo}/pulls/{n}/reviews` payload (an array). -# Env : HEAD_SHA (the only commit branch protection evaluates), PR_AUTHOR (login). +# Env : HEAD_SHA (the only commit branch protection evaluates), PR_AUTHOR (login), +# REVIEW_IDENTITY_LOGIN (the dedicated review identity's account login, when +# one is configured — see below; unset ⇒ the clause is inert, which is correct +# only for a project running no identity or an App one). # Output : one line per qualifying review id — count them; and always read ALL pages # (`--paginate`), since an approval can sit past page 1. # # Rejects by construction: a non-APPROVED review, an approval on any other commit # (i.e. stale after a force-push), a non-human account (`user.type != "User"` — bots -# and GitHub Apps, so the pair review itself can never satisfy the gate), and the PR -# author's own approval. +# and GitHub Apps), the PR author's own approval, and the DEDICATED REVIEW IDENTITY's +# own account by login. +# +# WHY THE LOGIN CLAUSE IS NOT REDUNDANT WITH THE TYPE CLAUSE. A GitHub **App** +# installation types as `"Bot"`, so the type clause alone excludes it. A **bot user** — +# an ordinary machine account, the `Review identity: bot-user` form — types as `"User"` +# on this API: the type clause does NOT exclude it, and without the login clause a +# machine account could sign the 🔴 explicit HUMAN approval. The clause is the mechanical +# exclusion for that form; `review_identity_exclusion_ok` (review-identity.sh) makes an +# unprovisioned `REVIEW_IDENTITY_LOGIN` a not-healthy identity, so the flow HALTs rather +# than running with the clause inert. See github-implementation.md § "Dedicated review +# identity" for how the variable reaches the `pair-explicit-approval` job. human_approval_jq_filter() { - printf '%s' '.[] | select(.state=="APPROVED" and .commit_id==env.HEAD_SHA and .user.type=="User" and .user.login!=env.PR_AUTHOR) | .id' + printf '%s' '.[] | select(.state=="APPROVED" and .commit_id==env.HEAD_SHA and .user.type=="User" and .user.login!=env.PR_AUTHOR and .user.login!=env.REVIEW_IDENTITY_LOGIN) | .id' } # review_check_conclusion — maps a review verdict onto the conclusion the diff --git a/.pair/knowledge/assets/review-identity.sh b/.pair/knowledge/assets/review-identity.sh new file mode 100755 index 000000000..5dd09428a --- /dev/null +++ b/.pair/knowledge/assets/review-identity.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# review-identity.sh — provider-agnostic adapter for the DEDICATED REVIEW IDENTITY. +# +# WHAT THIS ANSWERS: *which credential executes a code-host write* — the native +# review verdict, the `pair-review` publication, the audit comment. Nothing else. +# It resolves a mode from configuration signals and projects that mode onto the +# host action to take. It never reads the diff, the code, or the change itself: +# like its siblings `tier-resolve.sh` and `pr-state.sh` it carries NO classification +# criteria (D18), and unlike them it carries no state synthesis either — the PR +# state remains `resolve_pr_state`'s, untouched. +# +# WHY IT SHIPS AS AN ASSET rather than as prose in a skill: the same "one executable +# projection" rule the tier resolver and the 🔴 approval predicate already follow. +# The skills (`/pair-process-review` Steps 5.3–5.4, `/pair-capability-publish-pr` Phase 5), the code host's +# implementation guide and the tests all read THIS text, so the rule that decides who +# may approve a pull request cannot drift between a doc code block and a hand-copied +# twin in a test. +# +# THREE MODES, AND NO FOURTH: +# identity — a dedicated review identity is configured and usable. Host writes +# execute as it: the verdict becomes a NATIVE APPROVE / REQUEST_CHANGES +# (the reviewer is not the author, so the host no longer rejects it), and +# on a GitHub-App identity `pair-review` publishes through the Checks API. +# session — no identity is configured. This is TODAY'S SHIPPED BEHAVIOR in full and +# is NOT an error: host writes execute with the session token, `pair-review` +# is a commit status, and the verdict is the NATIVE event unless the acting +# account authored the pull request — the self-review case, where the host +# rejects it and the verdict degrades to `--comment`. +# halt — an identity IS configured but is not usable (invalid credential, missing +# permission, unknown health). The flow STOPS with a setup pointer. It +# never falls back to the session user: a review silently attributed to +# whoever's token happened to be loaded is worse than a stopped review. +# +# WHAT ADOPTING AN IDENTITY DOES *NOT* CHANGE (ADR-018, amendment 2026-08-28): +# the 🔴 explicit-human-approval rule. An identity approval must never satisfy +# `pair-explicit-approval` — and that exclusion is MECHANICAL, by two different +# mechanisms, because the two identity forms are two different account kinds on the +# host and only ONE of them is covered by the account-type clause: +# app — a GitHub App installation types as `user.type == "Bot"` on the reviews +# API, so `human_approval_jq_filter`'s `user.type == "User"` clause +# already rejects it. Nothing further is required. +# bot-user — a machine USER account types as `"User"`. The type clause does NOT +# reject it, and a bot-user approval would otherwise satisfy the 🔴 gate. +# The exclusion is the login clause `.user.login != env.REVIEW_IDENTITY_LOGIN` +# in `human_approval_jq_filter` (pr-state.sh), which is INERT until the +# identity's login is provisioned to the job evaluating the predicate. +# A bot-user identity whose login is not provisioned is therefore NOT +# healthy: see `review_identity_exclusion_ok` below, which +# `review_identity_health` folds into the `healthy` flag +# `resolve_identity_mode` reads, so a misconfigured bot user resolves to +# `halt` instead of running unguarded. +# With that in place a `risk:red` pull request still needs a second HUMAN account. See: +# .pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md +# .pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +# § "Dedicated review identity" (per-host setup: GitHub App vs bot user — R2.12) +# +# Usage (in /pair-process-review, /pair-capability-publish-pr, or a code host's automation): +# source review-identity.sh +# # The adoption value must be one of the vocabulary BEFORE it is trusted: a key that is +# # present but does not parse is configured-but-unusable, never `none`. See below. +# review_identity_kind_ok "$IDENTITY_KIND" || { echo "fix the Review identity key" >&2; exit 1; } +# # HEALTH IS COMPUTED ON THIS RUN, from the host guide's per-run, artifact-free probes +# # (AUTH_OK: the credential authenticated and is scoped to this repository; PERMS_OK: +# # the required grants were observed). It is never a remembered setup result. +# # $RV: the identity's login READ BACK FROM THE HOST on this run, from the same +# # configuration store the job evaluating the 🔴 predicate resolves it from (on GitHub a +# # repository variable — see the implementation guide). NEVER the caller's ambient +# # environment: a login set only in the agent's shell satisfies this precondition while +# # the gate's clause compares against the empty string and matches every account. +# # $ACTING: the login the identity credential itself reports on this run. On a machine +# # user it MUST equal $RV; the code-host implementation guide owns that host read. +# IDENTITY_HEALTHY="$(review_identity_health "$IDENTITY_KIND" "$AUTH_OK" "$PERMS_OK" "$RV" "$ACTING")" +# MODE="$(resolve_identity_mode "$IDENTITY_CONFIGURED" "$IDENTITY_HEALTHY")" +# [ "$MODE" = halt ] && exit 1 +# # APPROVE only when the light row authorized it (pr-state.sh light_auto_approve_allowed): +# light_auto_approve_allowed "$PR_LABELS" "$LIGHT_DECLARED" "$TIER" "$STATE" && APPROVE_OK=1 +# # SELF_AUTHORED: 0 when the acting account provably did NOT author the pull request, +# # 1 when it did. The DEFAULT differs by mode (see identity_verdict_event): unknown is +# # self-authored in `session`, not-self-authored in `identity`. +# EVENT="$(identity_verdict_event "$MODE" "$VERDICT" "${APPROVE_OK:-0}" "${SELF_AUTHORED:-}")" +# PUB="$(pair_review_publication_mode "$MODE" "$IDENTITY_KIND")" # checks-api | commit-status + +# resolve_identity_mode +# configured : 1 when the project's adoption declares a dedicated review identity +# (`Review identity: app|bot-user` under way-of-working's Quality +# Gates); anything else ⇒ not configured +# healthy : 1 when that identity's credential authenticated AND holds the +# required host permissions; anything else — including empty and +# unknown — is treated as NOT healthy (fail-safe) +# +# Echoes exactly one of: identity | session | halt. Always exits 0, so a caller can +# branch on the word instead of on an exit status it might swallow. +resolve_identity_mode() { + local configured="${1:-0}" healthy="${2:-}" + + if [ "$configured" != "1" ]; then + # The zero-configuration default, not a degradation: the flow behaves exactly as + # it did before an identity existed as an option. + echo "session" + return 0 + fi + + if [ "$healthy" = "1" ]; then + echo "identity" + return 0 + fi + + echo "review-identity: a dedicated review identity IS configured but is not usable ('${healthy:-unknown}' health) — HALT. This never falls back to the session user: see the code host's implementation guide, section 'Dedicated review identity', for the required permissions and the credential setup." >&2 + echo "halt" +} + +# review_identity_kind_ok +# identity_kind : the value read out of adoption's `Review identity` key +# +# Exit 0 = the value is one this adapter understands: `app`, `bot-user` (the adoption +# literal) or its short form `user`, or `none` (no identity configured). Exit 1 = it is +# not, with the vocabulary on stderr. +# +# WHY THE READ IS TWO QUESTIONS, NOT ONE. A caller extracts the value with a host-side +# expression over a markdown adoption file, and any such expression can FAIL TO MATCH a +# key an adopter did write in a slightly different shape (`**Review identity**: bot-user` +# with no bullet, `- Review identity: app` with no bold). Treating that empty result as +# `none` is not a harmless default: `none` means NO IDENTITY IS CONFIGURED, so +# `resolve_identity_mode` returns `session` and the review is written — and, where the +# host allows it, APPROVED — with the SESSION token, on a repository that provisioned a +# dedicated identity precisely so that would not happen. That is the session-user +# fallback the HALT rule forbids, reached without any HALT because the flow never learns +# an identity was configured. So the caller detects the key's PRESENCE format-agnostically, +# extracts the value, and then asks THIS function whether the value parsed: present but +# unparseable ⇒ configured-but-unusable ⇒ HALT with the setup pointer; genuinely absent +# ⇒ `none`. The vocabulary lives here, once, so a host guide's snippet cannot drift from +# what `review_identity_exclusion_ok` and `pair_review_publication_mode` accept. +review_identity_kind_ok() { + case "${1:-}" in + app | user | bot-user | none) + return 0 + ;; + *) + echo "review-identity: '${1:-empty}' is not a Review identity value — expected one of: app | bot-user (or its short form user) | none. If the key IS present in adoption, this is a configured-but-unusable identity: HALT and fix the key, never treat it as 'none' (which means no identity and writes the review with the session token)." >&2 + return 1 + ;; + esac +} + +# review_identity_exclusion_ok +# identity_kind : app | user | bot-user | +# `bot-user` is the ADOPTION literal (`Review identity: bot-user` +# in way-of-working) and `user` its short form: the skills forward +# the literal they read, so BOTH must resolve to the machine-user +# arm. A spelling this function does not accept would make a +# correctly provisioned bot user a NOT-healthy identity and HALT +# every review on that repository. +# review_identity_login: the identity's account login AS THE JOB THAT EVALUATES +# `human_approval_jq_filter` WILL SEE IT — on GitHub the repository +# variable `REVIEW_IDENTITY_LOGIN`, which the +# `pair-explicit-approval` job reads as +# `${{ vars.REVIEW_IDENTITY_LOGIN }}`. The caller must READ IT BACK +# from the host on this run, never pass its own ambient environment +# variable of the same name: an operator who exports the login but +# never sets the repository variable would satisfy the precondition +# here while the gate's clause compares against the empty string — +# `.user.login != ""` is true for every account, so the bot's own +# approval would satisfy the 🔴 HUMAN gate. Passing what the gate +# cannot see is exactly the state this check exists to refuse. +# acting_login : the login the identity's own credential reports on this run. +# The code-host implementation guide owns the host-specific read. +# Read on the machine-user arm only. A non-empty login that is not +# this one excludes a DIFFERENT account: the predicate would drop +# `acme-bot` while `acme-bot-2` — the rotated account, or a typo in +# the variable — signs the 🔴 approval as a `"User"` and the PR +# reaches `ready-to-merge` with zero humans. Empty is UNKNOWN, and +# unknown is never excluded: a caller that cannot name the acting +# account cannot establish the match, so the identity is not healthy. +# +# Exit 0 = the identity is MECHANICALLY excluded from the 🔴 explicit-human-approval +# predicate. Exit 1 = it is NOT, with the missing piece on stderr; the caller must then +# treat the identity as NOT healthy, so `resolve_identity_mode` yields `halt`. +# +# WHY THIS EXISTS. The exclusion is not one rule but two, because the account kinds are +# different on the host: an App installation is `user.type == "Bot"` and the predicate's +# type clause rejects it outright; a bot USER account is `user.type == "User"` and the +# type clause does not. For the bot-user form the exclusion is the login clause, and a +# login clause with nothing to compare against excludes nothing — an unprovisioned +# `REVIEW_IDENTITY_LOGIN` would leave a machine account able to sign the 🔴 human +# approval. Provisioned but naming ANOTHER account is the same hole one step along, so +# BOTH halves are checked here rather than left to each host's own snippet: the check is +# the host-agnostic contract a second adapter is wired from, and a host that implements +# only the non-empty half excludes a login nobody is acting under. This function is what +# makes "the identity is excluded by construction" a checked precondition rather than a +# claim in prose. +review_identity_exclusion_ok() { + local kind="${1:-}" login="${2:-}" acting="${3:-}" + + case "$kind" in + app) + # `user.type == "Bot"` — the shipped type clause already rejects it. + return 0 + ;; + user | bot-user) + # Both spellings of the machine-USER form: `bot-user` is what adoption declares and + # what the skills forward verbatim; `user` is the short form the adapter's own docs use. + if [ -z "$login" ]; then + echo "review-identity: a bot-USER identity types as user.type == \"User\" on the reviews API, so the 🔴 predicate's type clause does NOT exclude it. Its login must be provisioned as REVIEW_IDENTITY_LOGIN to the job evaluating human_approval_jq_filter, or the identity could sign the explicit human approval itself. Treat this identity as NOT healthy until it is set — see the code host's implementation guide, section 'Dedicated review identity'." >&2 + return 1 + fi + if [ -z "$acting" ]; then + echo "review-identity: the ACTING account was not passed, so the provisioned login ('$login') cannot be shown to be the one acting — unknown is never excluded. Read the acting login under the identity's own credential on this run and pass it as the third argument. See the code host's implementation guide, section 'Dedicated review identity'." >&2 + return 1 + fi + if [ "$login" != "$acting" ]; then + echo "review-identity: REVIEW_IDENTITY_LOGIN names '$login' but the acting account is '$acting' — the 🔴 predicate excludes THAT login, so an approving review by '$acting' still satisfies the explicit HUMAN approval. Fix the variable, or run the identity under the account it names. See the code host's implementation guide, section 'Dedicated review identity'." >&2 + return 1 + fi + return 0 + ;; + *) + echo "review-identity: identity kind '${kind:-unknown}' is unknown — cannot establish that it is excluded from the 🔴 human-approval predicate (fail-safe: not excluded)" >&2 + return 1 + ;; + esac +} + +# review_identity_health +# auth_ok : 1 when THIS RUN's credential probe answered 2xx — the token authenticated +# AND the identity is scoped to this repository. Anything else (empty, a +# probe that was not run, a malformed value) is NOT authenticated. +# perms_ok : 1 when THIS RUN observed the required grants — the host guide's per-run, +# ARTIFACT-FREE probes (for an App: the installation-token exchange requested +# with explicit `permissions`, which 422s when the installation lacks one; +# for a bot user: the account's repository permission read). Anything else is +# NOT granted. +# The last two arguments are the exclusion precondition's, forwarded verbatim: the +# provisioned login read back from the host, and the login of the account ACTING on this +# run. On the machine-user form they must MATCH — see `review_identity_exclusion_ok`. +# +# Echoes exactly `1` or `0` — the `healthy` argument of `resolve_identity_mode`, so an +# unhealthy identity becomes `halt` and never a session fallback. Always exits 0. +# +# WHY THIS EXISTS. `healthy` is the single signal that separates `identity` from `halt`, +# i.e. the security-critical decision point of the whole flow, and it had NO defined +# runtime source: the host guide's publication snippet carried an inert +# `PROBES_PASSED=0 # set to 1 by whatever ran step 5's probes` that nothing ever set. Two +# ways to read that, both broken on a CORRECTLY provisioned repository: follow the guide +# literally (its probes are explicitly setup-time — they leave an undeletable check run on +# the head commit) and `healthy` is 0 forever, so every review and every publish HALTs +# with a setup pointer to a setup that is already right; or re-run the setup probes per +# review and every reviewed head permanently carries a neutral `pair-identity-probe` check +# run plus a posted/deleted scratch comment. So the health question is split in two: +# PER RUN (here) — cheap, artifact-free: does the credential authenticate, is it scoped +# to this repository, are the grants observable without writing? +# AT SETUP (once) — the probes that must WRITE to prove a write grant (a check run, a +# scratch comment). They leave artifacts; the guide says run them once. +# What a read probe cannot prove at run time is covered by the third rule, stated in both +# skills and the host guide: a 403/422 met MID-WRITE is a HALT — report it against the +# artifact that failed, never continue with the session token and never leave `pair-review` +# published as if a review had been written. +# +# The exclusion precondition is folded in deliberately: it is part of "may this identity +# act at all", so no caller can compute health while forgetting it. +review_identity_health() { + local kind="${1:-}" auth_ok="${2:-}" perms_ok="${3:-}" login="${4:-}" acting="${5:-}" + + if [ "$auth_ok" != "1" ]; then + echo "review-identity: the identity's credential did not authenticate on this run (auth probe='${auth_ok:-not run}') — NOT healthy. Unknown is never healthy: see the code host's implementation guide, section 'Dedicated review identity', for the per-run probes." >&2 + echo "0" + return 0 + fi + + if [ "$perms_ok" != "1" ]; then + echo "review-identity: the identity's required permissions were not observed on this run (permission probe='${perms_ok:-not run}') — NOT healthy. A missing grant discovered later lands mid-flow, after pair-review was already published." >&2 + echo "0" + return 0 + fi + + if ! review_identity_exclusion_ok "$kind" "$login" "$acting"; then + echo "0" + return 0 + fi + + echo "1" +} + +# identity_verdict_event +# mode : identity | session (a `halt` never reaches here — the caller stops first) +# verdict : approved | changes-requested | +# approve_authorized : 1 when the adoption-gated light row (`light_auto_approve_allowed` +# in pr-state.sh) authorized an APPROVING review on this pull +# request; anything else — including empty and absent — is NOT +# authorized (fail-safe). Read in `identity` mode ONLY: it governs +# the review the IDENTITY would sign on the project's behalf. +# self_authored : 0 when the acting account is provably NOT the pull request's +# author, 1 when it is. Read in BOTH modes, with per-mode DEFAULTS +# that are deliberately asymmetric: +# session — unknown ⇒ SELF-authored (the acting account is +# routinely the author: the solo maintainer, the agent +# reviewing the PR its own account opened) ⇒ COMMENT. +# identity — unknown ⇒ NOT self-authored. Setup forbids using a +# PR-AUTHORING account as the review identity (host +# guide), so the common case is a distinct account; +# defaulting to self-authored here would collapse +# every identity verdict to COMMENT and delete the +# feature. The wrong guess is loud, not silent — the +# host answers 422 and the caller HALTs on the failed +# write (it never publishes `pair-review` as though a +# review had landed). +# +# Echoes the native review event to submit on the code host: +# APPROVE | REQUEST_CHANGES | COMMENT +# +# WHY AN APPROVING VERDICT IS NOT AUTOMATICALLY AN `APPROVE` EVENT. On a repository whose +# branch protection sets `required_approving_review_count >= 1`, a native APPROVE by the +# identity IS what satisfies the host's approvals rule — it makes the pull request +# mergeable with no human action. That outcome is exactly what the adoption-gated light +# row governs, so it is that row, and only that row, that authorizes the APPROVE event. +# An approving verdict outside it is recorded as a COMMENT-form review with the verdict +# token leading the body: the judgment is published in full, it simply does not sign the +# host's approval on the project's behalf. `REQUEST_CHANGES` needs no such gate — it +# blocks, it never unlocks. +# +# WHY `session` MODE IS NOT UNIFORMLY A COMMENT. The COMMENT form there is the workaround +# for exactly ONE host rule: a code host rejects an APPROVE / REQUEST_CHANGES on a pull +# request you authored yourself. That is the SELF-REVIEW case (the solo maintainer, the +# agent reviewing the PR its own account opened) and nothing else. On a two-person team +# with no identity configured — the shipped default — the reviewer is NOT the author, the +# host accepts the native event, and collapsing it to a COMMENT would silently drop a real +# change request (nothing blocks the merge button, no blocking reviewer is recorded) and a +# real approval (nothing the host counts toward `required_approving_review_count`). So the +# session path returns the NATIVE event whenever self-authorship is provably false, and +# COMMENT whenever it is true or unknown. +# +# WHY `identity` MODE READS AUTHORSHIP TOO. The same host rule applies to the identity: if +# the review identity IS the account that opened the pull request — an unattended loop that +# implements and publishes as `acme-bot` and then provisions `acme-bot` as its +# `Review identity: bot-user` — the host rejects the native event +# (`422 Can not request changes on your own pull request`). The verdict would never land as +# a review while the check publication (a separate step) still marked `pair-review` +# `success`: an approving verdict recorded as a green required check on a pull request +# carrying NO review body. The setup rule is the primary containment (the identity must not +# author pull requests in the repository — host guide); this arm is the mechanical one, and +# it degrades to the COMMENT form rather than losing the verdict. +identity_verdict_event() { + local mode="${1:-}" verdict="${2:-}" approve_authorized="${3:-0}" self_authored="${4:-}" + + case "$mode" in + identity) + if [ "$self_authored" = "1" ]; then + echo "review-identity: the review identity is the AUTHOR of this pull request — the host rejects a self-authored APPROVE/REQUEST_CHANGES, so the verdict is submitted as COMMENT with its token leading the body. Fix the setup: the review identity must not be an account that opens pull requests in this repository (see the code host's implementation guide, section 'Dedicated review identity')." >&2 + echo "COMMENT" + return 0 + fi + case "$verdict" in + approved) + if [ "$approve_authorized" = "1" ]; then + echo "APPROVE" + else + echo "review-identity: the verdict is APPROVED but no adoption-gated light row authorized an approving review — submitting as COMMENT, so the identity never satisfies the host's required-approvals rule on the project's behalf" >&2 + echo "COMMENT" + fi + ;; + changes-requested) echo "REQUEST_CHANGES" ;; + *) + echo "review-identity: verdict '${verdict:-unknown}' is not a decision — submitting as COMMENT, never as an approval (fail-safe)" >&2 + echo "COMMENT" + ;; + esac + ;; + session) + if [ "$self_authored" != "0" ]; then + echo "review-identity: the acting session account authored this pull request (self_authored='${self_authored:-unset}'; unknown is treated as self-authored, fail-safe) — the host rejects a self-authored APPROVE/REQUEST_CHANGES, so the verdict is submitted as COMMENT with its token leading the body" >&2 + echo "COMMENT" + return 0 + fi + case "$verdict" in + approved) echo "APPROVE" ;; + changes-requested) echo "REQUEST_CHANGES" ;; + *) + echo "review-identity: verdict '${verdict:-unknown}' is not a decision — submitting as COMMENT, never as an approval (fail-safe)" >&2 + echo "COMMENT" + ;; + esac + ;; + *) + echo "review-identity: mode '${mode:-unknown}' is not a resolved actor — submitting as COMMENT, never a native verdict (fail-safe)" >&2 + echo "COMMENT" + ;; + esac +} + +# pair_review_publication_mode +# identity_kind : app | user | bot-user | +# same vocabulary as `review_identity_exclusion_ok`: `bot-user` is the +# adoption literal, `user` its short form, and both are machine-user +# forms that the Checks API rejects. +# +# Echoes: checks-api | commit-status. +# +# The Checks API is writable ONLY by a GitHub App installation token; an ordinary +# user token or PAT gets `403`. So the check-run form is available exactly when an +# App identity is resolved, and every other combination — a bot user under either +# spelling, an identity of unknown kind, no identity at all — keeps the commit status +# the flow already ships. Only `app` is matched positively, so a new or misspelled kind +# degrades to the commit status rather than attempting a write it cannot perform. +pair_review_publication_mode() { + if [ "${1:-}" = "identity" ] && [ "${2:-}" = "app" ]; then + echo "checks-api" + return 0 + fi + echo "commit-status" +} + +# identity_audit_comment +# action : approve | comment | block (every identity action is audited — the +# approving review the light row authorized, the COMMENT-form approving +# verdict it did not, and the block) +# tag : the tag that drove it (e.g. `light`), or empty when none did +# declared : 1 when adoption declares that tag family in `## Tag Projection` +# tier : the tier read from the pull request's `risk:*` label +# state : the `resolve_pr_state` synthesis +# +# Echoes ONE deterministic audit line. It is a projection of the inputs, never a +# summary an agent writes from memory: the reason a pull request was approved or +# blocked by the identity has to be reconstructable from the comment alone, and it has +# to name the TAG and the DECLARATION rather than any judgment about the change. +identity_audit_comment() { + local action="${1:-}" tag="${2:-}" declared="${3:-0}" tier="${4:-}" state="${5:-}" + local declaration="not declared" + [ "$declared" = "1" ] && declaration="declared in ## Tag Projection" + + printf 'pair review identity — %s. Tag: %s (%s) · tier: %s · PR state: %s. Inputs are tags, gate results and the review verdict only; nothing here classifies the change (D18). An identity approval never satisfies the explicit human approval required at risk:red: an App identity is rejected by `human_approval_jq_filter`'"'"'s `user.type == "User"` clause, a bot-USER identity (which does type as "User") by its login clause against REVIEW_IDENTITY_LOGIN.\n' \ + "${action:-unknown}" "${tag:-none}" "$declaration" "${tier:-unknown}" "${state:-unknown}" +} diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index dc92de91d..236b017f9 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -664,10 +664,451 @@ Stated plainly rather than assumed away: with neither setting applied, `pair-exp ```bash REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner)" # owner/repo — matches `github.repository` PR= -HEAD_SHA="$(gh pr view "$PR" --json headRefOid -q .headRefOid)" +# `--repo "$REPO"` like every other call here: unpinned, `gh pr view` resolves the number +# against the CWD's `origin`, which is not `$REPO` whenever `REPO` was supplied from CI +# (`github.repository`) or the block runs from an agent worktree. +HEAD_SHA="$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)" ``` -**Token prerequisite (why a commit status, not a check run).** The Checks API (`POST /repos/{owner}/{repo}/check-runs`) is writable **only by a GitHub App installation token**: with an ordinary user token or PAT it answers `403 You must authenticate via a GitHub App`. The skills that publish the verdict (`/pair-capability-publish-pr` Phase 5, `/pair-process-review` Step 5.4) run agent-side with exactly that ordinary token, so a check run is not an option for them. The **commit-statuses API** accepts the same token and branch protection treats a status **context** as a required check identically. The token needs `repo:status` (classic PAT) / `Commit statuses: write` (fine-grained); inside a workflow that is `permissions: statuses: write`. If a project does publish through a GitHub App instead, keep the check-run form — but then the publication must happen inside a workflow holding `checks: write`, plus a relay that carries the agent's verdict there. +**Token prerequisite (why a commit status, not a check run).** The Checks API (`POST /repos/{owner}/{repo}/check-runs`) is writable **only by a GitHub App installation token**: with an ordinary user token or PAT it answers `403 You must authenticate via a GitHub App`. The skills that publish the verdict (`/pair-capability-publish-pr` Phase 5, `/pair-process-review` Step 5.4) run agent-side with exactly that ordinary token **whenever no dedicated review identity is configured** — the default — so on that path a check run is not an option for them. The **commit-statuses API** accepts the same token and branch protection treats a status **context** as a required check identically. The token needs `repo:status` (classic PAT) / `Commit statuses: write` (fine-grained); inside a workflow that is `permissions: statuses: write`. + +**The App path is the documented exception, and it needs no relay.** With `Review identity: app` configured, the same skills mint an **installation token** agent-side (§ [Dedicated review identity](#dedicated-review-identity), step 4) and POST `/check-runs` directly with it — `pair_review_publication_mode` is what routes them there. Read the rest of this section as the `session`-token case; nothing below requires a workflow or a verdict relay. + +### Dedicated review identity + +**Optional, and off by default.** With nothing configured the flow runs exactly as documented above: the session token writes, `pair-review` is a commit status, and the verdict is the native review action unless that account authored the pull request (self-review — GitHub rejects a self-approval, so it degrades to a `--comment` review). That is `Review identity: none` in [way-of-working.md](../../../../adoption/tech/way-of-working.md) and it is **not a degradation** — it is the zero-configuration mode. + +A **dedicated review identity** is a second principal — a GitHub App installation, or a bot user account — whose credential the review flow uses for its code-host writes instead of the session token. Provisioning it is **project infrastructure**: a registration/seat and a secret, which no skill can create for you. What the flow does is _consume_ it, through the host-agnostic adapter [`review-identity.sh`](../../../assets/review-identity.sh) (`review_identity_kind_ok`, `resolve_identity_mode`, `review_identity_exclusion_ok`, `review_identity_health`, `identity_verdict_event`, `pair_review_publication_mode`, `identity_audit_comment` — seven entry points; `review_identity_exclusion_ok` is the security-critical one, since it is what makes the bot-user 🔴 exclusion a **checked precondition** rather than prose, and a host adapter wired without it lets a bot-user identity with no `REVIEW_IDENTITY_LOGIN` resolve to `identity` and sign the 🔴 approval). The model is in [pr-states.md](pr-states.md); only the GitHub specifics live here (R2.12). + +**What it buys:** + +| | `Review identity: none` (default) | `bot-user` | `app` (recommended) | +| --- | --- | --- | --- | +| Verdict | native review action; `--comment` on a **self-authored** PR, which the host rejects | native **REQUEST_CHANGES**; native **APPROVE** only where the light row authorizes it, `--comment` otherwise | native **REQUEST_CHANGES**; native **APPROVE** only where the light row authorizes it, `--comment` otherwise | +| `pair-review` | commit status | commit status | **check run** (the Checks API needs an App token) | +| Audit | "who reviewed" is a token in the review body | per-identity in the host's review events | per-identity in review events **and** check runs | +| 🔴 explicit approval | still a second **human** | still a second **human** — but only once `REVIEW_IDENTITY_LOGIN` is provisioned (below): this account types as `"User"` | still a second **human**, by account type — nothing to configure | + +**The last row is the point, and it does not move — but the two forms are excluded by two different clauses, and only one of them is free.** `pair-explicit-approval` counts approvals matching `human_approval_jq_filter`. + +| Identity form | `user.type` on `GET /pulls/{n}/reviews` | What excludes it from the 🔴 predicate | +| --- | --- | --- | +| `app` (App installation) | `"Bot"` | the **type clause** (`user.type=="User"`) — nothing to configure | +| `bot-user` (machine user account) | `"User"` — **an ordinary user, indistinguishable by type** | the **login clause** (`.user.login != env.REVIEW_IDENTITY_LOGIN`) — inert until you provision the login | + +Only a GitHub **App** types as `"Bot"`. A bot _user_ is an ordinary account: without the login clause its approving review satisfies `pair-explicit-approval` exactly like a human's, and a `risk:red` PR with green gates and an APPROVED verdict would reach `ready-to-merge` with no human involvement at all. That is why `Review identity: bot-user` **requires** `REVIEW_IDENTITY_LOGIN` (below), why `review_identity_exclusion_ok` treats a bot-user identity without it as **not healthy** (⇒ `halt`), and why this is a mechanism rather than a rule stated in prose. With it in place, adopting an identity never relaxes the 🔴 rule and a `risk:red` pull request still needs a second human account (ADR-018, amendment 2026-08-28). Two mechanisms live side by side deliberately — the identity signs the ordinary review, a human signs the 🔴 one. + +**MANDATORY for BOTH forms — the identity must NOT be an account that opens pull requests in this repository.** Provision a **separate** principal for reviewing, even where an automation already runs under a machine account. This is not a bot-user-only rule: a GitHub App authors pull requests as `[bot]` — the login shape Dependabot appears under — so the cheapest setup, ONE credential for the whole unattended pipeline — the agent that opens the pull request and the review flow alike — hits the rule exactly as a shared bot user does. GitHub rejects a self-authored review action (`422 Can not request changes on your own pull request`), so an identity that is also the PR author cannot deliver the verdict as a review at all: `identity_verdict_event` degrades it to the COMMENT form, and the native APPROVE the light row would authorize is unobtainable on every PR that account opened. The concrete cases are an unattended-delivery project that implements and publishes as `acme-bot` and then declares `Review identity: bot-user` pointing at the same `acme-bot`, and the same project with one App named as both publisher and `Review identity: app`. **Each form's per-run health probe checks it** — the App path in step 6 probe 3, the bot-user path in its `ACTING` comparison — so the misconfiguration is a `halt` before any host write rather than a `422` discovered mid-review. The App probe compares **both** login shapes `gh` can return for one Bot actor: `app/` from the GraphQL read (`gh pr view --json author`) and `[bot]` from the REST one (`gh api repos/{owner}/{repo}/pulls/{n} --jq .user.login`). Comparing one shape only leaves the gate inert on the path that emits the other, which is the same mid-write `422` with an extra step. + +**MANDATORY when CHANGING `Review identity` on a repository with pull requests already open — drain them first.** `pair-review` is **dual-form**: a check run on an `app` identity, a commit status on every other mode (`pair_review_publication_mode`). The form is resolved **independently** by `/pair-capability-publish-pr` at PR creation and by `/pair-process-review` at Step 5.4, so a pull request published under one value of `Review identity` and reviewed under another ends up with two independent records under **one required context** — a PR opened while `none` keeps a `pending` **commit status** named `pair-review`, and the later App review publishes a **check run** of the same name. Which record branch protection honours is host-defined; if it honours the stale pending status the PR is unmergeable with nothing in the flow to clear it. Same rule as `pair-explicit-approval`'s (§ [PR state flow — required checks & branch protection](#pr-state-flow--required-checks--branch-protection)): **one producer per required context**. Two exits, and a project must take one: + +- **Drain (recommended, and the only exit that always works)** — merge or close every open pull request before changing `Review identity`, so no head carries a record in the outgoing form. +- **Supersede the outgoing form** — after publishing `pair-review` in the resolved form, overwrite the other form on the same head with the same conclusion, so exactly one record stays authoritative: + + ```bash + # $STATE is `review_check_conclusion`'s answer (`success` / `failure`); $HEAD_SHA / $REPO + # are the section's shared variables. Run this ONLY on the enablement transition. + if [ "$(pair_review_publication_mode "$MODE" "$IDENTITY_KIND")" = checks-api ]; then + # `none`/`bot-user` ➝ `app`: a commit status from the earlier publish would stay pending + # forever. TWO changes, not one, and both are needed: `Commit statuses: write` on the App + # registration (step 1) AND `"statuses":"write"` added to step 4's `permissions` payload. + # An installation access token carries ONLY the subset that payload requests, so with the + # grant alone this POST answers `403 Resource not accessible by integration`, the stale + # pending status survives, and the pull request this rule exists to unblock stays blocked. + # Make both changes ONLY while taking this exit: step 4 requests `permissions` explicitly + # and GitHub 422s a permission the installation was never granted, so asking for + # `statuses` before granting it breaks the mint — i.e. every review, not just this one. + gh api "repos/$REPO/statuses/$HEAD_SHA" -X POST -f context='pair-review' \ + -f state="$STATE" -f description='superseded by the pair-review check run' + else + # `app` ➝ `none`/`bot-user`: conclude a check run the retired App left on this head. + # The Checks API is App-only, so this arm needs the OLD App token — if it is already + # revoked, draining is the only remaining exit. + CR="$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs" \ + --jq '.check_runs[] | select(.name=="pair-review") | .id' | head -1)" + [ -n "$CR" ] && gh api "repos/$REPO/check-runs/$CR" -X PATCH \ + -f status=completed -f conclusion="$STATE" + fi + ``` + +#### GitHub App (recommended) + +Recommended because it is the only form that unlocks the Checks API, and because an App installation token is scoped to the repository rather than to a person's whole account. + +1. **Register** the App (Settings → Developer settings → GitHub Apps → New). Repository permissions, and nothing more: + - `pull_requests: write` — submit the native review, post the audit comment + - `checks: write` — publish `pair-review` as a check run + - `contents: read` — read the branch under review + - `metadata: read` (mandatory for every App) + - `statuses: write` — **conditional, NOT part of the baseline**: required only by the **supersede** exit of the enablement-transition rule above (clearing the pending `pair-review` **commit status** an earlier `none`/`bot-user` publish left on an open head). Grant it **and** add `"statuses":"write"` to step 4's `permissions` payload **together** — the grant alone is inert, since the token carries only what that payload requests. Take the **drain** exit and neither is needed; add the payload entry without the grant and the mint 422s on every run. + - Do **not** grant `administration` — the identity must not be able to edit branch protection. +2. **Install** it on the repository (Settings → GitHub Apps → Install), and note the installation id. +3. **Store the credential** per the [security guidelines](../../quality-assurance/security/security-guidelines.md): the App's private key is a secret and **never enters the repository** — no `.pem` committed, no key in an adoption file, no key in a skill argument. Put it in the project's secret store (GitHub Actions secret, or the local secret manager the project already uses) and reference it by name. The repository's deterministic secret scan (D24) is the backstop, not the policy. +4. **Mint the installation token.** Everything below runs as the App's **installation token**, which is not the private key and not a PAT: it is exchanged for one, expires in an hour, and is what `GH_TOKEN` must hold. Inside a workflow, use an action that performs the exchange (`actions/create-github-app-token@v1` with `app-id` + `private-key`, output `token`). Outside one, do it by hand — a JWT signed with the private key, then the installation's access-token endpoint: + + ```bash + # $APP_ID, $INSTALLATION_ID (from step 2), $PRIVATE_KEY_PEM — from the secret store, never the repo. + b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + NOW=$(date +%s) + HEADER="$(printf '{"alg":"RS256","typ":"JWT"}' | b64)" + PAYLOAD="$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((NOW - 60))" "$((NOW + 540))" "$APP_ID" | b64)" + SIG="$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -sign <(printf '%s' "$PRIVATE_KEY_PEM") | b64)" + JWT="$HEADER.$PAYLOAD.$SIG" + # curl, not `gh api`: the exchange is the one call authenticated with the App JWT rather + # than a token, and GitHub documents it with an explicit `Authorization: Bearer ` + # header. Do not assume gh's own auth scheme is accepted here — a 401 at this step is + # indistinguishable from a bad signature and costs an hour of setup debugging. + # `permissions` is requested EXPLICITLY: GitHub answers 422 when the installation was + # never granted one of them, which makes the exchange itself the run-time write-grant + # probe (step 6) — a read-only grant then fails at mint time, not mid-review. + # It cuts both ways: the token carries ONLY this subset, so a permission granted on the + # App but absent HERE is not in the token and its first write 403s. The baseline below is + # exactly what the flow writes; the supersede exit of the enablement-transition rule is + # the one documented addition — append `"statuses":"write"` while taking that exit, and + # only once the App holds the grant (an ungranted request 422s the mint for every run). + TOKEN_JSON="$(curl -sS -X POST \ + -H "Authorization: Bearer $JWT" -H 'Accept: application/vnd.github+json' \ + -d '{"permissions":{"pull_requests":"write","checks":"write","contents":"read"}}' \ + "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens")" + # SAVE THE SESSION CREDENTIAL FIRST. The `export GH_TOKEN` below replaces it + # PROCESS-GLOBALLY, and the flow still needs it: the `pr-state:*` label is written by the + # SESSION token in every mode (it is a board view, not one of the identity's three + # attributed writes, and the App baseline above does not request the `issues` grant the + # labels endpoint needs). Overwrite it unsaved and that write is refused — a refusal the + # flow declares NON-BLOCKING, so every review on the App path reports `pr-state label: + # not applied` and the board view the labels drive stays permanently empty. Scope the + # label write to this variable: `GH_TOKEN=$SESSION_GH_TOKEN gh pr edit …`. + SESSION_GH_TOKEN="${GH_TOKEN:-}" + export SESSION_GH_TOKEN + GH_TOKEN="$(printf '%s' "$TOKEN_JSON" | jq -r '.token // empty')" + export GH_TOKEN # every `gh` call below uses this + # The App's own SLUG, captured here because `GET /app` is a JWT endpoint (the + # installation token answers 403). It is what the App authors pull requests as — + # `[bot]` — so step 6's probe 3 needs it to check the identity is not this PR's + # author. Capture it now or that probe cannot run. + APP_SLUG="$(curl -sS -H "Authorization: Bearer $JWT" -H 'Accept: application/vnd.github+json' \ + https://api.github.com/app | jq -r '.slug // empty')" + export APP_SLUG + ``` + + The token is short-lived by design: mint it per run, never store it. Inside a workflow the same explicit request is `permission-pull-requests: write` + `permission-checks: write` + `permission-contents: read` on `actions/create-github-app-token@v1` (plus `permission-statuses: write` **only** while taking the supersede exit, matching the payload above), and the step fails the same way when a grant is missing; that action also exposes the slug as its `app-slug` output, which is `$APP_SLUG` on that path. + +5. **Verify ONCE, at setup** — the probes that can only be answered by WRITING. A read probe cannot prove a write grant, and a `403` discovered later lands mid-flow, after `pair-review` was already published. Every probe below runs with that installation token, and none of them runs per review (step 6 is the per-run health check): + + ```bash + REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner)" + PR= # an open PR to probe against + # PINNED, like the `repos/$REPO/…` calls below: unpinned this reads the CWD's `origin`, + # so a `$REPO` supplied from CI (`github.repository`) that differs from the checkout's + # origin yields a FOREIGN head SHA and probe 1 answers `422 No commit found for SHA` — + # an error the diagnosis below does not cover, met while debugging App setup. + HEAD_SHA="$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)" + + # 1. checks: write — leaves a `pair-identity-probe` check run on $HEAD_SHA (see the note below). + gh api "repos/$REPO/check-runs" -X POST -f name=pair-identity-probe \ + -f head_sha="$HEAD_SHA" -f status=completed -f conclusion=neutral # 201 ⇒ checks: write + + # 2. pull_requests: WRITE — a read probe cannot prove it. Post and delete a scratch + # PR comment (issue comments and reviews share the `pull_requests` permission). + CID="$(gh api "repos/$REPO/issues/$PR/comments" -X POST -f body='pair identity write probe' --jq .id)" + gh api "repos/$REPO/issues/comments/$CID" -X DELETE # 204 ⇒ write granted + ``` + + **These probes leave artifacts on a real pull request**: the check run stays on `$HEAD_SHA` (check runs cannot be deleted — pick a scratch PR, or accept a `neutral` `pair-identity-probe` entry in the checks list), and the write probe creates then deletes a comment. **Run them once at setup, never per review** — that is precisely why they are not the flow's health input. + + A `403` on the check-run probe means `checks: write` was not granted or the App is not installed on this repository; a `403` on the write probe means the grant is `pull_requests: read`. Either is a **configured-but-broken** identity, and the flow HALTs on it rather than falling back to the session user. + +6. **Check the identity's health on EVERY run** — this is `resolve_identity_mode`'s `healthy` input, and it is computed on the run that uses it, never remembered from setup. All three probes are **cheap and artifact-free**: they write nothing to the pull request, so they can run before every review and every publish. + + ```bash + # 1. AUTH_OK — the credential authenticates AND the identity is scoped to THIS repo. + # `gh api user` does NOT work here: an installation token is not associated with a + # user and answers `403 Resource not accessible by integration`. + # MEMBERSHIP, not reachability: the token is scoped to the INSTALLATION, which is + # org-wide, so `/installation/repositories` answers 200 in a repository the App was + # never installed on. Testing `.total_count` there passes health, runs the whole + # review, and 404s on the FIRST host write — into the mid-write HALT, with a + # diagnostic blaming a revoked grant. `$REPO` (the section's shared variable, + # `owner/repo`) must appear in the list. + # `-F` is load-bearing: without it `grep -qx` reads `$REPO` as a BASIC REGEX, and a + # repository name routinely contains `.`. `$REPO=acme/pair.js` then matches a listed + # `acme/pairXjs` — membership asserted on a repository the App was never installed on, + # straight back into the 404 this probe exists to rule out. + AUTH_OK=0 + gh api /installation/repositories --paginate --jq '.repositories[].full_name' 2>/dev/null | + grep -Fqx "$REPO" && AUTH_OK=1 + + # 2. PERMS_OK — the grants, observed WITHOUT writing: step 4's exchange IS the + # write-grant probe, because it requested `permissions` explicitly and GitHub 422s + # when the installation lacks one. Read the answer it already returned. + PERMS_OK=0 + [ -n "${GH_TOKEN:-}" ] && + [ "$(printf '%s' "$TOKEN_JSON" | jq -r '.permissions.pull_requests')" = write ] && + [ "$(printf '%s' "$TOKEN_JSON" | jq -r '.permissions.checks')" = write ] && PERMS_OK=1 + + # 3. The identity must not be the PR's own AUTHOR (the MANDATORY rule for both forms, + # above). An App CAN author pull requests, so this is CHECKED here, never assumed + # away. Reading the author and not comparing it would leave the one-App setup (same + # credential publishes the PR and reviews it) to fail on the first write instead. + # TWO LOGIN SHAPES, and both are compared, because `gh` renders a Bot actor + # differently per API: `gh pr view --json author` goes through + # GraphQL and answers + # `app/`, while the REST read (`gh api "repos/$REPO/pulls/$PR" --jq + # .user.login`) answers `[bot]`. MEASURED on a public App-authored PR: + # gh pr view 14276 --repo cli/cli --json author -q .author.login ⇒ app/dependabot + # gh api repos/cli/cli/pulls/14276 --jq .user.login ⇒ dependabot[bot] + # Comparing against ONE shape leaves the gate INERT on the path that emits the other: + # health passes, the whole review runs, and only the final submission answers + # `422 Can not request changes on your own pull request` — a mid-write HALT on every + # review. A bot-user identity is a plain User and compares literally on both paths. + # $APP_SLUG comes from step 4: `GET /app` is a JWT endpoint, so the installation + # token cannot read it here. Unknown slug ⇒ unknown health ⇒ not healthy. + # IT PRINTS ITS OWN REASON. This probe encodes a THIRD, distinct failure while + # reusing $PERMS_OK — the flag that means "the required grants were OBSERVED". A + # silent zero therefore makes `review_identity_health` emit the grant-shaped + # diagnostic ("the identity's required permissions were not observed on this run"), + # which points the operator of the one-credential pipeline — the likeliest + # misconfiguration, and the one this probe exists for — at App grants that are + # correct, with nothing in the trail naming authorship. + # AN UNREADABLE AUTHOR IS NOT "NOT THE AUTHOR". If the read fails (network, a wrong + # $PR, a token that cannot read the pull request) $PR_AUTHOR is empty, the `case` + # matches neither shape, and a silent pass would let the one-credential pipeline — + # the likeliest misconfiguration, and the one this probe exists for — reach the + # native review and meet `422 Can not request changes on your own pull request` + # mid-write, the expensive diagnosis this probe promises to prevent. Unknown + # authorship is unknown health, exactly as an unset $APP_SLUG is below. + if ! PR_AUTHOR="$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login)" || [ -z "$PR_AUTHOR" ]; then + echo "review-identity: the pull request's author could not be read, so the author comparison could not run — unknown authorship is unknown health. Not a grant problem: check \$PR and the token's access to this pull request." >&2 + PERMS_OK=0 + else + case "$PR_AUTHOR" in + "app/${APP_SLUG:-}" | "${APP_SLUG:-}[bot]") + echo "review-identity: the identity ($PR_AUTHOR) is this pull request's AUTHOR — not a grant problem. See § Dedicated review identity, MANDATORY for BOTH forms: the review identity must not be an account that opens pull requests in this repository." >&2 + PERMS_OK=0 + ;; + esac + fi + if [ -z "${APP_SLUG:-}" ]; then + echo "review-identity: \$APP_SLUG is unset, so the author comparison could not run — unknown authorship is unknown health. Capture the slug at mint time (step 4: GET /app is a JWT endpoint)." >&2 + PERMS_OK=0 + fi + ``` + + **A `403`/`422` met MID-WRITE is a HALT, not a fallback.** These probes make that rare, not impossible (a grant can be revoked between the probe and the write). If any identity write in the flow is refused, report it against the artifact that failed, stop, and point at this section — never retry with the session token, and never publish `pair-review` as though the review had landed. **The one exception is the `pair-review` publication itself**: a refusal there is reported `pair-review: NOT PUBLISHED — advisory` and the flow continues (the consumer skills' Graceful Degradation), since the verdict still lives in the native review and enforcement is simply advisory until publication works. + +7. **Publish `pair-review` as a check run** on the App path (the commit-status form stays exactly as documented above for every other path): + + ```bash + source .pair/knowledge/assets/review-identity.sh + source .pair/knowledge/assets/pr-state.sh + + # The three inputs, from their real sources — none of them is ambient. + # 1. IDENTITY_KIND — the `Review identity` value in adoption, forwarded VERBATIM. The + # read is TWO questions, deliberately: is the key THERE, and does its value PARSE. + WOW=.pair/adoption/tech/way-of-working.md + # 1a. PRESENCE — anchored to a KEY at the START OF A LINE, with the DECORATIONS an + # adopter plausibly hand-writes it in ENUMERATED, since the HALT below hangs off this + # answer: blockquote markers (`>`, repeatable), an ATX heading (`#`..`######`), a list + # bullet (`-`/`*`), bold markers, in that order, any of them omitted. So all of + # `- **Review identity**: app`, `* Review identity: app`, `## Review identity: app` + # and `> - **Review identity**: app` answer PRESENT. A shape outside this set reads as + # ABSENT ⇒ `none` ⇒ `session` — the silent session-token fallback this read exists to + # prevent — so extend the class rather than assume it covers whatever an adopter + # wrote. It is NOT format-agnostic about + # POSITION: a bare `grep -qi 'Review + # identity'` matches PROSE ("we use no dedicated review identity — reviews run with + # the session token"), and a phrase-then-colon match that is not line-anchored still + # matches prose mid-sentence ("A note on review identity: we deliberately run none"). + # Either way the extraction is empty and the HALT below fires on a project that + # configured nothing — a permanent review outage pointing at a key it never wrote. + # The line anchor still catches both unparseable shapes the design must HALT on: + # `- Review identity: app` (no bold) and `**Review identity**: bot-user` (no bullet) + # are line-leading, colon-terminated keys. + IDENTITY_KEY_PRESENT=0 + grep -qiE '^[[:space:]]*(>[[:space:]]*)*(#{1,6}[[:space:]]*)?[-*]?[[:space:]]*\*{0,2}Review identity\*{0,2}[[:space:]]*:' "$WOW" && IDENTITY_KEY_PRESENT=1 + # 1b. VALUE — the shipped form is a markdown BULLET with bold markers and a backticked + # value (`- **Review identity**: `app` — ...`), so strip bullet, bold and backticks + # and keep the bare kind. An expression anchored at `^Review identity:` matches nothing. + IDENTITY_KIND="$(sed -n 's/^-[[:space:]]*\*\*Review identity\*\*:[[:space:]]*`\{0,1\}\([a-z-]*\).*/\1/p' "$WOW" | head -1)" + # 1c. PRESENT BUT UNPARSEABLE IS NOT `none`. `- Review identity: app` (no bold) and + # `**Review identity**: bot-user` (no bullet) both extract to EMPTY. Defaulting that + # to `none` would mean "no identity configured" ⇒ MODE=session ⇒ the review, and on a + # host that accepts it the APPROVE, written with the SESSION token on a repository + # that provisioned an identity — and no HALT, because the flow never learned one was + # configured. So the vocabulary is checked by the adapter (one source, no drift with + # `review_identity_exclusion_ok` / `pair_review_publication_mode`), and only a + # genuinely ABSENT key becomes `none`. + # ORDER THE CHECK ON PRESENCE, not on the value. On the DEFAULT path — the key + # genuinely absent, every project that has not opted in — the extraction is empty, + # and calling the validator with it prints its own HALT-flavoured diagnostic + # ("`empty` is not a Review identity value … HALT and fix the key") before the + # fallback assigns `none`: an alarming, contradicted line in the trail of every + # review and every publish on a correctly configured repository, naming a key the + # project deliberately does not have. The validator is for a key that IS there. + if [ "$IDENTITY_KEY_PRESENT" = 1 ]; then + if ! review_identity_kind_ok "$IDENTITY_KIND"; then + echo "review-identity: $WOW carries a 'Review identity' key whose value does not parse — configured-but-unusable, HALT (never 'none'). Write it as the shipped bullet: - **Review identity**: \`app\` — see this section." >&2 + exit 1 + fi + else + IDENTITY_KIND=none + fi + # 2. IDENTITY_CONFIGURED — 1 for any value other than `none`. + IDENTITY_CONFIGURED=0 + [ "$IDENTITY_KIND" != none ] && IDENTITY_CONFIGURED=1 + # 3. IDENTITY_HEALTHY — computed on THIS run from step 6's artifact-free probes + # ($AUTH_OK, $PERMS_OK), never remembered from setup: step 5's probes leave + # undeletable artifacts, so they cannot run per review, and nothing persists their + # result. The adapter folds in the exclusion precondition and answers 1 or 0; + # unknown is never healthy, so a repository that skipped step 6 gets `halt`, not a + # silent session fallback. + # THE LOGIN ARGUMENT IS $RV — the value the per-run probe READ BACK from the + # repository variable, which is what `${{ vars.REVIEW_IDENTITY_LOGIN }}` resolves to + # in the `pair-explicit-approval` job. Passing an ambient `$REVIEW_IDENTITY_LOGIN` + # here would let a set-in-the-shell-only login satisfy the exclusion precondition + # while the gate's clause stays inert (see the bot-user probe below). On the App path + # the argument is not read at all — an App is excluded by account type — so `$RV` + # being empty there is correct and harmless. + # THE ACTING ARGUMENT IS $ACTING — the login the identity's OWN credential answers + # with on this run (`gh api user --jq .login`, set by the bot-user probe). The adapter + # compares the two on the machine-user form: a provisioned login that names a + # DIFFERENT account (rotated seat, typo in the variable) excludes that other login, + # while the account actually acting stays inside the 🔴 human predicate. Unset ⇒ not + # excluded ⇒ not healthy. On the App path it is not read, like `$RV`. + IDENTITY_HEALTHY=0 + if [ "$IDENTITY_CONFIGURED" = 1 ]; then + IDENTITY_HEALTHY="$(review_identity_health "$IDENTITY_KIND" "${AUTH_OK:-0}" \ + "${PERMS_OK:-0}" "${RV:-}" "${ACTING:-}")" + fi + + MODE="$(resolve_identity_mode "$IDENTITY_CONFIGURED" "$IDENTITY_HEALTHY")" + [ "$MODE" = halt ] && exit 1 + # $REPO / $HEAD_SHA: the section's shared variables (above). $VERDICT / $VERDICT_SUMMARY: + # the review decision and its one-line summary, from the review flow's decision step. + STATE="$(review_check_conclusion "$VERDICT")" + [ "$STATE" = pending ] && { echo "no decision yet — leaving the pending check in place"; exit 0; } + if [ "$(pair_review_publication_mode "$MODE" "$IDENTITY_KIND")" = checks-api ]; then + gh api "repos/$REPO/check-runs" -X POST \ + -f name='pair-review' -f head_sha="$HEAD_SHA" \ + -f status=completed -f conclusion="$STATE" \ + -f 'output[title]=pair review' -f "output[summary]=$VERDICT_SUMMARY" + fi + ``` + + The same `pending`-first discipline applies: the pending check is registered at PR creation, and only a real decision resolves it. **One producer per required context**: this snippet publishes the check run and nothing else, so on a head that already carries a `pair-review` **commit status** from a publish that ran before `Review identity` became `app`, add the supersede write from the enablement-transition rule above — or drain the open pull requests before switching, which is the exit that needs no extra grant. + +#### Bot user (alternative) + +A second GitHub **user** account, invited to the repository with **write** access (never admin), authenticated with its own fine-grained PAT: + +- `Pull requests: write` (`pull_requests: write`) — the native review and the audit comment +- `Commit statuses: write` (`repo:status` on a classic PAT) — publish `pair-review` as a commit status +- `Contents: read` +- `Variables: read` (`actions_variables:read` on a fine-grained PAT; covered by `repo` on a classic one) — the only grant here that no WRITE needs, and it is **required**: the per-run health probe below reads `REVIEW_IDENTITY_LOGIN` back from the repository variables **with this token**. The `gh variable set`/`gh variable get` further down runs under the MAINTAINER's token, so provisioning succeeds without it and the gap surfaces only at review time — the read answers `403 Resource not accessible by personal access token`, health resolves `0`, and every review and every publish on the repository HALTs with the setup otherwise complete + +Same secret rule: the PAT lives in the secret store, never in the repository. It costs a seat on paid plans and it does **not** unlock the Checks API (that is App-only), so `pair_review_publication_mode` keeps it on the commit-status form. A user token _is_ associated with a user, so here `gh api user --jq '.login, .type'` is the right probe (expect `"User"`). + +The section's MANDATORY rule applies here in full — this account must not open pull requests in this repository — and the last line of the probe below is what enforces it. + +**Per-run health, artifact-free** (this form's equivalent of the App path's step 6 — the `healthy` input, computed on every run): + +```bash +ACTING="$(gh api user --jq .login)" # 200 ⇒ PAT valid +# THE LOGIN COMES FROM THE REPOSITORY VARIABLE, READ BACK ON THIS RUN — never from the +# agent's ambient environment. The 🔴 clause this health check exists to arm is evaluated +# in the `pair-explicit-approval` workflow from `${{ vars.REVIEW_IDENTITY_LOGIN }}` (below), +# so the variable is the ONLY value that can make the clause fire. Gating on an exported +# `$REVIEW_IDENTITY_LOGIN` instead would pass health on a repository where `gh variable set` +# was never run (or where the login was stored as a SECRET, or scoped to an Environment +# this `pull_request_target` job does not use): the flow would run as `identity` while the +# gate resolved the variable to the empty string, making the clause `.user.login != ""` — +# true for EVERY account — so an APPROVED review by the bot on a `risk:red` head would +# satisfy the explicit HUMAN approval. Read the variable, or the identity is not healthy. +# The read needs `Variables: read` on this PAT (the grant list above) — the one grant no +# WRITE needs, so a PAT provisioned for the writes alone answers 403 HERE and nowhere else. +# ITS EXIT STATUS IS CAPTURED, never swallowed with `|| true`: a REFUSED read (403, grant +# missing), an UNSET variable (404) and a genuinely dead credential would otherwise all +# collapse into AUTH_OK=0, and `review_identity_health` would then report "the identity's +# credential did not authenticate on this run" about a credential that answered 200 one +# line above — sending the operator to re-issue a PAT that is fine while the actual cause +# (a missing read grant, or a variable never set) appears nowhere in the trail. Each cause +# PRINTS ITS OWN REASON before zeroing the flag, the rule the authorship check below follows. +AUTH_OK=0 +RV="" +if [ -z "$ACTING" ]; then + : # No login: the PAT itself did not authenticate. This is the ONE case health's own + # auth diagnostic fits, so let it speak — nothing to add here. +elif ! RV="$(gh api "repos/$REPO/actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value 2>/dev/null)"; then + RV="" + echo "review-identity: REVIEW_IDENTITY_LOGIN could not be READ BACK from the repository variables — 403 ⇒ this PAT lacks 'Variables: read' (add it; see the grant list in § Dedicated review identity, Bot user), 404 ⇒ the variable was never set (run 'gh variable set REVIEW_IDENTITY_LOGIN' below). Not a credential failure: 'gh api user' answered '$ACTING'. The 🔴 exclusion clause is inert without this variable, so the identity is NOT healthy." >&2 +elif [ "$ACTING" != "$RV" ]; then + # The acting login must MATCH the provisioned variable: the 🔴 exclusion clause names that + # login, so an identity acting under a different account is not the one being excluded. + echo "review-identity: the acting account '$ACTING' is not the login REVIEW_IDENTITY_LOGIN names ('$RV') — the 🔴 exclusion clause excludes THAT login, so this identity is not the one being excluded. Not a credential failure: 'gh api user' answered 200. Fix the variable, or run the identity under the account it names." >&2 +else + AUTH_OK=1 +fi +PERM="$(gh api "repos/$REPO/collaborators/$ACTING/permission" --jq .permission)" +PERMS_OK=0 +case "$PERM" in write | admin) PERMS_OK=1 ;; esac +# And the identity must not be this PR's author (the MANDATORY rule for both forms, above). +# It PRINTS ITS OWN REASON before zeroing the flag: `PERMS_OK` means "the required grants +# were observed", so a silent zero here makes `review_identity_health` emit the grant-shaped +# diagnostic for an authorship problem — sending the operator to re-inspect permissions that +# are correct, with nothing in the trail naming the actual cause. +# AN UNREADABLE AUTHOR IS NOT "NOT THE AUTHOR" either: a failed or empty read used to +# compare unequal and pass, so the one-credential pipeline (the bot publishes the PR and is +# declared `Review identity: bot-user`) plus a transient author read resolved HEALTHY and +# HALTed mid-review on the host's `422 Can not request changes on your own pull request`. +# Unknown authorship is unknown health, and it names itself like every other cause here. +if ! PR_AUTHOR="$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login)" || [ -z "$PR_AUTHOR" ]; then + echo "review-identity: the pull request's author could not be read, so the author comparison could not run — unknown authorship is unknown health. Not a grant problem: 'gh api user' answered '$ACTING'. Check \$PR and this PAT's access to the pull request." >&2 + PERMS_OK=0 +elif [ "$ACTING" = "$PR_AUTHOR" ]; then + echo "review-identity: the identity ($ACTING) is this pull request's AUTHOR — not a grant problem. See § Dedicated review identity, MANDATORY for BOTH forms: the review identity must not be an account that opens pull requests in this repository." >&2 + PERMS_OK=0 +fi +``` + +Neither probe writes anything. A classic PAT's scopes are also readable from the `X-OAuth-Scopes` response header (`gh api -i user`); a fine-grained PAT exposes none, which is why the write grant is proved once at setup and a `403`/`422` met **mid-write is a HALT** — reported against the artifact that failed, never retried with the session token. + +**MANDATORY for this form — provision `REVIEW_IDENTITY_LOGIN`.** `"User"` is the answer that makes this account indistinguishable from a human on the reviews API: without the login clause its approving review satisfies `pair-explicit-approval`, and the 🔴 human gate is gone. Set the login as a repository **variable** (not a secret — it is not sensitive and the job needs it in plain text), so the `pair-explicit-approval` job can exclude it: + +```bash +BOT_LOGIN="$(GH_TOKEN=$BOT_PAT gh api user --jq .login)" +gh variable set REVIEW_IDENTITY_LOGIN --body "$BOT_LOGIN" # repo variable, read by the job below +gh variable get REVIEW_IDENTITY_LOGIN # verify: must echo the bot's login +``` + +That **repository variable** — not an exported shell/CI environment variable of the same name — is the health input `review_identity_exclusion_ok user "$RV" "$ACTING"` checks, where `$RV` is the value the per-run probe above read back with `gh api "repos/$REPO/actions/variables/REVIEW_IDENTITY_LOGIN"` and `$ACTING` is the login that probe's own credential answered with. **Unset ⇒ the identity is not healthy ⇒ `resolve_identity_mode` yields `halt`**, and no review is written at all. Reading it back per run is what makes that sentence true: the clause it arms lives in the `pair-explicit-approval` job and resolves `${{ vars.REVIEW_IDENTITY_LOGIN }}`, so a login that exists only in the agent's environment leaves the gate comparing against the empty string while health reports green. A variable that is present but names a **different** account fails the probe — and `review_identity_exclusion_ok` itself, which is why it is a contract every host adapter inherits rather than this snippet's own rule — for the same reason: the excluded login must be the one acting. Keeping the bot out of the repository's human-reviewer set is still good hygiene, but it is no longer the containment — the login clause is. The App form needs none of this because it types as `"Bot"`. + +#### Failure modes, and what each one does + +| Situation | `resolve_identity_mode` | Behavior | +| --- | --- | --- | +| Nothing configured — the key absent, or its value `none` | `session` | Today's mode, in full. Not an error, not reported as a degradation. | +| The key **present** but its value unparseable (`- Review identity: app` — no bold; `**Review identity**: bot-user` — no bullet) | `halt` (before `resolve_identity_mode` is even called) | `review_identity_kind_ok` rejects it, so the read HALTs with a pointer here. Deliberately **not** `none`: `none` means _no identity_, which resolves `session` and would write the review with the session token on a repository that provisioned an identity — silently, since the flow would never learn one was configured. | +| Configured and the probes pass | `identity` | Native verdict; check run on the App path. | +| Configured, credential invalid / expired | `halt` | **HALT** with a pointer back to this section. Never a session-user fallback. | +| Configured, a permission missing (`403` on a probe) | `halt` | Same — the setup is incomplete, and acting as the human whose token is loaded would misattribute the review. | +| Configured, health unknown (the per-run probes of step 6 not run, network error) | `halt` | Fail-safe: unknown is not healthy. `review_identity_health` answers `0` for any probe outcome that is not exactly `1`. | +| Configured, and the identity **is the pull request's author** | `halt` — the per-run probe catches it (App: probe 3, against **both** login shapes, `app/` and `[bot]`; bot user: the `ACTING` comparison) | A provisioning error, caught **before any host write**: the identity must not open pull requests here. Where a host adapter runs no such probe, or the authorship read failed, the in-flow fail-safe still holds — `identity_verdict_event` returns **COMMENT**, so the verdict is published in full with its token leading the body while the native event (including the light row's APPROVE) is unobtainable on that PR. | +| A grant revoked between the probe and the write (`403`/`422` mid-write) | — | **HALT** on the refused write, reported against the artifact that failed. Never retried with the session token, and `pair-review` is never published as though the review had landed. | +| Configured as `bot-user`, `REVIEW_IDENTITY_LOGIN` not provisioned **as a repository variable** — absent, stored as a secret, scoped to an unused Environment, or naming another account (an exported env var of the same name does **not** count: the probe reads the variable back from the host) | `halt` | `review_identity_exclusion_ok` fails, so the identity is not healthy. Deliberately a HALT and not a warning: this is the one misconfiguration that would let a machine account satisfy the 🔴 explicit **human** approval — the gate job resolves `${{ vars.REVIEW_IDENTITY_LOGIN }}` to the empty string and its clause matches every account. | +| Configured as `bot-user`, the variable **provisioned correctly** but the identity's PAT lacks `Variables: read` | `halt` | The per-run read answers `403` under the BOT's token while `gh variable get` succeeded under the maintainer's, so the setup looks complete and every review halts. The probe reports the refused **read** (`Variables: read` / the variable never set) instead of letting it surface as a credential failure — the credential answered `200` on the line above. Add the grant; it is in the list under _Bot user_. | + +Do not "fall back to the session token so the review still runs". A review recorded against the maintainer's account, that the maintainer did not perform, is a worse outcome than a stopped review — and it is exactly the misattribution a dedicated identity exists to prevent. + +#### Adoption-gated light auto-approval (off unless declared) + +When a repository declares the `light` family in `## Tag Projection` (`tech/risk-matrix.md`), the identity may submit a **native approving review** on a PR that carries the `light` tag, is **below 🔴**, and has already synthesized `ready-to-merge` — so a light PR becomes mergeable with no human action. On a repository that sets `"required_approving_review_count": 1` or more (the payload below ships `0`, so this only bites where a project raised it), that review is what satisfies the host's approvals rule. + +**Why it is this row and not every approving verdict.** `light_auto_approve_allowed` in [`pr-state.sh`](../../../assets/pr-state.sh) is the third argument of `identity_verdict_event` — it is the sole authority for an `APPROVE` event **the identity signs** (in `session` mode no identity acts and the argument is not read). An approving verdict the row does not authorize is submitted as a **COMMENT-form review** (verdict token leading the body), so it never satisfies `required_approving_review_count` on the project's behalf. Without that wiring the gate would be decorative: any green/yellow PR with an APPROVED verdict would be auto-approved, `light` tag or not, declaration or not. Every identity action writes the audit comment `identity_audit_comment` renders — the authorized approval, the unauthorized one, and the block. **Nothing here reads the change**: the row consumes a tag and a declaration (D18). + +Two containments are worth stating on the host page, because this is where someone will try to shortcut them: + +- **The declaration is the gate, not the label.** A hand-applied `light` label on a repository that declares no `light` projection triggers nothing. Do not add the label family to the repository "so the flow can use it" — provisioning a label is not declaring a projection. **That containment is conditional, and the residual belongs with it:** it holds only on repositories that never opted in. On one that HAS declared the family — and that sets `required_approving_review_count >= 1` — anyone with **write or triage** access can label their own PR `light` + `risk:green`, and the identity's approving review then satisfies the host rule with no second human. Nothing in the flow verifies **who** applied the label, so `light` becomes a **merge-authorizing capability** and declaring the family means access-controlling it: apply `light` only from classification (the refinement/review skills), and restrict manual application — GitHub has no per-label ACL, so the practical controls are keeping write access small and auditing label events (`gh api "repos/$REPO/issues/$PR/events" --jq '.[] | select(.event=="labeled")'`), or requiring a `CODEOWNERS`-reviewed classification change. The 🔴 gate is unaffected either way (`light` is inert at red). +- **It never touches 🔴.** `required_approving_review_count` is a _host_ rule; `pair-explicit-approval` is the pair rule, and it still demands a human. A `light` label on a `risk:red` PR is inert. ### Provision the `pr-state:*` labels (once per repository) @@ -769,6 +1210,13 @@ jobs: PR: ${{ github.event.pull_request.number }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # The dedicated review identity's login, excluded from the human-approval + # predicate. REQUIRED when `Review identity: bot-user` — that account types + # as `user.type == "User"`, so the type clause does not exclude it and its + # approval would otherwise satisfy this very gate. Empty/unset on a project + # with no identity, or with an App one (excluded by type): the clause is then + # inert, which is correct. See § Dedicated review identity. + REVIEW_IDENTITY_LOGIN: ${{ vars.REVIEW_IDENTITY_LOGIN }} run: | set -euo pipefail source .pair/knowledge/assets/tier-resolve.sh # tags only, no criteria @@ -784,7 +1232,9 @@ jobs: # The predicate is NOT written out here — it is `human_approval_jq_filter` # from the sourced pr-state.sh, so this job and the tests that verify it # read one text and cannot drift (it rejects non-APPROVED, another commit, - # `user.type != "User"`, and the author's own approval). + # `user.type != "User"`, the author's own approval, and the dedicated review + # identity by login — the clause that covers a bot USER account, which types + # as "User" and the type clause therefore does NOT reject). # Use the REST reviews endpoint — it is the only one carrying BOTH the # account type and the reviewed commit. `gh pr view --json reviews` exposes # `author.login` and NO bot flag whatsoever, so filtering there on a diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md index 43edb1615..b61c96054 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md @@ -86,6 +86,40 @@ Because it is only a view, the label family is a **prerequisite, not a dependenc Host mechanics — how the check run is published, the exact branch-protection payload, the `pair-explicit-approval` job — live in the code host's implementation guide (R2.12): [github-implementation.md](github-implementation.md) § "PR state flow — required checks & branch protection". +## Dedicated review identity (optional) + +Everything above describes who *decides*. This section is about who **acts**: which credential executes the code-host writes the flow performs — the native review carrying the verdict, the `pair-review` publication, the audit comment. + +By default there is no separate actor: the session token writes. Where that account also authored the pull request — the self-review case — the host rejects a native approve/request-changes and the verdict degrades to a comment-form review; where it did not, the native event is submitted as it always was. A project may instead provision a **dedicated review identity** (a code-host App installation, or a machine account). The flow then resolves **which credential acts** through the shipped, provider-agnostic [`review-identity.sh`](../../../assets/review-identity.sh) — the same "one executable projection" pattern as `pr-state.sh` — into exactly three modes: + +| Mode | When | Behavior | +| --- | --- | --- | +| `session` | no identity configured — **the default** | Today's behavior, unchanged and unreported: session token, `pair-review` as a commit status, native verdict — comment-form only on a **self-authored** pull request, which is the one case the host rejects. Not an error, not a degradation. | +| `identity` | configured and usable — which includes being **mechanically excluded** from the 🔴 human-approval predicate (see below) | Host writes execute as the identity. A blocking verdict is a **native REQUEST_CHANGES** (the reviewer is not the author, so the host no longer rejects it); an approving verdict is a **native APPROVE only where the adoption-gated light row authorizes it**, and a comment-form review otherwise — so the identity never satisfies a host `required_approving_review_count` on the project's behalf by accident. Where the identity is an App the required check publishes through the host's check-run API instead of a commit status. Every action is attributable to the identity in the host's own records. | +| `halt` | configured but unusable — invalid credential, missing permission, unknown health | The flow **HALTs** with a pointer to the host guide's *Dedicated review identity* section. It **never** falls back to the session user: a review recorded against a human who did not perform it is worse than a stopped review, and is exactly the misattribution the identity exists to remove. **The HALT binds a phase that actually performs an identity host write** (the three above). A phase that would write nothing *as the identity* — e.g. PR publication where the project's `Review enforcement` is `disabled`, so no `pair-review` record is produced there — has no misattribution to prevent: it reports the identity as unusable with the same pointer and **continues**, so an enforcement-disabled project with a half-provisioned identity is not stopped from opening pull requests. | + +**An identity approval never counts as the explicit human approval at 🔴** — and the exclusion is a **mechanism**, in two parts, because the two identity forms are two different account kinds: + +| Identity form | Account type on the reviews API | What excludes it | +| --- | --- | --- | +| Host **App** installation | `"Bot"` | `human_approval_jq_filter`'s type clause (`user.type == "User"`). Automatic. | +| **Machine user** account | `"User"` — an ordinary user, indistinguishable by type | its **login**, via the filter's `.user.login != env.REVIEW_IDENTITY_LOGIN` clause. **Not automatic**: until that login is provisioned to the job evaluating the predicate, a machine account's approval satisfies the 🔴 gate exactly like a human's. | + +`review_identity_exclusion_ok` (in [`review-identity.sh`](../../../assets/review-identity.sh)) is the check: a machine-user identity with no login provisioned is **not healthy**, so `resolve_identity_mode` yields `halt` and no review is written. **What it must be fed is the login READ BACK from the host's configuration store on this run** — the same store the predicate's job resolves the value from — never the acting agent's ambient environment variable of the same name. A login that exists only in the agent's shell makes the identity look healthy while the job's clause compares against the empty string, which matches every account: the exclusion is then inert in exactly the state the check exists to refuse. Adopting an identity does not relax D10 — a `risk:red` pull request still needs a second human account, and the two mechanisms stay side by side on purpose: the identity signs the ordinary review, a human signs the 🔴 one. + +Per-host setup — which identity form, which permissions, where the credential lives — is the code host's concern and lives in its implementation guide (R2.12): [github-implementation.md](github-implementation.md) § "Dedicated review identity". The mechanism above names no host. + +### Adoption-gated light auto-approval + +One row, and it is inert unless a project turns it on. When **all four** hold — the project's adoption declares the `light` family in `## Tag Projection`, the pull request carries the `light` tag, the tier is **below 🔴**, and the synthesis is already `ready-to-merge` — the identity submits the approving review, so the pull request is mergeable with no human action. The evaluation is `light_auto_approve_allowed` in [`pr-state.sh`](../../../assets/pr-state.sh), a **sibling** of `resolve_pr_state`: the synthesis table above is unchanged, and this row consumes its output rather than adding to it. + +- **Zero criteria** (D18). "Light" is read from a tag the classification produced upstream; nothing in this flow computes it, and nothing here inspects the change. +- **Adoption is the gate, the label is not.** A hand-applied `light` label on a project that declares no `light` projection triggers nothing — which is what contains the obvious abuse of mis-tagging a pull request to auto-approve it. **That containment is conditional, and the residual belongs here:** on a project that HAS declared the family, nothing verifies *who* applied the label, so applying `light` to a sub-🔴 pull request is itself a **merge-authorizing action** wherever the host requires an approving review. Declaring the family therefore means access-controlling the label — apply it from classification, restrict and audit manual application — and treating it as a permission, not a hint. +- **Most restrictive wins.** `light` on a 🔴 (or untagged, i.e. fail-safe 🔴) pull request is inert; light applies below red only. +- **Every action is audited.** Every identity review — the authorized approval, the approving verdict it did **not** authorize, and the block — writes a comment naming the tag, the declaration, the tier and the state, so the reason is reconstructable from the pull request alone. The `pr-state:*` label view stays in sync exactly as before; nothing about the label mechanics changes. +- **It is the only authority for an approving review the identity signs.** The row's result is an input to `identity_verdict_event`, so an approving verdict by the identity that it does not authorize is published as a **comment-form review**: the judgment is recorded in full, the `pair-review` check still carries its conclusion, and the state still synthesizes to `ready-to-merge` — what does not happen is the identity signing the host's required approval. Without that wiring the four conditions above would gate nothing, since any approving native review satisfies a host `required_approving_review_count >= 1` on its own. +- **Absent a declaration, the row does not exist as a behavior** — and the flow then behaves exactly as it does with no identity at all as far as approvals go. That is the state of every project that has not opted in. + ## Edge cases | Case | Behavior | @@ -104,6 +138,7 @@ Host mechanics — how the check run is published, the exact branch-protection p | `/pair-capability-publish-pr` | Creates the PR, propagates the story's classification tags, registers `pair-review` as **pending**, labels the PR `pr-state:to-be-reviewed`, and triggers the review in a clean-context subagent | | `/pair-process-review` | Produces the judgment verdict in the native review, publishes the `pair-review` check conclusion (`review_check_conclusion`), computes the state (`resolve_pr_state`), swaps the `pr-state:*` label, and refuses to merge unless `merge_allowed` passes | | `/pair-process-implement` Phase 4 | The **other** merge path (the author re-invoked after an approving review): runs the *same* precondition as `/pair-process-review`'s — re-synthesize the current signals, `merge_allowed`, HALT otherwise — before merging. "At least one approval" is **not** the condition; a 🔴 PR with an approving verdict and no explicit human approval must not merge here either | +| **Dedicated review identity** (optional) | The **actor**, not a decider: executes the code-host writes `/pair-capability-publish-pr` and `/pair-process-review` perform — the native verdict, the `pair-review` publication, the audit comment — when one is configured. It signs an **approving** review only below 🔴 and only where adoption declares the `light` family; every other approving verdict stays a comment-form review. It never satisfies the 🔴 explicit human approval | | `/pair-capability-setup-gates` | Wires `pair-review` + `pair-explicit-approval` as required checks on the protected branch alongside the gate jobs, or reports degraded mode | | Code host | Enforces: blocks the merge button while any required check is red, pending, or absent | | Human | Fixes findings, gives the explicit approval at 🔴, and presses merge | @@ -113,7 +148,8 @@ Host mechanics — how the check run is published, the exact branch-protection p - [canonical-states.md](canonical-states.md) — the five work-item macrostates (`Review` is the macrostate a PR lives under) - [quality-model.md](../../quality-assurance/quality-model.md) — §3.2 fail-safe tier, §4 per-tier requirements (the single source of the thresholds this flow reads) - [tier-aware-pipeline.md](../../infrastructure/cicd-strategy/tier-aware-pipeline.md) — the gate side of the flow (tags-only pre-merge pipeline, fail-safe red) -- [`pr-state.sh`](../../../assets/pr-state.sh) — the provider-agnostic synthesis evaluator (`resolve_pr_state`, `merge_allowed`, `explicit_approval_required`, `review_check_conclusion`, `human_approval_jq_filter`) +- [`pr-state.sh`](../../../assets/pr-state.sh) — the provider-agnostic synthesis evaluator (`resolve_pr_state`, `merge_allowed`, `explicit_approval_required`, `review_check_conclusion`, `human_approval_jq_filter`) plus the adoption-gated `light_auto_approve_allowed` row +- [`review-identity.sh`](../../../assets/review-identity.sh) — the provider-agnostic identity adapter, seven entry points: `review_identity_kind_ok` (the adoption value parsed, so a present-but-unparseable key HALTs instead of degrading to `none`), `resolve_identity_mode`, `review_identity_exclusion_ok` (the checked 🔴 precondition — omit it from a host adapter and a bot-user identity with no `REVIEW_IDENTITY_LOGIN` resolves to `identity` and can sign the 🔴 approval), `review_identity_health` (the runtime source of the `healthy` flag: this run's artifact-free probes, folded together with the exclusion check — the setup-time probes leave artifacts and cannot answer it), `identity_verdict_event`, `pair_review_publication_mode`, `identity_audit_comment` - [`tier-resolve.sh`](../../../assets/tier-resolve.sh) — the provider-agnostic, tags-only tier resolver - [code-review-template.md](../templates/code-review-template.md) — the verdict-first review body whose verdict this flow consumes - [github-implementation.md](github-implementation.md) — GitHub check-run + branch-protection mechanics for the two pair checks diff --git a/.pair/llms.txt b/.pair/llms.txt index 9ca1efe76..5287c0ef0 100644 --- a/.pair/llms.txt +++ b/.pair/llms.txt @@ -125,6 +125,13 @@ - [Decision: the CLI invocation name is `pair-cli`, not `pair`](.pair/adoption/decision-log/2026-08-25-cli-invocation-canonical-name-is-pair-cli.md) - [Decision: Post-merge cleanup covers local branches and worktrees; PR analyses retire at merge](.pair/adoption/decision-log/2026-08-25-post-merge-cleanup-covers-local-branches-pr-analyses-retire-at-merge.md) - [Decision: tier 1's `$approval` posture is unconditional, and tier 1 has no declaring composition site yet](.pair/adoption/decision-log/2026-08-28-tier1-approval-posture-is-unconditional-and-has-no-declaring-composition-site-yet.md) +- [Decision: Review re-checks use an immutable baseline and prove provisioned artifacts](.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md) +- [Decision: External boundary proof prevents false equivalence](.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md) +- [Decision: a workflow guard reads a quoted `echo` argument as data, never as a command](.pair/adoption/decision-log/2026-09-01-quoted-echo-arguments-are-data-not-commands.md) +- [Decision: a repo-wide guard is enforced by a `$TURBO_ROOT$` cache input, not necessarily by a thin CLI + root gate step](.pair/adoption/decision-log/2026-09-01-repo-wide-guard-enforced-by-turbo-root-input.md) +- [Decision: Review contract inventory prevents serial findings](.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) +- [Decision: the format-workflow guard PARSES `format.yml` with `yaml@2.8.2`, and every rule is an allow-list over the parsed document](.pair/adoption/decision-log/2026-09-01-workflow-guard-rejects-what-it-cannot-read.md) +- [Decision: the review severity floor defaults to Minor — Questions are carried, never fixed](.pair/adoption/decision-log/2026-09-03-the-review-severity-floor-defaults-to-minor.md) ## How-To Guides diff --git a/apps/website/content/docs/concepts/meta.json b/apps/website/content/docs/concepts/meta.json index 15d1793cd..d5351c6e4 100644 --- a/apps/website/content/docs/concepts/meta.json +++ b/apps/website/content/docs/concepts/meta.json @@ -10,6 +10,7 @@ "definition-of-ready-and-done", "tag-driven-gates", "pr-state-flow", + "review-identity", "agent-integration", "llms-txt" ] diff --git a/apps/website/content/docs/concepts/pr-state-flow.mdx b/apps/website/content/docs/concepts/pr-state-flow.mdx index 905c0fd92..74dc708f1 100644 --- a/apps/website/content/docs/concepts/pr-state-flow.mdx +++ b/apps/website/content/docs/concepts/pr-state-flow.mdx @@ -82,6 +82,7 @@ Reviewer count, SLA, checklist depth, and the 🔴 explicit-approval rule come f ## Related +- [Dedicated Review Identity](/docs/concepts/review-identity) — who executes the flow's code-host writes, and the adoption-gated light auto-approval row - [Tag-Driven Quality Gates](/docs/concepts/tag-driven-gates) — the gate side of the flow (the first filter) - [Canonical States](/docs/concepts/canonical-states) — the work-item macrostates a PR lives under (`Review`) - [Quality Model](/docs/reference/quality-model) — the per-tier requirements this flow reads diff --git a/apps/website/content/docs/concepts/review-identity.mdx b/apps/website/content/docs/concepts/review-identity.mdx new file mode 100644 index 000000000..2ad214384 --- /dev/null +++ b/apps/website/content/docs/concepts/review-identity.mdx @@ -0,0 +1,96 @@ +--- +title: Dedicated Review Identity +description: Who executes the review's code-host writes — the session token by default, a dedicated identity when a project provisions one — plus the one adoption-gated row that lets a light PR merge without human action. +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +The [PR state flow](/docs/concepts/pr-state-flow) decides **what** a pull request's state is. This page is about **who acts**: which credential writes the review verdict, the `pair-review` check, and the audit comment to the code host. + +By default nobody special does. The flow writes with the **session token** — whoever is running it — and the check is a commit status. The verdict is the host's native action (approve / request changes) when the session account is reviewing someone else's pull request; only when the account running the flow authored the pull request does the host reject the action, and the verdict is then recorded as a comment-form review led by its verdict token. That works, it is the default, and nothing below is required to use pair. + +A project that wants more can provision a **dedicated review identity**: a code-host App installation, or a machine account. The flow then executes its host writes as that identity instead. + +## Three modes, and no fourth + +| Mode | When | What happens | +| --- | --- | --- | +| `session` | nothing configured — **the default** | Today's behavior, unchanged: session token, `pair-review` as a commit status, native verdict — except on a self-authored pull request, where the host rejects it and the verdict is a comment-form review. Not an error and not reported as a degradation. | +| `identity` | configured and usable | Host writes execute as the identity. A blocking verdict becomes a **native REQUEST_CHANGES** — the reviewer is no longer the author, so the host accepts the real event. An **approving** verdict becomes a native APPROVE only where the light row below authorizes it, and a comment-form review otherwise. An App identity publishes `pair-review` as a **check run** instead of a commit status. Every action is attributable to the identity in the host's own records. | +| `halt` | configured but unusable — invalid credential, missing permission, unknown health | The flow **stops**, with a pointer to the host setup guide. | + + + **A broken identity never falls back to the session user.** If a project says "review as this + identity" and the credential does not work, the flow halts instead of quietly writing the review + under the human whose token happened to be loaded. A review recorded against someone who did not + perform it is worse than a review that did not happen — and misattribution is exactly what a + dedicated identity exists to remove. + + +**Usable is checked on every run, not remembered from setup.** Health is two questions with two homes: the cheap, artifact-free probes — does the credential authenticate, is it scoped to this repository, are the required grants observable without writing — run before *every* review and publish; the probes that must write something to prove a write grant run **once, at setup**, because they leave artifacts on a real pull request that cannot be removed. Anything a read cannot prove at run time is covered by one rule: a permission error met *mid-write* halts, reported against the artifact that failed — never retried under the session token, and never a published check implying a review that did not land. + +**The identity must not be an account that opens pull requests.** Hosts reject a review action on your own pull request, so an identity that is also the author cannot deliver the verdict as a native review at all — it degrades to the comment form, and the light row's approval is unobtainable there. This bites the obvious setup: an unattended pipeline that already runs under one credential and then names that same principal as its review identity. It applies to **both** forms — an app opens pull requests under its own bot login, exactly as a machine user does — so the per-run health check compares the identity against the pull request's author and halts before any write. Provision a separate one. + +**Turning it on with pull requests already open: drain them first.** The `pair-review` required check has two forms — an app identity publishes it as a check run, every other mode as a commit status — and the form is resolved twice on a pull request's life, once when it is published and once when it is reviewed. Change the setting in between and one required context ends up with two independent records, only one of which the flow will ever update again; a host that honours the stale one blocks the merge with nothing in the flow to clear it. Merge or close what is open before switching, or follow the host guide's supersede step. One producer per required context. + +The resolution is one shipped, host-agnostic projection (`review-identity.sh`), so the publishing skill and the reviewing skill cannot disagree about who is acting on the same pull request. Which identity form to use, which permissions to grant, and where the credential lives are host concerns and live in the code host's implementation guide. + +## What it does not change + +An identity approval **still does not satisfy the 🔴 explicit human approval**. `pair-explicit-approval` counts approvals from accounts where `user.type == "User"`, and it excludes the review identity — but by two different clauses, because the two forms are two different account kinds: + +| Identity form | Account type on the reviews API | What excludes it | +| --- | --- | --- | +| App installation | `"Bot"` | the account-type clause. Automatic, nothing to configure. | +| Machine **user** account | `"User"` — an ordinary user | its **login**. You must provision it as a **repository variable** (`REVIEW_IDENTITY_LOGIN`) — the gate job reads it from there, so exporting it in your shell or CI environment does not arm the clause; until the variable is set, that account's approval satisfies the 🔴 gate exactly like a human's. | + + + **A machine user is not a "Bot" to the API.** Only a host App types as `"Bot"`. If you run the + bot-account form, the login exclusion is the whole containment — so the flow reads the repository + variable back on every run and treats an identity whose login is not provisioned there (or which + names a different account) as **unusable**, halting rather than reviewing with the 🔴 gate silently + open. + + +That the identity cannot grant the human gate is deliberate rather than an oversight: it buys a native verdict and per-identity auditability, not the right to sign for a person. A `risk:red` pull request still needs a **second human account**, exactly as before. The two mechanisms stay side by side — the identity signs the ordinary review, a human signs the 🔴 one. + +## Light auto-approval (off unless you declare it) + +One optional row builds on the identity. When **all four** of these hold, the identity submits the approving review, so the pull request is mergeable with no human action: + +1. the project's adoption declares the `light` family in `## Tag Projection`, +2. the pull request carries the `light` tag, +3. its tier is **below 🔴**, and +4. the state is already `ready-to-merge` (green gates, approving verdict). + + + **Adoption is the gate, the label is not.** A hand-applied `light` label on a project that declares + no `light` projection triggers nothing at all. That is what contains the obvious abuse — tagging a + pull request to get it approved — and it is why the declaration is one of the four conditions rather + than a convenience. + + + + **Once you declare the family, `light` becomes a merge-authorizing label.** The containment above + holds on projects that never opted in. On one that has — and that requires an approving review to + merge — nothing verifies *who* applied the tag: anyone who can label a pull request can label their + own, and the identity's approving review then satisfies that requirement with no second person. + Declaring the family means access-controlling the label: apply it from classification, restrict and + audit manual application, and treat it as a permission rather than a hint. 🔴 is unaffected — `light` + is inert there. + + +Four properties are worth stating plainly: + +- **Zero criteria.** Nothing here computes "lightness". The row reads a tag produced upstream by classification, a declaration made in adoption, the tier, and the state — it never inspects the change. +- **It is a sibling, not a rewrite.** The state synthesis table is untouched; this row consumes its output. A green or yellow pull request still reaches `ready-to-merge` the same way, and a human still presses merge unless this row supplied the host's required approval. +- **It is the only thing that authorizes the identity's approving review.** Outside the row an approving verdict by the identity is published as a comment-form review, so the identity never satisfies a host's required-approvals rule on your project's behalf. Without that, the four conditions would gate nothing — any approving review satisfies the host rule on its own. +- **Most restrictive wins.** `light` on a 🔴 — or on an untagged pull request, which is fail-safe 🔴 — is inert. + +Every identity review writes an audit comment naming the tag, the declaration, the tier and the state — the authorized approval, the approving verdict it did *not* authorize, and the block — so the reason is reconstructable from the pull request alone. + +## Related + +- [PR State Flow](/docs/concepts/pr-state-flow) — the states, the required checks, and the 🔴 rule this page extends +- [Tag-Driven Quality Gates](/docs/concepts/tag-driven-gates) — where the `risk:*` tag the flow reads comes from +- [Quality Model](/docs/reference/quality-model) — the per-tier requirements, including the 🔴 explicit approval diff --git a/apps/website/e2e/docs.e2e.test.ts b/apps/website/e2e/docs.e2e.test.ts index 5e6017e42..34d0da75b 100644 --- a/apps/website/e2e/docs.e2e.test.ts +++ b/apps/website/e2e/docs.e2e.test.ts @@ -990,6 +990,7 @@ test('no circular prev/next footer links on any docs page', async ({ page }) => '/docs/concepts/code-host', '/docs/concepts/definition-of-ready-and-done', '/docs/concepts/pr-state-flow', + '/docs/concepts/review-identity', '/docs/concepts/tag-driven-gates', '/docs/contributing/writing-migration-pages', '/docs/customization/external-kb', diff --git a/package.json b/package.json index ddf80d3f8..7c0242325 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "smoke-tests": "./scripts/smoke-tests/run-all.sh --cleanup", "format": "pnpm prettier:fix && pnpm mdlint:fix", "format:check": "pnpm prettier:check; _p=$?; pnpm mdlint:check; _m=$?; exit $((_p > 1 || _m > 1 ? 2 : (_p || _m)))", - "gate:composition": "pnpm --filter @pair/dev-tools pre-push-gate:check", + "gate:composition": "pnpm --filter @pair/dev-tools pre-push-gate:check && pnpm --filter @pair/dev-tools format-workflow:check", "workflows:test": "cd .claude/workflows && node --test" }, "engines": { diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index e6dc5c874..b2b677f9b 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -18,7 +18,8 @@ "sync-version": "ts-node src/quality-gates/sync-version-in-docs.ts", "benchmark-update-link": "ts-node src/quality-gates/benchmark-update-link.ts", "determine-version": "ts-node src/release/determine-version.ts", - "pre-push-gate:check": "ts-node src/quality-gates/pre-push-gate-composition.ts" + "pre-push-gate:check": "ts-node src/quality-gates/pre-push-gate-composition.ts", + "format-workflow:check": "ts-node src/quality-gates/format-workflow-composition.ts" }, "devDependencies": { "@pair/eslint-config": "workspace:*", @@ -30,7 +31,8 @@ "vite-tsconfig-paths": "catalog:", "@vitest/coverage-v8": "catalog:", "typescript": "catalog:", - "ts-node": "catalog:" + "ts-node": "catalog:", + "yaml": "catalog:" }, "prettier": "@pair/prettier-config" } diff --git a/packages/dev-tools/src/quality-gates/format-workflow-composition.test.ts b/packages/dev-tools/src/quality-gates/format-workflow-composition.test.ts new file mode 100644 index 000000000..063d59964 --- /dev/null +++ b/packages/dev-tools/src/quality-gates/format-workflow-composition.test.ts @@ -0,0 +1,4070 @@ +import { describe, it, expect } from 'vitest' +import { existsSync, readFileSync } from 'fs' +import { + checkFormatWorkflow, + checkThisRepoFormatWorkflow, + extractRunBlocks, + FORMAT_CHECK_SCRIPT, + FORMAT_WORKFLOW, +} from './format-workflow-composition' + +// #413. `pnpm format:check` was enforced by the husky pre-push hook and NOWHERE +// else: `--no-verify`, or a contributor whose hooks are not installed, landed +// unformatted code with every CI check green. The remedy is a dedicated +// `.github/workflows/format.yml` — and a YAML-only change has nothing asserting +// its shape unless a guard reads the real file. +// +// The failure modes this guard exists for are all one-line edits that keep the +// workflow LOOKING like enforcement: +// - a `paths-ignore:` key (the reason this is not a job inside ci.yml, whose +// workflow-level `paths-ignore: ['.changeset/**']` a job would inherit), or +// its allow-list twin `paths:`, which excludes everything it does not list, +// - a trigger narrowed off the base branch (`pull_request.branches: [release]`, +// or its negative spelling `branches-ignore: [main]`) or off the events that +// matter (`types: [closed]`), +// - `pull_request_target` instead of `pull_request` (a fork PR would then run +// with the base repo's credentials), +// - a write-mode formatter step (`pnpm format`), which would make CI rewrite +// files instead of reporting — the ADL 2026-07-31 ban is repo-wide, not +// hook-specific, +// - dropping the `push: main` trigger, so drift on the base branch goes unseen, +// - dropping `concurrency`, so a superseded run keeps reporting a stale verdict, +// - `continue-on-error: true`, ANY `if:` on the job, ANY `needs:` on the job (the +// same neutralization with no condition written anywhere: a job whose dependency +// fails or is skipped never runs, and a skipped job's required check reads +// SUCCESSFUL), ANY `if:` on the step that runs `format:check` (a `failure()` +// guard included — it is false on a normal PR, so the check is skipped and the +// job ends green), a step `if:` elsewhere that is not a SCOPED `failure()` guard, +// or a write-scoped token — each keeps the check green (or absent) while the +// context still reports, +// - dropping the failure-path remedy, so a red check names the offending file +// and nothing a contributor can act on (AC1) — or widening ANY failure-path +// step past the check step's own outcome, or naming that outcome without +// COMPARING it to `'failure'` (`== 'success'` is false exactly when the check +// failed), so a broken `pnpm install` is annotated "not formatted" or the remedy +// never fires at all, or placing a correctly-SPELLED scope where it cannot +// RESOLVE (a second job, or above the check step), so it is false on every run, +// or leaving the comparison exactly right but letting it DECIDE nothing — `&&` +// binds tighter than `||`, so `failure() && || ` fires the +// remedy on runs the check passed, +// - respelling a trigger as a FLOW mapping, which the block reader sees as an +// empty block and therefore as "no filter at all" — the spelling all four +// trigger holes above walk through untouched — or respelling a STEP the same +// way (`- { uses: creyD/prettier_action@v4 }`), which is a sequence ITEM and so +// is invisible to the key-level sweep, to `usesProblems` and to the write-mode +// scan at once, +// - a formatting action (`uses:`), invisible to a write scan that reads `run:` +// blocks, and needing no permission at all when placed before the check, +// - a checking command that is not THE command (`--filter=`, `-s`, a `cd`), so CI +// checks a strict subset of the tree the developer checks, +// - renaming the job that RUNS the check, which deletes the `format` status +// context branch protection is told to require — or leaving a decoy `format:` +// job behind that only echoes, so that context reports SUCCESS while the real +// check publishes one nobody requires — or renaming it through `name:` (GitHub +// publishes the DISPLAY name, id only when `name:` is absent) or suffixing it +// through a matrix (`format (20)`), or giving another job `name: format`, +// - a `push:` filtered by `tags:` alone, which GitHub fires for tag refs only — the +// workflow never runs on a push to `main` and `branchesOf` read "no filter" as +// "every branch", +// - a `concurrency.group` not keyed on `github.ref`, which puts a PR push and an +// in-progress run on `main` in ONE group and cancels the latter, +// - a `#` inside quotes read as a comment, which cuts an executing command out of +// the guard's view, +// - the checkout's `with:` — `ref: main` checks out `main` instead of the PR merge +// ref (measured: an unformatted PR, `format` SUCCESS), `sparse-checkout` a subset, +// - `working-directory:` on the checking step or `defaults:` anywhere — `cd` spelled +// as a key, so CI runs a package's own `format:check` the moment one declares it, +// - a remedy conjunct that narrows it to zero on the PR path (`&& github.event_name +// == 'push'`: measured, check fails and the remedy is skipped), +// - `cancel-in-progress` / `concurrency.group` matched as SUBSTRINGS, so `!(… == +// 'pull_request')` and `format-${{ github.run_id }}-${{ github.ref }}` passed, +// - an indentless block sequence or a filter-level alias reported as a DIFFERENT, +// false problem ("no branch") instead of read or named, +// - (round 13) the shell of the non-check, non-remedy steps left as the one deny-list +// (`git checkout origin/main -- .` before the check was green), `prettier -w` missing +// from the write list, and three correct spellings misreported (a quoted `run:`, +// CRLF line endings, workflow-level `permissions:`). +// - (round 16) `runs-on:` pointing off GitHub's runners. The job-key allow-list says +// WHICH keys a job may declare; `runs-on` is on it, so its VALUE was free — measured +// `ok=true` on the shipped file for `self-hosted` and for `[self-hosted, linux]`, one +// line relocating the check onto a machine the pull request chose (`container:` spelled +// as a value). +// The module header is the rule inventory; this list is the failure modes the suites +// below are named after. +// +// Structure is asserted, never exact file text: cosmetic YAML edits (comments, +// step names, action versions) must not false-fail this guard. + +/** + * A workflow shaped like the one #413 lands. Each test breaks exactly ONE thing + * in it by `replace`, so a failure names the property that regressed rather than + * "the fixture changed". + */ +const WELL_FORMED = `name: Format + +on: + pull_request: + branches: + - main + push: + branches: + - main + +concurrency: + group: format-\${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '10.15.0' + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + - name: Check formatting + id: format_check + run: pnpm format:check + - name: Explain how to fix it + if: failure() && steps.format_check.outcome == 'failure' + run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result." +` + +/** + * A mutation asserted to have HAPPENED, for every test that expects the guard to stay + * GREEN through it. + * + * A positive-path `.replace` that matches nothing passes vacuously: rename `id: + * format_check` in the workflow — an edit this guard permits, since `checkStepId` reads + * whatever id is there — and "accepts a quoted `id:`" silently re-runs the guard on the + * UNMUTATED file, still passes, and stops covering the `unquote` path that once made + * this guard RED on a correct workflow. Nothing goes red to say so. + * + * A NEGATIVE-path test cannot fail this way — an unmutated well-formed workflow is + * green, so `expect(ok).toBe(false)` catches the no-op replacement itself — which is why + * only the green ones route through here. + * + * Asserted as "the needle is still there" rather than `mutated !== source`, because one + * accepted spelling IS the shipped one (`uses: actions/checkout@v4` in the version + * table): that row replaces text with itself and must stay covered. + */ +function mutate(source: string, from: string | RegExp, to: string, label = String(from)): string { + expect(source, `the fixture no longer contains ${label}`).toMatch(from) + return source.replace(from, to) +} + +describe('extractRunBlocks reads what the workflow actually executes (#413)', () => { + it('collects an inline `run:` command', () => { + expect(extractRunBlocks(' steps:\n - run: pnpm format:check\n')).toEqual([ + 'pnpm format:check', + ]) + }) + + it('collects a block scalar body, not just its first line', () => { + const blocks = extractRunBlocks( + [ + ' - name: Check', + ' run: |', + ' set -e', + ' pnpm format:check', + ' - name: Next', + ' uses: actions/checkout@v4', + '', + ].join('\n'), + ) + expect(blocks).toEqual(['set -e\npnpm format:check']) + }) + + it('stops the block at the next key of the same or lower indent', () => { + const blocks = extractRunBlocks( + [ + ' - run: |', + ' pnpm format:check', + ' - uses: actions/checkout@v4', + '', + ].join('\n'), + ) + expect(blocks).toEqual(['pnpm format:check']) + }) + + it('ignores commented-out steps, so a comment cannot smuggle a command in', () => { + expect( + extractRunBlocks(' # - run: pnpm format\n - run: pnpm format:check\n'), + ).toEqual(['pnpm format:check']) + }) +}) + +describe('the format workflow closes the trigger-shaped holes (#413)', () => { + it('accepts the well-formed workflow', () => { + const r = checkFormatWorkflow(WELL_FORMED) + expect(r.ok, r.message).toBe(true) + }) + + // AC3. A `paths-ignore` is the hole this whole story exists to avoid: shipping a + // check whose TRIGGER excludes paths reads as enforcement and is not. + it('fails on a workflow-level `paths-ignore`', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + ' push:\n branches:\n - main\n paths-ignore:\n - .changeset/**', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths-ignore') + }) + + it('fails on a `paths-ignore` nested under any trigger', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:\n - main', + ' pull_request:\n paths-ignore:\n - docs/**\n branches:\n - main', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths-ignore') + }) + + // AC3, allow-list twin. `paths:` is the same hole spelled positively: everything + // NOT listed is excluded. A markdown-only or `.changeset`-only PR would then run + // no formatting check at all — identical outcome to the `paths-ignore` above. + it('fails on a `paths:` allow-list under pull_request', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:', + " pull_request:\n paths: ['**/*.ts']\n branches:", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths') + }) + + it('fails on a `paths:` allow-list under push', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:', + " push:\n paths: ['**/*.ts']\n branches:", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths') + }) + + // AC2, base-branch half. `branchesOf` was applied to `push` only, so retargeting + // the PR trigger at a branch nobody opens PRs against silenced the check for + // every real PR while the guard stayed green. + it('fails when `pull_request` no longer covers the base branch', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:\n - main', + ' pull_request:\n branches:\n - release', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('pull_request') + }) + + // AC2, base-branch NEGATIVE spelling. `branches-ignore` is the same filter written + // the other way round, and a missing filter correctly means "every branch" — so + // reading only `branches:` made this one-line edit invisible: no PR targeting main + // is format-checked and the `format` context simply never reports. + it('fails on a `branches-ignore` under pull_request', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:\n - main', + ' pull_request:\n branches-ignore:\n - main', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('branches-ignore') + }) + + it('fails on a `branches-ignore` under push', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + ' push:\n branches-ignore: [main]', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('branches-ignore') + }) + + // Rejected outright rather than pattern-matched against `main`: the values are + // globs, so `ma*` excludes the base branch too and no substring test would see it. + it('fails on a `branches-ignore` that excludes the base branch by glob', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:\n - main', + ' pull_request:\n branches-ignore:\n - ma*', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('branches-ignore') + }) + + // AC7, the filter GitHub reads as a DIFFERENT ref kind. `branches` and `tags` are + // two independent filters on one event, and the producer's rule (GitHub docs, "events + // that trigger workflows" § push: "If you define only tags/tags-ignore or only + // branches/branches-ignore, the workflow won't run for events affecting the undefined + // Git ref") is measured on this repo: release.yml declares `push: tags: ['v*']` and + // nothing else, and every one of its `push` runs is a tag — none is `main`, while + // ci.yml ran on each of those days' pushes to `main`. So `push: tags:` with no + // `branches:` never runs on any push to `main`, and `branchesOf` returning null — + // "no filter, every branch" — was exactly wrong for it: the guard reported the + // workflow well-formed while post-merge drift went unseen. + it('fails on a `push` filtered by `tags:` alone', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + " push:\n tags:\n - 'v*'", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('tags') + expect(r.message).toContain('never runs on a push to any branch') + }) + + it('fails on a `push` filtered by `tags-ignore:` alone', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + " push:\n tags-ignore: ['v*']", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('tags-ignore') + }) + + // `tags:` is not a filter `pull_request` accepts at all; same fail-closed treatment, + // since either way no PR against `main` is checked. + it('fails on a `pull_request` filtered by `tags:` alone', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:\n - main', + " pull_request:\n tags:\n - 'v*'", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('tags') + }) + + // Both filters defined: the same producer rule says the event fires for EITHER ref + // kind, so `main` is still covered. Kept green so the rule rejects the hole, not the + // word. + it('accepts a `tags:` filter beside a `branches:` filter that covers the base branch', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' push:\n branches:\n - main', + " push:\n branches:\n - main\n tags:\n - 'v*'", + 'the `push:` trigger', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts a `tags-ignore:` filter beside a `branches:` filter that covers the base branch', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' push:\n branches:\n - main', + " push:\n tags-ignore: ['v*']\n branches: [main]", + 'the `push:` trigger', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // A tag filter beside a branch filter that MISSES the base branch is still the + // off-base-branch hole, reported as that and not as a tag problem. + it('still reports the off-base-branch hole when a `tags:` filter sits beside it', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + " push:\n branches:\n - release\n tags:\n - 'v*'", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('does not cover `main`') + }) + + // AC2, event half. `types: [closed]` runs the check only AFTER the PR is closed — + // never while it is reviewable. + it('fails when a `types:` narrowing drops opened/synchronize', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' pull_request:\n branches:', + ' pull_request:\n types: [closed]\n branches:', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('types') + }) + + it('accepts a `types:` list that still covers opened and synchronize', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' pull_request:\n branches:', + ' pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n branches:', + 'the `pull_request` trigger block', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // AC2. Without `pull_request` the check does not exist where it matters. + it('fails when the `pull_request` trigger is gone', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace(' pull_request:\n branches:\n - main\n', ''), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('pull_request') + }) + + // AC5. `pull_request_target` runs with the BASE repo's token against the fork's + // head — the classic fork-PR privilege escalation. One word from the safe form. + it('fails on `pull_request_target`, whatever else the file says', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace(' pull_request:', ' pull_request_target:')) + expect(r.ok).toBe(false) + expect(r.message).toContain('pull_request_target') + }) + + // AC7. Drift on the base branch must be visible, not only on pull requests. + it('fails when the `push` trigger is gone', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace(' push:\n branches:\n - main\n', '')) + expect(r.ok).toBe(false) + expect(r.message).toContain('push') + }) + + it('fails when `push` no longer covers the base branch', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' push:\n branches:\n - main', + ' push:\n branches:\n - release/*', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('main') + }) + + it('accepts the flow spelling of the branch filter (`branches: [main]`)', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' push:\n branches:\n - main', + ' push:\n branches: [main]', + 'the block-style `push` branch filter', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // Duplicate-run edge case: `push` + `pull_request` queue two runs of the same + // head, and the mitigation is a one-line block that is equally easy to drop. + it('fails when the concurrency guard is dropped', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + 'concurrency:\n group: format-${{ github.ref }}\n cancel-in-progress: true\n\n', + '', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('concurrency') + }) + + it('fails when concurrency no longer cancels the superseded run', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('cancel-in-progress: true', 'cancel-in-progress: false'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('cancel-in-progress') + }) + + // Two merges to `main` a minute apart share `format-refs/heads/main`, so an + // unconditional cancel throws away the FIRST commit's verdict — AC7 wanted drift + // on the base branch visible. Cancelling only PR runs keeps both properties. + it('accepts a cancel-in-progress conditioned on the pull_request event', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'cancel-in-progress: true', + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + '`cancel-in-progress: true`', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('fails on a cancel-in-progress expression naming no event at all', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + 'cancel-in-progress: true', + "cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('cancel-in-progress') + }) + + // The accepted-expression rule is an ALLOW-list of equality, not a substring test + // for `pull_request`: the NEGATION contains that substring and inverts the + // mitigation, producing BOTH failure modes the rule exists for — three pushes to a + // PR branch keep three runners alive on a stale verdict, and two merges to `main` a + // minute apart leave the first commit with no formatting verdict at all. + it('fails on a cancel-in-progress that NEGATES the pull_request event', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + 'cancel-in-progress: true', + "cancel-in-progress: ${{ github.event_name != 'pull_request' }}", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('cancel-in-progress') + }) + + // The `github.ref` keying is what the whole concurrency argument stands on — "the + // two triggers never meet" is only true because a PR run is `refs/pull//merge` and + // a push to main is `refs/heads/main`. Nothing read `group:`. So `group: format` (or + // `${{ github.workflow }}`) put EVERY run in one group: a `push` run on main in + // progress, any PR push then joins that group with `cancel-in-progress` true (the + // event is `pull_request`) and cancels main's run — that commit ends with no + // formatting verdict, the AC7 loss the conditional cancel exists to prevent — and two + // PRs pushed a minute apart cancel each other's verdict. Same-group cancellation is + // measured, not inferred: runs 33527856271 and 33528146034 of the shipped workflow + // are `cancelled` because a later push to the same PR ref joined their group. + it('fails when the concurrency group is a constant', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('group: format-${{ github.ref }}', 'group: format'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('group: format`') + expect(r.message).toContain('github.ref') + }) + + it('fails when the concurrency group is keyed on the workflow name only', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('group: format-${{ github.ref }}', 'group: ${{ github.workflow }}'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('github.ref') + }) + + // The token outside `${{ }}` is the literal string `github.ref`, i.e. a constant. + it('fails when `github.ref` is written outside an expression', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('group: format-${{ github.ref }}', 'group: format-github.ref'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('github.ref') + }) + + // Allow-list of the canonical spelling, as for `cancel-in-progress`: a different + // context that happens to START with `github.ref` is not the same key. `ref_name` is + // `/merge` for a PR and the bare branch name for a push; `head_ref` is EMPTY on a + // push, so every push to main shares `format-` — and `sha` never groups two runs at + // all, so nothing is ever superseded. + it('fails on the near-miss contexts `github.ref_name`, `github.head_ref` and `github.sha`', () => { + for (const group of [ + 'group: format-${{ github.ref_name }}', + 'group: format-${{ github.head_ref }}', + 'group: format-${{ github.sha }}', + ]) { + const r = checkFormatWorkflow(WELL_FORMED.replace('group: format-${{ github.ref }}', group)) + expect(r.ok, group).toBe(false) + expect(r.message, group).toContain('github.ref') + } + }) + + it('fails when the concurrency block declares no group at all', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace(' group: format-${{ github.ref }}\n', '')) + expect(r.ok).toBe(false) + expect(r.message).toContain('group') + }) + + // Correct spellings — a workflow-distinguishing prefix (a constant or the workflow + // name) followed by the ref key or its documented `head_ref || ref` fallback, quoted + // or not — stay green. + it('accepts every group spelling keyed on github.ref', () => { + for (const group of [ + 'group: ${{ github.workflow }}-${{ github.ref }}', + 'group: format-${{ github.head_ref || github.ref }}', + 'group: "format-${{ github.ref }}"', + ]) { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, 'group: format-${{ github.ref }}', group, 'the concurrency group'), + ) + expect(r.ok, `${group}: ${r.message}`).toBe(true) + } + }) +}) + +// Nothing asserted a single JOB-level property, so the check could be made +// advisory, skipped outright, or handed a write-scoped token while every trigger +// and step rule above stayed green — and the `format` context would still report. +describe('the format job cannot be made advisory, skipped or privileged (#413)', () => { + it('fails on `continue-on-error: true`, which reports SUCCESS on unformatted code', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + ' - name: Check formatting\n continue-on-error: true\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('continue-on-error') + }) + + it('fails on an unconditionally false `if:`', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace(' format:\n runs-on:', ' format:\n if: false\n runs-on:'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // The rule is an ALLOW-list, and this is why: a deny-list of literal falses waves + // through every never-true EXPRESSION, which is the spelling anyone would write. + // The job never runs on a PR, and a skipped required check reports neutral. + it('fails on a never-true job `if:` EXPRESSION, not just the literal false', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on:', + " format:\n if: github.event_name == 'workflow_dispatch'\n runs-on:", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // `needs:` is the UNGUARDED spelling of the job `if:` above. A job whose dependency + // fails — or is itself skipped — never runs and is reported skipped, and on GitHub a + // skipped job reports its required check as SUCCESSFUL + // (github-implementation.md § Ordering). So once AC8 lists `format`, that context + // reads green and the merge goes through with the formatting check never having + // executed. Every other rule in this module stays green through it, exactly like the + // job-level `if:` it sits beside. + const PRECHECK = ` precheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Gate + run: exit 1 +` + + const dependencies: [string, string][] = [ + ['a scalar', ' needs: precheck\n'], + ['a flow sequence', ' needs: [precheck]\n'], + ['a block sequence', ' needs:\n - precheck\n'], + ] + + for (const [label, spelling] of dependencies) { + it(`fails when the job that runs the check is gated by \`needs:\` spelled as ${label}`, () => { + const r = checkFormatWorkflow( + `${WELL_FORMED.replace('jobs:\n', `jobs:\n${PRECHECK}`).replace( + ' format:\n runs-on: ubuntu-latest\n', + ` format:\n runs-on: ubuntu-latest\n${spelling}`, + )}`, + ) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain('needs:') + }) + } + + it('fails on `needs:` even with no failing job to depend on — the gating is the loss', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on: ubuntu-latest\n', + ' format:\n runs-on: ubuntu-latest\n needs: setup\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('needs:') + }) + + // The worse half: the JOB runs on every PR, and only the one step that checks + // anything is skipped — so the `format` context reports SUCCESS on unformatted + // code while every other rule in the module stays green. + it('fails on an `if:` that skips the checking STEP while the job still reports', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + " - name: Check formatting\n if: github.event_name == 'push'\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + it('fails on an `if:` added to any other step, e.g. the install', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Install dependencies\n', + ' - name: Install dependencies\n if: false\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // The hole an allow-list keyed on `failure()` ALONE still leaves open: the guard + // permitted any condition containing `failure()`, on ANY step — including the one + // step that checks anything. `if: failure()` there is never true on a normal PR + // (every earlier step succeeded), so `Check formatting` is SKIPPED, the job ends + // successful, and the `format` context reports SUCCESS on unformatted code. The + // check step therefore carries NO condition at all. + it('fails on `if: failure()` on the CHECK step itself', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + ' - name: Check formatting\n if: failure()\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + it('fails when `failure()` is ANDed onto a never-true event test on the check step', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + " - name: Check formatting\n if: github.event_name == 'push' && failure()\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // Worse than the other two: a step referencing its OWN `steps..outcome` reads + // an unpopulated context, so `'' == 'failure'` is false on every event and the + // check never runs at all — while spelling out the exact scoping the guard asks + // the REMEDY for. + it('fails when the check step scopes itself on its own outcome', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + " - name: Check formatting\n if: failure() && steps.format_check.outcome == 'failure'\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // One correctly-scoped step must not license every other one: `failure()` on a + // second annotation step is JOB-scoped, so a `pnpm install` dying on a lockfile + // drift annotates the Checks tab "not formatted" over the real cause — exactly + // the diagnosis the scoping rule exists to prevent, one step further out. + it('fails on a SECOND failure-path step left unscoped beside a scoped remedy', () => { + const r = checkFormatWorkflow( + `${WELL_FORMED} - name: Extra note + if: failure() + run: echo "::error title=Formatting check failed::not formatted" +`, + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + it('fails on `if: failure()` added to the install step beside a scoped remedy', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Install dependencies\n', + ' - name: Install dependencies\n if: failure()\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + // AC5 is "safe on fork PRs by construction, not by review". This job runs + // `pnpm install`, i.e. PR-authored lifecycle scripts; a write-scoped token in + // reach of that is the whole exposure. + it('fails on `permissions: write-all`', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' permissions:\n contents: read\n', + ' permissions: write-all\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('permissions') + }) + + it('fails on any write scope inside the permissions block', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('contents: read', 'contents: write')) + expect(r.ok).toBe(false) + expect(r.message).toContain('permissions') + }) + + it('fails when the permissions block is deleted, so the repo default is inherited', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace(' permissions:\n contents: read\n', ''), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('permissions') + }) + + it('accepts the empty and read-all spellings of "no write scope"', () => { + for (const spelling of ['permissions: {}', 'permissions: read-all']) { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' permissions:\n contents: read', + ` ${spelling}`, + 'the block-style `permissions:`', + ), + ) + expect(r.ok, `${spelling}: ${r.message}`).toBe(true) + } + }) +}) + +// AC1: the failing check must name the offending file AND the remedy. Prettier's +// `--list-different` prints the file and suppresses its own "run with --write" +// line, so without a failure-path step the contributor this story exists for — +// hooks not installed, pushed with `--no-verify` — gets a bare filename. +describe('a failing format check tells the contributor what to run (#413)', () => { + it('fails when no failure-path step names the remedy', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + / {6}- name: Explain how to fix it\n {8}if: failure\(\).*\n {8}run: .*\n/, + '', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('pnpm format') + }) + + it('does not accept `pnpm format:check` as the remedy', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + 'echo "::error::Not formatted. Run \'pnpm format\' locally and commit the result."', + 'echo "::error::Not formatted. Run pnpm format:check locally."', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('pnpm format') + }) + + it('does not accept a remedy printed unconditionally on the success path', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace(/^ {8}if: failure\(\).*\n/m, '')) + expect(r.ok).toBe(false) + expect(r.message).toContain('pnpm format') + }) + + // `if: failure()` is JOB-scoped: it fires when ANY earlier step failed. A `pnpm + // install` broken by a lockfile drift or a registry outage would be annotated + // "not formatted. Run 'pnpm format'" — the contributor runs it, nothing changes, + // and the real cause is buried under a confident wrong diagnosis. + it('fails when the remedy is not scoped to the check step (a bare `if: failure()`)', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + " if: failure() && steps.format_check.outcome == 'failure'\n", + ' if: failure()\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + it('fails when the check step carries no `id:` to scope the remedy against', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace(' id: format_check\n', '')) + expect(r.ok).toBe(false) + expect(r.message).toContain('id:') + }) + + // A quoted id is valid YAML, prettier-stable, and `steps.format_check.outcome` + // resolves against it exactly the same on GitHub. Reading the scalar raw made the + // guard red on a CORRECT workflow, with a message describing a file that does + // declare a usable id — and a false-positive gate is the kind that gets deleted. + it('accepts a quoted `id:` on the check step', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, 'id: format_check', "id: 'format_check'", '`id: format_check`'), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts `conclusion` as well as `outcome` for the scoping', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'steps.format_check.outcome', + 'steps.format_check.conclusion', + '`steps.format_check.outcome`', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // The reason this rule needs its own scanner: the write-mode guard reads the + // literal `pnpm format`, so the obvious spelling of the remedy was rejected as a + // write-mode STEP. A quoted message is data, not a command. + // + // Asserted on the REMEDY step, not the checking one: since AC4 became an equality on + // the checking step's command, `pnpm format:check || { … }` is rejected there for a + // different reason (it is no longer the one command a developer runs), and since round + // 13 the install step is an allow-list of toolchain commands. The remedy carries the + // required literal inside a quoted message, so the rule under test — quoted arguments + // are DATA — is what decides, and the checking-step spelling is asserted red just below. + it('does not mistake an echoed remedy for a step that writes files', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n`, + ' run: echo "Formatting failed. Run pnpm format and commit."\n', + 'the remedy step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('rejects the inline `|| { … }` remedy on the checking step, as a command shape', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm format:check\n', + ' run: pnpm format:check || { echo "Formatting failed. Run pnpm format and commit."; exit 1; }\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('one command, two places') + }) + + it('still fails when `pnpm format` is actually RUN rather than quoted', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace(' run: pnpm format:check\n', ' run: pnpm format\n'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier:fix') + }) + + // A quoted string is only inert if it cannot execute: `$( … )` and backticks + // inside double quotes DO run. Those quotes stay in scope for the write scan. + it('still fails on a write hidden in a command substitution inside a quoted message', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm format:check\n', + ' run: echo "$(prettier --write .)" && pnpm format:check\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('does not let a quoted message satisfy the "CI runs format:check" rule', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm format:check\n', + ' run: echo "pnpm format:check"\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain(FORMAT_CHECK_SCRIPT) + }) +}) + +describe('the format workflow runs the same command a developer runs (#413)', () => { + // AC4. Local/CI parity is the point of the story: a CI-only variant (a flag, a + // path list, a re-implementation) recreates the divergence it closes. + it('fails when no step invokes `pnpm format:check`', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('run: pnpm format:check', 'run: pnpm lint')) + expect(r.ok).toBe(false) + expect(r.message).toContain(FORMAT_CHECK_SCRIPT) + }) + + it('does not accept a step that merely NAMES the script', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('run: pnpm format:check', 'run: echo format:check'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain(FORMAT_CHECK_SCRIPT) + }) + + it('accepts `pnpm run format:check`, the one other spelling of the same invocation', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'run: pnpm format:check', + 'run: pnpm run format:check', + 'the checking command', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // The checking step's command is an ALLOW-list of two spellings, not "references + // the script". Every row below still satisfies `referencesScript` — the old rule — + // and every row makes CI check a strict SUBSET of what the developer's and the + // hook's whole-repo `pnpm format:check` covers, which is the divergence this story + // exists to close. `-s` belongs here too: it silences the output, i.e. the list of + // offending filenames AC1 requires the contributor to read. + it('fails on any narrowed spelling of the checking command', () => { + // Two rules cover the table. A spelling `referencesScript` still recognises as an + // invocation of the script is caught by the equality ("one command, two places"); + // one whose flag takes a SPACE-separated value is not recognised as an invocation + // at all, so the older "no step RUNS" rule catches it. Both are red, both name the + // local/CI divergence — the fragment column records which fired, so a later change + // that moves a row between them is visible rather than silent. + const narrowed: [string, string][] = [ + ['pnpm --filter=@pair/website format:check', 'one command, two places'], + ['pnpm -F @pair/website format:check', 'no step RUNS'], + ['pnpm -C apps/website format:check', 'no step RUNS'], + ['pnpm -s format:check', 'one command, two places'], + ['cd apps/website && pnpm format:check', 'one command, two places'], + ['npm run format:check', 'one command, two places'], + ['pnpm format:check --ignore-path .prettierignore.ci', 'one command, two places'], + ] + for (const [spelling, fragment] of narrowed) { + const r = checkFormatWorkflow( + WELL_FORMED.replace('run: pnpm format:check', `run: ${spelling}`), + ) + expect(r.ok, `${spelling}: ${r.message}`).toBe(false) + expect(r.message, spelling).toContain(fragment) + expect(r.message, spelling).toContain('divergence') + } + }) + + it('fails when the checking step wraps the command in a multi-line block scalar', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm format:check\n', + ' run: |\n set -e\n pnpm format:check\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('one command, two places') + }) + + // AC6. Check-only holds in CI exactly as it does in the hook (ADL 2026-07-31). + // Reuses the pre-push guard's offender list rather than a second copy of it. + it('fails when a step writes instead of checking (`pnpm format`)', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('pnpm format:check', 'pnpm format')) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier:fix') + }) + + it('fails on a raw write-mode formatter appended to the check', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('pnpm format:check', 'pnpm format:check || prettier --write .'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('fails on an auto-commit step, so CI can never repair the branch', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm format:check\n', + ' run: |\n pnpm format\n git commit -am "chore: format"\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier:fix') + }) +}) + +describe('the format workflow is safe on a fork PR by construction (#413)', () => { + // AC5. No secret in the job means a fork run is a FULL-STRENGTH run, and there + // is no credential for PR-authored code to reach. + it('fails when any step reads a secret', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting', + ' - name: Check formatting\n env:\n TOKEN: ${{ secrets.GITHUB_TOKEN }}', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('secret') + }) + + // The classic script-injection sink: attacker-controlled text (a PR title, a + // branch name) expanded by the runner INTO the shell before it runs. + it('fails when an expression is interpolated into a `run:` block', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + 'run: pnpm format:check', + 'run: echo "${{ github.event.pull_request.title }}" && pnpm format:check', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('${{') + }) + + it('leaves an expression OUTSIDE a run block alone (the concurrency group)', () => { + // `group: format-${{ github.ref }}` is not a shell sink — banning it would + // ban the duplicate-run mitigation this same guard requires. + expect(checkFormatWorkflow(WELL_FORMED).ok).toBe(true) + }) +}) + +// YAML has TWO spellings for every mapping, and this guard's block reader only +// understands one of them. `blockUnder` collects the lines indented deeper than a +// key, so a FLOW mapping on the same line yields an EMPTY block — and an empty +// block is read by `listValueOf` as "the key is absent", which for a trigger filter +// means "no filter, therefore every value". Every trigger rule then passes +// vacuously on a workflow whose trigger is exactly as narrow as the rule forbids. +// The complete table below is the decision table for the structural keys this guard +// reads as blocks: each has a flow spelling GitHub honours, and each is rejected +// rather than parsed — a hand-rolled block reader that pretends to understand flow +// style is a second hole, not a fix. +// Rounds 5–6 REJECTED every spelling the hand-rolled line reader could not follow; the +// migration to `yaml@2.8.2` (ADL 2026-09-01, amended 2026-09-03) deletes that whole rule +// family, because the parser resolves those spellings to the same document GitHub runs. +// So the contract inverts: a flow mapping, a JSON step, an anchor, an alias — all legal +// YAML GitHub honours — are READ, and what decides is the SEMANTIC rule on the resolved +// value. Correct workflow, exotic spelling: GREEN. Hole, whatever the spelling: RED, and +// named by its cause. +describe('a flow-style spelling is READ, and the semantic rule decides (#413)', () => { + // Same document as WELL_FORMED, spelled in flow/JSON style throughout — the spelling + // the reader used to reject wholesale, on every structural key at once. + const WELL_FORMED_FLOW = `name: Format +"on": { pull_request: { branches: [main] }, push: { branches: [main] } } +concurrency: { group: "format-\${{ github.ref }}", cancel-in-progress: true } +jobs: + format: + { + runs-on: ubuntu-latest, + permissions: { contents: read }, + steps: + [ + { name: Checkout code, uses: actions/checkout@v4 }, + { name: Install pnpm, uses: pnpm/action-setup@v4, with: { version: "10.15.0" } }, + { name: Install dependencies, run: pnpm install }, + { name: Check formatting, id: format_check, run: pnpm format:check }, + { + name: Explain how to fix it, + if: "failure() && steps.format_check.outcome == 'failure'", + run: "echo \\"::error::Not formatted. Run 'pnpm format' locally and commit the result.\\"", + }, + ], + } +` + + it('accepts a workflow spelled entirely in flow style', () => { + const r = checkFormatWorkflow(WELL_FORMED_FLOW) + expect(r.ok, r.message).toBe(true) + }) + + // Each row is a CORRECT workflow wearing the spelling round 5 rejected by name. + const legalSpellings: [string, string, string][] = [ + [ + '`on:` itself as a flow mapping', + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on: { pull_request: { branches: [main] }, push: { branches: [main] } }\n', + ], + [ + '`on:` as a flow SEQUENCE of event names (no filter is a SUPERSET of `main`)', + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on: [pull_request, push]\n', + ], + [ + '`on:` as a block LIST of event names', + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on:\n - pull_request\n - push\n', + ], + [ + '`pull_request` as a flow mapping', + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [main] }\n', + ], + [ + '`pull_request` as a flow mapping spanning two lines', + ' pull_request:\n branches:\n - main\n', + ' pull_request: {\n branches: [main] }\n', + ], + [ + '`push` as a flow mapping', + ' push:\n branches:\n - main\n', + ' push: { branches: [main] }\n', + ], + [ + '`concurrency` as a flow mapping', + 'concurrency:\n group: format-${{ github.ref }}\n cancel-in-progress: true\n', + 'concurrency: { group: "format-${{ github.ref }}", cancel-in-progress: true }\n', + ], + [ + 'the checking step as a JSON-spelled sequence item', + ' - name: Check formatting\n id: format_check\n run: pnpm format:check\n', + ' - { "name": "Check formatting", "id": "format_check", "run": "pnpm format:check" }\n', + ], + ] + + for (const [label, block, flow] of legalSpellings) { + it(`accepts ${label}`, () => { + const mutated = mutate(WELL_FORMED, block, flow, label) + const r = checkFormatWorkflow(mutated) + expect(r.ok, `${label}: ${r.message}`).toBe(true) + }) + } + + // …and the four holes that spelling used to hide are still RED — each now named by + // the SEMANTIC rule that owns it, not by its punctuation. + const flowHoles: [string, string, string, string][] = [ + [ + 'a flow `paths-ignore`', + ' pull_request:\n branches:\n - main\n', + " pull_request: { branches: [main], paths-ignore: ['**/*.md'] }\n", + 'paths-ignore', + ], + [ + 'a flow trigger off the base branch', + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [release] }\n', + 'does not cover `main` (release)', + ], + [ + 'a flow `types: [closed]`', + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [main], types: [closed] }\n', + 'dropping opened, synchronize', + ], + [ + 'a flow `push` off the base branch', + ' push:\n branches:\n - main\n', + ' push: { branches: [release] }\n', + 'does not cover `main` (release)', + ], + [ + 'a flow `concurrency.group` that is not keyed on the ref', + 'concurrency:\n group: format-${{ github.ref }}\n cancel-in-progress: true\n', + 'concurrency: { group: format, cancel-in-progress: true }\n', + 'github.ref', + ], + ] + + for (const [label, block, flow, cause] of flowHoles) { + it(`still fails on ${label}, naming the cause`, () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, block, flow, label)) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain(cause) + expect(r.message, label).not.toContain('flow-style') + }) + } + + // A step is a sequence ITEM. Every non-block spelling of one used to walk past + // `usesProblems` AND the write-mode scan at once; the parser resolves each to the + // same mapping, so both rules now see it. Measured `ok=true` on the shipped file + // before the migration, RED here for the reason the step actually is one. + describe('a step spelled as a flow or JSON item is read by every step rule', () => { + const items: [string, string, string][] = [ + [ + 'a flow mapping running a formatter', + '- { name: Fix, run: npx prettier --write . }', + 'prettier --write', + ], + ['a flow mapping with no spaces', '- {run: prettier --write .}', 'prettier --write'], + [ + 'the JSON spelling of the same', + '- { "name": "Fix", "run": "npx prettier --write ." }', + 'prettier --write', + ], + ['a flow mapping using a formatting ACTION', '- { uses: creyD/prettier_action@v4 }', 'uses:'], + [ + 'a flow mapping using an auto-commit action', + '- { uses: stefanzweifel/git-auto-commit-action@v5 }', + 'uses:', + ], + [ + 'an anchored item whose first key shares the line', + '- &fixer { run: npx prettier --write . }', + 'prettier --write', + ], + [ + 'a bare dash, with the node on the NEXT line', + '-\n { run: npx prettier --write . }', + 'prettier --write', + ], + ] + + for (const [label, item, cause] of items) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + ` ${item}\n - name: Check formatting\n`, + ), + ) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain(cause) + }) + } + + // A `steps:` item that is not a mapping carries no step key at all — and GitHub + // refuses to run the file (probe run 33724280781 on PR #477: zero jobs). Rejected + // here rather than filtered out silently. + it('fails on a sequence item that is not a mapping', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + ' - [a, b]\n - name: Check formatting\n', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('not mappings') + }) + + // Over-reach: an extra block-mapping step and a second branch scalar stay green. + it('leaves a block-mapping step and a scalar list item alone', () => { + const extraStep = mutate( + WELL_FORMED, + ' - name: Check formatting\n', + ' - name: Enable corepack\n run: corepack enable\n - name: Check formatting\n', + 'the checking step', + ) + expect(checkFormatWorkflow(extraStep).ok, checkFormatWorkflow(extraStep).message).toBe(true) + + const twoBranches = mutate( + WELL_FORMED, + ' push:\n branches:\n - main\n', + " push:\n branches:\n - main\n - 'release'\n", + 'the `push` branch filter', + ) + expect(checkFormatWorkflow(twoBranches).ok, checkFormatWorkflow(twoBranches).message).toBe( + true, + ) + }) + + // …and shell text inside a `run:` block scalar is not YAML at all: the parser hands + // the body over as a string, so a brace expansion, a `[` test or a `*` glob inside a + // quoted message is shell and never a sequence item or an alias. + it('does not read a `run:` block scalar body as YAML structure', () => { + const shell = mutate( + WELL_FORMED, + ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n`, + ` run: | + echo "::error::Not formatted. Run 'pnpm format' locally and commit the result." + echo "Lines like - { a,b } and - [ x ] and * are shell here, not YAML." +`, + 'the remedy step', + ) + expect(checkFormatWorkflow(shell).ok, checkFormatWorkflow(shell).message).toBe(true) + expect(extractRunBlocks(shell).some(run => run.includes('- { a,b }'))).toBe(true) + }) + }) + + // Over-reach guard: the spellings the guard always read correctly stay green. + it('leaves the flow spellings the guard does read correctly alone', () => { + const flowBranches = mutate( + WELL_FORMED, + ' pull_request:\n branches:\n - main\n', + ' pull_request:\n branches: [main]\n', + 'the `pull_request` branch filter', + ) + expect(checkFormatWorkflow(flowBranches).ok, checkFormatWorkflow(flowBranches).message).toBe( + true, + ) + + const inlinePermissions = mutate( + WELL_FORMED, + ' permissions:\n contents: read\n', + ' permissions: { contents: read }\n', + 'the block-style `permissions:`', + ) + expect( + checkFormatWorkflow(inlinePermissions).ok, + checkFormatWorkflow(inlinePermissions).message, + ).toBe(true) + }) +}) + +// The parse is the fail-closed boundary the rejection list used to be. `yaml@2.8.2` +// refuses a document it cannot resolve unambiguously, and so does GitHub — measured on +// PR #477: a merge-keyed job (run 33724280781) and an unknown top-level key (run +// 33724281525) each produced a run with ZERO jobs, i.e. "invalid workflow file". +describe('a file the parser refuses is a problem, not a pass (#413)', () => { + const unparseable: [string, string][] = [ + ['a duplicate key', 'name: Format\nname: Format\n'], + ['a tab indent', 'on:\n\tpush:\n'], + ['an alias with no anchor', 'on: *nowhere\n'], + ['unbalanced flow punctuation', 'on: { push: { branches: [main] }\n'], + ] + + for (const [label, text] of unparseable) { + it(`rejects ${label}`, () => { + const r = checkFormatWorkflow(text) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain('not valid YAML') + }) + } + + it('rejects a document that is not a mapping of workflow keys', () => { + const r = checkFormatWorkflow('- pull_request\n- push\n') + expect(r.ok).toBe(false) + expect(r.message).toContain('mapping of workflow keys') + }) + + it('rejects a merge key, which GitHub refuses to run at all', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'jobs:\n format:\n', + 'jobs:\n base: &base\n runs-on: ubuntu-latest\n format:\n <<: *base\n', + 'the jobs block', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('`<<`') + }) +}) + +// AC6, the `uses:` half. The write-mode scan reads `run:` blocks only, so a step +// that writes through an ACTION was invisible to it. Placed before the checking +// step, a formatting action rewrites the runner's checkout and `pnpm format:check` +// then passes on unformatted code with the `format` context green — and it needs no +// permission at all to do it, because it never pushes. +describe('a step may only use an allow-listed action (#413)', () => { + const banned = [ + 'creyD/prettier_action@v4', + 'stefanzweifel/git-auto-commit-action@v5', + 'EndBug/add-and-commit@v9', + './.github/actions/format-fixer', + 'docker://alpine:3', + 'Actions/Checkout-Extra@v1', + ] + + for (const action of banned) { + it(`fails on \`uses: ${action}\``, () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' - name: Check formatting\n', + ` - name: Fix\n uses: ${action}\n with:\n prettier_options: --write .\n - name: Check formatting\n`, + ), + ) + expect(r.ok, `${action}: ${r.message}`).toBe(false) + expect(r.message).toContain('uses:') + }) + } + + it('accepts the three actions the workflow needs, at any version and quoted', () => { + for (const action of [ + 'actions/checkout@v4', + 'actions/checkout@v5', + "'actions/checkout@v4'", + 'actions/checkout@a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0', + 'ACTIONS/CHECKOUT@v4', + ]) { + // One row here IS the shipped spelling, so this mutation is deliberately an + // identity for it — `mutate` asserts the NEEDLE, not that the text changed. + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'uses: actions/checkout@v4', + `uses: ${action}`, + '`uses: actions/checkout@v4`', + ), + ) + expect(r.ok, `${action}: ${r.message}`).toBe(true) + } + }) +}) + +// `stripComments` cut from the first ` #` to end of line unconditionally. Inside a +// `run:` block scalar — and inside a QUOTED YAML scalar — that `#` may sit inside +// quotes, where neither bash nor YAML treats it as a comment. The truncation was +// documented as "a comment cannot smuggle a banned pattern IN"; it also smuggled a +// real, executing command OUT of the guard's view, which is the direction that costs +// the AC6 ban. +describe('a `#` inside quotes is not a comment (#413)', () => { + it('sees a write-mode formatter hidden behind a quoted `#`', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm install\n', + ' run: |\n pnpm install\n echo "note # here"; prettier --write .\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('sees it behind single quotes too', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' run: pnpm install\n', + " run: |\n pnpm install\n echo 'note # here'; prettier --write .\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('still strips a real shell comment inside a block scalar', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' run: pnpm install\n', + ' run: |\n pnpm install # prettier --write .\n', + 'the install step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('still strips a whole-line YAML comment, so a comment cannot smuggle a command in', () => { + expect( + extractRunBlocks(' # - run: pnpm format\n - run: pnpm format:check\n'), + ).toEqual(['pnpm format:check']) + }) +}) + +// The job's display name (its id when `name:` is absent) IS the status context. Nothing +// asserted it, so renaming `format:` to `fmt:` left the guard green while the context way-of-working documents — and that +// AC8 names for branch protection — silently stopped existing. In advisory mode that +// is no signal at all; once protection lists it, a required context that never +// reports leaves every PR pending with no escape hatch +// (github-implementation.md:857). +describe('the job that reports the `format` context is named (#413)', () => { + it('fails when the job is renamed', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('\n format:\n', '\n fmt:\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('format') + expect(r.message).toContain('status context') + }) + + it('fails on a case variant, since the context name is case-sensitive', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('\n format:\n', '\n Format:\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('status context') + }) + + it('accepts the quoted spelling of the same job id', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, '\n format:\n', "\n 'format':\n", 'the `format:` job header'), + ) + expect(r.ok, r.message).toBe(true) + }) + + // "Some job is named `format`" is satisfied by a DECOY. Keep `format:` with one + // `run: echo ok` step, move the real steps into `worker:`, and the `format` context — + // the one way-of-working documents and AC8 tells branch protection to list — reports + // SUCCESS after an echo, while the job that actually checks anything publishes a + // `worker` context nobody requires. Same loss as the plain rename, and worse: the + // rename goes red, this used to stay green. So the assertion is on the HOST job. + it('fails on a decoy `format` job while another job runs the check', () => { + const decoy = WELL_FORMED.replace( + '\n format:\n', + ` + format: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Nothing + run: echo ok + worker: +`, + ) + const r = checkFormatWorkflow(decoy) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('is `worker`, not `format`') + }) + + // The same shape without the decoy: the host is renamed and no `format` job exists + // at all. One accurate problem, not the old name-set message. + it('names the host job when it is renamed and nothing else claims the context', () => { + const r = checkFormatWorkflow(WELL_FORMED.replace('\n format:\n', '\n worker:\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('is `worker`, not `format`') + }) + + // A second job beside a correctly-named host is not the loss — the context still + // belongs to the job that checks. (Its shell is an allow-listed inert echo: the + // toolchain allow-list applies to every non-check, non-remedy step in every job.) + it('accepts an extra job beside a `format` host that runs the check', () => { + const extra = mutate( + WELL_FORMED, + '\n format:\n', + ` + notes: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Nothing + run: echo "ok" + format: +`, + 'the `format:` job header', + ) + expect(checkFormatWorkflow(extra).ok, checkFormatWorkflow(extra).message).toBe(true) + }) + + // No job runs the check at all: `stepProblems` owns that cause, and this rule falls + // back to the name-set assertion so a missing context is still reported. + it('still reports the missing `format` job when no job runs the check', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace('\n format:\n', '\n worker:\n').replace( + ' run: pnpm format:check\n', + ' run: echo skip\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('no job is named `format`') + }) + + // GitHub publishes the job's DISPLAY NAME as the check context, not its id. Measured + // on this repo: version.yml's job id `version` carries `name: Create version commits + // and tags`, and `gh run view 32579550290 --json jobs` reports the job as `Create + // version commits and tags`. So one `name:` line renames the `format` context the + // same way `fmt:` does — rename red, `name:` was green. + it('fails when the host job carries a `name:` that is not `format`', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on: ubuntu-latest\n', + ' format:\n name: Formatting\n runs-on: ubuntu-latest\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('name: Formatting') + expect(r.message).toContain('display name') + }) + + it('fails when the host job name is an expression, whatever it evaluates to', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on: ubuntu-latest\n', + ' format:\n name: ${{ github.workflow }}\n runs-on: ubuntu-latest\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('display name') + }) + + // A `name:` equal to the id publishes the same context; nothing is lost. + it('accepts a `name:` on the host job that spells the same context', () => { + for (const name of ['name: format', "name: 'format'"]) { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' format:\n runs-on: ubuntu-latest\n', + ` format:\n ${name}\n runs-on: ubuntu-latest\n`, + 'the `format:` job header', + ), + ) + expect(r.ok, `${name}: ${r.message}`).toBe(true) + } + }) + + // A matrix appends its values to the display name: actions/checkout's job id + // `analyze` with `name: Analyze` and `matrix.language: ['javascript']` is published as + // `Analyze (javascript)` (run 33304315280). `format` would become `format (20)` and + // stop existing — every PR pending once protection lists it, with no escape hatch. + it('fails when the host job carries a `strategy:` (matrix)', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on: ubuntu-latest\n', + " format:\n strategy:\n matrix:\n node: ['20']\n runs-on: ubuntu-latest\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('strategy') + expect(r.message).toContain('format (') + }) + + // `strategy:` without a `matrix:` publishes no suffix today; rejected all the same, + // fail-closed — a strategy block exists to carry a matrix. + it('fails on a `strategy:` block without a matrix, fail-closed', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + ' format:\n runs-on: ubuntu-latest\n', + ' format:\n strategy:\n fail-fast: false\n runs-on: ubuntu-latest\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('strategy') + }) + + // The decoy, spelled through the display name: a second job whose `name:` is + // `format` publishes a SECOND `format` context after an `echo`, beside the real one. + it('fails when another job takes the `format` display name', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + '\n format:\n', + ` + notes: + name: format + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Nothing + run: echo ok + format: +`, + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('`notes`') + expect(r.message).toContain('display name') + }) + + // A step's `name:` sits one level deeper and is not the job's display name. + it('does not mistake a step `name:` for the job display name', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' - name: Check formatting\n', + ' - name: Formatting\n', + 'the check step name', + ), + ) + expect(r.ok, r.message).toBe(true) + }) +}) + +// `steps.` is JOB-LOCAL and populated only for steps that have already run. A +// failure-path step that names it from another job, or from above the checking step, +// carries a condition that is false on every run — so the remedy never fires, and +// AC1's contributor reads a bare filename with the guard reporting the workflow +// well-formed. +describe('a scoped failure-path step must be able to resolve its scope (#413)', () => { + const REMEDY = + " - name: Explain how to fix it\n if: failure() && steps.format_check.outcome == 'failure'\n run: echo \"::error::Not formatted. Run 'pnpm format' locally and commit the result.\"\n" + + it('fails when the remedy lives in a second job', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace( + REMEDY, + ` explain: + runs-on: ubuntu-latest + permissions: + contents: read + steps: +${REMEDY}`, + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('job-local') + }) + + it('fails when the remedy sits ABOVE the checking step in the same job', () => { + const r = checkFormatWorkflow( + WELL_FORMED.replace(REMEDY, '').replace( + ' - name: Check formatting\n', + `${REMEDY} - name: Check formatting\n`, + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('has not run yet') + }) + + it('accepts two correctly-placed scoped remedies', () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, REMEDY, REMEDY + REMEDY, 'the remedy step')) + expect(r.ok, r.message).toBe(true) + }) +}) + +// Naming `steps..outcome` and COMPARING it are two different things, and only the +// second one scopes anything. `steps..outcome` holds one of four values — `success`, +// `failure`, `cancelled`, `skipped` — so the reference is a substring of every condition +// that reads it, including the ones that are false exactly when the check fails. +// +// The table below is that value domain, both operators, both operand orders and both +// quote styles. The decisive row is `== 'success'`: a PR carries an unformatted file, +// `Check formatting` fails, `failure()` is true — but `outcome` is `'failure'`, so the +// remedy is SKIPPED on the one run that needed it and the contributor reads +// `--list-different`'s bare filename with prettier's own "--write to fix" hint +// suppressed. That is AC1's exact loss from a one-token edit. +describe('a failure-path scope must resolve on the FAILURE path (#413)', () => { + const SCOPE = "steps.format_check.outcome == 'failure'" + + const accepted: [string, string][] = [ + ['the shipped spelling', "steps.format_check.outcome == 'failure'"], + ['`conclusion` instead of `outcome`', "steps.format_check.conclusion == 'failure'"], + ['double quotes around the value', 'steps.format_check.outcome == "failure"'], + ['no spaces around the operator', "steps.format_check.outcome=='failure'"], + ['extra spaces around the operator', "steps.format_check.outcome == 'failure'"], + ['the operands reversed', "'failure' == steps.format_check.outcome"], + [ + 'the whole condition wrapped in an expression', + "${{ failure() && steps.format_check.outcome == 'failure' }}", + ], + ] + + for (const [label, condition] of accepted) { + it(`accepts ${label}`, () => { + // The wrapped row already carries its own `failure()`; the others are ANDed onto one. + const spelled = condition.startsWith('${{') ? condition : `failure() && ${condition}` + const r = checkFormatWorkflow( + mutate(WELL_FORMED, `failure() && ${SCOPE}`, spelled, 'the scoped remedy condition'), + ) + expect(r.ok, `${label}: ${r.message}`).toBe(true) + }) + } + + // Every other value in the domain, both operators, plus the two shapes that name the + // context without comparing it at all. Each leaves the reference intact — the whole + // point: a substring test for `steps..outcome` reports all of them well-formed. + const rejected: [string, string, string][] = [ + [ + "== 'success' — false exactly when the check failed, so the remedy never fires on a red check", + "steps.format_check.outcome == 'success'", + 'AC1', + ], + [ + "== 'skipped' — fires only when the check never ran, i.e. on the broken `pnpm install`", + "steps.format_check.outcome == 'skipped'", + 'wrong diagnosis', + ], + [ + "== 'cancelled' — fires on a superseded run and on nothing else", + "steps.format_check.outcome == 'cancelled'", + 'AC1', + ], + ["!= 'failure' — the scope inverted", "steps.format_check.outcome != 'failure'", 'AC1'], + [ + "!= 'success' — true for `failure`, `skipped` AND `cancelled`, i.e. the unscoped remedy back", + "steps.format_check.outcome != 'success'", + 'wrong diagnosis', + ], + [ + "conclusion == 'success' — the same inversion on the other status field", + "steps.format_check.conclusion == 'success'", + 'AC1', + ], + [ + 'a bare reference with no comparison, which is truthy for all four values', + 'steps.format_check.outcome', + 'no comparison', + ], + [ + '`contains()` instead of the equality this allow-list accepts', + "contains(steps.format_check.outcome, 'failure')", + 'not canonical', + ], + ] + + for (const [label, condition] of rejected) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + `failure() && ${SCOPE}`, + `failure() && ${condition}`, + 'the scoped remedy condition', + ), + ) + expect(r.ok, `${condition}: ${r.message}`).toBe(false) + expect(r.message, condition).toContain("== 'failure'") + }) + } + + // The reference is still there, so the "not scoped at all" message would be the wrong + // cause to report: the author DID scope it, to the wrong value. + it('names the comparison, not the missing reference, when the value is wrong', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + `failure() && ${SCOPE}`, + "failure() && steps.format_check.outcome == 'success'", + 'the scoped remedy condition', + ), + ) + expect(r.message).toContain('compare it to') + expect(r.message).not.toContain('are not scoped to the formatting check') + }) +}) + +// Requiring the equality to be PRESENT is the third leg; requiring it to DECIDE is the +// fourth. `if:` is a boolean expression, and in GitHub's grammar `&&` binds tighter than +// `||`, so `failure() && || ` parses as `(failure() && ) || ` +// — the scope is still there, spelled exactly as shipped, and decides nothing. +describe('a failure-path scope must DECIDE, not merely appear (#413)', () => { + const SCOPE = "steps.format_check.outcome == 'failure'" + const remedyOf = (condition: string) => + mutate(WELL_FORMED, `failure() && ${SCOPE}`, condition, 'the scoped remedy condition') + + // Every one of these keeps `steps.format_check.outcome == 'failure'` intact, so + // `scopesTo` reports them scoped — and every one of them fires the remedy on a run the + // check did not fail on, which is round 3's loss (a broken `pnpm install` annotated + // "not formatted. Run `pnpm format`") restored by adding tokens the guard never reads. + const neutralized: [string, string][] = [ + [ + '`|| true`, which makes the whole condition unconditionally true', + `failure() && ${SCOPE} || true`, + ], + [ + '`|| github.event_name ==` …, true on every push regardless of the check', + `failure() && ${SCOPE} || github.event_name == 'push'`, + ], + [ + "another step's outcome ORed in — the install failure this scope exists to stay quiet about", + `failure() && ${SCOPE} || steps.install.outcome == 'failure'`, + ], + [ + 'the same disjunction parenthesised, so the scope is one disjunct of two', + `failure() && (${SCOPE} || steps.install.outcome == 'failure')`, + ], + ['`always()` ORed in front of the whole guard', `always() || failure() && ${SCOPE}`], + ['the scope negated as a group', `failure() && !(${SCOPE})`], + [ + "the status reference negated, which compares `false` to `'failure'` and is never true", + `failure() && !steps.format_check.outcome == 'failure'`, + ], + ['the neutralised condition wrapped in an expression', `\${{ failure() && ${SCOPE} || true }}`], + ] + + for (const [label, condition] of neutralized) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow(remedyOf(condition)) + expect(r.ok, `${condition}: ${r.message}`).toBe(false) + expect(r.message, condition).toContain('decides nothing') + }) + } + + // "A conjunction can only narrow" was the round-8 premise, and narrowing to ZERO on + // the PR path IS the AC1 loss: `&& github.event_name == 'push'` keeps the scope exactly + // right and skips the remedy on every `pull_request` run — the contributor this + // workflow exists for reads `--list-different`'s bare filename with no instruction, + // the identical loss `== 'success'` costs, reached through the conjunct form the guard + // explicitly waved through. So the conjunction is an ALLOW-list too: every `&&` term + // must be `failure()`, a negated status function that is TRUE on the failure path + // (`!cancelled()`, `!success()`), or the scope equality. Anything else — a `github.*` + // context, another step's outcome, a literal, `always()` (a no-op), `!failure()` + // (never true beside `failure()`), a SECOND equality on the same context — is rejected. + const narrowedToZero: [string, string][] = [ + [ + "`&& github.event_name == 'push'`, false on every pull_request run", + `failure() && ${SCOPE} && github.event_name == 'push'`, + ], + ['`&& false`, never true', `failure() && ${SCOPE} && false`], + [ + "`&& steps.install.outcome == 'success'`, another step's outcome ANDed in", + `failure() && ${SCOPE} && steps.install.outcome == 'success'`, + ], + ['`&& !failure()`, false whenever `failure()` is true', `failure() && ${SCOPE} && !failure()`], + ['`&& always()`, a conjunct that decides nothing', `failure() && ${SCOPE} && always()`], + [ + "a second equality on the same context (`== 'success'`), so the conjunction is never true", + `failure() && ${SCOPE} && steps.format_check.outcome == 'success'`, + ], + [ + "the round-8 'narrowing' conjunct itself (`&& github.event_name == 'pull_request'`)", + `failure() && ${SCOPE} && github.event_name == 'pull_request'`, + ], + ] + + for (const [label, condition] of narrowedToZero) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow(remedyOf(condition)) + expect(r.ok, `${condition}: ${r.message}`).toBe(false) + expect(r.message, condition).toContain('decides nothing') + }) + } + + // The allow-listed conjuncts, in the spellings a correct workflow may use — a `!` on a + // status FUNCTION that is true on the failure path is not a `!` on the scope, an outer + // `${{ }}` is how GitHub lets any `if:` be written, and a parenthesised term is the same + // term. Rejecting these would fail a correct workflow, which is how a guard gets weakened. + const kept: [string, string][] = [ + [ + '`!cancelled()`, a negated function rather than a negated scope', + `failure() && !cancelled() && ${SCOPE}`, + ], + ['`!success()`, true on the failure path', `failure() && !success() && ${SCOPE}`], + ['the shipped spelling itself', `failure() && ${SCOPE}`], + ['the shipped spelling wrapped in `${{ }}`', `\${{ failure() && ${SCOPE} }}`], + ['each term parenthesised', `(failure()) && (${SCOPE})`], + ['the equality reversed', `failure() && 'failure' == steps.format_check.outcome`], + ] + + for (const [label, condition] of kept) { + it(`accepts ${label}`, () => { + const r = checkFormatWorkflow(remedyOf(condition)) + expect(r.ok, `${condition}: ${r.message}`).toBe(true) + }) + } + + // The three buckets stay disjoint: a neutralised condition DOES name the check and + // DOES carry the equality, so reporting it as "not scoped" or "does not compare it to + // 'failure'" would name a cause the author already got right. + it('names the neutralisation, not the missing reference or the wrong value', () => { + const r = checkFormatWorkflow(remedyOf(`failure() && ${SCOPE} || true`)) + expect(r.message).toContain('decides nothing') + expect(r.message).not.toContain('are not scoped to the formatting check') + expect(r.message).not.toContain('do not compare it to') + }) + + // On the file this repo actually runs, not only the fixture: the mutation is one + // ` || true` appended to line 139, and every other rule in the module stays green. + it('fires on the shipped workflow when the scope is ORed away', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate(shipped, `failure() && ${SCOPE}`, `failure() && ${SCOPE} || true`, SCOPE), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('decides nothing') + }) + + // The same on the conjunct form. Measured on GitHub (probe run on this PR, see the + // working log): with `&& github.event_name == 'push'` appended, a PR carrying an + // unformatted file gets `Check formatting: failure` and the remedy step `skipped`. + it('fires on the shipped workflow when a conjunct narrows the remedy off the PR path', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + `failure() && ${SCOPE}`, + `failure() && ${SCOPE} && github.event_name == 'push'`, + SCOPE, + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('decides nothing') + }) +}) + +// The guard reads the REAL file, exactly like `checkThisRepoGate` does — a +// hand-maintained fixture would keep passing while the shipped workflow drifts. +describe('checkThisRepoFormatWorkflow reads the shipped workflow (#413)', () => { + it('points at .github/workflows/format.yml', () => { + expect(FORMAT_WORKFLOW.endsWith('.github/workflows/format.yml')).toBe(true) + }) + + it('the workflow exists on disk', () => { + expect(existsSync(FORMAT_WORKFLOW), `${FORMAT_WORKFLOW} is missing`).toBe(true) + }) + + it('this repo’s format workflow satisfies every rule above', () => { + const r = checkThisRepoFormatWorkflow() + expect(r.ok, r.message).toBe(true) + }) + + it('reports a missing workflow as a failure, never a vacuous pass', () => { + // The degenerate case the story is about: no workflow at all must be RED. + expect(checkFormatWorkflow('').ok).toBe(false) + }) + + // Asserted against the SHIPPED file, not the fixture: the guard is only worth + // anything if it fires on the workflow this repo actually runs. (The rule is + // "declares no paths-ignore", not "never says the word" — the header comment + // explains why the key is banned, and a comment must not fail the check.) + it('fires on the shipped workflow the moment a paths-ignore is declared in it', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' push:\n branches:', + ' push:\n paths-ignore:\n - .changeset/**\n branches:', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths-ignore') + }) + + // The same "fires on the SHIPPED file" treatment for the three properties whose + // absence a well-formed fixture cannot demonstrate: they are job-level, and a + // fixture that drifts from the real job would keep passing while the real job + // goes advisory / privileged / silent. + it('fires on the shipped workflow when the job is made advisory or privileged', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const advisory = checkFormatWorkflow( + shipped.replace( + ' - name: Check formatting\n', + ' - name: Check formatting\n continue-on-error: true\n', + ), + ) + expect(advisory.ok).toBe(false) + expect(advisory.message).toContain('continue-on-error') + + const privileged = checkFormatWorkflow(shipped.replace('contents: read', 'contents: write')) + expect(privileged.ok).toBe(false) + expect(privileged.message).toContain('permissions') + }) + + it('fires on the shipped workflow when its failure path stops naming the remedy', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(shipped.replace(/^\s*if: failure\(\).*\n/m, '')) + expect(r.ok).toBe(false) + expect(r.message).toContain('pnpm format') + }) + + it('fires on the shipped workflow when the remedy widens to a bare `if: failure()`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(shipped.replace(/^(\s*)if: failure\(\).*$/m, '$1if: failure()')) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + // The two never-true `if:` EXPRESSIONS, on the file this repo actually runs: the + // job-level one skips the job, the step-level one leaves the job green with the + // only checking step skipped. A literal-false deny-list waved both through. + it('fires on the shipped workflow when a never-true `if:` is added to the job', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + 'jobs:\n format:\n', + "jobs:\n format:\n if: github.event_name == 'workflow_dispatch'\n", + 'the shipped job header', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + it('fires on the shipped workflow when a never-true `if:` is added to the check step', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Check formatting\n', + " - name: Check formatting\n if: github.event_name == 'push'\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('if:') + }) + + // The three spellings an allow-list keyed on `failure()` alone waved through, on + // the file this repo actually runs. All of them leave `Check formatting` skipped + // on a normal pull request while the job — and the `format` context — end green. + it('fires on the shipped workflow when the check step is given a `failure()` guard', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const conditions = [ + 'failure()', + "github.event_name == 'push' && failure()", + "failure() && steps.format_check.outcome == 'failure'", + ] + for (const condition of conditions) { + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Check formatting\n', + ` - name: Check formatting\n if: ${condition}\n`, + ), + ) + expect(r.ok, `${condition}: ${r.message}`).toBe(false) + expect(r.message).toContain('if:') + } + }) + + // One correctly-scoped remedy must not license a second, job-scoped one: the + // annotation would fire on a broken `pnpm install` and tell the contributor to + // run `pnpm format` against a cause it cannot fix. + it('fires on the shipped workflow when a second unscoped `failure()` step is added', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + `${shipped} - name: Extra note + if: failure() + run: echo "::error title=Formatting check failed::not formatted" +`, + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + it('fires on the shipped workflow when `if: failure()` is added to the install step', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Install dependencies\n', + ' - name: Install dependencies\n if: failure()\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not scoped') + }) + + // Quoting the id is valid YAML and resolves identically on GitHub: the shipped + // workflow must stay GREEN through it, or the guard fails a correct file. + it('stays green on the shipped workflow when its `id:` is quoted', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate(shipped, 'id: format_check', "id: 'format_check'", '`id: format_check`'), + ) + expect(r.ok, r.message).toBe(true) + }) + + // One token on the file this repo actually runs. `failure()` is still true when the + // formatting check fails, but `outcome` is `'failure'`, so `== 'success'` is FALSE and + // the remedy step is skipped on precisely the run that needed it: the contributor gets + // `--list-different`'s bare filename with prettier's own "--write to fix" hint + // suppressed, and nothing telling them what to run (AC1). + it('fires on the shipped workflow when the remedy scope compares against the wrong outcome', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + for (const value of ['success', 'skipped']) { + const r = checkFormatWorkflow( + mutate( + shipped, + "steps.format_check.outcome == 'failure'", + `steps.format_check.outcome == '${value}'`, + "the shipped remedy's scope", + ), + ) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + expect(r.message, value).toContain('compare it to') + } + }) + + // The reviewer's measured repro: a second job the `format` job depends on. `precheck` + // fails (or is itself skipped), `format` never runs and is reported skipped, and a + // skipped job reports its required check SUCCESSFUL — so once AC8 lists `format` the + // merge goes through with the formatting check never having executed. + it('fires on the shipped workflow when the format job is gated by `needs:`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const gated = mutate( + shipped, + 'jobs:\n format:\n', + `jobs: + precheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Gate + run: exit 1 + format: + needs: precheck +`, + 'the shipped `format` job header', + ) + const r = checkFormatWorkflow(gated) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('needs:') + }) + + // The negative branch filter, on the shipped file: one line under `pull_request` + // and no PR targeting `main` is ever format-checked. + it('fires on the shipped workflow when a trigger gains a `branches-ignore`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + for (const event of ['pull_request', 'push']) { + const r = checkFormatWorkflow( + shipped.replace( + ` ${event}:\n branches:\n - main`, + ` ${event}:\n branches-ignore:\n - main`, + ), + ) + expect(r.ok, `${event}: ${r.message}`).toBe(false) + expect(r.message).toContain('branches-ignore') + } + }) + + // The flow-mapping hole, on the file this repo actually runs. Each of the four is + // ONE line of valid YAML that GitHub honours, and each left `ok=true` before round 5: + // a markdown-only PR never format-checked, no PR targeting `main` ever checked, the + // check running only after the PR closes, and post-merge drift on `main` invisible. + // Since the parser migration the SPELLING is read and the SEMANTIC rule is what fires, + // so each row asserts its own cause — and the correct flow spelling stays green. + it('fires on the shipped workflow when a trigger is respelled as a flow mapping WITH a hole', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const holes: [string, string, string][] = [ + [ + ' pull_request:\n branches:\n - main\n', + " pull_request: { branches: [main], paths-ignore: ['**/*.md'] }\n", + 'paths-ignore', + ], + [ + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [release] }\n', + 'does not cover `main` (release)', + ], + [ + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [main], types: [closed] }\n', + 'dropping opened, synchronize', + ], + [ + ' push:\n branches:\n - main\n', + ' push: { branches: [release] }\n', + 'does not cover `main` (release)', + ], + ] + for (const [block, flow, cause] of holes) { + const mutated = shipped.replace(block, flow) + expect(mutated, `${flow}: the shipped file no longer contains the block spelling`).not.toBe( + shipped, + ) + const r = checkFormatWorkflow(mutated) + expect(r.ok, `${flow}: ${r.message}`).toBe(false) + expect(r.message, flow).toContain(cause) + } + }) + + it('stays green on the shipped workflow when a correct trigger is respelled as a flow mapping', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + ' pull_request:\n branches:\n - main\n', + ' pull_request: { branches: [main] }\n', + 'the shipped `pull_request` trigger', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // The `uses:` writer, on the shipped file. Before the `Check formatting` step this + // needs no permission at all: the action rewrites the runner's checkout, the check + // then passes on unformatted code, and the `format` context goes green. + it('fires on the shipped workflow when a formatting action is added', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Check formatting\n', + ' - name: Fix\n uses: creyD/prettier_action@v4\n with:\n prettier_options: --write .\n - name: Check formatting\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('uses:') + }) + + it('fires on the shipped workflow when a writer hides behind a quoted `#`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Install dependencies\n run: pnpm install\n', + ' - name: Install dependencies\n run: |\n pnpm install\n echo "note # here"; prettier --write .\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('fires on the shipped workflow when the job that carries the context is renamed', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(shipped.replace('\n format:\n', '\n fmt:\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('status context') + }) + + // Measured on the shipped file before the fix: `ok=true`. `format` reports SUCCESS + // after an `echo`; the job that checks publishes `worker`, which nothing requires. + it('fires on the shipped workflow when a decoy job takes the `format` name', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + '\n format:\n', + ` + format: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Nothing + run: echo ok + worker: +`, + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('is `worker`, not `format`') + }) + + // Each of these was measured `ok=true` on the shipped file before round 6: a step + // spelled as anything but a block mapping was invisible to `usesProblems` AND to the + // write-mode scan, so inserted before the checking step it rewrites the checkout and + // the `format` context goes green on unformatted code (AC6). The parser resolves each + // to the same mapping GitHub runs (probe run 33724282504 on PR #477: the JSON-spelled + // step executed), so every step rule now reads it — and names the real cause. + it('fires on the shipped workflow when a step is spelled as a flow item', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const items: [string, string][] = [ + ['- { name: Fix, run: npx prettier --write . }', 'prettier --write'], + ['- {run: prettier --write .}', 'prettier --write'], + ['- { uses: creyD/prettier_action@v4 }', 'uses:'], + ['- { uses: stefanzweifel/git-auto-commit-action@v5 }', 'uses:'], + ['- { "name": "Fix", "run": "npx prettier --write ." }', 'prettier --write'], + ] + for (const [item, cause] of items) { + const r = checkFormatWorkflow( + shipped.replace( + ' - name: Check formatting\n', + ` ${item}\n - name: Check formatting\n`, + ), + ) + expect(r.ok, `${item}: ${r.message}`).toBe(false) + expect(r.message, item).toContain(cause) + } + }) + + // …and a correct step spelled as a flow item stays green on the shipped file — the + // spelling GitHub accepted in the same probe run. + it('stays green on the shipped workflow when a correct step is spelled as a flow item', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + ' - name: Check formatting\n', + ' - { name: Install dependencies, run: pnpm install }\n - name: Check formatting\n', + 'the shipped checking step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // The shipped file's own `run: |` block scalar contains shell (`if ! command -v + // pnpm …`), and the workflow is green: the structural rules do not read that body. + it('stays green on the shipped workflow, whose run blocks carry real shell', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + expect(shipped, 'the shipped workflow no longer has a block scalar to prove this on').toContain( + 'run: |', + ) + expect(checkFormatWorkflow(shipped).ok).toBe(true) + }) + + it('fires on the shipped workflow when the remedy is moved above the checking step', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const remedy = + / {6}- name: Explain how to fix a formatting failure\n(?:.*\n)*? {10}echo "::error[^\n]*\n/ + const found = remedy.exec(shipped) + expect(found, 'the shipped remedy step was not found').not.toBeNull() + const moved = shipped + .replace(remedy, '') + .replace( + ' - name: Check formatting\n', + `${found?.[0] ?? ''} - name: Check formatting\n`, + ) + const r = checkFormatWorkflow(moved) + expect(r.ok).toBe(false) + expect(r.message).toContain('has not run yet') + }) + + it('fires on the shipped workflow when the checking command is narrowed', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace('run: pnpm format:check', 'run: pnpm --filter=@pair/website format:check'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('one command, two places') + }) + + it('fires on the shipped workflow when cancel-in-progress negates the PR event', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "cancel-in-progress: ${{ github.event_name != 'pull_request' }}", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('cancel-in-progress') + }) + + // Measured `ok=true` on the shipped file before the fix. The workflow never runs on + // a push to any branch — post-merge drift invisible with the guard green. + it('fires on the shipped workflow when `push:` is filtered by tags alone', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + shipped.replace( + ' push:\n branches:\n - main\n', + " push:\n tags:\n - 'v*'\n", + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('tags') + }) + + it('stays green on the shipped workflow when a tags filter is added BESIDE the branch filter', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + ' push:\n branches:\n - main\n', + " push:\n branches:\n - main\n tags:\n - 'v*'\n", + 'the shipped `push:` trigger', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // Measured `ok=true` on the shipped file before the fix, both spellings. Every run in + // one group: a PR push cancels main's in-progress run. + it('fires on the shipped workflow when the concurrency group stops being keyed on the ref', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + for (const group of ['group: format', 'group: ${{ github.workflow }}']) { + const r = checkFormatWorkflow(shipped.replace('group: format-${{ github.ref }}', group)) + expect(r.ok, group).toBe(false) + expect(r.message, group).toContain('github.ref') + } + }) + + // Measured `ok=true` on the shipped file before the fix, both rows: a `name:` renames + // the published context, a matrix suffixes it — `format` stops existing either way. + it('fires on the shipped workflow when the host job is given a display name', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + 'jobs:\n format:\n', + 'jobs:\n format:\n name: Formatting\n', + 'the shipped job header', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('display name') + }) + + it('fires on the shipped workflow when the host job is given a matrix', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + 'jobs:\n format:\n', + "jobs:\n format:\n strategy:\n matrix:\n node: ['20']\n", + 'the shipped job header', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('strategy') + }) +}) + +// `uses:` was matched on the action NAME only, so the step's `with:` was invisible. +// Producer boundary (actions/checkout@v4 action.yml, input `ref`): "The branch, tag or +// SHA to checkout. When checking out the repository that triggered a workflow, this +// defaults to the reference or SHA for that event" — i.e. setting it REPLACES the PR +// merge ref. Measured on GitHub (probe run on this PR, see the working log): with +// `with: ref: main` on the shipped checkout, a PR carrying an unformatted file gets a +// SUCCESSFUL `format` context — CI checked `main`, not the PR. `sparse-checkout` is +// AC4's subset divergence spelled as a checkout input; `repository` and `path` change +// what tree the command runs on at all. So the checkout's `with:` is an ALLOW-list of +// fetch-mechanics inputs that leave the tree as the event's ref. +describe('the checkout step may not choose WHAT is checked out (#413)', () => { + const CHECKOUT = ' - name: Checkout code\n uses: actions/checkout@v4\n' + const withInputs = (source: string, inputs: string) => + mutate(source, CHECKOUT, `${CHECKOUT} with:\n${inputs}`, 'the checkout step') + + const redirected: [string, string, string][] = [ + ['`ref: main`, the base branch instead of the PR merge ref', ' ref: main\n', 'ref'], + [ + '`ref:` set to an expression', + ' ref: ${{ github.event.pull_request.base.sha }}\n', + 'ref', + ], + ['`repository:`, another repository', ' repository: foomakers/other\n', 'repository'], + ['`path:`, a directory the check does not run in', ' path: checkout\n', 'path'], + [ + '`sparse-checkout:`, a subset of the tree', + ' sparse-checkout: packages/dev-tools\n', + 'sparse-checkout', + ], + [ + '`sparse-checkout-cone-mode:`', + ' sparse-checkout-cone-mode: false\n', + 'sparse-checkout-cone-mode', + ], + ['`submodules:`, content the PR does not carry', ' submodules: true\n', 'submodules'], + ['`lfs:`', ' lfs: true\n', 'lfs'], + [ + '`token:`, not an allow-listed input either', + ' token: ${{ github.token }}\n', + 'token', + ], + [ + 'a rejected input BESIDE an accepted one', + ' fetch-depth: 0\n ref: main\n', + 'ref', + ], + ["a quoted key (`'ref'`)", " 'ref': main\n", 'ref'], + ['an input this guard has never heard of', ' new-input: true\n', 'new-input'], + ] + + for (const [label, inputs, key] of redirected) { + it(`fails on checkout ${label}`, () => { + const r = checkFormatWorkflow(withInputs(WELL_FORMED, inputs)) + expect(r.ok, `${inputs}: ${r.message}`).toBe(false) + expect(r.message, inputs).toContain('with:') + expect(r.message, inputs).toContain(`\`${key}\``) + }) + } + + // The recommendation's named inputs are named in the message, whichever one fired — + // the reader learns the whole class, not the one key they happened to write. + it('names `ref`, `repository`, `path`, `sparse-checkout` and `sparse-checkout-cone-mode` in the message', () => { + const r = checkFormatWorkflow(withInputs(WELL_FORMED, ' submodules: true\n')) + expect(r.ok).toBe(false) + for (const key of [ + 'ref', + 'repository', + 'path', + 'sparse-checkout', + 'sparse-checkout-cone-mode', + ]) { + expect(r.message).toContain(`\`${key}\``) + } + }) + + // The `with:` is READ whatever its spelling — the parser resolves a flow mapping and + // an alias to the same inputs GitHub passes the action, so the allow-list decides. + it('fails on a flow-style `with: { ref: main }`, naming the input', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + CHECKOUT, + `${CHECKOUT} with: { ref: main }\n`, + 'the checkout step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('with:') + expect(r.message).toContain('`ref`') + }) + + it('follows `with: *inputs` to the inputs the alias names', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + CHECKOUT, + ` - name: Anchor\n uses: actions/setup-node@v4\n with: &inputs\n ref: main\n${CHECKOUT} with: *inputs\n`, + 'the checkout step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('with:') + expect(r.message).toContain('`ref`') + }) + + it('stays green on an aliased `with:` carrying only fetch mechanics', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + CHECKOUT, + `${CHECKOUT} with: &inputs\n fetch-depth: 0\n`, + 'the checkout step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // Fetch mechanics leave the tree as the event's ref — a guard that rejected `fetch-depth: 0` + // would fail a correct workflow, which is how a guard gets weakened. + const mechanics: [string, string][] = [ + ['`fetch-depth: 0`', ' fetch-depth: 0\n'], + [ + '`fetch-depth` and `persist-credentials: false`', + ' fetch-depth: 0\n persist-credentials: false\n', + ], + ['`fetch-tags: false`', ' fetch-tags: false\n'], + ['`show-progress: false`', ' show-progress: false\n'], + ['`clean: true`', ' clean: true\n'], + ['`set-safe-directory: true`', ' set-safe-directory: true\n'], + ["a quoted accepted key (`'fetch-depth'`)", " 'fetch-depth': 1\n"], + ] + + for (const [label, inputs] of mechanics) { + it(`accepts checkout with ${label}`, () => { + const r = checkFormatWorkflow(withInputs(WELL_FORMED, inputs)) + expect(r.ok, `${inputs}: ${r.message}`).toBe(true) + }) + } + + it('accepts the bare `uses: actions/checkout@v4` (the shipped spelling)', () => { + const r = checkFormatWorkflow(WELL_FORMED) + expect(WELL_FORMED).toContain(CHECKOUT) + expect(r.ok, r.message).toBe(true) + }) + + // The bound: only the checkout decides WHAT is checked out. `pnpm/action-setup` and + // `actions/setup-node` inputs choose tool versions, never the tree, so their `with:` is + // not constrained — the fixture already carries `version:`, `node-version:`, `cache:`. + it('does not constrain the `with:` of the toolchain actions', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + " version: '10.15.0'\n", + " version: '10.15.0'\n run_install: false\n standalone: true\n", + 'the pnpm/action-setup inputs', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('fires on the shipped workflow when its checkout is pointed at `main`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(withInputs(shipped, ' ref: main\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('`ref`') + }) + + it('stays green on the shipped workflow when its checkout gains `fetch-depth: 0`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(withInputs(shipped, ' fetch-depth: 0\n')) + expect(r.ok, r.message).toBe(true) + }) +}) + +// The command equality reads only the `run:` text. `working-directory:` is `cd` spelled +// as a key, and `defaults.run.working-directory` is the same `cd` one level up (job) or +// two (workflow) — each makes CI run `pnpm format:check` inside a package, i.e. that +// package's OWN `format:check`. Measured (pnpm, the real producer): today no workspace +// package declares the script, so `cd packages/dev-tools && pnpm format:check` exits 254 +// (ERR_PNPM_NO_SCRIPT) — fail-closed by accident; a package.json carrying +// `"format:check": "echo SUBSET-ONLY"` runs it, exit 0. The first package to gain that +// script turns the accident into a silent subset with this guard green — AC4's divergence +// reinstated through a key the `--filter=`/`cd` rule was written to catch. +describe('the checking step runs in the repository root, carrying only the keys it needs (#413)', () => { + const CHECK = + ' - name: Check formatting\n id: format_check\n run: pnpm format:check\n' + const RUN = ' run: pnpm format:check\n' + const checkWith = (source: string, key: string) => + mutate(source, CHECK, CHECK.replace(RUN, `${key}${RUN}`), 'the checking step') + + const foreignKeys: [string, string, string][] = [ + [ + '`working-directory:`', + ' working-directory: packages/dev-tools\n', + 'working-directory', + ], + ['`shell:`', ' shell: bash\n', 'shell'], + ['`env:`', ' env:\n NODE_OPTIONS: --max-old-space-size=4096\n', 'env'], + [ + '`with:` (meaningless on a `run:` step, and not needed)', + ' with:\n x: y\n', + 'with', + ], + ] + + for (const [label, key, name] of foreignKeys) { + it(`fails when the checking step carries ${label}`, () => { + const r = checkFormatWorkflow(checkWith(WELL_FORMED, key)) + expect(r.ok, `${key}: ${r.message}`).toBe(false) + expect(r.message, key).toContain(`\`${name}`) + expect(r.message, key).toContain('may carry only') + }) + } + + it('accepts `timeout-minutes:` on the checking step', () => { + const r = checkFormatWorkflow(checkWith(WELL_FORMED, ' timeout-minutes: 5\n')) + expect(r.ok, r.message).toBe(true) + }) + + // `if:` and `continue-on-error:` on that step are owned by their own rules, which + // name the loss precisely; this rule stays silent on them rather than reporting a + // second, vaguer cause. + it('leaves `if:` and `continue-on-error:` on the checking step to their own rules', () => { + for (const key of [ + " if: github.event_name == 'push'\n", + ' continue-on-error: true\n', + ]) { + const r = checkFormatWorkflow(checkWith(WELL_FORMED, key)) + expect(r.ok, key).toBe(false) + expect(r.message, key).not.toContain('may carry only') + } + }) + + const JOB = ' format:\n runs-on: ubuntu-latest\n' + + it('fails on `defaults:` on the host job, in either spelling', () => { + for (const defaults of [ + ' defaults:\n run:\n working-directory: packages/dev-tools\n', + ' defaults:\n run:\n shell: bash\n', + ]) { + const r = checkFormatWorkflow(mutate(WELL_FORMED, JOB, `${JOB}${defaults}`, 'the host job')) + expect(r.ok, `${defaults}: ${r.message}`).toBe(false) + expect(r.message, defaults).toContain('defaults') + } + }) + + it('fails on a workflow-level `defaults:`', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + '\njobs:\n', + '\ndefaults:\n run:\n working-directory: packages/dev-tools\n\njobs:\n', + 'the jobs key', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('defaults') + }) + + it('fires on the shipped workflow when the checking step gains a `working-directory:`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + checkWith(shipped, ' working-directory: packages/dev-tools\n'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('working-directory') + }) + + it('fires on the shipped workflow when the host job gains `defaults:`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + 'jobs:\n format:\n', + 'jobs:\n format:\n defaults:\n run:\n working-directory: packages/dev-tools\n', + 'the shipped job header', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('defaults') + }) +}) + +// The rule's own comment said a substring test for `pull_request` was not enough — and +// `CANCEL_ON_PULL_REQUEST` was itself an unanchored substring over the value. Measured +// on GitHub (probe run on this PR, see the working log), evaluated on a pull_request +// event: `!(github.event_name == 'pull_request')` → false, `github.event_name == +// 'pull_request' && false` → false, `github.event_name == 'pull_request' || true` → true. +// The first two cancel nothing on a PR (stale verdicts, superseded runs keep burning +// runners); the third cancels on `main` too, so of two merges a minute apart only the +// second commit carries a verdict of its own. +// +// The allow-list has TWO members, and a bare `true` is the weaker of them, not a defect: +// it supersedes, which is what AC7 asks, and `main` is linear, so the surviving run's tree +// still contains the cancelled commit's changes — drift is caught one commit later. What it +// costs is the per-commit `format` history, which is why the shipped file spells the +// conditional. `|| true` is rejected not because it is weak but because it is NEITHER +// member: the rule is anchored to the WHOLE value, so a spelling that merely CONTAINS one +// of the two is a third value nobody argued for. +describe('cancel-in-progress is an anchored allow-list, not a substring (#413)', () => { + const cancelOf = (source: string, value: string) => + mutate(source, 'cancel-in-progress: true', `cancel-in-progress: ${value}`, 'cancel-in-progress') + + const rejected: string[] = [ + "${{ !(github.event_name == 'pull_request') }}", + "${{ github.event_name == 'pull_request' && false }}", + "${{ github.event_name == 'pull_request' || true }}", + "${{ github.event_name == 'push' }}", + "${{ github.event_name == 'pull_request' }}-x", + "${{ contains(github.event_name, 'pull_request') }}", + '${{ true }}', + ] + + for (const value of rejected) { + it(`fails on \`cancel-in-progress: ${value}\``, () => { + const r = checkFormatWorkflow(cancelOf(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + expect(r.message, value).toContain('cancel-in-progress') + }) + } + + const accepted: string[] = [ + "${{ github.event_name == 'pull_request' }}", + "${{ 'pull_request' == github.event_name }}", + '${{ github.event_name == "pull_request" }}', + '"${{ github.event_name == \'pull_request\' }}"', + "${{github.event_name=='pull_request'}}", + // The deliberate weaker member — see the block comment above this describe. + 'true', + ] + + for (const value of accepted) { + it(`accepts \`cancel-in-progress: ${value}\``, () => { + const r = checkFormatWorkflow(cancelOf(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(true) + }) + } + + it('fires on the shipped workflow when the PR equality is negated as a group', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "cancel-in-progress: ${{ !(github.event_name == 'pull_request') }}", + 'the shipped cancel-in-progress', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('cancel-in-progress') + }) +}) + +// Round 11, carried forward. `GROUP_KEYED_ON_REF` was a substring test too: a group that +// CONTAINS `github.ref` and also `github.run_id` (or `sha`, `run_number`) is unique per +// run, so nothing is ever superseded — the "concurrency dropped" loss with the block still +// present. And a bare `${{ github.ref }}` with no workflow-distinguishing prefix shares +// its group with any other workflow keyed the same way (latent today; one token from a +// cross-workflow cancel). The accepted shape is `-`, anchored. +describe('the concurrency group is a prefixed ref key and nothing more (#413)', () => { + const GROUP = 'group: format-${{ github.ref }}' + const groupOf = (source: string, value: string) => + mutate(source, GROUP, `group: ${value}`, 'the concurrency group') + + const unique: string[] = [ + 'format-${{ github.run_id }}-${{ github.ref }}', + 'format-${{ github.ref }}-${{ github.sha }}', + 'format-${{ github.ref }}-${{ github.run_number }}', + 'format-${{ github.ref }}-${{ github.run_attempt }}', + ] + + for (const value of unique) { + it(`fails on \`group: ${value}\`, unique per run so nothing supersedes`, () => { + const r = checkFormatWorkflow(groupOf(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + expect(r.message, value).toContain('github.ref') + }) + } + + it('fails on a bare `${{ github.ref }}` with no workflow-distinguishing prefix', () => { + const r = checkFormatWorkflow(groupOf(WELL_FORMED, '${{ github.ref }}')) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('prefix') + }) + + it('fires on the shipped workflow when the group gains a per-run token', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(groupOf(shipped, 'format-${{ github.run_id }}-${{ github.ref }}')) + expect(r.ok).toBe(false) + expect(r.message).toContain('github.ref') + }) +}) + +// Two VALID spellings the reader could not follow were reported as a DIFFERENT, false +// problem. (a) An indentless block sequence — YAML permits `- ` at the parent key's +// indent, and `yaml@2.8.2` parses `branches:\n- main` and `branches:\n - main` to the +// same value (measured); GitHub honours it (probe run on this PR, see the working log). +// `pnpm format:check` runs prettier over ts/tsx/js/jsx/json/html only, so nothing in this +// repo normalizes YAML indentation and an editor default produces exactly this shape. +// It was reported as "does not cover `main` (no branch)" / "no failure-path step names the +// remedy". (b) `branches: *shared` was reported as the same "no branch", while the ADL +// states aliases are rejected anywhere with a message naming the spelling. A guard that +// names the wrong cause on a correct workflow is the kind that gets weakened. +describe('an indentless block sequence is read, and an alias is named as one (#413)', () => { + /** Every `steps:` item and its body moved two columns left — the indentless spelling. */ + function indentlessSteps(source: string): string { + const out: string[] = [] + let inSteps = false + for (const line of source.split('\n')) { + if (/^ {4}steps:\s*$/.test(line)) { + inSteps = true + out.push(line) + continue + } + if (inSteps && line.trim() !== '' && line.length - line.trimStart().length <= 4) { + inSteps = false + } + out.push(inSteps && line.startsWith(' ') ? line.slice(2) : line) + } + expect(out.join('\n'), 'the steps did not move').not.toBe(source) + return out.join('\n') + } + + const indentlessBranches = (source: string) => + mutate(source, /^ {6}- main$/gm, ' - main', 'the indented branch items') + + it('accepts indentless `branches:` items on both triggers', () => { + const r = checkFormatWorkflow(indentlessBranches(WELL_FORMED)) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts indentless `steps:` items', () => { + const r = checkFormatWorkflow(indentlessSteps(WELL_FORMED)) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts the shipped workflow with both sequences written indentless', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(indentlessSteps(indentlessBranches(shipped))) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts indentless `types:` items that keep opened and synchronize', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' pull_request:\n branches:\n - main\n', + ' pull_request:\n branches:\n - main\n types:\n - opened\n - synchronize\n - reopened\n', + 'the pull_request trigger', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // Read, not merely tolerated: the rules see through the spelling to the real cause. + it('reads an indentless branch filter that misses `main` and names THAT cause', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' pull_request:\n branches:\n - main\n', + ' pull_request:\n branches:\n - release\n', + 'the pull_request branch filter', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('does not cover `main` (release)') + }) + + it('reads an indentless `types:` narrowing and names THAT cause', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' pull_request:\n branches:\n - main\n', + ' pull_request:\n branches:\n - main\n types:\n - closed\n', + 'the pull_request trigger', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('dropping opened, synchronize') + }) + + it('follows indentless steps into the write-mode scan', () => { + const r = checkFormatWorkflow( + indentlessSteps(WELL_FORMED).replace( + ' - name: Check formatting\n', + ' - name: Fix\n run: npx prettier --write .\n - name: Check formatting\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('follows indentless steps into the `uses:` allow-list', () => { + const r = checkFormatWorkflow( + indentlessSteps(WELL_FORMED).replace( + ' - name: Check formatting\n', + ' - name: Fix\n uses: creyD/prettier_action@v4\n - name: Check formatting\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('uses:') + }) + + it('reads an indentless `needs:` list and rejects it as `needs:`', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' format:\n runs-on: ubuntu-latest\n', + ' precheck:\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n format:\n needs:\n - precheck\n runs-on: ubuntu-latest\n', + 'the host job', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('needs: precheck') + }) + + // `on:` as a LIST of events is valid YAML and a valid workflow, and it carries NO + // filter — every branch, GitHub's default activity types — which is a SUPERSET of what + // this guard requires. Read, therefore, not rejected; what is still RED is a list that + // drops an EVENT. + it('accepts `on:` written as an event list', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on:\n- pull_request\n- push\n', + 'the on block', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('reads an event list that drops `push` and names THAT cause', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on:\n- pull_request\n', + 'the on block', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('has no `push` trigger') + }) + + it('reads a scalar `on: pull_request` and names the missing `push`', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'on:\n pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + 'on: pull_request\n', + 'the on block', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('has no `push` trigger') + }) +}) + +// (b) An ANCHOR names a node; an ALIAS is that node. GitHub resolves both — measured on +// PR #477, probe run 33724282535: a workflow whose `push.branches` is `*b`, anchored on +// `pull_request.branches`, RAN on a push to that branch — so a guard that reported the +// alias as an unreadable spelling was reporting a CORRECT workflow red. The parser +// resolves them and the semantic rule decides on the resolved value. Rows are the +// decision table over that domain: the same key, aliased to a value that is fine, and +// aliased to a value that is a hole. +describe('an alias resolves to the value it names, and the rule decides (#413)', () => { + const aliased: [string, string, string, string | null][] = [ + [ + '`branches: *shared` anchored on a filter that names `main`', + ' pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + ' pull_request:\n branches: &shared\n - main\n push:\n branches: *shared\n', + null, + ], + [ + '`branches: *shared` anchored on a filter that misses `main`', + ' pull_request:\n branches:\n - main\n push:\n branches:\n - main\n', + ' pull_request:\n branches: &shared\n - release\n push:\n branches: *shared\n', + 'does not cover `main` (release)', + ], + [ + '`types:` narrowed through an anchored/aliased list item', + ' pull_request:\n branches:\n - main\n', + ' pull_request:\n branches:\n - main\n types: [&t closed, *t]\n', + 'dropping opened, synchronize', + ], + [ + '`tags: *tags` with no `branches:` beside it', + ' push:\n branches:\n - main\n', + " push:\n tags: &tags\n - 'v*'\n", + 'never runs on a push to any branch', + ], + [ + '`needs: *deps` anchored on a job list', + ' format:\n runs-on: ubuntu-latest\n', + ' precheck:\n runs-on: ubuntu-latest\n steps:\n - name: Gate\n run: pnpm install\n format:\n needs: &deps\n - precheck\n runs-on: ubuntu-latest\n', + 'needs: precheck', + ], + [ + '`permissions: *perms` anchored on a WRITE scope', + ' permissions:\n contents: read\n', + ' permissions: &perms\n contents: write\n', + 'WRITE', + ], + [ + '`group: *group` anchored on a constant', + 'concurrency:\n group: format-${{ github.ref }}\n', + 'concurrency:\n group: &group format\n', + 'github.ref', + ], + [ + '`cancel-in-progress: *cancel` anchored on `true`', + 'concurrency:\n group: format-${{ github.ref }}\n cancel-in-progress: true\n', + 'concurrency:\n group: format-${{ github.ref }}\n cancel-in-progress: &cancel true\n', + null, + ], + [ + 'an anchor on a value (`group: &g format-${{ github.ref }}`)', + 'group: format-${{ github.ref }}', + 'group: &g format-${{ github.ref }}', + null, + ], + [ + 'an anchored trigger key (`pull_request: &filters`)', + ' pull_request:\n', + ' pull_request: &filters\n', + null, + ], + ] + + for (const [label, from, to, cause] of aliased) { + it(`${cause === null ? 'accepts' : 'fails on'} ${label}`, () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, from, to, label)) + if (cause === null) { + expect(r.ok, `${label}: ${r.message}`).toBe(true) + return + } + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain(cause) + }) + } + + // An aliased STEP is a whole step the write-mode scan used to lose. The parser puts it + // back, so the alias is scanned exactly like the node it names. + it('follows an aliased step into the write-mode scan', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' - name: Check formatting\n', + ' - &fixer\n name: Fix\n run: npx prettier --write .\n - *fixer\n - name: Check formatting\n', + 'the checking step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('accepts an aliased step that is an allow-listed one', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' - name: Checkout code\n uses: actions/checkout@v4\n', + ' - &checkout\n name: Checkout code\n uses: actions/checkout@v4\n - *checkout\n', + 'the checkout step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // Shell is not YAML: the parser hands a `run:` body over as a string, so a `*` at the + // start of a line inside it (a `case` pattern, a glob) is never an alias. + it('leaves a `*` inside a run block scalar alone', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n`, + ` run: |\n echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n echo "* and & are shell here, not YAML."\n`, + 'the remedy step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) +}) + +// Round 13. Every OTHER surface of the workflow is an allow-list; the shell of the +// non-check, non-remedy steps was the one deny-list left (write-mode formatters, `${{`, +// `secrets.`). Measured on the shipped file: `- name: Sync / run: git fetch origin main && +// git checkout origin/main -- .` before `Check formatting` → ok=true; `run: pnpm install +// && find . -name '*.ts' -not -path './node_modules/*' -delete` → ok=true. Both make +// `pnpm format:check` run on a tree that is not the PR's — the identical AC2 loss measured +// with `with: ref: main` (run 33635537234) and closed by an allow-list there — spelled in +// shell, where no formatter list can name it. The workflow has exactly two such commands: +// `pnpm install` (flags only) and the corepack fallback. +describe('a step outside the check and its remedy runs only the toolchain install (#413)', () => { + const CHECK = ' - name: Check formatting\n' + const INSTALL = ' - name: Install dependencies\n run: pnpm install\n' + const before = (source: string, step: string) => + mutate(source, CHECK, `${step}${CHECK}`, 'the checking step') + const install = (source: string, run: string) => + mutate( + source, + INSTALL, + ` - name: Install dependencies\n run: ${run}\n`, + 'the install step', + ) + + const foreign: [string, (source: string) => string, string][] = [ + [ + 'a sync step that replaces the PR tree with `main`', + source => + before( + source, + ' - name: Sync\n run: git fetch origin main && git checkout origin/main -- .\n', + ), + 'git checkout origin/main -- .', + ], + [ + 'an install line that deletes files afterwards', + source => + install( + source, + "pnpm install && find . -name '*.ts' -not -path './node_modules/*' -delete", + ), + '-delete', + ], + [ + 'an install followed by a second command on the same line', + source => install(source, 'pnpm install; git checkout origin/main -- .'), + 'git checkout', + ], + [ + 'an install with a positional argument (`pnpm install ` edits package.json)', + source => install(source, 'pnpm install left-pad'), + 'left-pad', + ], + [ + 'a bare echo (unquoted words are not an inert message)', + source => before(source, ' - name: Say hello\n run: echo hello\n'), + 'echo hello', + ], + [ + 'a block scalar whose lines are not all toolchain commands', + source => + install( + source, + '|\n pnpm install\n curl -sSf https://example.com/fix.sh | sh', + ), + 'curl', + ], + [ + 'an unconditional step AFTER the check (position does not license it)', + source => + mutate( + source, + ' - name: Explain how to fix it\n', + ' - name: Tests\n run: pnpm test\n - name: Explain how to fix it\n', + 'the remedy step', + ), + 'pnpm test', + ], + [ + 'a step in a second job', + source => + mutate( + source, + '\njobs:\n', + '\njobs:\n other:\n runs-on: ubuntu-latest\n permissions:\n contents: read\n steps:\n - run: git checkout origin/main -- .\n', + 'the jobs key', + ), + 'git checkout', + ], + ] + + for (const [label, edit, needle] of foreign) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow(edit(WELL_FORMED)) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain(needle) + expect(r.message).toContain('toolchain install') + }) + } + + // The message names the loss in its shell spelling, whichever foreign command fired. + it('names `git checkout origin/main -- .` and `find … -delete` as the loss', () => { + const r = checkFormatWorkflow(install(WELL_FORMED, 'pnpm test')) + expect(r.ok).toBe(false) + expect(r.message).toContain('git checkout origin/main -- .') + expect(r.message).toContain('-delete') + expect(r.message).toContain('AC2') + }) + + // The two commands the workflow has, in every spelling this repo uses or may need. + const toolchain: [string, string][] = [ + ['`pnpm install` with a flag', 'pnpm install --frozen-lockfile'], + [ + '`pnpm install` with several flags, one valued', + 'pnpm install --prefer-offline --reporter=append-only', + ], + [ + 'the corepack fallback block, as shipped', + [ + '|', + ' if ! command -v pnpm >/dev/null 2>&1; then', + ' echo "pnpm not found in PATH; attempting to enable via corepack"', + ' corepack enable || true', + ' corepack prepare pnpm@10.15.0 --activate || true', + ' fi', + ].join('\n'), + ], + ['`corepack enable` alone', 'corepack enable'], + ['`corepack prepare` without the `|| true`', 'corepack prepare pnpm@10.15.0 --activate'], + ['a trailing shell comment on the install line', 'pnpm install # frozen by CI=true'], + ] + + for (const [label, run] of toolchain) { + it(`accepts ${label}`, () => { + const r = checkFormatWorkflow(install(WELL_FORMED, run)) + expect(r.ok, r.message).toBe(true) + }) + } + + // Allow-listing the toolchain does not retire the write scan: a write-mode formatter on + // the install line is reported as BOTH — the reader learns the formatter, not only "foreign". + it('still names a write-mode formatter beside the toolchain rejection', () => { + const r = checkFormatWorkflow(install(WELL_FORMED, 'pnpm install && npx prettier --write .')) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + expect(r.message).toContain('toolchain install') + }) + + // The remedy's shell stays deny-list scanned (it runs after the check, on the failure + // path): its message may say what it needs to. + it('leaves the remedy step outside the TOOLCHAIN allow-list (it has its own)', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n`, + ` run: |\n echo "::error::Not formatted. Run 'pnpm format' locally."\n echo "See the step above for the files."\n`, + 'the remedy step', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + // The two reviewer probes, on the SHIPPED file. + it('fires on the shipped workflow when a sync step precedes the check', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + before( + shipped, + ' - name: Sync\n run: git fetch origin main && git checkout origin/main -- .\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('git checkout origin/main -- .') + }) + + it('fires on the shipped workflow when the install line deletes files', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + install(shipped, "pnpm install && find . -name '*.ts' -not -path './node_modules/*' -delete"), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('-delete') + }) +}) + +// `-w` is prettier's documented short form of `--write` (`prettier --help`: "-w, --write +// Edit files in-place"). Measured: `run: pnpm install && npx prettier -w .` → ok=true while +// `--write` was red — the one list the AC6 ban reuses had the long spelling only. +describe('prettier `-w` is `--write` (#413)', () => { + const INSTALL = ' - name: Install dependencies\n run: pnpm install\n' + + it('fails on `npx prettier -w .` in the install step, naming the formatter', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + INSTALL, + ' - name: Install dependencies\n run: pnpm install && npx prettier -w .\n', + 'the install step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('fails on `prettier -w` in the remedy step too, where no allow-list applies', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n`, + ` run: npx prettier -w . && echo "::error::Not formatted. Run 'pnpm format' locally."\n`, + 'the remedy step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) + + it('fires on the shipped workflow', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + INSTALL, + ' - name: Install dependencies\n run: pnpm install && npx prettier -w .\n', + 'the install step', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('prettier --write') + }) +}) + +// Three reader-incompleteness false-fails on CORRECT workflows (the failure class ADL +// 2026-09-01-workflow-guard-rejects-what-it-cannot-read names as the parser flip trigger). +// Each is a spelling GitHub resolves identically to the shipped one — measured on run +// 33676806439 (probe C on PR #477): `run: "pnpm format:check"` logged as `Run pnpm +// format:check`; the whole file with CRLF line endings parsed and ran; workflow-level +// `permissions:` was INHERITED by a job without its own (token: Contents+Issues: read) and +// REPLACED by the job with its own (token: Contents: read only). +describe('a quoted `run:` scalar, CRLF line endings and workflow-level permissions are read (#413)', () => { + const CHECK_RUN = ' run: pnpm format:check\n' + + it('accepts `run: "pnpm format:check"` (double-quoted YAML scalar)', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, CHECK_RUN, ' run: "pnpm format:check"\n'), + ) + expect(r.ok, r.message).toBe(true) + }) + + it("accepts `run: 'pnpm format:check'` (single-quoted)", () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, CHECK_RUN, " run: 'pnpm format:check'\n"), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('still rejects a quoted command that is not THE command', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, CHECK_RUN, ' run: "pnpm format:check --filter=@pair/website"\n'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('not `pnpm format:check`') + }) + + // The same quoted scalar on the INSTALL step. yaml@2.8.2 (the catalog entry) parses + // `run: "pnpm install"` and `run: 'pnpm install'` to the string `pnpm install`; GitHub + // resolves them the same way (run 33676806439). Round 13 unquoted the check command + // but not the setup allow-list, so a correct spelling was red with a misleading cause + // ("runs `"pnpm install"`: … the only shell this workflow runs is the toolchain install"). + const INSTALL_RUN = ' run: pnpm install\n' + const quotedInstall: [string, string][] = [ + ['`run: "pnpm install"` (double-quoted)', ' run: "pnpm install"\n'], + ["`run: 'pnpm install'` (single-quoted)", " run: 'pnpm install'\n"], + ['a quoted install with a flag', ' run: "pnpm install --frozen-lockfile"\n'], + ] + + for (const [label, run] of quotedInstall) { + it(`accepts ${label} on the install step`, () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, INSTALL_RUN, run, 'the install run')) + expect(r.ok, r.message).toBe(true) + }) + } + + it('accepts the shipped workflow with its install line quoted', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(mutate(shipped, INSTALL_RUN, ' run: "pnpm install"\n')) + expect(r.ok, r.message).toBe(true) + }) + + // Quotes do not launder a foreign command: what is inside them is still read. + it('still rejects a quoted install with a positional argument', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, INSTALL_RUN, ' run: "pnpm install left-pad"\n', 'the install run'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('pnpm install left-pad') + expect(r.message).toContain('toolchain install') + }) + + it('still rejects a quoted install chained to a tree rewrite', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + INSTALL_RUN, + ' run: "pnpm install && git checkout origin/main -- ."\n', + 'the install run', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('git checkout origin/main -- .') + expect(r.message).toContain('toolchain install') + }) + + it('accepts the well-formed workflow with CRLF line endings', () => { + const crlf = WELL_FORMED.replace(/\n/g, '\r\n') + expect(crlf).toContain('\r\n') + const r = checkFormatWorkflow(crlf) + expect(r.ok, r.message).toBe(true) + }) + + it('still sees a hole through CRLF line endings', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' push:\n branches:', + ' push:\n paths-ignore:\n - .changeset/**\n branches:', + 'the push trigger', + ).replace(/\n/g, '\r\n'), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('paths-ignore') + expect(r.message).not.toContain('list of events') + }) + + const JOB_PERMISSIONS = ' permissions:\n contents: read\n' + const atWorkflowLevel = (source: string, scope: string, job: string | null) => { + const withoutJob = mutate(source, JOB_PERMISSIONS, job ?? '', 'the job permissions') + return mutate(withoutJob, '\njobs:\n', `\npermissions:\n${scope}\njobs:\n`, 'the jobs key') + } + + it('accepts `permissions: contents: read` at workflow level with none on the job', () => { + const r = checkFormatWorkflow(atWorkflowLevel(WELL_FORMED, ' contents: read\n', null)) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts `permissions: read-all` at workflow level', () => { + const r = checkFormatWorkflow( + mutate( + mutate(WELL_FORMED, JOB_PERMISSIONS, '', 'the job permissions'), + '\njobs:\n', + '\npermissions: read-all\n\njobs:\n', + 'the jobs key', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('rejects a WRITE scope at workflow level inherited by the job, naming the level', () => { + const r = checkFormatWorkflow(atWorkflowLevel(WELL_FORMED, ' contents: write\n', null)) + expect(r.ok).toBe(false) + expect(r.message).toContain('WRITE') + expect(r.message).toContain('workflow-level') + }) + + it('rejects `permissions: write-all` at workflow level', () => { + const r = checkFormatWorkflow( + mutate( + mutate(WELL_FORMED, JOB_PERMISSIONS, '', 'the job permissions'), + '\njobs:\n', + '\npermissions: write-all\n\njobs:\n', + 'the jobs key', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('WRITE') + }) + + it("accepts a job's own `contents: read` under a workflow-level write (the job's block REPLACES it)", () => { + const r = checkFormatWorkflow( + atWorkflowLevel(WELL_FORMED, ' contents: write\n', JOB_PERMISSIONS), + ) + expect(r.ok, r.message).toBe(true) + }) + + it("rejects a job's own write under a workflow-level read", () => { + const r = checkFormatWorkflow( + atWorkflowLevel( + WELL_FORMED, + ' contents: read\n', + ' permissions:\n contents: write\n', + ), + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('WRITE') + expect(r.message).not.toContain('workflow-level') + }) + + it('still rejects no `permissions:` at either level, naming both', () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, JOB_PERMISSIONS, '', 'the job permissions')) + expect(r.ok).toBe(false) + expect(r.message).toContain('declares no `permissions:`') + expect(r.message).toContain('workflow level') + }) + + it('stays green on the shipped workflow under each of the three spellings', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const quoted = mutate(shipped, CHECK_RUN, ' run: "pnpm format:check"\n') + expect(checkFormatWorkflow(quoted).ok, checkFormatWorkflow(quoted).message).toBe(true) + const crlf = shipped.replace(/\n/g, '\r\n') + expect(checkFormatWorkflow(crlf).ok, checkFormatWorkflow(crlf).message).toBe(true) + const inherited = atWorkflowLevel(shipped, ' contents: read\n', null) + expect(checkFormatWorkflow(inherited).ok, checkFormatWorkflow(inherited).message).toBe(true) + }) +}) + +// Every key that decides WHAT or HOW the check runs was allow-listed on the checking +// STEP — and a job-level or workflow-level key reaches that step anyway, so the step +// rule was bypassable by RELOCATION, the same shape as the `working-directory:` / +// `defaults:` pair. `env:` is the measured case, end to end: +// node prettier/bin/prettier.cjs --list-different bad.ts → prints `bad.ts`, exit 1 +// NODE_OPTIONS=--require=$PWD/shim.js node … --list-different bad.ts → prints `bad.ts`, exit 0 +// (`shim.js` is one line: `process.on('exit', () => { process.exitCode = 0 })`; prettier +// is the repo's own pinned 3.6.2.) And a job-level `env:` DOES reach a step that declares +// none of its own — GitHub probe run 33724282486 on PR #477 logged `D5-JOB-ENV=from-job-env`. +// So `pnpm format:check` still NAMES the offending file and the `format` context reports +// SUCCESS on unformatted code: the `with: ref: main` loss class (run 33635537234), spelled +// as a job key. +describe('the workflow and its jobs carry only allow-listed keys (#413)', () => { + const JOB_HEADER = ' format:\n runs-on: ubuntu-latest\n' + + const jobKeys: [string, string][] = [ + [ + '`env:` carrying NODE_OPTIONS', + ' format:\n env:\n NODE_OPTIONS: --require ./scripts/shim.js\n runs-on: ubuntu-latest\n', + ], + [ + '`container:`, third-party code as a job key', + ' format:\n container: node:20\n runs-on: ubuntu-latest\n', + ], + [ + '`services:`', + ' format:\n services:\n redis:\n image: redis\n runs-on: ubuntu-latest\n', + ], + ['`environment:`', ' format:\n environment: production\n runs-on: ubuntu-latest\n'], + ['`outputs:`', ' format:\n outputs:\n files: x\n runs-on: ubuntu-latest\n'], + [ + '`concurrency:` on the job', + ' format:\n concurrency: format\n runs-on: ubuntu-latest\n', + ], + ] + + for (const [label, header] of jobKeys) { + it(`fails on a job declaring ${label}`, () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, JOB_HEADER, header, label)) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + expect(r.message, label).toContain('a job may carry only') + }) + } + + it('fires on the SHIPPED workflow when the job gains an `env:`', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + 'jobs:\n format:\n', + 'jobs:\n format:\n env:\n NODE_OPTIONS: --require ./scripts/shim.js\n', + 'the shipped job header', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('`env`') + }) + + it('fails on a workflow-level `env:`, which every job and step inherits', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'jobs:\n', + 'env:\n NODE_OPTIONS: --require ./scripts/shim.js\njobs:\n', + 'the jobs key', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('at workflow level') + expect(r.message).toContain('`env`') + }) + + // GitHub itself refuses an unknown top-level key — probe run 33724281525 on PR #477 + // (`x-base:` beside `on:`/`jobs:`) produced a run with ZERO jobs, i.e. invalid workflow + // file. The allow-list agrees with the producer rather than tolerating it. + it('fails on an unknown top-level key, which GitHub rejects outright', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, 'jobs:\n', 'x-base:\n runs-on: ubuntu-latest\njobs:\n', 'the jobs key'), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('at workflow level') + expect(r.message).toContain('`x-base`') + }) + + // Over-reach: every key the workflow legitimately carries stays green, and the keys + // owned by a rule of their own keep reporting THAT rule's message, not the generic one. + it('accepts the keys the workflow and its job actually need', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' format:\n runs-on: ubuntu-latest\n permissions:\n contents: read\n', + ' format:\n name: format\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: read\n', + 'the job header', + ), + ) + expect(r.ok, r.message).toBe(true) + }) + + it('accepts a workflow-level `permissions:` and `name:`', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, 'jobs:\n', 'permissions:\n contents: read\njobs:\n', 'the jobs key'), + ) + expect(r.ok, r.message).toBe(true) + }) + + const ownedElsewhere: [string, string, string][] = [ + ['if', " format:\n if: github.event_name == 'push'\n runs-on: ubuntu-latest\n", 'if:'], + [ + 'strategy', + ' format:\n strategy:\n matrix:\n node: [20]\n runs-on: ubuntu-latest\n', + 'strategy', + ], + [ + 'defaults', + ' format:\n defaults:\n run:\n working-directory: packages/dev-tools\n runs-on: ubuntu-latest\n', + 'defaults', + ], + ] + + for (const [key, header, cause] of ownedElsewhere) { + it(`reports \`${key}:\` through its own rule, not the key allow-list`, () => { + const r = checkFormatWorkflow(mutate(WELL_FORMED, JOB_HEADER, header, key)) + expect(r.ok, `${key}: ${r.message}`).toBe(false) + expect(r.message, key).toContain(cause) + expect(r.message, key).not.toContain('a job may carry only') + }) + } +}) + +// The remedy's shell was the module's LAST deny-list (write-mode formatters, `${{`, +// `secrets.`), and it fell to the argument that retired the `uses:` and `with:` ones: a +// deny-list of formatters waves through the next formatter published. Measured on the +// shipped file before this rule: a remedy body of `echo "::error…pnpm format"` + +// `npx dprint fmt` + `git commit -am style && git push` → ok=TRUE. The remedy SAYS what +// to run; it does not run anything. +describe('a failure-path step only says what to run (#413)', () => { + const REMEDY = ` run: echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n` + const remedyBody = (body: string) => + mutate(WELL_FORMED, REMEDY, ` run: |\n${body}`, 'the remedy step') + const KEEP = ` echo "::error::Not formatted. Run 'pnpm format' locally and commit the result."\n` + + const banned: [string, string][] = [ + ['a formatter no offender list names', ' npx dprint fmt\n'], + ['an auto-commit', ' git commit -am style && git push\n'], + ['a checkout rewrite', ' git checkout origin/main -- .\n'], + ['a command substitution inside the message', ' echo "$(prettier --write .)"\n'], + ['a backtick substitution', ' echo "`prettier --write .`"\n'], + ['an unquoted echo argument', ' echo pnpm format && rm -rf .\n'], + ] + + for (const [label, extra] of banned) { + it(`fails on a remedy that also runs ${label}`, () => { + const r = checkFormatWorkflow(remedyBody(`${KEEP}${extra}`)) + expect(r.ok, `${label}: ${r.message}`).toBe(false) + }) + } + + it('names the remedy allow-list as the cause of the dprint row', () => { + const r = checkFormatWorkflow(remedyBody(`${KEEP} npx dprint fmt\n`)) + expect(r.ok).toBe(false) + expect(r.message).toContain('REMEDY_COMMAND_LINES') + }) + + it('fires on the SHIPPED workflow when the remedy gains a formatter', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow( + mutate( + shipped, + ' echo "::error title=Formatting check failed::The files listed in the previous step are not formatted. Run \'pnpm format\' locally, commit the result, and push again. CI never rewrites your branch."\n', + ' echo "::error title=Formatting check failed::The files listed in the previous step are not formatted. Run \'pnpm format\' locally, commit the result, and push again. CI never rewrites your branch."\n npx dprint fmt\n', + 'the shipped remedy body', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('REMEDY_COMMAND_LINES') + }) + + const accepted: [string, string][] = [ + ['a single-quoted echo', ` echo '::error::Not formatted. Run pnpm format locally.'\n`], + ['two quoted echoes', `${KEEP} echo "See the step above for the file list."\n`], + [ + 'an echo with a flag', + ` echo -e "::error::Not formatted. Run 'pnpm format' locally."\n`, + ], + ['a printf', ` printf '%s\\n' "::error::Not formatted. Run 'pnpm format' locally."\n`], + ] + + for (const [label, body] of accepted) { + it(`accepts a remedy that is ${label}`, () => { + const r = checkFormatWorkflow(remedyBody(body)) + expect(r.ok, `${label}: ${r.message}`).toBe(true) + }) + } + + it('accepts the shipped remedy, which is one quoted echo', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + expect(checkFormatWorkflow(shipped).ok).toBe(true) + }) + + // The remedy still has to NAME the remedy — the allow-list narrows the shell, it does + // not replace AC1's content rule. + it('still fails when the allow-listed echo says nothing about `pnpm format`', () => { + const r = checkFormatWorkflow(remedyBody(' echo "::error::Something went wrong."\n')) + expect(r.ok).toBe(false) + expect(r.message).toContain('names the remedy') + }) +}) + +// `jobKeyProblems` allow-lists the KEYS a job may declare and `runs-on` is on that list, +// so until `runsOnProblems` the VALUE was free: measured on the SHIPPED file, `runs-on: +// self-hosted` was `ok=true`, and so was `[self-hosted, linux]` — one line relocating the +// `format` check onto a machine the pull request chose, with every other rule green. It is +// the `container:` argument spelled as a value: the machine decides what `pnpm` and +// `prettier` are, and on a PUBLIC repo a `pull_request` run executes the PR's OWN copy of +// this file. +// +// Boundary proof, GitHub itself (probe workflow on this PR, run 33729726089, reverted): +// runs-on: ubuntu-24.04 -> job `d7-ubuntu-2404` completed success (x86_64, 24.04) +// runs-on: ubuntu-22.04 -> job `d7-ubuntu-2204` completed success (x86_64, 22.04) +// runs-on: 'ubuntu-latest' -> job `d7-quoted-ubuntu` completed success, RUNNER_ENVIRONMENT=github-hosted +// runs-on: [ubuntu-latest] -> job `d7-list-ubuntu` completed success, RUNNER_ENVIRONMENT=github-hosted +// runs-on: self-hosted -> job `d7-self-hosted` still `queued`, never started +// runs-on: [self-hosted, …] -> job `d7-self-hosted-list` still `queued`, never started +// (`gh api repos/foomakers/pair/actions/runners` -> `total_count: 0`.) So the quoted and +// one-element-list spellings are the SAME machine, not a weaker one; and self-hosted reads +// today as a BLOCKED merge — the context stays PENDING, never SUCCESS — which lasts exactly +// as long as no self-hosted runner is registered on this public repo. +// +// A LIST IS ANDed. Boundary proof, GitHub itself (probe workflow on this PR, run +// 33782665948, reverted), 20 minutes after the run started: +// runs-on: [ubuntu-latest] -> `d8-one-ubuntu` success +// runs-on: [ubuntu-latest, ubuntu-latest] -> `d8-two-same-label` success (GitHub dedupes) +// runs-on: [ubuntu-latest, ubuntu-22.04] -> `d8-two-ubuntu` still `queued`, never started +// runs-on: [ubuntu-latest, ubuntu-24.04, ubuntu-22.04]-> `d8-three-ubuntu` still `queued`, never started +// and, in a second probe workflow (run 33782655630): +// runs-on: [[ubuntu-latest]] -> the RUN failed outright, no job scheduled +// So a value every element of which is on the allow-list can still name NO machine: the +// rule counts labels (exactly one), it does not merely allow-list each of them. The +// duplicate spelling is measured GREEN on GitHub and rejected here anyway — one label is +// the contract, and the direction of that narrowing is a false red, never a false green. +describe('runs-on is an allow-list of GitHub-hosted Ubuntu labels (#413)', () => { + const runsOn = (source: string, value: string) => + mutate(source, ' runs-on: ubuntu-latest\n', ` runs-on: ${value}\n`, 'the runs-on line') + + const accepted: [string, string][] = [ + ['the pinned 24.04 image', 'ubuntu-24.04'], + ['the pinned 22.04 image', 'ubuntu-22.04'], + ['a single-quoted label', "'ubuntu-latest'"], + ['a double-quoted label', '"ubuntu-24.04"'], + ['a one-element flow sequence', '[ubuntu-latest]'], + ['a one-element block sequence', '\n - ubuntu-latest'], + ] + + for (const [label, value] of accepted) { + it(`accepts ${label}`, () => { + const r = checkFormatWorkflow(runsOn(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(true) + }) + } + + // The parser resolves the alias, so the rule reads the LABEL, not the `*runner` text. + it('accepts a label reached through an anchor and an alias', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + ' runs-on: ubuntu-latest\n', + ' runs-on: &runner ubuntu-latest\n', + 'the runs-on line', + ) + + ' helper:\n runs-on: *runner\n permissions:\n contents: read\n steps:\n - run: pnpm install\n', + ) + expect(r.ok, r.message).toBe(true) + }) + + const rejected: [string, string][] = [ + ['a bare self-hosted label', 'self-hosted'], + ['a self-hosted label list', '[self-hosted, linux]'], + ['a list that only CONTAINS an allowed label', '[ubuntu-latest, self-hosted]'], + ['a runner group mapping', '\n group: ubuntu-runners'], + ['a runner group with labels', '\n group: ubuntu-runners\n labels: [ubuntu-latest]'], + ['another GitHub-hosted OS', 'macos-latest'], + ['a Windows image', 'windows-latest'], + ['a larger runner label', 'ubuntu-latest-4-cores'], + ['an arm image', 'ubuntu-24.04-arm'], + ['a label chosen by a repository variable', '${{ vars.RUNNER }}'], + ['a label chosen by a matrix', '${{ matrix.os }}'], + ['an empty value', ''], + ['an empty list', '[]'], + ['a nested sequence', '[[ubuntu-latest]]'], + // A LIST IS ANDed BY GITHUB (probe run 33782665948, table above): every extra label + // narrows the runner set, and no GitHub-hosted image carries two image labels, so a + // list of allow-listed labels reaches NO runner — the job queues and the `format` + // context stays PENDING, the same blocked-merge loss as `self-hosted`. + ['a two-label list of allow-listed images', '[ubuntu-latest, ubuntu-22.04]'], + ['a three-label list of allow-listed images', '[ubuntu-latest, ubuntu-24.04, ubuntu-22.04]'], + ['a two-label block sequence', '\n - ubuntu-latest\n - ubuntu-22.04'], + // MEASURED GREEN on GitHub (`d8-two-same-label` completed success — GitHub dedupes the + // label set): rejected anyway. The contract is ONE label, and a spelling nobody needs + // is not worth a second acceptance path; the direction is safe (a false red, never a + // false green). + ['the same allow-listed label twice', '[ubuntu-latest, ubuntu-latest]'], + ['a sequence carrying a mapping', '[{group: ubuntu-runners}]'], + ] + + for (const [label, value] of rejected) { + it(`fails on ${label}`, () => { + const r = checkFormatWorkflow(runsOn(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + expect(r.message, value).toContain('runs-on') + }) + } + + // The rejection QUOTES the value back, so a non-scalar item must not flatten into the + // spelling the rule accepts: `[[ubuntu-latest]]` rendered as `[ubuntu-latest]` reads + // "you wrote `[ubuntu-latest]`, which is rejected; use `[ubuntu-latest]`". + const rendered: [string, string, string][] = [ + ['a nested sequence', '[[ubuntu-latest]]', '`runs-on: [["ubuntu-latest"]]`'], + ['a sequence carrying a mapping', '[{group: x}]', '`runs-on: [{"group":"x"}]`'], + [ + 'a sequence carrying a null item', + '[ubuntu-latest, null]', + '`runs-on: [ubuntu-latest, null]`', + ], + [ + 'a two-label list', + '[ubuntu-latest, ubuntu-22.04]', + '`runs-on: [ubuntu-latest, ubuntu-22.04]`', + ], + ] + + for (const [label, value, quoted] of rendered) { + it(`quotes ${label} back distinguishably`, () => { + const r = checkFormatWorkflow(runsOn(WELL_FORMED, value)) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + expect(r.message, value).toContain(quoted) + }) + } + + it('never quotes a REJECTED value back as the accepted one-label spelling', () => { + const r = checkFormatWorkflow(runsOn(WELL_FORMED, '[[ubuntu-latest]]')) + expect(r.message).not.toContain('`runs-on: [ubuntu-latest]`') + }) + + it('fails when the job declares no `runs-on` at all', () => { + const r = checkFormatWorkflow( + mutate(WELL_FORMED, ' runs-on: ubuntu-latest\n', '', 'the runs-on line'), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('no `runs-on:`') + }) + + // The rule is per JOB, not per host job: a second job on a machine the PR chose runs + // `pnpm install` — PR-authored lifecycle scripts — on that machine just the same. + it('fails on a SECOND job pinned to a self-hosted runner', () => { + const r = checkFormatWorkflow( + mutate( + WELL_FORMED, + 'jobs:\n', + 'jobs:\n helper:\n runs-on: self-hosted\n steps:\n - run: pnpm install\n', + 'the jobs key', + ), + ) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('runs-on') + expect(r.message).toContain('helper') + }) + + it('fires on the SHIPPED workflow when it is relocated to a self-hosted runner', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(runsOn(shipped, 'self-hosted')) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('runs-on') + }) + + it('fires on the SHIPPED workflow when it is relocated through a label list', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(runsOn(shipped, '[self-hosted, linux]')) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('runs-on') + }) + + it('fires on the SHIPPED workflow when it is widened to a two-label list', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(runsOn(shipped, '[ubuntu-latest, ubuntu-22.04]')) + expect(r.ok, r.message).toBe(false) + expect(r.message).toContain('`runs-on: [ubuntu-latest, ubuntu-22.04]`') + }) + + it('accepts the shipped workflow, which runs on a GitHub-hosted Ubuntu image', () => { + const shipped = readFileSync(FORMAT_WORKFLOW, 'utf-8') + const r = checkFormatWorkflow(shipped) + expect(r.ok, r.message).toBe(true) + }) + + // The message is the entire user-facing product of a RED gate, and its two failure modes + // have no other guard: it asserts things about GitHub that no rule re-measures, and + // prettier does not reflow a string literal, so a spliced sentence leaves a ragged line + // nothing in the toolchain catches. + describe('the rejection message says only what was measured, at one column width', () => { + const flat = (m: string) => m.replace(/\s+/g, ' ') + const rejectionFor = (value: string) => { + const r = checkFormatWorkflow(runsOn(readFileSync(FORMAT_WORKFLOW, 'utf-8'), value)) + expect(r.ok, `${value}: ${r.message}`).toBe(false) + return r.message + } + + // MEASURED AT THE PRODUCER. Probe run 33782665948, re-read this round with + // `gh api repos/foomakers/pair/actions/runs/33782665948/jobs`: job `d8-two-same-label` + // (`runs-on: [ubuntu-latest, ubuntu-latest]`) got runner `GitHub Actions 1000003379`, + // ran `Set up job` / its `echo` / `Complete job` and completed `success` — GitHub + // dedupes the label SET. Only the DIFFERENT-label rows sat unscheduled: + // `d8-two-ubuntu` and `d8-three-ubuntu` show `runner_name: ""`, zero steps, still + // queued 23 minutes later. So "the job never gets a runner and the context stays + // PENDING" is true of two DIFFERENT labels and FALSE of a repeated one — and this + // message tags it `(measured)`, on a value a contributor can disprove in one push. + it('scopes the never-starts claim to DIFFERENT labels', () => { + const m = flat(rejectionFor('[ubuntu-latest, ubuntu-latest]')) + expect(m).toMatch(/two DIFFERENT allow-listed labels are ANDed[^.]*never starts/) + expect(m).not.toMatch(/A list of TWO allow-listed labels[^.]*never gets a runner/) + }) + + it('names the repeated label as its own case, not as a job that never runs', () => { + const m = flat(rejectionFor('[ubuntu-latest, ubuntu-latest]')) + expect(m).toMatch(/EXACTLY ONE label/) + expect(m).toMatch(/repeated label is rejected too/) + expect(m).toMatch(/GitHub dedupes/) + }) + + // The same paragraph is printed for every rejected value, so the two rows that DO + // queue forever must still read as measured fact. + it('keeps the PENDING claim for the values that really never start', () => { + for (const value of ['self-hosted', '[ubuntu-latest, ubuntu-22.04]']) { + expect(flat(rejectionFor(value)), value).toMatch(/PENDING, never SUCCESS \(measured\)/) + } + }) + + // Line 0 is the file header and line 1 carries the quoted value (its width is the + // contributor's, not ours); the last line closes the paragraph. Everything between is + // body text and must render as one column (the paragraph is wrapped at 95; the floor + // is 80, which is where greedy wrapping lands when the next word is a long one) — a + // 48-char line beside 95-char neighbours is what a mid-line splice leaves behind. + it('wraps every body line to one column width', () => { + for (const value of [ + 'self-hosted', + '[ubuntu-latest, ubuntu-latest]', + '[ubuntu-latest, ubuntu-22.04]', + ]) { + for (const line of rejectionFor(value).split('\n').slice(2, -1)) { + expect(line.length, `${value}: ${line.length} chars — ${line}`).toBeGreaterThanOrEqual(80) + expect(line.length, `${value}: ${line.length} chars — ${line}`).toBeLessThanOrEqual(99) + } + } + }) + }) +}) diff --git a/packages/dev-tools/src/quality-gates/format-workflow-composition.ts b/packages/dev-tools/src/quality-gates/format-workflow-composition.ts new file mode 100644 index 000000000..7d1b0aef7 --- /dev/null +++ b/packages/dev-tools/src/quality-gates/format-workflow-composition.ts @@ -0,0 +1,1967 @@ +/** + * format-workflow-composition — keeps CI's formatting check real (story #413). + * + * Since #394 the pre-push hook CHECKS formatting instead of applying it, which is + * the right shape for a hook — but it left the local hook as the ONLY enforcement + * point. `--no-verify`, or a contributor whose hooks are not installed, lands + * unformatted code with every CI check green. `.github/workflows/format.yml` is + * the fix; this module is what stops that fix from quietly decaying. + * + * THE FILE IS PARSED, NOT READ LINE BY LINE (ADL 2026-09-01 + * `workflow-guard-rejects-what-it-cannot-read`, amended 2026-09-03). `yaml@2.8.2` — this + * repo's adopted parser — resolves the workflow to the same document GitHub runs, once, + * at the top of `checkFormatWorkflow`. Every rule below reads NODES. The hand-rolled + * line reader this module used between rounds 5 and 14 (`blockUnder`, `listValueOf`, + * `scalarAt`, `keysAt`, `stepsOf`, `withoutBlockScalars`) is retired, and with it the + * four rule families that existed only to reject the spellings it could not follow: + * flow mappings, JSON-spelled steps, anchors/aliases, indentless sequences, CRLF and + * quoted scalars are now READ, and their resolved values are subject to every rule + * here. The reader failed OPEN on those spellings (round 5: `pull_request: { branches: + * [main], paths-ignore: ['**\/*.md'] }` left every trigger rule passing vacuously) and + * then failed CLOSED on five correct ones (rounds 12–14); a parser does neither. + * + * Two properties the parse gives for free and the rules rely on: + * + * - **fail-closed on an unreadable file**: a parse error is itself a reported problem, + * and so is a duplicate key (`yaml@2.8.2` rejects both) — the direction the rejection + * list used to hold. + * - **`run:` bodies stay shell, never YAML**. The parser hands each `run:` scalar over + * as a string; shell comments inside it are stripped quote-aware (`stripLineComment`), + * because `#` inside quotes is not a comment to bash and cutting there once removed an + * executing `prettier --write .` from view. + * + * Every regression this guards against is a one-line edit that leaves the + * workflow LOOKING like enforcement: + * + * - a `paths-ignore:` key. This is why the check is a dedicated workflow and not + * a job in `ci.yml`: that workflow carries `paths-ignore: ['.changeset/**']` at + * WORKFLOW level, which a job inherits — so a PR touching only `.changeset/**` + * would run no formatting check at all. Trigger coverage is part of check + * coverage. + * - `paths:`, the same hole spelled positively — an allow-list excludes everything + * it does not name, so a markdown-only PR runs no check at all. + * - a trigger narrowed off the base branch (`pull_request.branches: [release]`, or + * its negative spelling `branches-ignore: [main]`) or off the events that matter + * (`types: [closed]` runs the check only once the PR is closed). Whatever shapes + * the trigger shapes the coverage. + * - `pull_request_target` instead of `pull_request`, which hands the base repo's + * credentials to a fork's head commit. + * - a write-mode formatter, or a formatting auto-commit. The ADL 2026-07-31 ban + * ("the gate reports, the developer fixes deliberately") is repo-wide, not + * hook-specific — CI repairing the branch is the same defect one layer up. Held by + * the SHELL ALLOW-LISTS below, not by the formatter deny-list: every `run:` in this + * workflow is one of three things and each is allow-listed (the checking command as + * an equality, `SETUP_COMMAND_LINES` for the toolchain install, `REMEDY_COMMAND_LINES` + * for the failure-path message). `findWriteModeFormatters` still runs across all of + * them, as the repo-wide statement of the ban rather than as the surface that holds it. + * - the same write, spelled `uses:` instead of `run:`. The write-mode scan reads + * `run:` blocks, so a formatting ACTION was invisible to it — and placed before the + * checking step it needs no permission at all, because it never pushes. Hence an + * allow-list of the three actions this job needs (`usesProblems`). + * - a checking command that is not THE command. "Some run block references the + * script" was satisfied by `pnpm --filter= format:check` and by `cd && + * pnpm format:check`, each making CI check a strict subset of the tree the developer + * and the hook check — AC4's divergence, reinstated green. The command is an + * equality (`checkCommandProblems`). + * - the job that RUNS the check renamed, or displaced by a decoy. The job's DISPLAY + * NAME — its id when `name:` is absent — is the status context GitHub publishes: + * `format` is what way-of-working requires and what AC8 tells branch protection to + * list, so `fmt:` deletes the context without touching a rule — and a `format:` job + * that only echoes, beside a `worker:` job carrying the real steps, reports SUCCESS + * in that context after an echo. Asserted on the HOST job, never on the set of job + * names (`jobIdentityProblems`). And the id is not the whole story: a `name:` line + * renames the published context as surely as the id does (this repo's version.yml, + * job id `version`, is published as `Create version commits and tags`), and a + * `strategy.matrix` suffixes its values (`format (20)`), so `name:` other than + * `format` and any `strategy:` on the host are rejected, and `name: format` on any + * OTHER job is the decoy spelled through the display name. + * - a `push:` trigger filtered by `tags:` alone. `branches` and `tags` are independent + * filters and GitHub fires the event only for the ref kinds a filter names, so with no + * `branches:` the workflow never runs on a push to any branch — measured on this + * repo's release.yml, whose `push` runs are all tags. "No filter ⇒ every branch" was + * the wrong reading for it (`tagFilterProblems`). + * - a `concurrency.group` that is not keyed on `github.ref`. Every claim below about + * the two triggers "never meeting" holds only because of that key; `group: format` + * puts every run in one group, so a PR push cancels an in-progress run on `main` + * (the cancel is conditioned on the PR event, which the PR push is) and that commit + * ends with no verdict (`groupProblems`). + * - dropping `push: main`, so drift on the base branch is invisible. + * - dropping `concurrency`, so a superseded run keeps burning a runner and + * reporting a stale verdict for a ref that has already moved on. + * - `continue-on-error: true`, ANY `if:` on the job, ANY `needs:` on the job, ANY `if:` + * on the step that runs `format:check`, a step `if:` elsewhere that is not a SCOPED + * `failure()` guard, or + * a write-scoped `permissions:`. None of these touch a trigger or a step COMMAND, + * yet each turns the `format` context into one that cannot fail, never runs, or + * hands a write-scoped token to a job that executes PR-authored lifecycle scripts + * (`pnpm install`). AC5's "safe on fork PRs by construction, not by review" is only + * construction if the construction is asserted. `if:` is an ALLOW-list on purpose: + * a deny-list of literal falses (`if: false`) waves through every never-true + * EXPRESSION — `if: github.event_name == 'workflow_dispatch'` on the job, or + * `if: github.event_name == 'push'` on the checking step, both leave a SUCCESS + * `format` context on unformatted code. And `failure()` is not the allow-list + * either: on the CHECKING step it is false on a normal PR, so the check is skipped + * and the job ends green wearing the remedy's own spelling — hence no condition at + * all there, and everywhere else `failure()` AND the check step's own outcome. + * `needs:` sits in the same bullet because it is that neutralization with no + * condition written anywhere: a job whose dependency fails or is skipped never runs, + * is reported skipped, and a skipped job's required check reads SUCCESSFUL. + * - dropping the failure-path remedy, or widening it. `--list-different` prints the + * offending file and suppresses prettier's own "run with --write to fix" line, so + * a red check without that step hands the contributor this story exists for — + * hooks not installed, pushed with `--no-verify` — a bare filename and no + * instruction (AC1). And an unscoped `if: failure()` is JOB-scoped, so a failed + * `pnpm install` is annotated "not formatted, run `pnpm format`" — a confident + * wrong diagnosis over the real cause. EVERY failure-path step is therefore + * conditioned on the checking step's own `outcome`: one scoped step does not + * license a second, unscoped one beside it. And that scope must RESOLVE, not + * merely be spelled — `steps` is job-local and empty before the step has run, so a + * remedy in a SECOND JOB, or ABOVE the checking step in the same job, carries a + * condition false on every run: the remedy fires never and AC1's contributor reads + * the bare filename anyway. Nor is naming the context enough: the condition must + * COMPARE it to `'failure'`. `outcome` holds one of four values, so + * `steps..outcome == 'success'` still mentions the check and is false exactly + * when the check fails — the same never-firing remedy from a one-token edit. Nor is + * the comparison being PRESENT enough: it must DECIDE. `&&` binds tighter than + * `||`, so `failure() && || steps.install.outcome == 'failure'` keeps the + * scope spelled exactly right and puts the broken-install annotation back; and a + * CONJUNCT can narrow the remedy to zero — `&& github.event_name == 'push'` is false + * on every pull_request run (measured: check fails, remedy skipped). The condition + * is a conjunction of allow-listed terms (`failure()`, `!cancelled()`/`!success()`, + * the scope equality); `||`, a negated scope and any other conjunct are rejected. + * - the checkout's `with:`. `uses:` was matched on the action NAME; `with: ref: main` + * makes actions/checkout check out `main` instead of the PR merge ref, so a PR + * carrying an unformatted file gets a SUCCESSFUL `format` context (measured on + * GitHub). `repository`, `path`, `sparse-checkout` are the same loss; the checkout's + * inputs are an allow-list of fetch mechanics (`checkoutInputProblems`). + * - `working-directory:` on the checking step, or `defaults:` on a job or the + * workflow — `cd` spelled as a key, invisible to the command equality, so CI runs a + * package's own `format:check` (a subset) the moment one declares the script. The + * checking step carries only `name`, `id`, `run`, `timeout-minutes` + * (`checkStepKeyProblems`, `defaultsProblems`). + * - `env:` or `container:` on the JOB — the same relocation one level up. Every key + * that decides WHAT or HOW the check runs is allow-listed on the checking STEP, and + * a job-level key reaches that step anyway, so the step-level rule was bypassable by + * relocation exactly as `working-directory:`/`defaults:` were. Measured end-to-end + * against the repo's own pinned prettier 3.6.2: `node bin/prettier.cjs + * --list-different bad.ts` prints `bad.ts` and exits 1, while + * `NODE_OPTIONS=--require=./shim.js node bin/prettier.cjs --list-different bad.ts` + * prints `bad.ts` and exits 0 (`shim.js` being one line: `process.on('exit', () => { + * process.exitCode = 0 })`). A job-level `env: NODE_OPTIONS:` reaches the checking + * step — measured on GitHub, probe run 33724282486 on PR #477, `D5-JOB-ENV=from-job-env` + * logged from a step that declares no `env:` of its own — so `pnpm format:check` still + * NAMES the offending file and the job goes GREEN. `container:` is the `uses:` + * third-party-code argument spelled as a job key (the image decides what `pnpm` and + * `prettier` even are), and `services:` with it. Hence the workflow's own keys and + * every job's keys are allow-lists (`workflowKeyProblems`, `jobKeyProblems`). + * - `runs-on:` pointing off GitHub's runners. Those two rules allow-list which KEYS may + * appear; `runs-on` is on the job's list because the job needs a machine, and its VALUE + * picks which one — the same "what are `pnpm` and `prettier` here" question `container:` + * asks, and a machine also decides who watches the run. On a PUBLIC repo a + * `pull_request` run executes the PR's own version of this file, so the value is chosen + * by the pull request. `runs-on: self-hosted` (and `[self-hosted, linux]`) was `ok=true` + * with every other rule green, so the value is an allow-list too: GitHub-hosted Ubuntu + * labels, alone or as a one-label list (`RUNNER_LABELS`, `runsOnProblems`). On this repo + * today that regression reads as a BLOCKED merge rather than a green check — no + * self-hosted runner is registered (`total_count: 0`), so the job queues and `format` + * stays pending (measured, probe run 33729726089) — which lasts exactly as long as that + * stays true. ONE label, counted, not "every label allow-listed": GitHub ANDs a label + * list, so `[ubuntu-latest, ubuntu-22.04]` names no machine at all (measured, probe run + * 33782665948) and its job queues forever — the same blocked-merge loss as `self-hosted`, + * reached by a value every element of which is on the allow-list. + * - `cancel-in-progress` and `concurrency.group` read as SUBSTRINGS. `!(github.event_name + * == 'pull_request')` contains the accepted equality and inverts it; `format-${{ + * github.run_id }}-${{ github.ref }}` contains the ref key and is unique per run. + * Both values are anchored allow-lists over the WHOLE value now. + * - the shell of a step that is neither the check nor its remedy. `run:` was the last + * deny-list surface in this module, and the `with: ref: main` loss has a shell + * spelling no formatter list names: `run: git fetch origin main && git checkout + * origin/main -- .` before the check, or `pnpm install && find . -name '*.ts' + * -delete` — each `ok=true` on the shipped file, each running `pnpm format:check` on a + * tree that is not the PR's (AC2). The toolchain install is the whole allow-list: `pnpm + * install` with flags, and the corepack fallback line by line (`setupCommandProblems`). + * - the shell of the REMEDY. It is the last surface a deny-list guarded, and the same + * argument retires it: `npx dprint fmt` (a formatter no offender list names) and + * `git commit -am style && git push` beside the required `pnpm format` message were + * `ok=true`. The remedy says something; it does not do anything. Its shell is an + * allow-list of quoted `echo`/`printf` lines (`REMEDY_COMMAND_LINES`) — which is all + * it has ever contained. + * - `prettier -w`, the documented short form of `--write`, missing from the one offender + * list the AC6 ban reuses (`WRITE_MODE_FORMATTERS`, next door): `pnpm install && npx + * prettier -w .` was green while `--write` was red. + * + * Structure is asserted, never exact file text: comments, step names, action + * versions and YAML spelling must be editable without false-failing this guard. + * + * WRITE-MODE DETECTION IS NOT RE-IMPLEMENTED. `findWriteModeFormatters` and the + * transitive `expandScriptReferences` come from the pre-push guard next door — the + * workflow says `pnpm format:check`, so scanning its literal text would miss the + * likeliest regression (`pnpm format`, which only resolves to `prettier:fix` + * through the root scripts). Two copies of that offender list would drift, and the + * one that drifts is always the one guarding the newer surface. + * + * Per the gate-tooling ADL (2026-07-13) the logic lives here as a tested module, with a + * thin `main()` CLI behind a `require.main` guard (`format-workflow:check`, run by the + * root `gate:composition` — AC6). Turbo's cache is handled by the `$TURBO_ROOT$` input + * entry on `@pair/dev-tools#test` (turbo.json) — the same treatment + * `@pair/knowledge-hub#test` already uses for repo-wide artifacts — so `pnpm test` is the + * other enforcement point and is never a stale PASS. + */ +import { readFileSync } from 'fs' +import { resolve } from 'path' + +import { parseDocument } from 'yaml' + +import { + expandScriptReferences, + findWriteModeFormatters, + referencesScript, + REMEDY_SCRIPT, + ROOT_PACKAGE_JSON, + type GateCheckResult, +} from './pre-push-gate-composition' +import { REPO_ROOT } from './repo-root' + +/** The workflow this guard reads — the real file, never a fixture copy. */ +export const FORMAT_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/format.yml') + +/** The one command CI must run. Same script a developer runs — that is the point. */ +export const FORMAT_CHECK_SCRIPT = 'format:check' + +/** The branch whose formatting drift must be visible post-merge, not only on PRs. */ +export const BASE_BRANCH = 'main' + +/** + * The job id — and, with no `name:` on the job, its display name, which is what GitHub + * publishes as the status context. way-of-working documents `format` as the required + * check and AC8 names it as the context branch protection must list, so renaming the + * job, giving it a `name:`, or giving it a matrix deletes that context without touching + * a rule. + */ +export const FORMAT_JOB = 'format' + +/** + * The complete set of actions this workflow may use. An ALLOW-list, like `if:`, and + * for the same reason: the write-mode scan reads `run:` blocks, so a step that writes + * through an ACTION is invisible to it, and a deny-list of formatting actions waves + * through the next one published. + */ +export const ALLOWED_USES = ['actions/checkout', 'pnpm/action-setup', 'actions/setup-node'] as const + +/** + * The `with:` inputs `actions/checkout` may carry — fetch mechanics only, i.e. inputs + * that leave the checked-out tree as the event's ref. An ALLOW-list for the same reason + * `uses:` is one: the action's `ref` input "defaults to the reference or SHA for that + * event" (action.yml) and setting it REPLACES the PR merge ref, so a `with:` the guard + * never read decided WHAT `pnpm format:check` ran on. Measured on GitHub (probe run on + * PR #477): `with: ref: main` on the shipped checkout, a PR carrying an unformatted file + * → `Check formatting: success`, `format` context SUCCESS — AC2 defeated by one line. + * `repository` and `path` change which tree the command runs on at all; `sparse-checkout` + * / `sparse-checkout-cone-mode` check out a SUBSET (AC4's `--filter=` divergence spelled + * as an input). The toolchain actions' inputs choose versions, never the tree, and are + * not constrained. + */ +export const ALLOWED_CHECKOUT_INPUTS = [ + 'fetch-depth', + 'fetch-tags', + 'show-progress', + 'persist-credentials', + 'clean', + 'set-safe-directory', +] as const + +/** + * The `pull_request` activity types the check must keep covering. GitHub's default + * is `[opened, synchronize, reopened]`; a narrowing that keeps `reopened` but drops + * either of these two runs the check where it cannot influence a review. + */ +export const REQUIRED_PR_TYPES = ['opened', 'synchronize'] as const + +/** + * The keys the WORKFLOW may declare. `env:` is the reason this is an allow-list: it is + * inherited by every job and every step, so it reaches the checking step no matter what + * the step-level allow-list says (see `jobKeyProblems`). `defaults:` has its own rule, + * which names the loss precisely, so it is not reported twice here. + */ +export const WORKFLOW_KEYS = ['name', 'on', 'concurrency', 'permissions', 'jobs'] as const +const WORKFLOW_KEYS_OWNED_ELSEWHERE = ['defaults'] as const + +/** + * The keys a JOB may declare. Same allow-list discipline as `CHECK_STEP_KEYS`, one level + * up: `env:` on the job reaches the checking step (measured on GitHub, probe run + * 33724282486 — a step declaring no `env:` printed the job-level value), and + * `NODE_OPTIONS=--require=` makes the repo's own prettier 3.6.2 print the offending + * filename and exit 0 (measured against `prettier/bin/prettier.cjs`), i.e. `format` GREEN + * on unformatted code. `container:`/`services:` decide what `pnpm` and `prettier` even + * ARE — third-party code chosen by a job key, which is the `uses:` argument one level up. + * `if`, `needs`, `strategy`, `defaults` and `continue-on-error` have their own rules and + * are not reported twice here. + * + * This is an allow-list of KEYS. `runs-on` is on it because the job needs a machine, and + * its VALUE picks which — the same question `container:` asks — so that value has an + * allow-list of its own (`runsOnProblems`). + */ +export const JOB_KEYS = ['name', 'runs-on', 'permissions', 'timeout-minutes', 'steps'] as const +const JOB_KEYS_OWNED_ELSEWHERE = [ + 'if', + 'needs', + 'strategy', + 'defaults', + 'continue-on-error', +] as const + +/** + * The machines this workflow may run on: GitHub-hosted Ubuntu images, ephemeral and + * owned by GitHub. Every other label — `self-hosted`, a runner `group:`, another + * GitHub-hosted OS (`macos-*`, `windows-*`), a larger or arm runner — is a deliberate + * edit to this list, because each changes what `pnpm` and `prettier` are on the machine + * that publishes the `format` context. Measured on GitHub (probe run 33729726089, PR + * #477): `ubuntu-24.04`, `ubuntu-22.04`, a quoted `'ubuntu-latest'` and the one-element + * list `[ubuntu-latest]` all ran on a GitHub-hosted x86_64 runner + * (`RUNNER_ENVIRONMENT=github-hosted`), so the list spelling is the same value, not a + * weaker one. + */ +export const RUNNER_LABELS = ['ubuntu-latest', 'ubuntu-24.04', 'ubuntu-22.04'] as const + +/** + * Removes `#` comments from a `run:` body so a comment can neither smuggle a banned + * pattern in nor trip the guard by merely mentioning one — but only a `#` that is + * OUTSIDE quotes. + * + * YAML's own comments never reach here: the parser drops them. This is the SHELL's + * comment rule, applied to shell text. The unconditional cut this replaces ran the wrong + * way: `#` inside quotes is not a comment to bash, so `echo "note # here"; prettier + * --write .` EXECUTES in full while the guard saw only `echo "note` — the write-mode + * formatter stripped out of view, the AC6 ban silently gone, and `pnpm format:check` + * passing on a tree CI had already rewritten. + * + * Bias is deliberate on the one ambiguity left: an UNBALANCED quote earlier in the + * line leaves a later `#` unstripped, so the guard scans more text than the shell + * runs. Scanning too much can only add a problem, never hide one. + */ +function stripLineComment(line: string): string { + let quote: string | null = null + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (quote !== null) { + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' && (index === 0 || /\s/.test(line[index - 1] ?? ''))) + return line.slice(0, index) + } + return line +} + +/** A `run:` body as the shell sees it: comments gone, blank edges trimmed. */ +function shellOf(run: string): string { + return run.split('\n').map(stripLineComment).join('\n').trim() +} + +type Mapping = Record + +function isMapping(value: unknown): value is Mapping { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * The values of a list-valued key. `null` means the key is ABSENT — for a trigger filter + * that means "no filter", which GitHub reads as "every value", so callers treat `null` as + * covering everything. A key present with an empty or null value is `[]`, which covers + * nothing: the two are not the same and the parser is what finally tells them apart. + */ +function filterOf(block: Mapping, key: string): string[] | null { + if (!(key in block)) return null + const value = block[key] + if (value === null || value === undefined) return [] + return Array.isArray(value) ? value.map(item => String(item)) : [String(value)] +} + +/** A scalar key's value as text, or `undefined` when the key is absent. */ +function textAt(block: Mapping, key: string): string | undefined { + if (!(key in block)) return undefined + const value = block[key] + return value === null || value === undefined ? '' : String(value) +} + +/** Every key and string scalar in the document, so a textual scan reads the whole file. */ +function everyText(node: unknown): string[] { + if (typeof node === 'string') return [node] + if (Array.isArray(node)) return node.flatMap(everyText) + if (isMapping(node)) + return Object.entries(node).flatMap(([key, value]) => [key, ...everyText(value)]) + return [] +} + +/** Every `run:` scalar in the document, as shell text. */ +function collectRuns(node: unknown): string[] { + if (Array.isArray(node)) return node.flatMap(collectRuns) + if (!isMapping(node)) return [] + const own = typeof node['run'] === 'string' ? [shellOf(node['run'])] : [] + return [ + ...own, + ...Object.entries(node).flatMap(([key, value]) => (key === 'run' ? [] : collectRuns(value))), + ] +} + +/** + * Every shell command the workflow executes: inline `run: cmd` and block scalars + * (`run: |`) alike, in document order. What a step DOES lives here — the security and + * check-only rules are asserted against these, not against the whole file, so an + * expression in `concurrency.group` is not confused with one expanded into a shell. + */ +export function extractRunBlocks(yamlText: string): string[] { + const document = parseDocument(yamlText) + if (document.errors.length > 0) return [] + try { + return collectRuns(document.toJS() as unknown) + } catch { + return [] + } +} + +/** The `run:` of a single parsed step, as shell text — `[]` when the step runs nothing. */ +function runsOf(step: Mapping): string[] { + return typeof step['run'] === 'string' ? [shellOf(step['run'])] : [] +} + +/** The root scripts a `pnpm