You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Gate the Host Tool Versions, and Say Where a Tool Must Come From (#638)
The host contract checked presence, and both host defects this fleet has hit are version facts on a tool that is installed, answers --version, and looks healthy. spec/host-tools.json declares the floors as data and each records the defect it encodes, so most entries carry none deliberately: a floor nobody can justify becomes a host failure nobody can act on. scripts/host_gate.py reads it and replaces the presence-only line in the docs/host-setup.md verification block.
Two floors. A distribution gh in the 2.45.x / 2.46.x range is named by the GitHub CLI maintainers as broken by deprecated GitHub APIs. A git-restore-mtime before 2025.08 calls git whatchanged, which current git refuses, so it restores nothing, prints its ordinary statistics and exits 0, and a deploy keyed on mtimes then ships a full copy and reports success. Note the direction of that second one: a newer git is the trigger rather than the remedy.
A repository layers its own host-tools.json over the hub's, so a repo needing ffmpeg, or needing a tool the fleet calls optional, declares that where it is true. Layering is tighten-only, since lowering a floor from inside the repository a floor protects retires the check, and a rejected relaxation is reported rather than dropped.
The premise was verified during review. The maintainer installed gh 2.97.0 from the official repository and git-restore-mtime 2025.08, the gate went clean, and both gh limitations OPERATIONS.md carried as permanent behavior were re-tested and are gone. That file and .github/copilot-instructions.md are corrected, the latter naming the version range rather than this host since it reaches the fleet. Neither symptom was visible to a presence check, which is the argument for the whole change.
Thirteen review rounds, every finding the same shape: a guard whose stated scope is wider than what it reads.
Copy file name to clipboardExpand all lines: .github/copilot-instructions.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -300,7 +300,7 @@ Issue-level Copilot comments (those in `issues/<N>/comments`) have no resolution
300
300
301
301
### PR Edits and Merge-State Gotchas
302
302
303
-
-**`gh pr edit --title/--body` is broken here.**It touches the deprecated Projects-classic `projectCards` GraphQL field and **exits non-zero without applying the change** (a stale PR description then survives review rounds). Edit the title/body via the API and verify it took: GraphQL `updatePullRequest(input: { pullRequestId, title, body })`, or REST `gh api -X PATCH repos/<owner>/<repo>/pulls/<N> -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`).
303
+
-**`gh pr edit --title/--body` is broken on `gh` 2.45.x and 2.46.x, and works from 2.47 up.**Those releases touch the deprecated Projects-classic `projectCards` GraphQL field and **exit non-zero without applying the change** (a stale PR description then survives review rounds), which the GitHub CLI maintainers name as broken by deprecated APIs. A distribution package is where that version comes from, so check `gh --version` before concluding the command is unusable, and install from the official repository rather than working around it. Where a host is genuinely stuck on one, edit via the API and verify it took: GraphQL `updatePullRequest(input: { pullRequestId, title, body })`, or REST `gh api -X PATCH repos/<owner>/<repo>/pulls/<N> -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`). The same version range carries no `--json` flag on `gh pr checks`, so a watcher built on it prints nothing and a quiet result reads as a passing one.
304
304
-**`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches/<b>/protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is most often just **unresolved review threads** (the ruleset requires thread resolution), and resolving them moves it to `CLEAN`. (`BLOCKED` is a `mergeStateStatus` value, so don't confuse it with the separate `mergeable` field's `MERGEABLE`/`CONFLICTING`, which reports merge conflicts, not review gates.)
305
305
- **`BLOCKED` never says which gate, so never infer one.** The same word covers a red check, a required check nothing is running, an unresolved thread, and a missing approval, and the bullet above says "most often" rather than "always" for that reason. Read the checks instead of guessing: `pr_review.py status` prints `checks=N/M` beside the merge word and names a stuck one, and it exits `44` from `wait` where the merge reads `BLOCKED`, the review loop closed, and a check is starved, expected and never posted, running far past what the job costs, or failed. A **queued check with no runner** is the case that reads exactly like patience: a run here polled `BLOCKED` for twenty-five minutes on a pull request whose only unfinished check was an aggregator job GitHub dispatched and never assigned a runner, and the cause came from the maintainer rather than from any field. Nothing agent-side starts that job, because the pool is GitHub-hosted, so the remedy is a re-run of the workflow or waiting on that capacity, and it is **not** a re-request, a rebase, or an empty commit. A job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so a queued check is never a dependency waiting its turn.
306
306
-**Push -> head-SHA read race.** A `headRefOid` read taken immediately after a push can return the **old** head, so re-read after the push registers, or a coverage poll evaluates the stale SHA.
Copy file name to clipboardExpand all lines: OPERATIONS.md
+5-3Lines changed: 5 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -96,10 +96,12 @@ Both commands are the canonical invocations from [GOVERNANCE.md](./GOVERNANCE.md
96
96
97
97
The `editorconfig-checker` action is setup-only. Using it alone silently skips the check, so CI invokes the checker itself rather than relying on the action.
98
98
99
-
Two `gh` limitations on the current host, both worked around rather than fixed:
99
+
Two `gh` limitations were carried here as permanent behavior for months. **Both were artifacts of a distribution-packaged `gh` 2.46.0, and both are gone on 2.97.0 installed from the official repository.** The GitHub CLI maintainers name that exact range, `2.45.x` and `2.46.x`, as broken by deprecated GitHub APIs, which is the class both belonged to. Re-tested on this host on 2026-08-09 after the upgrade, rather than assumed from the version number:
100
100
101
-
-`gh pr checks` carries no `--json` flag on the installed `gh` 2.46.0, so a watcher built on it prints nothing and a quiet result reads as a passing one. Read the checks from `gh pr view --json statusCheckRollup` instead.
102
-
-`gh pr edit` fails with a Projects-classic deprecation error whichever field it is given, `--base`, `--title` and `--body-file` alike, since the failure is in the mutation the command builds rather than in the field asked for. It exits non-zero without applying the change, so a stale pull request description survives review rounds. Use `gh api --method PATCH repos/[owner/repo]/pulls/[number]` with the field instead, `-f base=[branch]` or `-F body=@[file]`, and verify it took. [.github/copilot-instructions.md](./.github/copilot-instructions.md) carries the same limitation against the title and body under "PR Edits and Merge-State Gotchas".
101
+
-`gh pr checks --json` returned the rollup as JSON. It carried no `--json` flag on 2.46.0, so a watcher built on it printed nothing and a quiet result read as a passing one.
102
+
-`gh pr edit --body-file` applied the change and exited 0. On 2.46.0 it failed with a Projects-classic `projectCards` deprecation error whichever field it was given, `--base`, `--title` and `--body-file` alike, since the failure was in the mutation the command built rather than in the field asked for, and it exited non-zero **without applying the change**, so a stale pull request description survived review rounds.
103
+
104
+
The `gh api --method PATCH repos/[owner/repo]/pulls/[number]` form still works and is still correct where a host is stuck on an old `gh`, but it is no longer the required path here. **The lesson worth keeping is not either symptom.** A tool old enough to be broken answers `--version` cleanly and looks healthy, so the defect arrived as two documented workarounds rather than as an upgrade, and it was the *floor* that found it rather than either symptom. [docs/host-setup.md](./docs/host-setup.md) states where `gh` must come from, and `scripts/host_gate.py` fails a host below the floor.
Copy file name to clipboardExpand all lines: docs/host-setup.md
+20-2Lines changed: 20 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,6 +28,16 @@ Two consequences worth reading off the table rather than discovering later. **Py
28
28
29
29
A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo.
30
30
31
+
### Where a Tool Comes From, and How Old It May Be
32
+
33
+
Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor exists only where a version is known to break a documented procedure, so most entries carry none, deliberately: a floor nobody can justify becomes a host failure nobody can act on.
34
+
35
+
**`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows**`winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own.
36
+
37
+
**`git-restore-mtime` must not come from it either, where a repo uses it.** Debian and Ubuntu package **2022.12**, which shells out to `git whatchanged`. Current `git` refuses that without a hidden opt-in flag a caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and **exits 0**. A deploy keyed on mtimes then ships a full copy and reports success. Take the upstream release from [git-tools][git-tools-link], or in CI the [action][git-restore-mtime-action-link] that vendors it. Note the direction of that interaction: a **newer**`git` is the trigger rather than the remedy, so a host old enough to still allow `whatchanged` hides the defect rather than avoiding it. No procedure in this repo needs the tool, so the gate declares it **optional** and skips it when absent.
38
+
39
+
A repository that needs more than the fleet does adds its own `host-tools.json` at its root, which the gate layers over the hub's. It may add a tool nobody else uses, raise a floor, or turn an optional tool required. It may **not** lower a floor or turn a required tool optional, since those edits retire a fleet check from inside the repository it protects, and the gate reports a rejected relaxation rather than dropping it.
40
+
31
41
## Git Identity
32
42
33
43
Configure your name and email, used for commit authorship. **The email is the committing account's GitHub `noreply` address, never a private, personal, or invented one**, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules], which owns the rule and states the fleet's value. A private address trips GitHub's email-privacy push protection (GH007), and an invented one pollutes history.
@@ -195,7 +205,7 @@ Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it,
If signing fails locally, the devcontainer will fail too, so fix here first.
207
217
208
-
**This block is POSIX, and on native Windows the interpreter line needs translating**, since `python3` is the one name a correctly set-up Windows host does not have. Read it as `py -3 --version` there, matching the contract table above, and run the rest from WSL2 or Git Bash per the shell note. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. A PowerShell equivalent of this block is deliberately **not** given here, because it has not been run on a Windows host, and an unverified verification command is worse than none. [#483][issue-483] is where one belongs once someone has executed it.
218
+
The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, prints the defect behind the floor rather than the number alone, and names where to install from.
219
+
220
+
**This block is POSIX, and on native Windows the interpreter line needs translating**, since `python3` is the one name a correctly set-up Windows host does not have. Read it as `py -3 scripts/host_gate.py` there, matching the contract table above, and run the rest from WSL2 or Git Bash per the shell note. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. A PowerShell equivalent of this block is deliberately **not** given here, because it has not been run on a Windows host, and an unverified verification command is worse than none. [#483][issue-483] is where one belongs once someone has executed it.
209
221
210
222
**What the host can do once this passes**, which is the point of the contract above:
211
223
@@ -229,12 +241,18 @@ A host that fails any row is not ready for the procedure that row names, and the
Copy file name to clipboardExpand all lines: scripts/README.md
+13Lines changed: 13 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,6 +115,16 @@ A `note:` line is how a check says it did less than its name. It prints under th
115
115
116
116
A stale-backticked-path check was built and **rejected**: a template repo legitimately references paths that live in downstream repos, so it produced 34 false positives on a clean tree with no way to separate those from real drift. Doc-to-doc drift is a review lens, not a regex.
117
117
118
+
## `host_gate.py`
119
+
120
+
The host contract in [`docs/host-setup.md`][host-setup] as a check, reading the tool floors declared in [`spec/host-tools.json`][host-tools]. It exists because presence is the weaker half of that contract: both host defects this fleet has hit are version facts on a tool that is installed, answers `--version`, and looks healthy.
121
+
122
+
**A floor is declared only where a version is known to break a documented procedure**, and each one records that defect rather than a preference. Two exist today. A distribution `gh` in the `2.45.x` / `2.46.x` range is named broken by the GitHub CLI maintainers, and both `gh` limitations in [`OPERATIONS.md`][operations] were observed on one. A `git-restore-mtime` before `2025.08` calls `git whatchanged`, which current `git` refuses, so it restores nothing, prints its ordinary statistics and **exits 0**. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on.
123
+
124
+
The three states a tool can be in are kept apart because their remedies differ: **absent** means install it, **unreadable** means the declared pattern is wrong and the fix is in this repo rather than on the host, and **read** means the floor applies. A probe that runs and exits non-zero is not an answer, which is what separates a tool that is missing from one this file cannot parse.
125
+
126
+
A repository adds its own `host-tools.json` at its root and the gate layers it over the hub's, so a repo needing `ffmpeg`, or needing a tool the fleet calls optional, declares that where it is true. Layering is **tighten-only**: a local entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because those retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped.
127
+
118
128
## `pr_review.py`
119
129
120
130
One compact digest of a pull request's Copilot review state, replacing a sequence of one-`gh`-call-per-turn polls. `status` prints the digest, `wait` runs the backoff in-process so a long review wait costs one agent turn instead of one per poll, `reply` answers one thread and resolves it, and `claims` reads the description against the branch it describes. Re-requesting a review stays out and its runbook is in [`.github/copilot-instructions.md`][copilot-instructions].
@@ -189,6 +199,9 @@ The match is on the block's heading rather than anywhere in the body, and on the
0 commit comments