Skip to content

Commit 2082547

Browse files
authored
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.
1 parent 042c853 commit 2082547

10 files changed

Lines changed: 1011 additions & 6 deletions

File tree

‎.gitattributes‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ scripts/pr_review.py text eol=lf
2929
scripts/test_prose_lint.pytexteol=lf
3030
scripts/test_repo_gate.pytexteol=lf
3131
scripts/test_pr_review.pytexteol=lf
32+
scripts/host_gate.pytexteol=lf
33+
scripts/test_host_gate.pytexteol=lf
3234

3335
# The uv tool regenerates uv.lock with LF on every platform, so pin it here.
3436
# That makes git enforce LF on checkout and renormalize, so a CRLF-default repo does not fight the tool on every `uv lock` or `uv sync`.

‎.github/copilot-instructions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ Issue-level Copilot comments (those in `issues/<N>/comments`) have no resolution
300300

301301
### PR Edits and Merge-State Gotchas
302302

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.
304304
-**`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.)
305305
- **`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.
306306
-**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.

‎OPERATIONS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ Both commands are the canonical invocations from [GOVERNANCE.md](./GOVERNANCE.md
9696

9797
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.
9898

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:
100100

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.
103105

104106
## Configuration Layout
105107

‎docs/host-setup.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ Two consequences worth reading off the table rather than discovering later. **Py
2828

2929
A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo.
3030

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+
3141
## Git Identity
3242

3343
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,
195205
## Verify Host Setup
196206

197207
```shell
198-
git --version && gh --version && python3 --version && docker --version && uv --version
208+
python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json
199209
git config --global --list | grep -E "user\.|signing|gpg\."
200210
ssh-add -L # should list your public key
201211
git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing"
@@ -205,7 +215,9 @@ gh auth status
205215

206216
If signing fails locally, the devcontainer will fail too, so fix here first.
207217

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.
209221

210222
**What the host can do once this passes**, which is the point of the contract above:
211223

@@ -229,12 +241,18 @@ A host that fails any row is not ready for the procedure that row names, and the
229241
[agent-safety]: ../host-setup/agent-safety/README.md
230242
[devcontainer]: ./devcontainer.md
231243
[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules
244+
[host-gate]: ../scripts/host_gate.py
245+
[host-tools]: ../spec/host-tools.json
232246
[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483
247+
[operations]: ../OPERATIONS.md
233248
[ssh-signing]: ./ssh-signing.md
234249
[standup]: ../STANDUP.md
235250
[write-guard]: ../host-setup/agent-safety/gh-write-guard.py
236251

237252
<!-- External -->
238253

254+
[cli-install-link]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md
239255
[cli-link]: https://cli.github.com/
256+
[git-restore-mtime-action-link]: https://github.com/chetan/git-restore-mtime-action
257+
[git-tools-link]: https://github.com/MestreLion/git-tools
240258
[keys-link]: https://github.com/settings/keys

‎scripts/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ A `note:` line is how a check says it did less than its name. It prints under th
115115

116116
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.
117117

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+
118128
## `pr_review.py`
119129

120130
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
189199
[gitattributes]: ../.gitattributes
190200
[governance]: ../GOVERNANCE.md
191201
[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling
202+
[host-setup]: ../docs/host-setup.md
203+
[host-tools]: ../spec/host-tools.json
204+
[operations]: ../OPERATIONS.md
192205
[prose-gate-action]: ../.github/actions/prose-gate/action.yml
193206
[repos]: ../registry/repos.json
194207
[section-model]: ../spec/section-model.md

0 commit comments

Comments
 (0)