diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fc8ba252..649cf01a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,12 +18,13 @@ Summarized for VS Code's generators. The full rules, rationale, and examples are ## Reviewing Carried Fleet Content -Several of this repository's governance files are carried from a shared template and kept in sync across a fleet of sibling repositories, among them `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, this file, and the `repo-config/` rulesets. Most of `GOVERNANCE.md` is universal fleet law: every section that states a rule, as opposed to the two that describe this repository's own directory tree and devcontainer, is byte-locked and verified by an automated byte-for-byte match against the template canonical, not by line-by-line review. `AGENTS.md` is the thin router and carries two byte-locked sections of its own, with no repository-specific ones. +Several of this repository's governance files are carried from a shared template and kept in sync across a fleet of sibling repositories, among them `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, this file, and the `repo-config/` rulesets. Most of `GOVERNANCE.md` is universal fleet law: every section that states a rule, as opposed to the two that describe this repository's own directory tree and devcontainer, is byte-locked and verified by an automated byte-for-byte match against the template canonical, not by line-by-line review. `AGENTS.md` is the thin router and carries three byte-locked sections of its own, with no repository-specific ones. -Two constraints follow when reviewing that content. +Three constraints follow when reviewing that content. - **A reference inside byte-locked text to a path or section this repository does not carry is intentional, not a broken link.** Universal rule text names shared infrastructure (a fleet registry, a reusable config snippet, the other workflow model's ruleset payload) that a given repository legitimately may not contain. Editing the text to "fix" such a reference would break the fleet audit that governs it, so the reference is correct as written. Do not report it as a dead link, a missing file, or a broken cross-reference. - **A genuine substantive defect is still worth raising.** Byte-locked is not unreviewable. A self-contradiction, a factual error, or a real typo in the canonical prose is a valid finding, but note that the fix lands at the template and re-vendors to every repository, rather than proposing a local edit the audit would reject. +- **A reference to a hub script is a pointer to follow, not a broken local path.** The fleet's gates live in one place and a repository runs them from a checkout of that place rather than holding a copy, so `scripts/prose_lint.py` (prose the CI linters pass on), `scripts/repo_gate.py` (repository settings and action pins), `scripts/pr_review.py` (the review digest, and reply plus resolve without a hand-typed id), and `spec/audit.py` (the conformance audit) resolve there and in none of the repositories they measure. [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) carries the exception that permits such a pointer inside carried text, and [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling) states how one is reached and what to report when it cannot be. Reach for them before writing a check of your own, since a reconstructed gate encodes its author's reading of a rule rather than the rule, and agrees with no other repository. ## GitHub Copilot Review Runbook @@ -99,7 +100,7 @@ mutation($pr: ID!, $bot: ID!) { The bot node id is read from an existing Copilot **formal** review (`pullRequest.reviews`), so step 1 needs at least one prior formal review on the PR, and the auto-review-on-open normally supplies the first one (it may have **no inline comments**, which still counts, and its bot node id is still readable). Poll for it (give auto-review-on-open a few minutes) before deciding it is missing. -**Cold start (round 1 not yet landed): read the id repo-wide, not from this PR.** The Copilot reviewer's bot node id is the reviewer bot *account's* node id and is **stable across every PR in the repo**. So a freshly opened PR that has neither a formal review nor an issue comment yet does **not** need UI seeding to bootstrap the id: read it from any prior Copilot review anywhere in the repo, then feed it into the `requestReviews` mutation to drive round 1. Query the **most recent** PRs (`first: 20` with an explicit newest-first order; plain `last: 20` returns the *oldest* PRs, which may predate Copilot on the repo), and **guard for an empty result**, since an empty `$BOT_ID` means none of the sampled PRs carry a Copilot review. Widen the window (raise the count or paginate) before concluding the repo has never had one and falling back to UI seeding; never feed an empty id into the mutation: +**Cold start (round 1 not yet landed): read the id repo-wide, not from this PR.** The Copilot reviewer's bot node id is the reviewer bot *account's* node id and is **stable across every PR in the repo**. So a freshly opened PR that has neither a formal review nor an issue comment yet does **not** need UI seeding to bootstrap the id: read it from any prior Copilot review anywhere in the repo, then feed it into the `requestReviews` mutation to drive round 1. Query the **most recent** PRs (`first: 20` with an explicit newest-first order, since plain `last: 20` returns the *oldest* PRs, which may predate Copilot on the repo), and **guard for an empty result**, since an empty `$BOT_ID` means none of the sampled PRs carry a Copilot review. Widen the window (raise the count or paginate) before concluding the repo has never had one and falling back to UI seeding. Never feed an empty id into the mutation: ```sh BOT_ID=$(gh api graphql -f query=' @@ -155,6 +156,42 @@ gh api repos///issues//comments --jq \ Coverage is confirmed when (1) exits 0, and **a formal review with no inline comments still satisfies path (1)**, because coverage is about the head SHA, not the comment count. The exception is the refusal above, which is a formal review on the head with no inline comments and covers nothing, so path (1) exits 0 over a round that never ran. Read the body of the review the SHA matched, not only the SHA. For issue comments (path 2), body content is the only reliable signal, and `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. +**Coverage of the head is not coverage of the diff, and the second one is stated in a line nothing above reads.** A review body says how many of the pull request's changed files it read, and a round that read fewer than the pull request changed is byte for byte the clean pass in everything else: the same `commit.oid`, the same absent threads, the same "generated no comments". Measured over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed, and all three merged. One of them changed three files, left one unread across **both** its rounds, and reported no comments each time. This is the third instance of the shape the refusal above and the suppressed block below are the first two, so read it the same way: **fail closed on a wording you do not recognize**, since a gate that allows whatever it does not recognize stops gating as the wording drifts, and both of those wordings have drifted once already. + +Two spellings carry the count, and both are current rather than one superseding the other. Each opens its own line, which is what separates the round stating its coverage from prose mentioning changed files, that prose being what a review of a change to this rule looks like: + +```text +Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments. +- **Files reviewed:** 2/3 changed files +``` + +The sentence tail after the first spelling reports how many comments the round raised and appears in four wordings. It is not coverage, so it is not part of what has to be recognized, and the counts are. Read them into three verdicts and two exemptions: + +- **Counts equal** - the round read the whole diff. This is the clean pass. +- **Counts unequal** - files in the diff have no review at all. Re-request on this head or split the pull request, rather than merging on it. +- **Coverage-shaped and unreadable** - the remedy is to fix the reader, not to read past it. The vetted spellings live in `scripts/pr_review.py` and here, and they stay in step because a case reads them out of this file. +- **Exempt: a body stating no coverage at all.** 28 of those 332 bodies are an overview and a change list and nothing more. That shape is current, interleaves with the counted one throughout, and one pull request carries both across its two rounds, so treating it as a failure cries wolf on about one review in twelve and teaches an agent to work around the gate. It reads as `coverage=unstated`, never as a pass and never as a failure. +- **Exempt: a refusal.** It carries no coverage line by design, and the refusal rule above has already classified it. Read it here as well and every refusal grows a spurious second failure on top of the one that names its remedy. + +`scripts/pr_review.py status --repo /` reports this as `coverage=full`, `coverage=PARTIAL`, `coverage=UNVETTED` or `coverage=unstated`, and exits `42` on a partial round. An unreadable wording exits `43` instead, as one of the unrecognized shapes below rather than as a case of its own, since both say the reader is what needs fixing. Read it by hand as: + +```sh +gh pr view --json reviews --jq \ + '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .body + | split("\n")[] | select(test("^(Copilot|[-*] \\*\\*Files reviewed:).*changed files?"))' +``` + +### A Shape Nothing Recognizes Blocks the Loop and Earns an Issue + +**Every rule above keys on a marker in what Copilot sent, so a marker that changes spelling is a section the reader stops finding and reports as absent.** That is not a hypothetical: all three failures on record here have exactly that shape. The suppressed heading was reworded and the count went to zero. The suppressed section moved inside another wrapper and the count went to zero again. The coverage line was never read at all. Each one reported a clean pass over a review it had misread, and each was caught by the maintainer after it had already landed, rather than by the gate. + +**So an unrecognized shape is a blocking outcome, and its remedy is an issue rather than a judgment call.** When any reader here meets a heading, a collapsed section, a metadata line, a coverage wording or a reviewer login it has no vetted spelling for, the review loop **does not close**, whatever else the digest says. Do not read past it, do not infer what the new wording probably means, and do not treat a body that looks clean as a clean review, because "looks clean" is precisely what a misread review looks like. Two things follow, in this order: + +1. **File an issue on the hub, `ptr727/ProjectTemplate`**, which hosts `scripts/pr_review.py` and holds the vetted inventory. Name each unrecognized shape and quote the review body it came from, so the fix is made against the real wording rather than a paraphrase. The issue is filed even when the shape turns out to be cosmetic, since "cosmetic" is a conclusion drawn after reading the body and not before. +2. **The merge decision is the maintainer's**, not the agent's and not the script's. An unrecognized shape does not mean the pull request is bad, it means nothing here can vouch for the review of it. Report the state, hand it over, and stop. + +`scripts/pr_review.py status --repo /` reports this as `shapes=UNRECOGNIZED`, lists each shape under a marker naming the remedy, and exits `43`. `wait` carries the same code, so a wait cannot end on a clean zero over output nothing read. The vetted inventory lives in that script and is small on purpose: measured over 332 Copilot review bodies on this repository, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `` texts and three metadata labels, and every body carries at least one of them. A body carrying none is itself the unrecognized shape, which is what catches a rewrite that changes everything at once, the refusal wording drifting among it. + ### Bounded Retry Workflow This path is only for a **genuinely missing** review, meaning no Copilot review (formal *or* issue comment) covers the current head SHA after polling. A review that covered the head but produced no comments is a clean pass, not a missing review, so do not enter this retry path for it. @@ -212,6 +249,10 @@ If a review did not run on the current head, retry: Every id below is captured from a live query into a variable and passed from there, never hand-typed, guessed, or pasted as a `PRRT_...` literal. A node id resolves globally, so a fabricated or stale id does not fail, it writes to a real thread on an unrelated repository. This runbook implements [GOVERNANCE.md "Repository Boundaries and Write Safety"](../GOVERNANCE.md#repository-boundaries-and-write-safety): write only to this repo, capture every id from a live query, and never suppress a mutation's output. +**Use the hub's helper, which has nowhere to type an id.** `scripts/pr_review.py reply --repo / --match "" --body "" --resolve` queries the thread id itself and passes it straight to the mutation. That rule is known and read by the agents that break it anyway, three times so far, so the shape is what changes rather than the wording. It selects on the finding's own words rather than a line number, since a fix push moves the line; it refuses on no match and on more than one rather than picking; and it does not resolve a thread whose reply came back without a `url`. Cross-owner targets it refuses outright, which is where the hand-run form below applies, and there the `gh-write-guard` hook is what reads the maintainer's grant. It is hub-hosted per [GOVERNANCE.md "Hub-Hosted Tooling"](../GOVERNANCE.md#hub-hosted-tooling), so it is invoked from a hub checkout and never rebuilt locally. + +The hand-run form is below, for a cross-owner target and for the case where the hub cannot be reached and the work cannot wait. + List unresolved threads. Use `first: 100` with cursor-based pagination, and where `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: ```sh @@ -274,9 +315,9 @@ Issue-level Copilot comments (those in `issues//comments`) have no resolution ### PR Edits and Merge-State Gotchas - **`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///pulls/ -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`). -- **`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches//protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is usually just **unresolved review threads** (the ruleset requires thread resolution); resolving them moves it to `CLEAN`. (`BLOCKED` is a `mergeStateStatus` value; don't confuse it with the separate `mergeable` field's `MERGEABLE`/`CONFLICTING`, which reports merge conflicts, not review gates.) +- **`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches//protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is usually 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.) - **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. -- **Copilot is sometimes factually wrong** (e.g. it claimed `actionlint -color` "requires a value" when it is a boolean flag). Verify a finding before fixing, and decline with evidence when it is wrong, which is distinct from dismissing a still-present finding as stale. +- **Copilot is sometimes factually wrong** (e.g. it claimed `actionlint -color` "requires a value" when it is a boolean flag). Verify a finding before fixing, and decline with evidence when it is wrong, which is distinct from dismissing a still-present finding as stale. The evidence goes under [Disproved Claims](#disproved-claims) as well as in the thread, because the thread closes with the pull request and the next round starts without it. Reply-body conventions: @@ -287,6 +328,32 @@ Reply-body conventions: After the final push, sweep-resolve stale older threads for removed code paths. +### Disproved Claims + +**A disproof is proof about this repository, and the thread it was written in is not where the next round looks.** [GOVERNANCE.md "Every Finding Ends in an Action"](../GOVERNANCE.md#every-finding-ends-in-an-action) closes a false finding by disproving it in the thread, addressed to the reviewer so it does not raise the same thing again, and while the pull request is open that is the right place for it. Afterwards it is the wrong one. The pull request merges, the next round begins with no memory of the last, and the second occurrence reaches a maintainer with no way to tell it from a first. Each entry below is a claim that was tested against this repository and found false, kept so the proof is read rather than built twice. + +**An entry names the claim, what was run or read to disprove it, the revision it was proved against, and what ends it.** A disproof is true of one tree at one revision, so an entry whose subject moves is deleted by the change that moves it rather than edited to look current, which is the same sweep the [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) rule already requires of prose asserting a behavior that has changed underneath it. This is deliberately not a list to append to, since an entry outliving the code it was proved against becomes a reason not to check, and that is strictly worse than proving the claim a second time. + +**The record answers a repeated claim and never dismisses a new one.** An entry is cited only where the revision it names is still what the tree carries, and the reply carries the proof re-read rather than a pointer to the entry, since a reviewer that cannot open this file learns nothing from being pointed at it. Judge a finding on its merits first and match it against this record second, because reading it the other way round is how a real finding gets closed by a stale proof. + +**The entries are this repository's own.** Each names a file and a revision, so a repository holding a copy of this file carries the shape and the rules above rather than these findings, deletes an entry whose subject it does not carry, and records what it has proved itself. + +- **`keys_unsorted` requires jq 1.6, so the ruleset normalizer in `repo-config/configure.sh` fails to compile on jq 1.5.** Raised as a suppressed finding, by analogy to the `walk/1` call the same filter was rewritten to avoid. + - **Disproved by** - running both builtins on `jq-1.5-1-a5b5cbe`, the build that reproduces the `walk/1` failure. `keys_unsorted` evaluates there and the whole normalizer returns the sorted document, while `walk(.)` on that binary answers `jq: 1 compile error`. The two builtins are not in the same position, and the analogy is the whole of what carried the finding. + - **Proved against** - the `norm` filter in `repo-config/configure.sh` on `develop` at `756a53e`. + - **Delete when** - the filter stops calling `keys_unsorted`, or nothing this check runs on carries a jq older than 1.6. + +- **Splitting the fallback parse in `host-setup/agent-safety/gh-write-guard.py` a line at a time mis-reads a newline inside a quoted argument, reintroducing the false deny that path exists to remove.** Raised against the branch that made a newline end a command, on the ground that a `--body` argument holding a newline and a `git push origin develop` would have that line read as a push. + - **Disproved by** - the arm being unreachable, and then by measuring it rather than resting on that. `punctuation_chars` arrived in Python 3.6, the module uses f-strings throughout, and `install.py` refuses to install below 3.7, so an interpreter that would raise the `TypeError` fails to import the module before reaching the fallback. Simulated against a `shlex` that rejects the keyword and passes everything else through, the quoted-newline example is allowed on both paths, because splitting a line whose quoting cannot be parsed leaves the quote glued to the token and the push target reads as `develop"`, matching no branch. The shape does bite one line further out, where a three-line body whose middle line is a bare `git push origin develop` denies on the forced path, and the alternative is worse where it counts: parsing the whole command at once keeps a quoted newline intact and drops every real one, so an ordinary push followed by a `gh pr create` denies under every interpreter rather than under none. + - **Proved against** - `_git_subcommand_arglists` in `host-setup/agent-safety/gh-write-guard.py` and the interpreter floor in `host-setup/agent-safety/install.py`, on `develop` at `dbd1cdc`. + - **Delete when** - the floor drops below 3.6, or the fallback stops splitting the command a line at a time. + - **Earned anyway** - a test case rather than a change. Only `ValueError` from unbalanced quoting reaches that path in practice and nothing covered it, so a finding wrong about its own reachability was right that the path was untested. + +- **A description's stale commit claims are found by extracting the bare SHAs it quotes.** Not a reviewer's finding but the method this repository's own backlog specified for the `claims` check in `scripts/pr_review.py`, recorded here because a rejected method costs the same to re-propose as a declined finding costs to re-derive, and because a backlog has a place for a claim the tree contradicts and none for a method a measurement rejects. + - **Disproved by** - running it over the 25 most recent merged pull requests, where it raised four references and all four were correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in another repository written without a URL. Nothing in the shape of a bare SHA separates those from a claim, and separating them by meaning is the similarity heuristic [`spec/section-model.md`](../spec/section-model.md) rules out. A path arm measured on the same corpus is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames and other repositories. + - **Proved against** - the 25 most recent merged pull requests as of `develop` at `756a53e`, the corpus on which the anchored verb form that ships instead raises one reference, and that one true. + - **Delete when** - `claims` stops reading a description for commit references. + ## When in Doubt Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d796b022..7b5319ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,9 @@ # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # -# github-actions is the only ecosystem this repo ships. Dual-target (main + develop) so both branches -# stay current independently of the develop -> main release cadence; the merge-bot auto-merges each -# base with its per-base method. See catalog/snippets/configs/dependabot.yml for the multi-ecosystem -# reference (nuget, uv) a code-shipping repo uses. +# The github-actions ecosystem is the only one this repo ships. +# Both main and develop are targeted so each stays current independently of the develop to main release cadence. +# The merge-bot auto-merges each base with its per-base method. +# See catalog/snippets/configs/dependabot.yml for the multi-ecosystem reference, covering nuget and uv, that a code-shipping repo uses. version: 2 updates: diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 596e436c..a579cd47 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -3,15 +3,14 @@ name: Merge bot pull request action # Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push. # - Merge method by base: develop = squash, main = merge. # - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs. -# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the -# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by URL). +# - pull_request_target rather than pull_request, since jobs hold the App key, so the workflow and action SHAs resolve from the trusted base rather than the PR head. +# This is safe because no job checks out PR code, each one running gh pr merge by URL. on: pull_request_target: types: [opened, reopened, synchronize] -# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would -# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up -# synchronize doesn't cancel an in-flight opened run before it enables auto-merge. +# Concurrency keys on the PR number rather than on github.ref, which under pull_request_target is the base branch and would serialize every bot PR against it, so each PR queues independently. +# The cancel-in-progress setting is false so a follow-up synchronize does not cancel an in-flight opened run before it enables auto-merge. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false @@ -21,7 +20,8 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. + # Dependabot PRs from this repo rather than from forks. + # Only on opened or reopened, so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'dependabot[bot]' && @@ -59,8 +59,9 @@ jobs: merge-codegen: name: Merge codegen pull request job runs-on: ubuntu-latest - # Codegen PRs from this repo. Head/base pairing is enforced strictly (codegen-main->main, codegen-develop-> - # develop). Only on opened/reopened so the disable job stays sticky. + # Codegen PRs from this repo. + # Head and base pairing is enforced strictly, codegen-main to main and codegen-develop to develop. + # Only on opened or reopened, so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'ptr727-codegen[bot]' && @@ -101,8 +102,9 @@ jobs: merge-upstream-version: name: Merge upstream version pull request job runs-on: ubuntu-latest - # Upstream-version bump PRs from the App. Head/base pairing is enforced (upstream-version-main->main, - # upstream-version-develop->develop). Only on opened/reopened so the disable job stays sticky. + # Upstream-version bump PRs from the App. + # Head and base pairing is enforced, upstream-version-main to main and upstream-version-develop to develop. + # Only on opened or reopened, so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'ptr727-codegen[bot]' && @@ -143,8 +145,9 @@ jobs: disable-auto-merge-on-maintainer-push: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest - # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the - # maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent. + # Fires when a maintainer pushes to a bot's branch, meaning a synchronize whose actor is not the bot. + # It disables auto-merge so the maintainer's commits do not merge with the bot's, and the maintainer re-enables it manually. + # The disable call is idempotent. if: >- github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name == github.repository && diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 264960a4..ec149e86 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,8 +3,8 @@ name: Publish project release action on: workflow_dispatch: -# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is -# never left with a half-created GitHub release. +# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel. +# That leaves no run with a half-created GitHub release. concurrency: group: ${{ github.workflow }} cancel-in-progress: false @@ -18,8 +18,9 @@ jobs: permissions: contents: read - # Publish the dispatched branch (main => release, develop => prerelease): NBGV computes the tag from the ref, then - # a GitHub release is created (tag + auto source archive + README + LICENSE). Source-only repo - no build targets. + # Publish the dispatched branch, where main gives a release and develop a prerelease. + # NBGV computes the tag from the ref, then a GitHub release is created carrying the tag, the auto source archive, README and LICENSE. + # This repo is source-only, so it has no build targets. publish: name: Publish project release job runs-on: ubuntu-latest @@ -56,8 +57,8 @@ jobs: # Create-or-refresh: every trigger here is a dispatch, so an existing tag is refreshed, never skipped # (the exists-gate belongs to the multi-trigger reusable form, where a scheduled re-run must no-op). - # target_commitish pins the tag to the exact built commit (GitCommitId), not the default branch. The release is - # the tag plus GitHub's auto source archive, README, and LICENSE - no build assets (source-only). + # The target_commitish input pins the tag to the exact built commit, GitCommitId, rather than to the default branch. + # The release is the tag plus GitHub's auto source archive, README and LICENSE, carrying no build assets because the repo is source-only. - name: Create GitHub release step uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 115f52c1..a8cd0bb4 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -18,8 +18,8 @@ jobs: permissions: contents: read - # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates - # the merge. Its name is the ruleset-bound required status-check context - rename it and the ruleset context together. + # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates the merge. + # Its name is the ruleset-bound required status-check context, so rename it and the ruleset context together. check-workflow-status: name: Check pull request workflow status job runs-on: ubuntu-latest diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index ea729253..8a0ce712 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -55,6 +55,7 @@ jobs: # Each gate in scripts/ is proven by a case that reintroduces the fault it catches. # Standard library only, so no install step and no dependency to pin. # The audit engine self-test is offline, so it runs here rather than only on an owner sweep. + # The write-guard self-test is offline too, and it otherwise runs only when a host installs the hook, which is where a regression in it would surface as a broken machine. - name: Run script self-tests step run: | set -Eeuo pipefail @@ -62,17 +63,18 @@ jobs: python3 scripts/test_repo_gate.py python3 scripts/test_pr_review.py python3 spec/audit.py --selftest + python3 host-setup/agent-safety/gh-write-guard.py --selftest - name: Check repo gates step run: python3 scripts/repo_gate.py - # The charset, duplicate-word and spelling rules are clean tree-wide, so they gate. + # The charset, duplicate-word, spelling and comment rules are clean tree-wide, so they gate. # Every other prose rule reports in the step below without gating. - name: Check prose step - run: python3 scripts/prose_lint.py . --check charset --check dupword --check spelling + run: python3 scripts/prose_lint.py . --check charset --check dupword --check spelling --check comment-wrap --check comment-case # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. - # The backlog is corrected as each file is next edited, never swept. + # The backlog is corrected as each file is next edited, or cleared in a deliberate batch. - name: Report prose backlog step continue-on-error: true - run: python3 scripts/prose_lint.py . --check charset-unknown --check semicolon --check dash --check comment-wrap --check comment-case --summary + run: python3 scripts/prose_lint.py . --check charset-unknown --check semicolon --check dash --summary diff --git a/AGENTS.md b/AGENTS.md index 0b6459c1..477f266e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, an ## Fleet Bootstrap -This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. +This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. @@ -17,7 +17,7 @@ Route by what this repository currently holds rather than by what it is expected - **A repository with the instruction set, current or stale.** Follow the hub's `AUDIT.md` end to end, then apply what it finds per its section 10. An audit that reports drift and stops is half the procedure. - **A repository that believes it is conformant.** Run the audit anyway and commit the report, because conformance asserted without a report is conformance nobody can check. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. -Two rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. +Three rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. **Reach the hub as a checkout of your own and fetch it immediately before reading it**, because a clone is whatever it last fetched rather than the branch it names, and work only in that checkout rather than in one that another task is using, per [`GOVERNANCE.md`](./GOVERNANCE.md) "Repository Boundaries and Write Safety" and "Hub-Hosted Tooling". And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. ## Context and Delegation Discipline @@ -68,7 +68,7 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). R | --- | --- | | Why the rules are shaped this way | `Foundational Principles` | | Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement` | -| Any push, API mutation, comment, label, or merge | `Repository Boundaries and Write Safety` | +| Any push, API mutation, comment, label, or merge, or which checkout the work happens in | `Repository Boundaries and Write Safety` | | Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` | | Committing, signing, rebasing, force-pushing | `Git and Commit Rules` | | Branch choice, promotion, keeping branches in sync | `Branching Model` | diff --git a/CODESTYLE.md b/CODESTYLE.md index 5088aafd..348a3cc6 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -35,7 +35,7 @@ These apply repo-wide, in every directory: 1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`][markdownlint-cli2] at the repo root is the single source of truth, and the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length) are **intentional**, so do not "fix" them. `MD033` inline HTML stays **enabled**: HTML comments are permitted (markdownlint does not flag them), `details` and `summary` are allowed because a GitHub collapsible has no Markdown equivalent, every other element is flagged, and anything with a native Markdown equivalent uses the Markdown. Fix violations at the source rather than disabling rules. 2. **Spelling**: All spelling must be clean via the CSpell VS Code integration, and words must be correctly spelled in **US English** (the repo-wide convention, per [GOVERNANCE.md][governance]). The shared `cspell.json` sets `"language": "en-US"` so British spellings are flagged, where a bare `"en"` accepts both US and British and silently passes the wrong spelling. Project-specific terms go in the shared `cspell.json` `words` list, the single source of truth the extension, CLI, and CI all read. The `.code-workspace` must **not** carry its own `cspell.words`/`cSpell.words` block, and when externalizing words into `cspell.json`, delete any word list left in the workspace (a leftover one duplicates the list and silently drifts). -3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only**, because these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many Markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, Markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but README + HISTORY are the default; keep the CI workflow, the `Lint: Spelling` VS Code task, and the GOVERNANCE.md cspell one-liner on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone, since cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md`, which does not choke on technical terms. +3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only**, because these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many Markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, Markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but README + HISTORY are the default. Keep the CI workflow, the `Lint: Spelling` VS Code task, and the GOVERNANCE.md cspell one-liner on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone, since cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md`, which does not choke on technical terms. 4. **`HISTORY.md` mirrors the README opening**: `HISTORY.md` is the maintainer-curated changelog and opens as the README's twin, carrying the same `# ` (without the README's ToC-omit comment) and the same intro paragraph copied verbatim, then a `## Release History` section. The mirrored opening keeps the project identity consistent for a reader who lands on the changelog directly. The audit checks that the title and intro match the README, with HTML comments stripped. 5. **"Markdown" is the format's name**: The format is a proper noun, so prose capitalizes it, meaning a Markdown file, a Markdown link, and the Markdown a surface renders. Lowercase is for the strings a machine reads and for nothing else: a tool or package name (`markdownlint`, `markdownlint-cli2`, `yzhang.markdown-all-in-one`), a settings key (`markdown.extension.toc.levels`), a heading anchor (`#markdown-and-spelling`), an identifier in code, and a file extension. A hyphenated compound in prose is prose, so it capitalizes too (Markdown-only), which is the boundary a mechanical sweep gets wrong, since it reads the hyphen as the mark of an identifier. What this settles is the mix rather than either spelling, because a file carrying both gives the next author no default to follow and a reviewer a finding to raise on whichever one it wrote last. The rule lives here because every repo carries this file, so the convention arrives with it rather than being re-decided per repo. @@ -261,7 +261,7 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-d logger.LogError(exception, "{Function}", function); ``` -2. **Libraries log through abstractions, never a concrete backend.** A NuGet **library** depends only on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It must **not** reference Serilog or any sink, which would force a logging framework on every consumer and drag in AOT-incompatible dependencies. The consuming **application** owns the concrete logger (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the library; the consuming CLI builds the Serilog-backed factory and injects it via `LogOptions.SetFactory`. +2. **Libraries log through abstractions, never a concrete backend.** A NuGet **library** depends only on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It must **not** reference Serilog or any sink, which would force a logging framework on every consumer and drag in AOT-incompatible dependencies. The consuming **application** owns the concrete logger (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the library, against which the consuming CLI builds the Serilog-backed factory and injects it via `LogOptions.SetFactory`. 3. **CallerMemberName**: Use for automatic function name tracking @@ -355,7 +355,7 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-d This is the style guide for any **Python project(s)** in this repo. -**Adapt before propagating.** The rules below describe the default Python profile: a package that publishes to PyPI, type-checked by `pyright` in strict mode, dependencies in `[dependency-groups]`. A derived repo often differs; when it does, **adapt these fields to match the repo's actual toolchain rather than copying verbatim** (a verbatim copy that misdescribes the repo is inaccurate and gets rejected in review). The axes that commonly vary per repo: +**Adapt before propagating.** The rules below describe the default Python profile: a package that publishes to PyPI, type-checked by `pyright` in strict mode, dependencies in `[dependency-groups]`. A derived repo often differs, and when it does, **adapt these fields to match the repo's actual toolchain rather than copying verbatim** (a verbatim copy that misdescribes the repo is inaccurate and gets rejected in review). The axes that commonly vary per repo: - **Type checker in CI** - `pyright` strict, **`mypy` in CI with `pyright` editor-only** (Pylance), or both. Whichever runs in CI is the one the clean-compile and the CI gate invoke. - **Dependency declaration** - `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` (dev tools installed with `uv sync --extra <group>`). @@ -366,7 +366,7 @@ This is the style guide for any **Python project(s)** in this repo. **Two profiles.** A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated against the `pyproject.toml` shape. The rest of this section (uv project, `uv.lock`, `uv run`, `src` layout, pytest coverage) describes the **Project** shape (the `build` profile). The two differ by whether the Python has **third-party runtime dependencies**, which shows up structurally in `pyproject.toml`, so the audit reads the shape there (`python.profile.detect`): - **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed `uv.lock` (pinned LF, per [Line Endings][line-endings]). CI runs `uv sync --frozen` + `uv run <tool>`, so the lockfile pins tool versions. -- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a **non-Python** repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with **`uvx`** (no project install, no lockfile): the `pyproject.toml` carries **only** tool config (`[tool.ruff]`, `[tool.mypy]`, and an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no `uv.lock` (that metadata would misrepresent it as a shippable package). **mypy** is the type-check gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` block in **standard** mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` step is not something Dependabot tracks, so **CI runs `uvx ruff@latest` / `uvx mypy@latest`** rather than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, README, and CI all run the unpinned latest here. `.py` files follow the repo's line-ending default (CRLF in a CRLF-default repo, and a shebang-executed script is LF-pinned by path, per [Line Endings][line-endings]). There is no pytest suite and no coverage gate. A script that carries a gate still earns tests, written with the standard library's `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` beside the script it exercises; measure them with `uvx coverage@latest run -m unittest discover -s <dir>` when a number is wanted, without adopting a threshold. A co-present `csharp` type still carries `codecov.yml` for its own tests. +- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a **non-Python** repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with **`uvx`** (no project install, no lockfile): the `pyproject.toml` carries **only** tool config (`[tool.ruff]`, `[tool.mypy]`, and an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no `uv.lock` (that metadata would misrepresent it as a shippable package). **mypy** is the type-check gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` block in **standard** mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` step is not something Dependabot tracks, so **CI runs `uvx ruff@latest` / `uvx mypy@latest`** rather than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, README, and CI all run the unpinned latest here. `.py` files follow the repo's line-ending default (CRLF in a CRLF-default repo, and a shebang-executed script is LF-pinned by path, per [Line Endings][line-endings]). There is no pytest suite and no coverage gate. A script that carries a gate still earns tests, written with the standard library's `unittest` so they run under bare `python3` with nothing installed, as `test_<script>.py` beside the script it exercises. Measure them with `uvx coverage@latest run -m unittest discover -s <dir>` when a number is wanted, without adopting a threshold. A co-present `csharp` type still carries `codecov.yml` for its own tests. ### Toolchain @@ -381,7 +381,7 @@ This is the style guide for any **Python project(s)** in this repo. **Type checking targets strongly typed, deterministic code.** `pyright` in **strict** mode is the default baseline on first-party code (a repo may instead run `mypy` in CI and keep `pyright` editor-only via Pylance, per the next paragraph) (`[tool.pyright]` `strict = ["src"]`, or the integration package for a Home Assistant repo, with tests run in standard mode). pyright is the anchor because **Pylance embeds it**, so the editor and the CLI/CI (`uv run pyright`) run the *same* engine and never disagree. The standalone `ms-pyright.pyright` extension stays in `unwantedRecommendations` because Pylance covers it. Relax strictness on **third-party** code only when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented `# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. -**`mypy` is allowed, and required where the ecosystem demands it. It is not banned.** Running more than one checker is normal when each serves a purpose (the .NET side pairs `CSharpier` and `dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. `pydantic.mypy`) catch different classes of error. A **Home Assistant** integration runs `mypy --strict` because the platinum `strict-typing` quality-scale tier requires it; a pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in **CI and the editor** (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy command joins the clean-compile; a repo with no such need stays pyright-only, which is lighter and inherently consistent. +**`mypy` is allowed, and required where the ecosystem demands it. It is not banned.** Running more than one checker is normal when each serves a purpose (the .NET side pairs `CSharpier` and `dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. `pydantic.mypy`) catch different classes of error. A **Home Assistant** integration runs `mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in **CI and the editor** (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and inherently consistent. ### Local Development Loop diff --git a/GOVERNANCE.md b/GOVERNANCE.md index e6d37680..48f6c8ff 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -20,12 +20,14 @@ The specific rules in this file implement a few governing principles. Read these ## Repository Boundaries and Write Safety -A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform. Reads are unrestricted. The bounds below are on writes. +A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. - **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. - **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. If a query returns no id, stop rather than invent one to proceed. - **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (`repo-config/configure.sh` does exactly this). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. - **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. +- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. ## Representative Data in Agent-Authored Text @@ -61,7 +63,7 @@ Agent-authored text illustrates with data the agent constructed, never with data - *Main:* the check is graph-based: it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds (rebasing develop onto main, or rewriting develop's history) exist but contradict the squash-only develop ruleset and the linearity invariant. - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status job` still gates merges. The only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - See [`repo-config/README.md`](./repo-config/README.md) "Rulesets" for the configured state. -- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` (`gh ruleset` is read-only). The names are load-bearing, since this file and the workflows reference them. Operational repos import `repo-config/operational/develop.json` as their `develop` ruleset (the `main` ruleset is shared); [`configure.sh`](./repo-config/configure.sh) selects the right develop payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset. +- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos/<owner>/<repo>/rulesets"` (`gh ruleset` is read-only). The names are load-bearing, since this file and the workflows reference them. Operational repos import `repo-config/operational/develop.json` as their `develop` ruleset (the `main` ruleset is shared), and [`configure.sh`](./repo-config/configure.sh) selects the right develop payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset. - **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and the codegen workflow runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop, and both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no ecosystem or update-type guard): the required CI checks are the gate, not the bump magnitude, so a major that breaks the build fails its checks and never merges. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto <PR>` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. @@ -78,16 +80,16 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - **Required check.** The `changes` job is in the `Check pull request workflow status job` aggregator's `needs` and **must succeed** (not just "not fail"), because a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass, and `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as `inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting.** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build-<target>-task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. -- **Orchestration vs. build: the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (single-branch publish plan), the `get-version` + `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the `build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job collects every `release-asset-<branch>-*` artifact by pattern, so its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing). That makes it (the tag-the-commit + create-the-release + attach-the-assets logic) reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included**: name your one asset `release-asset-<branch>-<target>` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. +- **Orchestration vs. build: the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (single-branch publish plan), the `get-version` + `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the `build-<target>-task.yml` leaf tasks) is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job collects every `release-asset-<branch>-*` artifact by pattern, so its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing). That makes it (the tag-the-commit + create-the-release + attach-the-assets logic) reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included**: name your one asset `release-asset-<branch>-<target>` and the verbatim `github-release` globs it. Do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - **What a repo still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do. `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. - **Map your outputs to the right seam** - pick by where each artifact *goes*, not by language: - - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file step but specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. + - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset-<branch>-<name>`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset-<branch>-library`. It deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt it. - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-<branch>` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point), and PyPI contributes **no** `release-asset-*`. - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly and contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. - *Filesystem on a host the project owns* (a static site, a config tree): a deploy leaf builds the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than the repository secret store. Its last step asserts what the host actually serves, the release id and the environment, never that the transport exited zero. Retention at the destination is bounded by a declared count, and one side is recorded as owning the prune: the deploy where its credential can observe the destination, the host where that credential is deliberately write-only. - *Source-only / no build* (validate + tag + release): this seam does not apply. A source-only repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is the standalone [`publish-release.yml`](./.github/workflows/publish-release.yml) on `workflow_dispatch`: a `validate` job (the repo's reusable validation task) gates a publish job that **inlines** NBGV for the tag and `action-gh-release` for the release (tag + auto source archive + README + LICENSE). - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. -- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. - **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor. NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`, and `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`. Dependency bumps, CI/workflow fixes, and doc edits leave it untouched. - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version (a new feature, a behavior or API change, a breaking change), and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit. - **No post-release bump, and no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`, and a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height. @@ -218,14 +220,14 @@ Agent-authored text is ASCII by default: documentation, code, comments, commit m - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** (every file type is CRLF unless pinned otherwise), with **LF** pinned for the execution-sensitive exceptions: `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared. The redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. - **Choosing an ending for a new file type:** CRLF is the **default**, since cross-platform editors on Windows produce it and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF**, since GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig`, because Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile*, interpreted and broken by a CR in a `RUN` heredoc or line continuation, is LF. -- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory*, often the exact tree mounted into that app's container, so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry `lineEndings` field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config), and **CRLF** for a Windows-native editor, for example Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default, which is exactly the over-normalization these per-repo endings prevent. +- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory*, often the exact tree mounted into that app's container, so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry `lineEndings` field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config), and **CRLF** for a Windows-native editor, for example Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only, and `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default, which is exactly the over-normalization these per-repo endings prevent. - **Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited subtree), the clean answer is a repo per consumer, each single-platform with its own `lineEndings`. For example a controller config edited by a Windows-native editor (CRLF) lives in its own repo, **not** as a subtree inside a Linux-`lf` config repo. That keeps each repo's default, CI, and checkout matched to one platform and avoids per-path EOL machinery entirely. **Fallback only if a subtree genuinely cannot be split out:** keep the global default at the primary consumer and pin the odd subtree with an `.editorconfig` path override (e.g. `[<subtree>/**] end_of_line = crlf`) matching its consumer, treated like any tool-owned format. The global `* -text` in `.gitattributes` already preserves those bytes, so no extra git pin is needed. - **Scripts and extensionless executables must be LF, and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing, so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** (`Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks), so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them, for example KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. -- **Pair each such pin with a matching `.editorconfig` override, since the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL: `[<dir>/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. +- **Pair each such pin with a matching `.editorconfig` override, since the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`), while the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`), and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL: `[<dir>/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. - **New files:** create them with the `.editorconfig`-mandated ending. - **Editing an existing file:** **preserve the file's current line endings**, and do not reflow them as a side effect of a content change, even if the file is already non-compliant. A tool that rewrites a file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify before staging: `git diff --stat` should touch only the lines you changed, and a byte check should confirm the expected ending (`file` is unreliable here, per Auditing below). If a diff balloons to the whole file, you flipped the endings, so restore them and re-stage. - **Fixing a non-compliant file:** bring it to its `.editorconfig` ending as a **deliberate** change, and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader maintenance change has to normalize endings alongside content edits (a repo-wide cleanup sometimes does), call it out explicitly in the commit/PR description and verify the content separately with `git diff --ignore-cr-at-eol`. -- **Auditing line endings: don't trust `file` or naive `git ls-files --eol`.** The authoritative check is a **byte scan** that classifies by which endings are present: **CRLF-only** (every `\n` is preceded by `\r`), **LF-only** (no `\r`), or **mixed** (both forms present). Flag mixed explicitly rather than lumping it in with CRLF; skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json`/`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` column holds multiple tokens that shift naive field-splitting into false positives. Scope a repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw `find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line string replace is EOL-safe, but an agent tool that inserts **multiple lines** or writes a **new file** into a CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is JSONC (it has `//` comments); strip them before JSON-parsing it. +- **Auditing line endings: don't trust `file` or naive `git ls-files --eol`.** The authoritative check is a **byte scan** that classifies by which endings are present: **CRLF-only** (every `\n` is preceded by `\r`), **LF-only** (no `\r`), or **mixed** (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json`/`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` column holds multiple tokens that shift naive field-splitting into false positives. Scope a repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw `find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line string replace is EOL-safe, but an agent tool that inserts **multiple lines** or writes a **new file** into a CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is JSONC (it has `//` comments), so strip them before JSON-parsing it. - **Both `.editorconfig` and `.gitattributes` are required.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) together govern line endings. A repo missing either file, or one whose `.editorconfig` sets no global `end_of_line` default (e.g. declares it only under `[*.md]`), will accumulate files mixed between LF and CRLF, the exact failure these two files prevent. The canonical form is a `[*] end_of_line = crlf` default plus the LF exception pins, mirroring `.gitattributes`. Carry both files **whole** (the `[*.cs]` block is inert without `.cs` files), including the `*.sh text eol=lf` pin and any extensionless-script path pins. Adopting `.gitattributes` for the first time requires a one-time normalization pass. ### Quantitative Claims @@ -236,6 +238,7 @@ Agent-authored text is ASCII by default: documentation, code, comments, commit m The checks that separate work actually done from work that merely reports success. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, so finding the check never depends on how well one repository worded a pointer to it. - **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. - **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. - **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. @@ -246,6 +249,7 @@ The checks that separate work actually done from work that merely reports succes - **Never edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. - **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. - **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. +- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated, and two failures of exactly that shape are on record from one session: a repository reported as still drifted on a file whose fix had already merged, and a repository reported as missing a file it carries because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading, and name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. - **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. - **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. - **A review flags an instance, so fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. @@ -261,11 +265,12 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom **Do not merge, and do not enable auto-merge, unless ALL of these hold:** 1. Required status checks are green (`mergeStateStatus: CLEAN`), **and** -2. A Copilot review is confirmed on the **current head SHA** by matching the review's commit SHA to the head, not an earlier push, because a push makes required checks go green **before** the re-review lands, so a green merge-state can precede the current-head review and never signals readiness on its own, and the matched review is **read** rather than only counted, because Copilot declines a pull request it will not take on with a formal review carrying that same head SHA and no findings, which matches the SHA and covers nothing, **and** +2. A Copilot review is confirmed on the **current head SHA** by matching the review's commit SHA to the head, not an earlier push, because a push makes required checks go green **before** the re-review lands, so a green merge-state can precede the current-head review and never signals readiness on its own, and the matched review is **read** rather than only counted, because Copilot declines a pull request it will not take on with a formal review carrying that same head SHA and no findings, which matches the SHA and covers nothing, and because a review can carry the head SHA and still say it read only part of the diff, which is the same clean pass in everything a SHA match can see, so the body's own count of the files it read is checked against the files the pull request changed, **and** 3. **Every** Copilot finding on that head SHA is closed out, with all review threads resolved, **and** any issue-level Copilot comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered, since those appear in no thread and a loop that polls threads alone reports a clean pass while they stand, so zero outstanding findings remain, **and** -4. The maintainer has given **explicit** permission to merge. +4. Nothing in the reviewer's output was a shape the review tooling could not read, since every reader keys on a marker and a marker that changes spelling is a section the reader stops finding and reports as absent, which is how three separate misreadings each reported a clean pass over a review they had not understood. An unrecognized shape blocks this gate on its own, and the remedy is an issue filed against the repository hosting the reader, naming the shape and quoting the body it came from, rather than a judgment about what the new wording probably meant, **and** +5. The maintainer has given **explicit** permission to merge. -`mergeStateStatus: CLEAN` reflects **only** required statuses, and never open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). +`mergeStateStatus: CLEAN` reflects **only** required statuses, and never open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate, so treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging", and merging is maintainer-authorized). **Merging is not releasing.** A merge to a release branch does **not** by itself publish. Publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. @@ -279,7 +284,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom 6. Reply to each thread and resolve what was addressed. 7. Re-run the loop after every fix push until no actionable findings remain. -Drive the loop to green, meaning a review confirmed on the latest head SHA and every actionable finding closed, then stop and apply the **Merge Gate** above: all four preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. +Drive the loop to green, meaning a review confirmed on the latest head SHA and every actionable finding closed, then stop and apply the **Merge Gate** above: all five preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract, and that file owns the mechanics. @@ -338,18 +343,18 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. -- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags; Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-<branch>` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-<branch>` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-<branch>` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`<repo>:buildcache-<branch>` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. - **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly, because without it GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). diff --git a/HISTORY.md b/HISTORY.md index e6bfe7fa..f058517e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,6 @@ Agent enablement for a fleet of repositories: autonomy and repeatable quality in ## Release History - Version 2.0: - - Repurposed from a .NET sample-project template into a governance and workflow-audit catalog: the shared fleet rules (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`), a machine-readable `spec/`, the fleet `registry/`, per-repo audit `reports/`, branch rulesets, and the `AUDIT.md` convergence procedure. Ships no application code; the old sample project and its build pipeline are removed. + - Repurposed from a .NET sample-project template into a governance and workflow-audit catalog: the shared fleet rules (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`), a machine-readable `spec/`, the fleet `registry/`, per-repo audit `reports/`, branch rulesets, and the `AUDIT.md` convergence procedure. Ships no application code, and the old sample project and its build pipeline are removed. - Version 1.0: - .NET sample-project template with a build and publish pipeline. diff --git a/OPERATIONS.md b/OPERATIONS.md index 6e74419c..29e621ba 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -2,30 +2,33 @@ How this repository is run. It ships no application code, so its operations are the fleet audit, the gates that mirror CI, and the script that applies repository configuration. Those gates and that script serve the whole fleet from this checkout rather than being carried into each repository, per [GOVERNANCE.md "Hub-Hosted Tooling"](./GOVERNANCE.md#hub-hosted-tooling), so every run below is a run from here against a repository named on the command line. -## Runbooks +## Local Verification + +What verifying a change here requires, including the part CI cannot perform. The gates below do run in CI, so a local run of them buys an earlier failure rather than a different one. The two verifications CI never performs are the fleet audit and the repository-configuration check, because each reads live state in another repository over the API and a pull request runner is given neither the credentials nor a target to read. `python3 spec/audit.py --selftest` is what CI runs of the audit, which exercises the checker against its fixtures and reads no repository at all. So a change to [spec/](./spec/), [registry/](./registry/), [repo-config/](./repo-config/) or [AUDIT.md](./AUDIT.md) is verified by running `python3 spec/audit.py` and `repo-config/configure.sh check` from this checkout before the pull request opens, each from the repository root and each taking the arguments the two runbooks below give it. A green pipeline says nothing about either, and taking it as coverage is the failure this section exists to name. ### Run the gates the way CI runs them -CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which is those two lists together. What differs is the exit code rather than the coverage: CI gates on `charset`, `dupword` and `spelling` and reports the other five warn-only, where a bare run exits non-zero on any of the eight. `sentence-split` is in neither and is asked for by name. Run the CI invocations: +CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which is those two lists plus `home-path`. What differs is the exit code rather than the coverage: CI gates on `charset`, `dupword`, `spelling`, `comment-wrap` and `comment-case` and reports the other three warn-only, where a bare run exits non-zero on any of the nine. `sentence-split` is in neither and is asked for by name. Run the CI invocations: ```sh python3 scripts/test_prose_lint.py python3 scripts/test_repo_gate.py python3 scripts/test_pr_review.py python3 spec/audit.py --selftest +python3 host-setup/agent-safety/gh-write-guard.py --selftest python3 scripts/repo_gate.py -python3 scripts/prose_lint.py . --check charset --check dupword --check spelling -python3 scripts/prose_lint.py . --check charset-unknown --check semicolon --check dash --check comment-wrap --check comment-case --summary +python3 scripts/prose_lint.py . --check charset --check dupword --check spelling --check comment-wrap --check comment-case +python3 scripts/prose_lint.py . --check charset-unknown --check semicolon --check dash --summary for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done python3 spec/validate.py docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest ``` -Two gaps in that list are CI's rather than this runbook's, reproduced here so a local run matches CI rather than quietly exceeding it. The `jq` glob covers `repo-config/*.json` and does not reach `repo-config/operational/develop.json`, so a malformed operational payload passes. And `sentence-split` is implemented and tested but named by no invocation, so nothing runs it. +Three gaps in that list are CI's rather than this runbook's, reproduced here so a local run matches CI rather than quietly exceeding it. The `jq` glob covers `repo-config/*.json` and does not reach `repo-config/operational/develop.json`, so a malformed operational payload passes. And `sentence-split` is implemented and tested but named by no invocation, so nothing runs it. The third is `home-path`, which is in `DEFAULT_RULES` and so runs on every bare local run, yet is named by neither CI list, so the pattern-detectable half of the representative-data rule gates nothing in CI. It is clean tree-wide today, which is why the gap is a hole rather than a backlog. -Run the `editorconfig-checker` line before pushing any new file. This repository defaults to CRLF, most tooling writes LF, and a new file therefore fails that check on its first CI run rather than locally. +Run the `editorconfig-checker` line before pushing a new file, and before pushing an existing file that a script rewrote rather than an editor. This repository defaults to CRLF and most tooling writes LF, so a new file fails that check on its first CI run rather than locally. A scripted rewrite is the same hazard on a file that was already correct, since reading and rewriting a whole file in text mode converts every line ending in it, which no prose or Markdown gate reports. -The first prose invocation gates. The second reports the backlog that is corrected as each file is next edited, and it exits non-zero locally whenever findings exist. It is warn-only in CI because the workflow step sets `continue-on-error: true`, not because the command is lenient, so a non-zero exit locally is the expected result rather than a problem. +The first prose invocation gates. The second reports the backlog that is corrected as each file is next edited, or cleared in a deliberate batch, and it exits non-zero locally whenever findings exist. It is warn-only in CI because the workflow step sets `continue-on-error: true`, not because the command is lenient, so a non-zero exit locally is the expected result rather than a problem. Scope a run to what changed, which matches the correct-as-next-edited rule: @@ -35,6 +38,8 @@ python3 scripts/prose_lint.py . --diff origin/develop Whole-tree discovery reads only files git tracks, so `python3 scripts/prose_lint.py .` and `--diff` do not see a new file until it is staged, and a clean whole-tree run proves nothing about an unstaged one. An explicit path is always read, tracked or not, so name a new file directly to check it before staging. +## Runbooks + ### Audit the fleet ```sh @@ -94,7 +99,7 @@ The `editorconfig-checker` action is setup-only. Using it alone silently skips t Two `gh` limitations on the current host, both worked around rather than fixed: - `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. -- `gh pr edit --base` fails with a Projects-classic deprecation error. Use `gh api --method PATCH repos/[owner/repo]/pulls/[number] -f base=[branch]` instead. +- `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". ## Configuration Layout diff --git a/README.md b/README.md index c7f62d52..adad6e3a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,15 @@ This repo is the single home for those rules, a machine-readable spec they are c - **[catalog/][catalog]** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. - **[reports/][reports]** - per-repo audit output. +**The words a request is phrased in are defined here.** A repository is asked to audit itself against the hub, or to close the review loop on a pull request, and the phrasing carries the whole instruction, so each term below names the file that answers it and a request using one is a complete instruction rather than a starting point for interpretation. + +- **The hub** - this repository. It is the single authority on what a fleet project is supposed to hold, and every rule naming the hub means this one. +- **The fleet** - the projects listed in [registry/repos.json][repos], each audited downward against the ground truth here rather than negotiating its own. +- **Stand a repository up** - carry the baseline a project is owed for its declared types and workflow model, per [STANDUP.md][standup]. An absent file is a baseline that never arrived rather than drift. +- **Audit a repository** - read a live project against the spec and report its drift, per [AUDIT.md][audit]. The audit never edits what it measures, so a fix is a separate change. +- **Close the review loop** - request a review on every push, confirm it covered the head commit, triage every finding, reply and resolve, and escalate when stuck, per [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette]. +- **Carried against reached** - a project carries the content it is audited against and reaches the machinery that is identical everywhere, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. + ## What It Achieves Keeping a fleet of repositories consistent has always been a tax paid in review attention, and it stops scaling at the point where one person can no longer hold every repo in their head. An agent changes that arithmetic in both directions at once. It can apply a convention across every repository in an afternoon, and it can spread a mistake exactly as fast. What makes the speed worth having is a ground truth an agent can read, a gate that proves the result rather than reporting it, and a boundary naming the decisions that are never the agent's to make. Each objective below is a standing capability, with the machinery that delivers it named so the claim is checkable. @@ -177,6 +186,7 @@ See [LICENSE][license]. [files]: ./spec/files.json [governance]: ./GOVERNANCE.md [governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling [governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette [governance-running-the-linters-locally-known-working-invocations]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations [history]: ./HISTORY.md @@ -190,6 +200,7 @@ See [LICENSE][license]. [repos]: ./registry/repos.json [spec]: ./spec/ [ssh-signing]: ./docs/ssh-signing.md +[standup]: ./STANDUP.md [version]: ./version.json [workflow]: ./WORKFLOW.md diff --git a/STANDUP.md b/STANDUP.md index 5e978e26..7c5dd1a6 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -89,10 +89,10 @@ Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's - [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules. - `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. -- `OPERATIONS.md`: how the repo is run, under the headings `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. +- `OPERATIONS.md`: how the repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. `Local Verification` leads because it is the only pre-merge heading, and it names the part of the repo's contract CI structurally cannot exercise. - `TODO.md`: the repo's running backlog, per [`spec/readme-structure.md`][readme-structure]. It keeps open work out of the README's section order, where it does not belong and changes on a different cadence from everything around it. -**`OPERATIONS.md` is required on every repo**, not optional, so it appears in the baseline above with `appliesTo: "*"`. It is presence-checked only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own and a repo with little to say still carries the file as a stub, meaning those five headings with no content under them, for which this repo's own `OPERATIONS.md` is the worked example. Do not read the `operational` workflow model into the requirement, because that selector describes where config lives rather than whether the repo has runbooks, and a repo that publishes to a package registry or deploys a site has operational surface under either model. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half, so real runbooks (a deploy procedure, a rollback, a retention policy, a credential rotation) go there rather than into a carried file. It is agent-instruction content, so it takes the inline-link exception the Markdown rules name rather than the reference-style default. `ARCHITECTURE.md` and `TODO.md` stay advisory and are required by no selector, so a repo with nothing to say in one carries no file rather than an empty one. +**`OPERATIONS.md` is required on every repo**, not optional, so it appears in the baseline above with `appliesTo: "*"`. It is presence-checked only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own and a repo with little to say still carries the file as a stub, meaning those six headings with no content under them, for which this repo's own `OPERATIONS.md` is the worked example. Do not read the `operational` workflow model into the requirement, because that selector describes where config lives rather than whether the repo has runbooks, and a repo that publishes to a package registry or deploys a site has operational surface under either model. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half, so real runbooks (a deploy procedure, a rollback, a retention policy, a credential rotation) go there rather than into a carried file. It is agent-instruction content, so it takes the inline-link exception the Markdown rules name rather than the reference-style default. `ARCHITECTURE.md` and `TODO.md` stay advisory and are required by no selector, so a repo with nothing to say in one carries no file rather than an empty one. Choose the destination while scaffolding rather than after. Repo-specific content left in a carried file is drift, which the audit lists as an undeclared section to reconcile, and reconciling it later means moving prose that downstream readers have already started trusting in the wrong place. diff --git a/TODO.md b/TODO.md index c510d827..88f61752 100644 --- a/TODO.md +++ b/TODO.md @@ -1,89 +1,443 @@ # TODO -Running backlog for this repo, kept in a committed file so the guidance survives across environments where agent memory does not. Entries are grouped by the surface they change rather than by priority, since an entry's cost is mostly set by what it touches. Where an open issue covers the same ground it is named on the entry, so the two are read together rather than worked twice. +Running backlog for this repo, kept in a committed file so the research survives across environments where agent memory does not. Entries are grouped by the change that ships them, so a `###` heading under "Work Clusters" is one pull request, and selecting work is reading the cluster headings rather than re-deriving the grouping from the entries. What each cluster touches and costs is a field on the cluster, since a cluster confined to one surface and a cluster spanning two are both legitimate and only the second needs saying. -Every issue claim below was re-checked against `develop` at `1ed0cc8` on 2026-08-03, and the entries added from the onboarding-feedback pass were checked against `develop` at `b82c1a3` on 2026-08-05. An issue records the tree as it was on the day it was filed, so a claim in one is a starting point for a check rather than a finding to act on. +An entry carries `Blocked by`, `Issue` and `Checked` exactly once each, in that order, and never omits one, because an omitted field reads as unknown rather than as none. `Open` states a decision the session doing the work makes, and `Settled` states a finding that is not re-derived, each carrying a number, a proper name, or a rejected alternative. `Checked` is the freshness anchor, naming the branch, the commit, and the date a claim was last read against the tree, so a claim older than the branch is a claim rather than a finding. + +A cluster's `State` is one of four. `ready` means every open question is answerable by the session doing the work. `blocked` names the cluster it waits on. `decision` needs the maintainer. `measure` means the first action is a count rather than an edit. + +## How to Select the Next Item + +The steps below are followed in order rather than sampled. + +1. Run `gh issue list --state open` and confirm every number it returns appears somewhere in this file. A number appearing nowhere is an entry that does not exist yet, so write it before selecting anything, because an invisible issue cannot be selected. Nothing mechanical enforces this, which is the honest limit of a hand-maintained file and the reason the step is first. +2. Read the cluster headings and their `State` lines. A cluster is the unit of selection, so pick a cluster rather than an entry, and never carry two clusters in one pull request. +3. Prefer a cluster whose state is `ready`. Select a `decision` cluster only when the maintainer is present to answer its open questions, select a `blocked` cluster only after the cluster it names has shipped, and select a `measure` cluster knowing its deliverable is a number rather than a behavior change. +4. Re-verify every `Checked` line in the chosen cluster against current `develop` before writing anything, by reading the surface the anchor names rather than by re-reading the issue. An issue records the tree as it was on the day it was filed, so a claim in one is a starting point for a check rather than a finding to act on. +5. Rewrite the `Checked` line with the branch, the short commit, and the date whenever a claim is confirmed, whether or not the work ships in the same session. A re-verification that leaves no anchor is a check the next session repeats. +6. Move a claim the tree contradicts out of `Settled` and state what the tree carries instead. Where the tree answers a whole entry, move the entry to "Verified Complete, Awaiting Close" with the commit that answered it, and never delete it silently, since a deleted entry reads as work nobody recorded. +7. Fold a new observation in under one of four dispositions, named on the pull request carrying it: `New entry`, `Amends "<entry title>"`, `Already covered`, or `Already shipped as #N`. A second observation of a surface an entry already reasons about strengthens that entry rather than opening a second one. +8. An amendment adds a `Settled` bullet, shortens `Open`, and refreshes `Checked`. An observation that answers an open question deletes that question rather than annotating it. +9. Delete a cluster heading when its pull request merges, and move anything the pull request did not carry into a new cluster with its own state. + +## Work Clusters + +### Giving the Fleet's Own Pins Something to Resolve Against + +One pull request pointing a hub `uses:` at a hub-owned action, so that the resolvability pass added beside it has a reference under this owner to read. It is separated from that pass because it changes what a workflow runs, where the pass only changes what a gate reports. + +**State** `decision`. **Touches** the hub's own workflows. **Cost** one hub edit, hub-only, and it changes a running workflow so it is not a paper change. + +- **Decide whether the hub consumes its own [`prose-gate`][prose-gate] action the way the fleet does.** Today it calls `prose_lint.py` directly, so every `uses:` in the tree is under another owner. + - **Blocked by** - Nothing, though it is only worth doing on its own merits rather than to give a gate something to read. + - **Issue** - None filed. + - **Checked** - `develop` at `dbd1cdc` on 2026-08-07, where the tree carries 45 pinned `uses:` refs and not one of them names a `ptr727` repository. + - **Open** - Whether the hub gating itself through its own pinned action is desirable at all, given the action reads the rules from hub `develop` on a non-`main` target and the hub already has the script in its own checkout. + - **Settled** - The resolvability pass reports what it covered on every run, so the hub's zero is visible rather than silent, which is why this is a separate decision rather than a defect in that pass. + - **Settled** - The fleet's `ptr727` pins are live in the downstream repos that consume the action, and `repo_gate.py --root <repo>` from a hub checkout reads them there, so the pass is not idle fleet-wide. + +### Three Rules That Leave the Recurring Case Unstated + +One pull request widening three carried [`GOVERNANCE.md`][governance] rules that each state their common case and go quiet on the case that recurs, filed together because they share that shape and land in one re-vendor. + +**State** `ready`. **Touches** [`GOVERNANCE.md`][governance] "Git and Commit Rules", "Communicating with the User", and "Operational Repositories". **Cost** one hub edit plus a carried-section re-vendor, which rides the visit in "Fleet Sweeps". + +- **Say when an issue is closed by hand, not only that the closing keyword belongs on the promotion.** The uncovered case is work complete on `develop` with no promotion imminent. + - **Blocked by** - Nothing. + - **Issue** - [#578][issue-578] item 1. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05, where the rule licenses a hand-close only once a promotion has merged without the keyword. + - **Open** - Nothing. + - **Settled** - Downstream agents keep re-deriving the rule and reporting it as a discovery, which says it is being missed rather than that it is missing, so the discoverability half is not a wording fix. + - **Settled** - The widening says an issue is closed when the work is verifiably complete, citing the squash commit that completed it, and that the promotion keyword is the automation for the common case rather than the only permitted route. + +- **Say that the message carrying the clickable link comes before the prompt it accompanies.** A message emitted after the prompt is not read before the question is answered. + - **Blocked by** - Nothing. + - **Issue** - [#578][issue-578] item 2. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05, where the rule says accompanying rather than preceding. + - **Open** - Nothing. + - **Settled** - The rule already gets the hard part right, that an interactive prompt renders neither a Markdown link nor a bare URL, so the reference inside it is a bare number and the link goes in the message. + - **Settled** - The recurrence is evidence that the wording does not reach the case rather than that the rule is ignored, which is the same diagnosis the entry above reaches. + +- **State that an operational repository still opens a pull request for a large or risky change.** The grant to commit direct to `develop` says nothing about when to decline it. + - **Blocked by** - Nothing. + - **Issue** - [#578][issue-578] item 3. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where `repo-config/operational/develop.json` carries exactly three rules, `deletion`, `non_fast_forward` and `required_signatures`. + - **Open** - What counts as large, stated as a shape rather than a line count, since the property that matters is whether the change can be read at a glance and reverted cleanly. + - **Settled** - The reason the grant exists is the one-line config edit that a review round costs more than it protects, and that reason stops applying well before a change gets large. + - **Settled** - This stays guidance by construction, because adding a `pull_request` rule to the operational ruleset would withdraw the direct-commit grant the model exists to give. + +### The Declared Repository Description + +One pull request moving the canonical short description into declared data, which settles the second-paragraph ambiguity by construction rather than by writing an extraction rule the same change then deletes. + +**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], [`spec/readme-structure.md`][readme-structure], and [`CODESTYLE.md`][codestyle]. **Cost** one hub edit plus a carried re-vendor of the `CODESTYLE.md` item, and repos adopt the field one at a time. + +- **Declare the description in [`registry/repos.json`][repos] instead of deriving it by parsing the README.** Every check and every push then reads a field. + - **Blocked by** - Nothing. + - **Issue** - None filed, and the disposition is recorded on [#509][issue-509]. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where neither `registry/repos.json` nor `registry/repos.schema.json` carries a `description` key. + - **Open** - Nothing beyond sequencing, which is that this leads and the README shape follows. + - **Settled** - PhotoCleaner#32 measures the cost of parsing, since a workflow step reading the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. + - **Settled** - The field makes the README intro a third mirror rather than the source, so the audit compares all three against one declared value and `repo-config/configure.sh` sets the About panel from the same field it already sets every other setting from. + - **Settled** - The 100-character cap stays, since Docker Hub's short description is the tightest surface. + - **Settled** - The field is optional at first so the audit falls back to the README intro while repos adopt it, and it needs a schema entry because `registry/repos.schema.json` sets `additionalProperties: false`. + - **Settled** - The ask on the Docker repos meanwhile is only that the parsing step is not propagated further. + +- **Let the README intro carry more than the tagline, and say which line the mirrors take.** The current pair of rules forbids a README from saying anything further about itself above the fold. + - **Blocked by** - The entry above, since taking this first means writing an extraction rule the registry change deletes. + - **Issue** - [#577][issue-577], which carries the three surfaces that change together. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05, where [`spec/readme-structure.md`][readme-structure] item 1 reads as though the canonical description is the paragraph after the H1 and [`CODESTYLE.md`][codestyle] has `HISTORY.md` copy the same intro paragraph verbatim. + - **Open** - Nothing. + - **Settled** - The shape is that the first line after the H1 is the tagline, it alone carries the 100-character link-free rule, it alone mirrors to the GitHub About panel, the Docker Hub short description and the `HISTORY.md` opening, and any further paragraph is free prose no mirror reads. + - **Settled** - The audit changes with the rule, since it measures the first non-empty line and would otherwise report a legitimate second paragraph. + - **Settled** - The cap belongs to a mirror rather than to the reader, which is why declaring the field removes the parser that motivates it. + +### Content in the Wrong File + +One pull request teaching the audit to see content sitting in a file the section model assigns elsewhere, which is invisible today and reported as a missing file instead. + +**State** `decision`. **Touches** [`spec/audit.py`][audit] and possibly [`spec/files.json`][files]. **Cost** one hub edit, hub-only, and it changes what every repo's next audit reports. + +- **Compare an `intent` file's headings against the destinations the section model assigns.** Collect the level-two headings, subtract the ones the manifest declares for that file, and compare the remainder against the headings other destinations declare. + - **Blocked by** - Nothing. + - **Issue** - [#523][issue-523], which carries the four things to settle. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the audit checks file presence, declared-section presence, verbatim hashes, and workflow interface conformance, and nothing that reads a heading against a destination. + - **Open** - Whether an undeclared heading is a finding at all, given a repo may legitimately add locally. + - **Open** - Whether the destination mapping becomes declared data rather than prose, and whether it reaches the advisory `ARCHITECTURE.md`. + - **Open** - How many repos are affected, measured before the check is designed rather than after it starts reporting. + - **Settled** - The case that found it is a repo whose `.github/copilot-instructions.md` carried 311 lines under nine headings assigned to `ARCHITECTURE.md` and `OPERATIONS.md`, reported as a missing-file letter while the misplacement that caused it was invisible. + - **Settled** - The similarity-based version is rejected by [`spec/section-model.md`][section-model], and a detector built on it produces findings whose remedy is to delete content. + +### Registry Membership Coverage + +One pull request asking the inverse question the fleet tools never ask, whether a repository that exists has a registry entry, since every tool iterates the registry and an omission at standup is permanent and silent. + +**State** `decision`. **Touches** [`spec/audit.py`][audit], [`registry/repos.json`][repos] and its schema, and [`STANDUP.md`][standup]. **Cost** one hub edit, hub-only. + +- **Report a non-fork repository under the owner that has no registry entry.** The reports read as complete while under-counting today. + - **Blocked by** - Nothing. + - **Issue** - [#550][issue-550], which carries the four repos the comparison found. + - **Checked** - `develop` at `362aec8`, per the issue, and unverified since. + - **Open** - How a deliberate exclusion is recorded, since without one the check becomes a permanent four-line complaint people learn to scroll past, and the candidates are a third `status` value or a separate list carrying a reason per entry. + - **Open** - Where the check runs, since neither `validate.py` in CI nor an owner-initiated audit catches an omission at the moment it is made, which is the standup itself and the moment the fix costs one line. + - **Settled** - The consequence is worse than a gap, because the reports are confidently wrong rather than silent: [reports/divergences.md][divergences-report] counted 19 repos owing `AGENTS.md` "Fleet Bootstrap" when the real number was 20. + - **Settled** - The procedure is not the gap, since [`STANDUP.md`][standup] section 1A already says to write the entry and names every field, and nothing verifies it happened. + - **Settled** - The reason matters more than the mechanism, since an unexplained exclusion is the same silent omission in a different file. + - **Settled** - Private repositories are outside the public listing the issue used, so the true count is a floor rather than a total. + +### Reducing the Carried Surface Further + +One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. + +**State** `decision`. **Touches** [`AUDIT.md`][audit-doc], [`spec/secrets.json`][secrets], [`spec/files.json`][files], and [`catalog/snippets/workflows/`][workflows]. **Cost** one hub edit plus a retirement per repo on its next visit. + +- **Measure carried [`AUDIT.md`][audit-doc] and [`spec/secrets.json`][secrets] against the test.** Each is adapted per repo today and the question is how much of each is genuinely per-repo. + - **Blocked by** - Nothing. + - **Issue** - None filed, and [#305][issue-305] covers the propagation half from the other direction. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] declares both at `intent` and no longer declares `repo-config/configure.sh` at all. + - **Open** - Which of the two moves, if either. + - **Settled** - The test is stated: a repository carries the content it is audited against and the configuration that describes it, and it reaches machinery whose content is identical in every repository. + - **Settled** - `repo-config/configure.sh` is the first file moved across, carrying the ledger's only `retire` disposition and naming six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. + - **Settled** - An unreachable hub means the tool did not run, reported as not run rather than worked around, since a hand-rolled substitute is the duplicated effort the model exists to end. + +- **Investigate replacing copy-pasted workflow content with cross-repo reuse.** A public repository's composite actions and reusable workflows are consumable by any other repository regardless of owner type, so the organization account this pattern was assumed to need is not needed. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where [`.github/actions/prose-gate/action.yml`][prose-gate] is the worked example and has zero callers, in this repo or the fleet. + - **Open** - Which jobs are genuinely identical across repos against which only look similar, since a reusable workflow needing a long input list to cover per-repo variation is worse than the copy it replaces. + - **Settled** - The catalog under [`catalog/snippets/workflows/`][workflows] is copied into each repo, so a fix to a shared job is a fleet sweep rather than one edit, and it is the mechanism by which a defect in a snippet seeds itself into every repo that adopted it. + - **Settled** - The ref policy is settled rather than open, since CI reaches hub code as an action pinned to a commit SHA, which is the action-pinning rule applied unchanged. + - **Settled** - `uses:` accepts no expressions, so a per-branch ref cannot be selected in the workflow file and any branch-dependent behavior belongs inside the consumed action, which is what the prose gate action does. + +### The README Structure Rework + +One pull request reworking the README spec to the hand-crafted PlexCleaner shape the maintainer wants, and making the result auditable rather than advisory. + +**State** `decision`. **Touches** [`spec/readme-structure.md`][readme-structure] and the `readme-structure` dimension in [`spec/audit.py`][audit]. **Cost** one hub edit, and it re-grades every repo's README. + +- **Encode the distribution channel by deliverable rather than as one fixed label.** Four divergences are already identified against PlexCleaner and this repo. + - **Blocked by** - "The Declared Repository Description", since the mirrors settle first. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - Nothing. + - **Settled** - PlexCleaner ships executables and calls the channel Binary Releases, while the spec fixes the label as Versioned Releases for every repo, so the label belongs in a per-channel table. + - **Settled** - The license shield sits in the top Build Status block here and at the very bottom of PlexCleaner, inside a closing License section reading that the project is licensed under the MIT License, followed by the shield, immediately before the link definitions. + - **Settled** - The Release Notes section closes by pointing at the release history for complete release notes and older versions, which is the wanted form, and PlexCleaner writes that link inline, which the reference-style rule forbids, so the wording is adopted and the reference form kept. + - **Settled** - Channel bullets and shields vary by deliverable, meaning GitHub binaries, Docker Hub, NuGet and PyPI each carry a different bullet label and shield set, which is what a per-type table has to encode. + +- **Decide whether the canonical section order follows PlexCleaner.** This affects every repo plus the audit dimension. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - The position of the sections the spec already names, since PlexCleaner places Questions or Issues immediately after the Table of Contents where the spec orders it ninth. + - **Settled** - PlexCleaner's Performance Considerations, Runtime Metrics, Custom Plugins, Testing, Development Tooling, Feature Ideas and Sample Media Files are correctly repo-specific under the recurrence rule in [`spec/section-model.md`][section-model] and stay undeclared. + +### Branch Bootstrap in the Standup + +One pull request giving [`STANDUP.md`][standup] the branch-creation step it lacks, so an agent stops committing onto whichever branch it finds. + +**State** `ready`. **Touches** [`STANDUP.md`][standup]. **Cost** one hub edit, hub-only, since the file is deliberately not carried. + +- **State the sequence that avoids every cleanup problem.** Nothing ever has to be cleaned off `main` or `develop`, because nothing reaches them without review. + - **Blocked by** - Nothing. + - **Issue** - [#490][issue-490] covered the human-gated half and is complete, so this is the remaining half rather than the whole gap. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where section 0A covers the prerequisites and nothing says how the branches come into being. + - **Open** - Nothing. + - **Settled** - The sequence is create `main` carrying nothing, create `develop` from it carrying nothing, create the first feature branch from `develop` and do the whole standup there, add the repo to GitHub and apply the config while still on that branch, then open a normal pull request to `develop`. + - **Settled** - A git branch cannot exist without a commit, so carrying nothing means exactly one signed empty root commit, and section 0's signing window applies to it like any other. + - **Settled** - Committing onto `develop` and squashing afterwards does not work, since `non_fast_forward` is set on both `develop` payloads, and Blog was correctly blocked when it tried. + - **Settled** - The protection is uneven, since a `release` repo's `develop` payload carries a `pull_request` rule that blocks a direct commit outright while the operational payload carries only three rules, so on an operational repo only the instruction stands between the agent and an unfixable history. + - **Settled** - It matters on a public repo because the exploratory standup history is where secrets and noise commits accumulate, and squashing the feature branch is the one chance to leave them out. + +### Two Project Types and a Shared C++ Style + +One pull request extending the type model with the two types the fleet already needs, plus the shared style the `cpp` type has no canonical for. + +**State** `ready`. **Touches** [`spec/project-types.json`][project-types], [`catalog/snippets/`][snippets], [`CODESTYLE.md`][codestyle]. **Cost** one hub edit plus a carried `CODESTYLE.md` re-vendor. + +- **Add a linter-only Python type for codegen and boilerplate Python.** Code that runs during another tool's build to emit generated source ships no unit tests and no coverage and needs only the linter. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - Nothing. + - **Settled** - It stays distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage, as in PlexCleaner. + - **Settled** - ESPHome-Config stays `source-only` until it exists and its reclassification is deferred, so its one outstanding validation finding is accepted meanwhile. + +- **Add a fleet-standard clang-format config for the `cpp` type.** A catalog snippet plus a `CODESTYLE.md` C++ section, the analogue of the shared ruff config. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - Nothing. + - **Settled** - It exists so the `cpp` clang-format check references one canonical style rather than each repo inventing its own, and the ESPHome-Config agent's proposed file is the base. + +### How a Hugo Repository Carries Its Theme + +One pull request deciding what the `hugo` type says about a theme, which it declares nothing about today. + +**State** `decision`. **Touches** [`spec/project-types.json`][project-types] and [`spec/type-model.md`][type-model]. **Cost** one hub edit, and it becomes the type's contract that a second generator inherits. + +- **Decide the theme carry mechanism as a question about the type rather than about Blog.** The candidates differ along the same axis the carried-content clusters are about. + - **Blocked by** - Nothing. + - **Issue** - None filed, and [#456][issue-456] and [#558][issue-558] carry the type's intake. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05. + - **Open** - Which of three the type requires, the vendored copy Blog ships, a submodule pinned to an upstream ref, or a separate fleet-owned repository the site consumes. + - **Settled** - A vendored theme is a copy that goes stale with nothing detecting it, and a submodule is a pin Dependabot can see, which is the whole difference. + - **Settled** - Three details the intake predicted are wrong against what Blog runs, so planning from the prediction encodes requirements the repo does not meet: the theme is vendored with no recorded upstream ref rather than a Dependabot-tracked submodule, the generator is pinned by version and hash rather than run at latest, and the deploy is a separate dispatch rather than a tag cut last after the live check. + - **Settled** - What held is that the deploy is a publish, the type is named for the generator with the generic checks phrased so they do not name it, and the URL parity gate asserting a floor on the golden list length before comparing is the check of record. + - **Settled** - Promoting the generator-agnostic `hugo` checks to a shared type when a second generator arrives is a registry edit by construction, per [`spec/type-model.md`][type-model] "Generators". + - **Settled** - The `copilot_code_review` rule in both ruleset payloads gates no merge today, because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline holding the line. + +### Locally Required Secrets + +One pull request giving a repo a declared way to say what it needs at runtime, the way GitHub-stored secrets are already declared. + +**State** `decision`. **Touches** [`spec/secrets.json`][secrets] and its schema, [`spec/audit.py`][audit], and the hub's own `.gitignore`. **Cost** one hub edit plus adoption per repo that deploys. + +- **Make a gitignored secrets directory the fleet standard and declare its contents.** The required set is discoverable only by reading the deploy today. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores and the hub carries neither the directory nor a `.gitignore` entry for one. + - **Open** - Nothing on the local half, and the GitHub half below is the same axis rather than a separate problem. + - **Settled** - The pattern already runs in the fleet in two shapes, HomeAutomation-Config keeping a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeping a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. + - **Settled** - The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the split the GitHub side already gets from `requiredSecrets`. + - **Settled** - Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. + - **Settled** - Adopting it in the hub comes first, since the hub carries neither piece. + - **Settled** - The GitHub side has the same missing axis, surfaced by the `hugo` type, since a deploy's credentials are per-environment secrets and variables while `stores` is a closed enum of `actions` and `dependabot`, and [`spec/audit.py`][audit] seeds its map with those two keys and indexes it unguarded, so adding an `environments` value raises a key error for every repo whose publish maps to that mechanism. + - **Settled** - An optional `environments` block is legal in [`spec/secrets.schema.json`][secrets-schema] so a repo may declare its per-environment names, and no tool reads one where it exists, which is honest and is not a gate, so a clean audit says nothing about whether an environment is configured. + +### The Docker Image Freshness Rule + +One pull request stating that an agent never assumes a Docker image is present locally, however recently it pulled one. + +**State** `ready`. **Touches** [`GOVERNANCE.md`][governance], and [`OPERATIONS.md`][operations] if the mirrored one-liners move with it. **Cost** one hub edit, plus a carried re-vendor if the rule lands in a carried section. + +- **State the always-pull default and the explicit pull where the flag does not apply.** A background prune can remove an image between two commands of the same session. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03, where the four documented lint invocations already carry the always-pull flag and no rule states why. + - **Open** - Where it lives, since "Running the Linters Locally" is scoped to the four lint tools while the rule covers any container an agent starts, and whether it is carried, since every repo runs the same images from the same instructions. + - **Settled** - What is missing is the rule rather than the one-liners, since an agent composing an ad-hoc `docker run` drops the flag precisely because it believes the image is cached. + - **Settled** - The honest limit stops the flag reading as the whole answer, since `docker run` against a registry tag re-pulls an absent image on its own, so the cases that break are a locally built tag with no registry to pull from, and any command that branches on the image being present such as `docker image inspect` or `docker images`. + +### The Merge-Bot Token Grants + +One pull request dropping the unused token grants from the highest-blast-radius workflow shape in the fleet. + +**State** `ready`. **Touches** [`.github/workflows/merge-bot-pull-request.yml`][merge-bot]. **Cost** one hub edit plus a re-vendor, since every repo carries the file. + +- **Drop the grants no step consumes.** Every write in the file authenticates with the App token. + - **Blocked by** - Nothing. + - **Issue** - [#521][issue-521]. + - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where three jobs carry both `contents: write` and `pull-requests: write` and the fourth carries `pull-requests: write` alone, so the issue's claim that all four carry both is one job wide. + - **Open** - Whether to drop the job-level blocks or set an empty workflow-level permissions map. + - **Open** - Whether the audit compares permissions at all, given the file is `interface` fidelity with only a required-job-keys contract. + - **Settled** - The finding is least privilege on a `pull_request_target` workflow holding an App private key, where the grant is not exploitable today only because no step consumes it. + - **Settled** - [`spec/files.json`][files] declares this workflow at `appliesTo: "*"`, which closes the separate gap [#456][issue-456] raised, that the audit graded a file the file spec never required. + +### Review Cost and the Local Review Pass + +One pull request, after a measurement, stating what change size licenses and whether a local adversarial pass earns its place, which are one question because both are about where review cost goes. + +**State** `measure`. **Touches** [`GOVERNANCE.md`][governance] branching or review guidance, once the numbers exist. **Cost** a measurement first, then one hub edit plus a carried re-vendor. + +- **Measure review rounds against pull request size, and decide what the number licenses.** The data needs no new instrumentation, since the review history carries it. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `1ed0cc8` on 2026-08-03. + - **Open** - The threshold, expressed as the size at which a change is split rather than as advice to keep changes small. + - **Settled** - For each recent pull request the record carries the diff size in files and lines, the number of rounds, and the findings per round, counting suppressed findings alongside threaded ones because they are the majority of what these loops produce. + - **Settled** - Two confounds bound any line drawn from the numbers, that a large change is usually also a novel one so size and unfamiliarity move together, and that a round finding something new is the reviewer working rather than evidence of a problem, so the metric is findings a smaller first cut would have surfaced earlier. + +- **Try local defensive-review subagents as a first pass, and measure what the pass is worth.** One agent per lens rather than one general reviewer. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `b82c1a3` on 2026-08-05. + - **Open** - Whether the overlap is large enough to shorten the remote loop rather than to add a step in front of it, which is what running both for a stretch measures. + - **Settled** - The remote loop is where most of a session's tokens and wall-clock go, and it delivers findings one round at a time, which is the slowest available way to learn that a change had five problems. + - **Settled** - The trap is that a local pass finding nothing reads exactly like a clean change, and the next inference is that the remote review can be skipped, which is the one outcome the review contract exists to prevent, so the local pass is an input to the loop and never a substitute for the round the merge gate requires. + +### Where a Disproof Goes When the Reviewer Is Not Copilot + +One pull request routing the disproof record from the provider-agnostic contract, so an agent that never opens the provider runbook still knows where a proof lives after the thread closes. + +**State** `ready`. **Touches** [`GOVERNANCE.md`][governance] "PR Review Etiquette". **Cost** one hub edit plus a carried re-vendor of a byte-locked section, which is why it is not folded into the change that built the record. + +- **State that a disproof is recorded where it survives the pull request, not only in the thread.** The record exists in [`.github/copilot-instructions.md`][copilot-instructions] "Disproved Claims" and nothing agent-agnostic points at it. + - **Blocked by** - Nothing. + - **Issue** - None filed. + - **Checked** - `develop` at `756a53e` on 2026-08-07, where outcome 2 of "Every Finding Ends in an Action" ends at the thread, "Responding and Resolution Expectations" requires the proof and says nothing about where it then lives, and the only pointer to the runbook is scoped to provider mechanics. + - **Open** - Whether the destination is named in the byte-locked text at all, since a repository is free to keep its record elsewhere and a rule naming one file is a rule that has to be true in every copy. + - **Settled** - The write side is where the gap bites rather than the read side, because an agent following the loop is already routed to the runbook for mechanics and an agent posting a decline is routed nowhere. + - **Settled** - "Durable Knowledge and Self-Improvement" already requires durable knowledge to reach a committed file, so this states where one class of it goes rather than adding an obligation. + +### A Programmatic Reading of a Copilot Review + +One pull request, after a survey, deciding whether anything stands between this fleet's review loop and the raw prose of a Copilot review. Today `scripts/pr_review.py` reads the review body as text and holds a vetted inventory of the headings, collapsed sections, metadata labels and coverage wordings it recognizes, blocking on anything it does not. That design is correct for a prose surface and it carries a cost the maintainer has accepted deliberately: a wording change at GitHub blocks every open pull request in the fleet at once, until the inventory is updated. The cost is worth paying against a reviewer silently missing a raised finding, which is the failure it replaces, but it is worth paying only for as long as prose is the only surface on offer. + +**State** `measure`. **Touches** `scripts/pr_review.py` and the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], once the survey says whether there is anything to move to. **Cost** a survey first, then either nothing or a rewrite of the reading layer, which is the larger of the two outcomes and the reason the survey comes first. + +- **Find out whether GitHub publishes a structured form of a Copilot review, and decide whether to read that instead of the prose.** A schema, an API surface, a published payload, or a maintained library, anything that would make a wording change a non-event rather than a fleet-wide block. + - **Blocked by** - Nothing. The prose reader ships either way, so this decides what replaces it rather than whether the loop has a gate. + - **Issue** - None filed. The prose reader and its vetted inventory shipped under [#607][issue-607], which is the change this would supersede. + - **Checked** - `develop` at `20916ad` on 2026-08-07, reading the live GraphQL schema by introspection and one review over REST, against the reader in `scripts/pr_review.py`. + - **Open** - Whether `bodyHTML` is a better surface than the Markdown body, since it arrives as a rendered tree whose structure survives a change in Markdown syntax, while leaving the wording drift the inventory exists for exactly where it is. + - **Open** - Whether any third-party library tracks this output, and whether depending on one is acceptable at all, given that [`scripts/README.md`][scripts] holds these scripts to the standard library with no third-party packages. + - **Open** - Whether the review's own inline threads and their metadata carry enough to derive coverage and suppression without reading the body, which would narrow the prose surface rather than replace it. + - **Settled** - The public API carries no structured Copilot review as of the date above. GraphQL `PullRequestReview` exposes `body`, `bodyText` and `bodyHTML` and no field naming a finding, a file count, or a withheld section, and REST returns the same prose body beside its ids and its state. + - **Settled** - The only Copilot-named types in the GraphQL schema are `CopilotCodeReviewParameters` and its input form, which configure review-on-push inside a branch ruleset and describe nothing about a review that has run, so the schema search that looks promising by name answers a different question. + - **Settled** - A negative finding is the deliverable as much as a positive one, and it is recorded here rather than re-derived, since the reading layer's design rests on prose being the only surface and that premise is worth re-checking rather than assuming. + +## Standalone Chores + +Small work with no research to preserve, selectable one bullet at a time. + +- **Reconsider whether the pre-commit hook runs the doc gates now that they are diff-scoped.** [`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, which was sound when the only mode was a whole-tree sweep, and a diff-scoped run finishes in about a second. The failure it would prevent is the most repeated one on record, comment sentences wrapped across lines caught after the commit rather than before it. Weigh it against the standing preference for a fast hook and against a hook that runs the gate from the wrong directory, which is its own false clean. +- **Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file beside it and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script. +- **Make a table of contents standard for a long document rather than for the README alone.** [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current. +- **Adopt the OCI annotation keys for Docker image metadata across the Docker repos**, replacing the ad-hoc and label-schema keys, per [#363][issue-363]. +- **Sweep the central package-version property to `Directory.Packages.props` fleet-wide**, since PlexCleaner sets it in `Directory.Build.props`, off the [`CODESTYLE.md`][codestyle] canonical. +- **Canonicalize Python linter-config placement on `pyproject.toml`**, since one cataloged repo uses a standalone ruff config plus a pyright config. Track it as a drift finding and fix it downstream. +- **Populate [reports/][reports] for the cataloged repos that still have no audit**, since a registry `status` of `cataloged` asserts a result only a committed report evidences. Nine of 22 have one, measured on `develop` at `3d1a0b1` on 2026-08-06. This is paced by maintainer capacity rather than blocked, since repos are brought up to spec as they are worked on. +- **Finish onboarding hardening**, from [#310][issue-310], making the [`AUDIT.md`][audit-doc] audit a required onboarding step and running the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix]. Every cold-standup cell reads not-tested today. +- **Refresh the README, which has gone stale, and evaluate a lower-maintenance structure**, for example a per-section index pointing into each doc with a one-line description, keeping the README as the adoption and audit-instruction entry point. A per-section index trades brevity for a sync obligation, since it must track what the docs contain. +- **Consider renaming this repo to reflect the audit-catalog identity**, which updates badge and link URLs across the fleet. +- **Revisit automating the audit**, explored and deliberately deferred, recorded so the reasoning is not re-derived. Three shapes were considered, a scheduled hub-driven audit publishing each report as a workflow artifact, the same thing committing the report back, and a pull-request hook in each downstream repo auditing itself against the current hub. Three things block all of them: until the fleet reaches stasis a scheduled run reports mostly noise, since a repo mid-onboarding is expected to be non-conformant, the hub has to be stable before downstreams audit against it because a hub change lands as fleet-wide findings the same day, and the downstream half is a catch-22 since a self-auditing hook is CI instrumentation the repos that most need it do not carry. Worth reopening once the fleet is onboarded and the hub goes a stretch without carried-content changes, and the artifact shape is the one to try first since it produces evidence without committing anything. + +## Fleet Sweeps + +Work that lands on a downstream visit rather than as a hub pull request, so it is not selectable here. The fleet is caught up periodically rather than after every hub change, which means a carried-content edit landing in the hub does not owe an immediate sweep and this list is expected to carry several entries at once. + +Blog is the pilot. A sweep is proven there before any fleet-wide rollout, because it is the smallest tree, `hugo` plus `source-only` with no build to break, cataloged and audited on 2026-08-05, and one of only two repos carrying `AGENTS.md` "Fleet Bootstrap" today, so a carried-section change can be observed arriving there. The other carrier is HomeAutomation-Config, which is `operational` and therefore exercises the direct-to-`develop` path rather than the pull request one, which is the second visit worth making rather than the first. + +Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since the committed copy predates the retirement decision and renders `repo-config/configure.sh` under a re-vendor disposition that no longer applies to it. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. + +- **Re-vendor the changed `verbatim` content, which is one sweep covering seven files.** Every repo holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06 for the sections below, with the prose batch adding five more [`GOVERNANCE.md`][governance] sections, verified `develop` at `d791930` on 2026-08-07. + - **Outstanding** - The whole fleet, pilot on Blog first. + - **Issue** - None filed, and it is the follow-through [#489][issue-489] and [#379][issue-379] wait on. + - **Rides with** - The `configure.sh` retirement and the `.editorconfig` line from [#353][issue-353], since all three are the same visit. + - **Detail** - In [`AGENTS.md`][agents], "Context and Delegation Discipline" carries the wait rule's failure clause and "Where the Rules Live" carries a row for "Hub-Hosted Tooling". + - **Detail** - In [`GOVERNANCE.md`][governance], "Verification Discipline" carries the rule that a launched process is not a result and the rule that a change's checks are located before any is run, with CI's coverage not being that list, "PR Review Etiquette" carries the five outcomes that close a finding, "Repository Boundaries and Write Safety" carries the rule that a refused write is reported rather than re-shaped, and both "Representative Data in Agent-Authored Text" and "Hub-Hosted Tooling" are entirely new carried sections no downstream repo holds, which the audit reports as sections that never arrived rather than as drift. + - **Detail** - Three further [`GOVERNANCE.md`][governance] sections differ by a single word each, "Documentation Style Conventions", "Communicating with the User" and "Repository Details", where a format name took the capitalization [`CODESTYLE.md`][codestyle] "Markdown and Spelling" states, so they are byte-mismatched for a reason a reader of the diff would otherwise call cosmetic. + - **Detail** - Two comment lines in [`.markdownlint-cli2.jsonc`][markdownlint] took the same capitalization, and that file is `verbatim` and `whole`, so every downstream copy is byte-mismatched on a config nothing else changed about. + - **Detail** - [`CODESTYLE.md`][codestyle] is the fifth file, at `intent` rather than `verbatim`, so it reaches the fleet as a rule each repo adopts in its own copy, and the same mixed spelling waits in every downstream tree. + - **Detail** - [`.github/copilot-instructions.md`][copilot-instructions] is the sixth, also at `intent`, where "Reply and Thread Resolution Workflow" now leads with the hub's reply helper and keeps the hand-run mutations as the cross-owner and unreachable-hub path. A repo taking the old copy is not broken by it, since the mutations it documents still work, so this rides the visit rather than gating it. + - **Detail** - The prose batch rewrote punctuation in five `verbatim` [`GOVERNANCE.md`][governance] sections, "Branching Model", "Release Model", "Documentation Style Conventions", "PR Review Etiquette" and "Workflow YAML Conventions", so every downstream copy of those five is byte-mismatched and the audit reports it as stale. No rule changed meaning, so the re-vendor is a hash refresh rather than a propagation, and a repo taking the old copy is correct on the rule while wrong on the bytes. + - **Detail** - [`WORKFLOW.md`][workflow] is the seventh file and `repo-config/README.md` joins [`CODESTYLE.md`][codestyle] and [`.github/copilot-instructions.md`][copilot-instructions] at `intent`, where a punctuation-only edit produces no hash and therefore no audit finding at all. Nothing reports these, which is why they are recorded here rather than left to the run. `HISTORY.md` is `presence` and is each repo's own changelog, so its one fix owes nothing downstream. + +- **Carry the `Local Verification` heading into every repository's `OPERATIONS.md`.** The heading leads the file and states what verifying a change there requires, naming the part of the repo's contract CI structurally cannot exercise, and a repo whose gates are entirely in CI says that under it rather than omitting it. + - **Hub state** - Done, verified `develop` at `8e10a2c` on 2026-08-06, where [`spec/section-model.md`][section-model] and [`STANDUP.md`][standup] declare six headings and this repo's own [`OPERATIONS.md`][operations] leads with the section. + - **Outstanding** - Every repo carrying an `OPERATIONS.md`, which is every repo, since none holds the heading yet. + - **Issue** - [#597][issue-597], filed from a downstream repo whose pre-merge gate sat under a heading of its own invention and was skipped by an agent following every carried rule correctly. + - **Rides with** - The `verbatim` re-vendor above, since the carried [`GOVERNANCE.md`][governance] rule that points at the heading lands in the same visit and neither half works alone. + - **Detail** - The audit reports nothing here today, because `OPERATIONS.md` is presence-checked only, so a repo using none of the declared headings passes. The heading check is [#523][issue-523]'s cluster, "Content in the Wrong File", and until it ships this sweep is verified by reading each file rather than by a run. + - **Detail** - A repo that already documents a local gate has the content and not the location, so the visit is usually a re-heading rather than new prose, and the prose it does need is the sentence naming what CI cannot reach. + +- **Retire the downstream `repo-config/configure.sh` copies.** Delete the copy as each repo is next worked on and run the hub's script against it by name. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where [`spec/files.json`][files] no longer declares the file and [`spec/divergences.json`][divergences] carries it under the `retire` disposition. + - **Outstanding** - Six repos, NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore and LanguageTags. + - **Issue** - None filed, and [#580][issue-580] carries the decision. + - **Rides with** - The `verbatim` re-vendor above. + - **Detail** - Nothing asks a repo for the file and nothing reports its absence, which makes this a visit-ordered chore rather than a gate. + - **Detail** - The six carried a fork predating the payload-driven check mode, which is the drift this removes rather than converges. + +- **Drop the `.editorconfig` analyzer relaxation across six C# repos.** The hub side is done and the tree confirms it. + - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06, where the analyzer severity property appears nowhere in `.editorconfig`. + - **Outstanding** - Six C# repos, sequenced in the issue so PhotoCleaner's 362 sites do not gate the other five. + - **Issue** - [#353][issue-353], which stays open on the downstream half alone. + - **Rides with** - The `verbatim` re-vendor above. + +- **Close out the two downstream acknowledgements that hold their issues open.** Neither is hub work. + - **Hub state** - Done, verified `develop` at `1ed0cc8` on 2026-08-03, where the manifest gap [#379][issue-379] raised is closed by `repo-config/settings.json` reaching [`spec/files.json`][files], and the `configure.sh` half has since been retired outright. + - **Outstanding** - Financial-Modeling's acknowledgement and re-vendor for [#379][issue-379], and the re-vendor [#489][issue-489] leaves. + - **Issue** - [#379][issue-379] and [#489][issue-489]. + - **Rides with** - The `verbatim` re-vendor above. + +- **Widen the operational lint trigger to `develop` on four repos.** Each triggers on a pull request to `main` only and therefore runs nothing at all on a pull request into `develop`. + - **Hub state** - Done, verified `develop` at `b82c1a3` on 2026-08-05, where the change is prose and spec, so it fixes no downstream repo by itself. + - **Outstanding** - Four repos, HomeAutomation-Config, ESPHome-Config, HomeAssistant-Config and Vantage-Config, one line each. + - **Issue** - [#585][issue-585]. + - **Rides with** - Nothing, since an operational repo takes its changes direct to `develop`. + - **Detail** - Confirm the workflow really does trigger on `main` alone before editing, because a repo already naming both is conformant and needs no change. + - **Detail** - Leave the ruleset alone, since the required check stays on `main` and nothing is added to `repo-config/operational/develop.json`. + - **Detail** - The evidence this is not hypothetical is HomeAutomation-Config PR 34, which merged into `develop` with an empty check list and a clean mergeable state. + +- **Finish the host rollout and fill the tooling matrix, which are one visit each.** The rollout needs the matrix to be repeatable and the matrix is only worth filling if the rollout uses it. + - **Hub state** - Done for the documentary half, verified `develop` at `1ed0cc8` on 2026-08-03. + - **Outstanding** - Four machines, WSL2 Ubuntu, the MacBook Air and both ThinkPads, plus any headless or cron environment running with the token. macOS needs someone on that platform, the Proxmox question is whether that host also runs containers which decides whether Docker is required there, and the engine-inside-the-distro variant of the WSL2 Docker cell is unverified. + - **Issue** - [#365][issue-365] and [#483][issue-483]. + - **Rides with** - Nothing on the hub, since the write-guard newline fix has landed on `develop` and a machine keeps running the old hook until the installer is re-run there. + - **Detail** - A ticked row means the host-wide rules text and not the hook, since only running the installer deploys both layers, and the proxmox host proved that distinction by carrying the documentary half alone for eight days on the machine where the incident originated. + - **Detail** - The prose comment batch rewrote comments in [`gh-write-guard.py`][write-guard] and both installer wrappers, so every installed copy is now behind the hub by that much. The divergence is comment-only and changes no decision the hook takes, which the self-test confirms, so it is a re-run of the installer at the next visit rather than a correctness problem. + - **Detail** - Honor the issue's own rule when filling a cell, that an unverified install command is worse than a blank, because a blank prompts a question while a wrong command produces a broken host and a false sense that setup succeeded. + - **Detail** - The superseded safety section from [#364][issue-364] still sits above the canonical block in this host's rules file, so the two overlap. Removing it is a judgment call on a per-machine file, which is why it is surfaced rather than applied. + +## Recorded for the Maintainer + +Actions on issues that are the maintainer's to take, each carrying its evidence so it is one action rather than a re-derivation. + +- **Re-scope [#305][issue-305] to the push half, and make it the tracking issue for the fleet re-vendor sweep.** Most of what it asked for is built, since the fidelity model, the [`spec/files.json`][files] manifest, [`spec/divergences.json`][divergences] with its generated [reports/divergences.md][divergences-report], and [`AUDIT.md`][audit-doc] section 10 together give the canonical-versus-adapted split and the audit path it proposed. What is genuinely still missing is the push half, since every one of those detects drift while the sweep that fixes it is manual. Re-scoped, it carries the "Fleet Sweeps" visit manifest and Blog as the pilot. Closing it against the built machinery is the alternative, and it loses the only tracking issue the sweep would have. +- **Comment on [#577][issue-577] that it is decided together with the declared description.** Declaring the field in [`registry/repos.json`][repos] makes every mirror read a field rather than parse a paragraph, so taking [#577][issue-577] first means writing an extraction rule the registry change then deletes. ## Verified Complete, Awaiting Close -Each of these was checked line by line against the current tree and has nothing left to do here. Closing them is the maintainer's call, and each wants the evidence quoted in the closing comment rather than a bare close. - -- [#509][issue-509] (PhotoCleaner spec questions) is complete on all five items. Item 1, `.github/copilot-instructions.md` now reads "Most of `GOVERNANCE.md` is universal fleet law" and describes `AGENTS.md` as the thin router carrying two byte-locked sections and no repository-specific ones. Item 2, `CODESTYLE.md` "Markdown and Spelling" now names `details` and `summary` as allowed, matching [`.markdownlint-cli2.jsonc`][markdownlint]. Item 3, [`spec/readme-structure.md`][readme-structure] now states the private-repository behavior outright, that the full shape is carried and the GitHub-sourced badges render broken until the flip, which is accepted rather than worked around. Item 4, the same file now says the `HISTORY.md` mirror rule lives in `CODESTYLE.md`, which every repo carries, so the rule has a carried home and the file states only what the audit does with it. Item 5, `WORKFLOW.md` D2.2 now reads "the check exits early while the job still reports success" and spells out why a job-level `if:` would couple the release to smoke through `needs:`. -- [#490][issue-490] (STANDUP has no repo-creation step) is complete on all four suggestions. [`STANDUP.md`][standup] carries a new section 0A, "Hand Over What Only the Maintainer Can Supply", listing the repository, the installed App, the App secret values in both stores, and every declared publish credential and environment. It states that a repo with no remote is not partially stood up but not started, and that a blocking prerequisite is escalated the moment it is found rather than carried, which is the failure mode the issue diagnosed in the agent's own task list. Step 4 now opens with `gh repo view` and carries the ruleset ordering constraint that the issue asked for. -- [#489][issue-489] (Blog carry findings) is closed on all six items by the maintainer's own status table, five fixed and one resolved as needing no change. The single piece of follow-through it leaves is the `repo-config/configure.sh` re-vendor, which is already carried by the re-vendor entry below rather than by this issue. -- [#379][issue-379] (Financial-Modeling fidelity findings) is dispositioned on all six items, and the manifest gap the follow-up raised was fixed by adding `repo-config/configure.sh` and `repo-config/settings.json` to [`spec/files.json`][files]. What holds it open is a downstream acknowledgement and that repo's own re-vendor, neither of which is hub work. -- [#353][issue-353] (`.editorconfig` relaxes all analyzers) is done hub-side, and the tree confirms it, since `dotnet_analyzer_diagnostic.severity` no longer appears in `.editorconfig`. What holds it open is the downstream re-vendor across six C# repos, sequenced in the issue so PhotoCleaner's 362 sites do not gate the other five. That is cross-repo work needing the maintainer to name the repos. - -## Gate Honesty - -The gates in [`scripts/`][scripts] are hub-only and are the fleet's main defense against agent-authored drift, so a gate that reports clean without having read anything is the worst failure available here. The `gh-write-guard` hook in [`host-setup/agent-safety/`][agent-safety] is grouped with them, since it is the same kind of instrument even though it ships per host rather than per repo. These entries are ordered so the scope floor lands before anything relies on a clean verdict. - -- Make [`prose_lint.py`][prose-lint] assert a floor on its own scope, applying to itself the rule [`GOVERNANCE.md`][governance] already states: a gate that finds nothing is indistinguishable from a gate with nothing to find. A `--diff` run that resolves a non-empty diff and then matches **zero** files has almost certainly failed to scope rather than found a clean change, so it should say so instead of exiting 0. One session produced four separate routes to that same false clean: an unresolvable base widening to a whole-tree scan, a multi-line `paths` input read only to its first newline, a diff taken in one repository while scanning another, and a path under no repository at all. Each was fixed with its own guard, which is the wrong shape, because the fifth route will need a fifth guard and will be found the same way the first four were, by a reviewer rather than by the gate. A floor assertion covers the family. Note the honest limit before building it: a change touching only files the gate does not read (an image, a lock file) legitimately scopes to zero, so the assertion compares against the diff's own file list rather than against zero alone. This lands first, because every entry below it produces verdicts that are only worth reading once the gate can prove it read something. -- Clear the [#519][issue-519] prose backlog, starting with `catalog/snippets/**`. Two of the four changes that issue planned have already landed and its headline numbers are stale, so re-measure before quoting anything from it. `comment-wrap` and `comment-case` are now in `DEFAULT_RULES`, and `reports/` is now exempt as a generated tree, which is why the current figures are **559** whole-tree under the default rules and **184** under `catalog/snippets` alone rather than the 668 and 119 the issue records. The whole-tree figure is the one that moves, having been 534 when this entry was written and 520 by the time [#570][issue-570] scoped the semicolon exemption to the sentence it lives in, which reported 44 semicolons the rule had been silent on. Re-measure it rather than quoting it, since a fix to the gate moves it as readily as a fix to the prose does. The snippets lead the fix order for the reason the issue gives, that a non-conformant snippet seeds its violations into every repo that adopts it and the downstream repo is then flagged for content it was handed. What remains undecided is whether the gate itself becomes a carried file rather than a hub-only one, which is the same question the entry below on hub-only machinery asks from the other direction. -- Gate the pattern-detectable half of the representative-data rule in [`prose_lint.py`][prose-lint], meaning an absolute home path (`/home/<name>`, `/Users/<name>`, `C:\Users\<name>`) or a bare drive letter sitting in committed prose, a code comment, or a fixture. [`GOVERNANCE.md`][governance] "Representative Data in Agent-Authored Text" states the rule and says why a check is a floor rather than an answer, so the check is introduced as covering the easy half or it gets read as closing the rule, which is the specific way it would make things worse. Three things to settle when writing it. The exemption carries the whole burden, since the rule's own wording, the `host-setup/` docs, and the audit's examples all quote path shapes in order to describe them, and a wrong exemption hands out a work list that damages correct documents. The leak that motivated the rule was in a pull request comment, which no committed-file linter reads at all, so say what surface the gate covers rather than letting its name imply the rule. And a home path in an operational repo's runbook may be the literal path an operator types, which is the repo's own content rather than an agent quoting the maintainer's environment, so decide whether the finding is scoped by file, by repo type, or left for the author to judge. -- Teach the `sha-pin` check in [`repo_gate.py`][repo-gate] to verify a pin **resolves**, not merely that it is shaped like a SHA. Forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a real failure mode rather than a hypothetical one. A resolvability check also catches the neighboring case, a pin whose commit was reachable only from a branch that has since been squashed and deleted, which breaks a downstream gate long after the change that caused it. Scope the network call to same-owner repositories, where the fleet's own actions live, and skip rather than fail when the host is offline so the local gate stays usable. Note that the existing `gh-write-guard` hook cannot cover this, since it watches Bash and an editor tool writing the same string into a file never reaches it. -- Add a check that a pull request's **description** does not contradict its own branch. Three stale descriptions in one session generated six review findings between them, each one a reviewer noticing that the body named a commit, a branch, or a behavior the branch no longer carried. The cheap and precise form is to extract SHAs and `uses:` refs quoted in the body and confirm each still appears in the head tree, since those are the claims that go stale silently and the ones a reviewer actually catches. Prose claims are out of scope, and deliberately so: judging those needs a similarity heuristic, which [`spec/section-model.md`][section-model] already rejects for exactly the reason it would fail here. -- Reconsider whether the pre-commit hook should run the doc gates now that they are diff-scoped. [`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, and that reason was sound when the only mode was a whole-tree sweep. A `--diff` run reads the lines one commit touches and finishes in about a second, so the trade has moved. The failure it would prevent is the most repeated one on record: comment sentences wrapped across lines, caught by CI or by a reviewer after the commit rather than before it, over and over within a single session. Weigh it against the standing preference for a fast hook, and against the risk of a hook that runs the gate from the wrong directory, which is its own false clean. -- Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is the cheaper form. The evidence is the review record rather than a language preference: a non-trivial shell script earns findings round after round, while every gate under [`scripts/`][scripts] carries a `test_<script>.py` beside it and converges in one or two. `repo-config/configure.sh` and `host-setup/agent-safety/install.sh` are the two worth measuring, and the measure is lines, branch count, and the review rounds each has actually cost, not a general claim about the two languages. Two constraints bound the answer before anything is rewritten. A bootstrap script runs before anything is installed, so an installer that needs the Python it exists to install is not a rewrite worth having, which protects `install.sh` more than it protects `configure.sh`. The constraint that used to bound `configure.sh` has lifted, since it is hub-hosted rather than carried and changing its form is now one edit rather than a fleet re-vendor, so it is the cheaper of the two to rewrite and no longer has to wait on a sweep. -- Move the commands that keep failing the same way into helper functions, rather than restating the rule they keep failing against, per [#580][issue-580]. Two are on record. A command run from the wrong working directory, which does not error and instead returns a confident answer about a different tree. And a GitHub node id typed by hand into a mutation, which the `gh-write-guard` hook correctly refused because the id had not come from a query in that same command. The second is the instructive one, since the rule it broke is stated plainly in [`GOVERNANCE.md`][governance] and in the host-level guidance, and an agent that had read both still reached for the literal, which says the shape of the operation is what fails rather than the agent's knowledge of the rule. A reply-and-resolve helper in [`pr_review.py`][pr-review] would take a pull request number and a finding, query the thread ids itself, and never put an id anywhere a hand could type one, which removes the failure instead of detecting it. The dependency it was waiting on is met, since [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states how a repository reaches a hub script, so a helper added here is runnable from every repository the day it lands. -- Fix the newline parsing in [`gh-write-guard.py`][write-guard], where `_git_subcommand_arglists` ends a `git push` argument list at `&&` but not at a newline, so every token on a later line of the same command is read as a refspec. Measured against the installed hook, `git push -u origin revendor/x` alone resolves to the one branch, while the same push followed by a newline and `gh pr create` with a base of `develop` resolves to five, meaning `revendor/x`, `gh`, `pr`, `create`, and `develop`. The `&&` form resolves correctly, which is what isolates the defect to the newline case. The direction of the error is over-blocking rather than under-blocking, so it is a usability defect rather than a safety hole, and that is exactly why it is worth fixing rather than tolerating: the denial tells the agent it is attempting a direct push to a protected branch that only lands by bypassing a rule with admin power, when the push targets an ordinary feature branch. An agent that believes the message either escalates a push needing no escalation or goes looking for a way around the guard, and teaching a safety hook to cry wolf is how it stops being read. Note the fix reaches every host only when `host-setup/agent-safety/install.sh` is re-run, so it lands with the rollout work in [#365][issue-365] rather than ahead of it, and the entry to write alongside the fix is a self-test case for the newline form, since the existing suite covers the `&&` form and passed throughout. - -## Carried Content and Propagation - -Everything here changes a file the fleet holds a copy of, so each entry costs a hub edit plus a sweep, and the sweep is the part that gets skipped. - -- Re-vendor the changed `verbatim` content across the fleet, which is one sweep covering five files. `repo-config/configure.sh` is carried `verbatim` with `appliesTo: "*"` and the hub swept it to one sentence per line. In `AGENTS.md`, "Context and Delegation Discipline" carries the wait rule's failure clause and "Where the Rules Live" carries a row for the new section named below. In `GOVERNANCE.md`, "Verification Discipline" carries the rule that a launched process is not a result, "PR Review Etiquette" carries the five outcomes that close a finding, "Repository Boundaries and Write Safety" carries the rule that a refused write is reported rather than re-shaped, and "Representative Data in Agent-Authored Text" is an entirely new carried section that no downstream repo holds, which the audit reports as a missing section rather than as drift. Three further `GOVERNANCE.md` sections differ by a single word each, "Documentation Style Conventions", "Communicating with the User" and "Repository Details", where the format's name was capitalized to the convention `CODESTYLE.md` "Markdown and Spelling" now states, so they are byte-mismatched for a reason a reader of the diff would otherwise call cosmetic. Two comment lines in `.markdownlint-cli2.jsonc` took the same capitalization, and that file is `verbatim` and `whole`, so every downstream copy is byte-mismatched on a config nothing else changed about. `CODESTYLE.md` carries the new item and is the fifth file, at `intent` rather than `verbatim` fidelity, so it reaches the fleet as a rule each repo adopts in its own copy rather than as bytes to match, and the same mixed spelling is waiting in every downstream tree. Every repo already holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. This sweep is also the follow-through [#489][issue-489] and [#379][issue-379] were waiting on, and the `.editorconfig` line in [#353][issue-353] rides the same visit to each repo. Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since the committed copy is dated 2026-07-22 and therefore predates the router split, which shows in it naming "Repository Boundaries and Write Safety", "Git and Commit Rules" and "Verification Discipline" as `AGENTS.md` sections when all three now live in `GOVERNANCE.md`. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. -- Drop the unused `GITHUB_TOKEN` grants from the merge-bot workflow, per [#521][issue-521], which the tree confirms is still open, since all four jobs still carry `contents: write` and `pull-requests: write` while every write in the file authenticates with the App token. The finding is least privilege on the highest-blast-radius shape in the fleet, a `pull_request_target` workflow holding an App private key, where the grant is not exploitable today only because no step consumes it. Three things to settle: whether to drop the job-level blocks or set a workflow-level `permissions: {}`, whether the audit should compare permissions at all given the file is `interface` fidelity with only a `requiredJobKeys` contract, and the re-vendor, since every repo carries the file. Note that [`spec/files.json`][files] now declares this workflow at `appliesTo: "*"`, which closes the separate gap [#456][issue-456] raised, that D8.1 graded a file the file spec never required. -- State the rule that an agent never assumes a Docker image is present locally, however recently it pulled one, because a background prune can remove an image between two commands of the same session and the run then fails on something that worked minutes earlier. The prescription is the always-pull flag by default, and an explicit `docker pull <image>:<tag>` before use where the flag does not apply. The four documented lint invocations already carry `--pull=always`, in [`GOVERNANCE.md`][governance] "Running the Linters Locally", mirrored in [`OPERATIONS.md`][operations] and both VS Code task snippets, so what is missing is the rule rather than the one-liners: an agent composing an ad-hoc `docker run` drops the flag precisely because it believes the image is already cached. Two things to settle when writing it. Where it lives, since that section is scoped to the four lint tools while the rule covers any container an agent starts, which argues for placing it with the Docker or verification guidance instead, and whether it is carried to the fleet, since every repo runs the same images from the same instructions. Note the limit before writing it, so the flag does not read as the whole answer: `docker run` against a registry tag re-pulls an absent image on its own, so the cases that actually break are a locally built tag with no registry to pull from, and any command that branches on the image being present (`docker image inspect`, `docker images`) rather than simply running it. -- Retire the downstream `repo-config/configure.sh` copies, which is the sweep the [#580][issue-580] decision leaves. [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" settles where a carried file may name hub machinery, and it settles it in the third direction the entry below proposed rather than either of the two this entry originally offered: the hub hosts the tool, a carried rule names it as the hub's, and no repository holds a copy. The manifest entry is gone from [`spec/files.json`][files], so nothing asks a repo for the file and nothing reports its absence, which makes this a visit-ordered chore rather than a gate. Delete the copy as each repo is next worked on and run the hub's script against it by name. For the size of the sweep, regenerate [reports/divergences.md][divergences-report] rather than reading the committed copy, which predates this decision and splits the carriers into a matching set and a divergent one under a re-vendor disposition that no longer applies to either. The ledger entry in [`spec/divergences.json`][divergences] carries the per-repo detail under the new `retire` disposition. -- Define the fleet's own vocabulary somewhere a repository with no history will find it, per [#579][issue-579], because the words a request is phrased in are defined nowhere it can read. **Hub** is used throughout these docs and in every request asking a repository to reconcile or audit itself against the canonical, and no file states that it means this repository, so a repository carrying no instruction set has to be told what the hub is, where it lives, and which file to open, by hand, each time. The review loop has the opposite shape of the same problem: [`GOVERNANCE.md`][governance] "Expected Review Loop" states the procedure precisely and never presents its name as the way to ask for it, so every request improvises a phrasing and the scope an agent reads out of it moves with the wording. A defined term is what makes "close the review loop on #123" a complete instruction. Two things to settle. Where the definitions live, since the natural home is the byte-locked Fleet Bootstrap section of [`AGENTS.md`][agents] and a repository with no carried instruction set is precisely the one that does not have it, which leaves the hub's own `README.md` as the only surface a stranger to the fleet reads first. And whether the entry point is named per task rather than per repository state, since the bootstrap section already routes correctly and the gap is that the router is unreachable rather than that it is wrong. Note that an edit to the byte-locked section lands with [#552][issue-552], which already holds that section open, rather than as a change of its own. -- Revisit [#305][issue-305], which asks for a propagation path for the rule docs and is the oldest open issue here, because most of the machinery it asked for has since been built and the issue has not been re-scoped to match. The fidelity model, the `spec/files.json` manifest, [`spec/divergences.json`][divergences] with its generated [reports/divergences.md][divergences-report], and [`AUDIT.md`][audit-doc] section 10 together give the canonical-versus-adapted split and the audit path the issue proposed. What is genuinely still missing is the push half, since every one of those detects drift while the sweep that fixes it is manual, which is exactly the entry above. Either re-scope the issue to the push half or close it against the entry above and the audit-automation entry below. -- Carry on reducing the surface that is copied downstream, now that the model is settled rather than open. [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" answers the three questions this entry held. What is vendored is the content a repository is audited against and the configuration that describes it, what is reached is machinery holding nothing per-repo. A tool is reached as a hub checkout read at `main` and fetched immediately before use, where CI reaches the same rules through a composite action pinned to a commit SHA, which is the action-pinning rule applied unchanged. And an unreachable hub means the tool did not run, which is reported as not run rather than worked around, since a hand-rolled substitute is the duplicated effort the model exists to end. `repo-config/configure.sh` is the first file moved across and the entry above is its sweep. What remains is the next candidate rather than the decision: the reusable-workflow entry below is the same model applied to CI, and the carried `AUDIT.md` and `spec/secrets.json` are the two remaining carried files worth measuring against the test, since each is adapted per repo today and the question is how much of each is genuinely per-repo. -- Investigate replacing copy-pasted workflow content with cross-repo reuse, now that this repo is public. A public repository's composite actions and reusable workflows can be consumed by any other repository regardless of owner type, so the organization account this pattern was assumed to require is not needed, and the constraint that shaped the current vendor-everything model no longer holds. The catalog under [`catalog/snippets/workflows/`][workflows] is copied into each repo today, which means a fix to a shared job is a sweep across the fleet rather than one edit here, and it is the mechanism by which a defect in a snippet seeds itself into every repo that adopted it. Scope the investigation to which jobs are genuinely identical across repos against which only look similar, since a reusable workflow that needs a long input list to cover per-repo variation is worse than the copy it replaces. The ref policy is settled rather than open, since [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" states that CI reaches hub code as an action pinned to a commit SHA, which is "Action pinning" applied unchanged, and [`.github/actions/prose-gate/action.yml`][prose-gate] is the worked example of it. Note that `uses:` does not accept expressions, so a per-branch ref cannot be selected in the workflow file and any branch-dependent behavior belongs inside the consumed action instead. -- Make a table of contents standard for a long document rather than for the README alone. [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and [`GOVERNANCE.md`][governance] states how it is generated and which headings it excludes, and no other file in the hub carries one, which leaves the three longest documents in the fleet without the thing that makes them navigable: `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 421, and `WORKFLOW.md` at 298. Two things to settle. The threshold, stated in headings or lines rather than left to judgment, so the audit can check it and no repository is put in the position of arguing that its own document is short. And how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the same property that makes a contents list worth having in them, and the generated list is already the one exception that rule carries. Note the mechanical constraint before adopting it, that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale contents list rather than none, and a stale one is worse than absent because it is read as current. -- State that an operational repository still opens a pull request for a large or risky change, even though its model permits committing straight to `develop`, which is item 3 of [#578][issue-578]. [`GOVERNANCE.md`][governance] "Operational Repositories" grants the direct commit and says nothing about when to decline the grant, and `repo-config/operational/develop.json` carries only `deletion`, `non_fast_forward` and `required_signatures`, so nothing mechanical stands between an agent and a large unreviewed change landing on the live surface. The reason the grant exists is the one-line config edit that a review round costs more than it protects, and that reason stops applying well before a change gets large. Settle what counts as large, and prefer a stated shape over a line count, since the property that matters is whether the change can be read at a glance and reverted cleanly rather than how many lines it moved. Note that this stays guidance by construction, because adding a `pull_request` rule to the operational ruleset would withdraw the direct-commit grant that the whole model exists to give. -- Say when an issue is closed by hand, not only that the closing keyword belongs on the promotion pull request, which is item 1 of [#578][issue-578]. [`GOVERNANCE.md`][governance] "Git and Commit Rules" already states that `Closes #N` fires only on a merge into the default branch and therefore goes on the `develop -> main` promotion, and downstream agents keep re-deriving that rule and reporting it as a discovery, which says it is being missed rather than that it is missing. What the rule genuinely does not cover is the case that produces the confusion, which is work complete on `develop` while a promotion is not imminent, leaving the issue open with nothing on it recording that it is done. The current text licenses a hand-close only once a promotion has merged without the keyword, which is the narrower case. Widen it to say that an issue is closed when the work is verifiably complete, citing the squash SHA that completed it, and that the promotion keyword is the automation for the common case rather than the only permitted route. [#552][issue-552] is the live example, fixed by the merge of [#572][pr-572] and open at the time of writing. -- Say that the message carrying the clickable link comes **before** the prompt it accompanies, which is item 2 of [#578][issue-578] and which [`GOVERNANCE.md`][governance] "Communicating with the User" leaves unstated. The rule already gets the hard part right, that an interactive prompt renders neither a Markdown link nor a bare URL, so the reference inside it is a bare `#123` and the link goes in the message that accompanies it. It says accompanying rather than preceding, and a message emitted after the prompt is not read before the question is answered, which is the entire outcome the rule exists to produce. This keeps recurring in practice, so read the recurrence as the wording being underspecified rather than as the rule being ignored, which is the same diagnosis the entry above reaches about the closing keyword. - -## Audit and Spec Model - -- Teach the audit to see content sitting in the wrong file, per [#523][issue-523], which the tree confirms is untouched, since [`spec/audit.py`][audit] still checks file presence, declared-section presence, verbatim hashes, and workflow interface conformance and nothing that reads a heading against a destination. The case that found it is a repo whose `.github/copilot-instructions.md` carried 311 lines under nine headings that [`spec/section-model.md`][section-model] assigns to `ARCHITECTURE.md` and `OPERATIONS.md`, reported as a missing-file letter while the misplacement that caused it was invisible. The shape that fits the engine is structural, collecting the level-two headings of an `intent` file, subtracting the ones [`spec/files.json`][files] declares for it, and comparing the remainder against the headings other destinations declare. The similarity-based version is explicitly rejected by `section-model.md`, and a detector built on it produces findings whose remedy is to delete content. Four things to settle, all in the issue: whether an undeclared heading is a finding at all given a repo may add locally, whether the destination mapping becomes declared data rather than prose, whether it reaches the advisory `ARCHITECTURE.md`, and how many repos are affected, which is measured before the check is designed rather than after it starts reporting. -- State what a `driftNote` may contain at the moment one is written, not only what happens to it once the deviation clears, and widen the check that guards it. [`AUDIT.md`][audit-doc] section 8 says a note records a current deviation and is deleted once resolved, which is a rule about a note's end of life and leaves an agent authoring one with no rule about its content. PhotoCleaner is the worked example, in [#537][pr-537]: asked to self-audit and reconcile its notes, the agent wrote a completion record ("baseline onboarding completed"), a historical fact (the date a past version first published), and a description of the repo conforming to the README spec. All three are prose about the past or about compliance rather than a current deviation, and none of them is what the field is for. Read that as an incomplete instruction rather than an agent ignoring one, since nothing it was given forbids any of the three. Two things to settle. The rule wants stating positively at authoring time, that a note names a deviation that is true right now and would still be true if nobody read it, so history, completion, and conformance are each out by construction, and a repo doing exactly what the spec requires earns no note at all. And the detector needs the same widening, because [`spec/audit.py`][audit] currently flags a stale note by matching words that assert *outstanding* work ("pending", "not yet", "missing", "behind"), which is the opposite polarity to all three of PhotoCleaner's notes, and it only runs that check when the repo audits clean, which PhotoCleaner did not until the round that found this. A guard that fires only on one polarity, and only in the state where the problem has already gone, cannot see the failure it exists to catch. Note the limit before building it, since it is the same limit the section model already records: judging whether a sentence describes a current deviation is a semantic call, so the honest target is the shapes that are mechanically recognizable, a past-tense completion verb and an explicit date among them, with the rest left to the rule. -- Declare the canonical short description in [`registry/repos.json`][repos] instead of deriving it by parsing the README, which makes every check and every push deterministic. Today [`spec/audit.py`][audit] extracts the intro line after the H1 and compares the GitHub About panel and the Docker Hub short description against it, so the source of truth lives inside prose. That forces anything wanting the value to parse Markdown, and PhotoCleaner#32 shows the cost, since a workflow step that reads the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. Adding a `description` field turns the README intro into a third mirror rather than the source, so the audit compares all three against one declared value, the Docker workflow pushes a literal with no parser, and `repo-config/configure.sh` can set the GitHub About panel from the same field it already sets every other repository setting from. The 100-character cap stays, since Docker Hub's short description is still the tightest surface. Add the field to `registry/repos.schema.json` as well, which sets `additionalProperties: false`, and make it optional at first so the audit falls back to the README intro while repos adopt it one at a time. The disposition is recorded on [#509][issue-509], and the ask on the Docker repos is only that the parsing step is not propagated further while the field is pending. -- Let the README intro carry more than the tagline, and say which line the mirrors actually take, per [#577][issue-577]. [`spec/readme-structure.md`][readme-structure] item 1 reads as though the canonical description **is** the paragraph after the H1, capped at 100 characters and link-free, and [`CODESTYLE.md`][codestyle] "Markdown and Spelling" then has `HISTORY.md` copy "the same intro paragraph" verbatim. Read together they forbid a README from saying anything further about itself above the fold, which is a real cost on a repository whose purpose needs a sentence of context, and the cap they enforce exists for Docker Hub's short description rather than for the reader. State the shape instead: the first line after the H1 is the tagline, it alone is bound by the 100-character link-free rule, and it alone mirrors to the GitHub About panel, the Docker Hub short description, and the `HISTORY.md` opening, with any further paragraph free prose that no mirror reads and no cap applies to. The audit changes with it, since it currently measures the first non-empty line and would otherwise start reporting a legitimate second paragraph. Note this is the same axis as the entry above, because declaring the description in [`registry/repos.json`][repos] makes every mirror read a field rather than parse a paragraph and settles the ambiguity by construction, so the two want deciding together rather than one after the other. -- Rework [`spec/readme-structure.md`][readme-structure] to match the hand-crafted PlexCleaner README, which is the shape the maintainer wants, and make the result auditable rather than advisory. Four concrete divergences are already identified, measured against PlexCleaner `README.md`, this repo's `README.md`, and the current spec. First, the distribution bullet is labeled by deliverable: PlexCleaner ships executables and calls the channel **Binary Releases**, while the spec fixes the label as **Versioned Releases** for every repo, so the label belongs in a per-channel table rather than as one string. Second, the license shield sits in the top **Build Status** block here and at the very bottom of PlexCleaner, inside a closing `## License` section that reads `Licensed under the [MIT License]` followed by the shield, immediately before the link definitions. Third, the Release Notes section closes with `See Release History for complete release notes and older versions.` in PlexCleaner against `See Release History for the full history.` here, and the PlexCleaner form is the wanted one. Note that PlexCleaner writes that link inline, which the reference-style rule forbids, so adopt the wording and keep the reference form. Fourth, the channel bullets and their shields vary by deliverable, meaning GitHub binaries, Docker Hub, NuGet, and PyPI each carry a different bullet label and a different shield set, which is what a per-type table has to encode for the `readme-structure` audit dimension to check a repo against its own declared types. -- Decide whether the canonical README section order follows PlexCleaner, which is a separate question from the four divergences above and affects every repo plus the `readme-structure` audit. PlexCleaner places **Questions or Issues** immediately after the Table of Contents, where the spec orders it ninth, and it carries sections the spec names nowhere, including Performance Considerations, Runtime Metrics, Custom Plugins, Testing, Development Tooling, Feature Ideas, and Sample Media Files. Under the recurrence rule in [`spec/section-model.md`][section-model] those last ones are correctly repo-specific and stay undeclared, so the open question is only the position of the sections the spec already names. -- Declare locally-required secrets the way GitHub-stored ones are already declared, and make a gitignored `secrets/` directory the fleet standard that holds them. [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores, so a repo that deploys somewhere has no declared way to say what it needs at runtime, and the required set is discoverable only by reading the deploy. The pattern already runs in the fleet in two shapes: HomeAutomation-Config keeps a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeps a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the same split the GitHub side already gets from `requiredSecrets[]`. Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. The hub carries neither the directory nor a `.gitignore` entry for one today, so adopting it here comes first. Note that the **GitHub** side has the same missing axis, surfaced by the `hugo` type: a deploy's credentials are per-environment GitHub Environment secrets and variables, `stores` is a closed enum of `actions` and `dependabot`, and `spec/audit.py` seeds `required_by_store` with those two keys and indexes it unguarded, so adding an `environments` value to the enum raises `KeyError` for every repo whose `publish[]` maps to that mechanism. The type landed with an optional `environments` block added to [`spec/secrets.schema.json`][secrets-schema] and described in `spec/secrets.json`'s note, so a repo that needs it may declare its per-environment names and the shape is legal rather than an invention. The hub's own `spec/secrets.json` carries no such block, having no environments of its own, and no tool reads one where it exists. That is honest but it is not a gate, so a clean audit says nothing about whether an environment is configured. Solving it properly means a store vocabulary that does not crash on an unknown key and a per-environment secrets and variables fetch, and it is the same axis as the local half above rather than a separate problem. -- Canonicalize Python linter-config placement on `pyproject.toml`, since one cataloged repo uses a standalone `.ruff.toml` plus `pyrightconfig.json`. Track it as a drift finding and fix it downstream. -- Populate [reports/][reports] for the cataloged repos that still have no audit, since a registry `status` of `cataloged` asserts a result that only a committed report evidences. Eight repos have one. This is paced by maintainer capacity rather than blocked on anything, since repos are brought up to spec as they are worked on, so the entry records the outstanding set rather than a defect. -- Revisit automating the audit, which was explored and deliberately deferred, recorded here so the reasoning is not re-derived from scratch. Three shapes were considered: a scheduled hub-driven audit publishing each report as a workflow artifact, the same thing committing the report back the way the codegen bot updates its own files, and a pull-request hook in each downstream repo that audits itself against the current hub. Three things blocked all of them. Until the fleet reaches stasis with every repo onboarded, a scheduled run reports mostly noise, since a repo mid-onboarding is expected to be non-conformant. The hub has to be stable before downstreams can audit against it, because a hub change lands as fleet-wide findings the same day. And the downstream half is a catch-22, since a self-auditing pull-request hook is CI instrumentation the repos that most need it do not yet carry. The agreed outcome was the on-demand audit that [`AUDIT.md`][audit-doc] describes today. Worth reopening once the fleet is onboarded and the hub goes a stretch without carried-content changes, and the artifact shape is the one to try first, since it produces evidence without committing anything and so cannot generate review load while the noise level is still unknown. - -## Onboarding and New Types - -- Give [`STANDUP.md`][standup] an explicit branch-bootstrap step, because it currently says nothing about how `main` and `develop` come into being and an agent therefore commits onto whichever branch it finds. Note that section 0A now covers the human-gated prerequisites from [#490][issue-490], so this is the remaining half of that gap rather than the whole of it. The sequence that avoids every cleanup problem is: create `main` and leave it carrying nothing, create `develop` from `main` and leave it carrying nothing, create the first feature branch from `develop` and do the whole standup there, add the repo to GitHub and apply the repo config while still on that branch, then open a normal pull request to `develop` when the repo is ready. Nothing ever has to be cleaned off `main` or `develop`, because nothing ever reached them without review. Note the mechanical wrinkle when writing this up: a git branch cannot exist without a commit, so "carrying nothing" means exactly one signed empty root commit (`git commit --allow-empty`), and section 0's signing window applies to that commit like any other. The alternative of committing onto `develop` and squashing afterwards does not work: `non_fast_forward` is set on both `develop` payloads, so the history cannot be rewritten without disabling the ruleset, and Blog was correctly blocked when it tried. Worth stating that the protection is uneven, since a `release` repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries only `deletion`, `non_fast_forward` and `required_signatures`, so on an operational repo a direct commit succeeds and only the instruction stands between the agent and an unfixable history. The reason it matters on a public repo is that the exploratory standup history is where PII, secrets and noise commits accumulate, and squashing a feature branch is the one chance to leave them out. -- The static-site generator type from [#456][issue-456] and [#558][issue-558] has landed as `hugo`, with the deploy destination as the `self-hosted` publish target and the `deploy-ssh` mechanism. Correct the record while reading this, because three details the intake predicted are wrong against what Blog actually runs, and planning from the prediction would encode requirements the repo does not meet: the theme is **vendored with no recorded upstream ref**, not a Dependabot-tracked submodule; the generator is **pinned by version and SHA256**, not run at latest; and the deploy is a **separate dispatch** from the GitHub release, not a tag cut last after the live check. What was settled and did hold: the deploy is a publish, the type is named for the generator with the generic checks phrased so they do not name it, and the URL parity gate asserting a floor on the golden list length before comparing is the check of record. What remains: promote the generator-agnostic `hugo.*` checks to a shared type when a second generator arrives, which `spec/type-model.md` "Generators" makes a registry edit by construction, and decide how a `hugo` repository is meant to carry its theme at all, which the type declares nothing about today. The candidates are the vendored copy Blog actually ships, a submodule pinned to an upstream ref, and a separate fleet-owned repository the site consumes, and they differ along exactly the axis the carried-content entries above are about, since a vendored theme is a copy that goes stale with nothing detecting it and a submodule is a pin that Dependabot can see. Settle it as a question about the type rather than about Blog, because whatever is decided becomes the type's contract and the second generator inherits it. Also still true and stated nowhere else, the `copilot_code_review` rule in both ruleset payloads does not gate a merge today because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline actually holding the line. -- Finish onboarding hardening (from [#310][issue-310]): make the [`AUDIT.md`][audit-doc] audit a required onboarding step and run the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix]. `STANDUP.md` is already in place. -- Add a linter-only Python project type for codegen and boilerplate Python, code that runs during another tool's build to emit generated source (for example ESPHome codegen that produces enriched C++ at compile time), so it ships no unit tests and no coverage and needs only the linter. Keep it distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage, as in PlexCleaner. Until it exists, ESPHome-Config stays `source-only` and its `+python` reclassification is deferred, so accept its one outstanding validation finding meanwhile. -- Add a fleet-standard clang-format config for the `cpp` type, a catalog snippet plus a CODESTYLE C++ section defining the style, the C++ analogue of the shared ruff config, so the `cpp` clang-format check references one canonical style rather than each repo inventing its own. Base it on the ESPHome-Config agent's proposed `.clang-format`. - -## Host and Environment - -- State the rule that an agent works only in its own checkout, in its own directory, on its own feature branch, and never writes to a repository because it happened to be on disk. A clone left in a scratch directory is convenient exactly when it is most dangerous, since the operations that cross the boundary are the ordinary ones rather than the reckless ones. A blanket `git add -A` sweeps another agent's uncommitted work into your commit, a `git reset --hard` deletes it outright, and a branch switch carries it along into a change it has nothing to do with. This session produced the first of those: a blanket add swept a second agent's in-progress registration work into an unrelated commit and pushed it, and the work survived only because it was rescued and restored by hand afterwards. Care does not make a shared working tree safe, because every one of those commands is correct in isolation and wrong only because of who else is in the directory. Two things to settle when writing it. Whether the rule is stated as one checkout per agent or one checkout per task, since a single agent moving between two repositories in one directory hits the same hazard as two agents sharing one. And what an agent does when it finds a foreign checkout anyway, where the answer is to leave it alone and clone its own rather than to work carefully in it. -- Extend the same rule to **reading**, which is the more common case and the easier one to think is harmless. A clone on disk is not the branch it names. It is whatever that clone was last fetched to, so reading it to answer what a repository carries answers a different question, which is what that clone last saw. The failure is silent, because the read succeeds and returns a plausible answer. This session produced two instances. A detection pass over local clones reported one repository as still drifted on a file whose fix had already merged, because that clone's refs predated the merge, and the correction came from a fetch rather than from the read. A second pass then reported a repository as missing a file it does carry, because the local checkout sat on a branch behind the one being measured. State the working practice with the rule: read the live ref through the API where a claim will be acted on, or fetch immediately before reading, and name the ref and commit in any finding derived from a local read so a reader can tell what was actually measured. Note the exception worth keeping, which is that a local clone is the right tool for anything needing history or a build, since those are what an API read cannot give. - -- Finish the [#365][issue-365] rollout, which has two halves and one of them is invisible from here. Four machines are still unchecked, WSL2 Ubuntu, the MacBook Air, and both ThinkPads, plus any headless or cron environment that runs Claude Code with the token. A ticked row means the `CLAUDE.md` text and not the hook, since only running `host-setup/agent-safety/install.sh` deploys both layers, and the proxmox host proved that distinction the hard way by carrying the documentary half alone for eight days on the very machine where the incident originated. Note also the follow-up left open on this host and never applied, that the superseded `## Safety rules` section from [#364][issue-364] still sits above the canonical `agent-safety v1` block in `~/.claude/CLAUDE.md`, so the two overlap. Removing the superseded section is a judgment call on a per-machine file, which is why it was surfaced rather than applied. -- Fill in the remaining cells of the [#483][issue-483] host tooling matrix, which is the reference that makes the per-machine chore above repeatable, so the two close together rather than either alone. Linux, Windows and WSL2 are filled in and verified. **macOS** needs someone on that platform, the **Proxmox** question is whether that host is a shell for agent work or also runs containers, which decides whether `docker` is required there at all, and the engine-inside-the-distro variant of the WSL2 `docker` cell is still unverified since that host only exercised the Docker Desktop integration. Honor the issue's own rule when filling a cell, that an unverified install command is worse than a blank, because a blank prompts a question while a wrong command produces a broken host and a false sense that setup succeeded. - -## Fleet Chores - -- Measure review rounds against pull request size, and decide what the number licenses. The recent loops suggest a large change earns a different finding every round while a small one converges in one or two, which would make change size the lever on review cost rather than the reviewer's thoroughness, and would argue for splitting a change before review rather than discovering it through five rounds of findings. The data needs no new instrumentation, since the review history already carries it: for each recent pull request, record the diff size in files and lines, the number of rounds, and the findings per round, counting suppressed findings alongside threaded ones because they are the majority of what these loops produce. The outcome worth having is a threshold [`GOVERNANCE.md`][governance] can state in the branching or review guidance, expressed as the size at which a change is split rather than as advice to keep changes small. Note two confounds before drawing a line from the numbers. A large change is usually also a novel one, so size and unfamiliarity move together and the record should note what kind of change each was. And a round that finds something new is not evidence of a problem by itself, since a round that finds something new is the reviewer working, so the metric to watch is findings that a smaller first cut would have surfaced earlier rather than findings per round on its own. -- Try local defensive-review subagents as a first pass before the remote reviewer, and measure what the pass is worth. The remote loop is thorough and it is also where most of a session's tokens and wall-clock go, and it delivers its findings one round at a time, which is the slowest available way to learn that a change had five problems. A local pass prompted adversarially, one agent per lens rather than one general reviewer, would surface the mechanical half before the branch is even pushed. Scope the experiment to what it can actually prove: run both for a stretch, record which findings the local pass caught that the remote reviewer then raised anyway, and keep it only where that overlap is large enough to shorten the remote loop rather than to add a step in front of it. Note the trap that makes this worth designing carefully rather than just trying. A local pass that finds nothing reads exactly like a clean change, and the next inference from there is that the remote review can be skipped, which is the one outcome the review contract exists to prevent, so the local pass is an input to the loop and never a substitute for the round the merge gate requires. -- Record a disproved review finding somewhere the next round will read it, so the same proof is not built twice. [`.github/copilot-instructions.md`][copilot-instructions] already says the reviewer is sometimes factually wrong, already requires a decline to carry evidence, and already keeps a list of known non-working request paths, which is the shape this wants. What it has nowhere to put is the finding itself, so a claim disproved against a downloaded binary in one session is argued from scratch in the next. The worked example is the claim that `keys_unsorted` needs jq 1.6, disproved against a 1.5 binary on which `keys_unsorted` works and `walk/1` does not. Note the reason this is not simply a list to append to, which is that a disproof is true of one tree at one revision, so an entry outliving the code it was proved against becomes a reason not to check, and that is strictly worse than re-proving it. Each entry therefore records what was tested and against which revision, and an entry whose subject changes is deleted rather than edited. -- Refresh the README, which has gone stale, and evaluate a lower-maintenance structure, for example a per-section index that points into each doc with a one-line description, keeping the README as the adoption and audit-instruction entry point with pointers to the other docs. A per-section index trades brevity for a sync obligation, since it must track what the docs contain. -- Apply the operational `pull_request` trigger fix from [#585][issue-585] to the four operational repos, `HomeAutomation-Config`, `ESPHome-Config`, `HomeAssistant-Config` and `Vantage-Config`, each of which currently triggers its lint workflow on `pull_request` to `main` only and therefore runs nothing at all on a PR into `develop`. The hub change is prose and spec, so it fixes no downstream repo by itself, and the downstream edit is one line per repo, widening the trigger to `branches: [ main, develop ]`. Two things to check per repo rather than assume, since the prescription is prose and each repo owns its YAML. Confirm the workflow really does trigger on `main` alone before editing, because a repo that already names both is conformant and needs no PR. And leave the ruleset alone: the required check stays on `main`, so nothing is added to `repo-config/operational/develop.json`. The evidence that this is not hypothetical is `HomeAutomation-Config` PR #34, which merged into `develop` with an empty check list and a `CLEAN` mergeable state. Cross-repo work, so it needs the maintainer to name the repos. -- Adopt the OCI annotation keys (`org.opencontainers.image.*`) for Docker image metadata across the Docker repos, replacing the ad-hoc and `org.label-schema.*` labels (from [#363][issue-363]). -- Sweep `ManagePackageVersionsCentrally` placement to `Directory.Packages.props` fleet-wide, since PlexCleaner sets it in `Directory.Build.props`, off the CODESTYLE canonical. -- Consider renaming this repo to reflect the audit-catalog identity, which updates badge and link URLs across the fleet. +Each was checked against the tree and has nothing left to do anywhere. Closing is the maintainer's call, and each wants the evidence quoted in the closing comment rather than a bare close. + +- **[#519][issue-519], the hub's own tree does not pass the prose gate it ships.** Complete on the prose and on both questions. + - **Fixed by** - `f7a6a13` (snippets), `c9c92dd` (comments), `d791930` (hub-only Markdown), and the carried batch on `prose/carried-semicolons`. + - **Checked** - `develop` at `d791930` on 2026-08-07, where `python3 scripts/prose_lint.py --summary` reported 41 across 6 files, and 0 across 0 with the carried batch applied. + - **Closing evidence** - The whole-tree figure went 557 across 45 to zero, in four batches split by surface, being 184 in `catalog/snippets/`, 241 in non-Markdown comments, 90 in hub-only Markdown and 41 in the six carried files. Question 1 is answered by `reports/` being exempt as a generated tree, and question 2 by the snippets leading, since a non-conformant snippet seeds its violations into every repo that adopts it. + - **Closing evidence** - The issue's claim that the governance files were clean, and that this was therefore not a carry problem, was true of the checker of the day and false of the tree. Today's checker reports 38 findings against the same six files as they stood at `69688ec`, the commit the issue measured, while that commit's own checker reports zero. Scoping the list exemption to a sentence rather than a whole bullet accounts for 37 of the 38, because a colon anywhere ahead of the first semicolon had exempted every semicolon after it. The carry problem was real throughout and invisible, which is the stale-exemption hazard running in the loose direction. + +- **[#557][issue-557], the agent-isolation rule and its two open questions.** Complete on the rule and on both questions. + - **Fixed by** - `9d85941`. + - **Checked** - `develop` at `9d85941` on 2026-08-06. + - **Closing evidence** - [`GOVERNANCE.md`][governance] "Repository Boundaries and Write Safety" carries the rule that each task runs in its own checkout, in its own directory, on its own feature branch, scoped per task rather than per agent, which adopts the first open question. It names the three commands that cross the boundary while being correct in isolation, and it names the two signals that another task is live in a tree, whose stated response is to stop, which adopts the second. The rule reaches every machine through the same text in [`host-setup/agent-safety/`][agent-safety]. + +- **[#579][issue-579], a repository with no instruction set cannot resolve its own vocabulary.** Complete on the term definitions and on the routing. + - **Fixed by** - `9d85941`. + - **Checked** - `develop` at `9d85941` on 2026-08-06. + - **Closing evidence** - [`AGENTS.md`][agents] "Fleet Bootstrap" names the hub as a defined term and carries the reach rule, and [`README.md`][readme] defines the six words a request is phrased in, the hub, the fleet, standing a repository up, auditing one, closing the review loop, and carried against reached, each naming the file that answers it. The host-wide block under [`host-setup/agent-safety/`][agent-safety] carries the same definition, which is what reaches a repository holding no instruction set at all. <!-- Issues --> @@ -102,19 +456,16 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a [issue-519]: https://github.com/ptr727/ProjectTemplate/issues/519 [issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 [issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 -[issue-552]: https://github.com/ptr727/ProjectTemplate/issues/552 +[issue-550]: https://github.com/ptr727/ProjectTemplate/issues/550 +[issue-557]: https://github.com/ptr727/ProjectTemplate/issues/557 [issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 -[issue-570]: https://github.com/ptr727/ProjectTemplate/issues/570 [issue-577]: https://github.com/ptr727/ProjectTemplate/issues/577 [issue-578]: https://github.com/ptr727/ProjectTemplate/issues/578 [issue-579]: https://github.com/ptr727/ProjectTemplate/issues/579 [issue-580]: https://github.com/ptr727/ProjectTemplate/issues/580 [issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 - -<!-- Pull requests --> - -[pr-537]: https://github.com/ptr727/ProjectTemplate/pull/537 -[pr-572]: https://github.com/ptr727/ProjectTemplate/pull/572 +[issue-597]: https://github.com/ptr727/ProjectTemplate/issues/597 +[issue-607]: https://github.com/ptr727/ProjectTemplate/issues/607 <!-- Repo --> @@ -130,18 +481,21 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a [governance]: ./GOVERNANCE.md [markdownlint]: ./.markdownlint-cli2.jsonc [matrix]: ./reports/conformance-matrix.md +[merge-bot]: ./.github/workflows/merge-bot-pull-request.yml [operations]: ./OPERATIONS.md -[pr-review]: ./scripts/pr_review.py +[project-types]: ./spec/project-types.json [prose-gate]: ./.github/actions/prose-gate/action.yml -[prose-lint]: ./scripts/prose_lint.py +[readme]: ./README.md [readme-structure]: ./spec/readme-structure.md -[repo-gate]: ./scripts/repo_gate.py [reports]: ./reports/ [repos]: ./registry/repos.json [scripts]: ./scripts/README.md [secrets]: ./spec/secrets.json [secrets-schema]: ./spec/secrets.schema.json [section-model]: ./spec/section-model.md +[snippets]: ./catalog/snippets/ [standup]: ./STANDUP.md +[type-model]: ./spec/type-model.md +[workflow]: ./WORKFLOW.md [workflows]: ./catalog/snippets/workflows/ [write-guard]: ./host-setup/agent-safety/gh-write-guard.py diff --git a/WORKFLOW.md b/WORKFLOW.md index b44b4516..99005b07 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -146,24 +146,24 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* - **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test). A non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it (both the aggregator and `smoke-build`, which `needs:` the validation job by name) to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* -- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; orphaned artifacts churning the storage quota.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests (see `spec/secrets.json` `typeMechanisms`). The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/`, `*.cobertura.xml`; `.gitignore` is the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests (see `spec/secrets.json` `typeMechanisms`). The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* ### D2 - Input/State Validation at Entry - **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds. Downstream jobs `needs:` it. -- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *Prevents: a non-default leg published as stable, a build-metadata false-positive, and the gate blocking every default-base promotion PR.* - **D2.3 Publish only from main or develop.** Input: a dispatch publish. Output: a dispatch from any ref other than `main` or `develop` fails fast. *Prevents: cutting a release from an unintended branch.* - **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* ### D3 - Versioning and Classification - **D3.1 One branch per run.** Input: a publish triggered on `main` or `develop`. Output: the run builds and versions that one branch, and `github.ref` names it, so NBGV classifies it directly (no `IGNORE_GITHUB_REF`). *Prevents: a cross-branch ref mismatch misclassifying the version.* -- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> `X.Y.Z-g<sha>`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`, and any other -> `X.Y.Z-g<sha>`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. - **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). -- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g<sha>` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g<sha>` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release, and a renamed/extra branch silently getting a plain version.* - **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the tracker (the writer) ships without consumer wiring, so a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`. If the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* ### D4 - Release / Publish @@ -171,9 +171,9 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`, with an Actions-only bump matching no release path and publishing nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* - **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download). With the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. -- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. Registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. Registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* - **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. -- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration; a URL contract checked against the wrong environment; a dead config watcher read as a routing fault.* +- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration, a URL contract checked against the wrong environment, and a dead config watcher read as a routing fault.* ### D5 - Resource Cleanup @@ -225,7 +225,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. - **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a dispatch from a ref other than `main` or `develop`. -- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`); the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. +- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`), and the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. - **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step is gated identically. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. - **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. - **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task and the paths-filter. @@ -238,7 +238,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` zip carries the package(s). - **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. - **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme/date-badge jobs are gated main-only. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. -- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root; the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). +- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root, and the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) @@ -286,8 +286,8 @@ Each type maps the *applicable* S-scenarios onto its targets. The differences ar - **PyPI library.** The leaf builds + uploads `pypilibrary-build-<branch>`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. - **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-<branch>`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. - **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset-<branch>-library` (`retention-days: 1`, upload gated `!smoke`, mirroring the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. -- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. -- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only; any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls `deploy-site-task.yml` with `secrets: inherit`. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task pins the generator by version and checksum, and re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), installs the verified generator, derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), builds the tree, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, and finally checks the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade; what the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. +- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2, or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. +- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls `deploy-site-task.yml` with `secrets: inherit`. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task pins the generator by version and checksum, and re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), installs the verified generator, derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), builds the tree, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, and finally checks the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. - **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job`, built from the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), with **no unit tests**; its triggers differ from the `release` model: `push` to `develop` (advisory feedback on the direct-commit path) plus `pull_request` to `branches: [ main, develop ]` (enforced on `main` as the promotion gate, reported but not required on `develop`) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). **The PR trigger names both branches, and naming `main` alone is a defect.** A set that omits `develop` matches nothing when a PR opens against `develop`, so the validation job never starts, the aggregator never reports, and the PR shows a clean mergeable state with an empty check list, which is the exact output D1.2 forbids. Naming both costs a duplicate run, since a PR-merged change validates once on the PR and again on the push when the merge lands, whatever merge method the repo allows (the operational `develop` ruleset prescribes none). The concurrency group is keyed on the workflow name plus `${{ github.ref }}` (Section 2), and a `pull_request` run's ref is `refs/pull/<n>/merge` where the push run's is `refs/heads/develop`, so the two occupy different groups and neither cancels the other. Pay it. On a lint-only gate that is a couple of runner-minutes, where the alternative, a condition that suppresses the push run, has to tell a merge commit from a direct commit and reintroduces exactly the reasoning the trigger set removes. Applicable scenarios: S1 (validation) on every PR, the promotion PR and a PR into `develop` alike, plus the source-only release set: S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule, neither of which an operational repo has) and every build/registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. <!-- Workflow --> diff --git a/catalog/README.md b/catalog/README.md index 8a5b79cb..933bc7aa 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -1,9 +1,9 @@ # Catalog Reusable reference snippets: concrete config artifacts a repo can copy or compare against. These are -**reference material, not run here** - this repo ships no build, so the workflow snippets below are not active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. +**reference material, not run here**. This repo ships no build, so the workflow snippets below are not active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. -- `snippets/workflows/` - the reusable build/publish workflow tasks that a code-shipping repo runs (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it implements. -- `snippets/configs/` - config exemplars: `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent - `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). -- `snippets/devcontainer/` - `.devcontainer` definitions for the .NET and Python toolchains. -- `snippets/vscode/` - the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. +- `snippets/workflows/`: the reusable build/publish workflow tasks that a code-shipping repo runs (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it implements. +- `snippets/configs/`: the config exemplars `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent, running `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), plus `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). +- `snippets/devcontainer/`: `.devcontainer` definitions for the .NET and Python toolchains. +- `snippets/vscode/`: the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. diff --git a/catalog/snippets/configs/codecov.yml b/catalog/snippets/configs/codecov.yml index 730d38ae..50cbf1c2 100644 --- a/catalog/snippets/configs/codecov.yml +++ b/catalog/snippets/configs/codecov.yml @@ -1,4 +1,5 @@ -# Codecov configuration. Codecov's coverage statuses are advisory - they report and trend coverage, never gating a PR. +# Codecov configuration. +# Codecov's coverage statuses are advisory: they report and trend coverage, never gating a PR. # - informational: true makes project and patch statuses advisory (always pass), so a coverage delta never blocks a PR. # - Distinct from the upload step's fail_ci_if_error: false, which only guards upload errors. # - A repo may override this to enforce a coverage threshold. @@ -11,6 +12,7 @@ coverage: default: informational: true -# Exclude code intentionally not unit-tested from the coverage denominator. Add the repo's own paths. +# Exclude code intentionally not unit-tested from the coverage denominator. +# Add the repo's own paths. # ignore: # - "Sandbox/**" diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml index d847fba9..ca644152 100644 --- a/catalog/snippets/configs/dependabot.yml +++ b/catalog/snippets/configs/dependabot.yml @@ -1,12 +1,14 @@ # Reference: multi-ecosystem dual-target Dependabot config for a code-shipping repo. # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # -# Every ecosystem appears twice, once per target branch (main and develop), so both branches stay -# current on dependency versions independently of the develop -> main release cadence. Push-distribution -# channels (HACS, distros that pull main) consume main directly, so main must get bumps without waiting -# for the next release. The merge-bot dispatches the merge method per base ref (squash on develop, merge -# on main). develop is strictly forward-only; each branch absorbs its own PRs. Security (CVE) PRs always -# target the default branch regardless of target-branch. Keep only the ecosystems the repo actually ships. +# Every ecosystem appears twice, once per target branch (main and develop). +# Both branches therefore stay current on dependency versions independently of the release cadence. +# Push-distribution channels (HACS, distros that pull main) consume main directly. +# The main branch must therefore get bumps without waiting for the next release. +# The merge-bot dispatches the merge method per base ref (squash on develop, merge on main). +# Because develop is strictly forward-only, each branch absorbs its own PRs. +# Security (CVE) PRs always target the default branch regardless of target-branch. +# Keep only the ecosystems the repo actually ships. version: 2 updates: @@ -50,7 +52,8 @@ updates: patterns: - "*" - # uv directory points at the Python package root (e.g. /PackageName); adjust per repo layout. + # The uv directory points at the Python package root (e.g. /PackageName). + # Adjust it per repo layout. - package-ecosystem: "uv" target-branch: "main" directory: "/PythonPackage" diff --git a/catalog/snippets/configs/docker-hub-readme.md b/catalog/snippets/configs/docker-hub-readme.md index 4e17500a..3c4c7da4 100644 --- a/catalog/snippets/configs/docker-hub-readme.md +++ b/catalog/snippets/configs/docker-hub-readme.md @@ -4,8 +4,8 @@ Container image for the ProjectTemplate Console app. ## Image Tags -- `latest` - the current stable release, built from `main`. -- `develop` - the current prerelease, built from `develop`. +- **`latest`** - the current stable release, built from `main`. +- **`develop`** - the current prerelease, built from `develop`. - Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g<sha>` for develop prereleases. ## Usage diff --git a/catalog/snippets/devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh index bfd59a8a..2f1a34d6 100755 --- a/catalog/snippets/devcontainer/python/post-create.sh +++ b/catalog/snippets/devcontainer/python/post-create.sh @@ -1,31 +1,30 @@ #!/usr/bin/env bash set -Eeuo pipefail -# Install uv (Astral) for the Python project. Idempotent - re-running -# overwrites in place. The installer drops the binary in $HOME/.local/bin and -# updates user shell init to add it to PATH for new shells; we add it to the -# current PATH explicitly so the rest of this script can invoke `uv` without a -# hard-coded path. +# Install uv (Astral) for the Python project. +# It is idempotent, since re-running overwrites in place. +# The installer drops the binary in $HOME/.local/bin. +# It also updates user shell init to add it to PATH for new shells. +# We add it to the current PATH explicitly so the rest of this script can invoke `uv`. # -# uv is pinned to a specific version (via the version-prefixed install URL, -# https://astral.sh/uv/<version>/install.sh) so a compromised or broken -# upstream `latest` script cannot silently change what runs on contributors' -# machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +# The uv version is pinned via the version-prefixed install URL. +# That URL is https://astral.sh/uv/<version>/install.sh. +# A broken or compromised upstream `latest` script therefore cannot silently change what runs. +# Bump UV_VERSION when you've reviewed release notes. # -# We re-install when uv is missing OR when the installed version doesn't -# match the pin. The latter handles the case where a contributor (or a -# previous run with a different pin) left a different uv version on PATH - -# the pin is what's reproducible and what the lockfile is generated against. +# We re-install when uv is missing OR when the installed version doesn't match the pin. +# The latter handles a contributor or an earlier pin leaving another uv version on PATH. +# The pin is what's reproducible and what the lockfile is generated against. UV_VERSION="0.11.8" installed_uv_version="" if command -v uv >/dev/null 2>&1; then installed_uv_version="$(uv --version | awk '{print $2}')" fi if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then - # Download the pinned installer to a temp file first instead of piping - # `curl ... | sh`. This produces a logged sha256 of exactly the bytes we - # ran, so a compromised installer leaves a forensic trail; it also lets - # a future change pin a known-good checksum (set EXPECTED_SHA below). + # Download the pinned installer to a temp file first instead of piping `curl ... | sh`. + # This produces a logged sha256 of exactly the bytes we ran. + # A compromised installer therefore leaves a forensic trail. + # It also lets a future change pin a known-good checksum (set EXPECTED_SHA below). installer=$(mktemp -t uv-install.XXXXXX.sh) trap 'rm -f "$installer"' EXIT curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" -o "$installer" @@ -40,8 +39,8 @@ if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then export PATH="$HOME/.local/bin:$PATH" fi -# Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script -# is safe before PyPiLibrary lands in the repo. +# Pre-warm uv environment for PyPiLibrary if it exists. +# It is guarded so this script is safe before PyPiLibrary lands in the repo. if [[ -f PyPiLibrary/pyproject.toml ]]; then (cd PyPiLibrary && uv sync) fi diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index a8ca49bf..bb417de9 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -1,7 +1,7 @@ # Husky snippet -`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only** - CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo - kept fast with native tooling and no Docker. +`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only**: CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo. Native tooling and no Docker is what keeps it fast. Full linting (line endings, workflow YAML, Markdown, spelling) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`). Keeping the doc linters out of the hook is what keeps it simple. -A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in both `.gitattributes` (`.husky/pre-commit text eol=lf`) and `.editorconfig` (`[.husky/pre-commit] end_of_line = lf`) - a CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. +A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in both `.gitattributes` (`.husky/pre-commit text eol=lf`) and `.editorconfig` (`[.husky/pre-commit] end_of_line = lf`). A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index e9a75d5a..c29797a9 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -1,9 +1,11 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -# Local pre-commit: language formatting and style only (no Docker). Full lint runs in CI and the VS Code Lint tasks. +# Local pre-commit: language formatting and style only (no Docker). +# Full lint runs in CI and the VS Code Lint tasks. -# .NET: CSharpier + dotnet format style via Husky.Net. A Python repo runs ruff here instead. +# .NET: CSharpier + dotnet format style via Husky.Net. +# A Python repo runs ruff here instead. if command -v dotnet >/dev/null 2>&1; then dotnet husky run fi diff --git a/catalog/snippets/vscode/README.md b/catalog/snippets/vscode/README.md index ffdc959e..94b04a3c 100644 --- a/catalog/snippets/vscode/README.md +++ b/catalog/snippets/vscode/README.md @@ -1,18 +1,18 @@ # VS Code Workspace Catalog -The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory - `base.jsonc` for the standard set, and `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` for the per-type additions. A repo's `<Repo>.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. +The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory. `base.jsonc` carries the standard set, and `dotnet.jsonc`, `python.jsonc`, and `docker.jsonc` carry the per-type additions. A repo's `<Repo>.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. ## Standard Extensions (every repo) -- `davidanson.vscode-markdownlint` - Markdown lint, sharing `.markdownlint-cli2.jsonc`. -- `streetsidesoftware.code-spell-checker` - cSpell, sharing `cspell.json`. -- `editorconfig.editorconfig` - applies `.editorconfig`. -- `yzhang.markdown-all-in-one` - Markdown editing and the auto-generated Table of Contents. -- `fanaticpythoner.better-todo-tree` - surfaces TODO/FIXME markers. -- `github.vscode-github-actions` - GitHub Actions authoring. -- `arahata.linter-actionlint` - actionlint for workflow YAML. -- `timonwong.shellcheck` - shellcheck for shell scripts. -- `anthropic.claude-code` - the coding agent. +- **`davidanson.vscode-markdownlint`** - Markdown lint, sharing `.markdownlint-cli2.jsonc`. +- **`streetsidesoftware.code-spell-checker`** - cSpell, sharing `cspell.json`. +- **`editorconfig.editorconfig`** - applies `.editorconfig`. +- **`yzhang.markdown-all-in-one`** - Markdown editing and the auto-generated Table of Contents. +- **`fanaticpythoner.better-todo-tree`** - surfaces TODO/FIXME markers. +- **`github.vscode-github-actions`** - GitHub Actions authoring. +- **`arahata.linter-actionlint`** - actionlint for workflow YAML. +- **`timonwong.shellcheck`** - shellcheck for shell scripts. +- **`anthropic.claude-code`** - the coding agent. ## Language and Target Additions @@ -22,10 +22,10 @@ The shared `.code-workspace` set for the fleet: the standard extensions every re ## Settings -- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"` - the Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. -- **Format on save** per language: C# via `csharpier.csharpier-vscode`; Python via `charliermarsh.ruff` with import organization. +- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"`. The Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. +- **Format on save** per language: C# via `csharpier.csharpier-vscode`, Python via `charliermarsh.ruff` with import organization. - **cSpell and markdownlint** read the repo's `cspell.json` and `.markdownlint-cli2.jsonc` (linter parity). -- Trim trailing whitespace except in Markdown and plaintext; sign off commits (`git.alwaysSignOff`). +- Trim trailing whitespace except in Markdown and plaintext, and sign off commits (`git.alwaysSignOff`). ## Composing a Workspace diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index d42e1e5e..b4120282 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,18 +1,18 @@ # Workflow snippets -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | | `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | | `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | | `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 | -| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough | -| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough | -| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough | -| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4; section 6 Docker walkthrough | -| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4; section 3 Release Model | -| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4; section 6 Docker walkthrough | +| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6, and section 6 Console walkthrough | +| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6, and section 6 NuGet walkthrough | +| `build-pypilibrary-task.yml` | Build PyPI package, with publishing split to an OIDC job | D3.4, D4, D7.2, and section 6 PyPI walkthrough | +| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4, and section 6 Docker walkthrough | +| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4, and section 3 Release Model | +| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4, and section 6 Docker walkthrough | | `deploy-site.yml` | Dispatch entry point for a site deploy: environment choice, per-environment concurrency, ref gate, shared validation | D2.1, D2.3, D7.1 | | `deploy-site-task.yml` | Build a site and ship it to a filesystem on a host the project owns, then verify against the running host | D4.6, D5.6, D7.2 (section 6 static-site walkthrough) | | `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | diff --git a/catalog/snippets/workflows/build-datebadge-task.yml b/catalog/snippets/workflows/build-datebadge-task.yml index 8042b526..3d4c3e21 100644 --- a/catalog/snippets/workflows/build-datebadge-task.yml +++ b/catalog/snippets/workflows/build-datebadge-task.yml @@ -1,7 +1,7 @@ name: Build BYOB date badge task -# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks -# the last main build. +# Caller-gated: the publisher invokes this only when main is published. +# The badge has no per-branch context, so it tracks the last main build. on: workflow_call: diff --git a/catalog/snippets/workflows/build-docker-task.yml b/catalog/snippets/workflows/build-docker-task.yml index 6d5a709c..0de5e291 100644 --- a/catalog/snippets/workflows/build-docker-task.yml +++ b/catalog/snippets/workflows/build-docker-task.yml @@ -13,13 +13,15 @@ on: required: false type: string default: '' - # Logical branch driving config and tags (main => Release/latest, otherwise Debug/develop). Required (no - # github.ref_name fallback): the publisher builds develop from a main-ref run, so a fallback would mistag it. + # Logical branch driving config and tags, where main gives Release/latest and anything else Debug/develop. + # It is required, with no github.ref_name fallback. + # The publisher builds develop from a main-ref run, so a fallback would mistag it. branch: required: true type: string - # Smoke mode: build linux/amd64 only (no QEMU/arm64), never push, and skip the shared registry cache-to so PR - # builds don't pollute the release buildcache. Used for fast PR feedback. + # Smoke mode: build linux/amd64 only, with no QEMU/arm64, and never push. + # It also skips the shared registry cache-to so PR builds don't pollute the release buildcache. + # It is used for fast PR feedback. smoke: required: false type: boolean @@ -49,7 +51,8 @@ jobs: with: ref: ${{ inputs.ref }} - # arm64 is non-native on the amd64 runner, so install its QEMU emulator only when the build includes it. + # The arm64 target is non-native on the amd64 runner. + # Install its QEMU emulator only when the build includes it. - name: Setup QEMU step if: ${{ contains(env.PLATFORMS, 'arm64') }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 @@ -61,8 +64,8 @@ jobs: with: platforms: ${{ env.PLATFORMS }} - # Always login (even for smoke) for the higher Docker rate limits on pulls/cache reads; fork PRs without the - # secrets can't run the Docker smoke build, acceptable for same-repo PRs. + # Always login, even for smoke, for the higher Docker rate limits on pulls and cache reads. + # Fork PRs without the secrets can't run the Docker smoke build, which is acceptable for same-repo PRs. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: @@ -79,8 +82,8 @@ jobs: docker.io/ptr727/projecttemplate:${{ inputs.branch == 'main' && 'latest' || 'develop' }} docker.io/ptr727/projecttemplate:${{ needs.get-version.outputs.SemVer2 }} platforms: ${{ env.PLATFORMS }} - # Read both branches' caches (near-identical layers) but write only this branch's tag, and only when - # pushing, so the weekly main+develop matrix legs don't overwrite one shared cache. + # Read both branches' caches, whose layers are near-identical, but write only this branch's tag. + # Write only when pushing, so the weekly main+develop matrix legs don't overwrite one shared cache. cache-from: | type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop diff --git a/catalog/snippets/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml index 20582689..9f09c599 100644 --- a/catalog/snippets/workflows/build-executable-task.yml +++ b/catalog/snippets/workflows/build-executable-task.yml @@ -8,13 +8,13 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the - # develop leg of the publisher's matrix isn't mislabeled. + # Logical branch driving build configuration, where `main` gives Release and anything else Debug. + # It is required, with no fallback, so the develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string - # Smoke mode: build a runtime subset (linux-x64 + win-x64) instead of the full matrix and skip the zip / - # artifact aggregation, for fast PR feedback. + # Smoke mode: build a runtime subset (linux-x64 + win-x64) instead of the full matrix. + # It also skips the zip and artifact aggregation, for fast PR feedback. smoke: required: false type: boolean @@ -65,8 +65,8 @@ jobs: -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - # Branch-suffixed so both branches can build in one run without colliding. Skipped on smoke: the aggregation - # job is `!smoke`, so the per-runtime output would have no consumer. + # Branch-suffixed so both branches can build in one run without colliding. + # It is skipped on smoke, since the aggregation job is `!smoke` and the per-runtime output would have no consumer. - name: Upload matrix build artifacts step if: ${{ !inputs.smoke }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -76,8 +76,8 @@ jobs: # Consumed within this run by the aggregation job; minimize artifact storage. retention-days: 1 - # Smoke builds only need the per-runtime compile to succeed, so skip the zip aggregation; the release job never - # runs on smoke, so no `release-asset-*` artifact is needed. + # Smoke builds only need the per-runtime compile to succeed, so skip the zip aggregation. + # The release job never runs on smoke, so no `release-asset-*` artifact is needed. upload-build-artifacts: name: Upload matrix build artifacts job if: ${{ !inputs.smoke }} @@ -96,8 +96,8 @@ jobs: - name: Zip build output step run: 7z a -t7z ${{ runner.temp }}/Console.7z ${{ runner.temp }}/publish/* - # GitHub-release asset, uploaded under the `release-asset-<branch>-*` pattern that the `github-release` job - # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. + # GitHub-release asset, uploaded under the `release-asset-<branch>-*` pattern that the `github-release` job collects. + # It is branch-suffixed so the publisher can build both branches in one run without colliding on the name. - name: Upload build artifacts step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/catalog/snippets/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml index 8bcfd0d7..8d550d36 100644 --- a/catalog/snippets/workflows/build-nugetlibrary-task.yml +++ b/catalog/snippets/workflows/build-nugetlibrary-task.yml @@ -13,13 +13,14 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the - # develop leg of the publisher's matrix isn't mislabeled. + # Logical branch driving build configuration, where `main` gives Release and anything else Debug. + # It is required, with no fallback, so the develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string - # Smoke mode: build for validation only and skip the release-asset zip/upload. A PR smoke run has no consumer - # for the artifact (the github-release job is gated `!smoke`), so uploading it just burns artifact storage. + # Smoke mode: build for validation only and skip the release-asset zip and upload. + # A PR smoke run has no consumer for the artifact, since the github-release job is gated `!smoke`. + # Uploading it would just burn artifact storage. smoke: required: false type: boolean @@ -76,9 +77,9 @@ jobs: if: ${{ !inputs.smoke }} run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - # GitHub-release asset, uploaded under the `release-asset-<branch>-*` pattern that the `github-release` job - # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. - # Skipped on smoke: the github-release job is `!smoke`, so nothing would consume it. + # GitHub-release asset, uploaded under the `release-asset-<branch>-*` pattern that the `github-release` job collects. + # It is branch-suffixed so the publisher can build both branches in one run without colliding on the name. + # It is skipped on smoke, since the github-release job is `!smoke` and nothing would consume it. - name: Upload build artifacts step if: ${{ !inputs.smoke }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/catalog/snippets/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml index 8efb9640..6a43dc1d 100644 --- a/catalog/snippets/workflows/build-pypilibrary-task.yml +++ b/catalog/snippets/workflows/build-pypilibrary-task.yml @@ -1,8 +1,9 @@ name: Build PyPI library task -# Reusable workflow that only builds the PyPI wheel + sdist and uploads them as a run artifact; it does not -# publish. Publishing lives in publish-release.yml so id-token:write is granted only at that entry point and not -# propagated to every caller (e.g. the PR smoke build). +# Reusable workflow that only builds the PyPI wheel + sdist and uploads them as a run artifact. +# It does not publish. +# Publishing lives in publish-release.yml, so id-token:write is granted only at that entry point. +# It is therefore not propagated to every caller, such as the PR smoke build. on: workflow_call: @@ -12,13 +13,14 @@ on: required: false type: string default: '' - # Logical branch driving the PEP 440 version (develop => .dev0 prerelease, otherwise plain release). Required; the - # orchestrator passes it explicitly. + # Logical branch driving the PEP 440 version, where develop gives a .dev0 prerelease and anything else a plain release. + # It is required, and the orchestrator passes it explicitly. branch: required: true type: string - # Smoke mode: build for validation only and skip the artifact upload. A PR smoke run has no consumer for the - # wheel/sdist (the publish-pypi job runs only on a real publish), so uploading it just burns artifact storage. + # Smoke mode: build for validation only and skip the artifact upload. + # A PR smoke run has no consumer for the wheel/sdist, since the publish-pypi job runs only on a real publish. + # Uploading it would just burn artifact storage. smoke: required: false type: boolean @@ -58,8 +60,8 @@ jobs: - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - # Pin uv to the same version as the devcontainer (UV_VERSION) so CI and local resolves cannot drift; bump in - # lockstep. + # Pin uv to the same version as the devcontainer (UV_VERSION) so CI and local resolves cannot drift. + # Bump the two in lockstep. version: "0.11.8" enable-cache: true cache-dependency-glob: "PyPiLibrary/uv.lock" @@ -79,8 +81,9 @@ jobs: - name: Run pytest step run: uv run pytest - # PEP 440 version: develop -> M.N.P.B.dev0 (prerelease), else M.N.P.B. The NBGV git height sits in the release - # segment, so develop's normally exceeds main's and `pip install --pre` picks the dev build over the main release. + # PEP 440 version: develop gives M.N.P.B.dev0 as a prerelease, and anything else gives M.N.P.B. + # The NBGV git height sits in the release segment, so develop's normally exceeds main's. + # `pip install --pre` therefore picks the dev build over the main release. - name: Compute PyPI version step id: pypiver run: | diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index cf1159d7..c10a99f0 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -23,19 +23,21 @@ on: required: false type: string default: '' - # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each - # publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build. + # Logical branch driving config / tags / prerelease for every target. + # It is required, with no fallback, because each publish run builds a single branch (the trigger ref). + # A silent fallback would therefore mislabel the build. branch: required: true type: string - # Smoke mode: reduced, never-published build for fast PR feedback. Forwarded to every target and hard-disables - # every push below, so a smoke run can never publish regardless of the publish flags. + # Smoke mode: a reduced, never-published build for fast PR feedback. + # It is forwarded to every target and hard-disables every push below. + # A smoke run can therefore never publish regardless of the publish flags. smoke: required: false type: boolean default: false - # Per-target presence gates. Default true (build everything); a PR smoke run sets these from the paths-filter - # so only changed targets build. + # Per-target presence gates, defaulting to true so everything builds. + # A PR smoke run sets these from the paths-filter so only changed targets build. enable_docker: required: false type: boolean @@ -52,8 +54,9 @@ on: required: false type: boolean default: true - # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the - # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. + # Set false for a repo that produces no release-asset-* files, such as a Docker-only one. + # The release is then just the tag + source zip + README + LICENSE. + # The artifact download is skipped and the unmatched-files guard relaxes. expect_release_assets: required: false type: boolean @@ -68,10 +71,12 @@ jobs: with: ref: ${{ inputs.ref }} - # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails - # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch - # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip - # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + # Entry gate: validate branch<->version consistency once, before the build jobs. + # An NBGV mis-classification then fails fast instead of after building and publishing. + # The main branch must be a public release, carrying no prerelease '-'. + # Every other branch must carry a prerelease '-', which guards a develop leg being published as stable. + # Strip '+buildmetadata' first, since a '-' there is legitimate. + # Only a '-' in the core/prerelease segment marks a prerelease. validate-release: name: Validate release version job needs: [get-version] @@ -115,8 +120,9 @@ jobs: # Skip the release-asset upload on smoke (nothing consumes it on a PR). smoke: ${{ inputs.smoke }} - # PyPI publishing happens in `publish-release.yml`, not here, so `id-token: write` is only granted at the - # entry-point job; this workflow just builds and uploads the artifact for that job to download. + # PyPI publishing happens in `publish-release.yml` rather than here. + # `id-token: write` is therefore only granted at the entry-point job. + # This workflow just builds and uploads the artifact for that job to download. build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} @@ -142,12 +148,14 @@ jobs: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Docker is the terminal registry push, so it must never push on a partial run. It needs every other build and - # guards with `!failure() && !cancelled()`: a *failed* build skips docker (no build, no push), while a *skipped* - # build - a target disabled on a smoke PR, or one this repo does not vendor - does not, so docker still builds - # the changed target on smoke. Plain `needs` cannot express this (a skipped need skips the dependent). The - # github-release job reaches the same intent more simply because it only runs on a publish (`!inputs.smoke`), - # where nothing is disabled. (A repo pushing a package alongside docker would need a build/publish split.) + # Docker is the terminal registry push, so it must never push on a partial run. + # It needs every other build and guards with `!failure() && !cancelled()`. + # A *failed* build skips docker, so there is no build and no push. + # A *skipped* build does not, so docker still builds the changed target on smoke. + # A skipped build is a target disabled on a smoke PR, or one this repo does not vendor. + # Plain `needs` cannot express this, since a skipped need skips the dependent. + # The github-release job reaches the same intent more simply, because it only runs on a publish (`!inputs.smoke`), where nothing is disabled. + # (A repo pushing a package alongside docker would need a build/publish split.) build-docker: name: Build Docker job if: ${{ inputs.enable_docker && !failure() && !cancelled() }} @@ -178,11 +186,11 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Collect assets by the `release-asset-<branch>-*` pattern so this step is target-agnostic: subset releases by - # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release - # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops - # every file-producing target (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) - # relaxes that guard. + # Collect assets by the `release-asset-<branch>-*` pattern so this step is target-agnostic. + # Subset releases by deleting the target rather than by `enable_*: false`, since a skipped `needs` job would skip this release job too. + # The release step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match. + # A repo that drops every file-producing target relaxes that guard. + # A Docker-only repo is the case, its release carrying only the source zip + README + LICENSE. - name: Download release asset artifacts step if: ${{ inputs.expect_release_assets }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -191,8 +199,8 @@ jobs: merge-multiple: true path: ./Publish - # The weekly publisher re-runs even with no new commits, so the version may already be released. Skip the release - # step when a release for this tag already exists to avoid a no-op republish. + # The weekly publisher re-runs even with no new commits, so the version may already be released. + # Skip the release step when a release for this tag already exists, to avoid a no-op republish. - name: Check for existing release step id: release-exists env: @@ -214,10 +222,10 @@ jobs: # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. - # Every release (any branch, any target) is a tag on the built commit plus the auto-attached source zip, README, - # and LICENSE; targets amend it by uploading `release-asset-*` files (binaries/packages) or pushing elsewhere - # (image/registry). `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or - # misnamed; a no-file-target repo relaxes it (see download step). + # Every release, on any branch and for any target, is a tag on the built commit plus the auto-attached source zip, README, and LICENSE. + # Targets amend it by uploading `release-asset-*` files (binaries/packages) or by pushing elsewhere (image/registry). + # `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or misnamed. + # A no-file-target repo relaxes it (see download step). - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 @@ -232,16 +240,17 @@ jobs: README.md ./Publish/* - # Surgical cleanup at the point of consumption: the release-asset-<branch>-* transfer artifacts now have durable - # copies on the release, so delete them by exact pattern to free the storage quota - scoped to this branch's - # assets, leaving diagnostics and any other artifacts. Gated to the same condition as the create step so it only - # deletes when a release was actually created/refreshed this run; on a skipped create (existing tag, no new - # commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. Needs the caller to - # grant `actions: write` (publish-release's publish job does). + # Surgical cleanup at the point of consumption, since the release-asset-<branch>-* transfer artifacts now have durable copies on the release. + # Delete them by exact pattern to free the storage quota. + # The pattern is scoped to this branch's assets, leaving diagnostics and any other artifacts. + # It is gated to the same condition as the create step, so it only deletes when a release was actually created or refreshed this run. + # On a skipped create (an existing tag, no new commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. + # It needs the caller to grant `actions: write`, which publish-release's publish job does. - name: Delete consumed release asset artifacts step if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} - # Best-effort: the release is already published, so a listing/delete hiccup must never red the job; the - # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). + # Best-effort, since the release is already published, so a listing or delete hiccup must never red the job. + # The retention-days: 1 backstop reaps anything missed. + # It deletes every matching id, because a rerun can upload duplicates. continue-on-error: true env: GH_TOKEN: ${{ github.token }} diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index e5a14845..62486520 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -1,16 +1,19 @@ name: Check upstream version task -# Skeleton for a wrapper repo tracking an upstream release: a resolver prints the upstream version(s) as a JSON object -# of name -> version (one key, or N for a multi-component pin), written to a committed state file beside version.json, -# and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges. Call from a scheduled entry-point -# workflow; matrix only the branches that ship the version (a CI-only version uses ["develop"]). +# Skeleton for a wrapper repo tracking an upstream release. +# A resolver prints the upstream version(s) as a JSON object of name -> version, one key or N for a multi-component pin. +# That object is written to a committed state file beside version.json. +# It then opens a rolling App-signed bump PR per branch that the merge-bot auto-merges. +# Call it from a scheduled entry-point workflow. +# Matrix only the branches that ship the version, since a CI-only version uses ["develop"]. on: workflow_call: inputs: resolver-command: - # Single-version wrappers print {"version":"X"}; multi-component wrappers print one - # key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}. + # Single-version wrappers print {"version":"X"}. + # Multi-component wrappers print one key per pinned upstream component. + # An example is {"esphome":"2026.6.2","device_builder":"1.0.12"}. description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout. required: true type: string @@ -25,8 +28,8 @@ on: type: string default: '["main", "develop"]' bump-branch-prefix: - # merge-bot-pull-request.yml hard-codes the head refs upstream-version-main/-develop; keep this - # default unless you also update the merge-upstream-version job, or auto-merge will not fire. + # The merge-bot-pull-request.yml workflow hard-codes the head refs upstream-version-main/-develop. + # Keep this default unless you also update the merge-upstream-version job, or auto-merge will not fire. description: Head-branch prefix; must match the merge-bot's upstream-version-<base> head refs. required: false type: string @@ -61,8 +64,8 @@ jobs: ref: ${{ matrix.branch }} token: ${{ steps.app-token.outputs.token }} - # Normalize the resolved JSON (sorted keys) so the committed file and its diff are stable; the old-vs-new key - # diff drives the PR title/body, naming only components that moved. + # Normalize the resolved JSON with sorted keys, so the committed file and its diff are stable. + # The old-vs-new key diff drives the PR title/body, naming only components that moved. - name: Resolve upstream version step id: resolve env: @@ -71,8 +74,9 @@ jobs: run: | set -Eeuo pipefail - # Require a non-empty JSON object of single-line name -> version strings; a CR/LF would corrupt the - # single-line GITHUB_OUTPUT, so reject it here instead of committing unconsumable state. + # Require a non-empty JSON object of single-line name -> version strings. + # A CR/LF would corrupt the single-line GITHUB_OUTPUT. + # Reject it here instead of committing unconsumable state. raw="$(bash -c "$RESOLVER_COMMAND")" if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \ || [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then @@ -80,25 +84,27 @@ jobs: exit 1 fi - # Missing, non-JSON, or non-object state => empty object, so the first run and any - # unusable prior file both diff cleanly against the resolved object instead of failing - # (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below). + # Missing, non-JSON, or non-object state becomes an empty object. + # The first run and any unusable prior file then both diff cleanly against the resolved object instead of failing. + # A valid-JSON-but-non-object file would otherwise break the `$old + $new` union below. if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi - # Write the canonical state file as CRLF (jq emits LF) to match .editorconfig's .json - # rule. Unchanged content => no diff => no PR. + # Write the canonical state file as CRLF, since jq emits LF, to match .editorconfig's .json rule. + # Unchanged content means no diff, and therefore no PR. printf '%s\n' "$new" | sed 's/$/\r/' > "$STATE_FILE" - # Diff across the union of old+new keys so an added, moved, or removed key is all caught; - # removals carry a null .new. These drive the PR title/body (only keys that moved). + # Diff across the union of old+new keys so an added, moved, or removed key is all caught. + # Removals carry a null .new. + # These drive the PR title/body, naming only the keys that moved. changed="$(jq -n --argjson old "$old" --argjson new "$new" ' [ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')" summary="$(printf '%s' "$changed" | jq -r ' map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')" - # Title: a canonicalization-only change (state reserialized, no key moved) when the prior - # file was valid-but-differently-formatted; the trivial single-version case renders bare; - # otherwise name each moved component. + # Title: a canonicalization-only change, meaning the state was reserialized with no key moved. + # That happens when the prior file was valid but differently formatted. + # The trivial single-version case renders bare. + # Otherwise name each moved component. if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then title="Canonicalize upstream version state file" elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then diff --git a/catalog/snippets/workflows/get-version-task.yml b/catalog/snippets/workflows/get-version-task.yml index 2d0321ca..066c3fe0 100644 --- a/catalog/snippets/workflows/get-version-task.yml +++ b/catalog/snippets/workflows/get-version-task.yml @@ -3,8 +3,9 @@ name: Get version information task on: workflow_call: inputs: - # Git ref to check out and version. Empty falls back to the caller's default checkout ref (`github.ref`); the - # publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. + # Git ref to check out and version. + # Empty falls back to the caller's default checkout ref (`github.ref`). + # The publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. ref: required: false type: string @@ -47,14 +48,16 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 0 - # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would propose a downgrade. + # The nbgv action is floated on @master, because its tag stream lags master. + # Dependabot tag-tracking would otherwise propose a downgrade. # Revisit if dotnet/nbgv resumes regular tagged releases. - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master env: - # Version from the checked-out branch, not the CI ref. GITHUB_REF is reserved and a step env can't reliably - # override it (the runner re-injects the dispatch ref), so on a publish dispatched from the default branch NBGV - # would classify every leg as the public ref. IGNORE_GITHUB_REF makes NBGV ignore GITHUB_REF and use the - # checked-out branch, which each matrix leg already is. The validate-release gate backstops any misclassification. + # Version from the checked-out branch rather than the CI ref. + # GITHUB_REF is reserved and a step env can't reliably override it, since the runner re-injects the dispatch ref. + # On a publish dispatched from the default branch, NBGV would therefore classify every leg as the public ref. + # IGNORE_GITHUB_REF makes NBGV ignore GITHUB_REF and use the checked-out branch, which each matrix leg already is. + # The validate-release gate backstops any misclassification. IGNORE_GITHUB_REF: "true" diff --git a/catalog/snippets/workflows/publish-docker-readme-task.yml b/catalog/snippets/workflows/publish-docker-readme-task.yml index 1c80cec5..b91c77f6 100644 --- a/catalog/snippets/workflows/publish-docker-readme-task.yml +++ b/catalog/snippets/workflows/publish-docker-readme-task.yml @@ -1,21 +1,23 @@ name: Publish Docker Hub readme task -# Pushes the Docker Hub repository overview (Docker/README.md). Caller-gated: the publisher invokes this only when main -# is published - the overview has no per-branch context. The repository list is either passed directly (`repositories`) -# or derived from a manifest (`manifest` + `manifest-jq`), covering single- and multi-image repos without per-repo glue. +# Pushes the Docker Hub repository overview (Docker/README.md). +# Caller-gated, so the publisher invokes this only when main is published. +# The overview has no per-branch context. +# The repository list is either passed directly (`repositories`) or derived from a manifest (`manifest` + `manifest-jq`). +# That covers single- and multi-image repos without per-repo glue. # An optional transform step (e.g. m4) renders the readme before pushing, optionally after downloading a build artifact. on: workflow_call: inputs: - # Ref whose readme (and any transform sources) to publish; empty uses the caller's ref. The publisher passes - # `main` so the overview tracks the main release even when dispatched from another ref. + # Ref whose readme, and any transform sources, to publish, where empty uses the caller's ref. + # The publisher passes `main` so the overview tracks the main release even when dispatched from another ref. ref: required: false type: string default: '' - # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. Leave empty to derive the list from a - # manifest instead (see `manifest`). + # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. + # Leave empty to derive the list from a manifest instead (see `manifest`). repositories: required: false type: string @@ -30,8 +32,8 @@ on: required: false type: string default: '' - # Optional command that renders the readme before pushing (e.g. an m4 step that writes Docker/README.md). Empty - # pushes the committed file as-is. + # Optional command that renders the readme before pushing, such as an m4 step that writes Docker/README.md. + # Empty pushes the committed file as-is. transform-run: required: false type: string @@ -42,8 +44,8 @@ on: required: false type: string default: '' - # Readme file to push (the transform's output, or the committed file). Single-image repos with a root README pass - # './README.md'. + # Readme file to push, being the transform's output or the committed file. + # Single-image repos with a root README pass './README.md'. readme-filepath: required: false type: string @@ -51,8 +53,8 @@ on: jobs: - # Resolve the repository list once - either the static `repositories` input or a jq program over a manifest - so the - # publish matrix is the same shape for single- and multi-image repos and no caller hand-rolls its own derivation. + # Resolve the repository list once, from either the static `repositories` input or a jq program over a manifest. + # The publish matrix is then the same shape for single- and multi-image repos, and no caller hand-rolls its own derivation. get-repos: name: Get repository list job runs-on: ubuntu-latest @@ -61,9 +63,10 @@ jobs: steps: - # Enforce the input contract once so the downstream steps can trust it: the list comes from exactly one source - - # `repositories`, the `manifest` + `manifest-jq` pair, or neither (default to this repo). Silent fall-through to the - # default would otherwise mask caller mistakes (a half-filled manifest pair, or both sources passed at once). + # Enforce the input contract once so the downstream steps can trust it. + # The list comes from exactly one source: `repositories`, the `manifest` + `manifest-jq` pair, or neither, defaulting to this repo. + # Silent fall-through to the default would otherwise mask caller mistakes. + # A half-filled manifest pair, or both sources passed at once, are the cases. - name: Validate inputs step env: REPOSITORIES: ${{ inputs.repositories }} @@ -101,8 +104,8 @@ jobs: elif [ -n "$MANIFEST" ]; then echo "repositories=$(jq --compact-output "$MANIFEST_JQ" "$MANIFEST")" >> "$GITHUB_OUTPUT" else - # Default to this repo's own Docker Hub repository (lowercased owner/name) so a single-image caller - # carries the orchestration verbatim with no repo-specific value. + # Default to this repo's own Docker Hub repository, being the lowercased owner/name. + # A single-image caller then carries the orchestration verbatim with no repo-specific value. echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT" fi diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml index b5997d2c..fa24cb4e 100644 --- a/catalog/snippets/workflows/publish-plan-task.yml +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -1,20 +1,24 @@ name: Publish plan task -# Single source of truth for the release-gate decision, reused by every publish-release.yml job so the policy -# lives here, not scattered across job `if:` conditions. A human PR merge never auto-publishes; a release is a -# deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. +# Single source of truth for the release-gate decision, reused by every publish-release.yml job. +# The policy therefore lives here rather than scattered across job `if:` conditions. +# A human PR merge never auto-publishes. +# A release is a deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. # # Outputs: -# publish - 'true' when this run should publish: a bot-authored push (the codegen App merges every bot PR, so -# its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. +# publish - 'true' when this run should publish, meaning a bot-authored push, a schedule, or a workflow_dispatch of main/develop. +# The codegen App merges every bot PR, so its identity, or dependabot[bot], is the gate. # A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. -# stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. -# Both outputs are the strings 'true'/'false' - gate with == 'true'; a bare `if: ${{ needs.plan.outputs.publish }}` -# is always truthy (a non-empty string is truthy in an Actions expression). +# stable - 'true' when the target branch is main (the stable channel). +# Main-only jobs gate on publish && stable. # -# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`; a -# Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. A case a given -# caller never triggers (e.g. schedule for a library) is simply inert for it - expected of a single-source task. +# Both outputs are the strings 'true'/'false', so gate with == 'true'. +# A bare `if: ${{ needs.plan.outputs.publish }}` is always truthy, since a non-empty string is truthy in an Actions expression. +# +# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`. +# A Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. +# A case a given caller never triggers (e.g. schedule for a library) is simply inert for it. +# That is expected of a single-source task. on: workflow_call: @@ -69,15 +73,17 @@ jobs: publish=true ;; push) - # A human merge never auto-publishes; only a bot merge to main does. The codegen App merges every - # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). The - # ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. + # A human merge never auto-publishes, and only a bot merge to main does. + # The codegen App merges every Dependabot/codegen PR, so github.actor is its identity. + # The dependabot[bot] identity is allowed defensively alongside it. + # The ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. if [[ "$REF" == "main" ]] && { [[ "$ACTOR" == "ptr727-codegen[bot]" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then publish=true elif [[ "$REF" == "main" ]]; then - # Fail loud: an unrecognized actor pushing to main is either a human commit (legitimately not - # publishing, but worth seeing) or a release bot under a new identity, which would otherwise - # stop publishing silently while a schedule keeps releasing - lost timeliness, no error. + # Fail loud, because an unrecognized actor pushing to main is one of two things. + # It is either a human commit, legitimately not publishing but worth seeing. + # Or it is a release bot under a new identity, which would otherwise stop publishing silently. + # A schedule would keep releasing meanwhile, costing timeliness with no error raised. echo "::warning::Push to main by unrecognized actor '$ACTOR'; not publishing. If this is a release bot under a new identity, update the allowlist in publish-plan-task.yml." fi ;; diff --git a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml index f22d91bc..0d53cfee 100644 --- a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml +++ b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml @@ -7,8 +7,9 @@ on: - cron: '0 4 * * *' concurrency: - # Workflow-only group (no `-${{ github.ref }}`): the task writes the fixed `codegen-main`/`codegen-develop` - # branches regardless of triggering ref, so a dispatch and the scheduled run must not race on them. + # Workflow-only group (no `-${{ github.ref }}`), since the task writes fixed branches. + # It writes `codegen-main` and `codegen-develop` regardless of the triggering ref. + # A dispatch and the scheduled run must therefore not race on them. group: ${{ github.workflow }} cancel-in-progress: true diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 6ee03e84..3821a628 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,6 +1,6 @@ # Devcontainer Setup -This repo ships no application toolchain. It keeps two per-language [Dev Container][containers-link] definitions under `catalog/snippets/devcontainer/` as reference for fleet code repos; each carries one toolchain, extension surface, and `postCreateCommand`. The mechanics below - SSH commit signing, bind mounts, and `gh` auth - apply to any repo that uses them. +This repo ships no application toolchain. It keeps two per-language [Dev Container][containers-link] definitions under `catalog/snippets/devcontainer/` as a reference for fleet code repos, each carrying one toolchain, extension surface, and `postCreateCommand`. The mechanics below (SSH commit signing, bind mounts, and `gh` auth) apply to any repo that uses them. | Devcontainer | Image | Toolchain | | ------------ | ----- | --------- | @@ -9,7 +9,7 @@ This repo ships no application toolchain. It keeps two per-language [Dev Contain In a repo that carries one of these definitions, install the [Dev Containers extension][marketplace-link] and pick **Reopen in Container**. -Prerequisite: complete [host setup][host-setup] first - without git config, an SSH key, and the allowed-signers file on the host, the devcontainer cannot sign commits. +Prerequisite: complete [host setup][host-setup] first. Without git config, an SSH key, and the allowed-signers file on the host, the devcontainer cannot sign commits. ## What's Inside (Both Containers) @@ -21,15 +21,15 @@ Prerequisite: complete [host setup][host-setup] first - without git config, an S The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the Python package venv where one is present. -Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical - when you add an extension to one, add it to the other. +Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical, so when you add an extension to one, add it to the other. ## Bind Mounts (Both Containers) -The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead - see [`gh` credential store][gh-credential-store] below for the full picture. +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead. See [`gh` credential store][gh-credential-store] below for the full picture. | Host path | Container path | Mode | Purpose | | --------- | -------------- | ---- | ------- | -| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container - SSH agent forwarding handles signing. | +| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container, since SSH agent forwarding handles signing. | | `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | | `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store][gh-credential-store] below. | @@ -43,8 +43,8 @@ Both `devcontainer.json` files run two scripts at well-defined points: - **`onCreateCommand`** - `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. - **`postCreateCommand`** - language-specific: - - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh` - runs `dotnet tool restore` (csharpier, dotnet-outdated). - - Python: `catalog/snippets/devcontainer/python/post-create.sh` - installs the pinned `uv` and pre-syncs the Python package if present. + - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh`, which runs `dotnet tool restore` (csharpier, dotnet-outdated). + - Python: `catalog/snippets/devcontainer/python/post-create.sh`, which installs the pinned `uv` and pre-syncs the Python package if present. Re-runs of either are idempotent. No git hooks are installed by default. @@ -60,18 +60,18 @@ To force them to run again after editing a script: VS Code -> Command Palette -> | WSL2 | file (no native credential store) | | macOS | macOS Keychain | -The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token** - the container will report "no authentication" until you either: +The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token**, so the container will report "no authentication" until you either: 1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or 2. Skip in-container `gh` and run those commands on the host instead. -The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off; if it isn't, use option 2. +The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off, and if it isn't, use option 2. ## Verify the Devcontainer After **Reopen in Container** finishes, run the language-appropriate checks. -**Both containers** - verify SSH signing and `gh`: +**Both containers.** Verify SSH signing and `gh`: ```shell gh auth status # logged in as you @@ -96,19 +96,19 @@ which dotnet # nothing - dotnet intentiona cd <package> && uv sync && uv run pytest # tests pass ``` -If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty - re-run the snippet in [host setup][host-setup]. +If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty, so re-run the snippet in [host setup][host-setup]. ## Troubleshooting -**Permission denied writing to `~/.ssh/known_hosts` in the container** - The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container; if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. +**Permission denied writing to `~/.ssh/known_hosts` in the container.** The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container, and if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. -**`git commit` fails with "no SSH agent socket"** - VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup][host-setup]). +**`git commit` fails with "no SSH agent socket".** VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first, and if it says "could not open a connection to your authentication agent", start the agent (see [host setup][host-setup]). -**uv not on `PATH` after rebuild** (Python container) - The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. +**uv not on `PATH` after rebuild** (Python container). The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. -**Container builds but extensions don't auto-install** - Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. +**Container builds but extensions don't auto-install.** Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. -**Wrong-language work in the wrong container** - The `.NET` container has no `uv` and no Python extensions; the Python container has no `dotnet` SDK and no C# extensions. This is intentional - use the matching container rather than installing the missing toolchain ad hoc. +**Wrong-language work in the wrong container.** The `.NET` container has no `uv` and no Python extensions, and the Python container has no `dotnet` SDK and no C# extensions. This is intentional, so use the matching container rather than installing the missing toolchain ad hoc. <!-- Repo --> diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index 14934150..472661d4 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -1,6 +1,6 @@ # repo-config: Carry, Apply, and Regenerate (Hub-Only) -The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** - it is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config. This carry/apply/regen procedure lives here so it never ships into a downstream copy. +The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** and is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config. This carry/apply/regen procedure lives here so it never ships into a downstream copy. ## Downstream Carry @@ -8,12 +8,12 @@ Every fleet repo carries the `repo-config/` directory. The hub keeps the canonic - **The payloads carry and the script does not.** A `release` repo carries `develop.json`, an `operational` repo carries `operational/develop.json` instead, and `main.json` and `settings.json` are shared by both models. `configure.sh` stays in the hub and is run from a hub checkout against the repo named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling], because it holds nothing per-repo and a copy of it is only current until the next fix. A repo still holding a copy has it deleted as it is next visited. Note the split this leaves: an apply or check run from the hub reads the hub's payloads rather than the repo's own, which is the single source the model is for, and the repo's carried payloads remain what its own `AUDIT.md` diffs the live rulesets against. - **Carried files name no fleet repo as an illustrative example.** A carried file adds no template-repo reference and names no sibling fleet repo as an example (any fleet repo may be private, so such a link 404s in a public carrier, and it couples the repos). A contextually relevant link a reader of *this* repo's content needs is fine. See [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style]. To point at a current good example, name it in the onboarding/conformance issue or the hub-only [`reports/conformance-matrix.md`][conformance-matrix]. -- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline - the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative. The adapted `AUDIT.md` is a settings diff, a normalized ruleset diff against the carried payloads (an operational carry swaps in `operational/develop.json`), and a names-only secrets check, all targeting the current repo - adapt this shape, don't invent. A current well-formed example is named in the onboarding/conformance issue. -- **Adapted `spec/secrets.json` shape.** The repo-scoped adaptation carries `baseline` (the App pair, which every fleet repo needs for the merge-bot) plus a `mechanisms` entry for each publish mechanism the repo actually uses, and the `targetMechanisms` routing entries for those mechanisms. **A source-only repo whose publish targets all map to a null mechanism (nothing to route) carries just `baseline` (plus a `note`)** - it omits `targetMechanisms` and `mechanisms` entirely, because a lone `targetMechanisms` map with no `mechanisms` reads as a schema bug (the audit enumerates `baseline` + `mechanisms`, never `targetMechanisms`, so an all-null routing map is dead weight). A `release` repo that uses a real mechanism (e.g. `nuget-oidc`, `docker-hub`, `codecov`) carries that `mechanisms` entry **and** its `targetMechanisms`/`typeMechanisms` routing, which the audit then picks up. +- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline, the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative. The adapted `AUDIT.md` is a settings diff, a normalized ruleset diff against the carried payloads (an operational carry swaps in `operational/develop.json`), and a names-only secrets check, all targeting the current repo. Adapt this shape, don't invent. A current well-formed example is named in the onboarding/conformance issue. +- **Adapted `spec/secrets.json` shape.** The repo-scoped adaptation carries `baseline` (the App pair, which every fleet repo needs for the merge-bot) plus a `mechanisms` entry for each publish mechanism the repo actually uses, and the `targetMechanisms` routing entries for those mechanisms. **A source-only repo whose publish targets all map to a null mechanism (nothing to route) carries just `baseline` (plus a `note`)**, omitting `targetMechanisms` and `mechanisms` entirely, because a lone `targetMechanisms` map with no `mechanisms` reads as a schema bug (the audit enumerates `baseline` + `mechanisms`, never `targetMechanisms`, so an all-null routing map is dead weight). A `release` repo that uses a real mechanism (e.g. `nuget-oidc`, `docker-hub`, `codecov`) carries that `mechanisms` entry **and** its `targetMechanisms`/`typeMechanisms` routing, which the audit then picks up. ## Applying the Config -**Configure by importing the JSON payloads, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them). Only the `develop` *content* varies by model. +**Configure by importing the JSON payloads, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`AGENTS.md` and the workflows reference them). Only the `develop` *content* varies by model. First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh apply` from a hub checkout, naming the target repo and its model (the script applies `settings.json` and the Dependabot security features alongside the rulesets). Name the model rather than leaving it to the lookup. The script reads the registry beside it and resolves a repo it does not find through `defaults.workflowModel` to `release`, so an unregistered operational repo silently takes the release `develop` ruleset. The carried-payload inference the script also carries is for a run with no registry to consult, which a run from here never is: @@ -49,7 +49,7 @@ done ## Brownfield Migration (Maintainer Only) -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits committed under a bot or web-flow identity (`dependabot[bot]`, `github-actions[bot]`), set each commit's committer to the signing identity before re-signing so the committer GitHub verifies matches your key, rather than leaving your key over another identity's commit (see the history-rewrite rule in [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]). +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] forbids agents from running. **An agent must never execute it, and must surface it to the maintainer instead.** Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits committed under a bot or web-flow identity (`dependabot[bot]`, `github-actions[bot]`), set each commit's committer to the signing identity before re-signing so the committer GitHub verifies matches your key, rather than leaving your key over another identity's commit (see the history-rewrite rule in [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]). <!-- Repo --> diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md index b10dfd4f..72e7d194 100644 --- a/docs/ssh-signing.md +++ b/docs/ssh-signing.md @@ -1,18 +1,18 @@ # SSH Commit Signing -This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing - one Ed25519 key serves both authentication (push) and signing. +This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing, since one Ed25519 key serves both authentication (push) and signing. If you haven't generated a key and configured git yet, follow [host setup][host-setup] first. ## Why SSH Signing - **One key for everything**. Same `id_ed25519` you use for `git push` also signs commits. No GPG keyring, no expirations to chase. -- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both - register it twice on the SSH and GPG keys page. +- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both, so register it twice on the SSH and GPG keys page. - **Survives rotation cleanly**. When you rotate the key, update the `allowed_signers` file and old signatures still verify against the historical entry. ## Configuration -Per-user (host) git config - set once: +Per-user (host) git config, set once: ```shell git config --global gpg.format ssh @@ -22,7 +22,7 @@ git config --global tag.gpgsign true git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers ``` -The `allowed_signers` file is what `git verify-commit` consults - without it, signatures sign fine but verify as "unknown signer". Format: +The `allowed_signers` file is what `git verify-commit` consults. Without it, signatures sign fine but verify as "unknown signer". Format: ```text you@example.com namespaces="git" ssh-ed25519 AAAA... your_public_key_contents_here @@ -36,7 +36,7 @@ echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers ``` -If you collaborate with others, append their entries to the same file - each line maps an email to a public key. +If you collaborate with others, append their entries to the same file, where each line maps an email to a public key. ## Per-OS Setup Notes @@ -49,7 +49,7 @@ systemctl --user enable --now ssh-agent.socket ssh-add ~/.ssh/id_ed25519 ``` -The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it - most distros do this in `/etc/X11/Xsession.d` or systemd user environment. +The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it. Most distros do this in `/etc/X11/Xsession.d` or systemd user environment. For shells without systemd integration, fall back to ad-hoc agent in `~/.bashrc` or `~/.zshrc`: @@ -60,7 +60,7 @@ if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then fi ``` -WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL; do not try to forward an agent from the Windows side. +WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL, and do not try to forward an agent from the Windows side. ### macOS @@ -82,24 +82,24 @@ Host github.com AddKeysToAgent yes ``` -The Keychain prompt for the passphrase appears on first use after each reboot; subsequent sessions are silent. +The Keychain prompt for the passphrase appears on first use after each reboot, and subsequent sessions are silent. ### Windows (without WSL) Native Windows is **not supported** for the devcontainer setup in this repo. Use WSL2 instead. The reason: VS Code Dev Containers needs a Linux-like file system for the bind-mounts to behave consistently, and Docker Desktop's WSL2 backend is the supported path. -If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh` - but the bind-mounted devcontainer setup expects Linux/WSL2 paths. +If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh`, but the bind-mounted devcontainer setup expects Linux/WSL2 paths. ## Verify Signing -The `-S` flag and `-c gpg.format=ssh` override are explicit so the verification works even before `commit.gpgsign` and `gpg.format` are set globally - useful when verifying a fresh setup mid-configuration. +The `-S` flag and `-c gpg.format=ssh` override are explicit so the verification works even before `commit.gpgsign` and `gpg.format` are set globally, which is useful when verifying a fresh setup mid-configuration. ```shell git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" git log --show-signature -1 ``` -Expected output includes `Good "git" signature for <your-email>`. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup - most often `allowed_signers` is missing the entry, or the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. +Expected output includes `Good "git" signature for <your-email>`. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup. Most often `allowed_signers` is missing the entry, or the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. ## Inside the Devcontainer @@ -109,17 +109,17 @@ The container picks up: - The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. - The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. -If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this - see [devcontainer setup][devcontainer] for the rationale. +If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this. See [devcontainer setup][devcontainer] for the rationale. ## Troubleshooting -**`gpg.ssh.allowedSignersFile needs to be configured`** - Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. +**`gpg.ssh.allowedSignersFile needs to be configured`.** Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. -**`signing failed: no allowed signers`** - The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> ...` snippet. +**`signing failed: no allowed signers`.** The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> ...` snippet. -**Verifies on the host but not in the container** - The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts; on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. +**Verifies on the host but not in the container.** The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts, and on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. -**SSH agent says "could not open a connection"** - The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. +**SSH agent says "could not open a connection".** The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. <!-- Repo --> diff --git a/docs/token-cost.md b/docs/token-cost.md index a9493915..405698b8 100644 --- a/docs/token-cost.md +++ b/docs/token-cost.md @@ -19,7 +19,7 @@ A session's context grows monotonically and every request re-reads all of it at | Output | $528 | 10.4% | | Fresh input | $9 | 0.2% | -**Output verbosity is not the lever.** Output was 0.30% of prompt volume and 10.4% of cost, so instructing an agent to write less prose targets a tenth of the bill at best. The cache is also working correctly at a 98.4% read share - the problem is the volume being re-read, not the hit rate. +**Output verbosity is not the lever.** Output was 0.30% of prompt volume and 10.4% of cost, so instructing an agent to write less prose targets a tenth of the bill at best. The cache is also working correctly at a 98.4% read share. The problem is the volume being re-read, not the hit rate. ## The Root Cause: Sessions Outliving Their Task @@ -63,9 +63,9 @@ Two measured attributions. Both **overlap** with the counterfactual above rather | `CODESTYLE.md` | 56 | $32 | | `AUDIT.md` | 27 | $17 | -Median 199 requests remained in the session after such a read, p90 6,005, and only 36.9% of all reads used a range. `AGENTS.md` was **not** auto-loaded by any harness - the session baseline was 33,000 to 37,000 tokens with none of its text present - so the whole cost came from explicit whole-file reads. +Median 199 requests remained in the session after such a read, p90 6,005, and only 36.9% of all reads used a range. `AGENTS.md` was **not** auto-loaded by any harness (the session baseline was 33,000 to 37,000 tokens with none of its text present), so the whole cost came from explicit whole-file reads. -The rule text could not simply be cut: 16 of 18 sections were declared `fidelity: verbatim` and carried byte-identically to the fleet. So the file was split instead - `AGENTS.md` from 87,457 to 7,789 bytes as a router, with the rule text in [`GOVERNANCE.md`][governance] - and reading one section now costs about 3,200 bytes against 87,457. +The rule text could not simply be cut: 16 of 18 sections were declared `fidelity: verbatim` and carried byte-identically to the fleet. So the file was split instead (`AGENTS.md` from 87,457 to 7,789 bytes as a router, with the rule text in [`GOVERNANCE.md`][governance]), and reading one section now costs about 3,200 bytes against 87,457. ### GitHub orchestration: $938, 27% of the cache-read bill diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index f0ec9e7e..98a37f80 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -8,10 +8,10 @@ Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): - **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies two classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. - **Two marker-delimited blocks of host-wide content in `CLAUDE.md`**, loaded into every session on the machine (including ad-hoc work outside any project), installed and updated independently so one can change without rewriting the other. - - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `GitHub Write Safety` carries the same three write rules as behavioral guidance and mirrors the committed `GOVERNANCE.md` "Repository Boundaries and Write Safety". `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. + - The `agent-safety` block carries restrictions alone, so nothing in it can widen a permission. `Repository Boundaries and Write Safety` mirrors the committed `GOVERNANCE.md` section of the same name, carrying its write rules and its rule that a task works in its own checkout as behavioral guidance. `Data in Agent-Authored Text` mirrors `GOVERNANCE.md` "Representative Data in Agent-Authored Text", so an agent illustrates with constructed data rather than data observed on the machine. `Authorization Scope and Memory Hygiene` bounds how a granted permission is recorded and read. Each mirrors a rule that otherwise reaches fleet repos only. - The `fleet-bootstrap` block carries `Fleet Governance Entry Point`, which names the template repository and routes by the state a repository is actually in. It is separate precisely because it enables rather than restricts, and the safety block's own text says nothing in it widens a permission, so merging the two would contradict that. It is host-wide rather than per repository because the repositories that most need it are the ones carrying no instruction set to point the way, and it mirrors the byte-locked `AGENTS.md` "Fleet Bootstrap" section that a conformant repository carries. -The hook is the mechanical backstop. The CLAUDE.md rules and the carried GOVERNANCE.md rules are the behavioral layer. Prose alone is not enough, since the incident happened under prose rules, so both ship. The write rules have a hook behind them, and the other two are prose only, since no hook can see what a line of text means. +The hook is the mechanical backstop. The CLAUDE.md rules and the carried GOVERNANCE.md rules are the behavioral layer. Prose alone is not enough, since the incident happened under prose rules, so both ship. The GitHub write rules have a hook behind them, as do the git operations that bypass a repository protection, while which checkout a command belongs in, the data a line of text quotes, and how an authorization is recorded are prose only, since no hook can see any of the three. ## Install (Idempotent, Safe to Re-Run to Update) diff --git a/host-setup/agent-safety/claude-md-fleet.md b/host-setup/agent-safety/claude-md-fleet.md index 24c76102..aecf8b88 100644 --- a/host-setup/agent-safety/claude-md-fleet.md +++ b/host-setup/agent-safety/claude-md-fleet.md @@ -1,11 +1,12 @@ <!-- fleet-bootstrap v1 start --> ## Fleet Governance Entry Point (Any Project, Every Session) -Repositories under the `ptr727` owner are governed by a shared template at `github.com/ptr727/ProjectTemplate`, which holds the canonical rules, the machine-readable spec, the repository registry, and the procedures for standing a repository up and bringing one back to conformance. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up. This section is host-wide rather than per repository, deliberately, because the repositories that most need it are the ones carrying no instruction set to point the way, and a pointer that lives only inside a conformant repository is a pointer that is present exactly when it is not needed. A repository carrying its own `AGENTS.md` states the same routing in a byte-locked "Fleet Bootstrap" section, and the two are kept in sync on purpose. +Repositories under the `ptr727` owner are governed by a shared template at `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub, which holds the canonical rules, the machine-readable spec, the repository registry, and the procedures for standing a repository up and bringing one back to conformance. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up. This section is host-wide rather than per repository, deliberately, because the repositories that most need it are the ones carrying no instruction set to point the way, and a pointer that lives only inside a conformant repository is a pointer that is present exactly when it is not needed. A repository carrying its own `AGENTS.md` states the same routing in a byte-locked "Fleet Bootstrap" section, and the two are kept in sync on purpose. This section enables rather than restricts, so it is bounded by everything above it. It is not an authorization to write anywhere, and the write-safety rules govern every action it leads to. Reading the hub, reading any repository, and running an audit are reads, and they need nothing. A change to a repository is a write and follows the ordinary rules, meaning a branch, a pull request, and a maintainer's merge. - **Route by what the repository actually holds, not by what it should hold.** The two differ precisely when this matters. No repository yet means the hub's `STANDUP.md` from section 0, and note that nothing in that file creates the GitHub repository, which is an outward-facing write needing explicit permission. A missing or partial instruction set means carrying the baseline per that file's sections 1A and 2, since absent files are a baseline that never arrived rather than drift to re-vendor. An instruction set that is present, current or stale, means the hub's `AUDIT.md` end to end, then applying what it finds per its section 10. - **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. The same holds for a repository being audited, for both workflow models. - **The audit is read-only and produces a report.** It never edits the repository it measures, so a fix is a separate and reviewable change. An audit that reports drift and stops is half the procedure, and a conformance claim carrying no committed report is a claim nobody can check. +- **Reach the hub as a checkout of your own, fetched immediately before it is read.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a single file lifted out of the tree runs against whatever the caller happens to have. Work only in that checkout rather than in one that another task is using, since a blanket add, a hard reset, or a branch switch in a tree someone else is editing destroys work while every command is individually correct. <!-- fleet-bootstrap v1 end --> diff --git a/host-setup/agent-safety/claude-md-safety.md b/host-setup/agent-safety/claude-md-safety.md index 0b7c0bdd..79ce9928 100644 --- a/host-setup/agent-safety/claude-md-safety.md +++ b/host-setup/agent-safety/claude-md-safety.md @@ -1,11 +1,12 @@ <!-- agent-safety v1 start --> -## GitHub Write Safety (Any Project, Every Session) +## Repository Boundaries and Write Safety (Any Project, Every Session) -A `gh` / GitHub API write runs under the logged-in identity, so a mis-targeted write acts publicly as that account on someone else's repository, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) in every session on this machine, including ad-hoc work outside any project. Reads are unrestricted. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" states the same rules for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. The `gh-write-guard` PreToolUse hook enforces the mechanical half. +A `gh` / GitHub API write runs under the logged-in identity, so a mis-targeted write acts publicly as that account on someone else's repository, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) in every session on this machine, including ad-hoc work outside any project, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted. A committed repo's `GOVERNANCE.md` "Repository Boundaries and Write Safety" states the same rules for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. The `gh-write-guard` PreToolUse hook enforces the mechanical half. - **Write only within the owner of the current checkout's repository.** Every state-changing call targets this checkout's `origin` or a sibling repository under the same owner. A broad or logged-in identity is capability, not permission. A repository under a **different owner** needs explicit human permission naming it, set in `GH_WRITE_GUARD_ALLOW` before the session starts rather than granted by the agent to itself, and a "harmless test" write is still a write. - **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a write consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the same session into a variable and passed from there. Ids resolve globally, so a wrong-but-valid id does not fail. It writes to the wrong target, in another repository. If a query returns no id, stop rather than invent one. - **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works, and never append an output-discarding or force-success tail (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`) to a mutation. A write that appears to fail is verified, not assumed harmless, because it may have succeeded on the server. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree. A blanket `git add -A`, a `git reset --hard`, and a branch switch each cross the boundary while being correct in isolation, so a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited. A branch that changes when nothing you did changed it, or an edit of yours reverted with no conflict, means another task is live in that tree, and the response is to stop rather than to re-apply the edit. ## Data in Agent-Authored Text (Any Project, Every Session) diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py index 8f2e341d..93ac819a 100644 --- a/host-setup/agent-safety/gh-write-guard.py +++ b/host-setup/agent-safety/gh-write-guard.py @@ -35,7 +35,8 @@ from urllib.parse import quote # --- What counts as a GitHub write ------------------------------------------------------------------- -# gh subcommands that mutate. `gh api` is handled separately (it needs field/method inspection). +# The gh subcommands that mutate. +# The `gh api` command is handled separately, since it needs field and method inspection. _GH_WRITE_SUB = re.compile( r"""\bgh\s+(?: pr\s+(?:create|comment|close|merge|edit|review|reopen|ready|lock|unlock) @@ -49,7 +50,7 @@ ) _GH_API = re.compile(r"\bgh\s+api\b") _EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.I) -# gh api with a field flag defaults to POST even without -X, so it is a write. +# A gh api call with a field flag defaults to POST even without -X, so it is a write. _API_FIELD_FLAG = re.compile(r"(?:^|\s)(?:-f|-F|--field|--raw-field|--input)\b") _GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.S) _MUTATION = re.compile(r"\bmutation\b") @@ -58,11 +59,10 @@ _GIT_PUSH = re.compile(r"\bgit\b.*?\bpush\b", re.S) # --- Bypass-of-branch-rule detectors (Rule 4) -------------------------------------------------------- -# A git operation is denied when it would only succeed by bypassing an active branch rule - the harm is -# that the maintainer's admin identity CAN bypass, so a plain-looking push silently lands on a protected -# branch. The judgment is made against the branch's *live* rules (self-configuring: a code-style develop -# carries `pull_request` and is denied, a config-style develop does not and is allowed), except for the -# explicit-bypass flags below, which are the bypass by definition and need no query. +# A git operation is denied when it would only succeed by bypassing an active branch rule. +# The harm is that the maintainer's admin identity can bypass, so a plain-looking push silently lands on a protected branch. +# The judgment is made against the branch's *live* rules, which makes it self-configuring: a code-style develop carries `pull_request` and is denied, where a config-style develop does not and is allowed. +# The exception is the explicit-bypass flags below, which are the bypass by definition and need no query. # # Branches that fail CLOSED when their rules cannot be read - protected-by-default across every config. _PROTECTED_DEFAULT_ORDER = ("main", "master", "develop") @@ -71,27 +71,25 @@ _GH_ADMIN_MERGE = re.compile(r"\bgh\s+pr\s+merge\b[^\n|&;]*(?:^|\s)--admin\b") # --- Risk-pattern detectors -------------------------------------------------------------------------- -# Output-discard / force-success tails. Bare `2>&1` is NOT here: it merges stderr into stdout, leaving -# the output visible, so it is not suppression (and denying it would break `... 2>&1 | tee log`). +# Output-discard and force-success tails. +# A bare `2>&1` is deliberately not here, since it merges stderr into stdout and leaves the output visible, so it is not suppression, and denying it would break `... 2>&1 | tee log`. _SUPPRESS = re.compile(r">\s*/dev/null|&>\s*/dev/null|2>\s*/dev/null|\|\|\s*(?:true\b|echo\b|:)") -# A quoted argument value ("..." or '...'). Stripped before the suppression scan so a --body/--title -# that merely mentions `|| true` or `>/dev/null` as text is not mistaken for a real command tail. Real -# suppression tails are unquoted shell operators, so stripping quotes never hides an actual footgun. The -# double-quoted form allows `\"` escapes so an embedded quote does not end the span early; shell single -# quotes take no escapes, so their form is literal. +# A quoted argument value, in either double or single quotes. +# It is stripped before the suppression scan so a --body or --title that merely mentions `|| true` or `>/dev/null` as text is not mistaken for a real command tail. +# Real suppression tails are unquoted shell operators, so stripping quotes never hides an actual footgun. +# The double-quoted form allows `\"` escapes so an embedded quote does not end the span early. +# Shell single quotes take no escapes, so their form is literal. _QUOTED_SPAN = re.compile(r'"(?:\\.|[^"\\])*"' r"|'[^']*'") -# A GitHub global node id literal: an UPPERCASE prefix (PR_, PRRT_, IC_, BOT_, ...) + a long base64url -# body, or a legacy MD... base64 id. The uppercase prefix plus a >=12-char body keeps it from matching -# an ordinary underscored word in a reply body (e.g. body="fixed_the_thing_now", lowercase prefix). +# A GitHub global node id literal, being an uppercase prefix such as PR_, PRRT_, IC_ or BOT_ followed by a long base64url body, or a legacy MD-prefixed base64 id. +# The uppercase prefix plus a body of at least 12 characters keeps it from matching an ordinary underscored word in a reply body, such as body="fixed_the_thing_now" with its lowercase prefix. _NODE_ID_LITERAL = re.compile(r'^(?:[A-Z]{1,5}_[A-Za-z0-9_\-]{12,}|MD[A-Za-z0-9]{12,})$') # -F/-f name=VALUE, capturing the value - handles "quoted" and bare _FIELD_ASSIGN = re.compile(r"""(?:-F|-f|--field|--raw-field)\s+[A-Za-z_][\w]*=(?P<v>'[^']*'|"[^"]*"|\S+)""") -# Every spelling gh accepts for the target flag: `--repo x`, `--repo=x`, `-R x`, `-R=x`, and the attached -# short form `-Rx`. A form left out is not a near-miss, it is a silent bypass of the whole repository -# scope, so the separator is matched rather than assumed to be a space. The look-behind requires the flag -# to start a shell token (whitespace before it, or the string start), which is where a real flag always -# sits, so a value that opens a quoted span (`--title "-Rowner/repo"`) is not read as a target. -# A mention inside prose (`--title "use -Rowner/repo"`) IS still read as a target and still denies. +# Every spelling gh accepts for the target flag, being `--repo x`, `--repo=x`, `-R x`, `-R=x`, and the attached short form `-Rx`. +# A form left out is not a near-miss, it is a silent bypass of the whole repository scope, so the separator is matched rather than assumed to be a space. +# The look-behind requires the flag to start a shell token, meaning whitespace before it or the string start, which is where a real flag always sits. +# A value that opens a quoted span, as in `--title "-Rowner/repo"`, is therefore not read as a target. +# A mention inside prose, as in `--title "use -Rowner/repo"`, is still read as a target and still denies. # A space precedes it exactly as one precedes a real flag, so no look-behind can separate the two. # Telling a flag from text needs argv-position parsing, the way _push_targets does it for git push. _EXPLICIT_REPO = re.compile(r"(?<![^\s])(?:--repo[=\s]+|-R[=\s]*)(?P<q>['\"]?)(?P<r>[^\s'\"]+)(?P=q)") @@ -148,8 +146,8 @@ def _granted_targets(environ=None): def _target_permitted(target, origin, granted): """True when a write to target is in scope for a checkout whose origin is origin.""" - # Same owner covers the origin itself and every sibling repository, which is the case the maintainer - # works in daily. A different owner is the incident shape and needs the grant. + # Same owner covers the origin itself and every sibling repository, which is the case the maintainer works in daily. + # A different owner is the incident shape and needs the grant. if target[0] == origin[0]: return True return target in granted or (target[0], "*") in granted @@ -163,7 +161,7 @@ def _live_branch_rules(owner, repo, branch): """ try: r = subprocess.run( - # quote the branch: a name with `/` (feature/x) would otherwise split the API path. + # Quote the branch, since a name carrying `/`, such as feature/x, would otherwise split the API path. ["gh", "api", f"repos/{owner}/{repo}/rules/branches/{quote(branch, safe='')}", "--jq", "[.[].type]"], capture_output=True, text=True, timeout=10, ) @@ -195,27 +193,40 @@ def _current_push_branch(cwd): # Flags that consume the following token as a value, so the value is not a positional (remote/refspec). _PUSH_VALUE_FLAGS = {"-o", "--push-option", "--repo", "--receive-pack", "--exec"} -# git global options (before the subcommand) that consume the following token as their value. +# The git global options, which sit before the subcommand, that consume the following token as their value. _GIT_GLOBAL_VALUE_OPTS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"} -_SHELL_OP_CHARS = set("();<>|&") +# A newline ends a command exactly as `;` does, so it is an operator character here rather than whitespace. +# Read as whitespace it vanishes when tokenizing, and every token on a later line of a multi-line command is then read as one more argument of the first line's command. +# A backslash-newline continuation is folded to a space in `classify` before any of this runs, so every newline reaching the tokenizer is a real command separator. +# The string is the form shlex takes the set in, and the set is derived from it so the two cannot drift apart. +_PUNCTUATION_CHARS = "();<>|&\n" +_SHELL_OP_CHARS = set(_PUNCTUATION_CHARS) def _shell_tokens(cmd): - """Tokenize like a shell, isolating operator runs (`|`, `&&`, `;`, `>`, `2>&1`, ...) as their own - tokens even when glued to a word - so a `>` inside a quoted value stays part of that token while a - real redirection is separated. Degrades gracefully if the quoting cannot be parsed. + """Tokenize like a shell, isolating operator runs (`|`, `&&`, `;`, newline, `>`, `2>&1`, ...) as + their own tokens even when glued to a word - so a `>` or a newline inside a quoted value stays part + of that token while a real redirection or line break is separated. Degrades gracefully if the + quoting cannot be parsed. """ try: - lex = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lex = shlex.shlex(cmd, posix=True, punctuation_chars=_PUNCTUATION_CHARS) lex.whitespace_split = True + lex.whitespace = lex.whitespace.replace("\n", "") # A newline is an operator above rather than a gap between words. return list(lex) except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python - try: - return shlex.split(cmd, posix=True) - except ValueError: - return cmd.split() + # Neither fallback isolates an operator, so the lines are split here to keep the one thing this path must not lose, that a newline ends the command before it. + toks = [] + for i, line in enumerate(cmd.split("\n")): + if i: + toks.append("\n") + try: + toks.extend(shlex.split(line, posix=True)) + except ValueError: + toks.extend(line.split()) + return toks def _is_shell_op(tok): @@ -227,7 +238,7 @@ def _is_redir_op(tok): def _is_separator(tok): - return _is_shell_op(tok) and ">" not in tok and "<" not in tok # |, ||, &, &&, ;, (, ) + return _is_shell_op(tok) and ">" not in tok and "<" not in tok # |, ||, &, &&, ;, (, ), newline def _is_git_exe(tok): @@ -243,7 +254,8 @@ def _git_subcommand_arglists(cmd, sub): Keying off a real `git`->`<sub>` token sequence (git's value-taking global options skipped, an absolute-path or .exe git recognized) means the same invocation named inside a quoted --body forms no - such sequence, and a compound `<sub> A && <sub> B` yields two independent arg lists so both are seen. + such sequence, and a compound `<sub> A && <sub> B` yields two independent arg lists so both are seen, + whether the two are joined by `&&` or written on their own lines. """ toks = _shell_tokens(cmd) n = len(toks) @@ -265,7 +277,7 @@ def _git_subcommand_arglists(cmd, sub): while k < n: t = toks[k] if _is_separator(t): - break # a command separator (|, &&, ;) ends this git invocation + break # a command separator (|, &&, ;, newline) ends this git invocation if t.isdigit() and k + 1 < n and _is_redir_op(toks[k + 1]): k += 1 # a file-descriptor number before a redirection is shell syntax, not git argv continue @@ -313,7 +325,7 @@ def _push_targets(cmd, cwd=None, current_branch=None): else: positionals.append(t) i += 1 - # positionals are [remote, refspec...]; a lone positional is the remote (a bare push). + # The positionals are the remote followed by any refspecs, and a lone positional is the remote, meaning a bare push. refspecs = positionals[1:] if len(positionals) >= 2 else [] branches = [] for rs in refspecs: @@ -363,9 +375,9 @@ def _check_bypass_flags(cmd): "This uses `gh pr merge --admin`, which merges past required reviews and status checks using " "admin power - a bypass of the merge gate. Merge only when the gate is satisfied." + _handoff(cmd) ) - # --no-verify / commit -n skip the git hooks, so they only matter as an actual arg to a git commit or - # push (other tools use --no-verify for unrelated things; shlex keeps a quoted mention out of the argv). - # `-n` is --no-verify only for commit; `git push -n` is --dry-run. + # The --no-verify flag and `commit -n` skip the git hooks, so they only matter as an actual argument to a git commit or push. + # Other tools use --no-verify for unrelated things, and shlex keeps a quoted mention out of the argv. + # The `-n` form is --no-verify only for commit, since `git push -n` is --dry-run. commit_lists = _git_subcommand_arglists(cmd, "commit") push_lists = _push_arg_lists(cmd) commit_bypass = any(("--no-verify" in a) or ("-n" in a) for a in commit_lists) @@ -433,17 +445,16 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, stands in for the live branch-rules query, and environ stands in for the process environment the maintainer's grant is read from. """ - # Fold shell line-continuations so a multi-line Bash invocation (`gh pr merge 5 \<newline> --admin`) - # parses as one command; only backslash-newline is joined, so a real newline between commands still - # separates them. + # Fold shell line-continuations so a multi-line Bash invocation, such as `gh pr merge 5 \<newline> --admin`, parses as one command. + # Only backslash-newline is joined, so a real newline between commands still separates them. cmd = re.sub(r"\\\r?\n", " ", cmd) - # Rule 4: a git operation that would only succeed by bypassing an active branch rule. Checked before - # the gh-write gate below, since `git commit --no-verify` is a bypass yet not a GitHub write. + # Rule 4 covers a git operation that would only succeed by bypassing an active branch rule. + # It is checked before the gh-write gate below, since `git commit --no-verify` is a bypass yet not a GitHub write. dec, reason = _check_bypass_flags(cmd) if dec == "deny": return dec, reason - # `_push_targets` tokenizes with shlex and keys off a real `git push` argv adjacency, so a push named - # only inside a quoted argument yields no target - the raw substring is just a cheap pre-filter. + # The `_push_targets` helper tokenizes with shlex and keys off a real `git push` argv adjacency, so a push named only inside a quoted argument yields no target. + # The raw substring is just a cheap pre-filter. if _GIT_PUSH.search(cmd): dec, reason = _check_push_bypass(cmd, cwd, origin, current_branch, rules_lookup) if dec == "deny": @@ -483,8 +494,8 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, if origin is None: origin = _origin_owner_repo(cwd) targets = [] - # Every occurrence, not the first: a compound command carries one target per invocation, and reading - # only the first checks the harmless one while the write after `&&` goes unexamined. + # Every occurrence is read rather than the first, since a compound command carries one target per invocation. + # Reading only the first checks the harmless one while the write after `&&` goes unexamined. for mr in _EXPLICIT_REPO.finditer(cmd): val = mr.group("r") if "/" in val and "<" not in val: @@ -493,9 +504,8 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, for m in _API_REPO_PATH.finditer(cmd): if "<" not in m.group("owner"): targets.append((m.group("owner").lower(), m.group("repo").lower())) - # Only runs when origin resolves (a git checkout): with no project context there is nothing to - # compare an explicit target against, so this check is skipped and rules 1-2 still apply. A node-id - # target is invisible here regardless - that is what rule 2 guards. + # This only runs when origin resolves, meaning a git checkout, since with no project context there is nothing to compare an explicit target against, so the check is skipped and rules 1 and 2 still apply. + # A node-id target is invisible here regardless, which is what rule 2 guards. if origin: granted = _granted_targets(environ) for t in targets: @@ -542,8 +552,9 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, ("gh api graphql -f query='mutation{resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"TODO_fixit\"", "allow", "short all-caps token is not a node id"), ] -# Rule-3 (repository scope) cases. Each carries the environment the grant is read from, so the run never -# depends on the environment the self-test happens to inherit. Origin is ptr727/plexcleaner throughout. +# Rule-3 cases, covering repository scope. +# Each carries the environment the grant is read from, so the run never depends on the environment the self-test happens to inherit. +# Origin is ptr727/plexcleaner throughout. _SCOPE_CASES = [ # (command, environ, expected_decision, label) ("gh issue create --repo ptr727/PhotoCleaner --title x --body y", {}, "allow", "sibling repo under the same owner"), @@ -555,8 +566,8 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, ("gh issue comment 5 -R mankatcheung/job-finder --body hi", {_ALLOW_ENV: "esphome/*"}, "deny", "the incident: a grant for one owner does not reach another"), ("gh issue create --repo esphome/esphome --title x", {_ALLOW_ENV: "not-an-owner-repo"}, "deny", "a malformed grant grants nothing"), ("GH_WRITE_GUARD_ALLOW=esphome/esphome gh issue create --repo esphome/esphome --title x", {}, "deny", "an inline env prefix is part of the command, not the hook's environment"), - # Every spelling of the target flag. A form the extraction misses is a silent bypass of rule 3, not a - # near-miss, so each is asserted against a foreign owner that must deny. + # Every spelling of the target flag. + # A form the extraction misses is a silent bypass of rule 3 rather than a near-miss, so each is asserted against a foreign owner that must deny. ("gh issue create --repo=esphome/esphome --title x", {}, "deny", "--repo=value equals form"), ("gh issue create -R=esphome/esphome --title x", {}, "deny", "-R=value equals form"), ("gh issue create -Resphome/esphome --title x", {}, "deny", "-Rvalue attached short form"), @@ -565,9 +576,10 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, ("gh issue create --repo ptr727/PhotoCleaner --title \"-Resphome/esphome\"", {}, "allow", "a value opening a quoted span is not a flag"), ] -# Rule-4 (branch-rule bypass) cases. Each carries its own branch->rules map so the run is deterministic -# and offline - the real hook queries the live rules, here rules_lookup is injected. current_branch -# stands in for the git resolution of a bare push. `None` rules mean the query could not be read. +# Rule-4 cases, covering branch-rule bypass. +# Each carries its own branch-to-rules map so the run is deterministic and offline, where the real hook queries the live rules and here rules_lookup is injected. +# The current_branch value stands in for the git resolution of a bare push. +# A `None` rules value means the query could not be read. _CODE_RULES = {"deletion", "non_fast_forward", "required_linear_history", "required_signatures", "pull_request", "required_status_checks", "copilot_code_review"} # code-style develop / any main _CONFIG_RULES = {"deletion", "non_fast_forward", "required_signatures"} # config-style develop: no pull_request @@ -622,13 +634,21 @@ def classify(cmd, cwd=None, origin=None, current_branch=None, rules_lookup=None, ("gh issue comment 5 --body \"first git push\" && git push origin develop", None, {"develop": _CODE_RULES}, "deny", "a quoted mention before a real push does not hide the real target"), ("git push >push.log 2>&1", "develop", {"develop": _CODE_RULES}, "deny", "redirection tokens are not a branch: bare push to develop still denies"), ("git push origin develop >push.log 2>&1", None, {"develop": _CODE_RULES}, "deny", "redirect after a real refspec does not hide the develop target"), + # A newline ends a command as `&&` does, and reading it as whitespace made every token on a later line an argument of the push. + # A feature-branch push followed by a `gh pr create` then denied as a direct push to the base branch that command named. + ("git push -u origin feature/x\ngh pr create --base develop --title x --body y", None, {"feature/x": set(), "develop": _CODE_RULES}, "allow", "a newline ends the push argv: the pr-create base is not a push target"), + ("cd /repo\ngit push origin develop", None, {"develop": _CODE_RULES}, "deny", "a push on a later line is still parsed as a push"), + ("git push origin feature/x\ngit push origin develop", None, {"feature/x": set(), "develop": _CODE_RULES}, "deny", "a second push on the next line is checked: develop denies"), + ("git push \\\n origin develop", None, {"develop": _CODE_RULES}, "deny", "a backslash-newline is a continuation, not a separator: develop still parsed"), + ("gh issue comment 5 --body \"one line\ngit push origin develop\"", None, {"develop": _CODE_RULES}, "allow", "a newline inside a quoted body does not start a new command"), + # Unbalanced quoting is what actually reaches the degraded path, and the separator has to survive there too. + ("git push origin feature/x\ngit push origin develop 'unclosed", None, {"feature/x": set(), "develop": _CODE_RULES}, "deny", "the degraded path keeps the newline: a push on the next line is still read"), ] def _selftest(): - # Deterministic offline run: pin origin to ptr727/PlexCleaner (the incident repo) so the cross-origin - # case resolves without touching a real checkout. The gh-write cases inject empty rules + a feature - # current-branch so no case reaches the live branch-rules query. + # A deterministic offline run, pinning origin to ptr727/PlexCleaner, the incident repo, so the cross-origin case resolves without touching a real checkout. + # The gh-write cases inject empty rules and a feature current-branch so no case reaches the live branch-rules query. origin = ("ptr727", "plexcleaner") ok = True for cmd, want, label in _CASES: diff --git a/host-setup/agent-safety/install.ps1 b/host-setup/agent-safety/install.ps1 index 2274fea6..09d1f2f8 100644 --- a/host-setup/agent-safety/install.ps1 +++ b/host-setup/agent-safety/install.ps1 @@ -1,20 +1,21 @@ # Thin wrapper: run the cross-platform installer with a Python 3 (Windows). -# All logic lives in install.py so every OS runs one tested code path. Idempotent, safe to re-run. +# All logic lives in install.py so every OS runs one tested code path. +# It is idempotent and safe to re-run. # .\install.ps1 # $env:CLAUDE_HOME = "C:\path"; .\install.ps1 # override the target (testing) $ErrorActionPreference = "Stop" $here = Split-Path -Parent $MyInvocation.MyCommand.Path $script = Join-Path $here "install.py" -# Prefer launchers that are unambiguously Python 3. install.py and the hook use Python 3 syntax, so a -# bare `python` (Python 2 on some systems) is the last resort. +# Prefer launchers that are unambiguously Python 3. +# The installer and the hook use Python 3 syntax, so a bare `python`, which is Python 2 on some systems, is the last resort. if (Get-Command "py" -ErrorAction SilentlyContinue) { & py -3 $script @args } elseif (Get-Command "python3" -ErrorAction SilentlyContinue) { & python3 $script @args } elseif (Get-Command "python" -ErrorAction SilentlyContinue) { - # Verify a bare `python` is Python 3 before handing it Python 3 syntax - it is Python 2 on some setups, - # which would fail to parse install.py. py -3 and python3 above are Python 3 by construction. + # Verify a bare `python` is Python 3 before handing it Python 3 syntax, since it is Python 2 on some setups and would fail to parse install.py. + # The two branches above are Python 3 by construction, so only this one needs the check. & python -c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null if ($LASTEXITCODE -ne 0) { Write-Error "Found python on PATH but it is not Python 3 (tried py -3, python3, python). Install Python 3." diff --git a/host-setup/agent-safety/install.sh b/host-setup/agent-safety/install.sh index d52c88d0..0d89e3d4 100755 --- a/host-setup/agent-safety/install.sh +++ b/host-setup/agent-safety/install.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # Thin wrapper: run the cross-platform installer with a Python 3 (Linux / WSL / macOS / Proxmox). -# All logic lives in install.py so every OS runs one tested code path. Idempotent, safe to re-run. +# All logic lives in install.py so every OS runs one tested code path. +# It is idempotent and safe to re-run. # ./install.sh installs to ~/.claude # CLAUDE_HOME=/x ./install.sh overrides the target (testing) set -Eeuo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Pick the first candidate that is actually Python 3 - install.py and the hook use Python 3 syntax, so a -# bare `python` that is Python 2 must be rejected, not handed the script (it would fail on import). +# Pick the first candidate that is actually Python 3. +# The installer and the hook use Python 3 syntax, so a bare `python` that is Python 2 is rejected rather than handed the script, which would fail on import. py="" for c in python3 python; do if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)' 2>/dev/null; then diff --git a/repo-config/README.md b/repo-config/README.md index 857b572d..b0af33a1 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -19,7 +19,7 @@ The result is **exactly two rulesets named `develop` and `main`**, and the names ## Secrets -Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. +Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key, so the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores, and the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. ## Repo Settings diff --git a/reports/divergences.md b/reports/divergences.md index 9e9feb31..1ec747dc 100644 --- a/reports/divergences.md +++ b/reports/divergences.md @@ -6,8 +6,7 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur ### re-vendor -- **.markdownlint-cli2.jsonc** - AudioCleaner, aiopurpleair _(recorded PhotoCleaner now resolved)_ - Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical. -- **repo-config/configure.sh** - ESPHome-NonRoot, LanguageTags, NxWitness, VSCode-Server-DotNetCore, aiopurpleair, homeassistant-purpleair - The hub adopted the fleet check mode into the configure.sh canonical (apply plus a payload-driven check). These carry an older design that predates it (five an older check-mode fork with ruleset-*.json filenames and no registry, LanguageTags an older apply-only copy). Re-vendor the new canonical to converge them. The stale copies re-vendor mechanically. NxWitness needs no wrapper for its hardcoded secret checks, settled by measurement: its fork asserts four secret names, while spec/audit.py checks the spec/secrets.json baseline plus the registry requiredSecrets for that repo, which is a strict superset of the four and additionally covers CODECOV_TOKEN that the fork misses. Re-vendoring therefore loses no coverage and gains some. Five of the six carry ruleset-develop.json and ruleset-main.json against the canonical's develop.json and main.json, so each needs the payload migration before it can take the script at all, and a re-vendor without it leaves apply and check aborting on payloads that do not exist. +- **.markdownlint-cli2.jsonc** - AudioCleaner, PhotoCleaner, aiopurpleair - Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical. ### investigate @@ -15,42 +14,49 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur - **.github/workflows/publish-release.yml** (manifest gap) - Carried by some repos, absent from others, and varies widely (12 divergent, 5 absent). Needs a fidelity call (interface vs intent) and an appliesTo scope before tracking - it would surface many new findings. - **.github/workflows/validate-task.yml** (manifest gap) - As publish-release.yml (11 divergent, 9 absent): fidelity plus appliesTo decision pending. +### retire + +- **repo-config/configure.sh** (manifest gap) - The script is hub-hosted rather than carried, per GOVERNANCE.md "Hub-Hosted Tooling", so it is no longer a manifest entry and a downstream copy is retired rather than re-vendored. Every copy the fleet holds is the hub's content with nothing per-repo in it, and the six repos previously dispositioned for re-vendor (NxWitness, aiopurpleair, homeassistant-purpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore, LanguageTags) carried a fork predating the payload-driven check mode, which is the drift this removes rather than converges. Delete the copy as each repo is next visited and run the hub's script against the repo instead. The ruleset payloads and settings.json stay carried, since those are what the repo is audited against and what its own AUDIT.md diffs the live configuration against. + ### accepted -- **.editorconfig-checker.json** - HolidayLights, HomeAutomation-Config - Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim. +- **.editorconfig-checker.json** - HolidayLights _(recorded HomeAutomation-Config now resolved)_ - Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim. - **LICENSE** (manifest gap) - Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest. ## Untriaged - add a disposition to `spec/divergences.json` -- **AGENTS.md > Fleet Bootstrap** - **not carried** by AudioCleaner, Blog, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **AGENTS.md > Context and Delegation Discipline** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **AGENTS.md > Where the Rules Live** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) -- **GOVERNANCE.md > Representative Data in Agent-Authored Text** - **not carried** by Blog, Financial-Modeling, so the section never arrived rather than being edited (verbatim canonical) +- **AGENTS.md > Fleet Bootstrap** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **AGENTS.md > Context and Delegation Discipline** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **AGENTS.md > Where the Rules Live** - **not carried** by AudioCleaner, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair, so the section never arrived rather than being edited (verbatim canonical) +- **GOVERNANCE.md > Representative Data in Agent-Authored Text** - **not carried** by Financial-Modeling, so the section never arrived rather than being edited (verbatim canonical) +- **GOVERNANCE.md > Hub-Hosted Tooling** - **not carried** by Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner, so the section never arrived rather than being edited (verbatim canonical) ## Mechanical re-vendor (verbatim stale copies) A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. -- **AGENTS.md > Context and Delegation Discipline** (2): Blog, Financial-Modeling -- **AGENTS.md > Where the Rules Live** (2): Blog, Financial-Modeling +- **AGENTS.md > Context and Delegation Discipline** (1): Financial-Modeling +- **AGENTS.md > Where the Rules Live** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Foundational Principles** (1): Financial-Modeling - **GOVERNANCE.md > Durable Knowledge and Self-Improvement** (1): Financial-Modeling -- **GOVERNANCE.md > Repository Boundaries and Write Safety** (1): Financial-Modeling -- **GOVERNANCE.md > Git and Commit Rules** (2): Blog, Financial-Modeling -- **GOVERNANCE.md > Branching Model** (1): Financial-Modeling -- **GOVERNANCE.md > Release Model** (1): Financial-Modeling -- **GOVERNANCE.md > Operational Repositories** (1): Financial-Modeling +- **GOVERNANCE.md > Repository Boundaries and Write Safety** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **GOVERNANCE.md > Git and Commit Rules** (1): Financial-Modeling +- **GOVERNANCE.md > Branching Model** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **GOVERNANCE.md > Release Model** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **GOVERNANCE.md > Operational Repositories** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Pull Request Title and Commit Message Conventions** (1): Financial-Modeling -- **GOVERNANCE.md > Documentation Style Conventions** (1): Financial-Modeling -- **GOVERNANCE.md > Verification Discipline** (2): Blog, Financial-Modeling -- **GOVERNANCE.md > PR Review Etiquette** (2): Blog, Financial-Modeling -- **GOVERNANCE.md > Communicating with the User** (1): Financial-Modeling +- **GOVERNANCE.md > Documentation Style Conventions** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **GOVERNANCE.md > Verification Discipline** (1): Financial-Modeling +- **GOVERNANCE.md > PR Review Etiquette** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **GOVERNANCE.md > Communicating with the User** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Workflow YAML Conventions** (1): Financial-Modeling - **GOVERNANCE.md > Supported Development Platforms** (1): Financial-Modeling - **GOVERNANCE.md > Editor and Tasks** (1): Financial-Modeling -- **GOVERNANCE.md > Repository Details** (1): Financial-Modeling -- **.markdownlint-cli2.jsonc** (16): DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair -- **repo-config/configure.sh** (9): Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PhotoCleaner, PlexCleaner, Utilities, Vantage-Config +- **GOVERNANCE.md > Repository Details** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner +- **.markdownlint-cli2.jsonc** (18): Blog, DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, Financial-Modeling, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PhotoCleaner, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair +- **repo-config/develop.json** (5): Financial-Modeling, MediaTools, PhotoCleaner, PlexCleaner, Utilities +- **repo-config/operational/develop.json** (3): ESPHome-Config, HomeAssistant-Config, Vantage-Config +- **repo-config/main.json** (8): ESPHome-Config, Financial-Modeling, HomeAssistant-Config, MediaTools, PhotoCleaner, PlexCleaner, Utilities, Vantage-Config ## Promote candidates (intent uniform -> verbatim) diff --git a/scripts/README.md b/scripts/README.md index 868dfca9..923c05fe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -30,9 +30,9 @@ Run it scoped to changed lines, matching the standing rule that existing prose i python3 scripts/prose_lint.py . --diff origin/develop ``` -Whole-tree (`python3 scripts/prose_lint.py .`) reports the legacy backlog as well, which is informational rather than a gate. `charset`, `dupword` and `spelling` are clean tree-wide, so CI gates those three and reports the rest warn-only. +Whole-tree (`python3 scripts/prose_lint.py .`) reports the legacy backlog as well, which is informational rather than a gate. `charset`, `dupword`, `spelling`, `comment-wrap` and `comment-case` are clean tree-wide, so CI gates those five and reports the rest warn-only. -The default rule set covers comment shape (`comment-wrap` and `comment-case`) alongside the prose rules. It did not, which meant a run nobody parameterized reported clean on a wrapped comment while the rule read as enforced, and comment shape is the most frequently regressed rule in agent-authored work. Reading the backlog it exposes needs no flag now, and gating it still needs `--diff`, because the tree carries several hundred of them. +The default rule set covers comment shape (`comment-wrap` and `comment-case`) alongside the prose rules. It did not, which meant a run nobody parameterized reported clean on a wrapped comment while the rule read as enforced, and comment shape is the most frequently regressed rule in agent-authored work. Reading the backlog it exposes needs no flag now, and gating it needed `--diff` while the tree carried several hundred of them. That backlog is cleared, so both comment rules gate whole-tree, and `--diff` is now about scoping a run rather than about surviving one. A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it, and leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable. @@ -66,15 +66,19 @@ A string that spans lines carries its state onto the lines it covers, so a marke A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. +**A comment body that is one token closing on a colon is a key or a heading rather than a sentence**, and neither rule applies to it. A `# ignore:` heading a commented-out block is disabled configuration, so the capitalization `comment-case` asks for corrupts the key a reader uncomments, and the rule damages the file it was pointed at. The token count carries the test, because a colon ending real prose always has words before it. Measured over this repo, the shape matches exactly two bodies, both of them a key or a heading, where the broader reading of a body with no whitespace at all covers 99 and sweeps in every reference URI and version marker with them. + +**A label opening a definition is exempt from `comment-case`, because the lowercase word is the name being defined.** A `# publish - 'true' when ...` documents an output named `publish`, and capitalizing it renames the output the workflow declares. This is the comment spelling of the `- **Label** - text` construct the dash rule already exempts, and it is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found: a comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence, since a continuation whose first word takes a spaced dash is a parenthetical, which is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit: the first design read any comment body and would have exempted both. + **A comment whose whole body is a URI is a reference rather than a sentence**, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else. -`charset` and `dupword` are clean tree-wide and gate CI. `charset-unknown`, `semicolon`, `dash`, `comment-wrap`, and `comment-case` run as one warn-only CI step, so the backlog is visible without blocking and is corrected as each file is next edited. +`charset`, `dupword`, `spelling`, `comment-wrap`, and `comment-case` are clean tree-wide and gate CI. `charset-unknown`, `semicolon`, and `dash` run as one warn-only CI step, so the remaining backlog is visible without blocking and is corrected as each file is next edited, or cleared in a deliberate batch. ## `repo_gate.py` Two deterministic checks: -- `sha-pin`: every workflow `uses:` is a 40-hex commit SHA, with the one documented `dotnet/nbgv@master` exception allowed. +- `sha-pin`: every workflow `uses:` naming an action is a 40-hex commit SHA that resolves, with the one documented `dotnet/nbgv@master` exception allowed. A local reusable workflow (`./.github/workflows/...`) names no action and carries no ref to pin, so it is skipped. - `eol`: every path pinned LF in [`.gitattributes`][gitattributes] has the matching [`.editorconfig`][editorconfig] override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only: an `.editorconfig` LF glob with no git pin is legitimate, since `.editorconfig` governs what the editor writes where git enforces a class it must not guess at. ```sh @@ -82,31 +86,66 @@ python3 scripts/repo_gate.py python3 scripts/repo_gate.py --check sha-pin ``` +**`sha-pin` resolves the pin as well as reading its shape**, because forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a failure this repo has seen rather than a hypothetical one. The `gh-write-guard` hook cannot cover it: the hook watches Bash, and an editor tool writing the same string into a file never reaches it. Resolving also catches the neighboring case, a pin whose commit was reachable only from a branch since squashed and deleted, which breaks a downstream gate long after the change that caused it. + +The resolution pass is **scoped to the scanned repository's own owner**, which is where the fleet's actions live and where that decay comes from, since a squash merge deletes the branch a pin was taken from and the pin outlives the commit. A third-party action's tag is stable by comparison, and reading one would make every local run of this gate depend on a stranger's repository answering. The cost is real and is stated rather than left to be found: a fabricated pin on a third-party action is still only shape-checked. Every run therefore prints what the pass actually covered, because a scope that resolves nothing prints the same `0 issue(s)` a full sweep does. On the hub today it covers nothing, since this repo's own `uses:` refs are all under other owners and the fleet's `ptr727` pins live in the downstream repos that consume [`prose-gate`][prose-gate-action]. Those are reached by running this gate from a hub checkout with `--root`, per the hosted-and-reached model above. + +A pin is a finding only where GitHub **answered** that the commit is absent, meaning a 404 or a 422. An offline host, a missing `gh`, a rate limit and a narrow token all report as unread and leave the pin on its shape, so the gate stays usable with no network instead of failing a correct tree. A 404 is confirmed against the repository itself before it becomes a finding, since an absent commit and a repository the credentials cannot see are the same answer from here, and a repository-scoped CI token is narrower than the fleet. That second read runs only on the failing path, and each distinct pin is read once however often it repeats. + +A `note:` line is how a check says it did less than its name. It prints under the check's own result, outside the issue count, and never changes the exit code, because nothing is wrong with the tree when the network is what is missing. It carries one fixed shape every run so a zero is as visible as a count, and it separates a pin under a known other owner from one whose owner could not be compared at all, since a checkout with no readable `origin` skips every pin including this owner's own and reporting those as somebody else's is the same false clean the note was added to prevent. + 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. ## `pr_review.py` -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, and `wait` runs the backoff in-process so a long review wait costs one agent turn instead of one per poll. Read-only by design: the mutations (re-request, reply, resolve) stay as explicit `gh` calls so they remain visible to the `gh-write-guard` hook and to review, and their runbook is in [`.github/copilot-instructions.md`][copilot-instructions]. +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]. ```sh python3 scripts/pr_review.py status 452 --repo ptr727/ProjectTemplate +python3 scripts/pr_review.py claims 452 --repo ptr727/ProjectTemplate python3 scripts/pr_review.py wait 452 --repo ptr727/ProjectTemplate --timeout 2700 +python3 scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \ + --match "retry count is off by one" --body "Fixed in abc1234: the loop now stops at n." --resolve ``` `--repo` is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with `repo=OWNER/NAME` for the same reason, since a number alone reads as correct in any repository. A value that is not `OWNER/NAME` is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits. -`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another asserts no mutation has crept into a read-only script. +`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the two mutations `reply` owns, so a third arriving is a write nobody reviewed as one. `wait` exits `40` when Copilot answers the request with a plain comment rather than a review, meaning a comment of its own that postdates its newest review on the pull request. The test is the **shape** of that answer and not its cause, which the script reads nothing of: a comment carries no commit, so it satisfies no coverage check whatever it says, and a wait reading formal reviews alone treats it as an unmet condition and then polls out its whole timeout against an answer that already arrived. A refusal is the case that makes this worth catching, a quota or rate-limit message among them, and `40` neither asserts nor detects one. The comment prints whole because its wording is the only thing separating a refusal, which is terminal since no review follows it and re-requesting does not clear it, from an ordinary remark that is not, so `40` ends the wait and hands the text to the reader who can tell them apart. A comment **older** than the newest review is spent rather than terminal, because the review it preceded did land. Every connection reads the newest `WINDOW` nodes rather than the reviewer's own, since GraphQL offers no author filter, so ordinary traffic is what pushes theirs out of reach. `window_blind` is the one guard over both sides, and each side fails differently. Blind on **comments** means an answer could be back there unseen, which reads as `answered_outside_review=unknown` rather than `no`. Blind on **reviews** is worse, because the newest review in view is then not the newest there is, and an empty baseline dates every comment as newer so each one reads as an answer: a false `40` that stops the loop on a pull request whose review actually landed. That case reports nothing and lets the wait keep polling, since a wait that runs on is visible where a wrong terminal is not. -Everything else is decidable and says so. One of the reviewer's own nodes in view, even a **spent** one, settles the question, because nodes arrive in creation order, so anything behind the window is older than everything inside it. A window holding every node the pull request has is settled too, which is why the guard reads `pageInfo.hasPreviousPage` rather than the node count: a full window and a complete one are the same length, so length alone would report a gap where none exists. Cases hold `WINDOW` equal across all four windows and hold all four to asking for `hasPreviousPage`, since a connection that stops asking reports `no` instead of `unknown`, the silent narrowing one level up. `wait` exits `50` when the reviewer sits in the pending request set and no `copilot_work_started` follows the newest request, meaning nothing is acting on it and waiting on will not start it. That state is invisible from the reviews alone and indistinguishable from patience: one request sat thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate it from a slow round either, so the pickup event decides. It is the one thing here read over REST, since no GraphQL timeline item carries it, and it runs on its own interval rather than per poll: the first read comes after `--pickup-grace` (default five minutes), because inside that window a pending request is simply a review being worked on, and each later read waits another interval. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so a long wait costs a handful of REST calls instead of one per poll. The pickup is checked **before** the timeout, so the stall reports as itself instead of as `PENDING` once the clock runs out. Recovery stays out of this script, which holds its no-mutation contract: the digest names the state and the runbook carries the two mutations that clear and re-raise the request. The pending set is read through GraphQL rather than `gh pr view --json reviewRequests`, which omits a Bot reviewer outright and reports an empty set while Copilot sits in it. +Everything else is decidable and says so. One of the reviewer's own nodes in view, even a **spent** one, settles the question, because nodes arrive in creation order, so anything behind the window is older than everything inside it. A window holding every node the pull request has is settled too, which is why the guard reads `pageInfo.hasPreviousPage` rather than the node count: a full window and a complete one are the same length, so length alone would report a gap where none exists. Cases hold `WINDOW` equal across all four windows and hold all four to asking for `hasPreviousPage`, since a connection that stops asking reports `no` instead of `unknown`, the silent narrowing one level up. `wait` exits `50` when the reviewer sits in the pending request set and no `copilot_work_started` follows the newest request, meaning nothing is acting on it and waiting on will not start it. That state is invisible from the reviews alone and indistinguishable from patience: one request sat thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate it from a slow round either, so the pickup event decides. It is the one thing here read over REST, since no GraphQL timeline item carries it, and it runs on its own interval rather than per poll: the first read comes after `--pickup-grace` (default five minutes), because inside that window a pending request is simply a review being worked on, and each later read waits another interval. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so a long wait costs a handful of REST calls instead of one per poll. The pickup is checked **before** the timeout, so the stall reports as itself instead of as `PENDING` once the clock runs out. Recovery stays out of this script: the digest names the state and the runbook carries the two mutations that clear and re-raise the request, neither of which has an id to hide and so neither of which is worth wrapping. The pending set is read through GraphQL rather than `gh pr view --json reviewRequests`, which omits a Bot reviewer outright and reports an empty set while Copilot sits in it. `wait` exits `41` when the review carrying the head is a **refusal**, meaning its body opens by saying it did not review. That answer arrives as a formal review, `state: COMMENTED`, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The `40` reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as `rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN` and was one command from merging on a review that never ran. A refusal is therefore not coverage: `review_on_head` reports `NO`, the summary line carries a `refusal=YES` of its own, since `rounds=1 review_on_head=NO` is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. The script reads neither cause, only that the round declined. The match is on the body's **opening line**, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The reading is **head-scoped**, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it, coverage that landed being coverage. The field is spent by that coverage as well as the exit code is, or the summary line reads `review_on_head=yes refusal=YES` and tells a reader to split a pull request the reviewer has just reviewed. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading. +`status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. + +The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. + +The line is matched at its **start** rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the `Review details` bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading is **head-scoped**, unlike a suppressed finding and like a refusal, because a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one, and where one head carries two rounds through a re-request the worst of them reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions. + +`status` and `wait` both exit `43` where the reviewer sent a **shape this script has no reader for**, and that outcome outranks every other reading here, because a reader that does not understand the output cannot be believed about what it found in it. Every rule in this script keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. All three failures on record have that exact shape: the suppressed heading reworded and the count went to zero, the suppressed section moved inside another wrapper and the count went to zero again, and the coverage line was never parsed at all. Each reported a clean pass over a review it had misread, and each was found by the maintainer afterwards rather than by the gate. The digest carries `shapes=UNRECOGNIZED`, lists each shape it could not place, and names the remedy in two parts: **file an issue on the hub repository that hosts this script**, quoting the body the shape came from, and leave the merge decision to the maintainer, since an unrecognized shape does not say the pull request is bad, only that nothing here can vouch for the review of it. + +The vetted inventory is measured rather than imagined, and it is small because the output is regular. Across those 332 bodies, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six `<summary>` texts and three metadata labels. Counts are normalized to `(N)` and the verdict headings' colored circle is dropped before comparing, since both change on every review without the section having changed, and dropping the emoji is also what keeps this repository's charset rule satisfied. A body carrying **no** heading at all is itself unrecognized, which is what catches a rewrite that changes every marker at once, and a **refusal is exempt** because it is a bare paragraph by design and `REFUSAL` is its vetted spelling. That exemption is the pattern rather than a carve-out, so a refusal reworded stops being exempt and blocks, which is the refusal check's own failure mode caught one rewording later. The last reading is the quietest: a reviewer **login** that reads as this reviewer without being the spelling every query filters on, since a rename leaves every filter matching nothing and the digest then reports a review that landed as no review at all. A case runs the whole inventory over the measured corpus, where it raises nothing. + The timeout path prints the full digest for the same reason, as a bare `PENDING` line reports a slow reviewer and a broken poll identically, which is the reading that turns a stalled watcher into a watcher nobody notices is stalled. The digest also reports the **suppressed findings** a review body collapses into a `<details>` block. Those reach no review thread, so a loop that polls threads alone reports a clean pass while they stand, and the [merge gate][governance] counts them as outstanding findings either way. `suppressed=N` counts findings rather than blocks, reading the `(N)` the heading carries, since one body holds one block per round and counting blocks reports two findings as one. It covers **every** round rather than the current head, because a suppressed finding has no resolved state for a push to retire: head-scoping read "superseded by a push" as "answered", and a finding nobody replied to left the digest the moment the branch moved, so the run reported zero. That is how four rounds went unanswered across three pull requests in one day, each found by the maintainer rather than by this script. The summary line splits the count as `suppressed=N (on_head=N earlier=N)` and each block is marked with the round that raised it, since a finding on an older round may since be moot and deciding that is the reader's call rather than one the count should make for them. Each block prints whole where a thread body truncates, because a thread can be re-read at its id and a suppressed finding cannot, and it prints under a marker naming what closing it takes: no thread exists to reply on or resolve, so the answer goes in the PR conversation. +`reply` posts one answer and resolves one thread, and it exists because the hand-run form keeps failing the same way rather than because a wrapper is tidier. Three instances are on record, each an agent that had read the rule against hand-typing a node id and reached for the literal regardless, the last of them refused by the `gh-write-guard` hook an hour after quoting that same rule in a pull request body. A shape that fails while the reader knows the rule is a shape to remove rather than a rule to restate, so the selector is the finding's own words and there is deliberately no argument a `PRRT_...` id fits in: the id is read from the query in the same run and passed straight to the mutation, and a case asserts the id the mutation carries is the one that run just read. The words are matched against the thread's opening comment rather than against a line number, because a fix push moves the line and every lookup keyed to one then misses, which is how three replies posted against nothing while the resolves still succeeded and closed the threads carrying no answer. Matching is case-insensitive, since the text is quoted back out of a digest by a reader. + +Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits `60` and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit `61` and print both candidates rather than taking the first, `head -n 1` being how a reply lands on the wrong finding. A reply whose response carries no `url`, or a body that came back empty, exits `62` **without** resolving. A resolve that does not report `isResolved` exits `63`, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. `--resolve` is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. A target under an owner other than this checkout's exits `64` before anything is read at all, and that refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit `gh` path where the hook reads the maintainer's grant from the session instead. + +What this trades away is stated rather than glossed. A mutation spelled as a `gh` command in a shell is read by the `gh-write-guard` hook and one this script performs is not, since the hook sees `python3 pr_review.py reply` and no `gh` write. That is a real loss of a second pair of eyes, and it is taken because what the hook guards against there is a fabricated id, which this removes at the source instead of catching after the fact. The guard's other rule is re-implemented here rather than assumed: the owner check above is the same scope rule, enforced in-process, and it is honest that it stops a mistake rather than a determined caller. The whole-source guard against every other state-changing call stays and was narrowed to these two documents rather than dropped when the first of them arrived. + +`claims` checks that a description does not contradict its own branch, and it exists for the same reason `sha-pin` now resolves: a reference that points at nothing is a silent failure caught by a reviewer or not at all. Three stale descriptions in one session generated six review findings between them, each a reviewer noticing that the body named a commit or a behavior the branch no longer carried. It reads the commits a body **claims** the branch carries and the `uses:` refs it quotes, confirms the head tree still carries each, and exits `70` where one does not. Prose claims stay **out of scope**, since judging those needs a similarity heuristic, which [`spec/section-model.md`][section-model] rejects for exactly the reason it would fail here. + +**Scanning for bare SHAs was built first and the corpus rejected it.** Over the 25 most recent merged pull requests it raised four findings, and every one was correct prose: a `develop` commit named as history, a SHA inside a pasted digest, and two commits in `ptr727/Blog` written without a URL. Nothing in the *form* of a bare SHA separates those from a real claim, and separating them by meaning is the heuristic already ruled out above. A path arm was measured on the same corpus and is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames, `origin/develop`, and other repositories. What survives is the verb: a commit counts only where the body says it was fixed, landed, shipped, added, introduced, corrected, resolved, carried or amended in it. That alternation raises exactly one reference over the same 25, and that one is true. It is an **inclusion** list, so a phrasing nobody thought of costs a detection rather than producing a finding, which is the direction to be incomplete in, and a claimed SHA still has to carry a digit as a backstop on the list growing later, since `accede` and `defaced` inflect into all-hex English words. + +A commit passes on **ancestry** rather than on membership of the branch's own commits, so a description may cite a commit it inherited from the base branch. A commit the repository does not carry at all and a commit this head does not descend from are both findings, and each names which it is, since the first is an amended-away SHA and the second is a branch cut elsewhere. The `uses:` refs are checked against the whole tree at head rather than a guessed set of workflow paths, because this repo carries `uses:` lines in catalog snippets and in documentation as well as under `.github/`, and a narrower surface would report a ref absent because it looked in the wrong place. One archive is also one request, where walking a listing costs a request per file and grows with the repository, and the match is on bytes so a file that does not decode is searched rather than skipped. + +Undecided is a third answer here for the reason it is one in `repo_gate.py`. A reference GitHub did not answer for is left undecided rather than reported stale, and where **every** reference is undecided the run exits `71` and says so, because `stale=0` from a check that read nothing renders exactly like `stale=0` from one that did. + The match is on the block's heading rather than anywhere in the body, and on the runbook's alternation rather than on one phrasing, since the wording has already appeared two ways. A case asserts the script's pattern is the one the runbook publishes rather than a copy of it that can drift. Reading the whole body was the first implementation and its own review caught it: a review whose overview prose discusses suppressed findings carries none, and reporting that as a finding trains the reader to skim the field. A heading outside any `<details>` wrapper is still read, because reporting zero when the markup moves is the same false clean one level up, and that fallback takes a count so ordinary prose does not become one. <!-- Internal --> @@ -119,3 +158,4 @@ The match is on the block's heading rather than anywhere in the body, and on the [governance]: ../GOVERNANCE.md [governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling [prose-gate-action]: ../.github/actions/prose-gate/action.yml +[section-model]: ../spec/section-model.md diff --git a/scripts/pr_review.py b/scripts/pr_review.py index 95161235..2e1a47a8 100644 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -8,7 +8,29 @@ Discipline" for the rule this implements. Subcommands + claims Check the description against the branch it describes. A body claiming a commit or + quoting a `uses:` ref the head tree no longer carries is a silent failure caught by a + reviewer or not at all, and three stale descriptions in one session generated six + review findings between them. Read-only. Exit 0 = every reference resolves, 70 = one + does not, 71 = there were references and none could be read, so nothing was decided. status One digest line, any unresolved threads, and any suppressed findings. Read-only. + Exit 0 = every shape in the reviewer's output is one this reads, and the round + covering the head read the whole diff or stated nothing about what it read. + 42 = that round read fewer files than the pull request changed, so part of the diff + has no review at all, and the remedy is a re-request or a split. + 43 = the reviewer sent a shape this script has no reader for, so no field here can + be believed. The remedy is an issue on the repository hosting this script, and the + review loop does not close until the reader is fixed. Merging regardless is the + maintainer's decision rather than the agent's. + reply Answer one thread selected by its text, and resolve it on request. The only + writing path here, and it exists because the hand-run form keeps failing the + same way: a node id typed into a mutation, which resolves globally and so + writes to a real thread somewhere rather than failing. This takes a pull + request number and words from the finding, queries the id itself, and offers + no argument an id fits in. Exit 0 = done, 60 = no thread matched, 61 = more + than one did, 62 = the reply returned no comment url so nothing was resolved, + 63 = the resolve did not report the thread resolved, 64 = the target is under + another owner. wait Poll until Copilot's review lands on the current head, then print the digest. The loop runs in-process, so a 45-minute wait costs one agent turn, not 90. Exit 0 = review present, 30 = still pending at timeout (pending is not failure), @@ -16,16 +38,24 @@ 40 reports the shape of that answer and reads nothing of its cause: an answer carrying no commit covers no head, so the wait ends and the reader decides. 41 = the review carrying the head says it did not review, so it covers nothing. + 42 and 43 = the review landed and `status`'s two blocking readings apply to it, + since a wait ending on a round that covered half the diff, or on output nothing here + can read, has ended on something other than a review of this pull request. 50 = the request is pending and nothing picked it up, which no amount of waiting changes. Recovery is two mutations, and they stay in the runbook. -Read-only by design. Mutations (re-request review, reply, resolve thread) are -deliberately NOT implemented here - they are state-changing calls that must stay -visible to the gh-write-guard PreToolUse hook and to review. See -.github/copilot-instructions.md for the mutation runbook. +Reading is the bulk of this and `reply` is the one exception, which is a trade rather +than a free win. A mutation spelled as a `gh` command in a shell is read by the +gh-write-guard PreToolUse hook, and one this script performs is not, since the hook sees +`python3 pr_review.py reply` and no gh write. What the hook guards against there is a +fabricated id, and that is the failure this removes at the source instead: the id is +never in the caller's hands to fabricate. Re-requesting a review stays out, having no +such failure and no id to hide. See .github/copilot-instructions.md for the runbook, and +GOVERNANCE.md "Repository Boundaries and Write Safety" for the rules `reply` enforces. """ from __future__ import annotations -import argparse, json, re, subprocess, sys, time +import argparse, io, json, re, subprocess, sys, tarfile, time +from pathlib import Path REVIEWER = 'copilot-pull-request-reviewer' @@ -42,6 +72,86 @@ # It also keeps the published filter usable inside single quotes, which neither survives. REFUSAL = re.compile(r'wasn.t able to review|was not able to review|unable to review', re.IGNORECASE) +# A round states how much of the diff it read on a line of its own. +# A round that read part of it is the clean pass elsewhere, same commit and threads and digest. +# Five such rounds landed across three merged pull requests here. +# One of them read 2 of 3 changed files across both its rounds and merged. +# This is the third instance of the shape the two patterns above answer. +# It is also the only one nothing was reading. +# The line is anchored at its start rather than matched body-wide, both spellings being structural. +# Over 332 review bodies every coverage statement opens its line and not one sits mid-sentence. +# 272 of them open with the reviewer's own name and 32 are the `Review details` bullet. +# A body-wide match reports the pull request adding this check as a partial round. +# That is the false positive the suppressed matcher and the refusal matcher have each had once. +# The cost of the anchor is named rather than hidden. +# A wording that moves the statement off the line start reads as no statement at all. +# Two openers rather than one alternation, because the text each needs beside it differs. +# The bullet's own label is the marker, so it is a coverage line whatever follows the counts. +# Requiring the trailing words there made a bullet that drops them read as no statement at all. +# The sentence opener is the reviewer's name, which prose also opens a line with. +# That one keeps the text requirement, since the name alone does not say the line states coverage. +COVERAGE_BULLET = re.compile(r'\s*[-*]\s*\*\*Files reviewed:', re.IGNORECASE) +COVERAGE_SENTENCE = re.compile(r'\s*Copilot\b', re.IGNORECASE) +# The count pair itself, in the two spellings the corpus carries. +# The comment tail one of them ends on is deliberately not part of the unit. +# It says how many comments the round raised, which is not coverage. +# A fifth wording of it would fail every merge over a sentence ending read correctly. +# The plural is optional, since a one-file round reading `1 changed file` means what it says. +# Blocking on that is the cry-wolf case, a fleet-wide stop over a grammatical agreement. +# The bullet's trailing words are optional for the reason its label alone identifies the line. +# Detection and parsing disagreeing there turned a readable `4/4` into a block on a readable line. +# What is left blocking is a bullet carrying no counts, which genuinely states no coverage. +COVERAGE_COUNTS = re.compile( + r'reviewed\s+(\d+)\s+out of\s+(\d+)\s+changed files?' + r'|\*\*Files reviewed:\*\*\s*(\d+)\s*/\s*(\d+)(?:\s+changed files?)?', re.IGNORECASE) +# A fenced block is a quotation rather than a statement, and 131 of those bodies carry one. +# This change puts both spellings into the source and the runbook, so a review of it quotes them. +# A quoted count read as this round's own is a coverage figure nobody stated. +FENCE = re.compile(r'^ {0,3}```.*?^ {0,3}```[^\n]*', re.DOTALL | re.MULTILINE) +# The readings a round's coverage carries, worst first. +# A head carries more than one round only through a re-request. +# Where two disagree, the one naming files it did not read is the one to answer. +# `UNSTATED` sits last rather than beside the failures, being the absence of a statement. +# A round that did state full coverage settles the question over one that stated nothing. +UNVETTED, PARTIAL, FULL, UNSTATED = 'unvetted', 'partial', 'full', 'unstated' +SEVERITY = (UNVETTED, PARTIAL, FULL, UNSTATED) +# Upper-case for the two that block a merge, for the reason `review_on_head=NO` is upper-case. +# `unstated` rather than `unknown`, since a body carrying no count is a shape this knows. +# What this script does not know is the separate `shapes` field, and one word for both hides it. +# The constant carries that name too, so no reader has to map a name here onto another word. +COVERAGE_FIELD = {UNVETTED: 'UNVETTED', PARTIAL: 'PARTIAL', FULL: 'full', + UNSTATED: 'unstated'} + +# Every structural marker the reviewer's own bodies carry, measured over the same 332. +# A body is read for these rather than trusted, because every reader below keys on one of them. +# A heading this script has no spelling for is a section it will not find, reported as absent. +# That is the shape of all three failures already on record here, each caught after it landed. +# The lists are small because the output is regular: 7 headings, 6 summaries and 3 labels. +# Counts are normalized to `(N)` and non-ASCII is dropped before comparing. +# The verdict headings carry a colored circle, so the emoji is what would drift most cheaply. +# Dropping it also keeps this file inside the charset rule that governs the repository. +VETTED_HEADINGS = { + '## Pull request overview', '### Reviewed changes', '### Ready to approve', + '### Changes recommended', '### Not ready to approve', '### Human review recommended', + '### Suppressed comments (N)', +} +VETTED_SUMMARIES = { + 'Pull request overview', 'Show a summary per file', 'File summaries', 'Review details', + 'Suppressed comments (N)', 'Comments suppressed due to low confidence (N)', +} +VETTED_LABELS = {'Files reviewed', 'Comments generated', 'Review effort level'} +MARKDOWN_HEADING = re.compile(r'\s*#{1,6}\s') +# The `Review details` metadata bullets, of which the coverage line is one. +LABEL_LINE = re.compile(r'\s*[-*]\s+\*\*([^*]+):\*\*') +# A login that reads as this reviewer without being the spelling every query here filters on. +# A rename leaves every filter matching nothing, so a review that landed reads as none at all. +# A wait then polls out its whole timeout against a review sitting in plain sight. +# `copilot-swe-agent` is the coding agent rather than the reviewer, and does not match this. +READS_AS_REVIEWER = re.compile(r'copilot.*review', re.IGNORECASE) +# The repository this script is hosted in, named because an unrecognized shape is fixed here. +# It is a literal rather than the pull request's own repository. +# That one is where the shape was seen, not where the reader failing on it lives. +HUB = 'ptr727/ProjectTemplate' DETAILS = re.compile(r'<details>(.*?)</details>', re.DOTALL | re.IGNORECASE) SUMMARY = re.compile(r'<summary>(.*?)</summary>', re.DOTALL | re.IGNORECASE) TAGS = re.compile(r'</?(?:details|summary)>', re.IGNORECASE) @@ -96,15 +206,93 @@ """ -def gql(query: str, owner: str, repo: str, num: int) -> dict: - r = subprocess.run( - ['gh', 'api', 'graphql', '-f', f'query={query}', - '-F', f'o={owner}', '-F', f'r={repo}', '-F', f'n={num}'], - capture_output=True, text=True) +# The description and the branch it describes, read together so neither is stale against the other. +# No review or comment connection here, since a description is neither. +Q_CLAIMS = """ +query($o:String!,$r:String!,$n:Int!){ + repository(owner:$o,name:$r){ pullRequest(number:$n){ headRefOid body }}} +""" + +# A `uses:` reference quoted in a description, in the spelling a workflow writes it. +BODY_USES = re.compile(r'uses:\s*(?P<ref>[A-Za-z0-9_.\-]+/[^\s`"\']+@[^\s`"\']+)') +# A commit the description claims this branch carries, being a verb plus a SHA rather than a SHA. +# The free scan was built first and the corpus rejected it outright. +# Over 25 merged pull requests it raised four findings and every one was correct prose. +# Those were a develop commit named as history, a SHA in quoted output, and two in another repo. +# Nothing in the shape of a bare SHA separates one of those from a genuine claim. +# Separating them by meaning is the similarity heuristic `spec/section-model.md` rejects. +# The verb is what makes a SHA a claim about this branch rather than a mention of one. +# This alternation raises exactly one reference over the same 25, and that one is true. +# The vocabulary is an inclusion list, so a phrasing nobody thought of costs a detection. +# Being incomplete in that direction is the safe one, since it never invents a finding. +BODY_CLAIM = re.compile( + r'\b(?:fixed|landed|shipped|added|introduced|corrected|resolved|carried|amended)' + r'\s+(?:in|by|as)\s+`?(?P<sha>[0-9a-f]{7,40})`?', re.IGNORECASE) +# What `gh` prints when GitHub answered, as opposed to when nothing was reached at all. +HTTP_STATUS = re.compile(r'\(HTTP (\d{3})\)') +# The two GitHub returns for an object that is not there. +# A network error carries no status, and 401 and 403 are credentials and a rate limit. +# Reading one of those as absence reports a correct description as stale. +# Everything outside this set is therefore "not read" rather than "not there". +ABSENT = {'404', '422'} +# What a reference reads as where GitHub never answered for it. +UNREAD = 'unread' +# Longer than the ordinary read's, since this one downloads a repository rather than a field. +TARBALL_TIMEOUT = 120 + +# Threads for the reply path, paginated. +# A first page read as the whole set reports no match on a thread that is simply further along. +# `line` is read for the confirmation line rather than for matching, since a push moves it. +# That is how a reply keyed on a line number went to a thread that had shifted underneath it. +Q_THREADS = """ +query($o:String!,$r:String!,$n:Int!,$after:String){ + repository(owner:$o,name:$r){ pullRequest(number:$n){ + reviewThreads(first:100, after:$after){ + nodes{ id isResolved path line comments(first:1){ nodes{ author{login} body } } } + pageInfo{ hasNextPage endCursor } } + }}} +""" + +# The two mutations the runbook publishes, in the order it publishes them. +# `url` is fetched because it is the one field that says the reply carried a body. +# A reply that posted empty still returns a comment, and three did, each then resolved. +M_REPLY = """ +mutation($threadId:ID!,$body:String!){ + addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId, body:$body}){ + comment{ id url body } }} +""" +M_RESOLVE = """ +mutation($threadId:ID!){ + resolveReviewThread(input:{threadId:$threadId}){ thread{ id isResolved } }} +""" + + +def gh_graphql(query: str, **variables) -> dict: + """Run one GraphQL document and return its `data`, raising rather than reporting a blank. + + A string goes through `-f` and an int through `-F`, because `-F` infers a type from the text: + a reply body of `123` or `true` arrives as an Int or a Boolean and the mutation fails on a + type nobody passed it, and a body opening with `@` is read as a filename. + + `errors` is checked rather than trusted to the exit code, since a GraphQL document can fail + per-field while the request itself succeeds, and the caller would read the null that leaves. + """ + argv = ['gh', 'api', 'graphql', '-f', f'query={query}'] + for name, value in variables.items(): + argv += ['-F' if isinstance(value, int) else '-f', f'{name}={value}'] + r = subprocess.run(argv, capture_output=True, text=True) if r.returncode != 0: sys.stderr.write(r.stderr[:800]) raise SystemExit(f'gh graphql failed rc={r.returncode}') - return json.loads(r.stdout)['data']['repository']['pullRequest'] + payload = json.loads(r.stdout) + if payload.get('errors'): + sys.stderr.write(json.dumps(payload['errors'])[:800]) + raise SystemExit('gh graphql reported errors') + return payload['data'] + + +def gql(query: str, owner: str, repo: str, num: int) -> dict: + return gh_graphql(query, o=owner, r=repo, n=num)['repository']['pullRequest'] def timeline(owner: str, repo: str, num: int) -> list[tuple[str, str]]: @@ -245,22 +433,195 @@ def refusing_review(pr: dict) -> dict | None: return max(refusals, key=lambda n: n.get('submittedAt') or '') if refusals else None -def reviewed_head(pr: dict) -> bool: - """True where one of the reviewer's own reviews covers the current head's commit. +def head_reviews(pr: dict) -> list[dict]: + """The reviewer's own reviews that cover the current head, refusals excluded. A refusal is not coverage. It is a formal review, `state: COMMENTED`, carrying the head's commit and raising no threads, so it satisfies every check a clean pass does and renders a digest identical to one. That is how a pull request of 301 changed files, one over the reviewer's limit, sat one command from merging on a review that never ran. + One list rather than a predicate beside a filter, because coverage and the count of rounds on + the head are read from the same set and a second spelling of it drifts from the first. + """ + head = pr['headRefOid'] + return [n for n in reviewer_nodes(pr, 'reviews') + if (n.get('commit') or {}).get('oid') == head and not refusal_of(n)] + + +def reviewed_head(pr: dict) -> bool: + """True where one of the reviewer's own reviews covers the current head's commit. + The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate rather than a gap: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart. No exit code and no merge decision is taken from the liveness reading. """ - head = pr['headRefOid'] - return any((n.get('commit') or {}).get('oid') == head and not refusal_of(n) - for n in reviewer_nodes(pr, 'reviews')) + return bool(head_reviews(pr)) + + +def is_coverage_line(line: str) -> bool: + """Whether this line is the reviewer stating its file coverage, rather than prose about it.""" + if COVERAGE_BULLET.match(line): + return True + return bool(COVERAGE_SENTENCE.match(line)) and 'changed file' in line.lower() + + +def coverage_statements(body: str) -> list[str]: + """The lines this round states its file coverage on, quotations excluded.""" + return [ln.strip() for ln in FENCE.sub('', body or '').splitlines() if is_coverage_line(ln)] + + +def read_coverage(line: str) -> tuple[int, int] | None: + """The (reviewed, changed) counts the line states, or None where they cannot be believed. + + None covers a wording this has no vetted spelling for and a pair that cannot both be true + alike, since each leaves the same question unanswered and each takes the same remedy. A round + reporting it read more files than the pull request changed is not a round that read them all, + it is a line this script is parsing wrongly, and reading it as full coverage fails open on + exactly the statement that says something is off. + """ + m = COVERAGE_COUNTS.search(line) + if not m: + return None + reviewed, changed = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4)) + return (int(reviewed), int(changed)) if int(reviewed) <= int(changed) else None + + +def coverage_of(node: dict) -> tuple[str, str]: + """This round's coverage reading, with the line it was read from. + + A round making no statement at all reads as unstated rather than a pass or a failure. 28 of + the 332 bodies measured carry an overview and a change list and nothing more, that shape + interleaves with the counted one throughout rather than preceding it, and one pull request + carries both across its two rounds. Failing on it would cry wolf on roughly one review in + twelve, and a guard an agent learns to work around is worse than no guard. Passing it as + coverage is the bug this whole reading exists to remove, one shape over. + + A wording that is coverage-shaped and parses to no counts is the failure whose remedy is + fixing this script, since a gate that allows whatever it does not recognize stops gating as + the wording drifts, which it has done once already for each of the two patterns above. + """ + worst, detail = UNSTATED, '' + for line in coverage_statements(node.get('body') or ''): + counts = read_coverage(line) + state = UNVETTED if counts is None else (FULL if counts[0] == counts[1] else PARTIAL) + if SEVERITY.index(state) < SEVERITY.index(worst): + worst, detail = state, line + return worst, detail + + +def head_coverage(pr: dict) -> tuple[str, str]: + """The worst coverage the rounds covering the current head state, and the line saying it. + + Head-scoped for the reason a refusal is, and unlike a suppressed finding: a partial round is + a statement about one commit's diff, and the push that changes that diff raises a round which + reads the whole of the new one. A refusal needs no exemption of its own here, `head_reviews` + having dropped it already, and reading one would report the round that declined as a wording + this script fails to recognize. + """ + worst, detail = UNSTATED, '' + for node in head_reviews(pr): + state, line = coverage_of(node) + if SEVERITY.index(state) < SEVERITY.index(worst): + worst, detail = state, line + return worst, detail + + +def normal(text: str) -> str: + """A marker reduced to what a vetted list compares: ASCII, single spaces, counts as `(N)`. + + The verdict headings carry a colored circle and the suppressed heading carries its finding + count, so both drift on every review without the section having changed at all. + """ + ascii_only = ''.join(c for c in text if ord(c) < 128) + return re.sub(r'\s+', ' ', re.sub(r'\(\d+\)', '(N)', ascii_only)).strip() + + +def unrecognized_in(body: str) -> list[str]: + """Every marker in one review body this script has no vetted spelling for. + + Read over the body with fenced blocks removed, for the reason the coverage line is: a review + quoting a heading is not a review carrying one, and this script's own pull requests quote + these lists in full. + + A body carrying no heading at all is reported rather than passed, since every one of the 332 + measured opens on a heading and a body with none is a format nothing here has seen. A refusal + is the exemption, being a bare paragraph by design and already classified as one. + """ + # A refusal is a bare paragraph rather than a review body, and `REFUSAL` is its spelling. + # It carries no marker to check, and its own opening line is not a coverage statement. + # Its wording drifting is still caught, since `refusal_of` then stops matching. + # What is left of a drifted refusal is a body with no heading, which is the arm below. + if refusal_of({'body': body}): + return [] + plain = FENCE.sub('', body or '') + headings = [normal(ln) for ln in plain.splitlines() if MARKDOWN_HEADING.match(ln)] + labels = [normal(m.group(1)) for m in map(LABEL_LINE.match, plain.splitlines()) if m] + found = [f'heading: {h}' for h in dict.fromkeys(headings) if h not in VETTED_HEADINGS] + found += [f'summary: {normal(s)}' for s in dict.fromkeys(SUMMARY.findall(plain)) + if normal(s) not in VETTED_SUMMARIES] + found += [f'metadata label: {la}' for la in dict.fromkeys(labels) if la not in VETTED_LABELS] + found += [f'coverage line: {ln}' for ln in coverage_statements(body) + if read_coverage(ln) is None] + if not headings and not refusal_of({'body': body}): + found.append('body carrying no heading at all, which no measured review body does') + return found + + +def unrecognized_shapes(pr: dict) -> list[str]: + """Everything about this pull request's reviewer output that this script cannot read. + + Every round rather than the head's, because this asks whether the reader still understands + the reviewer rather than what the reviewer said about this commit. A shape that arrived one + round ago is one every later round will carry. + """ + found = [] + for node in reviewer_nodes(pr, 'reviews'): + where = ((node.get('commit') or {}).get('oid') or '')[:8] or 'commit unknown' + found += [f'{item} (round {where})' for item in + unrecognized_in(node.get('body') or '')] + return found + reviewer_login_drift(pr) + + +def reviewer_login_drift(pr: dict) -> list[str]: + """Logins that read as this reviewer without being the spelling every query here filters on. + + Read from the authors alone, so the liveness query answers it as well as the full one. That + is what lets the wait stop on a drift rather than poll its whole timeout out against a review + sitting in plain sight, which is the failure this reading exists to name. + """ + logins = {(n.get('author') or {}).get('login') or '' + for field in ('reviews', 'comments') + for n in ((pr.get(field) or {}).get('nodes') or [])} + return [f'reviewer login: {login}, where every query here filters on {REVIEWER}' + for login in sorted(logins) + if login != REVIEWER and READS_AS_REVIEWER.search(login)] + + +def report_verdict(pr: dict) -> int: + """Print the blocking verdict's own status line, and return the exit code it carries. + + The unrecognized shape outranks the coverage one, because a reader that does not understand + the output cannot be trusted about what it read of the diff either. + """ + # A coverage line this cannot parse is one of the shapes below rather than a case of its own. + # It exits here with the remedy that fits it, the reader being what needs the fix. + if unrecognized_shapes(pr): + print(f'status=UNRECOGNIZED_REVIEWER_OUTPUT this script does not know one or more shapes ' + f'in what the reviewer sent, listed above, so nothing it reports about this review ' + f'is trustworthy and the review loop does not close on this digest. File an issue ' + f'on {HUB} naming each shape and quoting the body it came from, since this script ' + f'is hosted there and the fix lands there. Merging this pull request anyway is the ' + f'maintainer\'s decision to take and not this script\'s, and not the agent\'s.') + return 43 + state, _ = head_coverage(pr) + if state == PARTIAL: + print('status=COVERAGE_IS_PARTIAL the review covering the head read fewer files than the ' + 'pull request changed, so part of the diff has no review at all: re-request on this ' + 'head, or split the pull request, rather than merging it') + return 42 + return 0 def live_state(owner: str, repo: str, num: int) -> tuple[str, bool, dict | None]: @@ -329,10 +690,11 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None, stalled = stall_of(owner, repo, num, pr) if stalled is None else stalled head = pr['headRefOid'] revs = reviewer_nodes(pr, 'reviews') - # A refusal carries the head and covers nothing, so it counts as a round and not as coverage. - # Reading it as coverage prints `review_on_head=yes` over a review that says it did not run. - on_head = [n for n in revs - if (n.get('commit') or {}).get('oid') == head and not refusal_of(n)] + # `revs` is every round and `on_head` is the ones that reviewed this commit. + # A refusal sits in the first and not the second, being a round that covered nothing. + on_head = head_reviews(pr) + cover, cover_line = head_coverage(pr) + unknown = unrecognized_shapes(pr) threads = pr['reviewThreads']['nodes'] # A deleted account leaves `author` present and null, which `.get('author', {})` returns as # None rather than as the default, so the chained lookup crashes the whole digest. @@ -364,6 +726,12 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None, # A digest of the wrong pull request is well-formed, so naming it is what shows the miss. f'repo={owner}/{repo} pr={num} head={head[:8]} rounds={len(revs)} ' f'review_on_head={"yes" if on_head else "NO"} ' + # A field of its own beside that one, since a round can cover the head and read part. + # Those two readings are what `review_on_head=yes` alone conflates. + f'coverage={COVERAGE_FIELD[cover]} ' + # Every other field on this line is a reading of the review. + # This one says whether the readings can be believed at all, so it is not a count. + f'shapes={"UNRECOGNIZED" if unknown else "ok"} ' # A field of its own, since `rounds=1 review_on_head=NO` is also what a stale round is. # The two want opposite responses, one a re-request and the other a split pull request. # Upper-case for the reason `NO` is, as a state that blocks a merge is not one to skim. @@ -384,6 +752,22 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None, 'it, and the body below is what says which remedy applies') lines += [f' {ln.rstrip()}' for ln in (refusal.get('body') or '').splitlines() if ln.strip()] + if unknown: + # First of the blocks, since it says how far the rest of them can be trusted. + lines.append(f' UNRECOGNIZED REVIEWER OUTPUT ({len(unknown)}): the shapes below are ones ' + 'this script has no reader for, so every other field here is a reading of ' + 'output it does not fully understand and a clean digest does not mean a ' + f'clean review. File an issue on {HUB}, which hosts this script, naming each ' + 'shape and quoting the body it came from, before closing the review loop. ' + 'Whether to merge anyway is the maintainer\'s call rather than the agent\'s') + lines += [f' {item}' for item in unknown] + if cover == PARTIAL: + # The line prints under the marker for the reason a suppressed block does. + # The counts say how much of the diff went unread, and no thread carries them. + lines.append(' COVERAGE IS PARTIAL: the review covering the head read fewer files than ' + 'the pull request changed, so files in the diff have no review at all and ' + 'the remedy is a re-request or a split rather than a merge') + lines.append(f' {cover_line}') if stalled: lines.append(f' REQUEST NOT PICKED UP (requested {stalled}, no copilot_work_started ' 'since): clear the request and re-request, per the runbook') @@ -432,9 +816,302 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None, return '\n'.join(lines), len(unresolved) +def origin_owner() -> str | None: + """The owner of the checkout this script sits in, or None where that cannot be read. + + Anchored on the script's own directory rather than the working directory, because this is + reached from a hub checkout while the repository being answered is named on the command line, + so the working directory says nothing about who owns either. + """ + try: + url = subprocess.run(['git', '-C', str(Path(__file__).resolve().parent), + 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=5).stdout.strip() + except Exception: + return None + m = re.search(r'[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$', url) + return m.group(1).lower() if m else None + + +def in_scope(target_owner: str) -> tuple[bool, str]: + """Whether writing to `target_owner` is in scope here, with the reason where it is not. + + Same owner covers the origin and every sibling, which is the whole fleet. A different owner is + the shape that once put a comment on a stranger's pull request, and it is refused outright + rather than granted by an environment variable, because a variable this process can be handed + is one the caller can set on the command that runs it, and a grant the caller writes for + itself is not a grant. The `gh-write-guard` hook reads that grant from the session it was + launched with, which is why the cross-owner case belongs on the runbook's explicit `gh` path + where the hook is the one adjudicating it. + """ + origin = origin_owner() + if origin is None: + return False, ('this checkout has no readable `origin`, so the owner a write would stay ' + 'within cannot be established, and an unverified scope is not a scope') + if target_owner.lower() != origin: + return False, (f'the target is under {target_owner}, and this checkout is under {origin}. ' + 'A different owner is the shape this refuses outright: take it through the ' + 'runbook mutations, where the write-guard hook reads the maintainer grant') + return True, '' + + +def first_comment(thread: dict) -> dict: + """The thread's opening comment, which is the finding itself.""" + return ((thread.get('comments') or {}).get('nodes') or [{}])[0] + + +def unresolved_threads(owner: str, repo: str, num: int) -> list[dict]: + """Every unresolved review thread, following the cursor to the end. + + Stopping at the first page reports no match on a thread that is merely further along, and a + no-match is indistinguishable from a thread that was already answered. + """ + out: list[dict] = [] + after = None + while True: + extra: dict[str, str] = {'after': after} if after else {} + conn = gh_graphql(Q_THREADS, o=owner, r=repo, n=num, + **extra)['repository']['pullRequest']['reviewThreads'] + out += [t for t in conn['nodes'] if not t['isResolved']] + page = conn.get('pageInfo') or {} + if not page.get('hasNextPage'): + return out + after = page['endCursor'] + + +def describe(thread: dict) -> str: + """One line naming a thread by what a reader recognizes it as, never by its id.""" + c = first_comment(thread) + body = ' '.join((c.get('body') or '').split()) + return (f'{thread.get("path")}:{thread.get("line")} ' + f'by {(c.get("author") or {}).get("login")}: {body[:120]}') + + +def matching_threads(threads: list[dict], match: str, path: str | None) -> list[dict]: + """Threads whose finding text contains `match`, narrowed by `path` where one is given. + + Matched on the finding's own words rather than on a line number, because a fix push moves the + line and every lookup keyed to one then misses: replies posted against nothing while the + resolves still succeeded, so the threads closed carrying no answer. Case-insensitive, since + the text is quoted back out of a digest by a reader rather than compared by a machine. + """ + needle = match.lower() + return [t for t in threads + if needle in (first_comment(t).get('body') or '').lower() + and (path is None or t.get('path') == path)] + + +def reply_to_thread(owner: str, repo: str, num: int, match: str, body: str, + path: str | None, resolve: bool) -> int: + """Answer the one thread `match` selects, and resolve it where asked. Returns an exit code. + + Every refusal below is a stop rather than a fallback. There is no id to guess at, no + second-best thread to settle for, and no resolve on a reply that did not land, because each + of those closes a finding while leaving it unanswered, which is the state a reviewer reads as + addressed. + """ + ok, why = in_scope(owner) + if not ok: + print(f'status=OUT_OF_SCOPE nothing was written: {why}') + return 64 + + threads = unresolved_threads(owner, repo, num) + hits = matching_threads(threads, match, path) + if not hits: + print(f'status=NO_MATCH nothing was written: no unresolved thread on {owner}/{repo} ' + f'#{num} carries {match!r}' + + (f' at {path}' if path else '') + + '. Widen the words or drop --path rather than reaching for an id, since the ' + 'thread may also be resolved already, which reads the same from here.') + for t in threads: + print(f' unresolved: {describe(t)}') + return 60 + if len(hits) > 1: + print(f'status=AMBIGUOUS nothing was written: {len(hits)} unresolved threads carry ' + f'{match!r}, and picking one of them is the failure this avoids rather than a ' + 'default it can take. Quote more of the finding, or add --path.') + for t in hits: + print(f' candidate: {describe(t)}') + return 61 + + target = hits[0] + print(f'answering: {describe(target)}') + reply = (gh_graphql(M_REPLY, threadId=target['id'], body=body) + .get('addPullRequestReviewThreadReply') or {}) + comment = reply.get('comment') or {} + # The url is what says a reply carried a body, and an empty one still returns a comment. + # Resolving past that closes the thread with nothing in it, which is what happened three times. + if not comment.get('url') or not (comment.get('body') or '').strip(): + print('status=REPLY_NOT_CONFIRMED the reply returned no url or an empty body, so the ' + 'thread is NOT resolved and the answer is not recorded. Read the response above ' + 'before retrying, since a write that appears to fail may have taken on the server.') + print(f' response: {json.dumps(reply)[:400]}') + return 62 + print(f'replied: {comment["url"]}') + + if not resolve: + print('status=REPLIED the thread is answered and left open, since --resolve was not ' + 'given. A decline is resolved only once its evidence is in the thread.') + return 0 + + thread = ((gh_graphql(M_RESOLVE, threadId=target['id']).get('resolveReviewThread') or {}) + .get('thread') or {}) + if not thread.get('isResolved'): + print('status=RESOLVE_NOT_CONFIRMED the reply landed and the resolve did not report the ' + 'thread resolved, so it is still open and the answer is already posted.') + print(f' response: {json.dumps(thread)[:400]}') + return 63 + print('status=REPLIED_AND_RESOLVED') + return 0 + + +def gh_rest(path: str, jq: str | None = None) -> subprocess.CompletedProcess: + """One REST read, returned whole so the caller can tell an absent object from an unread one. + + Unlike `gh_graphql` this does not raise on a non-zero exit, because a 404 here is an answer + the caller acts on rather than a failure. Reads only: every path passed in is a GET. + """ + argv = ['gh', 'api', path] + (['--jq', jq] if jq else []) + try: + return subprocess.run(argv, capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError): + return subprocess.CompletedProcess(argv, 1, '', 'gh could not be run') + + +def answered_absent(proc: subprocess.CompletedProcess) -> bool: + """Whether GitHub said the object is not there, as opposed to nothing having been read.""" + m = HTTP_STATUS.search(proc.stderr) + return bool(m) and m.group(1) in ABSENT + + +def body_references(body: str) -> tuple[list[str], list[str]]: + """The `uses:` refs and the claimed commits a description quotes, de-duplicated and sorted. + + A commit counts only where a verb claims this branch carries it. A SHA mentioned without one + is a mention of history, of another repository, or of quoted output, and the corpus above says + that is what a description's SHAs almost always are. + + Prose claims stay out for the same reason the free SHA scan did. Judging those needs a + similarity heuristic, which `spec/section-model.md` rejects. + + A claimed SHA still has to carry a digit, as a backstop on the vocabulary above: `accede` and + `defaced` inflect into all-hex English words, so a verb this list gains later cannot start + reading one of them as a commit. It costs about one real SHA in a thousand. + """ + uses = sorted({m.group('ref') for m in BODY_USES.finditer(body)}) + shas = sorted({m.group('sha') for m in BODY_CLAIM.finditer(body) + if any(c.isdigit() for c in m.group('sha'))}) + return uses, shas + + +def commit_state(owner: str, repo: str, sha: str, head: str) -> str: + """The empty string where `head` carries `sha`, `UNREAD` where nothing was read, else why not. + + Ancestry rather than membership of the branch's own commits, so a description may cite a + commit it inherited from the base branch. GitHub reports the comparison from the base's side, + so `identical` and `ahead` are the two readings that say the head carries it. + """ + proc = gh_rest(f'repos/{owner}/{repo}/compare/{sha}...{head}', '.status') + if proc.returncode == 0: + status = proc.stdout.strip() + if status in ('identical', 'ahead'): + return '' + return (f'{owner}/{repo} carries the commit and this head does not descend from it ' + f'({status})') + if answered_absent(proc): + return f'{owner}/{repo} carries no such commit' + return UNREAD + + +def head_carries(owner: str, repo: str, head: str, refs: list[str]) -> set[str] | None: + """Which of `refs` appear anywhere in the tree at `head`, or None where nothing was read. + + Read at the head commit rather than from a local checkout, because the branch being described + need not be fetched here and a working tree is not what a description is measured against. + + The whole tree rather than a guessed set of workflow paths: this repo carries `uses:` lines in + catalog snippets and in documentation as well as under `.github/`, and a surface narrower than + the tree would report a ref as absent because it looked in the wrong place. One archive is + also one request, where walking a listing costs a request per file and grows with the repo. + """ + # Read as bytes, so this cannot go through `gh_rest` and carries that helper's guards itself. + # An absent `gh` or a hung download reads as undecided, like every other unreadable answer. + # Raising instead would abort a run the caller is in the middle of. + try: + proc = subprocess.run(['gh', 'api', f'repos/{owner}/{repo}/tarball/{head}'], + capture_output=True, timeout=TARBALL_TIMEOUT) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + needles = {r: r.encode() for r in refs} + found: set[str] = set() + try: + with tarfile.open(fileobj=io.BytesIO(proc.stdout), mode='r:gz') as archive: + for member in archive: + if not member.isfile(): + continue + handle = archive.extractfile(member) + if handle is None: + continue + # Matched as bytes, so a file this cannot decode is searched rather than skipped. + # No encoding guess can then turn a present ref into an absent one. + blob = handle.read() + found |= {r for r, n in needles.items() if r not in found and n in blob} + if len(found) == len(needles): + break + except (tarfile.TarError, OSError, EOFError): + return None + return found + + +def check_claims(owner: str, repo: str, num: int) -> int: + """Report every reference the description makes that its own head tree does not carry.""" + pr = gql(Q_CLAIMS, owner, repo, num) + head, body = pr['headRefOid'], pr.get('body') or '' + uses, shas = body_references(body) + + stale, unread = [], 0 + for sha in shas: + state = commit_state(owner, repo, sha, head) + if state == UNREAD: + unread += 1 + elif state: + stale.append(f'STALE COMMIT `{sha}`: {state}, so the description points at something ' + 'this branch does not have') + + # One read for every ref together, since the archive it reads is the same one either way. + carried = head_carries(owner, repo, head, uses) if uses else set() + for ref in uses: + if carried is None: + unread += 1 + elif ref not in carried: + stale.append(f'STALE USES `{ref}`: no file at this head carries that ref') + + print(f'repo={owner}/{repo} pr={num} head={head[:8]} commits={len(shas)} uses={len(uses)} ' + f'stale={len(stale)} unread={unread}') + for line in stale: + print(f' {line}') + if stale: + # Named as the description's problem rather than the branch's. + # The branch is the ground truth here, and the body is what drifted away from it. + print('status=DESCRIPTION_CONTRADICTS_ITS_BRANCH update the body to what the head tree ' + 'carries, since a reference that resolves to nothing is caught by a reviewer or ' + 'not at all') + return 70 + if unread and unread == len(shas) + len(uses): + # A check that decided nothing prints the same `stale=0` a clean one does. + print('status=NOTHING_WAS_READ every reference in the description was left undecided ' + 'because GitHub did not answer for any of them, so this reports no verdict') + return 71 + if unread: + print(f' NOTE: {unread} reference(s) were left undecided because GitHub did not answer') + return 0 + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser() - ap.add_argument('cmd', choices=['status', 'wait']) + ap.add_argument('cmd', choices=['claims', 'status', 'reply', 'wait']) ap.add_argument('number', type=int) # No default, because the wrong repository is the failure this argument has actually had. # A default names one repository, and every run from elsewhere silently reads that one. @@ -446,7 +1123,33 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument('--timeout', type=int, default=2700, help='seconds (default 45m)') ap.add_argument('--pickup-grace', type=int, default=300, help='seconds before the first pickup read, and between reads (default 5m)') + # `reply` takes the finding's words rather than its id. + # There is deliberately no argument an id fits in, so the caller never holds one to mistype. + ap.add_argument('--match', metavar='TEXT', + help='reply: words from the finding, matched against the thread\'s opening ' + 'comment, and required to select exactly one unresolved thread') + ap.add_argument('--path', metavar='FILE', + help='reply: narrow --match to one file, for a file with several findings') + ap.add_argument('--body', metavar='TEXT', + help='reply: the answer to post, carrying the fixing commit SHA or the ' + 'evidence that disproves the finding') + ap.add_argument('--resolve', action='store_true', + help='reply: resolve the thread once the reply is confirmed') a = ap.parse_args(argv) + # Named for the command they belong to, since one silently ignored reads as one that took effect. + # A `status` given --body reports a clean digest and writes nothing. + # Nothing in that output says the reply never happened. + writing = {'--match': a.match, '--path': a.path, '--body': a.body, + '--resolve': a.resolve or None} + if a.cmd != 'reply': + for flag, value in writing.items(): + if value is not None: + ap.error(f'{flag} belongs to `reply`, not `{a.cmd}`') + else: + for flag in ('--match', '--body'): + if not (writing[flag] or '').strip(): + ap.error(f'reply requires a non-empty {flag}, since a thread resolved on an ' + 'empty answer reads as addressed while carrying nothing') # A negative grace leaves the next reading permanently behind the clock. # That is the per-poll REST pattern the interval exists to prevent. if a.pickup_grace < 0: @@ -457,20 +1160,33 @@ def main(argv: list[str] | None = None) -> int: if not owner or not repo or '/' in repo: ap.error(f'--repo takes OWNER/NAME, not {a.repo!r}') + if a.cmd == 'claims': + return check_claims(owner, repo, a.number) + if a.cmd == 'status': - out, _ = digest(owner, repo, a.number) + # One payload renders the digest and decides the code, for the reason `wait` reads one. + # Fetched twice, a round landing between them prints one pull request and grades another. + pr = gql(Q_FULL, owner, repo, a.number) + out, _ = digest(owner, repo, a.number, pr=pr) print(out) - return 0 + return report_verdict(pr) + + if a.cmd == 'reply': + return reply_to_thread(owner, repo, a.number, a.match, a.body, a.path, a.resolve) # In-process backoff, so the whole wait costs one agent turn. delays = [15, 20, 30, 45, 60, 120] start = time.monotonic() pr = gql(Q_LIVE, owner, repo, a.number) done, answer = reviewed_head(pr), answered_outside_review(pr) + # A drifted login matches no filter here, so `done` stays false however long this runs. + # Waiting it out reports a review that landed as one that never did, at the timeout. + # The liveness query carries the authors, so this costs the loop no extra call. + drift = reviewer_login_drift(pr) stalled = '' i = 0 next_pickup = a.pickup_grace - while not done and not answer: + while not done and not answer and not drift: elapsed = time.monotonic() - start # Read the pickup before the clock, so a request nothing acted on reports as itself. # Running the clock out instead would report it exactly as a slow reviewer. @@ -488,6 +1204,7 @@ def main(argv: list[str] | None = None) -> int: # Re-read head each iteration: a push during the wait moves it. pr = gql(Q_LIVE, owner, repo, a.number) done, answer = reviewed_head(pr), answered_outside_review(pr) + drift = reviewer_login_drift(pr) # One payload decides the digest and the exit code together. # Read separately, a review landing between them prints coverage and returns a stalled code. @@ -501,8 +1218,15 @@ def main(argv: list[str] | None = None) -> int: out, _ = digest(owner, repo, a.number, pr=final, stalled=stalled) print(out) print(f'waited={int(time.monotonic()-start)}s') - if reviewed_head(final): - return 0 + # The shape reading comes first and is not gated on coverage of the head. + # `reviewed_head` is itself one of the readings a drift breaks. + # A renamed login matches no filter here, so it reads as no review at all. + # Every arm below then reports a review that landed as a pending one. + # Gating the verdict behind it left the login check unable to reach an exit code. + # The digest above printed `shapes=UNRECOGNIZED` the whole time it did so. + # Coverage of the head is the other half, returning 0 only once the diff is covered too. + if unrecognized_shapes(final) or reviewed_head(final): + return report_verdict(final) # A refusal before an answer, since it names the round that declined where 40 names none. # The digest prints both bodies regardless, so the narrower code costs the reader nothing. if refusing_review(final): diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 1353ca5d..8653a032 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -12,6 +12,7 @@ dupword No duplicated consecutive word. sentence-split A sentence must not wrap across lines (one sentence per line). spelling No British spelling, the repo-wide convention being US English. + home-path No absolute home path naming a real account, per the representative-data rule. Exit 1 if any violation is found. Read-only, never edits. """ @@ -31,9 +32,10 @@ 'dupword': 'a duplicated consecutive word', 'sentence-split': 'a sentence wrapping across lines', 'spelling': 'a British spelling where the repo convention is US English', + 'home-path': 'an absolute home path naming a real account', } DEFAULT_RULES = frozenset({'charset', 'charset-unknown', 'semicolon', 'dash', 'dupword', - 'spelling', 'comment-wrap', 'comment-case'}) + 'spelling', 'comment-wrap', 'comment-case', 'home-path'}) # Trees this repo generates rather than authors, skipped when a wider scan expands into them. # The gate then measures hand-written prose. @@ -53,6 +55,24 @@ # A sweep that quietly stops finding files satisfies every rule by having nothing to read. LEAST_PLAUSIBLE = 60 +# The pattern-detectable half of the representative-data rule, and only that half. +# A real user segment is required, so a documented placeholder describes the shape unmatched. +# That is how the rule's own wording escapes its own gate, with no exemption naming files. +# A bare drive letter is deliberately not a shape here. +# Measured against this repo it matched 11 files and named a path in none of them. +# An escaped newline after a word ending in a letter and a colon reads as a drive letter. +# `Users` is matched case-insensitively on the Windows branch alone, since that filesystem is. +# The POSIX branches stay case-sensitive, since a lowercase `/users/` is a common REST path. +# An API route is not a home directory, and widening this would flag one in every doc. +HOME_PATH = re.compile( + r'(?:/home/|/Users/|[A-Za-z]:\\(?i:users)\\)(?P<user>[A-Za-z][A-Za-z0-9._-]*)') + +# Accounts that belong to a container or a runner rather than to a person. +# Every one is a fixed name an image ships, so a path under it names no environment. +# `vscode` is the devcontainer user this repo's own snippets mount into. +# `runner` is the GitHub Actions user, and the rest are stock image accounts. +SERVICE_ACCOUNTS = frozenset({'vscode', 'runner', 'root', 'ubuntu', 'node', 'shared', 'public'}) + def rel(path: Path) -> str: """The repo-relative posix key a git diff uses for this path. @@ -86,6 +106,77 @@ def changed_lines(base: str) -> dict[str, set[int]] | None: return out +def asked_about(key: str, paths: list[str]) -> bool: + """Whether a repository-relative diff key falls under one of the requested paths. + + The floor below compares the diff's file list against what the run matched, and a caller who + narrowed the scan on purpose must not be told the narrowing is a defect. Anything the request + did not cover is not a file this run failed to read. + """ + for raw in paths: + # `Path` drops a trailing separator, so the test appends one rather than stripping it. + # Comparing the bare prefix would let `catalog` claim `catalogue/x.md`. + r = rel(Path(raw)) + if r in ('', '.'): + return True + if key == r or key.startswith(r + '/'): + return True + return False + + +def unread_diff_files(scope: dict[str, set[int]], paths: list[str], + excludes: tuple[str, ...]) -> list[str]: + """Files the diff names that this run was asked about and could have read, in sorted order. + + Keys are resolved against the repository top level rather than the working directory, because + `git diff` reports repository-relative paths while discovery keys off the directory the run + started in. Reading them against the working directory would make this list empty from a + subdirectory, which is the one place it most needs to be full. + """ + root = Path(repo_root(Path('.')) or '.') + out: list[str] = [] + for key in sorted(scope): + if not asked_about(key, paths): + continue + if any(x in key for x in excludes): + continue + if not GENERATED_TREES.isdisjoint(Path(key).parts): + continue + target = root / key + if target.is_file() and is_text(target): + out.append(key) + return out + + +def home_path_findings(lineno: int, line: str) -> list[tuple[int, str, str]]: + """Absolute home paths on this line that name a real account. + + The exposure this gates was a maintainer's own path reaching a public comment, so the unit + is the raw line rather than stripped prose. A path is the same exposure in a JSON config + value, in a fenced transcript pasted from a terminal, and in a sentence. + """ + out = [] + for m in HOME_PATH.finditer(line): + if m.group('user').lower() in SERVICE_ACCOUNTS: + continue + out.append((lineno, 'home-path', + f'absolute home path {m.group(0)!r} -> use a constructed path, not an ' + 'observed one')) + return out + + +def operational_checkout(root: Path) -> bool: + """Whether this checkout is an operational repository, read from what it carries. + + `spec/files.json` declares `repo-config/operational/develop.json` for the operational model + and `repo-config/develop.json` for the release one, so a repository states its own model and + nothing has to reach the hub registry to ask. The hub itself carries both payloads, being the + template for each, so carrying the release payload decides it. + """ + return ((root / 'repo-config' / 'operational' / 'develop.json').is_file() + and not (root / 'repo-config' / 'develop.json').is_file()) + + def repo_prefix(root: Path) -> str: """Where `root` sits inside its repository, as a posix prefix, or '' when git cannot say. @@ -579,12 +670,34 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # The initial guard anchors on a word boundary, so `J. Smith` reads as one name. # A sentence ending in an acronym such as CI is two sentences and has to be caught. # The second sentence may open in either case, since a lowercase opening is still a second sentence. -RUN_ON = re.compile(r'(?<!\b[A-Z])(?<!\be\.g)(?<!\bi\.e)(?<!\bvs)(?<!\betc)[.!?]\s+(?=[A-Za-z])') +# An ellipsis marks an elision inside one sentence, so its closing dot is not a terminator. +# The guard is that a dot preceded by a dot never terminates. +# That reads a schematic such as `... end_of_line = lf` as the one line it is. +# Splitting such a line would break the fragment it exists to show. +# It is scoped to the dot alternative, since a `?` or `!` after an ellipsis does terminate. +# Guarding the whole class would read `Really...? Yes.` as one sentence and miss a real run-on. +RUN_ON = re.compile(r'(?<!\b[A-Z])(?<!\be\.g)(?<!\bi\.e)(?<!\bvs)(?<!\betc)(?:(?<!\.)\.|[!?])\s+(?=[A-Za-z])') # A step marker opening a comment is a label on the sentence that follows, not a sentence of its own. # `# 1. Deploy the hook.` is one sentence, and reading the marker's dot as a terminator made it two. # It is stripped before the sentence checks so both the run-on and the opening-case test see the prose. ENUM_PREFIX = re.compile(r'^\d+[.)]\s+') + +# A comment body that is one token closing on a colon is a key or a heading, not a sentence. +# `# ignore:` above a commented-out block is disabled configuration. +# Capitalizing it corrupts the key a reader uncomments, so the rule would damage the file. +# The token count carries the test, since a colon ending real prose always has words before it. +KEY_ONLY = re.compile(r'^\S+:$') + +# A label opening a definition names the thing being defined, so it is not the sentence's first word. +# `# publish - 'true' when ...` documents an output named `publish`. +# Capitalizing it renames the output the workflow declares. +# This is the comment spelling of the `- **Label** - text` construct LABEL_DASH exempts in Markdown. +# It is tested where a line opens a definition, never where one continues a wrapped sentence. +# A continuation whose first word is followed by a spaced dash is a parenthetical instead. +# That is the construction the dash rule exists to catch, so exempting it would hide the violation. +# Both live instances in the tree are continuations, which is what scoped this to the case branch. +COMMENT_LABEL = re.compile(r'^[A-Za-z_][\w.-]*\s+-\s+') CODE_FENCE = re.compile(r'^\s*(```|~~~)') # Both are correct English. `the the` is always a typo, so it is not here. @@ -838,7 +951,8 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ prev_body = '' prev_no = 0 for n, body, leading in comments: - if not body or NOT_PROSE.search(body) or BARE_URI.match(body.strip()): + if (not body or NOT_PROSE.search(body) or BARE_URI.match(body.strip()) + or KEY_ONLY.match(body)): prev_body = '' continue # An unpunctuated Markdown HTML comment is a structural marker, not commentary. @@ -860,7 +974,8 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ out.append((prev_no, 'comment-wrap', 'comment sentence wraps into the next line -> one sentence per line')) # A lowercase opening that is not a continuation is a sentence that failed to start. - elif leading and body[:1].islower(): + # A label opening a definition is exempt, since the lowercase word is the name being defined. + elif leading and body[:1].islower() and not COMMENT_LABEL.match(body): out.append((n, 'comment-case', 'comment sentence opens in lowercase -> capitalize, or restructure so it ' 'does not open on a lowercase name')) @@ -892,6 +1007,10 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]: prev_no = 0 for i, line in enumerate(lines, 1): line = line.rstrip('\r') + # Judged before the fence and inline-code handling below, deliberately. + # A path pasted inside a fenced transcript is the same exposure as one in a sentence. + if 'home-path' in rules: + out.extend(home_path_findings(i, line)) if CODE_FENCE.match(line): in_fence = not in_fence prev_txt = '' @@ -980,6 +1099,15 @@ def main(argv: list[str] | None = None) -> int: rules = set(a.checks or DEFAULT_RULES) + # An operational repository's runbook carries the literal path an operator types. + # That is the repository's own content, not an agent quoting an environment it observed. + # The skip is announced, since a rule that silently stops running reads as one that passed. + # That is the same failure the diff-scope floor below exists to prevent. + if 'home-path' in rules and operational_checkout(Path(repo_root(Path('.')) or '.')): + rules.discard('home-path') + print('note: home-path is not checked in an operational repository, where an absolute ' + 'path is the operator instruction rather than observed data.', file=sys.stderr) + # Checked before discovery, which reads every tracked file to classify it as text. # A run this rejects would otherwise pay that cost and throw the result away. # `--list-files` is exempt, since it reports the scan scope and never consults the diff. @@ -1021,7 +1149,27 @@ def main(argv: list[str] | None = None) -> int: 'checkout carries its history.', file=sys.stderr) return 2 if scope is not None: - files = [f for f in files if rel(f) in scope] + matched = [f for f in files if rel(f) in scope] + # The floor every verdict below rests on, asserted rather than guarded. + # Each route to a false clean so far was closed after a reviewer saw it. + # The next is closed that way or not at all, which is what a floor covers. + # A run that resolves a non-empty diff and matches none of its files failed to scope. + # Zero alone is not the test. + # A change touching only files the rules do not read matches nothing and is clean. + # An image or a lock file is that case. + # So the comparison is against the diff's own list of files this run could have read. + if scope and not matched: + unread = unread_diff_files(scope, a.paths or ['.'], tuple(a.exclude)) + if unread: + shown = ', '.join(unread[:5]) + (' and more' if len(unread) > 5 else '') + print(f'error: the diff against {a.diff!r} names {len(unread)} readable file(s) ' + f'this run was asked about, and the scan matched none of them: {shown}. ' + 'Refusing to report a clean run, since a gate that read nothing is ' + 'indistinguishable from a gate with nothing to read. Check that the run ' + 'starts at the repository top level and that the requested paths cover ' + 'the change.', file=sys.stderr) + return 2 + files = matched total = 0 bykind: dict[str, int] = {} diff --git a/scripts/repo_gate.py b/scripts/repo_gate.py index 48674271..1a1fbead 100644 --- a/scripts/repo_gate.py +++ b/scripts/repo_gate.py @@ -7,6 +7,14 @@ sha-pin Action SHA-pinning gaps 25 findings (GOVERNANCE.md rule) eol .editorconfig <-> .gitattributes disagreement 40 findings +`sha-pin` reads the shape and then resolves it, because forty hex characters is a format any +fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a +failure this repo has seen rather than a hypothetical one. Resolving also catches the +neighboring case, a pin whose commit was reachable only from a branch since squashed and +deleted, which breaks a downstream gate long after the change that caused it. The +`gh-write-guard` hook cannot cover either, since it watches Bash and an editor tool writing +the same string into a file never reaches it. + A stale-backticked-path check was built and REJECTED: in a template repo, docs legitimately reference paths that live in downstream repos (`.vscode/tasks.json`, `Docker/README.md`, `reports/*/audit.md` targets), so it produced 34 false positives @@ -27,12 +35,72 @@ USES = re.compile(r'^\s*-?\s*uses:\s*(?P<ref>[^\s#]+)', re.M) PIN = re.compile(r'^[0-9a-f]{40}$') WORKFLOW = re.compile(r'workflows/.*\.ya?ml$') +# What `gh` prints when GitHub answered, as opposed to when nothing was reached at all. +HTTP_STATUS = re.compile(r'\(HTTP (\d{3})\)') +# The two GitHub returns for an object that is not there. +# Everything else is a failure to read, since 401 and 403 are credentials and a rate limit. +# A network error carries no status at all. +# Reading any of those as absence fails a correct pin, which is the direction that costs most. +ABSENT = {'404', '422'} +GH_TIMEOUT = 20 + +# How a check says it did less than its name. +# A gate that quietly degrades to a weaker reading prints the same clean line as one that ran. +# The narrowing is therefore printed rather than left to be inferred. +# Never a finding, since nothing is wrong with the tree when the network is what is missing. +NOTES: list[str] = [] def sh(*args: str) -> str: return subprocess.run(args, capture_output=True, text=True).stdout +def origin_owner(root: Path) -> str | None: + """The owner of the repository at `root`, lower-cased, or None where it cannot be read. + + Read from the tree being scanned rather than from this script's own checkout, since the gate + is hub-hosted and runs against whatever `--root` names. + """ + url = sh('git', '-C', str(root), 'remote', 'get-url', 'origin').strip() + m = re.search(r'[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$', url) + return m.group(1).lower() if m else None + + +def gh_exists(path: str) -> bool | None: + """True where GitHub returned the object, False where it answered absent, None where neither. + + None covers an absent `gh`, no credentials, a rate limit, and an offline host, which are one + thing here: nothing was learned. The caller reports those as skipped rather than as findings, + so the gate stays usable on a machine with no network instead of failing a correct tree. + """ + try: + r = subprocess.run(['gh', 'api', path, '--jq', '.sha'], + capture_output=True, text=True, timeout=GH_TIMEOUT) + except (OSError, subprocess.SubprocessError): + return None + if r.returncode == 0: + return True + m = HTTP_STATUS.search(r.stderr) + return False if m and m.group(1) in ABSENT else None + + +def pin_resolves(nwo: str, sha: str, cache: dict[tuple[str, str], bool | None]) -> bool | None: + """Whether `sha` is a commit in `nwo`, cached, with an unreadable repository read as unknown. + + An absent commit and a repository the credentials cannot see are the same 404 from here, and + reading the second as the first fails a correct pin whenever the token is narrower than the + fleet, which a repository-scoped CI token is. So a miss is confirmed against the repository + itself before it becomes a finding, and that second read runs only on the failing path. + """ + key = (nwo, sha) + if key not in cache: + seen = gh_exists(f'repos/{nwo}/commits/{sha}') + if seen is False and gh_exists(f'repos/{nwo}') is not True: + seen = None + cache[key] = seen + return cache[key] + + def tracked(root: Path) -> list[str]: out = sh('git', '-C', str(root), 'ls-files') return [l for l in out.split('\n') if l] @@ -47,7 +115,21 @@ def workflow_files(files: list[str]) -> list[str]: def check_sha_pin(root: Path, files: list[str]) -> list[str]: + """Every `uses:` naming an action is a 40-hex SHA, and one under this owner is a SHA that + resolves. A ref into this repository's own tree names no action and is skipped. + + Resolution is scoped to the scanned repository's own owner, because that is where the fleet's + own actions live and where the decay this catches comes from: a squash merge deletes the + branch a pin was taken from, and the pin outlives the commit. A third-party action's tag is + stable by comparison, and reading one would make every local run of this gate depend on a + stranger's repository answering. The cost is stated rather than left to be found, and it is + real: a fabricated pin on a third-party action is still only shape-checked here. The counts + below print on every run so that narrowness is visible rather than inferred from a clean line. + """ bad = [] + owner = origin_owner(root) + cache: dict[tuple[str, str], bool | None] = {} + resolved = foreign = unowned = unread = 0 for rel in workflow_files(files): try: text = (root / rel).read_text(encoding='utf-8', errors='replace') @@ -63,9 +145,35 @@ def check_sha_pin(root: Path, files: list[str]) -> list[str]: action, _, ver = ref.rpartition('@') if action in SHA_EXCEPTIONS: continue + line = text[:m.start()].count('\n') + 1 if not PIN.match(ver): - line = text[:m.start()].count('\n') + 1 bad.append(f'{rel}:{line}: `{action}@{ver}` is a floating ref, not a 40-hex SHA') + continue + # An action reference is `owner/repo` with an optional path to the action within it. + nwo = '/'.join(action.split('/')[:2]) + # Counted apart from a known other owner, since the two are not the same state. + # The note exists to describe the narrowing exactly, so it must not merge them. + # A checkout with no readable origin skips every pin, this owner's own included. + if owner is None: + unowned += 1 + continue + if nwo.split('/')[0].lower() != owner: + foreign += 1 + continue + state = pin_resolves(nwo, ver, cache) + if state is False: + bad.append(f'{rel}:{line}: `{action}@{ver}` is shaped like a SHA and resolves to ' + f'no commit in {nwo}') + elif state is None: + unread += 1 + else: + resolved += 1 + # Unconditional, so an all-zero run is as visible as a count rather than a clean line. + # A guard on a non-zero counter hides the run that resolved nothing, which is this one. + NOTES.append(f'resolved {resolved} pin(s) against GitHub. Read for shape only: ' + f'{foreign} under another owner, {unowned} whose owner could not be ' + f"compared because this checkout's origin is unreadable, " + f'{unread} GitHub did not answer for.') return bad @@ -139,11 +247,16 @@ def main(argv: list[str] | None = None) -> int: total = 0 for name in (a.check or sorted(CHECKS)): + # Cleared per check, so a note is attributed to the check that raised it. + NOTES.clear() hits = CHECKS[name](root, files) status = 'FAIL' if hits else 'ok' print(f'[{status:4}] {name:12} {len(hits)} issue(s)') for h in hits: print(f' {h}') + # After the findings and outside the count, since a note is not one. + for note in NOTES: + print(f' note: {note}') total += len(hits) return 1 if total else 0 diff --git a/scripts/test_pr_review.py b/scripts/test_pr_review.py index e9758651..712c8c2f 100644 --- a/scripts/test_pr_review.py +++ b/scripts/test_pr_review.py @@ -27,7 +27,12 @@ LATE = '2026-08-02T11:00:00Z' -def review(login: str = pr_review.REVIEWER, oid: str = HEAD, body: str = '', +# The shape 28 of the 332 measured bodies carry: an overview, and no count of what was read. +# A body of no text at all is not one of the shapes, and the reader now says so, correctly. +OVERVIEW = '## Pull request overview\n\nThe change is narrow.\n' + + +def review(login: str = pr_review.REVIEWER, oid: str = HEAD, body: str = OVERVIEW, at: str = EARLY) -> dict: return {'author': {'login': login}, 'state': 'COMMENTED', 'commit': {'oid': oid}, 'body': body, 'submittedAt': at} @@ -38,25 +43,40 @@ def comment(login: str = pr_review.REVIEWER, at: str = LATE, return {'author': {'login': login}, 'createdAt': at, 'body': body} +COVERED = ('Copilot reviewed 3 out of 3 changed files in this pull request and generated ' + 'no new comments.') + + def collapsed(heading: str = 'Comments suppressed due to low confidence (1)', - finding: str = 'a.py:12 The retry count is off by one.') -> str: - return (f'Reviewed 3 of 3 changed files.\n\n<details>\n<summary>{heading}</summary>\n\n' + finding: str = 'a.py:12 The retry count is off by one.', + covers: str = COVERED) -> str: + """The section as its own `<details>` wrapper, under the round's own coverage line. + + The coverage line is the reviewer's, quoted from the corpus rather than invented: it sat here + as filler that nothing asserted on, which is one of the two places the shape was already in + this file while no case read it. + """ + return (f'{OVERVIEW}\n{covers}\n\n<details>\n<summary>{heading}</summary>\n\n' f'{finding}\n\n</details>\n') def nested(heading: str = '### Suppressed comments (2)', - finding: str = '**a.py:12**\n* The retry count is off by one.') -> str: + finding: str = '**a.py:12**\n* The retry count is off by one.', + covers: str = '- **Files reviewed:** 1/1 changed files') -> str: """The section as a Markdown heading nested inside the `Review details` wrapper. The live shape as of 2026-08-05: the section is no longer its own `<details>` wrapper with a matching `<summary>`, it is a Markdown heading inside the wrapper that also carries the round's file and effort metadata, which trails the findings rather than preceding them. + + That metadata is where this shape states its coverage, and it is the second spelling of the + line rather than a second wrapper. It sat here as filler that nothing asserted on too. """ return ('### Ready to approve\n\nThe change is narrow.\n\n' '<details>\n<summary>File summaries</summary>\n\n' '| File | Description |\n\n</details>\n\n' f'<details>\n<summary>Review details</summary>\n\n{heading}\n\n{finding}\n\n' - '- **Files reviewed:** 1/1 changed files\n' + f'{covers}\n' '- **Review effort level:** Lite\n</details>\n') @@ -350,14 +370,15 @@ def test_the_finding_prints_whole_under_a_marker_naming_the_answer(self) -> None def test_a_body_naming_the_block_outside_a_details_wrapper_still_reports(self) -> None: """Reporting zero because the markup moved is the failure the whole case guards.""" - self.answer(payload([review(body='Suppressed comments (1)\n\na.py:12 Off by one.')])) + self.answer(payload([review(body=OVERVIEW + '\nSuppressed comments (1)\n\n' + 'a.py:12 Off by one.')])) out, _ = pr_review.digest('o', 'r', 7) self.assertIn('suppressed=1', out) self.assertIn('a.py:12 Off by one.', out) def test_the_per_file_summary_block_beside_it_is_not_a_finding(self) -> None: """Every real body collapses a file table too, and reporting that is noise, not a finding.""" - body = ('<details>\n<summary>Show a summary per file</summary>\n\n' + body = (OVERVIEW + '\n<details>\n<summary>Show a summary per file</summary>\n\n' '| File | Description |\n\n</details>\n' + collapsed()) self.answer(payload([review(body=body)])) out, _ = pr_review.digest('o', 'r', 7) @@ -529,6 +550,403 @@ def test_a_human_review_carrying_the_wording_is_not_the_reviewer_refusing(self) self.assertIn('refusal=no', out) +class TestCoverage(GqlCase): + """The round that covered the head and read part of the diff, which is a clean pass elsewhere. + + Measured over 332 Copilot review bodies on this repository: five rounds across three pull + requests reported reading fewer files than the pull request changed, and all three merged. + One of them changed three files, left one unread across both its rounds, and reported + "generated no comments" each time. + """ + + def digest_for(self, *reviews: dict) -> str: + self.answer(payload(list(reviews))) + out, _ = pr_review.digest('o', 'r', 7) + return out + + def test_each_vetted_spelling_of_a_full_round_reads_as_full(self) -> None: + """The census: four tails on the first spelling, and the `Review details` bullet.""" + for covers in ( + 'Copilot reviewed 7 out of 7 changed files in this pull request and generated ' + 'no new comments.', + 'Copilot reviewed 7 out of 7 changed files in this pull request and generated ' + 'no comments.', + 'Copilot reviewed 7 out of 7 changed files in this pull request and generated ' + '1 comment.', + 'Copilot reviewed 7 out of 7 changed files in this pull request and generated ' + '4 comments.', + '- **Files reviewed:** 7/7 changed files', + ): + with self.subTest(covers=covers[:44]): + self.assertEqual((pr_review.FULL, covers), + pr_review.coverage_of({'body': covers})) + + def test_a_round_that_read_part_of_the_diff_is_a_failure_the_digest_names(self) -> None: + """PR 592's shape: three changed files, one never read, and it merged.""" + line = ('Copilot reviewed 2 out of 3 changed files in this pull request and generated ' + 'no comments.') + out = self.digest_for(review(body=OVERVIEW + '\n' + line)) + self.assertIn('coverage=PARTIAL', out) + self.assertIn('COVERAGE IS PARTIAL', out) + # The counts print, since they are what say how much went unread and no thread carries it. + self.assertIn(line, out) + # The round did cover the head. + # That reading was correct, and it was not the whole reading. + self.assertIn('review_on_head=yes', out) + + def test_the_second_spelling_reports_a_partial_round_too(self) -> None: + """Both spellings appear in the corpus to this day, so neither replaces the other.""" + out = self.digest_for(review(body=nested(covers='- **Files reviewed:** 2/3 changed files'))) + self.assertIn('coverage=PARTIAL', out) + + def test_the_bullet_spelling_is_a_coverage_line_on_its_label_alone(self) -> None: + """Its label is the marker, so dropping the words after the counts is not dropping it. + + Requiring them read a bullet that had lost them as no statement at all, which is a + silent `unstated` over a round that stated its coverage plainly. + """ + # Detected and read, which are two assertions rather than one. + # Asserting only the first left the bare bullet blocking on a line it could read. + for line in ('- **Files reviewed:** 4/4', '- **Files reviewed:** 4/4 changed file', + '- **Files reviewed:** 4/4 changed files'): + with self.subTest(line=line): + self.assertEqual([line], pr_review.coverage_statements(line)) + self.assertEqual((4, 4), pr_review.read_coverage(line)) + self.assertEqual(pr_review.FULL, pr_review.coverage_of({'body': line})[0]) + + def test_a_bullet_carrying_no_counts_still_blocks(self) -> None: + """What is left after the words are optional is a line that states no coverage at all.""" + self.assertIsNone(pr_review.read_coverage('- **Files reviewed:** all of them')) + self.assertEqual(pr_review.UNVETTED, + pr_review.coverage_of({'body': '- **Files reviewed:** all of them'})[0]) + + def test_the_reviewer_s_name_alone_is_not_a_coverage_line(self) -> None: + """The other opener keeps its text requirement, prose opening lines with that name too.""" + self.assertFalse(pr_review.is_coverage_line('Copilot answers a request with a comment.')) + + def test_a_singular_changed_file_reads_rather_than_blocks(self) -> None: + """A one-file round means what it says, and stopping the fleet over an -s is crying wolf.""" + self.assertEqual((1, 1), pr_review.read_coverage( + 'Copilot reviewed 1 out of 1 changed file in this pull request and generated ' + 'no comments.')) + + def test_counts_that_cannot_both_be_true_block_rather_than_read_as_full(self) -> None: + """A round claiming it read more files than were changed is one this is parsing wrongly. + + Read as full coverage it fails open on the very statement saying something is off, which + is the shape of every other failure here. + """ + line = ('Copilot reviewed 8 out of 7 changed files in this pull request and generated ' + 'no comments.') + self.assertIsNone(pr_review.read_coverage(line)) + self.assertEqual(pr_review.UNVETTED, pr_review.coverage_of({'body': line})[0]) + self.assertIn(f'coverage line: {line}', pr_review.unrecognized_in(OVERVIEW + '\n' + line)) + + def test_a_coverage_line_that_parses_to_nothing_names_this_script(self) -> None: + """The wording has drifted once for each of the two patterns beside this one. + + Passing a shape it does not recognize is how a gate stops gating as the wording moves, + so the unrecognized shape is reported and its remedy is stated as fixing this script. + """ + line = 'Copilot reviewed most of the changed files in this pull request.' + out = self.digest_for(review(body=OVERVIEW + '\n' + line)) + self.assertIn('coverage=UNVETTED', out) + # It is one of the unrecognized shapes rather than a report of its own. + # Both say the reader needs fixing, and one of them saying it once is the whole message. + self.assertIn('UNRECOGNIZED REVIEWER OUTPUT (1)', out) + self.assertIn(f'coverage line: {line}', out) + + def test_a_round_stating_no_coverage_at_all_is_unstated_rather_than_a_verdict(self) -> None: + """28 of the 332 bodies are an overview and a change list, and that shape is current. + + It interleaves with the counted one throughout rather than preceding it, and one pull + request carries both across its two rounds, so failing on it would cry wolf on about one + review in twelve. Reporting it as coverage is the bug this reading exists to remove. + """ + body = ('## Pull request overview\n\nThis PR updates the backlog.\n\n' + '**Changes:**\n- Delete the shipped cluster from `TODO.md`.\n') + out = self.digest_for(review(body=body)) + self.assertIn('coverage=unstated', out) + self.assertNotIn('COVERAGE IS PARTIAL', out) + self.assertIn('shapes=ok', out) + + def test_a_refusal_is_exempt_rather_than_an_unrecognized_shape(self) -> None: + """It states no coverage by design, and `head_reviews` has already dropped it. + + Read as a round, every refusal becomes a spurious unvetted-shape failure sitting on top + of the `refusal=YES` that already names the state and its remedy. + """ + out = self.digest_for(review(body=REFUSED)) + self.assertIn('refusal=YES', out) + self.assertIn('coverage=unstated', out) + self.assertIn('shapes=ok', out) + + def test_coverage_is_read_from_the_head_rather_than_from_a_superseded_round(self) -> None: + """A partial round describes one commit's diff, and the push that changes it is answered + by a round reading the whole of the new one.""" + old = (OVERVIEW + '\nCopilot reviewed 2 out of 3 changed files in this pull request ' + 'and generated no comments.') + out = self.digest_for(review(oid=OLD, body=old), + review(body=OVERVIEW + '\n' + COVERED)) + self.assertIn('coverage=full', out) + self.assertNotIn('COVERAGE IS PARTIAL', out) + + def test_the_worst_of_two_rounds_on_one_head_is_what_reports(self) -> None: + """A head carries two rounds through a re-request, and both read the same diff.""" + partial = ('Copilot reviewed 2 out of 3 changed files in this pull request and ' + 'generated no comments.') + self.assertEqual(pr_review.PARTIAL, + pr_review.head_coverage(payload([review(body=COVERED, at=EARLY), + review(body=partial, at=LATE)]))[0]) + + + def test_a_round_that_states_full_coverage_settles_it_over_one_stating_none(self) -> None: + """Unstated is the absence of a statement rather than a bad one, so it loses to a count.""" + self.assertEqual(pr_review.FULL, + pr_review.head_coverage(payload([review(body='## Overview\n\nNarrow.'), + review(body=COVERED)]))[0]) + + def test_prose_mentioning_changed_files_is_not_this_round_stating_its_coverage(self) -> None: + """The false positive the suppressed matcher and the refusal matcher have each had once. + + A review of the pull request that adds this check discusses the wording it adds, and a + body-wide match reads that discussion as the round's own count. + """ + body = ('## Pull request overview\n\nThis PR reads the line saying Copilot reviewed 2 ' + 'out of 3 changed files, so a partial round stops reporting as a clean pass.\n\n' + '- The digest now carries a `coverage` field.\n') + out = self.digest_for(review(body=body)) + self.assertIn('coverage=unstated', out) + + def test_a_quoted_line_in_a_fenced_block_is_not_this_round_stating_its_coverage(self) -> None: + """131 of the 332 bodies carry a fence, and this change puts both spellings in the diff.""" + body = ('### Ready to approve\n\nThe vetted spellings read:\n\n```\n' + 'Copilot reviewed 2 out of 3 changed files in this pull request and generated ' + 'no comments.\n- **Files reviewed:** 4/9 changed files\n```\n\n' + COVERED + '\n') + body = '### Ready to approve\n\n' + body + out = self.digest_for(review(body=body)) + self.assertIn('coverage=full', out) + self.assertNotIn('COVERAGE IS PARTIAL', out) + + def test_a_human_review_carrying_a_coverage_line_is_not_the_reviewer_s_round(self) -> None: + partial = ('Copilot reviewed 2 out of 3 changed files in this pull request and ' + 'generated no comments.') + out = self.digest_for(review(login='ptr727', body=partial), + review(body=OVERVIEW + '\n' + COVERED)) + self.assertIn('coverage=full', out) + + +class TestUnrecognizedShapes(GqlCase): + """A shape this script has no reader for blocks, rather than being read past. + + Every reader here keys on a structural marker, so a marker that changes spelling is a section + the reader stops finding and reports as absent. All three failures on record have that shape: + a suppressed heading reworded, a suppressed section moved inside another wrapper, and a + coverage line nothing parsed. Each was caught after it had already reported a clean pass. + + The inventory is measured rather than imagined. Over the same 332 review bodies, with fenced + blocks removed and text reduced to ASCII, the whole corpus is 7 headings, 6 summaries and 3 + metadata labels, and every body carries at least one of them. + """ + + def digest_for(self, *reviews: dict) -> str: + self.answer(payload(list(reviews))) + out, _ = pr_review.digest('o', 'r', 7) + return out + + def test_every_vetted_marker_together_reads_as_recognized(self) -> None: + """The corpus shape in one body, so the lists are held against what they were built from.""" + self.assertEqual([], pr_review.unrecognized_in(nested())) + self.assertEqual([], pr_review.unrecognized_in(collapsed())) + self.assertEqual([], pr_review.unrecognized_in(OVERVIEW)) + + def test_a_heading_that_is_not_in_the_inventory_blocks(self) -> None: + """A renamed section is one the reader stops finding, which it reports as nothing there.""" + out = self.digest_for(review(body=OVERVIEW + '\n### Confidence assessment\n\nHigh.\n')) + self.assertIn('shapes=UNRECOGNIZED', out) + self.assertIn('heading: ### Confidence assessment', out) + + def test_a_details_summary_that_is_not_in_the_inventory_blocks(self) -> None: + """The suppressed section has already moved between wrappers once.""" + body = OVERVIEW + '\n<details>\n<summary>Withheld findings</summary>\n\nx\n</details>\n' + out = self.digest_for(review(body=body)) + self.assertIn('summary: Withheld findings', out) + + def test_a_metadata_label_that_is_not_in_the_inventory_blocks(self) -> None: + """The coverage line arrived as one of these bullets, so the next reading may too.""" + out = self.digest_for(review(body=nested() + '\n- **Confidence:** high\n')) + self.assertIn('metadata label: Confidence', out) + + def test_a_body_carrying_no_heading_at_all_blocks(self) -> None: + """Every measured body opens on a heading, so one with none is a format never seen. + + This is the arm that catches a rewrite wholesale rather than marker by marker, and it is + also what catches the refusal wording drifting, since a refusal stops being exempt. + """ + self.assertIn('body carrying no heading at all', + ' '.join(pr_review.unrecognized_in('Looks good to me.'))) + self.assertIn('body carrying no heading at all', + ' '.join(pr_review.unrecognized_in(''))) + + def test_a_refusal_is_exempt_because_it_is_already_a_vetted_shape(self) -> None: + """It is a bare paragraph by design, and `refusal=YES` already names its remedy.""" + self.assertEqual([], pr_review.unrecognized_in(REFUSED)) + out = self.digest_for(review(body=REFUSED)) + self.assertIn('shapes=ok', out) + self.assertIn('refusal=YES', out) + + def test_a_refusal_whose_wording_drifted_stops_being_exempt_and_blocks(self) -> None: + """The exemption is the pattern, so losing the pattern loses the exemption, not the check. + + This is the failure the refusal check was built for arriving one rewording later: the + body would read as an ordinary review carrying the head and raising nothing. + """ + drifted = 'Copilot has declined to review this pull request because it is too large.' + self.assertEqual('', pr_review.refusal_of({'body': drifted})) + out = self.digest_for(review(body=drifted)) + self.assertIn('shapes=UNRECOGNIZED', out) + self.assertIn('refusal=no', out) + + def test_the_emoji_and_the_finding_count_are_normalized_rather_than_vetted(self) -> None: + """Both change without the section changing, so comparing them raw blocks every review.""" + for heading in ('### \U0001F7E2 Ready to approve', '### \U0001F7E1 Changes recommended', + '### Suppressed comments (4)', '### Suppressed comments (11)'): + with self.subTest(heading=heading): + self.assertEqual([], pr_review.unrecognized_in(f'{heading}\n\nText.\n')) + + def test_a_marker_quoted_in_a_fenced_block_is_not_one_the_review_carries(self) -> None: + """This change publishes the inventory, so a review of it quotes the lot back.""" + body = (OVERVIEW + '\nThe vetted headings are:\n\n```\n### Confidence assessment\n' + '- **Confidence:** high\n```\n') + self.assertEqual([], pr_review.unrecognized_in(body)) + + def test_a_reviewer_login_that_is_not_the_one_every_query_filters_on_blocks(self) -> None: + """A rename leaves every filter here matching nothing, which reads as no review at all. + + That is the quietest drift of the lot: the digest reports `rounds=0 review_on_head=NO` + over a review that landed, and a wait polls out its whole timeout against it. + """ + pr = payload([review(login='copilot-code-review-agent')]) + found = ' '.join(pr_review.unrecognized_shapes(pr)) + self.assertIn('reviewer login: copilot-code-review-agent', found) + + def test_the_coding_agent_and_a_human_are_not_the_reviewer_renamed(self) -> None: + """`copilot-swe-agent` edits code and is not this reviewer under another name.""" + for login in ('copilot-swe-agent', 'ptr727', 'codecov[bot]', 'dependabot[bot]'): + with self.subTest(login=login): + self.assertEqual([], pr_review.unrecognized_shapes(payload([review(login=login)]))) + + def test_the_block_names_the_hub_and_leaves_the_merge_to_the_maintainer(self) -> None: + """The remedy is an issue where the reader lives, and the merge is not this script's call.""" + out = self.digest_for(review(body=OVERVIEW + '\n### Confidence assessment\n')) + self.assertIn('UNRECOGNIZED REVIEWER OUTPUT (1)', out) + self.assertIn(f'File an issue on {pr_review.HUB}', out) + self.assertIn("maintainer's call rather than the agent's", out) + + def test_every_round_is_read_rather_than_the_head_s(self) -> None: + """This asks whether the reader still understands the reviewer, which is not head-scoped. + + A shape that arrived one round ago is one every later round carries, so waiting for it to + reach the head is waiting through the rounds it is already misreading. + """ + out = self.digest_for(review(oid=OLD, body=OVERVIEW + '\n### Confidence assessment\n'), + review(body=OVERVIEW)) + self.assertIn('shapes=UNRECOGNIZED', out) + self.assertIn(f'(round {OLD[:8]})', out) + + +class TestCoverageExitCodes(GqlCase): + """`status` returned 0 unconditionally, so a partial round reported as a covered head.""" + + def setUp(self) -> None: + self.out = self.enterContext(contextlib.redirect_stdout(io.StringIO())) + + def partial(self) -> dict: + return review(body=OVERVIEW + '\nCopilot reviewed 2 out of 3 changed files in this ' + 'pull request and generated no comments.') + + def test_status_exits_forty_two_on_a_round_that_read_part_of_the_diff(self) -> None: + self.answer(payload([self.partial()])) + self.assertEqual(42, pr_review.main(['status', '7', '--repo', 'o/r'])) + self.assertIn('status=COVERAGE_IS_PARTIAL', self.out.getvalue()) + + def test_status_exits_forty_three_on_a_wording_it_does_not_read(self) -> None: + self.answer(payload([review(body=OVERVIEW + + '\nCopilot reviewed some of the changed files.')])) + self.assertEqual(43, pr_review.main(['status', '7', '--repo', 'o/r'])) + out = self.out.getvalue() + self.assertIn('status=UNRECOGNIZED_REVIEWER_OUTPUT', out) + self.assertIn('coverage=UNVETTED', out) + + def test_status_still_exits_zero_on_a_full_round_and_on_a_silent_one(self) -> None: + """Failing the silent shape would fail roughly one review in twelve on this repository.""" + for body in (OVERVIEW + '\n' + COVERED, nested(), OVERVIEW): + with self.subTest(body=body[:40]): + self.answer(payload([review(body=body)])) + self.assertEqual(0, pr_review.main(['status', '7', '--repo', 'o/r'])) + + def test_wait_carries_the_same_codes_rather_than_ending_on_a_partial_round(self) -> None: + """`reviewed_head` is what decided its zero, and coverage of the head is not coverage + of the diff.""" + self.answer(payload([self.partial()])) + with mock.patch.object(pr_review.time, 'sleep'): + self.assertEqual(42, pr_review.main(['wait', '7', '--repo', 'o/r'])) + self.assertIn('coverage=PARTIAL', self.out.getvalue()) + + def test_status_and_wait_both_exit_forty_three_on_an_unrecognized_shape(self) -> None: + """Neither may report a clean pass over output the reader does not understand.""" + self.answer(payload([review(body=OVERVIEW + '\n### Confidence assessment\n')])) + self.assertEqual(43, pr_review.main(['status', '7', '--repo', 'o/r'])) + with mock.patch.object(pr_review.time, 'sleep'): + self.assertEqual(43, pr_review.main(['wait', '7', '--repo', 'o/r'])) + self.assertIn('status=UNRECOGNIZED_REVIEWER_OUTPUT', self.out.getvalue()) + + def test_an_unrecognized_shape_outranks_the_partial_coverage_code(self) -> None: + """A reader that does not understand the output cannot be believed about the diff either.""" + body = (OVERVIEW + '\n### Confidence assessment\n\nCopilot reviewed 2 out of 3 changed ' + 'files in this pull request and generated no comments.\n') + self.answer(payload([review(body=body)])) + self.assertEqual(43, pr_review.main(['status', '7', '--repo', 'o/r'])) + + def test_a_drifted_login_reaches_the_code_rather_than_timing_out_as_pending(self) -> None: + """The gate could not fire for the one drift it was written to catch. + + `reviewed_head` filters on the login, so a renamed reviewer leaves it false, and gating + the verdict behind it meant the login check never reached an exit code. The digest + printed `shapes=UNRECOGNIZED` and the wait returned 30, which is the digest disagreeing + with the code, and an automated reader settles that by believing the code. + """ + pr = payload([review(login='copilot-code-review-agent')]) + self.assertFalse(pr_review.reviewed_head(pr)) + self.answer(pr) + with mock.patch.object(pr_review.time, 'sleep') as slept: + self.assertEqual(43, pr_review.main(['wait', '7', '--repo', 'o/r', + '--timeout', '600'])) + # Terminal, so it stops rather than polling out the timeout against a landed review. + slept.assert_not_called() + out = self.out.getvalue() + self.assertIn('status=UNRECOGNIZED_REVIEWER_OUTPUT', out) + self.assertIn('shapes=UNRECOGNIZED', out) + + def test_a_shape_on_a_round_that_covers_no_head_still_reaches_the_code(self) -> None: + """The same gap with the body reader rather than the login, since both sit behind it.""" + self.answer(payload([review(oid=OLD, body=OVERVIEW + '\n### Confidence assessment\n')])) + with mock.patch.object(pr_review.time, 'sleep'): + self.assertEqual(43, pr_review.main(['wait', '7', '--repo', 'o/r', '--timeout', '0'])) + + def test_a_pending_round_with_nothing_unrecognized_still_reports_pending(self) -> None: + """The gate is not allowed to swallow the ordinary wait, which is most of them.""" + self.answer(payload([review(oid=OLD)])) + with mock.patch.object(pr_review.time, 'sleep'): + self.assertEqual(30, pr_review.main(['wait', '7', '--repo', 'o/r', '--timeout', '0'])) + + def test_a_refusal_still_exits_forty_one_rather_than_on_a_coverage_code(self) -> None: + """The refusal names the round that declined, where a coverage code names a round.""" + self.answer(payload([review(body=REFUSED)])) + with mock.patch.object(pr_review.time, 'sleep'): + self.assertEqual(41, pr_review.main(['wait', '7', '--repo', 'o/r', '--timeout', '0'])) + + class TestDigestReportsTheAnswer(GqlCase): def test_the_comment_prints_whole_under_a_marker_naming_it_terminal(self) -> None: """Its wording is what separates a refusal from a remark, so it is not truncated.""" @@ -796,6 +1214,432 @@ def test_the_digest_names_the_repository_it_read(self) -> None: self.assertIn('repo=o/r pr=7', self.out.getvalue()) +def rthread(tid: str, body: str = 'The retry count is off by one.', path: str = 'a.py', + line: int = 12, resolved: bool = False, + login: str = pr_review.REVIEWER) -> dict: + """A thread as the reply query reads it, with `path` and `line` on the thread itself.""" + return {'id': tid, 'isResolved': resolved, 'path': path, 'line': line, + 'comments': {'nodes': [{'author': {'login': login}, 'body': body}]}} + + +def page(threads: list[dict], more: bool = False, cursor: str | None = None) -> dict: + return {'nodes': threads, 'pageInfo': {'hasNextPage': more, 'endCursor': cursor}} + + +LANDED = {'id': 'c1', 'url': 'https://github.com/o/r/pull/7#discussion_r1', 'body': 'Fixed in abc.'} + + +class ReplyCase(unittest.TestCase): + """Base driving `reply` against crafted responses, so no case reaches the network.""" + + def setUp(self) -> None: + self.out = self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.enterContext(mock.patch.object(pr_review, 'origin_owner', return_value='o')) + self.docs: list[str] = [] + + def wire(self, *pages: dict, reply: dict | None = LANDED, resolved: bool = True) -> None: + """Answer the thread reads from `pages`, and each mutation from the given shape.""" + queue = list(pages) or [page([])] + + def fake(query: str, **variables: object) -> dict: + self.docs.append(query) + if 'reviewThreads' in query: + return {'repository': {'pullRequest': {'reviewThreads': queue.pop(0)}}} + if 'addPullRequestReviewThreadReply' in query: + return {'addPullRequestReviewThreadReply': {'comment': reply}} + if 'resolveReviewThread' in query: + return {'resolveReviewThread': {'thread': {'isResolved': resolved}}} + raise AssertionError(f'unexpected document: {query[:60]}') + + self.enterContext(mock.patch.object(pr_review, 'gh_graphql', side_effect=fake)) + + def run_reply(self, *extra: str) -> int: + return pr_review.main(['reply', '7', '--repo', 'o/r', '--match', 'retry count', + '--body', 'Fixed in abc.', *extra]) + + def wrote(self) -> bool: + return any('mutation' in d for d in self.docs) + + def resolved_a_thread(self) -> bool: + return any('resolveReviewThread' in d for d in self.docs) + + +class TestReplySelectsWithoutAnId(ReplyCase): + def test_the_matching_thread_is_answered_and_resolved(self) -> None: + self.wire(page([rthread('t1')])) + self.assertEqual(0, self.run_reply('--resolve')) + self.assertIn('REPLIED_AND_RESOLVED', self.out.getvalue()) + self.assertIn(LANDED['url'], self.out.getvalue()) + + def test_the_id_comes_from_the_query_rather_than_the_caller(self) -> None: + """The whole point: the id each mutation carries is one this same run just read.""" + ids = [] + + def capture(query: str, **variables: object) -> dict: + if 'reviewThreads' in query: + return {'repository': {'pullRequest': + {'reviewThreads': page([rthread('t-from-the-query')])}}} + ids.append(variables.get('threadId')) + if 'addPullRequestReviewThreadReply' in query: + return {'addPullRequestReviewThreadReply': {'comment': LANDED}} + return {'resolveReviewThread': {'thread': {'isResolved': True}}} + + with mock.patch.object(pr_review, 'gh_graphql', side_effect=capture): + self.assertEqual(0, self.run_reply('--resolve')) + self.assertEqual(['t-from-the-query', 't-from-the-query'], ids) + + def test_no_match_writes_nothing_and_lists_what_is_open(self) -> None: + """A no-match reads the same as an already-answered thread, so it stops rather than guesses.""" + self.wire(page([rthread('t1', body='An unrelated finding about naming.')])) + self.assertEqual(60, self.run_reply('--resolve')) + self.assertFalse(self.wrote()) + self.assertIn('NO_MATCH', self.out.getvalue()) + # The open threads print, or the reader's next move is to go hunting for an id. + self.assertIn('unrelated finding', self.out.getvalue()) + + def test_two_matches_refuse_rather_than_take_the_first(self) -> None: + """`head -n 1` on an ambiguous match is how a reply lands on the wrong finding.""" + self.wire(page([rthread('t1', path='a.py'), rthread('t2', path='b.py')])) + self.assertEqual(61, self.run_reply('--resolve')) + self.assertFalse(self.wrote()) + self.assertIn('AMBIGUOUS', self.out.getvalue()) + for path in ('a.py', 'b.py'): + self.assertIn(path, self.out.getvalue()) + + def test_path_narrows_an_otherwise_ambiguous_match(self) -> None: + self.wire(page([rthread('t1', path='a.py'), rthread('t2', path='b.py')])) + self.assertEqual(0, self.run_reply('--resolve', '--path', 'b.py')) + self.assertIn('b.py:12', self.out.getvalue()) + + def test_a_resolved_thread_is_not_a_candidate(self) -> None: + """It is answered, and replying again reopens a conversation nobody is reading.""" + self.wire(page([rthread('t1', resolved=True)])) + self.assertEqual(60, self.run_reply('--resolve')) + self.assertFalse(self.wrote()) + + def test_the_match_follows_the_cursor_to_the_last_page(self) -> None: + """A first page read as the whole set reports no match on a thread further along.""" + self.wire(page([rthread('t1', body='Something else.')], more=True, cursor='c1'), + page([rthread('t2')])) + self.assertEqual(0, self.run_reply('--resolve')) + self.assertIn('REPLIED_AND_RESOLVED', self.out.getvalue()) + + def test_the_match_reads_the_finding_text_rather_than_a_line_number(self) -> None: + """A fix push moves the line, and every lookup keyed to one then misses.""" + self.wire(page([rthread('t1', line=999, body='The RETRY COUNT is off by one.')])) + self.assertEqual(0, self.run_reply('--resolve')) + self.assertIn('REPLIED_AND_RESOLVED', self.out.getvalue()) + + +class TestReplyConfirmsBeforeResolving(ReplyCase): + def test_a_reply_returning_no_url_leaves_the_thread_open(self) -> None: + """Three replies posted empty and the resolves still succeeded, closing them unanswered.""" + self.wire(page([rthread('t1')]), reply={'id': 'c1', 'url': None, 'body': ''}) + self.assertEqual(62, self.run_reply('--resolve')) + self.assertFalse(self.resolved_a_thread()) + self.assertIn('REPLY_NOT_CONFIRMED', self.out.getvalue()) + + def test_a_reply_whose_body_came_back_empty_leaves_the_thread_open(self) -> None: + """A url alone says a comment exists, not that it carries the answer.""" + self.wire(page([rthread('t1')]), reply={'id': 'c1', 'url': LANDED['url'], 'body': ' '}) + self.assertEqual(62, self.run_reply('--resolve')) + self.assertFalse(self.resolved_a_thread()) + + def test_a_resolve_that_does_not_confirm_is_reported_rather_than_assumed(self) -> None: + """The reply is already posted, so silence here leaves a thread open behind an answer.""" + self.wire(page([rthread('t1')]), resolved=False) + self.assertEqual(63, self.run_reply('--resolve')) + self.assertIn('RESOLVE_NOT_CONFIRMED', self.out.getvalue()) + + def test_without_resolve_the_thread_is_answered_and_left_open(self) -> None: + """A decline is resolved once its evidence is in the thread, which is the reader's call.""" + self.wire(page([rthread('t1')])) + self.assertEqual(0, self.run_reply()) + self.assertFalse(self.resolved_a_thread()) + self.assertIn('status=REPLIED', self.out.getvalue()) + + +class TestReplyStaysInScope(ReplyCase): + def test_a_target_under_another_owner_is_refused_before_anything_is_read(self) -> None: + """The incident shape: a write that lands on a stranger's repository.""" + self.wire(page([rthread('t1')])) + code = pr_review.main(['reply', '7', '--repo', 'someone-else/r', '--match', 'retry', + '--body', 'Fixed.', '--resolve']) + self.assertEqual(64, code) + self.assertEqual([], self.docs) + self.assertIn('OUT_OF_SCOPE', self.out.getvalue()) + + def test_an_unreadable_origin_refuses_rather_than_assuming_scope(self) -> None: + """An unverified scope is not a scope, and a check that cannot run reports itself.""" + self.wire(page([rthread('t1')])) + with mock.patch.object(pr_review, 'origin_owner', return_value=None): + self.assertEqual(64, self.run_reply('--resolve')) + self.assertEqual([], self.docs) + + def test_a_sibling_repository_under_the_same_owner_is_in_scope(self) -> None: + """The fleet is one owner, and that is the case the maintainer works in daily.""" + self.wire(page([rthread('t1')])) + code = pr_review.main(['reply', '7', '--repo', 'o/some-other-repo', '--match', + 'retry count', '--body', 'Fixed in abc.', '--resolve']) + self.assertEqual(0, code) + + +class TestReplyArguments(unittest.TestCase): + def err(self, argv: list[str]) -> str: + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit): + pr_review.main(argv) + return err.getvalue() + + def test_an_empty_body_is_rejected_rather_than_posted(self) -> None: + """A thread resolved on an empty answer reads as addressed while carrying nothing.""" + for body in ('', ' '): + with self.subTest(body=body): + self.assertIn('--body', self.err( + ['reply', '7', '--repo', 'o/r', '--match', 'x', '--body', body])) + + def test_a_missing_match_is_rejected_rather_than_matching_everything(self) -> None: + self.assertIn('--match', self.err(['reply', '7', '--repo', 'o/r', '--body', 'Fixed.'])) + + def test_a_writing_option_on_a_reading_command_is_an_error(self) -> None: + """Silently ignored, it reads as an option that took effect on a run that wrote nothing.""" + for flag in (['--body', 'Fixed.'], ['--match', 'x'], ['--resolve'], ['--path', 'a.py']): + with self.subTest(flag=flag[0]): + self.assertIn(flag[0], self.err(['status', '7', '--repo', 'o/r', *flag])) + + +PIN = 'actions/checkout@' + '9' * 40 + + +def refs(body: str) -> tuple[list[str], list[str]]: + return pr_review.body_references(body) + + +class TestBodyReferences(unittest.TestCase): + """What a description is read as claiming, before anything is fetched. + + Over-reading is the failure that matters here, and the free SHA scan this replaced is the + proof: over 25 merged pull requests it raised four findings and every one was correct prose. + The cases below are those four shapes, each held to yielding nothing. + """ + + def test_an_action_pin_is_a_uses_ref_and_not_a_commit_of_this_repository(self) -> None: + """The pin's SHA belongs to the action's repository, so reading it here reports it stale.""" + uses, shas = refs(f'Bumped to `uses: {PIN}` this round.') + self.assertEqual([PIN], uses) + self.assertEqual([], shas) + + def test_a_commit_a_verb_claims_this_branch_carries_is_read(self) -> None: + for phrase in ('Fixed in `69688ec`', 'landed in 69688ec', 'Corrected by `69688ec`', + 'shipped as `69688ec`'): + with self.subTest(phrase=phrase): + self.assertEqual(['69688ec'], refs(f'{phrase} on this branch.')[1]) + + def test_a_commit_stated_as_history_is_not_a_claim_about_this_branch(self) -> None: + """PR 592's shape: a `develop` commit named as history, correct and not on this head.""" + self.assertEqual([], refs('`9d85941` merged "Reaching the Hub" whole, so the cluster ' + 'is deleted rather than annotated.')[1]) + + def test_a_sha_inside_quoted_tool_output_is_not_a_claim(self) -> None: + """PR 584's shape: a digest pasted to show what the tool prints.""" + self.assertEqual([], refs('pr=108 head=9f56a472 rounds=1 review_on_head=yes ' + 'threads=0 merge=CLEAN')[1]) + + def test_a_commit_in_another_repository_is_not_a_claim(self) -> None: + """PR 571 and 568's shape, and neither carries a URL that would mark it as elsewhere.""" + for body in ('Read at Blog `main@2b132e4`. Verdict operational.', + 'Both are on Blog\'s ground-truth `main` (`2b132e4`), verified by reading it.', + '`themes/README.md` records the upstream repository, commit `154d006e`.'): + with self.subTest(body=body): + self.assertEqual([], refs(body)[1]) + + def test_an_all_hex_english_word_is_not_read_as_a_commit(self) -> None: + """The digit backstop, so a verb this list gains later cannot start reading prose.""" + for word in ('accede', 'acceded', 'defaced', 'effaced'): + with self.subTest(word=word): + self.assertEqual([], refs(f'The clause was corrected as {word} above.')[1]) + + def test_a_hex_run_shorter_than_gits_own_floor_is_not_a_commit(self) -> None: + """Six is short enough to collide with an identifier, and git abbreviates to seven.""" + self.assertEqual([], refs('Fixed in `abc12` which is not a commit.')[1]) + + def test_each_reference_is_reported_once_however_often_it_is_quoted(self) -> None: + uses, shas = refs(f'Fixed in `69688ec`, again fixed in `69688ec`, ' + f'and `uses: {PIN}` twice: `uses: {PIN}`.') + self.assertEqual(['69688ec'], shas) + self.assertEqual([PIN], uses) + + def test_a_description_naming_nothing_yields_nothing(self) -> None: + self.assertEqual(([], []), refs('This widens a rule and adds a case for it.')) + + +class ClaimsCase(unittest.TestCase): + """Base for the `claims` path, with the pull request and every read replaced.""" + + def setUp(self) -> None: + self.compare: dict[str, tuple[int, str, str]] = {} + self.carried: set[str] | None = set() + + def run_claims(self, body: str, head: str = HEAD) -> tuple[int, str]: + def rest(path: str, jq: str | None = None) -> subprocess.CompletedProcess: + rc, out, err = self.compare.get(path, (1, '', 'gh: Not Found (HTTP 404)')) + return subprocess.CompletedProcess([path], rc, out, err) + + with mock.patch.object(pr_review, 'gql', + return_value={'headRefOid': head, 'body': body}), \ + mock.patch.object(pr_review, 'gh_rest', side_effect=rest), \ + mock.patch.object(pr_review, 'head_carries', return_value=self.carried), \ + contextlib.redirect_stdout(io.StringIO()) as out: + code = pr_review.check_claims('ptr727', 'ProjectTemplate', 7) + return code, out.getvalue() + + def answer(self, sha: str, status: str, head: str = HEAD) -> None: + self.compare[f'repos/ptr727/ProjectTemplate/compare/{sha}...{head}'] = (0, status, '') + + def unread(self, sha: str, head: str = HEAD) -> None: + self.compare[f'repos/ptr727/ProjectTemplate/compare/{sha}...{head}'] = ( + 1, '', 'error connecting to api.github.com') + + +class TestClaimsReadsCommits(ClaimsCase): + def test_a_commit_the_head_descends_from_is_clean(self) -> None: + """The count is asserted beside the verdict, since `stale=0` over nothing read is also 0.""" + for status in ('ahead', 'identical'): + with self.subTest(status=status): + self.answer('69688ec', status) + code, out = self.run_claims('Fixed in `69688ec`.') + self.assertEqual(0, code) + self.assertIn('commits=1 uses=0 stale=0 unread=0', out) + + def test_a_commit_the_repository_does_not_carry_is_stale(self) -> None: + """The amended-away SHA: the body still names the commit the branch was rewritten off.""" + code, out = self.run_claims('Fixed in `deadbee1`.') + self.assertEqual(70, code) + self.assertIn('STALE COMMIT `deadbee1`', out) + self.assertIn('carries no such commit', out) + + def test_a_commit_this_head_does_not_descend_from_is_stale_and_names_the_status(self) -> None: + """A commit that exists and is not on this branch is the rebase case, not a missing one.""" + self.answer('dbd1cdc', 'diverged') + code, out = self.run_claims('Landed in `dbd1cdc`.') + self.assertEqual(70, code) + self.assertIn('does not descend from it (diverged)', out) + + def test_a_commit_github_did_not_answer_for_is_undecided_rather_than_stale(self) -> None: + """A network failure reported as a stale description sends a reader to fix correct prose.""" + self.unread('69688ec') + self.answer('a6d7a4b', 'ahead') + code, out = self.run_claims('Fixed in `69688ec`, then corrected in `a6d7a4b`.') + self.assertEqual(0, code) + self.assertIn('unread=1', out) + self.assertIn('left undecided', out) + + def test_every_reference_undecided_reports_no_verdict_rather_than_a_clean_pass(self) -> None: + """`stale=0` from a check that read nothing renders exactly like `stale=0` from one that did.""" + self.unread('69688ec') + code, out = self.run_claims('Fixed in `69688ec`.') + self.assertEqual(71, code) + self.assertIn('NOTHING_WAS_READ', out) + + +class TestClaimsReadsUsesRefs(ClaimsCase): + def test_a_ref_the_head_tree_carries_is_clean(self) -> None: + self.carried = {PIN} + code, out = self.run_claims(f'Pinned to `uses: {PIN}`.') + self.assertEqual(0, code) + self.assertIn('uses=1 stale=0', out) + + def test_a_ref_no_file_at_head_carries_is_stale(self) -> None: + """The bump the branch reverted, with the description still quoting the pin it named.""" + self.carried = set() + code, out = self.run_claims(f'Pinned to `uses: {PIN}`.') + self.assertEqual(70, code) + self.assertIn(f'STALE USES `{PIN}`', out) + + def test_an_unreadable_head_tree_is_undecided_rather_than_stale(self) -> None: + self.carried = None + code, out = self.run_claims(f'Pinned to `uses: {PIN}`.') + self.assertEqual(71, code) + self.assertIn('NOTHING_WAS_READ', out) + + def test_a_description_quoting_no_ref_reads_no_tree(self) -> None: + """The archive is one request, and a body naming nothing has no reason to spend it.""" + with mock.patch.object(pr_review, 'gql', + return_value={'headRefOid': HEAD, 'body': 'Prose only.'}), \ + mock.patch.object(pr_review, 'head_carries') as tree, \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(0, pr_review.check_claims('ptr727', 'ProjectTemplate', 7)) + tree.assert_not_called() + + def test_a_stale_ref_and_a_stale_commit_are_both_reported(self) -> None: + """One failing reference does not end the read, since a body drifts in more than one place.""" + self.carried = set() + code, out = self.run_claims(f'Fixed in `deadbee1`, pinned to `uses: {PIN}`.') + self.assertEqual(70, code) + self.assertIn('STALE COMMIT', out) + self.assertIn('STALE USES', out) + self.assertIn('stale=2', out) + + +class TestClaimsIsReadOnly(unittest.TestCase): + def test_the_head_tree_read_asks_for_an_archive_and_nothing_else(self) -> None: + source = (REPO / 'scripts' / 'pr_review.py').read_text(encoding='utf-8') + self.assertIn('tarball/{head}', source) + + def test_an_unreadable_archive_reads_as_undecided_rather_than_as_an_empty_tree(self) -> None: + """An empty tree carries no ref, so reading a failed download as one reports every ref stale.""" + for outcome in (subprocess.CompletedProcess([], 1, b'', b''),): + with mock.patch.object(pr_review.subprocess, 'run', return_value=outcome): + self.assertIsNone(pr_review.head_carries('ptr727', 'ProjectTemplate', HEAD, [PIN])) + + def test_gh_being_absent_leaves_the_tree_undecided_rather_than_aborting_claims(self) -> None: + """This read cannot go through `gh_rest`, so it carries that helper's guards itself. + + Raising here would abort a run mid-way, where every other unreadable answer in this + subcommand reports undecided and lets the caller see what was decided. + """ + with mock.patch.object(pr_review.subprocess, 'run', side_effect=FileNotFoundError): + self.assertIsNone(pr_review.head_carries('ptr727', 'ProjectTemplate', HEAD, [PIN])) + + def test_a_hung_download_times_out_rather_than_hanging_the_run(self) -> None: + with mock.patch.object(pr_review.subprocess, 'run', + side_effect=subprocess.TimeoutExpired('gh', 1)): + self.assertIsNone(pr_review.head_carries('ptr727', 'ProjectTemplate', HEAD, [PIN])) + + def test_the_archive_read_is_bounded_by_a_timeout(self) -> None: + """An unbounded read of a whole repository is a wait with no end and no message.""" + with mock.patch.object(pr_review.subprocess, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 1, b'', b'') + pr_review.head_carries('ptr727', 'ProjectTemplate', HEAD, [PIN]) + self.assertEqual(pr_review.TARBALL_TIMEOUT, run.call_args.kwargs['timeout']) + + def test_an_archive_that_is_not_a_readable_tarball_is_undecided(self) -> None: + proc = subprocess.CompletedProcess([], 0, b'not a gzip stream', b'') + with mock.patch.object(pr_review.subprocess, 'run', return_value=proc): + self.assertIsNone(pr_review.head_carries('ptr727', 'ProjectTemplate', HEAD, [PIN])) + + def test_a_ref_is_matched_as_bytes_so_an_undecodable_file_is_still_searched(self) -> None: + """Skipping a file this cannot decode is how a present ref reads as absent.""" + source = (REPO / 'scripts' / 'pr_review.py').read_text(encoding='utf-8') + self.assertIn('n in blob', source) + + def test_an_absent_status_is_absence_and_a_rate_limit_is_not(self) -> None: + """Reading a 403 as a missing commit reports a correct description as contradicting itself.""" + for status, absent in (('404', True), ('422', True), ('403', False), ('401', False), + ('500', False)): + with self.subTest(status=status): + proc = subprocess.CompletedProcess([], 1, '', f'gh: (HTTP {status})') + self.assertEqual(absent, pr_review.answered_absent(proc)) + + def test_a_network_error_carrying_no_status_is_not_absence(self) -> None: + proc = subprocess.CompletedProcess([], 1, '', 'error connecting to api.github.com') + self.assertFalse(pr_review.answered_absent(proc)) + + def test_gh_being_absent_does_not_raise(self) -> None: + with mock.patch.object(pr_review.subprocess, 'run', side_effect=FileNotFoundError): + self.assertEqual(1, pr_review.gh_rest('repos/o/r').returncode) + + class TestContract(unittest.TestCase): def test_the_reviewer_login_matches_the_runbook_graphql_form(self) -> None: """GraphQL drops the `[bot]` suffix REST carries, and this script is GraphQL-only. @@ -818,14 +1662,76 @@ def test_the_refusal_pattern_is_the_runbook_alternation(self) -> None: # The published filter is single-quoted, which no spelling of the apostrophe survives. self.assertNotIn("'", pr_review.REFUSAL.pattern) - def test_no_mutation_reaches_this_script(self) -> None: - """Mutations stay as explicit `gh` calls so the write-guard hook and review still see them.""" + def test_the_vetted_coverage_spellings_are_the_ones_the_runbook_publishes(self) -> None: + """The wording drifts, so the vetted list is read out of the runbook rather than recalled. + + The two published lines are pulled out with the script's own opener and handed to its own + parser, which holds the pair in step in both directions: a spelling the runbook adds and + this cannot read fails here, and so does one this reads that the runbook never named. + """ + text = RUNBOOK.read_text(encoding='utf-8') + published = [ln.strip() for ln in text.splitlines() + if pr_review.is_coverage_line(ln)] + self.assertEqual(2, len(published), published) + for line in published: + with self.subTest(line=line[:44]): + self.assertIsNotNone(pr_review.read_coverage(line)) + # The published filter is single-quoted in the shell, which no apostrophe survives. + self.assertNotIn("'", pr_review.COVERAGE_COUNTS.pattern) + + def test_the_runbook_names_the_partial_round_as_a_state_that_blocks_a_merge(self) -> None: + """A verify step reading `commit.oid` alone is what let five partial rounds merge.""" + text = RUNBOOK.read_text(encoding='utf-8') + self.assertIn('Coverage of the head is not coverage of the diff', text) + + def test_the_only_writes_are_the_two_the_reply_path_owns(self) -> None: + """One writing path, and everything else that changes state stays out of this script. + + The read subcommands are the bulk of it and a mutation reaching them is a digest that + writes, so the whole-source guard stays and is narrowed to the two documents `reply` + needs rather than dropped when the first of them arrived. + """ source = (REPO / 'scripts' / 'pr_review.py').read_text(encoding='utf-8') - # The GraphQL keyword is matched with its opening token, so naming the runbook is not a hit. - for verb in ('mutation(', 'mutation{', 'mutation {', '-X POST', '-X PATCH', '-X PUT', - '-X DELETE', 'gh pr merge', 'gh pr review'): + for verb in ('-X POST', '-X PATCH', '-X PUT', '-X DELETE', '--method', + 'gh pr merge', 'gh pr review', 'gh pr edit', 'requestReviews'): with self.subTest(verb=verb): - self.assertFalse(verb in source, f'{verb!r} is a state-changing call in a read-only script') + self.assertNotIn(verb, source, f'{verb!r} is a state-changing call this script ' + 'has no reason to make') + # `mutation(` opens a document, so the count is the number of documents. + # A third arriving is a write nobody reviewed as one rather than a style drift. + self.assertEqual(2, source.count('mutation(')) + self.assertIn('addPullRequestReviewThreadReply', source) + self.assertIn('resolveReviewThread', source) + + def test_the_mutations_are_the_ones_the_runbook_publishes(self) -> None: + """A helper performing a different write than the documented one is undocumented.""" + text = RUNBOOK.read_text(encoding='utf-8') + for name in ('addPullRequestReviewThreadReply', 'resolveReviewThread'): + with self.subTest(mutation=name): + self.assertIn(name, text) + + def test_no_argument_accepts_a_thread_id(self) -> None: + """The failure is an id typed into a mutation, so the fix is having nowhere to type one. + + A parser that takes an id restores the failing shape however plainly the docs discourage + it, which is the lesson the two prior instances taught: the rule was known and read. + """ + source = (REPO / 'scripts' / 'pr_review.py').read_text(encoding='utf-8') + # A node id literal anywhere in the source is an example a hand copies out of it. + self.assertNotIn('PRRT_', source) + parser_options = re.findall(r"add_argument\('(--[a-z-]+)'", source) + for opt in parser_options: + with self.subTest(option=opt): + self.assertNotIn('id', opt.replace('--', '').split('-')) + # The selector is the finding's text, and it is required rather than defaulted. + self.assertIn("'--match'", source) + + def test_no_write_suppresses_or_forces_its_own_result(self) -> None: + """A mutation whose output is discarded is a write nobody can say landed.""" + source = (REPO / 'scripts' / 'pr_review.py').read_text(encoding='utf-8') + for tail in ('>/dev/null', '2>/dev/null', '&>/dev/null', '|| true', '|| :', 'shell=True'): + with self.subTest(tail=tail): + self.assertNotIn(tail, source) def test_the_guard_tests_the_window_the_queries_actually_read(self) -> None: """A guard measuring one number while the query fetches another reads clean on drift.""" @@ -909,7 +1815,7 @@ class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: """A module whose cases fail to load still reports OK, which is a pass proving nothing.""" loaded = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]) - self.assertGreaterEqual(loaded.countTestCases(), 20) + self.assertGreaterEqual(loaded.countTestCases(), 48) if __name__ == '__main__': diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index ffe6d9df..cbe6b2ad 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -425,6 +425,30 @@ def test_a_step_marker_does_not_hide_a_real_run_on(self) -> None: """Stripping the marker must not stop the rule seeing what follows it.""" self.assertEqual(['comment-wrap'], self.flag('a.sh', f'# 1. {self.RUN_ON}\n')) + def test_an_ellipsis_is_not_a_sentence_terminator(self) -> None: + """An ellipsis marks an elision inside one sentence, so its closing dot does not end one. + + Reading it as a terminator made a schematic comment two sentences, and the split the rule + then asked for would have broken the fragment the line exists to show. + """ + self.assertEqual([], self.flag('a.py', '# .editorconfig: [glob] ... end_of_line = lf\n')) + + def test_an_ellipsis_does_not_hide_a_real_run_on(self) -> None: + """The guard is one dot wide, so a terminator later on the line is still caught.""" + self.assertEqual(['comment-wrap'], + self.flag('a.py', '# Take the first ... and the rest. Another sentence.\n')) + + def test_an_ellipsis_before_a_question_or_bang_still_terminates(self) -> None: + """The guard covers the dot alternative only, since `?` and `!` after an ellipsis do end a sentence. + + Guarding the whole terminator class would have read these as one sentence, because the `?` and + the `!` are each preceded by the ellipsis' closing dot. + """ + for terminator in ('?', '!'): + with self.subTest(terminator=terminator): + self.assertEqual(['comment-wrap'], + self.flag('a.py', f'# Really...{terminator} Yes it does.\n')) + def test_a_step_marker_does_not_hide_a_lowercase_opening(self) -> None: """Before the strip, the digit read as the opening character, so comment-case never fired.""" self.assertEqual(['comment-case'], self.flag('a.sh', '# 1. deploy the hook.\n')) @@ -610,6 +634,44 @@ def test_a_genuine_continuation_is_not_a_case_error(self) -> None: self.assertEqual(['comment-wrap'], self.flag('a.py', '# A sentence that keeps\n# going onto the next line.\n')) + def test_a_commented_out_key_is_not_a_sentence(self) -> None: + """`# ignore:` heading a disabled block is configuration, and capitalizing it breaks the key. + + The rule pointed at a codecov snippet whose commented-out `ignore:` block a repo uncomments, + so following the finding would have corrupted the file the exemption exists to protect. + """ + for text in ('# ignore:\n# - "Sandbox/**"\n', '# Outputs:\n'): + with self.subTest(text=text.splitlines()[0]): + self.assertEqual([], self.flag('a.yml', text)) + + def test_a_colon_ending_real_prose_is_still_judged(self) -> None: + """The exemption is one token wide, since prose closing on a colon has words before it.""" + self.assertEqual(['comment-case'], self.flag('a.yml', '# the outputs are these:\n')) + + def test_a_label_opening_a_definition_keeps_its_name(self) -> None: + """`publish` names the output being documented, so capitalizing it renames what ships.""" + for text in ("# publish - 'true' when this run should publish.\n", + "# stable - 'true' when the target branch is main.\n", + '# payload-file - create-or-update the ruleset by name.\n'): + with self.subTest(text=text.strip()[:30]): + self.assertEqual([], self.flag('a.yml', text)) + + def test_a_continuation_dash_is_not_read_as_a_label(self) -> None: + """A wrapped line whose first word takes a spaced dash is a parenthetical, not a definition. + + Two live instances in the tree have this shape, and exempting them would have hidden the + very construction the dash rule exists to catch. The label test is scoped to a line that + opens a definition, so a continuation never reaches it. + """ + self.assertEqual(['comment-wrap'], + self.flag('a.yml', '# It needs every other\n' + '# build - a target disabled on a smoke PR - does not.\n')) + + def test_a_label_does_not_hide_a_run_on(self) -> None: + """The exemption answers the opening word only, so the sentence checks still see the prose.""" + self.assertEqual(['comment-wrap'], + self.flag('a.yml', f'# publish - {self.RUN_ON}\n')) + def test_a_capitalized_opening_and_a_code_token_are_both_accepted(self) -> None: """A backticked identifier does not open in lowercase, so it needs no restructuring.""" for text in ('# The details element is allowed.\n', '# `ruff format` runs first.\n'): @@ -1540,6 +1602,318 @@ def test_an_unreadable_file_is_skipped_rather_than_raising(self) -> None: self.assertEqual([], prose_lint.check_file(self.tmp / 'absent.md', {'dupword'})) +# The gate reads this file, so a bait path is assembled rather than written. +# A literal would be a finding in the very file that defines the rule. +# This file already takes that approach for non-ASCII, which it writes as escapes. +# `adalovelace` is a constructed account name, never an account on any machine here. +BAIT_USER = 'adalovelace' +NIX_HOME = f'/home/{BAIT_USER}' +MAC_HOME = f'/Users/{BAIT_USER}' +WIN_HOME = f'C:\\Users\\{BAIT_USER}' + + +class TestHomePath(BaitCase): + """The pattern-detectable half of the representative-data rule, and only that half. + + Every fixture below uses a constructed account name. Writing the maintainer's own path into + a committed test would be the exact exposure the rule exists to prevent, in the file that + implements the rule. + + The shapes were chosen against the corpus rather than from the list the backlog recorded. A + bare drive letter matched 11 files here and named a path in none of them, because an escaped + newline after any word ending in a letter and a colon reads as one, so `jobs:\\n` inside a + fixture is a drive letter. The shape kept is a drive letter followed by `Users`. + """ + + def test_a_home_path_naming_a_person_is_flagged(self) -> None: + for text in (f'See {NIX_HOME}/notes.txt for the log.\n', + f'See {MAC_HOME}/notes.txt for the log.\n', + f'See {WIN_HOME}\\notes.txt for the log.\n'): + with self.subTest(text=text.strip()): + self.assertIn('home-path', self.kinds(text, {'home-path'})) + + def test_the_documentation_placeholder_is_not_a_finding(self) -> None: + """The rule's own wording quotes these shapes to describe them, and must survive its gate. + + A real user segment is required, so the placeholder form does not match and no exemption + list has to carry the files that describe the rule. A stale exemption list is what turns + a gate into a work list that damages correct documents. + """ + for text in ('The shapes are `/home/<name>` and `C:\\Users\\<name>`.\n', + 'A path under `/Users/<name>` is the macOS form.\n'): + with self.subTest(text=text.strip()): + self.assertNotIn('home-path', self.kinds(text, {'home-path'})) + + def test_a_container_account_is_not_a_personal_home(self) -> None: + """`/home/vscode` is a fixed name a devcontainer image ships, so it names no environment. + + Every one of this repository's 15 home-path hits is this shape, from the devcontainer + snippets and the doc describing them, so a rule without this exemption would open with a + work list of 15 findings and no true positive among them. + """ + for account in ('vscode', 'runner', 'root', 'ubuntu', 'node'): + with self.subTest(account=account): + self.assertNotIn('home-path', + self.kinds(f'Mounted at /home/{account}/.ssh here.\n', + {'home-path'})) + + def test_a_path_inside_a_fenced_block_is_still_a_finding(self) -> None: + """A transcript pasted from a terminal is the exposure, and it arrives fenced. + + Every other prose rule skips a fenced block because it holds code rather than prose. This + one reads it, since the rule gates a literal path rather than a sentence. + """ + self.assertIn('home-path', + self.kinds(f'```text\n$ ls {NIX_HOME}/keys\n```\n', + {'home-path'})) + + def test_a_path_in_a_config_value_is_still_a_finding(self) -> None: + """A bind mount naming a real home is the exposure in its most consequential form.""" + self.assertIn('home-path', + self.kinds(f'{{ "target": "{NIX_HOME}/.ssh/id_ed25519.pub" }}\n', + {'home-path'}, 'a.json')) + + def test_a_relative_or_tilde_path_is_not_a_finding(self) -> None: + """`~/.ssh` names no account, which is the form the docs are supposed to use.""" + for text in ('Copy `~/.ssh/id_ed25519.pub` into place.\n', + f'The path `home/{BAIT_USER}` is relative.\n'): + with self.subTest(text=text.strip()): + self.assertNotIn('home-path', self.kinds(text, {'home-path'})) + + def test_the_windows_branch_is_case_insensitive(self) -> None: + """Windows filesystems are case-insensitive, so a pasted path may be any casing.""" + for form in (f'C:\\users\\{BAIT_USER}', f'c:\\USERS\\{BAIT_USER}'): + with self.subTest(form=form): + self.assertIn('home-path', self.kinds(f'See {form} here.\n', {'home-path'})) + + def test_the_posix_branch_stays_case_sensitive(self) -> None: + """A lowercase `/users/` is the commonest REST path there is, and names no home. + + This is why the case-insensitive fix is scoped to the drive-letter branch rather than + applied to the whole pattern: widening it would flag every API route in every doc. + """ + for text in (f'GET https://api.example.com/users/{BAIT_USER} returns the record.\n', + f'The route is `/users/{BAIT_USER}` in the API.\n'): + with self.subTest(text=text.strip()): + self.assertNotIn('home-path', self.kinds(text, {'home-path'})) + + def test_a_bare_drive_letter_is_not_a_shape(self) -> None: + """Kept as a case because the backlog recorded it as one and the corpus rejected it. + + These are the two forms that made it unworkable: an escaped newline in a fixture, and a + temporary directory in a string literal. + """ + for text in ("key: 'jobs:\\n a:'\n", 'var p = "C:\\tmp\\out";\n'): + with self.subTest(text=text.strip()): + self.assertNotIn('home-path', self.kinds(text, {'home-path'})) + + def test_the_rule_runs_by_default(self) -> None: + """A rule outside DEFAULT_RULES reads as enforced while nothing runs it.""" + self.assertIn('home-path', prose_lint.DEFAULT_RULES) + self.assertIn('home-path', prose_lint.RULES) + + def test_neither_source_file_carries_a_literal_home_path(self) -> None: + """Both files are read by the rule they implement, so the bait is assembled. + + This is the same guarantee the tier tables carry for non-ASCII, and it is the reason the + constants above exist. Written as literals, the gate would report its own definition and + its own cases, and the only repair would be an exemption naming these files, which is the + stale-exemption shape that hands out a work list damaging correct documents. + """ + for name in ('prose_lint.py', 'test_prose_lint.py'): + with self.subTest(source=name): + found = prose_lint.check_file(Path(__file__).with_name(name), {'home-path'}) + self.assertEqual([], found) + + +class TestOperationalExemption(unittest.TestCase): + """An operational repository's runbook carries the path an operator types. + + That is the repository's own content rather than an agent quoting an environment it observed, + which is the distinction the rule is about. + """ + + def setUp(self) -> None: + self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.err = self.enterContext(contextlib.redirect_stderr(io.StringIO())) + + def _payload(self, *parts: str) -> None: + target = self.tmp.joinpath(*parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('{}\n', encoding='utf-8') + + def test_an_operational_checkout_is_read_from_what_it_carries(self) -> None: + """`spec/files.json` declares the payload per model, so the repository states its own.""" + self._payload('repo-config', 'operational', 'develop.json') + self.assertTrue(prose_lint.operational_checkout(self.tmp)) + + def test_a_release_checkout_is_not_operational(self) -> None: + self._payload('repo-config', 'develop.json') + self.assertFalse(prose_lint.operational_checkout(self.tmp)) + + def test_the_hub_carrying_both_payloads_is_not_operational(self) -> None: + """The hub is the template for each model, so carrying the release payload decides it. + + Read as operational, the hub would exempt itself from a rule it authors, which is the + one repository where that matters most. + """ + self._payload('repo-config', 'develop.json') + self._payload('repo-config', 'operational', 'develop.json') + self.assertFalse(prose_lint.operational_checkout(self.tmp)) + + def test_a_checkout_carrying_neither_payload_is_not_operational(self) -> None: + """An unknown model is gated rather than exempted, since exempting on doubt is the risk.""" + self.assertFalse(prose_lint.operational_checkout(self.tmp)) + + def test_the_skip_is_announced_rather_than_silent(self) -> None: + """A rule that stops running without saying so reads as a rule that passed.""" + self._payload('repo-config', 'operational', 'develop.json') + bait = self.tmp / 'runbook.md' + bait.write_text(f'Deploy into {NIX_HOME}/stack here.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[bait]): + self.assertEqual(0, prose_lint.main(['--check', 'home-path'])) + self.assertIn('operational repository', self.err.getvalue()) + + def test_a_release_repository_still_reports_the_finding(self) -> None: + """The exemption must not be the whole rule.""" + self._payload('repo-config', 'develop.json') + bait = self.tmp / 'runbook.md' + bait.write_text(f'Deploy into {NIX_HOME}/stack here.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[bait]): + self.assertEqual(1, prose_lint.main(['--check', 'home-path'])) + + +class TestDiffScopeFloor(unittest.TestCase): + """The assertion every `--diff` verdict rests on, that the run matched something it was given. + + Four routes to the same false clean are on record, each closed by a guard written after a + reviewer noticed it: an unresolvable base widening to a whole-tree scan, a multi-line `paths` + input read only to its first newline, a diff taken in one repository while scanning another, + and a path under no repository at all. Per-route guards are the wrong shape for a fifth, + because the fifth is found by a reviewer or not at all. This asserts the floor instead. + + Zero is not the test on its own. A change touching only files the rules do not read matches + nothing and is honestly clean, so the comparison is against the diff's own list of files this + run could have read. + """ + + def setUp(self) -> None: + self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.err = self.enterContext(contextlib.redirect_stderr(io.StringIO())) + + def test_a_diff_naming_readable_files_that_match_nothing_is_refused(self) -> None: + """The floor itself: a non-empty diff, a readable file in it, and nothing scanned.""" + named = self.tmp / 'named.md' + named.write_text('A clean line.\n', encoding='utf-8') + elsewhere = self.tmp / 'elsewhere.md' + elsewhere.write_text('A clean line.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[elsewhere]), \ + mock.patch.object(prose_lint, 'changed_lines', return_value={'named.md': {1}}): + self.assertEqual(2, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + self.assertIn('named.md', self.err.getvalue()) + + def test_a_diff_of_only_unreadable_files_is_a_clean_run(self) -> None: + """The honest limit, and the reason zero alone cannot be the test. + + An image or a lock file is a real change the rules do not read, so it matches nothing and + the run is clean rather than broken. Refusing here would make the gate cry wolf on a + commit that adds a logo, which is how a safety check stops being read. + """ + blob = self.tmp / 'logo.png' + blob.write_bytes(b'\x89PNG\r\n\x1a\n\x00\x00\x00binary') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', return_value={'logo.png': {1}}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + + def test_a_diff_naming_a_file_that_no_longer_exists_is_a_clean_run(self) -> None: + """A deletion names a path with nothing behind it, which this run cannot have read.""" + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', return_value={'gone.md': {1}}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + + def test_an_empty_diff_is_not_a_scoping_failure(self) -> None: + """A branch level with its base names no files, which is a result rather than a fault.""" + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', return_value={}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + + def test_a_run_that_matched_a_file_asserts_nothing_further(self) -> None: + """The floor is a floor. One match clears it, and the findings decide the exit code.""" + bait = self.tmp / 'bait.md' + bait.write_text(f'{DUP} thing\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[bait]), \ + mock.patch.object(prose_lint, 'changed_lines', + return_value={prose_lint.rel(bait): {1}}): + self.assertEqual(1, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + + def test_a_deliberately_narrowed_scan_is_not_told_the_narrowing_is_a_defect(self) -> None: + """Asking about one subtree while the change sits in another is a request, not a failure.""" + (self.tmp / 'scripts').mkdir() + changed = self.tmp / 'scripts' / 'tool.py' + changed.write_text('# A clean comment.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', + return_value={'scripts/tool.py': {1}}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD', + 'catalog'])) + + def test_an_excluded_file_is_not_counted_as_one_the_run_should_have_read(self) -> None: + """`--exclude` removes a file from the scan, so it cannot also be evidence of a failure.""" + skipped = self.tmp / 'vendor.md' + skipped.write_text('A clean line.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', return_value={'vendor.md': {1}}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD', + '--exclude', 'vendor'])) + + def test_a_generated_tree_is_not_counted_as_one_the_run_should_have_read(self) -> None: + """Discovery drops `reports/`, so a change confined to it matches nothing by design.""" + (self.tmp / 'reports').mkdir() + generated = self.tmp / 'reports' / 'divergences.md' + generated.write_text('A clean line.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)), \ + mock.patch.object(prose_lint, 'discover', return_value=[]), \ + mock.patch.object(prose_lint, 'changed_lines', + return_value={'reports/divergences.md': {1}}): + self.assertEqual(0, prose_lint.main(['--check', 'dupword', '--diff', 'HEAD'])) + + def test_diff_keys_resolve_against_the_repository_rather_than_the_working_directory( + self) -> None: + """The fifth route, which no per-route guard covers and this one found. + + Run from a subdirectory, `git diff` reports repository-relative keys while discovery keys + off the directory the run started in, so the intersection is empty and the run reports + clean. Reading the diff's paths against the working directory would leave this list empty + in exactly that case, making the floor silent where it is most needed. + """ + (self.tmp / 'scripts').mkdir() + nested = self.tmp / 'scripts' / 'tool.py' + nested.write_text('# A clean comment.\n', encoding='utf-8') + with mock.patch.object(prose_lint, 'repo_root', return_value=str(self.tmp)): + found = prose_lint.unread_diff_files({'scripts/tool.py': {1}}, ['.'], ()) + self.assertEqual(['scripts/tool.py'], found) + + def test_asked_about_reads_a_prefix_as_a_directory_boundary(self) -> None: + """`catalog` must not claim `catalogue/x.md`, which shares its first seven characters.""" + self.assertTrue(prose_lint.asked_about('catalog/x.md', ['catalog'])) + self.assertTrue(prose_lint.asked_about('catalog/x.md', ['catalog/'])) + self.assertTrue(prose_lint.asked_about('README.md', ['README.md'])) + self.assertTrue(prose_lint.asked_about('anything/at/all.md', ['.'])) + self.assertFalse(prose_lint.asked_about('catalogue/x.md', ['catalog'])) + self.assertFalse(prose_lint.asked_about('docs/x.md', ['catalog'])) + + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: """A module whose cases fail to load still reports OK, which is a pass proving nothing.""" diff --git a/scripts/test_repo_gate.py b/scripts/test_repo_gate.py index 9f637d08..b31e9c91 100644 --- a/scripts/test_repo_gate.py +++ b/scripts/test_repo_gate.py @@ -8,7 +8,7 @@ Run as `python3 scripts/test_repo_gate.py`, or under `python3 -m unittest discover -s scripts`. """ from __future__ import annotations -import contextlib, io, re, shutil, sys, tempfile, unittest +import contextlib, io, re, shutil, subprocess, sys, tempfile, unittest from pathlib import Path from unittest import mock @@ -50,7 +50,7 @@ def test_the_table_holds_every_check_the_cli_offers(self) -> None: def test_the_patterns_compile_and_carry_no_invisible_characters(self) -> None: """A shell heredoc turns a backslash escape into a control character no diff shows.""" - for name in ('USES', 'PIN', 'WORKFLOW'): + for name in ('USES', 'PIN', 'WORKFLOW', 'HTTP_STATUS'): with self.subTest(pattern=name): self.assertTrue(re.compile(getattr(repo_gate, name).pattern)) for action in repo_gate.SHA_EXCEPTIONS: @@ -89,6 +89,213 @@ def test_a_local_reusable_workflow_is_not_an_action(self) -> None: self.assertEqual([], repo_gate.check_sha_pin(self.tmp, files)) +class ResolveCase(TreeCase): + """Base for the resolvability pass, with the owner fixed and the network replaced. + + No case here reaches GitHub. The point is what each answer is read as, and a case that made a + live call would report the fleet's current state rather than this script's reading of it. + """ + + OWNER = 'ptr727' + + def setUp(self) -> None: + super().setUp() + repo_gate.NOTES.clear() + self.enterContext(mock.patch.object(repo_gate, 'origin_owner', + return_value=self.OWNER)) + + def pins(self, *refs: str) -> list[str]: + steps = ''.join(f' - uses: {r}\n' for r in refs) + return self.workflow(f'jobs:\n a:\n steps:\n{steps}') + + def answers(self, mapping: dict[str, bool | None]) -> mock.MagicMock: + """Replace the GitHub read with a table keyed on the API path it would have called.""" + stub = mock.MagicMock(side_effect=lambda path: mapping.get(path)) + self.enterContext(mock.patch.object(repo_gate, 'gh_exists', stub)) + return stub + + +class TestShaPinResolves(ResolveCase): + def test_a_forty_hex_pin_that_resolves_to_no_commit_is_flagged(self) -> None: + """The whole point: a fabricated pin satisfies the shape and fails the reference.""" + self.answers({f'repos/{self.OWNER}/Fleet/commits/{"c" * 40}': False, + f'repos/{self.OWNER}/Fleet': True}) + hits = repo_gate.check_sha_pin(self.tmp, self.pins(f'{self.OWNER}/Fleet@{"c" * 40}')) + self.assertEqual(1, len(hits)) + self.assertIn('resolves to no commit', hits[0]) + + def test_a_pin_that_resolves_is_accepted(self) -> None: + self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': True}) + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, + self.pins(f'{self.OWNER}/Fleet@{PINNED}'))) + + def test_an_action_path_within_a_repository_resolves_against_the_repository(self) -> None: + """`owner/repo/path/to/action@sha` is one repository, and the path is not part of it.""" + stub = self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': True}) + ref = f'{self.OWNER}/Fleet/.github/actions/prose-gate@{PINNED}' + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, self.pins(ref))) + stub.assert_called_once_with(f'repos/{self.OWNER}/Fleet/commits/{PINNED}') + + def test_a_pin_under_another_owner_is_read_for_shape_and_never_fetched(self) -> None: + """The scope is a decision, so a case holds it rather than leaving it to the docstring.""" + stub = self.answers({}) + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, + self.pins(f'actions/checkout@{PINNED}'))) + stub.assert_not_called() + self.assertIn('1 under another owner', repo_gate.NOTES[0]) + + def test_an_unreadable_owner_leaves_every_pin_on_shape_alone(self) -> None: + """A checkout with no origin cannot say which pins are the fleet's, so it fetches none.""" + stub = self.answers({}) + with mock.patch.object(repo_gate, 'origin_owner', return_value=None): + self.assertEqual([], repo_gate.check_sha_pin( + self.tmp, self.pins(f'{self.OWNER}/Fleet@{PINNED}'))) + stub.assert_not_called() + + def test_an_unreadable_owner_is_not_reported_as_another_owner(self) -> None: + """The two are different states, and this note exists to describe the narrowing exactly. + + A pin under this owner skipped because the origin is unreadable is not a pin belonging to + somebody else, and saying so in the one line that reports coverage is the same false clean + the note was added to prevent. + """ + self.answers({}) + with mock.patch.object(repo_gate, 'origin_owner', return_value=None): + repo_gate.check_sha_pin(self.tmp, self.pins(f'{self.OWNER}/Fleet@{PINNED}')) + self.assertIn('0 under another owner', repo_gate.NOTES[0]) + self.assertIn('1 whose owner could not be compared', repo_gate.NOTES[0]) + + def test_the_note_carries_every_count_including_the_zeroes(self) -> None: + """One fixed shape per run, so a zero in any position is as visible as a count.""" + self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': True}) + repo_gate.check_sha_pin(self.tmp, self.pins(f'{self.OWNER}/Fleet@{PINNED}')) + for fragment in ('resolved 1 pin(s)', '0 under another owner', + '0 whose owner could not be compared', '0 GitHub did not answer for'): + with self.subTest(fragment=fragment): + self.assertIn(fragment, repo_gate.NOTES[0]) + + def test_the_note_prints_where_every_count_is_zero(self) -> None: + """The all-zero run is the one the note exists for, and it was the one it skipped. + + Guarded on a non-zero counter, the note went silent on a repository carrying no workflow + at all, which is exactly the clean line the docstring says nobody should have to infer + the check's narrowness from. + """ + repo_gate.check_sha_pin(self.tmp, []) + self.assertEqual(1, len(repo_gate.NOTES)) + for fragment in ('resolved 0 pin(s)', '0 under another owner', + '0 whose owner could not be compared', '0 GitHub did not answer for'): + with self.subTest(fragment=fragment): + self.assertIn(fragment, repo_gate.NOTES[0]) + + def test_a_pin_github_did_not_answer_for_is_skipped_rather_than_failed(self) -> None: + """Offline, unauthenticated and rate-limited all read as nothing learned, not as absent.""" + self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': None}) + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, + self.pins(f'{self.OWNER}/Fleet@{PINNED}'))) + self.assertIn('1 GitHub did not answer for', repo_gate.NOTES[0]) + + def test_a_missing_commit_in_an_unreadable_repository_is_not_a_finding(self) -> None: + """A repository-scoped token 404s on a sibling, which is not the pin being wrong.""" + self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': False, + f'repos/{self.OWNER}/Fleet': None}) + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, + self.pins(f'{self.OWNER}/Fleet@{PINNED}'))) + self.assertIn('1 GitHub did not answer for', repo_gate.NOTES[0]) + + def test_one_pin_named_twice_is_read_once(self) -> None: + """A pin repeats across workflows, and a gate that re-fetches it burns the rate limit.""" + stub = self.answers({f'repos/{self.OWNER}/Fleet/commits/{PINNED}': True}) + files = self.pins(f'{self.OWNER}/Fleet@{PINNED}') + files += self.workflow(f'jobs:\n b:\n steps:\n - uses: {self.OWNER}/Fleet@{PINNED}\n', + name='x.yml') + self.assertEqual([], repo_gate.check_sha_pin(self.tmp, files)) + self.assertEqual(1, stub.call_count) + + def test_a_floating_ref_is_never_fetched(self) -> None: + """The shape fails first, so a ref that is not a SHA costs no request.""" + stub = self.answers({}) + self.assertEqual(1, len(repo_gate.check_sha_pin(self.tmp, + self.pins(f'{self.OWNER}/Fleet@v4')))) + stub.assert_not_called() + + def test_the_documented_exception_is_never_fetched(self) -> None: + stub = self.answers({}) + for action in repo_gate.SHA_EXCEPTIONS: + with self.subTest(exception=action): + self.assertEqual([], repo_gate.check_sha_pin( + self.tmp, self.pins(f'{action}@master'))) + stub.assert_not_called() + + +class TestGitHubRead(unittest.TestCase): + """`gh_exists` decides absence from the status GitHub returned, never from a non-zero exit.""" + + def run_with(self, rc: int, stderr: str) -> bool | None: + proc = subprocess.CompletedProcess([], rc, '', stderr) + with mock.patch.object(repo_gate.subprocess, 'run', return_value=proc): + return repo_gate.gh_exists('repos/o/r/commits/deadbeef') + + def test_a_successful_read_is_present(self) -> None: + self.assertIs(True, self.run_with(0, '')) + + def test_the_two_absent_statuses_are_absent(self) -> None: + for status in sorted(repo_gate.ABSENT): + with self.subTest(status=status): + self.assertIs(False, self.run_with(1, f'gh: Not Found (HTTP {status})')) + + def test_credentials_and_rate_limits_are_not_absence(self) -> None: + """Reading a 401 or a 403 as a missing commit fails a correct tree on a narrow token.""" + for status in ('401', '403', '500', '502'): + with self.subTest(status=status): + self.assertIsNone(self.run_with(1, f'gh: (HTTP {status})')) + + def test_a_network_error_carrying_no_status_is_not_absence(self) -> None: + self.assertIsNone(self.run_with(1, 'error connecting to api.github.com')) + + def test_gh_being_absent_is_not_absence_and_does_not_raise(self) -> None: + """The gate stays usable on a machine with no `gh`, reporting shape only.""" + with mock.patch.object(repo_gate.subprocess, 'run', side_effect=FileNotFoundError): + self.assertIsNone(repo_gate.gh_exists('repos/o/r')) + + def test_a_hung_read_times_out_rather_than_holding_the_gate(self) -> None: + with mock.patch.object(repo_gate.subprocess, 'run', + side_effect=subprocess.TimeoutExpired('gh', 1)): + self.assertIsNone(repo_gate.gh_exists('repos/o/r')) + + def test_the_read_is_a_get_and_carries_no_state_changing_verb(self) -> None: + """A gate is read-only, so the one network call it makes is held to that.""" + source = (REPO / 'scripts' / 'repo_gate.py').read_text(encoding='utf-8') + for verb in ('-X POST', '-X PATCH', '-X PUT', '-X DELETE', '--method', 'mutation'): + with self.subTest(verb=verb): + self.assertNotIn(verb, source) + + +class TestNotes(TreeCase): + def test_a_note_prints_without_failing_the_run(self) -> None: + """A check that quietly did less than its name prints the same clean line as one that ran.""" + def noting(root: Path, files: list[str]) -> list[str]: + repo_gate.NOTES.append('did less') + return [] + + with mock.patch.dict(repo_gate.CHECKS, {'sha-pin': noting}), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(0, repo_gate.main(['--root', str(REPO), '--check', 'sha-pin'])) + self.assertIn('note: did less', out.getvalue()) + + def test_a_note_from_one_check_is_not_reported_under_the_next(self) -> None: + repo_gate.NOTES.append('left over from a previous run') + with contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(0, repo_gate.main(['--root', str(REPO), '--check', 'eol'])) + self.assertNotIn('left over', out.getvalue()) + + def test_this_repo_reports_what_its_resolvability_pass_covered(self) -> None: + """Today it covers nothing here, and a silent zero is what this refuses to ship.""" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(0, repo_gate.main(['--root', str(REPO), '--check', 'sha-pin'])) + self.assertRegex(out.getvalue(), r'note: resolved \d+ pin\(s\) against GitHub') + + class TestEol(TreeCase): def test_a_gitattributes_pin_with_no_editorconfig_override_is_flagged(self) -> None: hits = self.eol_pair('*.sh text eol=lf\n', '[*]\nend_of_line = crlf\n') @@ -179,7 +386,7 @@ def test_an_unreadable_workflow_is_skipped_rather_than_raising(self) -> None: class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: loaded = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]) - self.assertGreaterEqual(loaded.countTestCases(), 22) + self.assertGreaterEqual(loaded.countTestCases(), 45) if __name__ == '__main__': diff --git a/spec/audit.py b/spec/audit.py index 11e819d1..172bd368 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -171,7 +171,7 @@ def driftnote_findings(entry, spec, open_count): def repo_slug(entry): - # url is https://github.com/<owner>/<repo> + # The url field is https://github.com/<owner>/<repo> return "/".join(entry["url"].rstrip("/").split("/")[-2:]) @@ -186,8 +186,8 @@ def repo_selectors(entry, defaults): sel = set(entry.get("types", [])) sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") sel.add(entry.get("releaseTrigger") or defaults.get("releaseTrigger") or "two-phase") - # consumerModel has no defaults fallback - the registry schema does not allow defaults.consumerModel, and - # validate.py requires it on every cataloged repo. The guard only shields a malformed non-cataloged entry. + # The consumerModel field has no defaults fallback, since the registry schema does not allow defaults.consumerModel and validate.py requires it on every cataloged repo. + # The guard only shields a malformed non-cataloged entry. cm = entry.get("consumerModel") if cm: sel.add(cm) @@ -341,8 +341,8 @@ def title_and_intro(text): if s.startswith("## "): break region.append(ln.rstrip()) - # Trim the blank lines surrounding the region but keep the interior ones, so a paragraph-boundary - # difference is a real difference - the spec says the intro is copied verbatim. + # Trim the blank lines surrounding the region but keep the interior ones, so a paragraph-boundary difference is a real difference. + # The spec says the intro is copied verbatim. while region and not region[0]: region.pop(0) while region and not region[-1]: @@ -449,9 +449,8 @@ def check_interface(path, contract, text): tok = contract.get("artifactNameToken") if tok and tok not in code: findings.append(("DRIFT", f"interface: {path} missing the '{tok}<branch>-<target>' artifact handoff")) - # Token checks only apply to a job that is present; an absent job is already reported by requiredJobKeys, - # so skip it rather than emit a redundant "missing token" for every token it cannot contain. Scan the - # job's code view so a token in a comment is not read as signal. + # Token checks only apply to a job that is present, since an absent job is already reported by requiredJobKeys, so skip it rather than emit a redundant "missing token" for every token it cannot contain. + # Scan the job's code view so a token in a comment is not read as signal. for job, toks in contract.get("requireTokensInJob", {}).items(): if job in jobs: block = _code_view(jobs[job]) @@ -467,15 +466,15 @@ def check_interface(path, contract, text): return findings -# A `uses: <action>@<40-hex sha>` pin, plus only a trailing Dependabot version comment (` # v1.2.3` - the -# leading `v`-or-digit is required). Dependabot bumps both per repo, so that drift is governed (like EOL), not a -# fidelity deviation. Anchored to `uses:`, so a 64-hex docker digest and a tag/branch ref (`@v4`) do not match. +# A `uses: <action>@<40-hex sha>` pin, plus only a trailing Dependabot version comment such as ` # v1.2.3`, where the leading `v` or digit is required. +# Dependabot bumps both per repo, so that drift is governed the way EOL is rather than being a fidelity deviation. +# It is anchored to `uses:`, so a 64-hex docker digest and a tag or branch ref such as `@v4` do not match. # Hex is case-insensitive, and a hand-written note on a pin is not version-shaped, so it survives to be compared. _ACTION_PIN = re.compile(r"(\buses:[ \t]*[^\s@]+)@[0-9a-fA-F]{40}(?:[ \t]+#[ \t]*v?[0-9][\w.\-]*)?") -# A workflow job's `needs:` list names the jobs it sequences after. In a verbatim job region a repo prunes that -# list to the targets it actually vendors - a `needs` entry naming an unvendored job fails the whole workflow to -# load - so the list is owned per repo, not fixed. Mask it (the inline `[ ... ]`, scalar, and block-list forms), -# same as the action pin. The interface contract still checks the required job keys separately. +# A workflow job's `needs:` list names the jobs it sequences after. +# In a verbatim job region a repo prunes that list to the targets it actually vendors, since a `needs` entry naming an unvendored job fails the whole workflow to load, so the list is owned per repo rather than fixed. +# Mask it in the inline, scalar and block-list forms, the same as the action pin. +# The interface contract still checks the required job keys separately. _JOB_NEEDS = re.compile( r"(^[ \t]*)needs:[ \t]*" r"(?:\[[^\]\n]*\]" # inline: needs: [a, b] (same line only) @@ -529,7 +528,8 @@ def git_file_history(rel_path): if rel_path in _HISTORY_CACHE: return _HISTORY_CACHE[rel_path] out = [] - # Decode as UTF-8/replace to match the downstream and canonical reads. A divergent decode would fabricate a mismatch. + # Decode as UTF-8 with replacement to match the downstream and canonical reads. + # A divergent decode would fabricate a mismatch. r = subprocess.run(["git", "log", "--format=%H", "--", rel_path], cwd=ROOT, capture_output=True, encoding="utf-8", errors="replace") if r.returncode == 0: @@ -548,8 +548,7 @@ def check_verbatim(label, down_text, canonical_rel, extract=None): byte diff is a hint to review, never proof of breakage. """ try: - # Same decode policy as the downstream copy and the git history, so a stray byte can never make - # otherwise-equal content hash differently across the three sources. + # Same decode policy as the downstream copy and the git history, so a stray byte can never make otherwise-equal content hash differently across the three sources. canon_text = (ROOT / canonical_rel).read_text(encoding="utf-8", errors="replace") except OSError: return [("DRIFT", f"verbatim: {label} canonical {canonical_rel} is unreadable from the hub (spec error?)")] @@ -609,8 +608,7 @@ def audit_repo(entry, spec, branch=None): if bool(entry.get("hasDevelop")) != dev_exists: findings.append(("DRIFT", f"registry: hasDevelop={entry.get('hasDevelop')} but develop {'exists' if dev_exists else 'is absent'}")) # Resolve the branch every content read is keyed on, reusing what the branch facts already read. - # An unresolvable one is an error rather than a run: every `?ref=` read would 404 and report the - # whole baseline as absent, which is a flood of letters describing the ref, not the repo. + # An unresolvable one is an error rather than a run, since every `?ref=` read would 404 and report the whole baseline as absent, which is a flood of letters describing the ref rather than the repo. # The branch facts above are reported either way, since they are already read and still true. ground_head = {"main": branch_main, "develop": branch_dev}.get(ground) if ground_head is None: @@ -618,22 +616,16 @@ def audit_repo(entry, spec, branch=None): if ground_head is None: return findings + [("ERROR", f"branch: ground-truth branch {ground} does not exist, so nothing could be read")], "" if main_exists and dev_exists: - # Commit counts mislead here: merge-commit promotions leave main permanently "ahead" while the - # head trees are identical, so tree equality is the no-drift fast path. When the head trees - # differ, empty compare files[] means develop is merely ahead (no main-side changes since the - # merge-base) - normal, no finding, no further API calls. + # Commit counts mislead here, since merge-commit promotions leave main permanently ahead while the head trees are identical, so tree equality is the no-drift fast path. + # Where the head trees differ, an empty compare files[] means develop is merely ahead, carrying no main-side changes since the merge-base, which is normal and yields no finding and no further API calls. if branch_main["commit"]["commit"]["tree"]["sha"] != branch_dev["commit"]["commit"]["tree"]["sha"]: cmp = gh(f"repos/{slug}/compare/develop...main", ok404=True) if cmp and cmp.get("files"): - # Non-empty files[] signals main-side changes, but is not usable directly: it is blind - # to cherry-picked promotions (develop may already hold identical content under - # different commit SHAs, e.g. promote/* branches) AND capped at 300 entries (#336). - # Instead, derive the main-side change set from the merge-base tree - paths whose - # object SHA (blob, or submodule pointer) differs base->main, additions and deletions - # included, no cap - then drop paths whose objects already match at develop: content - # develop already has is not "content develop lacks". Three recursive tree calls, so if - # any tree is truncated (or unexpectedly not a dict) the filter is skipped and the - # compare's unfiltered count kept (conservative, marked). + # A non-empty files[] signals main-side changes but is not usable directly. + # It is blind to cherry-picked promotions, where develop may already hold identical content under different commit SHAs such as a promote branch, and it is capped at 300 entries, per #336. + # Derive the main-side change set from the merge-base tree instead, taking paths whose object SHA, a blob or a submodule pointer, differs from base to main, additions and deletions included and with no cap. + # Then drop paths whose objects already match at develop, since content develop already has is not content develop lacks. + # That is three recursive tree calls, so where any tree is truncated, or unexpectedly not a dict, the filter is skipped and the compare's unfiltered count is kept, which is conservative and marked. trees = { "base": gh(f"repos/{slug}/git/trees/{cmp['merge_base_commit']['commit']['tree']['sha']}?recursive=1"), "develop": gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1"), @@ -685,15 +677,14 @@ def audit_repo(entry, spec, branch=None): # --- Secrets (names only) --- secrets = spec["secrets"] - # The codecov coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) is claimed by a - # type only at build profile: a lint-only language has no tests, so no coverage (spec/type-model.md). + # The codecov coverage requirement, meaning the CODECOV_TOKEN secret and the codecov.yml file, is claimed by a type only at build profile. + # A lint-only language has no tests and so no coverage, per spec/type-model.md. repo_profiles = entry.get("profiles", {}) if not isinstance(repo_profiles, dict): repo_profiles = {} coverage_active = any(secrets.get("typeMechanisms", {}).get(t) == "codecov" and repo_profiles.get(t) != "lint-only" for t in types) stores = {} - # No ok404: an empty store returns {"secrets": []}, so a 404/403 (permissions, rename) must - # surface as ERROR rather than cascade into false missing-secret DEFECTs. + # There is no ok404 here, since an empty store returns {"secrets": []}, so a 404 or 403 from permissions or a rename must surface as ERROR rather than cascade into false missing-secret DEFECTs. for store, path in [("actions", f"repos/{slug}/actions/secrets?per_page=100"), ("dependabot", f"repos/{slug}/dependabot/secrets?per_page=100")]: data = gh(path) stores[store] = {s["name"] for s in (data or {}).get("secrets", [])} @@ -706,9 +697,8 @@ def audit_repo(entry, spec, branch=None): for mech in claimed: for store in mech.get("stores", []): required_by_store[store] |= set(mech.get("requires", [])) - # Registry requiredSecrets[] are the domain-specific additions (STANDUP.md: requiredSecrets plus the - # implicit baseline). Mechanism-mapped names already carry their stores above, and unmapped ones are - # expected in the actions store and count as claimed (never stale). + # The registry requiredSecrets entries are the domain-specific additions, per STANDUP.md, being requiredSecrets plus the implicit baseline. + # Mechanism-mapped names already carry their stores above, and unmapped ones are expected in the actions store and count as claimed, never stale. required_by_store["actions"] |= set(entry.get("requiredSecrets", [])) forbidden = set(secrets["baseline"].get("forbids", [])) for mech in claimed: @@ -724,14 +714,13 @@ def audit_repo(entry, spec, branch=None): findings.append(("DRIFT", f"secrets: {name} in the {store} store is claimed by no applicable mechanism (stale?)")) # --- Dependabot ecosystem coverage --- - # A repo's tree implies Dependabot ecosystems it must track: github-actions when it ships workflows - # (the action versions they reference otherwise go stale, and a merge-bot then has no PRs to auto-merge), - # devcontainers when it ships a .devcontainer. dependabot.yml is YAML (no stdlib parser), so scan the - # declared package-ecosystem values by regex - anchored to the line start so a commented-out entry - # (# package-ecosystem: ...) is not read as declared. This asserts an implied ecosystem's *presence* - # only. Whether each declared ecosystem dual-targets main+develop (the fleet norm) is verified by - # inspection, not here. Only runs when dependabot.yml exists, since its absence is already a file-presence - # LETTER below. Language ecosystems (nuget/uv/npm) are directory-scoped and not yet cross-checked here. + # A repo's tree implies Dependabot ecosystems it must track, being github-actions where it ships workflows and devcontainers where it ships a .devcontainer. + # Without the first, the action versions those workflows reference go stale and a merge-bot then has no PRs to auto-merge. + # The dependabot.yml file is YAML and no stdlib parser reads it, so scan the declared package-ecosystem values by regex, anchored to the line start so a commented-out entry is not read as declared. + # This asserts an implied ecosystem's *presence* only. + # Whether each declared ecosystem dual-targets main and develop, which is the fleet norm, is verified by inspection rather than here. + # It only runs where dependabot.yml exists, since its absence is already a file-presence LETTER below. + # Language ecosystems such as nuget, uv and npm are directory-scoped and not yet cross-checked here. db = gh(f"repos/{slug}/contents/.github/dependabot.yml?ref={ground}", ok404=True) if db and db.get("content"): declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.M)) @@ -746,11 +735,10 @@ def audit_repo(entry, spec, branch=None): findings.append(("DRIFT", f"dependabot: {eco} ecosystem not declared though {why}; add it for both main and develop per the fleet norm")) # --- File and section presence on the ground-truth branch --- - # appliesTo is matched against the repo's full selector set (types + workflowModel + releaseTrigger + - # consumerModel), so the release/operational develop ruleset is two data entries, not a code swap. - # Required sections union across same-path entries. A carried Markdown file must contain each heading - # scoped to this repo. A rename reads as missing and equivalence is judged by hand, so a missing section - # is DRIFT (a hint to verify), never a LETTER. + # The appliesTo selector is matched against the repo's full selector set, being types, workflowModel, releaseTrigger and consumerModel, so the release and operational develop rulesets are two data entries rather than a code swap. + # Required sections union across same-path entries. + # A carried Markdown file must contain each heading scoped to this repo. + # A rename reads as missing and equivalence is judged by hand, so a missing section is DRIFT, a hint to verify, and never a LETTER. sel = repo_selectors(entry, spec["registry"].get("defaults", {})) wanted_sections = {} # path -> set of required section names, unioned across applicable entries verbatim_secs = {} # path -> set of section names checked byte-for-byte against the hub canonical @@ -776,15 +764,15 @@ def audit_repo(entry, spec, branch=None): item = check_item.get(path) fid = item.get("fidelity") if item else "presence" if content is None: - # An interface unit's presence is DRIFT, not LETTER - a workflow's naming is more variable than a - # carried config, so absence is a hint to verify. Any other unit's absence is a file-presence LETTER. + # An interface unit's presence is DRIFT rather than LETTER, since a workflow's naming is more variable than a carried config, so absence is a hint to verify. + # Any other unit's absence is a file-presence LETTER. if fid == "interface": findings.append(("DRIFT", f"interface: {path} absent on {ground}, cannot verify its contract")) else: findings.append(("LETTER", f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)")) continue - # Guard on encoding, not truthiness: an empty file returns encoding "base64" with content "" (decode it - # to ""), whereas a too-large or non-inline payload returns encoding "none" (text stays None -> flagged). + # Guard on encoding rather than truthiness, since an empty file returns encoding "base64" with an empty content that decodes to an empty string. + # A too-large or non-inline payload returns encoding "none", where text stays None and is flagged. text = base64.b64decode(content["content"]).decode("utf-8", "replace") if content.get("encoding") == "base64" else None if path in ("README.md", "HISTORY.md") and text is not None: doc_texts[path] = text # retained for the README/HISTORY mirror check below @@ -805,35 +793,30 @@ def audit_repo(entry, spec, branch=None): findings.append(("DRIFT", f"verbatim: could not read {path} content on {ground} to compare (no inline content returned); verify by hand")) else: findings.extend(check_verbatim(path, text, item.get("reference") or path)) - # Heading-based presence is only meaningful for Markdown. A "section" named on a non-md file (e.g. a - # tasks.json task group) is an intent marker judged per AUDIT.md, not a heading grep. + # Heading-based presence is only meaningful for Markdown. + # A "section" named on a non-md file, a tasks.json task group being one, is an intent marker judged per AUDIT.md rather than a heading grep. needed = wanted_sections[path] verbatim_needed = verbatim_secs[path] if (needed or verbatim_needed) and path.endswith(".md"): if text is None: - # Fail loud rather than skip silently: the contents API returned no inline content (an - # oversized file, a symlink, a submodule), so the section check could not run - surface that - # instead of a false clean. + # Fail loud rather than skip silently, since the contents API returned no inline content, which happens for an oversized file, a symlink or a submodule. + # The section check could not run, so surface that rather than a false clean. findings.append(("DRIFT", f"section: could not read {path} content on {ground} to verify sections (no inline content returned); verify by hand")) else: present = heading_texts(text) for name in sorted(needed): if name.strip().lower() not in present: findings.append(("DRIFT", f"section: '{name}' not found as a heading in {path} on {ground} (renamed or missing; verify intent per AUDIT.md section 7)")) - # A verbatim section must match the hub's canonical byte-for-byte (EOL-normalized), like a - # verbatim file but scoped to the one `## <heading>` region - so a universal rule block cannot - # drift or fall behind a newly added rule while its heading still passes the presence check. + # A verbatim section must match the hub's canonical byte-for-byte once EOL-normalized, like a verbatim file but scoped to the one `## <heading>` region. + # A universal rule block then cannot drift or fall behind a newly added rule while its heading still passes the presence check. for name in sorted(verbatim_needed): findings.extend(check_verbatim(f"{path} section '{name}'", text, path, extract=lambda t, n=name: extract_section(t, n))) - # Undeclared-section advisory (spec/section-model.md): an H2 the manifest does not declare is a - # candidate duplicate of a verbatim section, or repo-specific content to relocate. Advisory only - - # a repo may legitimately carry its own project-specific sections (the AGENTS.md preamble allows - # them) - so it points at the reconciliation, it never fails. AGENTS.md and GOVERNANCE.md only, - # the two files whose section structure is governed by section-model.md. - # Skip the hub itself: its copies are the source and legitimately hold hub-only sections - # (e.g. Repository Onboarding and Conformance) that are deliberately not carried. A downstream - # repo carrying such a section is still flagged, which is the point. + # The undeclared-section advisory, per spec/section-model.md, treats an H2 the manifest does not declare as a candidate duplicate of a verbatim section, or as repo-specific content to relocate. + # It is advisory only, since a repo may legitimately carry its own project-specific sections, which the AGENTS.md preamble allows, so it points at the reconciliation and never fails. + # It covers AGENTS.md and GOVERNANCE.md only, the two files whose section structure is governed by section-model.md. + # Skip the hub itself, since its copies are the source and legitimately hold hub-only sections, Repository Onboarding and Conformance being one, that are deliberately not carried. + # A downstream repo carrying such a section is still flagged, which is the point. if path in ("AGENTS.md", "GOVERNANCE.md") and entry.get("name") != HUB_NAME: declared = {n.strip().lower() for n in (needed | verbatim_needed)} h2s = {ln[3:].strip().lower() for ln in text.splitlines() if ln.startswith("## ")} @@ -854,8 +837,8 @@ def audit_repo(entry, spec, branch=None): findings.append(("DRIFT", f"carried: {path} references the template repo by name or link outside its verbatim sections (the coordination flow is machinery this repo's readers should not see; state the behavior, not the destination)")) # --- HISTORY.md mirrors the README opening --- - # spec/readme-structure.md "HISTORY.md": the changelog opens as the README's twin - same H1 title and the - # same intro paragraph. Checked only when both files were readable (absence is already a file LETTER above). + # Per spec/readme-structure.md "HISTORY.md", the changelog opens as the README's twin, carrying the same H1 title and the same intro paragraph. + # It is checked only where both files were readable, since absence is already a file LETTER above. if "README.md" in doc_texts and "HISTORY.md" in doc_texts: r_title, r_intro = title_and_intro(doc_texts["README.md"]) h_title, h_intro = title_and_intro(doc_texts["HISTORY.md"]) @@ -864,10 +847,10 @@ def audit_repo(entry, spec, branch=None): elif r_intro != h_intro: findings.append(("LETTER", "history: HISTORY.md intro does not mirror the README intro - copy the README's opening paragraph (spec/readme-structure.md)")) - # --- README title/intro is the one canonical short description --- - # spec/readme-structure.md item 1 + GOVERNANCE.md "Repository Details": the H1 is the repo name, and the intro - # line after it is a link-free, <=100-char plain sentence that carries verbatim to the GitHub About - # description and (for a docker repo) the Docker Hub short description. The README is the source of truth. + # --- README title and intro are the one canonical short description --- + # Per spec/readme-structure.md item 1 and GOVERNANCE.md "Repository Details", the H1 is the repo name. + # The intro line after it is a link-free plain sentence of at most 100 characters that carries verbatim to the GitHub About description, and on a docker repo to the Docker Hub short description. + # The README is the source of truth. if "README.md" in doc_texts: title, intro = title_and_intro(doc_texts["README.md"]) intro_line = intro.split("\n")[0] @@ -902,9 +885,8 @@ def audit_repo(entry, spec, branch=None): findings.append(("LETTER", f"description: the Docker Hub short description ('{dh.strip()}') does not match the README intro ('{want}') - set it from the README (spec/readme-structure.md)")) # --- cspell single source of truth --- - # CODESTYLE.md "Markdown and Spelling": cspell.json is the one word list, and a cSpell words block left in - # a *.code-workspace duplicates it and silently drifts. Checked only when cspell.json is carried - its - # absence is already a file LETTER above, and a workspace list with no cspell.json is that same finding. + # Per CODESTYLE.md "Markdown and Spelling", cspell.json is the one word list, and a cSpell words block left in a *.code-workspace duplicates it and silently drifts. + # It is checked only where cspell.json is carried, since its absence is already a file LETTER above, and a workspace list with no cspell.json is that same finding. if gh(f"repos/{slug}/contents/cspell.json?ref={ground}", ok404=True) is not None: root_entries = gh(f"repos/{slug}/contents/?ref={ground}", ok404=True) or [] for it in root_entries: @@ -912,7 +894,7 @@ def audit_repo(entry, spec, branch=None): if not ws_name.endswith(".code-workspace"): continue ws = gh(f"repos/{slug}/contents/{ws_name}?ref={ground}", ok404=True) - # isinstance guard: the contents API returns a list for a directory, and .get would raise on it. + # The isinstance guard is needed because the contents API returns a list for a directory, where .get would raise. ws_text = base64.b64decode(ws["content"]).decode("utf-8", "replace") if isinstance(ws, dict) and ws.get("encoding") == "base64" else None if ws_text is None: findings.append(("DRIFT", f"cspell: could not read {ws_name} on {ground} to check for a duplicated word list; verify by hand")) @@ -922,8 +904,8 @@ def audit_repo(entry, spec, branch=None): # --- Registry driftNotes freshness --- findings.extend(driftnote_findings(entry, spec, len(findings))) - # Stamp the commit actually read for the ground-truth branch. Never fall back to another branch: - # a stamp naming develop while carrying main's sha would misattribute every finding. + # Stamp the commit actually read for the ground-truth branch. + # Never fall back to another branch, since a stamp naming develop while carrying main's sha would misattribute every finding. audited_sha = ground_head.get("commit", {}).get("sha", "") return findings, audited_sha @@ -975,9 +957,8 @@ def _selftest(): else: print(" ok split_jobs (inline-mapping job captured with its content)") - # Verbatim engine: EOL normalization, hashing, and the stale-vs-modified classification. Exercised here - # rather than only in production, because a latent bug in the comparison would otherwise surface as a - # false clean on a real fleet run. + # The verbatim engine, covering EOL normalization, hashing, and the stale-versus-modified classification. + # It is exercised here rather than only in production, because a latent bug in the comparison would otherwise surface as a false clean on a real fleet run. canon = "line one\nline two\nline three\n" verbatim_cases = [ # (label, down_text, canon_text, history, want) @@ -994,9 +975,8 @@ def _selftest(): if got != want: ok = False print(f" {'ok ' if got == want else 'FAIL'} want={str(want):>8} got={str(got):>8} verbatim: {label}") - # Action-pin neutralization: a Dependabot uses:@<sha> bump (both the 40-hex sha and its ` # vN` comment) - # must not count as verbatim drift, but a changed action name must. This is what lets a verbatim workflow - # region survive routine action bumps while still catching a real fork. + # Action-pin neutralization, where a Dependabot uses:@<sha> bump, meaning both the 40-hex sha and its ` # vN` comment, must not count as verbatim drift, while a changed action name must. + # This is what lets a verbatim workflow region survive routine action bumps while still catching a real fork. pin_a = " - uses: actions/checkout@" + "a" * 40 + " # v7.0.0\n" pin_b = " - uses: actions/checkout@" + "B" * 40 + " # v7.0.1\n" # uppercase hex + version bump pin_struct = " - uses: actions/setup-node@" + "a" * 40 + " # v7.0.0\n" @@ -1014,8 +994,7 @@ def _selftest(): else: print(" ok action-pin: version bump normalizes equal, changed action differs, hand-written note survives") - # needs-mask: a verbatim job region whose `needs:` list is pruned to the repo's vendored targets must not - # count as drift (the list is owned), but a structural change to the job's steps must. + # The needs-mask case, where a verbatim job region whose `needs:` list is pruned to the repo's vendored targets must not count as drift, since the list is owned, while a structural change to the job's steps must. needs_full = " github-release:\n needs: [get-version, validate-release, build-nugetlibrary, build-executable]\n runs-on: x\n steps: []\n" needs_pruned = " github-release:\n needs: [get-version, validate-release, build-executable]\n runs-on: x\n steps: []\n" needs_block = " github-release:\n needs:\n - get-version\n - build-executable\n runs-on: x\n steps: []\n" @@ -1041,9 +1020,8 @@ def _selftest(): print(" FAIL verbatim: forked github-release region should hash differently") else: print(" ok verbatim: a forked github-release region hashes differently from the canonical") - # Section-region extraction: the region includes the heading line, keeps a nested ### and a fenced ## inside - # the body, ends at the next sibling H2, is None if absent, and rehashes when the heading is re-cased - the - # per-section verbatim check depends on every one of these. + # Section-region extraction, where the region includes the heading line, keeps a nested ### and a fenced ## inside the body, ends at the next sibling H2, is None where absent, and rehashes where the heading is re-cased. + # The per-section verbatim check depends on every one of these. md = "# Title\n\n## Alpha\n\nbody a\n\n```\n## not a heading\n```\n\n### nested\nstill alpha\n\n## Beta\n\nbody b\n" a, b, gone = extract_section(md, "Alpha"), extract_section(md, "Beta"), extract_section(md, "Gamma") spaced = extract_section("## Alpha\n\nbody a\n", "Alpha") # extra marker-gap whitespace still locates @@ -1128,7 +1106,7 @@ def _selftest(): else: print(" ok cspell: workspace cSpell word list detected, a plain cspell.json mention is not") - # branch-drift direction split, covering modify/add/delete on main and a develop-only change + # The branch-drift direction split, covering a modify, add and delete on main plus a develop-only change. bd_base = {"keep": "a", "moda": "1", "modb": "2", "deld": "e", "devonly": "x"} bd_main = {"keep": "a", "moda": "9", "modb": "9", "add": "n", "devonly": "x"} # moved moda/modb, added 'add', deleted 'deld' bd_dev = {"keep": "a", "moda": "1", "modb": "7", "add": "m", "deld": "e", "devonly": "y"} # still at base on moda/deld, moved modb/add/devonly @@ -1139,9 +1117,8 @@ def _selftest(): else: print(" ok branch-drift: behind (modify/delete develop still at base) vs diverged (both moved), develop-only excluded") - # CLI parsing: a repo name and a flag value must not be confused for one another. The previous - # hand-rolled parse took every non `--` argument as a repo name, so `--branch develop` would have - # audited a repo called "develop" instead of overriding the branch. + # CLI parsing, where a repo name and a flag value must not be confused for one another. + # The previous hand-rolled parse took every non `--` argument as a repo name, so `--branch develop` would have audited a repo called "develop" rather than overriding the branch. cli_cases = [ ([], [], None, False, False), (["Utilities"], ["Utilities"], None, False, False), @@ -1328,8 +1305,8 @@ def main(argv=None): print(f"Not cataloged: {', '.join(sorted(missing))}", file=sys.stderr) return 2 - # Findings are a point-in-time snapshot. Stamp the run so anything derived from it (an onboarding - # issue, a report) carries its own freshness signal and a reader can tell whether it still applies. + # Findings are a point-in-time snapshot. + # Stamp the run so anything derived from it, an onboarding issue or a report, carries its own freshness signal and a reader can tell whether it still applies. run_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") hub = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=ROOT) hub_sha = hub.stdout.strip() if hub.returncode == 0 else "unknown" diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index c76acb49..bd56a25a 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -4,7 +4,7 @@ How faithfully each carried unit must survive the carry, and how that is verifie ## The Fixed and the Overridable -Carried content is a class with virtual functions. The **fixed** part is the interface - when a thing is invoked, what it is named, and where it is wired. The **overridable** part is the implementation body, which a repo replaces to fit its own targets. Validation must allow the override while detecting a change to the interface or to content meant to stay fixed. Integrity is by **content hash, never a version number** - a version stamp is a claim a repo can keep while editing the body, so it is never trusted for detection. +Carried content is a class with virtual functions. The **fixed** part is the interface: when a thing is invoked, what it is named, and where it is wired. The **overridable** part is the implementation body, which a repo replaces to fit its own targets. Validation must allow the override while detecting a change to the interface or to content meant to stay fixed. Integrity is by **content hash, never a version number**. A version stamp is a claim a repo can keep while editing the body, so it is never trusted for detection. ## The Four Fidelity Levels @@ -12,14 +12,14 @@ Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `pr - **presence** - the unit exists (a file, or a Markdown section heading). The audit's baseline check. - **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts nothing beyond presence. -- **verbatim** - byte-identical to the hub's canonical after line-ending, action-pin, and job-needs normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file, a workflow job region (a job selected by key), or a Markdown section region (a `## heading` block selected by name). The section granularity lets one file be **intent overall while a few of its sections are verbatim** - a universal rule block stays byte-identical fleet-wide even though the rest of the document is a repo-adapted paraphrase, so a stale section or a missing rule is caught while its heading still passes the presence check. +- **verbatim** - byte-identical to the hub's canonical after line-ending, action-pin, and job-needs normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file, a workflow job region (a job selected by key), or a Markdown section region (a `## heading` block selected by name). The section granularity lets one file be **intent overall while a few of its sections are verbatim**. A universal rule block stays byte-identical fleet-wide even though the rest of the document is a repo-adapted paraphrase, so a stale section or a missing rule is caught while its heading still passes the presence check. - **interface** - an overridable body that must honor a named contract. The audit checks the contract by name and wiring, never the body. Fidelity is a declared field defaulting to `presence`, never inferred from `whole`/`placeholders`. `.editorconfig` and `.markdownlint-cli2.jsonc` are both whole with no placeholders yet sit at opposite fidelity, because the discriminator is governance, not field shape. ## Why Each Unit Sits Where It Does -- **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), the `github-release` job region of the release task (the canonical orchestration a repo must not fork), and the universal rule sections of `AGENTS.md` - fleet-law with no repo-specific content (no SHAs, no `ptr727/<repo>` references), where a paraphrase or a missing rule is a defect, not an adaptation. Which `AGENTS.md` sections are verbatim and which stay intent (the few that describe one repo) is defined in [section-model.md][section-model]. +- **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), the `github-release` job region of the release task (the canonical orchestration a repo must not fork), and the universal rule sections of `AGENTS.md`, which are fleet-law with no repo-specific content (no SHAs, no `ptr727/<repo>` references) and where a paraphrase or a missing rule is a defect, not an adaptation. Which `AGENTS.md` sections are verbatim and which stay intent (the few that describe one repo) is defined in [section-model.md][section-model]. - **interface** - the release and PR workflows. Their fixed contract is the job and check names plus the artifact handoff, while the leaf build jobs are owned. See the override seam in [`GOVERNANCE.md`][governance]. - **intent** - `.editorconfig` and `.gitattributes` (the `[*] end_of_line` default and path pins vary by platform), `cspell.json` (the words list and file scope vary), `CODESTYLE.md` / `WORKFLOW.md` / `AUDIT.md` / `.github/copilot-instructions.md` (carried docs judged by meaning), and the ruleset payloads (whose live state is diffed separately). - **presence** - `README.md`, `HISTORY.md`, `.gitignore`, and the per-repo config that only needs to exist. @@ -30,11 +30,11 @@ The fixed interface of a workflow is stated in [`GOVERNANCE.md`][governance] ("O ## Normalization -A verbatim check compares content by hash after **line-ending, action-pin, and job-needs normalization** - EOL variance is governed by the line-ending rules, a `uses: <action>@<sha>` pin (with its trailing `# vN` comment) is Dependabot-owned and bumped per repo, and a job's `needs:` list is pruned per repo to its vendored targets (an unvendored name fails the workflow to load), so all three are governed drift rather than a fidelity deviation. This keeps a verbatim workflow job region (the `github-release` job) from flagging on a routine action bump or a legitimate needs-prune while still catching a real structural fork. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with `<owner>`, `<repo>`, `<N>`) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway - a downstream copy holds the substituted value (`ptr727`), not the token (`<owner>`), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. +A verbatim check compares content by hash after **line-ending, action-pin, and job-needs normalization**. EOL variance is governed by the line-ending rules, a `uses: <action>@<sha>` pin (with its trailing `# vN` comment) is Dependabot-owned and bumped per repo, and a job's `needs:` list is pruned per repo to its vendored targets (an unvendored name fails the workflow to load), so all three are governed drift rather than a fidelity deviation. This keeps a verbatim workflow job region (the `github-release` job) from flagging on a routine action bump or a legitimate needs-prune while still catching a real structural fork. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with `<owner>`, `<repo>`, `<N>`) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway. A downstream copy holds the substituted value (`ptr727`), not the token (`<owner>`), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. ## Stale Versus Modified -A verbatim mismatch is one of two things, told apart **by hash, not by a version**. The audit hashes each past revision of the hub's canonical from its own git history. If the downstream copy matches a **past** canonical revision, the base advanced and the copy is **stale** - re-vendor it. If it matches **no** revision the base ever produced, the repo **modified fixed content** - review it. A version stamp could claim to be current while being neither, so it is demoted to a human-facing label and never consulted for integrity. +A verbatim mismatch is one of two things, told apart **by hash, not by a version**. The audit hashes each past revision of the hub's canonical from its own git history. If the downstream copy matches a **past** canonical revision, the base advanced and the copy is **stale**, so re-vendor it. If it matches **no** revision the base ever produced, the repo **modified fixed content**, so review it. A version stamp could claim to be current while being neither, so it is demoted to a human-facing label and never consulted for integrity. <!-- Repo --> [audit]: ./audit.py diff --git a/spec/fidelity_honesty.py b/spec/fidelity_honesty.py index 89de0a66..efb6343a 100644 --- a/spec/fidelity_honesty.py +++ b/spec/fidelity_honesty.py @@ -115,9 +115,9 @@ def fidelity_pass(spec): else: spread["differs"].append(r["name"]) spreads.append((unit, spread)) - # A verbatim candidate has NO hand-modified copy ("differs") and at least one confirmed match with - # the current canonical. Stale copies do not disqualify it - verbatim would flag them "stale -> - # re-vendor", which is the point. A unit that is entirely stale/unavailable is not confirmed uniform. + # A verbatim candidate has no hand-modified copy, meaning an empty "differs", and at least one confirmed match with the current canonical. + # Stale copies do not disqualify it, since verbatim would flag them as stale and owing a re-vendor, which is the point. + # A unit that is entirely stale or unavailable is not confirmed uniform. # An absent section does not disqualify it either, for the same reason an unavailable file does not. # Neither is evidence that a repo decided something locally, and only such evidence argues against promotion. # Treating absence as disqualifying would be the same conflation this bucket was split out to end. @@ -136,8 +136,8 @@ def manifest_gap_pass(spec, ref_repo): return None, [] slug = audit.repo_slug(entry) ground = entry.get("groundTruthBranch", "main") - # The git/trees endpoint takes a tree SHA, not a ref name, so resolve the branch to its tree SHA - # first (as audit.py does) - passing the branch name can 404 and silently drop the whole check. + # The git/trees endpoint takes a tree SHA rather than a ref name, so resolve the branch to its tree SHA first, as audit.py does. + # Passing the branch name can 404 and silently drop the whole check. # Fail loud on an unreadable reference adopter: an empty gaps list would report "none" (a false clean). br = audit.gh(f"repos/{slug}/branches/{ground}", ok404=True) if not br or "commit" not in br: @@ -190,15 +190,15 @@ def render_report(spreads, promote, gaps, ledger): spread_by_path = {e["path"]: sp for e, sp in spreads if sp is not None} def buckets(path): - # (still divergent, confirmed match, unavailable) repo sets for a unit. Unavailable is held apart - # from match: an absent copy cannot confirm a divergence was fixed. + # The three repo sets for a unit, being still divergent, confirmed match, and unavailable. + # Unavailable is held apart from match, since an absent copy cannot confirm a divergence was fixed. sp = spread_by_path.get(path) if not sp: return set(), set(), set() return set(sp["differs"]) | set(sp["stale"]) | set(sp["absent"]), set(sp["match"]), set(sp["unavailable"]) - # Keep only well-formed entries so --report degrades cleanly on a hand-malformed ledger instead of - # raising KeyError/TypeError downstream. validate.py reports the malformation loudly in CI. + # Keep only well-formed entries so --report degrades cleanly on a hand-malformed ledger rather than raising KeyError or TypeError downstream. + # The validate.py gate reports the malformation loudly in CI. dispositions = [d for d in ledger.get("dispositions", []) if isinstance(d, dict) and isinstance(d.get("path"), str) and isinstance(d.get("repos"), list) and isinstance(d.get("disposition"), str) @@ -212,8 +212,8 @@ def buckets(path): covered.setdefault(d["path"], set()).update(d["repos"]) # Untriaged: verbatim hand-modifications with no disposition, and live gaps with no gap disposition. - # Verbatim-only by design: an intent unit's byte diff is expected (judged by meaning), so only a verbatim - # hand-modification with no recorded disposition is a genuine anomaly worth surfacing. + # Verbatim-only by design, since an intent unit's byte diff is expected and is judged by meaning. + # Only a verbatim hand-modification with no recorded disposition is therefore a genuine anomaly worth surfacing. untriaged_files = [] untriaged_absent = [] for e, sp in spreads: @@ -338,8 +338,9 @@ def main(): if report_mode: content = render_report(spreads, promote, gaps, load_ledger()) - # CRLF to match the fleet default (reports/*.md is CRLF). Write bytes so the local platform does not - # re-translate. Git dates the file, so no timestamp is embedded (it would churn every regeneration). + # CRLF matches the fleet default, since reports/*.md is CRLF. + # Bytes are written so the local platform does not re-translate them. + # Git dates the file, so no timestamp is embedded, which would churn on every regeneration. (audit.ROOT / REPORT_PATH).write_bytes(content.replace("\n", "\r\n").encode("utf-8")) print(f"Wrote {REPORT_PATH} ({len(content.splitlines())} lines)") return 0 diff --git a/spec/readme-structure.md b/spec/readme-structure.md index c8cd568a..2d5a0703 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -1,6 +1,6 @@ # README Structure -The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the sections below, in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). A section that does not apply to a project type is N/A, not a defect (a library has no running-app "Installation"; a source-only repo has no "Usage"). +The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the sections below, in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). A section that does not apply to a project type is N/A, not a defect (a library has no running-app "Installation", and a source-only repo has no "Usage"). ## Sections and Order @@ -38,7 +38,7 @@ Shields are not a top-level section. They live under **Build and Distribution** ## TODO.md -A repo's running backlog lives in a root `TODO.md`, and **a README carries no backlog section**. The README is the adoption surface a reader lands on, so open work does not belong in its section order, and the backlog changes on a different cadence from everything around it. `TODO.md` opens with a `# TODO` heading and one line saying what it holds, then a flat bullet list, each item stating the work and the reason it is owed. +A repo's running backlog lives in a root `TODO.md`, and **a README carries no backlog section**. The README is the adoption surface a reader lands on, so open work does not belong in its section order, and the backlog changes on a different cadence from everything around it. `TODO.md` opens with a `# TODO` heading and an intro saying what it holds, then a bullet list, each item stating the work and the reason it is owed. That is the minimum rather than the shape, so a backlog long enough that selecting from it is itself work groups its items under headings and says on each what it costs, and a short one stays a flat list. This repo's own `TODO.md` is the worked example of the grouped form and is not a template every repo adopts, since a five-line backlog needs no selection procedure. The file is the declared destination rather than a required file, the same footing as `ARCHITECTURE.md`. A repo with no backlog carries no `TODO.md`, and a repo with one puts it here rather than inventing a location. Work already tracked as an issue belongs in the issue, so an entry here is either not yet filed or is the local pointer to a filed one. diff --git a/spec/scope-model.md b/spec/scope-model.md index 3747fcd7..ae89fd42 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -4,10 +4,10 @@ How every governance rule is scoped, so the carried docs are granular single-sco ## Two Axes -A rule has a physical home, and - if it is a repo rule - a reach. +A rule has a physical home, and a repo rule has a reach as well. -- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/` - it loads in every session regardless of repo and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. -- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`, loading in every session regardless of repo and covering ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ. The write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo: the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. ## Selectors @@ -20,14 +20,14 @@ A selector is one token from one of four **disjoint** namespaces. Because the na | release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | | consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | -A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default - [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default. [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. ## appliesTo Semantics `appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). - **`*`** means all repos. -- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. +- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate. A single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. - Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. ## Documenting a Whole-Carried File's Section Scopes diff --git a/spec/section-model.md b/spec/section-model.md index 7148b503..19c2e088 100644 --- a/spec/section-model.md +++ b/spec/section-model.md @@ -66,10 +66,12 @@ A repo's own content is not carried, and the hub declares where it goes rather t - [`CODESTYLE.md`][codestyle]: a repo's language and formatting conventions beyond the carried rules. - `ARCHITECTURE.md`: how a code repo is built, its module layout, data flow, and design decisions. -- `OPERATIONS.md`: how a repo is run, under the headings `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half. +- `OPERATIONS.md`: how a repo is run, under the headings `Local Verification`, `Runbooks`, `Backup and Recovery`, `Logs and Debugging`, `Tool Usage`, and `Configuration Layout`. It is the operational analogue of `ARCHITECTURE.md`, and it is where an `AGENTS.md` split puts the repo-specific half. - `TODO.md`: the repo's running backlog, which keeps open work out of the README's section order where it does not belong and changes on a different cadence from everything around it. -**`OPERATIONS.md` is required for every repo**, declared in [`files.json`][files] as `appliesTo: "*"` and checked for presence only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own. It is mandatory rather than advisory because the convention was already emerging unevenly: of the four operational-model repos, two wrote one unprompted and the others scattered the same material across ad-hoc names, which is the improvisation these destinations exist to prevent. That reasoning never depended on the workflow model. Every repo has operational surface, since publishing to a package registry needs trusted-publisher setup, shipping an image needs registry credentials, and serving a site needs a deploy path and a staging story. A repo with nothing to say still carries the file as a stub, meaning those five headings with no content under them, because a stub names the destination and its shape where a blank file names only the destination. This repo's own [`OPERATIONS.md`][operations] is the worked example. +**`OPERATIONS.md` is required for every repo**, declared in [`files.json`][files] as `appliesTo: "*"` and checked for presence only, the same footing as `README.md` and `HISTORY.md`, so its content is entirely the repo's own. It is mandatory rather than advisory because the convention was already emerging unevenly: of the four operational-model repos, two wrote one unprompted and the others scattered the same material across ad-hoc names, which is the improvisation these destinations exist to prevent. That reasoning never depended on the workflow model. Every repo has operational surface, since publishing to a package registry needs trusted-publisher setup, shipping an image needs registry credentials, and serving a site needs a deploy path and a staging story. A repo with nothing to say still carries the file as a stub, meaning those six headings with no content under them, because a stub names the destination and its shape where a blank file names only the destination. This repo's own [`OPERATIONS.md`][operations] is the worked example. + +**`Local Verification` leads because it is the only pre-merge heading.** It states what verifying a change in this repo requires, and specifically which part of the repo's contract CI structurally cannot exercise. Every other heading describes the repo after a change has landed, so a file ordered without it reads as post-merge from the first line down, and a pointer written from the file's most visible function then routes a pre-merge task elsewhere while describing the file accurately. That is the failure the heading exists to close, and it produces no signal, since the pipeline passes over the half it reaches and the routing description is not wrong. The content class recurs the way this list requires, because a runner reaches a build and not a redirect, a deploy, or a device, so every repo has some part of its contract that only a local run or a human covers. The heading is required for the same reason the file is, and a repo whose gates are entirely in CI says exactly that under it, since a stated *nothing beyond CI* is an answer a reader can act on where an absent heading is one they have to guess at. [GOVERNANCE.md][governance] "Verification Discipline" carries the reader's half of this, that the checks a change owes are read from what the repo declares rather than from what the pipeline happens to run. **The workflow model and the need for this file are unrelated axes.** `operational` as a `workflowModel` describes where config lives and how a change reaches `develop`, not whether the repo has runbooks. Keying the file to that selector read a sufficient condition as a necessary one, since an operational-model repo certainly has runbooks while a release repo has them too. Reclassifying a repo between models does not change how much operational surface it has, which is the test that showed the selector was wrong. diff --git a/spec/type-model.md b/spec/type-model.md index 7d4db591..b1d09e62 100644 --- a/spec/type-model.md +++ b/spec/type-model.md @@ -1,28 +1,28 @@ # Project Type Model -Companion to [section-model.md][section-model] and [fidelity-model.md][fidelity-model]. Those define how carried *content* is verified. This one defines how a repo's **types** - what it is built from and for - are declared, validated, and checked. It is the ground truth an agent or human consults before adding a type, a profile, or a type check, not a judgment re-derived each session. +Companion to [section-model.md][section-model] and [fidelity-model.md][fidelity-model]. Those define how carried *content* is verified. This one defines how a repo's **types** (what it is built from and for) are declared, validated, and checked. It is the ground truth an agent or human consults before adding a type, a profile, or a type check, not a judgment re-derived each session. **Rollout status.** This model is being wired in stages. Where a rule below names a check `audit.py` does not yet run, or a schema field not yet defined, this doc is the contract that implementation realizes. ## Declaration is the source of truth -A repo's types are **declared** in its [registry/repos.json][repos] entry (`types`), and a language type may also declare a **profile** (below). The audit runs the checks for each declared type plus the cross-cutting dimensions. Declaration - not inference - is authoritative: the registry states what the repo *is*, and [project-types.json][types] holds each type's requirements and checks. +A repo's types are **declared** in its [registry/repos.json][repos] entry (`types`), and a language type may also declare a **profile** (below). The audit runs the checks for each declared type plus the cross-cutting dimensions. Declaration, not inference, is authoritative: the registry states what the repo *is*, and [project-types.json][types] holds each type's requirements and checks. This mirrors the fleet principle that *the registry is ground truth about reality, not intent* (the `setup.driftnotes.current` check, over the registry `driftNotes` field): a declaration is a claim about the repo that must match what the repo actually contains. ## Detection validates, it does not classify -Each type in `project-types.json` carries `detect` patterns (files or markers that evidence the type). Detection is a **validator and a discovery aid**, never an auto-classifier - it checks declarations against reality and surfaces candidates, but it does not decide a repo's types on its own. The two axes give four cases: +Each type in `project-types.json` carries `detect` patterns (files or markers that evidence the type). Detection is a **validator and a discovery aid**, never an auto-classifier. It checks declarations against reality and surfaces candidates, but it does not decide a repo's types on its own. The two axes give four cases: | | detected | not detected | | --- | --- | --- | -| **declared** | consistent - the checks run | **false declaration** - a finding (e.g. `cpp` declared, no C/C++ files present) | -| **not declared** | **discovery advisory** - declare it or mark it ignored | nothing to do | +| **declared** | consistent, so the checks run | **false declaration**, a finding (e.g. `cpp` declared, no C/C++ files present) | +| **not declared** | **discovery advisory**, so declare it or mark it ignored | nothing to do | The undeclared-but-detected advisory has three honest resolutions, all explicit intent, never silent: -- **declare** it, so its checks run; -- **ignore** it - an explicit suppression carrying a reason (the driftNote shape), for a language deliberately not tracked (vendored third-party code, an incidental snippet); +- **declare** it, so its checks run. +- **ignore** it with an explicit suppression carrying a reason (the driftNote shape), for a language deliberately not tracked (vendored third-party code, an incidental snippet). - leave it as a standing advisory until decided. A false declaration is always a finding: a claim the repo does not back is drift, the same way a stale driftNote is. @@ -34,19 +34,19 @@ A language type is present at one of two **depths**, declared as its `profile`: - **build** - the language is compiled, tested, and/or packaged in this repo. Its full check set applies (style, type-check, tests, coverage, packaging). - **lint-only** - the language is present and style-checked here, but not built: there is no build/test/package for it in this repo. Only its lint/style/type-check checks apply. Build, test, coverage, and packaging checks are N/A. -Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build/test/coverage/package checks set `build`. The audit uses the declared profile to hold the coverage requirement - the CODECOV_TOKEN secret and the codecov.yml file - N/A for a lint-only language, replacing the older per-check "N/A for the SCRIPTS profile" prose. +Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build/test/coverage/package checks set `build`. The audit uses the declared profile to hold the coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) as N/A for a lint-only language, replacing the older per-check "N/A for the SCRIPTS profile" prose. -The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator** - a declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. +The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator**. A declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. ### Consequence for cross-cutting checks -A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks - the `CODECOV_TOKEN` secret and the `codecov.yml` file presence - are **profile-aware**: they are N/A for a language whose declared profile has no tests. A lint-only language must never manufacture a coverage finding. +A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks (the `CODECOV_TOKEN` secret and the `codecov.yml` file presence) are **profile-aware**: they are N/A for a language whose declared profile has no tests. A lint-only language must never manufacture a coverage finding. ## Languages Language types carry the style and type-check requirements for their language, gated by profile. A language that is only ever linted in the fleet is `lint-only` by nature and defines no build/test/package checks: -- **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here - for a codegen or config repo the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. +- **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here. For a codegen or config repo, the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. ## Generators diff --git a/spec/validate.py b/spec/validate.py index 68e4cef9..e15a4cf8 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -13,13 +13,12 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent -# Scope-selector vocabularies (see spec/scope-model.md), kept in sync with registry/repos.schema.json -# $defs. The four namespaces - project types plus these three - must stay disjoint, so a flat appliesTo -# token set in spec/files.json is unambiguous. +# Scope-selector vocabularies, per spec/scope-model.md, kept in sync with the $defs in registry/repos.schema.json. +# The four namespaces, meaning project types plus these three, must stay disjoint so a flat appliesTo token set in spec/files.json is unambiguous. WORKFLOW_MODELS = ("release", "operational") RELEASE_TRIGGERS = ("two-phase", "publish-on-merge", "dispatch-only", "none") CONSUMER_MODELS = ("push", "pull") -# How faithfully a carried unit is checked (spec/fidelity-model.md). Default presence. +# How faithfully a carried unit is checked, per spec/fidelity-model.md, defaulting to presence. FIDELITIES = ("presence", "intent", "verbatim", "interface") # The keys an interface unit's `contract` may carry (kept in sync with files.schema.json). CONTRACT_KEYS = {"requiredJobKeys", "requiredCheckName", "artifactNameToken", "requireTokensInJob", "forbidTokensInJob", "verbatimJobs"} @@ -58,8 +57,7 @@ def main(): target_mech = secrets["targetMechanisms"] mechanisms = secrets["mechanisms"] - # CI runs no JSON-schema validation, so shape-check secrets.json here to fail with a clear message - # rather than crash the cross-reference loops below. + # CI runs no JSON-schema validation, so shape-check secrets.json here to fail with a clear message rather than crash the cross-reference loops below. def check_secret_set(label, entry, need_kind): if not isinstance(entry, dict): errors.append(f"secrets.json: {label} is not an object") @@ -106,9 +104,8 @@ def check_secret_set(label, entry, need_kind): print(f" - {e}") return 1 - # defaults.workflowModel/releaseTrigger feed configure.sh's fallback and selector resolution, so an - # invalid value here breaks the apply or scopes wrong while every per-repo entry still validates - check - # them once. + # The defaults for workflowModel and releaseTrigger feed configure.sh's fallback and selector resolution. + # An invalid value there breaks the apply or scopes wrong while every per-repo entry still validates, so check them once. reg_defaults = repos.get("defaults", {}) default_model = reg_defaults.get("workflowModel") if default_model is not None and default_model not in WORKFLOW_MODELS: @@ -157,14 +154,13 @@ def check_secret_set(label, entry, need_kind): if model is not None and model not in WORKFLOW_MODELS: errors.append(f"{name}: workflowModel '{model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") - # releaseTrigger is a scope selector (spec/scope-model.md), so an invalid value would silently fail - # to match any releaseTrigger-scoped section rather than error. + # The releaseTrigger field is a scope selector, per spec/scope-model.md, so an invalid value would silently fail to match any releaseTrigger-scoped section rather than error. trigger = repo.get("releaseTrigger") if trigger is not None and trigger not in RELEASE_TRIGGERS: errors.append(f"{name}: releaseTrigger '{trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") - # consumerModel is a scope selector (spec/scope-model.md), so a cataloged repo must declare it or a - # push/pull-scoped section would fail open (never matched) on that repo. + # The consumerModel field is a scope selector, per spec/scope-model.md, so a cataloged repo must declare it. + # Otherwise a push-scoped or pull-scoped section would fail open on that repo, never matching. cm = repo.get("consumerModel") if cm not in CONSUMER_MODELS: errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected {' or '.join(CONSUMER_MODELS)})") @@ -172,10 +168,9 @@ def check_secret_set(label, entry, need_kind): eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): errors.append(f"{name}: lineEndings '{eol}' invalid (expected lf or crlf)") - # An operational repo's endings follow the consuming app's platform, so they must be declared; a release - # repo omits the field and uses the fleet CRLF default. Resolve the effective model the same way - # configure.sh does (repo -> defaults -> release) so the requirement holds even if a repo relies on an - # operational defaults.workflowModel rather than setting it explicitly. + # An operational repo's endings follow the consuming app's platform, so they must be declared, where a release repo omits the field and takes the fleet CRLF default. + # Resolve the effective model the way configure.sh does, from the repo, then the defaults, then release. + # The requirement then holds even where a repo relies on an operational defaults.workflowModel rather than setting its own. effective_model = model or default_model or "release" if effective_model == "operational" and eol is None: errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") @@ -196,25 +191,23 @@ def check_secret_set(label, entry, need_kind): errors.append(f"{name}: target '{target}' maps to undefined mechanism '{mech_key}'") continue spec_mech = mechanisms[mech_key] - # docker/static-secret must carry its required secrets + # A docker or static-secret target must carry its required secrets. for req in spec_mech.get("requires", []): if req not in required: errors.append(f"{name}: {target} requires secret '{req}' (missing)") - # oidc mechanisms must not carry a forbidden static key + # An oidc mechanism must not carry a forbidden static key. for bad in spec_mech.get("forbids", []): if bad in required: errors.append(f"{name}: {target} forbids secret '{bad}' (present)") - # mechanism label must match the target's expected mechanism family - # The repo's mechanism label (oidc / static-secret) must match the target mechanism's kind. - # An OIDC mechanism may still require a non-secret stored value (e.g. NUGET_USERNAME for - # NuGet/login), so requires-emptiness is not the signal - match on the explicit kind. + # The repo's mechanism label, oidc or static-secret, must match the target mechanism's kind. + # An OIDC mechanism may still require a non-secret stored value, NUGET_USERNAME for a NuGet login being one, so an empty requires list is not the signal. + # The match is on the explicit kind instead. kind = spec_mech.get("kind") if kind and mech != kind: errors.append(f"{name}: {target} labeled '{mech}' but its mechanism is '{kind}'") - # files.json appliesTo selectors must resolve to a known token, and no project type may collide with a - # reserved selector - a flat token set is only unambiguous while the namespaces stay disjoint. An - # unknown token fails open (it never matches), so a required file/section would silently apply nowhere. + # Every files.json appliesTo selector must resolve to a known token, and no project type may collide with a reserved selector, since a flat token set is only unambiguous while the namespaces stay disjoint. + # An unknown token fails open, never matching, so a required file or section would silently apply nowhere. reserved = set(WORKFLOW_MODELS) | set(RELEASE_TRIGGERS) | set(CONSUMER_MODELS) clash = known_types & reserved if clash: @@ -227,15 +220,14 @@ def check_selector(where, applies_to): return tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) for tok in tokens: - # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable - # token (e.g. a nested object) reaching the set-membership test below. + # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable token, a nested object being one, reaching the set-membership test below. if not isinstance(tok, str): errors.append(f"files.json: {where} appliesTo has a non-string token {tok!r}") elif tok not in universe: errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") - # CI runs no JSON-schema validation, so shape-check files.json here rather than crash on a malformed - # entry (a non-object baseline item, a non-array sections, a section that is neither string nor object). + # CI runs no JSON-schema validation, so shape-check files.json here rather than crash on a malformed entry. + # The shapes that reach this are a non-object baseline item, a non-array sections, and a section that is neither string nor object. files = load("spec/files.json") baseline = files.get("baseline", []) if not isinstance(baseline, list): @@ -251,9 +243,8 @@ def check_selector(where, applies_to): continue check_selector(path, item.get("appliesTo", "*")) - # fidelity governs how faithfully the unit is checked (spec/fidelity-model.md). CI runs no schema - # validation, so shape-check the fidelity fields here rather than let a malformed contract or an - # outside-root reference slip through and crash a later check. + # The fidelity field governs how faithfully the unit is checked, per spec/fidelity-model.md. + # CI runs no schema validation, so shape-check the fidelity fields here rather than let a malformed contract or an outside-root reference slip through and crash a later check. fid = item.get("fidelity", "presence") if fid not in FIDELITIES: errors.append(f"files.json: {path} fidelity '{fid}' invalid (expected one of {', '.join(FIDELITIES)})") @@ -301,9 +292,9 @@ def check_selector(where, applies_to): if not isinstance(elt.get("name"), str) or not elt.get("name"): errors.append(f"files.json: {path} section object missing a non-empty string 'name': {elt!r}") check_selector(f"{path} section '{elt.get('name', '?')}'", elt.get("appliesTo", "*")) - # A section may carry its own fidelity (intent default, or verbatim for a universal rule block - # checked byte-for-byte). verbatim is meaningful only on a Markdown file, where the heading - # delimits the region. The hub's own file is the canonical, so no reference is needed. + # A section may carry its own fidelity, defaulting to intent, or verbatim for a universal rule block checked byte-for-byte. + # Verbatim is meaningful only on a Markdown file, where the heading delimits the region. + # The hub's own file is the canonical, so no reference is needed. sfid = elt.get("fidelity", "intent") if sfid not in ("intent", "verbatim"): errors.append(f"files.json: {path} section '{elt.get('name', '?')}' fidelity '{sfid}' invalid (expected intent or verbatim)") @@ -312,11 +303,10 @@ def check_selector(where, applies_to): elif not isinstance(elt, str): errors.append(f"files.json: {path} section entry {elt!r} must be a string or object") - # Every declared section must resolve to a real `## <heading>` in the hub's own copy of the file - # (spec/section-model.md "Enforcement"). Without this, a renamed or mistyped section name declares a - # region that does not exist: the downstream verbatim byte-match in audit.py then has nothing to - # compare, and the section silently stops being checked anywhere - the quiet-narrowing failure that - # Verification Discipline forbids. Markdown only, and only when the hub ships the file. + # Every declared section must resolve to a real `## <heading>` in the hub's own copy of the file, per spec/section-model.md "Enforcement". + # Without this, a renamed or mistyped section name declares a region that does not exist. + # The downstream verbatim byte-match in audit.py then has nothing to compare, and the section silently stops being checked anywhere, which is the quiet-narrowing failure Verification Discipline forbids. + # This is Markdown only, and only where the hub ships the file. if path.endswith(".md") and (ROOT / path).exists(): hub_text = (ROOT / path).read_text(encoding="utf-8", errors="replace") headings = {m.group(1).strip() for m in re.finditer(r"^## (.+?)\s*$", hub_text, re.M)} @@ -325,16 +315,14 @@ def check_selector(where, applies_to): if isinstance(name, str) and name and name not in headings: errors.append(f"files.json: {path} declares section '{name}' but no '## {name}' heading exists in {path}") - # Validate the divergence ledger (spec/divergences.json) when present, so a mistyped repo name or - # disposition fails CI instead of silently dropping a burn-down row. + # Validate the divergence ledger in spec/divergences.json when present, so a mistyped repo name or disposition fails CI rather than silently dropping a burn-down row. dispositions = ("re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire") if (ROOT / "spec/divergences.json").exists(): div = load("spec/divergences.json") repo_names = {r.get("name") for r in repos["repos"] if isinstance(r, dict)} manifest_paths = {i.get("path") for i in baseline if isinstance(i, dict)} - # A verbatim section is an addressable unit too, labeled "path > section" (matches fidelity_honesty's - # SECTION_SEP), so a section-scoped divergence can carry its own disposition. Only well-formed section - # entries produce a label - a malformed one is already reported by the files.json checks above. + # A verbatim section is an addressable unit too, labeled "path > section" to match fidelity_honesty's SECTION_SEP, so a section-scoped divergence can carry its own disposition. + # Only well-formed section entries produce a label, since a malformed one is already reported by the files.json checks above. for i in baseline: if not isinstance(i, dict) or not isinstance(i.get("path"), str) or not isinstance(i.get("sections"), list): continue @@ -358,7 +346,7 @@ def check_selector(where, applies_to): errors.append(f"divergences.json: disposition {d!r} is not an object") continue p = d.get("path") - # isinstance guard first: a non-string path is unhashable and would crash the membership test. + # The isinstance guard comes first, since a non-string path is unhashable and would crash the membership test. if not isinstance(p, str): errors.append(f"divergences.json: disposition path {p!r} must be a string") elif p not in manifest_paths: @@ -380,7 +368,7 @@ def check_selector(where, applies_to): errors.append(f"divergences.json: gap {g!r} is not an object") continue gp = g.get("path") - # isinstance guard first: a non-string path is unhashable and would crash the membership test. + # The isinstance guard comes first, since a non-string path is unhashable and would crash the membership test. if not isinstance(gp, str): errors.append(f"divergences.json: gap path {gp!r} must be a string") elif gp in manifest_paths: