diff --git a/.agents/skills/README.md b/.agents/skills/README.md index aeffa88e..bacb7a02 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -1,6 +1,6 @@ # Fleet Skills -Canonical source for the fleet's Claude Code / opencode / Codex Skills, one directory per skill: `/SKILL.md` plus optional `scripts/` and `references/`. This is the only place a skill's content is hand-authored. Everything else derived from it is generated, never hand-edited. +Canonical source for the fleet's Claude Code / opencode / Codex Skills, one directory per skill: `/SKILL.md` plus optional `scripts/` and `references/`. This is the only place a skill is hand-authored, and the text inside an include region is generated from the file the region names. Everything else derived from it is generated, never hand-edited. Codex and opencode read this directory directly (`.agents/skills//SKILL.md`), no install step required, walking from a downstream repo's working directory up to its own repository root. Claude Code does not scan this path. GitHub Copilot discovers repository skills under `.github/skills/`. `scripts/build_dist.py` generates both the GitHub tree and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. diff --git a/.agents/skills/agent-conduct/SKILL.md b/.agents/skills/agent-conduct/SKILL.md index d02e4e04..48f42ffa 100644 --- a/.agents/skills/agent-conduct/SKILL.md +++ b/.agents/skills/agent-conduct/SKILL.md @@ -1,47 +1,86 @@ --- name: agent-conduct description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md "Context and Delegation Discipline" section is the always-on layer, and this skill fires at the moments rather than duplicating it, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, local-strict-review for the review passes a push owes, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill surfaces keep the full rules, and the skill carries each of them whole as a generated include rather than as a summary. --- # Agent Conduct ## Why This Exists -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and each of those three sections is carried here whole, as a generated include that `scripts/build_dist.py` fills from the section and holds to it, so the text that surfaces at the moment is the rule's own rather than a shorter list of it. The carried `AGENTS.md` "Context and Delegation Discipline" section is the always-on layer and is not carried here. A defect in included text is fixed in `GOVERNANCE.md` and regenerated, never edited in this file, per the `skill-lifecycle` Skill. ## Before Claiming Done -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: +Read the section below before reporting success on anything non-trivial. It is `GOVERNANCE.md` "Verification Discipline", whole. -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A checkout this session did not create is not ground truth.** One found already sitting on disk may belong to another concurrent session, sit on a stale fetch or an unexpected branch, or hold unreviewed uncommitted edits. Clone fresh or read the live API instead of trusting `git status`/`git remote -v` run against a pre-existing checkout. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A `raw.githubusercontent.com` 404 does not distinguish a private repository from a missing file.** Where visibility is not confirmed public, read content via `gh api "repos///contents/?ref="`, capturing the result before decoding it (`content=$(gh api ... --jq '.content') && printf '%s' "$content" | base64 -d`) rather than piping straight into `base64 -d`, whose own exit status is all a direct pipe reports, letting a failed fetch decode as an empty success. Never `2>&1` either form, which corrupts the decode with the error text instead of the payload. Verify the ref resolves before reading either failure as proof the content itself does not exist. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. -- **PR-bound work runs `local-strict-review` before the claim, and records the pass.** Claiming a unit of work done, verified, green, or fixed for work that will become, or already is, a pull request means running `local-strict-review` against the branch's diff first, before a PR-hosted reviewer finds the same gap, and recording that pass with a hub checkout's `scripts/local_review.py`, run with this repository as the working directory since the engine records into whichever repository the cwd sits in, per that skill's own commands. In the repository that authors canonical content others carry, a change moving one of its units owes a second pass over that unit's whole text, recorded with `scripts/canonical_review.py` before the commit, since its ledger is tracked. Where a capture point exists it then checks what applies. Every push toward a pull request owes one, the fix pushes answering review findings included, which is the round it is most often skipped on. + -Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. +The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. + +- **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` "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, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. +- **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. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write 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. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<///` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. +- **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. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. The two review passes the section above requires, one over a push's diff and one over each canonical unit a change moved, their delegation shape, and how each is recorded are the `local-strict-review` skill's. ## Before Assuming - **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. +- **The irreversible step (merge, publish, release, delete) stays the maintainer's, and a grant given in a past session or for a different task authorizes nothing now.** Whether a credential's reach or a tool that happens to work authorizes anything is answered by `GOVERNANCE.md` "Repository Boundaries and Write Safety" rather than here. + +How to ask, how to reference what the question is about, and how to raise work that is blocked on the answer is `GOVERNANCE.md` "Communicating with the User", whole. + + + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#123](https://github.com/OWNER/REPO/pull/123)`), never a bare `#123`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#123`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + ## When a Failure Surfaces a Lesson -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. +Where a lesson lands, and when it earns a mechanical hook, is `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. + + + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. + +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Two rules that bind at this moment have their homes elsewhere. A review flags an instance, so a fix covers the class, bounded to what the change touched or broke, stated under "Before Claiming Done" above. And a rule that keeps needing to be restated is a stale or missing skills install before it is a missing rule, per `AGENTS.md` "Where the Rules Live", with the `fleet-conformance-check` Skill as the check. ## Delegation, in One Paragraph -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline", loaded in every session, and are not restated here. The two that bind at a conduct moment are its rule on briefing a subagent and its rule on never tiering down the seat holding the judgment. diff --git a/.agents/skills/audit-a-repo/SKILL.md b/.agents/skills/audit-a-repo/SKILL.md index 5cd0c133..070703f9 100644 --- a/.agents/skills/audit-a-repo/SKILL.md +++ b/.agents/skills/audit-a-repo/SKILL.md @@ -18,10 +18,10 @@ The audit is the fleet's measurement procedure, and the two failure shapes it gu ## Measuring -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1, extended to `AUDIT.md`'s own checks: an item or check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). - **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. - **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit, each applicable guarantee cited in the form 5A sets out, then the 5B trace scenarios (`AUDIT.md` section 5). Read a workflow the repo only calls at the SHA it pins, for both. The `workflow-ci-contract` skill summarizes that contract. - **Check live settings, rulesets, and secrets from a hub checkout at `main`** with `AUDIT.md` section 6. Run `repo-config/configure.sh check` with the target repository and model for settings and rulesets, and `spec/audit.py [RepoName]` for secrets, rather than constructing a local comparison. The hub payloads are the only repository-configuration source. ## Reporting diff --git a/.agents/skills/backlog-burndown/SKILL.md b/.agents/skills/backlog-burndown/SKILL.md index 47c14be1..50cf84b6 100644 --- a/.agents/skills/backlog-burndown/SKILL.md +++ b/.agents/skills/backlog-burndown/SKILL.md @@ -38,17 +38,15 @@ Everything below turns on which seat is acting, so both are named once here. amends the promotion pull request, and it owns worktree and branch cleanup, which "Dispatching a Worker" states in full. - **A worker** is one dispatched subagent holding one group, one worktree, and one feature branch, - which is `AGENTS.md` "Session Scope"'s one-branch-one-deliverable rule applied as written. It - drives its own pull request into develop and ends there. + the dispatched task `AGENTS.md` "Session Scope" describes. It drives its own pull request into + develop and ends there. ## Scope -One repository, the one the session is in, resolved from its own `origin`. Reads are unrestricted -per `GOVERNANCE.md` "Repository Boundaries and Write Safety", so reading another repository's -issues breaks no rule. Working them is out of this skill's scope, and a fleet-wide backlog -sweep is a different request. That section bounds writes to the owner of -this repository rather than to this repository alone, and a run staying inside the one repository -it was invoked for is narrower than the rule requires, deliberately. +One repository, the one the session is in, resolved from its own `origin`. A run staying inside the +one repository it was invoked for is narrower than `GOVERNANCE.md` "Repository Boundaries and Write +Safety" requires, deliberately. Reading another repository's issues is governed there and not here, +working them is out of this skill's scope, and a fleet-wide backlog sweep is a different request. ## What Invoking This Skill Authorizes @@ -58,7 +56,7 @@ it was invoked for is narrower than the rule requires, deliberately. - **The grant is bounded by the session it was named in.** A run interrupted and resumed in a new session needs the skill named again, which costs one sentence and is the difference between a grant and a mode. A grant read back from a note is one nobody gave. -- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's item 5 for +- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's explicit-permission item for this run's feature -> develop merges and nothing else, so a pull request with one open finding still does not merge. - It is never authorization to merge a develop -> main promotion pull request, to dispatch a @@ -111,10 +109,10 @@ Rank on these, highest first where they conflict: An issue that asks a question rather than states a defect is not ranked and is never guessed at. It has no group, no worker, and no claim, so nothing in "Raising a Blocked Question" applies to it -except how the question travels. It goes to the maintainer at the end of -ranking, in the same prompt as any other question the run is sending at that moment and in one of -its own otherwise, rather than waiting for a stop that may not come. It stays unranked until -answered. +except how the question travels. It goes to the maintainer at the end of ranking, per +`GOVERNANCE.md` "Communicating with the User", batched with any other question the run is sending +at that moment and in a prompt of its own otherwise, rather than waiting for a stop that may not +come. It stays unranked until answered. ## Grouping and File Claims @@ -154,9 +152,9 @@ for, and it binds harder than any throughput target. rather than plain fetch because `--prune` is what drops a remote-tracking ref whose branch is gone from the remote, deleted there by another session or through the web interface, and a plain fetch leaves that ref in `git branch -r` to defer valid groups forever. Stop and report a - failed fetch rather than reading `git branch -r` anyway: the remote-tracking refs still resolve - from what the last successful fetch left, so the scan returns a confident answer about a remote - it did not reach, missing a branch pushed since and keeping one deleted since. The round + failed fetch rather than reading `git branch -r` anyway, per `GOVERNANCE.md` "Verification + Discipline" on what a local clone answers for: here the scan would miss a branch pushed since + the last successful fetch and keep one deleted since it. The round stops there and reports, rather than dispatching against a stale answer, and stopping rather than deferring is what the cleanup and promotion steps need too, since both read the same remote. @@ -199,9 +197,8 @@ its own bound stated in the worker's brief. remove the rule that leaned on it. A narrowed qualifier is where a new false claim gets introduced, and it is the most common way a prose round produces the finding the following round then fixes. -- **Set a review-round budget before the first push.** A whole-unit prose review can run many - rounds where a finding was introduced by the previous round's fix, so state a number in the - brief, and when it is reached, land what is correct and file the remainder rather than churning. +- **The review-round budget is `local-strict-review` "Disposing of Findings"'s.** The brief names + it and states no second one. ## Dispatching a Worker @@ -210,18 +207,19 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. - **The worker drives its group to a develop merge**, by invoking `drive-pr` with the target stated as develop only. That skill owns the review loop, the finding disposition, and the merge, so brief the group and the bounds rather than restating the loop. -- **The worker creates its own worktree**, always, as `drive-pr` step 1 and `repo-worktree`'s - task-start mandate already require of the task itself. No worker inherits another's worktree, +- **The worker creates its own worktree**, always, as `drive-pr`'s worktree isolation and + `repo-worktree`'s task-start mandate already require of the task itself. No worker inherits another's worktree, which is why "Bounding the Wait on a Worker" either removes a dead worker's tree and its branch or leaves that tree untouched for the maintainer, and never passes it on. -- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr` step 4 - and of `repo-worktree`'s post-merge procedure. Say so in the brief, because a worker following - either alone will clean up. The worker still performs step 4's merge itself, and what the override - moves is that step's two cleanup halves, the worktree procedure and the verify-then-delete of the - merged remote branch, **both** rather than only the first. "Cleanup Is the Orchestrator's" below, in this +- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr`'s + post-merge cleanup and of `repo-worktree`'s post-merge procedure. Say so in the brief, because + a worker following either alone will clean up. The worker still performs the merge itself, and + what the override moves is the two cleanup halves `drive-pr` runs after it, the worktree + procedure and the verify-then-delete of the merged remote branch, **both** rather than only the + first. "Cleanup Is the Orchestrator's" below, in this same section, says why and what it covers. -- **The worker runs `local-strict-review` before every push**, including one that only fixes a - review finding. That pass dispatches a reviewer of its own, so a harness where a subagent cannot +- **The worker runs `local-strict-review` before every push**, per `GOVERNANCE.md` "Verification + Discipline". That pass dispatches a reviewer of its own, so a harness where a subagent cannot dispatch one leaves the worker unable to run it and unable to push. It reports that rather than pushing, and its worktree is then retired, since git refuses to attach that branch anywhere else while the reporting tree holds it. The branch is left standing for its own reason, that the @@ -255,13 +253,13 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. `repo-worktree`'s post-merge procedure returns the base clone to current develop before proving the cleanup, and `operational-vs-release-workflow` states that requirement independently. Four workers doing that concurrently mutate one shared checkout, which `GOVERNANCE.md` "Repository Boundaries and -Write Safety" forbids outright by giving each task its own checkout. A worker also cannot +Write Safety" forbids. A worker also cannot finish the procedure from inside its own worktree, since removing that worktree leaves it with no working directory in which to delete its branch. So the whole procedure moves to the orchestrator, which runs it from the base clone at the round's -cleanup step, while no worker is live in a tree it touches. It carries `drive-pr` step 4's remote -half too, verifying the merged branch's tip against the pull request's `headRefOid` before +cleanup step, while no worker is live in a tree it touches. It carries the remote half of `drive-pr`'s +post-merge cleanup too, verifying the merged branch's tip against the pull request's `headRefOid` before `git push origin --delete`, since taking that step from the worker without naming a new owner would leave a live remote branch behind every group. It covers every group that is done with its tree, which is the finished ones **and the abandoned ones**: a group told to abandon its branch keeps a @@ -299,10 +297,10 @@ judgment here: the tier is chosen per group rather than defaulted, because a str produces better work up front and takes fewer review rounds to land it, which often costs less than a cheaper worker looping. Three kinds of group are never tiered down: -- One touching **carried canonical content**: rule text, a Skill, or anything else this repository - authors and other repositories carry, since a wrong rule propagates to every carrier. -- One touching **a gate, a ruleset, a release condition, or a carried governance section**, which - is `AGENTS.md`'s own list of what counts as a design change however small the diff looks. +- One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds + it, since a wrong rule propagates to every carrier. +- One touching **anything `AGENTS.md` "Delegation" calls a design change**, however small the diff + looks. - One whose issues are **complex or entangled**, where the fix depends on reasoning across several files or on a contract not stated in the file being edited. @@ -310,7 +308,8 @@ State the chosen tier and its reason in the round's report. ## Bounding the Wait on a Worker -`AGENTS.md` requires a wait to separate its outcomes and to be bounded, so this one is. A worker +`AGENTS.md` "Delegation" binds this wait as it binds any other, and this section is how the bound +is met here. A worker reports merged, parked, or stopped. A worker that reports nothing at all is the case needing a bound, since it is indistinguishable from a slow one and dying mid-drive is ordinary here. @@ -336,10 +335,8 @@ dispatched fresh, its claim comment released with the worktree. It fails, and cl and the group goes to the maintainer, since past that point removal discards work. **A dirty one is left exactly as it stands** and the group is stopped for the maintainer per "Raising a Blocked Question", naming the worktree and what is uncommitted in it. The orchestrator does not commit that work, hand the tree to a -replacement to commit, or remove it: reaching into a tree a task was live in is what -`GOVERNANCE.md` "Repository Boundaries and Write Safety" forbids, and doing it by proxy is still -doing it. Where no other worker remains to bound the wait, the same liveness answer bounds it -alone. +replacement to commit, or remove it, per `GOVERNANCE.md` "Repository Boundaries and Write Safety". +Where no other worker remains to bound the wait, the same liveness answer bounds it alone. ## Raising a Blocked Question @@ -350,13 +347,12 @@ rather than a decision. - **The group stops, and nothing about it is disposed of.** No thread is resolved, no finding is answered on the orchestrator's own judgment, and no pull request merges. - **The other groups keep driving.** One stopped group never idles the round. -- **The question travels worker to orchestrator to maintainer, and reaches the maintainer at the - point the work stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, - since a dispatched subagent is not the seat that can prompt anyone. The orchestrator is that - seat, and it asks then and there through the interface's own prompt mechanism, per - `GOVERNANCE.md` "Communicating with the User". Holding the question for a round boundary is the - handoff-buried-in-a-paragraph that section forbids, and a boundary can be a long way off or, - for a group blocking the promotion pull request, never arrive at all. Where several groups stop +- **The question travels worker to orchestrator to maintainer, and is asked when the group + stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, since a + dispatched subagent is not the seat that can prompt anyone. The orchestrator is that seat, and + it asks then and there, per `GOVERNANCE.md` "Communicating with the User". Holding the question + for a round boundary is what that section forbids, and a boundary can be a long way off or, for + a group blocking the promotion pull request, never arrive at all. Where several groups stop close together, their questions go in one prompt, which is batching without deferral. - **The question is also written on its issue**, so it survives the session that asked it. - **A stopped group keeps its branch and its claim**, and its worktree is left exactly as it @@ -377,16 +373,15 @@ for the maintainer, so that one carries a single round rather than accumulating **This section assumes the release workflow model**, where feature work reaches develop through squash-merged pull requests and a promotion pull request carries develop to main. A repository -whose registry `workflowModel` reads `operational` differs on both counts, per -`operational-vs-release-workflow`: it commits to develop directly, and it opens a promotion pull -request only occasionally rather than per round, so confirm with the maintainer whether one is -wanted at all there. - -Neither difference changes how this run's own work is read. Every worker invokes `drive-pr` -whatever the model, so this run's fixes still arrive as squash-merged feature pull requests -carrying the `Closes on promotion:` line, and the two hops still read them. What the model adds is -a second kind of commit in the same range, a direct push that never had a pull request, whose -issues are recoverable only from the commit message itself. Read both, the pull requests for this +whose registry `workflowModel` reads `operational` reaches develop differently, per `GOVERNANCE.md` +"Operational Repositories". Confirm with the maintainer whether a promotion pull request per round +is wanted there. + +That difference changes nothing about how this run's own work is read. Every worker invokes +`drive-pr` whatever the model, so this run's fixes still arrive as squash-merged feature pull +requests carrying the `Closes on promotion:` line, and the two hops still read them. What the +operational model adds is a second kind of commit in the same range, a direct push to develop that +never had a pull request, whose issues are recoverable only from the commit message itself. Read both, the pull requests for this run's work and the commit messages for the direct pushes, since reading either alone returns a partial set, and the range rather than this round is still what covers earlier work no promotion has carried. @@ -398,21 +393,22 @@ has carried. can still owe a promotion pull request, for work an earlier round landed and no promotion has yet carried. A count of zero is the only case with nothing to promote, and the round reports that instead of attempting one. -2. Drive its review loop per `drive-pr` steps 5 through 8, **with a review-round budget set before - the first one**, the same discipline "Bounding a Prose Group" applies to a feature branch. That - loop repeats until the promotion pull request carries no open finding, and nothing in it - terminates on its own, so when the budget is reached, stop and put the state to the maintainer - rather than continuing to spend the run's only forward gear on one pull request. -3. Put the ready pull request to the maintainer through the interface's own prompt mechanism, - naming the merge as the action that unblocks the run. The maintainer's merge is the run's clock, so one +2. Drive its review loop per the promotion half of `drive-pr` "The Drive Loop", **with a + review-round budget set before the first round**. That loop repeats until the promotion pull + request meets every `pr-review-conduct` Merge Gate item except the maintainer's explicit + permission to merge, and nothing in that loop terminates on its own, so when the budget is + reached, stop and put the state to the maintainer rather than continuing to spend the run's only + forward gear on one pull request. +3. Put the ready pull request to the maintainer, per `GOVERNANCE.md` "Communicating with the + User", with its merge as the action asked for. The maintainer's merge is the run's clock, so one reported in a closing paragraph and never actually asked about stalls every round behind it. Do not merge it. 4. **While it waits, develop takes only what that pull request itself needs.** A finding against it lands as its own feature -> develop pass, and that landing moving its head is expected, since its head **is** develop. **That pass is dispatched as a worker like any other**, which is the one push the freeze permits and the reason the orchestrator still opens no branch of its own. - `drive-pr` step 6 sends the seat driving a promotion pull request back through its own steps 1 - to 4 for such a fix, and here that seat dispatches rather than drives it. + `drive-pr` "The Drive Loop" sends the seat driving a promotion pull request back through its + own feature -> develop pass for such a fix, and here that seat dispatches rather than drives it. 5. **A promotion fix outranks any file claim.** A group holding a file it needs yields, because the promotion pull request is what the whole run is queued behind. A holder that is merely parked yields by handing the file over. A holder that already pushed and has an open pull request @@ -424,13 +420,13 @@ has carried. which is the worktree-only disposition "Cleanup Is the Orchestrator's" separates out and the retire-then-dispatch shape "Raising a Blocked Question" uses, and then dispatches a fresh worker on that same branch, briefed either to merge develop in to pick the fix up or to narrow - the change to drop the file. Never rebase it: - its branch is already pushed, so a rebase needs the force-push `git-commit-conventions` forbids - outright. + the change to drop the file. Never rebase it, + since its branch is already pushed and a rebase there needs what `GOVERNANCE.md` "Git and Commit + Rules" forbids. 6. **Nothing else pushes, and nothing else is dispatched.** The promotion fix of step 4 is the one exception to both, and everything in this step is said of the next round's work rather than of it. That round's preparation is orchestrator work and continues: rank, group, and verify claims. - Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, whose second step + Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, which pushes and opens a pull request, so a next-round worker dispatched under the freeze would either break it or sit in a state that procedure does not describe. None is left running across the wait either, since a worker held idle for an unbounded maintainer wait is one doing nothing at a @@ -468,8 +464,9 @@ body when it lands rather than leaving the issue to be closed by hand. - **Working notes outside the repository hold the round**: the ranking, the working groups, the tier choices, and the worker assignments. A scratch file the harness gives a session serves - where there is one, and any note kept out of the tree serves where there is not. It is working - state, and nothing about it is committed. + where there is one, and any note kept out of the tree serves where there is not. It is the + in-flight session state `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" describes, and + nothing about it is committed. - **GitHub holds what outlives the session.** A claim comment records a group's file set, a pull request body records what a round carried, a `Fixes #N` line records what the promotion closes, a deferral issue records what was put off and why, a thread reply records how a finding was diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.agents/skills/dotnet-codestyle/references/testing.md b/.agents/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.agents/skills/dotnet-codestyle/references/testing.md +++ b/.agents/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump forces the move. + +**The version floor is load-bearing rather than cautionary.** Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform `xunit.v3` 4.0.0 carries, runs zero tests, and **still writes a well-formed Cobertura file reporting full coverage**, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a solution with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.agents/skills/drive-pr/SKILL.md b/.agents/skills/drive-pr/SKILL.md index b234fb29..9ea24f53 100644 --- a/.agents/skills/drive-pr/SKILL.md +++ b/.agents/skills/drive-pr/SKILL.md @@ -2,19 +2,19 @@ name: drive-pr description: >- Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into - develop and, when asked, on to a mergeable develop -> main promotion PR, applying the - pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with - evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an - explicit answer in the same turn, escalating to whoever dispatched the drive instead where the - maintainer cannot be reached from that seat. Use this whenever asked to drive, land, take, chase, or push + develop and, when asked, on to a mergeable develop -> main promotion PR, disposing of every + reviewer finding along the way under pr-review-conduct's outcomes, carried here whole as a + generated include, and escalating to whoever dispatched the drive where the drive's own seat + cannot reach the maintainer. Use this whenever asked to drive, land, take, chase, or push a PR toward develop or main, or to run the review loop hands off instead of narrating each round. When the request does not say how far ("drive this PR", "land it"), ask once whether the target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when only one PR is named, because a finding raised against the develop -> main promotion PR routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends - at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges - main itself, that is the separate merge-and-release skill, its own go-ahead. + at develop merged, or at a promotion PR meeting every pr-review-conduct Merge Gate item except + the maintainer's explicit permission to merge, never merges main itself, that is the separate + merge-and-release skill, its own go-ahead. --- # Drive PR @@ -128,32 +128,57 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 1 to 4 in its own worktree and branch, then return here. 7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request a review on the new head and continue the loop. -8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green - on the current head. +8. Repeat 6 and 7 until the promotion PR meets every pr-review-conduct Merge Gate item except the + maintainer's explicit permission to merge. 9. Report the promotion PR number and its ready state. Do not merge it. ## Disposing of Every Finding -pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: - -- Real, so fix it, then step 2's own order again before replying with the fixing commit SHA - (outcome 1). This is the round the pass is most often skipped on, since the fix looks small and - the branch was already reviewed once, and a fix push carries content no pass has read exactly as - the first push did. -- Not real, or real but out of scope here, so decline in the thread with evidence: the command - and its output, the code path, or the rule that governs it. An assertion never closes a finding - on its own (outcome 2). -- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). -- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not - know which of the above applies, so ask the maintainer directly, whatever the runtime's own - interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask - later is resolution by silence (outcome 3). A drive that cannot reach the - maintainer directly, a dispatched one being the ordinary case, escalates to whoever dispatched - it and stops that unit of work there instead, per `pr-review-conduct`, which owns what the - receiving seat then does and how far the escalation travels. -- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a - comment, or take the rule itself to the maintainer, rather than re-arguing the instance every - round (outcome 5). +The rule below is a generated include, so a defect in it is fixed in `pr-review-conduct` and +regenerated rather than edited here. A drive that cannot reach the maintainer directly, a +dispatched one being the ordinary case, escalates per `pr-review-conduct` "Escalate to the +maintainer when". + + + +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. +5. **Keeps recurring, so fix the class, not the instance.** A finding raised repeatedly against + correct code means the code is not communicating something: add the comment, sharpen the name, + narrow the interface, or fix the rule if the rule is wrong. Bouncing the same point across + rounds is the signal to escalate the rule itself, not to keep re-arguing it. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + + ## Mechanics Live Elsewhere diff --git a/.agents/skills/local-strict-review/SKILL.md b/.agents/skills/local-strict-review/SKILL.md index 336effcc..fa1c1a86 100644 --- a/.agents/skills/local-strict-review/SKILL.md +++ b/.agents/skills/local-strict-review/SKILL.md @@ -55,6 +55,8 @@ Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of ``` +Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. + **Model tier:** the strongest tier this session can reach, per `AGENTS.md` "Match the model tier to the judgment" and "Never tier down the seat holding the judgment", applied here to the reviewer rather than the author. Run the pass on the same tier that authored the change when only one tier is reachable, a second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. "This session can reach" means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names a stronger tier for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch reaches several tiers but exposes no way to name one, take what it gives and run the pass, on the same reasoning as the single-reachable-tier sentence above. A seat that cannot dispatch a subagent at all cannot perform this pass. Instead of pushing, it reports that it could not run the pass, to whoever dispatched it, or to the maintainer where nobody did. Either way it is a push that does not happen rather than a pass quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. @@ -110,27 +112,38 @@ Bounds: read-only. Report a rule that looks incomplete rather than guessing at w git fetch origin # stop and report a failed fetch rather than measuring past it python3 scripts/canonical_review.py check --target # each uncovered unit, with its digest # run the pass above over each unit it named, then, per unit: -python3 scripts/canonical_review.py record --reviewer agent-skill --unit '=' [--findings N] +python3 scripts/canonical_review.py record --reviewer agent-skill --target --unit '=' [--findings N] ``` -These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the second is measured with the first's unit model, while `record` stamps the ledger with a commit read from the second. +These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the two mix, the engine's own section rules over the other tree's manifest and files. -`` is the branch this work targets, resolved once as the pass above resolves it and passed to `check` explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. +`` is the branch this work targets, resolved once as the pass above resolves it and passed to both commands explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read, and `record` stamps each pass with a merge-base against a branch the work never targeted. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. The digest is bound to the read for the same reason `--expect-digest` is above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. Record each unit whatever the pass found, including nothing. Fixing a finding is itself such an edit, so `record` then refuses the digest you were holding: that refusal is the content having moved rather than a fault in the record, and the answer is a read of the unit's new text, which is what a carrier will actually receive, recorded at its new digest. -**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger and its burn-down are tracked files the commit has to carry, so writing them after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. +**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger, `reports/canonical-review.json`, is a tracked file the commit has to carry, so writing it after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. -**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry in the hub's `reports/canonical-review.md` rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. +**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry `canonical_review.py report` renders rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. ## Disposing of Findings -Every finding maps to one of `pr-review-conduct`'s five outcomes, at whichever moment this pass ran: fixed (1), evidence-disproven (2), escalated to the maintainer for an explicit call (3), filed as a deferred issue (4), or, if it keeps recurring, taken as a signal to fix the class (5). Outcome 2 is the agent's own on its own evidence, covering a finding that is not real and one that is structurally out of scope. A finding judged real and left unfixed is never the agent's alone, so outcome 3 needs the maintainer's explicit answer in the same turn, reached only once outcome 2 is ruled out, or, where this pass ran in a seat that cannot reach the maintainer, an escalation to whoever dispatched it that stops the work there, which stops the push this pass runs before, and outcomes 4 and 5 reach the maintainer too, for the deferral and for the rule itself. Running this pass is required before every push toward a pull request, per `agent-conduct`. Two claims sit next to each other here and they point opposite ways, so they are stated apart rather than in one sentence. **The pass is mandatory**, and where a capture point enforces it, a push carrying content no recorded pass covers is refused. That refusal is the gate working rather than a fault to route around. **The findings stay advisory**, and the count a pass raises gates nothing at all, since a pass records that a review ran and never that the content is clean. The disposition above is what closes each finding, the same posture local lint holds today. It posts nothing to GitHub, it only reports to the session driving the work. A finding raised here and not fixed is not thereby resolved: the same finding shape reaching a PR-hosted reviewer later still gets its own fresh disposition, per `pr-review-conduct`'s "a disposition decided on one PR does not carry to the next." +Each bullet is a rule down to its `Why:` line, which is rationale rather than rule, so a stale rationale is a cleanup rather than a defect. + +- **Every finding ends in one of the outcomes that `pr-review-conduct` "Every finding ends in one of five outcomes" enumerates, reached here with no thread to reply in.** + - `Why:` a local finding and a PR-hosted one deserve the same dispositions, and one home for the list is what stops two copies of it drifting apart. +- **The agent disposing of a pass's findings classes each one `style`, `introduced`, or `pre-existing`, in that order.** `style` is a preference between defensible forms. `introduced` is any other finding on text this change wrote, rewrote, or removed, on text this change should have written, on a precondition this change left false elsewhere, or load-bearing for a decision this change puts to the maintainer. `pre-existing` is every other finding. + - `Why:` the reviewer is asked to omit preferences and returns some anyway, and `style` is classed first so that a preference on text this change wrote is not owed a fix. +- **Another round is owed only while an `introduced` finding is open.** Unless evidence disproves it, an `introduced` finding is fixed within the budget below, or escalated where `pr-review-conduct` "Escalate to the maintainer when" says so, a `pre-existing` one is filed once and blocks nothing, and a `style` one is declined with evidence, per `pr-review-conduct` "Every finding ends in one of five outcomes", the evidence being `code-review` "Review the Change"'s own rule to omit preferences. + - `Why:` a finding count over prose never reaches zero, so a loop closing on "did it find anything" does not close, where one closing on the false claim, the unfollowable instruction, or the wrong behavior this change put there does. +- **A push allows two rounds of edits in answer to the passes it owes, one budget across both.** Where an `introduced` finding is still open after the second round, editing stops and what remains goes to the maintainer with its counts per class, per `pr-review-conduct` "Escalate to the maintainer when". + - `Why:` past the second round nearly every finding is against text the previous round's fix wrote, so the rounds are producing the defects they find rather than removing them. +- **The pass is mandatory, and the count it records gates nothing.** A pass is recorded whatever it raised, so the record attests that a review ran rather than that the content is clean. + - `Why:` a gate reading the count would make a pass raising nothing the cheapest way through it, the opposite of what recording one is for. ## When to Run It -- Before the first push toward a pull request (`drive-pr`'s Drive Loop step 2, `pr-review-conduct`'s Expected review loop step 1). -- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (`drive-pr`'s "Disposing of Every Finding", `pr-review-conduct`'s outcome 1). +- Before the first push toward a pull request, the push that opens it in `drive-pr` "The Drive Loop" and in `pr-review-conduct` "Expected review loop". +- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (the fix outcome of `pr-review-conduct` "Every finding ends in one of five outcomes", which `drive-pr` "Disposing of Every Finding" carries). - Whenever `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for work that will become, or already is, a pull request. - Before pushing a change that edits canonical content other repositories carry, or that newly carries some by widening the manifest, over each unit `check` names, per "The Carried-Content Pass" above. diff --git a/.agents/skills/operational-vs-release-workflow/SKILL.md b/.agents/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.agents/skills/operational-vs-release-workflow/SKILL.md +++ b/.agents/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), diff --git a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 8bfd49a6..3ca50760 100644 --- a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.agents/skills/pr-review-conduct/SKILL.md b/.agents/skills/pr-review-conduct/SKILL.md index 1073b824..b6f9f91a 100644 --- a/.agents/skills/pr-review-conduct/SKILL.md +++ b/.agents/skills/pr-review-conduct/SKILL.md @@ -41,11 +41,14 @@ visible comments, routinely still carries a finding nobody has answered. Treatin 2. A review is confirmed on the **current head SHA**, matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the re-review lands, and the matched review is **read**, not just counted. A review can carry the head SHA and still decline - the PR outright, or say it read only part of the changed files. `pr_review.py`'s - `review_on_head` names Copilot's own coverage specifically, the currently required reviewer, - not "no review of any kind covers this head": a trialed advisory reviewer (CodeRabbit, - Qodo) carrying the exact head under `other_reviewed`, with an empty review body and no new - threads, is its own ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). + the PR outright, or say it read only part of the changed files. The coverage this item + requires is Copilot's, and CodeRabbit and Qodo are advisory, since the hub's + `docs/pr-reviewer-evaluation.md` "Status" names Copilot the incumbent and says no candidate is + a required reviewer: an advisory reviewer's absence blocks nothing, while its findings owe + item 3 exactly as Copilot's do. `pr_review.py`'s `review_on_head` names Copilot's own coverage + specifically, not "no review of any kind covers this head": an advisory reviewer carrying the + exact head under `other_reviewed`, with an empty review body and no new threads, is its own + ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). 3. **Every** finding on that head SHA is closed: threads resolved, issue-level comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered. Those appear in no thread, so polling threads @@ -55,6 +58,22 @@ visible comments, routinely still carries a finding nobody has answered. Treatin give each one the same triage the low-confidence findings above already get (#1058). Qodo's own `Resolved`/`Dismissed` self-tracked badge is a fast pre-triage signal, not a substitute for reading the finding, spot-verify against `gh pr diff` rather than trusting it outright. + What closing a finding owes turns on whether it is `pre-existing`. A finding on text inside a + canonical Markdown unit, one the hub's `scripts/canonical_review.py list` names, classed + `pre-existing` by the classes `local-strict-review` "Disposing of Findings" defines for a + local pass, applied here to a PR-hosted finding, is outcome 4 of "Every finding ends in one + of five outcomes" below applied once per unit rather than once per finding: the round gathers + that unit's such findings onto the unit's tracker, an open hub issue whose title carries the + unit key, retitled by the change that moves the key and filed by whichever round first needs + it, and answers each finding with that issue's link, resolving a thread on that reply, so a + `pre-existing` remark on a sentence the change never touched costs one link rather than a + decline or an issue per finding. The batch runs in the hub, which authors the text of every + verbatim unit. A carrying repository routes a finding on a verbatim unit by fidelity rather + than by class, since a resync writes the whole text there: it declines the finding under + that section's outcome 2, ownership sitting elsewhere, and files it on the same tracker, + while a finding on an intent unit is filed there too, the carrier adapting its own copy + meanwhile, since the defect is still fixed at the source. Every other finding, a `style` + remark on untouched text included, takes its own outcome in that section. 4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. File an issue naming it and quoting the body, rather than guessing what the new wording @@ -100,19 +119,22 @@ Run `local-strict-review` against the branch's current diff before step 1's push The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. After a timeout with `requested=yes`, rerun `wait` for another bounded interval by default because the request may still be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. Reporting only that the PR was opened is an early -exit unless the maintainer explicitly instructed the agent not to monitor or drive its review. +then apply the Merge Gate above. **Never exit this PR-hosted loop early.** Its pre-push +counterpart is bounded instead by `local-strict-review` "Disposing of Findings". A round count +is not a stopping condition here, and neither is patience running out. Reporting only that the +PR was opened is an early exit unless the maintainer explicitly instructed the agent not to +monitor or drive its review. After an authorized merge, run the `repo-worktree` post-merge cleanup procedure unless the user explicitly asks to retain the checkout or branch. The pull request loop is incomplete while its finished worktree or local task branch remains. It is also incomplete until the base clone returns to fetched and fast-forwarded `develop`. ## Every finding ends in one of five outcomes -1. **Real, so fix it.** Take the fix through `local-strict-review` the same way step 1's push - went, then reply with the fixing commit SHA. A branch already reviewed once - has not been reviewed for the fix, which is the round this gets dropped on and the churn - `local-strict-review` exists to stop. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. 2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** @@ -141,6 +163,9 @@ on a sibling repo or PR, even within one batch or one session, gets its own outc evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior instance's outcome is context for the new one, never a standing answer to reuse in its place. +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + ## Triaging findings **A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, diff --git a/.agents/skills/python-codestyle/references/testing.md b/.agents/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.agents/skills/python-codestyle/references/testing.md +++ b/.agents/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. diff --git a/.agents/skills/skill-lifecycle/SKILL.md b/.agents/skills/skill-lifecycle/SKILL.md index aa853916..4a8aa6f8 100644 --- a/.agents/skills/skill-lifecycle/SKILL.md +++ b/.agents/skills/skill-lifecycle/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-lifecycle description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the include regions it fills from a rule's home so a skill carries the rule's text without a copy, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. --- # Skill Lifecycle @@ -12,9 +12,11 @@ The agent most likely to get a skill wrong is the one editing a skill, and befor ## The Pipeline -- **`.agents/skills//SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **`.agents/skills//SKILL.md` is the only tree a skill is authored in**, with optional `references/` and `scripts/` directories beside it, and the one part of it not written by hand is the text inside an include region, described below. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. - **Generated distributions serve GitHub Copilot and Claude Code.** `scripts/build_dist.py` generates `.github/skills/` for GitHub Copilot and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. Neither generated tree is hand-edited, and `build_dist.py --check` exits non-zero when either tree differs from `.agents/skills/`. - **The skill set is implicit.** Every `.agents/skills//` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **A rule's text reaches a skill as a generated include, never as a copy.** A region opened by a line holding only `` and closed by a line holding only ``, each indented at most three spaces, is filled by `build_dist.py` with the body under that heading. The key is the root-relative path, spelled as the tree spells it, then ` > `, then the heading text at any level from two, matched case-insensitively. The fill lands in `.agents/skills/` itself, since Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the generated trees mirror the filled source. A source is any regular file under the repository root outside the two generated trees and reached through no symlink, so a key may name a `GOVERNANCE.md` section, an `AGENTS.md` subsection, or a section of a sibling skill, and a region filled from a file carrying regions of its own reads that file's filled text. Regenerating after a source edit changes the bytes of every skill unit including it, and `--check` fails the pull request until that regenerate runs, so the whole-unit review pass `scripts/canonical_review.py` records for each of those units is owed again, which is the cost of a carrier reading generated text in the skill's own context. +- **`--check` holds every region to its source.** It fails when a region differs from what its source renders now, so a hand edit inside one and a source edit nobody regenerated for both fail the pull request the same way a stale mirror does. A region it cannot render is a failure rather than a stale result, exit 2 rather than 1, because regenerating cannot repair it: a key with no ` > ` or an empty heading, a path naming no file, a heading that no longer resolves or that recurs in its source, a body with nothing in it or leaving a code fence open, a region in a file the generator does not walk, reached through a key, since it walks only the Markdown files of the skill directories, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path outside the root, through a symlink, under a generated tree, or spelled otherwise than the tree spells it, a skill file or source that is not UTF-8, a file holding a region while mixing line endings, and a line outside a code block that begins like a marker and matches neither form, which read as content would leave a region unfilled. - **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). ## Deciding a Topic Deserves a Skill @@ -34,16 +36,17 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim ## Changing or Retiring a Skill -- **Edit only the source tree.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **Edit only the source tree, and outside its include regions.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. The text inside an include region is generated too, so a change there is made at the region's source and regenerated, never typed into the region. +- **Retiring is deleting the source directory and regenerating.** A region in a sibling keyed on the retired skill stops that regenerate, since its key no longer resolves, so re-key or remove it first. The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. - **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. An include key spelling the old path is such a reference, and one left behind fails `--check` as a region it cannot render rather than as a stale mirror. ## The Doc-Packaging Pattern -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has three shapes, and each pairing states which it uses: - **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills//SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md` outside sections 3, 4, and 5). The skill states per topic which doc section owns it. +- **Included content.** The doc keeps the full rules and the skill needs them whole to work in isolation, so it carries the section as a generated include rather than as a summary or a copy, declared with the region markers "The Pipeline" above describes and keyed on the doc's section (`agent-conduct` over the three `GOVERNANCE.md` sections it surfaces, `workflow-ci-contract` over `WORKFLOW.md` sections 3, 4, and 5). The doc side states the shape with one sentence naming the skill that includes the section, and the skill side is the region itself. A section carried this way is read outside its own document, so it names a sibling section by document and heading rather than as above or below, and it links to no file by a relative path, since the path would resolve against the skill's directory rather than the doc's. The doc wins by construction, since `scripts/build_dist.py` writes the region from it and its `--check` reports a region that differs from it as stale. -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +In every shape the doc is the authority when the two are found to disagree, the moved-content shape included: the doc's summary says what the rule is, and the skill's full text is what gets corrected. A deliberate change to a packaged rule is not such a disagreement. It lands where the full text lives, and in the same change the author either edits the other side's summary to match, since a summary has no mechanical check, or regenerates the include, which has one. A rule stated fully in both places by hand is the drift this pattern exists to prevent, and an include is the one full second statement that cannot drift undetected. diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index 0a3b832f..a02a47d6 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -8,40 +8,24 @@ description: >- ## Why This Exists -`WORKFLOW.md` in the hub is a behavioral contract stating required outcomes rather than a required implementation. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where those two overlap, which `WORKFLOW.md`'s own canonical-scope note states. +`WORKFLOW.md` is the fleet's CI/CD behavioral contract. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary, and `WORKFLOW.md` sections 3, 4, and 5 are each carried whole in `references/` as a generated include. `WORKFLOW.md`'s own canonical-scope note says which of it and `GOVERNANCE.md` is authoritative where the two overlap. ## How the Contract Is Read -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Outcomes, not bytes.** A workflow is judged against `WORKFLOW.md` section 4's expected inputs and outputs, never against a snippet byte for byte, per `GOVERNANCE.md` "Foundational Principles". +- **Applicability.** A guarantee, or a 5B scenario from `WORKFLOW.md` section 5, governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version and release jobs) is generic and standard at the job level. Build leaves (the `build-` tasks) are repo-owned. Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf receives `ref`/`branch`/`smoke` and whatever else its target needs, a derived `push` among them where that leaf pushes, so assert each input in the layer that declares it. A package target declares no push input on either layer, its push living in a separate `publish-` job in the repo's own publisher. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry and output, the `smoke-build` enable-forward, and a package target's `publish-` job (D6.4). +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is reached as a hub-hosted `workflow_call` task, per `GOVERNANCE.md` "Hub-Hosted Tooling". The repo's own surface is the caller stub, pinned to a hub release commit, and a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the stage each workflow migrates in. Until a workflow's stage ships, its copy is graded against the same contract. +- **Two layers.** The pipeline splits into an orchestrator layer and a build-leaf layer, defined in `WORKFLOW.md` section 3's `Two Layers: Orchestration vs Build` and carried in `references/architecture.md`, while `WORKFLOW.md` section 1's `Two layers when auditing` maps which layer declares which input. Assert an input a guarantee names in the layer that declares it. -## Style Rules That Break in One-Line Diffs +## Style Rules -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two are documented exceptions. The publisher takes a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. The merge-bot takes `cancel-in-progress: false` and keys on the PR number rather than `github.ref`, per D8.1, so each PR queues independently and every event runs to completion. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so a callee declares `permissions:` only where every caller grants that scope at startup and otherwise declares none, running under the calling job's grant. A callee's extra scope (`actions: write` for cleanup) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and the `comment-and-doc-style` Skill keeps the line-ending policy, reached from `GOVERNANCE.md` "Documentation Style Conventions" under "Line Endings". Read both before editing a workflow or a composite action. -## The Core Behavioral Spine +## The Contract Text -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, running under `if: always()` so a failed or skipped dependency cannot skip the gate itself, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule, while a publisher whose only trigger is `workflow_dispatch` (`releaseTrigger: dispatch-only`) reaches the dispatch alone, its bot-push and schedule paths never firing, which covers a source-only repo and an operational repo alike. Each run builds the one trigger branch, the default branch a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The gate's branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` each name the repo's actual default branch, and a divergence among the three is a defect. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once at entry, in a dedicated job or in a step of an entry job, and the downstream jobs `needs:` that job, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A caller with no file target passes `expect_release_assets: false`, which covers a Docker-only, a PyPI-only, and a source-only repo, while a NuGet-only caller keeps the default `true`, its leaf uploading a `release-asset-*` that carries the package. -- **Artifacts are an intra-run handoff**: a cross-job transfer artifact is deleted at the job that consumes it, while an intermediate consumed only within the same run may instead rely on the `retention-days: 1` every upload sets. That delete is gated to the condition that made the artifact redundant, which is the release-create step's own condition where that step is the consumer, and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where a package publish job's push is, since an `if:` carrying no status-check function inherits `success()` and skips on exactly the failed push that leaves the artifact already downloaded. Cleanup is best-effort, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. - -A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. `references/test-methodology.md` indexes `WORKFLOW.md` section 5's audit, trace, and probe procedure, and the sweep itself is run from section 5, which carries the whole core list, the per-type addenda, and the scenario table. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit -Workflow-only changes are not smoke-built, so run actionlint locally before pushing. Run it from the repository being checked, as `python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" --linter actionlint`, using the hub-hosted wrapper documented in `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working Invocations)" section. actionlint includes `shellcheck` for `run:` blocks, so `--linter actionlint` already covers them. A workflow change is still only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. diff --git a/.agents/skills/workflow-ci-contract/references/architecture.md b/.agents/skills/workflow-ci-contract/references/architecture.md new file mode 100644 index 00000000..9f8e6736 --- /dev/null +++ b/.agents/skills/workflow-ci-contract/references/architecture.md @@ -0,0 +1,112 @@ +# The Pipeline Architecture + +The section below is `WORKFLOW.md` section 3, whole. The D-guarantees it cites by number are `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Architecture + + + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `` and ``, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names are branch-suffixed. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-` job for a package. An unchanged version re-pushes nothing (no-op republish). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-` / `pypi-build-`), and a separate `publish-` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.agents/skills/workflow-ci-contract/references/d-guarantees.md b/.agents/skills/workflow-ci-contract/references/d-guarantees.md index 31d837d5..c9e1f3e4 100644 --- a/.agents/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.agents/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,86 @@ -# The D-Guarantees, Condensed +# The D-Guarantees -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. +The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a given repository is `WORKFLOW.md` section 1's applicability rule. The architecture these items govern is `WORKFLOW.md` section 3 and the methodology that checks them is `WORKFLOW.md` section 5, carried whole in `architecture.md` and `test-methodology.md` beside this file. -## D1: PR Fast-Feedback (Smoke) +## The Behavioral Contract -- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. -- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. -- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, the upload best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. The Python invocation, `pytest --cov-report=xml`, names a report format and selects nothing to measure, so a Python repo with tests, lint-only excepted, carries `pytest-cov` in a dev group, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repository root as `coverage.xml`. The hub validator's Python leg, which runs where that root carries `pyproject.toml`, `tests/`, and `uv.lock`, reds its test step when no such report was written. + -## D2: Validation at Entry +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. +### D1 - PR Fast-Feedback (Smoke) -## D3: Versioning and Classification +- **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), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. A filter written the other way round, as a negation of the paths that must not build, marks a docs-only change as a target change: it satisfies D1.4 and violates this item. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere and the `dotnet test` or `pytest` path only where that tree is present, so a non-.NET repo calls the same one rather than replacing it. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **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 on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *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 marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *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**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, 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 that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. +### D2 - Input/State Validation at Entry -## D4: Release and Publish +- **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, 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.* -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. -- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch while the tip has not moved, since a dispatch names a branch rather than a commit and so builds that branch's tip at dispatch time. Once the tip has moved a re-dispatch builds the new tip instead, and NBGV deriving the version from git height makes that a further version, so the version whose push failed never reaches the registry. **Re-run all jobs** is the recovery there: GitHub replays under the original event's `GITHUB_SHA` and re-executes every job, and the publisher pins the build to that commit, so the same version is rebuilt, its package artifact rebuilt and re-uploaded rather than left missing by D5.2's delete, and its push retried, the release itself needing nothing from the re-run. Three bounds. D4.4's no-op re-run assumes the earlier push succeeded, so it does not describe this one. GitHub offers a re-run only within 30 days of the initial run. And **Re-run failed jobs** is unreliable rather than unavailable, D5.2's delete usually having taken the artifact its download needs while D5.3 leaves that delete best-effort. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. +### D3 - Versioning and Classification -## D5: Resource Cleanup +- **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`, and any other -> `X.Y.Z-g`. 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` 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.* -- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. +### D4 - Release / Publish -## D6: Seam Conformance +- **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 contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. 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 and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **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.* -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_` input, `build-` job, its `github-release` and `build-docker` `needs:` entries, paths-filter entry and output, the `smoke-build` enable-forward, and a package target's separate `publish-` job. +### D5 - Resource Cleanup -## D7: Concurrency, Permissions, Safety +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. +### D6 - Seam / Architecture Conformance -## D8: Bots and Automation +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. +### D7 - Concurrency, Permissions, Safety -## D9: Style and Static +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.agents/skills/workflow-ci-contract/references/test-methodology.md b/.agents/skills/workflow-ci-contract/references/test-methodology.md index d1e443cf..f7690a0a 100644 --- a/.agents/skills/workflow-ci-contract/references/test-methodology.md +++ b/.agents/skills/workflow-ci-contract/references/test-methodology.md @@ -1,31 +1,59 @@ # Testing a Repo's Workflows -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. +The section below is `WORKFLOW.md` section 5, whole. Its items and scenarios answer to the D-guarantees in `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. -## 5A: Static Audit +## The Test Methodology -Read the workflow files, `version.json`, and whatever else a check names as its own evidence: a project or dependency file, `global.json`, `codecov.yml`, `.gitignore`, the branch ruleset, and the repo's Actions and Dependabot secret names. Assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, cite a repository setting by its own name where that setting rather than a file is the evidence, and remember the two layers, asserting each input in the layer that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + -The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches coverage collection and its best-effort Codecov upload in every C# and Python repo that has tests at a profile other than `lint-only`, since a repo whose coverage never reaches Codecov passes every other check in this list. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. Those are entry points into `WORKFLOW.md` 5A's core list rather than the whole of it. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. -The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. +### 5A. Static Audit (No Execution) -## 5B: Trace Scenarios +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `/@` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. -## 5C: Live Probe +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -Only for what a static trace cannot settle. Every probe that dispatches a workflow, re-runs a real publish, or acts on the deploy host directly is the maintainer's to run: the agent prepares the command and reads the result back afterwards, and a harness refusal to fire one is the control working, never something to re-shape. The probes are a trivial PR to confirm S1, which runs same-repo only wherever the repo has a Docker leg, since that leg logs in to the registry even on smoke, registry queries after a real publish, the version classification and artifact lifecycle read from a real publish's logs, and the deploy ref gate, which is verified only by tripping it. That gate's evidence is four items, the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded skipped rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref, because a gate that fails open and a gate nobody tripped leave the same empty run history behind. +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: -## Verdict +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | -Record the workflow operational when every applicable 5A item passes, every applicable 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. Any applicable mismatch is a defect. The verdict names the failing guarantees with the triggering input for each, the items recorded N/A, and the 5C probes prepared but not run, so a static-only audit and a fully probed one do not read alike. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest deleted file mode 100644 index f451cc2f..00000000 --- a/.claude-plugin/fleet-skills/.source-digest +++ /dev/null @@ -1 +0,0 @@ -ecfc716438f27a07 diff --git a/.claude-plugin/fleet-skills/.source-digests/add-host-tool b/.claude-plugin/fleet-skills/.source-digests/add-host-tool new file mode 100644 index 00000000..97fb8abe --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/add-host-tool @@ -0,0 +1 @@ +8245d50f9c40fb5d diff --git a/.claude-plugin/fleet-skills/.source-digests/agent-conduct b/.claude-plugin/fleet-skills/.source-digests/agent-conduct new file mode 100644 index 00000000..b06cbe84 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/agent-conduct @@ -0,0 +1 @@ +f9c1bff5e71e2fe0 diff --git a/.claude-plugin/fleet-skills/.source-digests/audit-a-repo b/.claude-plugin/fleet-skills/.source-digests/audit-a-repo new file mode 100644 index 00000000..f0178002 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/audit-a-repo @@ -0,0 +1 @@ +b31e858b76975e80 diff --git a/.claude-plugin/fleet-skills/.source-digests/backlog-burndown b/.claude-plugin/fleet-skills/.source-digests/backlog-burndown new file mode 100644 index 00000000..f4a3a108 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/backlog-burndown @@ -0,0 +1 @@ +0f756cc42d53f21c diff --git a/.claude-plugin/fleet-skills/.source-digests/carried-instruction-file-guard b/.claude-plugin/fleet-skills/.source-digests/carried-instruction-file-guard new file mode 100644 index 00000000..e52615df --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/carried-instruction-file-guard @@ -0,0 +1 @@ +65a440b256923d07 diff --git a/.claude-plugin/fleet-skills/.source-digests/code-review b/.claude-plugin/fleet-skills/.source-digests/code-review new file mode 100644 index 00000000..8465cabe --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/code-review @@ -0,0 +1 @@ +ef65d965e49f56d3 diff --git a/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style b/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style new file mode 100644 index 00000000..b2449af9 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style @@ -0,0 +1 @@ +cff37a35f866c77c diff --git a/.claude-plugin/fleet-skills/.source-digests/copilot-instructions-keeper b/.claude-plugin/fleet-skills/.source-digests/copilot-instructions-keeper new file mode 100644 index 00000000..603aa15d --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/copilot-instructions-keeper @@ -0,0 +1 @@ +8d3cbfef68b03085 diff --git a/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle b/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle new file mode 100644 index 00000000..a221fb7b --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle @@ -0,0 +1 @@ +770653a908016ec0 diff --git a/.claude-plugin/fleet-skills/.source-digests/drive-pr b/.claude-plugin/fleet-skills/.source-digests/drive-pr new file mode 100644 index 00000000..e2fd732f --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/drive-pr @@ -0,0 +1 @@ +cdb7964c85903121 diff --git a/.claude-plugin/fleet-skills/.source-digests/fleet-conformance-check b/.claude-plugin/fleet-skills/.source-digests/fleet-conformance-check new file mode 100644 index 00000000..dcfd9df2 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/fleet-conformance-check @@ -0,0 +1 @@ +7a47f2945d635dd2 diff --git a/.claude-plugin/fleet-skills/.source-digests/git-commit-conventions b/.claude-plugin/fleet-skills/.source-digests/git-commit-conventions new file mode 100644 index 00000000..2c070319 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/git-commit-conventions @@ -0,0 +1 @@ +074ebf66f8ce7a8b diff --git a/.claude-plugin/fleet-skills/.source-digests/local-strict-review b/.claude-plugin/fleet-skills/.source-digests/local-strict-review new file mode 100644 index 00000000..aef8c7b7 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/local-strict-review @@ -0,0 +1 @@ +d8a070025e2cff17 diff --git a/.claude-plugin/fleet-skills/.source-digests/merge-and-release b/.claude-plugin/fleet-skills/.source-digests/merge-and-release new file mode 100644 index 00000000..300a555f --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/merge-and-release @@ -0,0 +1 @@ +c228b32f7efd1f2f diff --git a/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow b/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow new file mode 100644 index 00000000..91bfe9e9 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow @@ -0,0 +1 @@ +b65b6dc0582a9931 diff --git a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct new file mode 100644 index 00000000..d907d02a --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct @@ -0,0 +1 @@ +8aacb5724e76037a diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle new file mode 100644 index 00000000..92acb4ee --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -0,0 +1 @@ +9304c99ac1f261db diff --git a/.claude-plugin/fleet-skills/.source-digests/repo-worktree b/.claude-plugin/fleet-skills/.source-digests/repo-worktree new file mode 100644 index 00000000..99be9287 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/repo-worktree @@ -0,0 +1 @@ +7407d74226ea2c06 diff --git a/.claude-plugin/fleet-skills/.source-digests/resync-a-repo b/.claude-plugin/fleet-skills/.source-digests/resync-a-repo new file mode 100644 index 00000000..ead44730 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/resync-a-repo @@ -0,0 +1 @@ +159d91fa2776457e diff --git a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle new file mode 100644 index 00000000..e82c01f2 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle @@ -0,0 +1 @@ +40c3f8736d2d6180 diff --git a/.claude-plugin/fleet-skills/.source-digests/skill-lifecycle b/.claude-plugin/fleet-skills/.source-digests/skill-lifecycle new file mode 100644 index 00000000..8d070086 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/skill-lifecycle @@ -0,0 +1 @@ +644d3f1f0921b41e diff --git a/.claude-plugin/fleet-skills/.source-digests/standup-a-repo b/.claude-plugin/fleet-skills/.source-digests/standup-a-repo new file mode 100644 index 00000000..1175115b --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/standup-a-repo @@ -0,0 +1 @@ +be23d946c44a7c77 diff --git a/.claude-plugin/fleet-skills/.source-digests/upstream-contribution-workflow b/.claude-plugin/fleet-skills/.source-digests/upstream-contribution-workflow new file mode 100644 index 00000000..cdf01c1b --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/upstream-contribution-workflow @@ -0,0 +1 @@ +4c82f9c302e7cbf2 diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract new file mode 100644 index 00000000..4037fe61 --- /dev/null +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -0,0 +1 @@ +a5a8b94de6cb7d3a diff --git a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md index d02e4e04..48f42ffa 100644 --- a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md @@ -1,47 +1,86 @@ --- name: agent-conduct description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md "Context and Delegation Discipline" section is the always-on layer, and this skill fires at the moments rather than duplicating it, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, local-strict-review for the review passes a push owes, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill surfaces keep the full rules, and the skill carries each of them whole as a generated include rather than as a summary. --- # Agent Conduct ## Why This Exists -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and each of those three sections is carried here whole, as a generated include that `scripts/build_dist.py` fills from the section and holds to it, so the text that surfaces at the moment is the rule's own rather than a shorter list of it. The carried `AGENTS.md` "Context and Delegation Discipline" section is the always-on layer and is not carried here. A defect in included text is fixed in `GOVERNANCE.md` and regenerated, never edited in this file, per the `skill-lifecycle` Skill. ## Before Claiming Done -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: +Read the section below before reporting success on anything non-trivial. It is `GOVERNANCE.md` "Verification Discipline", whole. -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A checkout this session did not create is not ground truth.** One found already sitting on disk may belong to another concurrent session, sit on a stale fetch or an unexpected branch, or hold unreviewed uncommitted edits. Clone fresh or read the live API instead of trusting `git status`/`git remote -v` run against a pre-existing checkout. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A `raw.githubusercontent.com` 404 does not distinguish a private repository from a missing file.** Where visibility is not confirmed public, read content via `gh api "repos///contents/?ref="`, capturing the result before decoding it (`content=$(gh api ... --jq '.content') && printf '%s' "$content" | base64 -d`) rather than piping straight into `base64 -d`, whose own exit status is all a direct pipe reports, letting a failed fetch decode as an empty success. Never `2>&1` either form, which corrupts the decode with the error text instead of the payload. Verify the ref resolves before reading either failure as proof the content itself does not exist. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. -- **PR-bound work runs `local-strict-review` before the claim, and records the pass.** Claiming a unit of work done, verified, green, or fixed for work that will become, or already is, a pull request means running `local-strict-review` against the branch's diff first, before a PR-hosted reviewer finds the same gap, and recording that pass with a hub checkout's `scripts/local_review.py`, run with this repository as the working directory since the engine records into whichever repository the cwd sits in, per that skill's own commands. In the repository that authors canonical content others carry, a change moving one of its units owes a second pass over that unit's whole text, recorded with `scripts/canonical_review.py` before the commit, since its ledger is tracked. Where a capture point exists it then checks what applies. Every push toward a pull request owes one, the fix pushes answering review findings included, which is the round it is most often skipped on. + -Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. +The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. + +- **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` "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, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. +- **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. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write 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. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<///` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. +- **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. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. The two review passes the section above requires, one over a push's diff and one over each canonical unit a change moved, their delegation shape, and how each is recorded are the `local-strict-review` skill's. ## Before Assuming - **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. +- **The irreversible step (merge, publish, release, delete) stays the maintainer's, and a grant given in a past session or for a different task authorizes nothing now.** Whether a credential's reach or a tool that happens to work authorizes anything is answered by `GOVERNANCE.md` "Repository Boundaries and Write Safety" rather than here. + +How to ask, how to reference what the question is about, and how to raise work that is blocked on the answer is `GOVERNANCE.md` "Communicating with the User", whole. + + + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#123](https://github.com/OWNER/REPO/pull/123)`), never a bare `#123`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#123`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + ## When a Failure Surfaces a Lesson -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. +Where a lesson lands, and when it earns a mechanical hook, is `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. + + + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. + +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Two rules that bind at this moment have their homes elsewhere. A review flags an instance, so a fix covers the class, bounded to what the change touched or broke, stated under "Before Claiming Done" above. And a rule that keeps needing to be restated is a stale or missing skills install before it is a missing rule, per `AGENTS.md` "Where the Rules Live", with the `fleet-conformance-check` Skill as the check. ## Delegation, in One Paragraph -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline", loaded in every session, and are not restated here. The two that bind at a conduct moment are its rule on briefing a subagent and its rule on never tiering down the seat holding the judgment. diff --git a/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md index 5cd0c133..070703f9 100644 --- a/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/audit-a-repo/SKILL.md @@ -18,10 +18,10 @@ The audit is the fleet's measurement procedure, and the two failure shapes it gu ## Measuring -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1, extended to `AUDIT.md`'s own checks: an item or check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). - **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. - **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit, each applicable guarantee cited in the form 5A sets out, then the 5B trace scenarios (`AUDIT.md` section 5). Read a workflow the repo only calls at the SHA it pins, for both. The `workflow-ci-contract` skill summarizes that contract. - **Check live settings, rulesets, and secrets from a hub checkout at `main`** with `AUDIT.md` section 6. Run `repo-config/configure.sh check` with the target repository and model for settings and rulesets, and `spec/audit.py [RepoName]` for secrets, rather than constructing a local comparison. The hub payloads are the only repository-configuration source. ## Reporting diff --git a/.claude-plugin/fleet-skills/skills/backlog-burndown/SKILL.md b/.claude-plugin/fleet-skills/skills/backlog-burndown/SKILL.md index 47c14be1..50cf84b6 100644 --- a/.claude-plugin/fleet-skills/skills/backlog-burndown/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/backlog-burndown/SKILL.md @@ -38,17 +38,15 @@ Everything below turns on which seat is acting, so both are named once here. amends the promotion pull request, and it owns worktree and branch cleanup, which "Dispatching a Worker" states in full. - **A worker** is one dispatched subagent holding one group, one worktree, and one feature branch, - which is `AGENTS.md` "Session Scope"'s one-branch-one-deliverable rule applied as written. It - drives its own pull request into develop and ends there. + the dispatched task `AGENTS.md` "Session Scope" describes. It drives its own pull request into + develop and ends there. ## Scope -One repository, the one the session is in, resolved from its own `origin`. Reads are unrestricted -per `GOVERNANCE.md` "Repository Boundaries and Write Safety", so reading another repository's -issues breaks no rule. Working them is out of this skill's scope, and a fleet-wide backlog -sweep is a different request. That section bounds writes to the owner of -this repository rather than to this repository alone, and a run staying inside the one repository -it was invoked for is narrower than the rule requires, deliberately. +One repository, the one the session is in, resolved from its own `origin`. A run staying inside the +one repository it was invoked for is narrower than `GOVERNANCE.md` "Repository Boundaries and Write +Safety" requires, deliberately. Reading another repository's issues is governed there and not here, +working them is out of this skill's scope, and a fleet-wide backlog sweep is a different request. ## What Invoking This Skill Authorizes @@ -58,7 +56,7 @@ it was invoked for is narrower than the rule requires, deliberately. - **The grant is bounded by the session it was named in.** A run interrupted and resumed in a new session needs the skill named again, which costs one sentence and is the difference between a grant and a mode. A grant read back from a note is one nobody gave. -- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's item 5 for +- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's explicit-permission item for this run's feature -> develop merges and nothing else, so a pull request with one open finding still does not merge. - It is never authorization to merge a develop -> main promotion pull request, to dispatch a @@ -111,10 +109,10 @@ Rank on these, highest first where they conflict: An issue that asks a question rather than states a defect is not ranked and is never guessed at. It has no group, no worker, and no claim, so nothing in "Raising a Blocked Question" applies to it -except how the question travels. It goes to the maintainer at the end of -ranking, in the same prompt as any other question the run is sending at that moment and in one of -its own otherwise, rather than waiting for a stop that may not come. It stays unranked until -answered. +except how the question travels. It goes to the maintainer at the end of ranking, per +`GOVERNANCE.md` "Communicating with the User", batched with any other question the run is sending +at that moment and in a prompt of its own otherwise, rather than waiting for a stop that may not +come. It stays unranked until answered. ## Grouping and File Claims @@ -154,9 +152,9 @@ for, and it binds harder than any throughput target. rather than plain fetch because `--prune` is what drops a remote-tracking ref whose branch is gone from the remote, deleted there by another session or through the web interface, and a plain fetch leaves that ref in `git branch -r` to defer valid groups forever. Stop and report a - failed fetch rather than reading `git branch -r` anyway: the remote-tracking refs still resolve - from what the last successful fetch left, so the scan returns a confident answer about a remote - it did not reach, missing a branch pushed since and keeping one deleted since. The round + failed fetch rather than reading `git branch -r` anyway, per `GOVERNANCE.md` "Verification + Discipline" on what a local clone answers for: here the scan would miss a branch pushed since + the last successful fetch and keep one deleted since it. The round stops there and reports, rather than dispatching against a stale answer, and stopping rather than deferring is what the cleanup and promotion steps need too, since both read the same remote. @@ -199,9 +197,8 @@ its own bound stated in the worker's brief. remove the rule that leaned on it. A narrowed qualifier is where a new false claim gets introduced, and it is the most common way a prose round produces the finding the following round then fixes. -- **Set a review-round budget before the first push.** A whole-unit prose review can run many - rounds where a finding was introduced by the previous round's fix, so state a number in the - brief, and when it is reached, land what is correct and file the remainder rather than churning. +- **The review-round budget is `local-strict-review` "Disposing of Findings"'s.** The brief names + it and states no second one. ## Dispatching a Worker @@ -210,18 +207,19 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. - **The worker drives its group to a develop merge**, by invoking `drive-pr` with the target stated as develop only. That skill owns the review loop, the finding disposition, and the merge, so brief the group and the bounds rather than restating the loop. -- **The worker creates its own worktree**, always, as `drive-pr` step 1 and `repo-worktree`'s - task-start mandate already require of the task itself. No worker inherits another's worktree, +- **The worker creates its own worktree**, always, as `drive-pr`'s worktree isolation and + `repo-worktree`'s task-start mandate already require of the task itself. No worker inherits another's worktree, which is why "Bounding the Wait on a Worker" either removes a dead worker's tree and its branch or leaves that tree untouched for the maintainer, and never passes it on. -- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr` step 4 - and of `repo-worktree`'s post-merge procedure. Say so in the brief, because a worker following - either alone will clean up. The worker still performs step 4's merge itself, and what the override - moves is that step's two cleanup halves, the worktree procedure and the verify-then-delete of the - merged remote branch, **both** rather than only the first. "Cleanup Is the Orchestrator's" below, in this +- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr`'s + post-merge cleanup and of `repo-worktree`'s post-merge procedure. Say so in the brief, because + a worker following either alone will clean up. The worker still performs the merge itself, and + what the override moves is the two cleanup halves `drive-pr` runs after it, the worktree + procedure and the verify-then-delete of the merged remote branch, **both** rather than only the + first. "Cleanup Is the Orchestrator's" below, in this same section, says why and what it covers. -- **The worker runs `local-strict-review` before every push**, including one that only fixes a - review finding. That pass dispatches a reviewer of its own, so a harness where a subagent cannot +- **The worker runs `local-strict-review` before every push**, per `GOVERNANCE.md` "Verification + Discipline". That pass dispatches a reviewer of its own, so a harness where a subagent cannot dispatch one leaves the worker unable to run it and unable to push. It reports that rather than pushing, and its worktree is then retired, since git refuses to attach that branch anywhere else while the reporting tree holds it. The branch is left standing for its own reason, that the @@ -255,13 +253,13 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. `repo-worktree`'s post-merge procedure returns the base clone to current develop before proving the cleanup, and `operational-vs-release-workflow` states that requirement independently. Four workers doing that concurrently mutate one shared checkout, which `GOVERNANCE.md` "Repository Boundaries and -Write Safety" forbids outright by giving each task its own checkout. A worker also cannot +Write Safety" forbids. A worker also cannot finish the procedure from inside its own worktree, since removing that worktree leaves it with no working directory in which to delete its branch. So the whole procedure moves to the orchestrator, which runs it from the base clone at the round's -cleanup step, while no worker is live in a tree it touches. It carries `drive-pr` step 4's remote -half too, verifying the merged branch's tip against the pull request's `headRefOid` before +cleanup step, while no worker is live in a tree it touches. It carries the remote half of `drive-pr`'s +post-merge cleanup too, verifying the merged branch's tip against the pull request's `headRefOid` before `git push origin --delete`, since taking that step from the worker without naming a new owner would leave a live remote branch behind every group. It covers every group that is done with its tree, which is the finished ones **and the abandoned ones**: a group told to abandon its branch keeps a @@ -299,10 +297,10 @@ judgment here: the tier is chosen per group rather than defaulted, because a str produces better work up front and takes fewer review rounds to land it, which often costs less than a cheaper worker looping. Three kinds of group are never tiered down: -- One touching **carried canonical content**: rule text, a Skill, or anything else this repository - authors and other repositories carry, since a wrong rule propagates to every carrier. -- One touching **a gate, a ruleset, a release condition, or a carried governance section**, which - is `AGENTS.md`'s own list of what counts as a design change however small the diff looks. +- One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds + it, since a wrong rule propagates to every carrier. +- One touching **anything `AGENTS.md` "Delegation" calls a design change**, however small the diff + looks. - One whose issues are **complex or entangled**, where the fix depends on reasoning across several files or on a contract not stated in the file being edited. @@ -310,7 +308,8 @@ State the chosen tier and its reason in the round's report. ## Bounding the Wait on a Worker -`AGENTS.md` requires a wait to separate its outcomes and to be bounded, so this one is. A worker +`AGENTS.md` "Delegation" binds this wait as it binds any other, and this section is how the bound +is met here. A worker reports merged, parked, or stopped. A worker that reports nothing at all is the case needing a bound, since it is indistinguishable from a slow one and dying mid-drive is ordinary here. @@ -336,10 +335,8 @@ dispatched fresh, its claim comment released with the worktree. It fails, and cl and the group goes to the maintainer, since past that point removal discards work. **A dirty one is left exactly as it stands** and the group is stopped for the maintainer per "Raising a Blocked Question", naming the worktree and what is uncommitted in it. The orchestrator does not commit that work, hand the tree to a -replacement to commit, or remove it: reaching into a tree a task was live in is what -`GOVERNANCE.md` "Repository Boundaries and Write Safety" forbids, and doing it by proxy is still -doing it. Where no other worker remains to bound the wait, the same liveness answer bounds it -alone. +replacement to commit, or remove it, per `GOVERNANCE.md` "Repository Boundaries and Write Safety". +Where no other worker remains to bound the wait, the same liveness answer bounds it alone. ## Raising a Blocked Question @@ -350,13 +347,12 @@ rather than a decision. - **The group stops, and nothing about it is disposed of.** No thread is resolved, no finding is answered on the orchestrator's own judgment, and no pull request merges. - **The other groups keep driving.** One stopped group never idles the round. -- **The question travels worker to orchestrator to maintainer, and reaches the maintainer at the - point the work stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, - since a dispatched subagent is not the seat that can prompt anyone. The orchestrator is that - seat, and it asks then and there through the interface's own prompt mechanism, per - `GOVERNANCE.md` "Communicating with the User". Holding the question for a round boundary is the - handoff-buried-in-a-paragraph that section forbids, and a boundary can be a long way off or, - for a group blocking the promotion pull request, never arrive at all. Where several groups stop +- **The question travels worker to orchestrator to maintainer, and is asked when the group + stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, since a + dispatched subagent is not the seat that can prompt anyone. The orchestrator is that seat, and + it asks then and there, per `GOVERNANCE.md` "Communicating with the User". Holding the question + for a round boundary is what that section forbids, and a boundary can be a long way off or, for + a group blocking the promotion pull request, never arrive at all. Where several groups stop close together, their questions go in one prompt, which is batching without deferral. - **The question is also written on its issue**, so it survives the session that asked it. - **A stopped group keeps its branch and its claim**, and its worktree is left exactly as it @@ -377,16 +373,15 @@ for the maintainer, so that one carries a single round rather than accumulating **This section assumes the release workflow model**, where feature work reaches develop through squash-merged pull requests and a promotion pull request carries develop to main. A repository -whose registry `workflowModel` reads `operational` differs on both counts, per -`operational-vs-release-workflow`: it commits to develop directly, and it opens a promotion pull -request only occasionally rather than per round, so confirm with the maintainer whether one is -wanted at all there. - -Neither difference changes how this run's own work is read. Every worker invokes `drive-pr` -whatever the model, so this run's fixes still arrive as squash-merged feature pull requests -carrying the `Closes on promotion:` line, and the two hops still read them. What the model adds is -a second kind of commit in the same range, a direct push that never had a pull request, whose -issues are recoverable only from the commit message itself. Read both, the pull requests for this +whose registry `workflowModel` reads `operational` reaches develop differently, per `GOVERNANCE.md` +"Operational Repositories". Confirm with the maintainer whether a promotion pull request per round +is wanted there. + +That difference changes nothing about how this run's own work is read. Every worker invokes +`drive-pr` whatever the model, so this run's fixes still arrive as squash-merged feature pull +requests carrying the `Closes on promotion:` line, and the two hops still read them. What the +operational model adds is a second kind of commit in the same range, a direct push to develop that +never had a pull request, whose issues are recoverable only from the commit message itself. Read both, the pull requests for this run's work and the commit messages for the direct pushes, since reading either alone returns a partial set, and the range rather than this round is still what covers earlier work no promotion has carried. @@ -398,21 +393,22 @@ has carried. can still owe a promotion pull request, for work an earlier round landed and no promotion has yet carried. A count of zero is the only case with nothing to promote, and the round reports that instead of attempting one. -2. Drive its review loop per `drive-pr` steps 5 through 8, **with a review-round budget set before - the first one**, the same discipline "Bounding a Prose Group" applies to a feature branch. That - loop repeats until the promotion pull request carries no open finding, and nothing in it - terminates on its own, so when the budget is reached, stop and put the state to the maintainer - rather than continuing to spend the run's only forward gear on one pull request. -3. Put the ready pull request to the maintainer through the interface's own prompt mechanism, - naming the merge as the action that unblocks the run. The maintainer's merge is the run's clock, so one +2. Drive its review loop per the promotion half of `drive-pr` "The Drive Loop", **with a + review-round budget set before the first round**. That loop repeats until the promotion pull + request meets every `pr-review-conduct` Merge Gate item except the maintainer's explicit + permission to merge, and nothing in that loop terminates on its own, so when the budget is + reached, stop and put the state to the maintainer rather than continuing to spend the run's only + forward gear on one pull request. +3. Put the ready pull request to the maintainer, per `GOVERNANCE.md` "Communicating with the + User", with its merge as the action asked for. The maintainer's merge is the run's clock, so one reported in a closing paragraph and never actually asked about stalls every round behind it. Do not merge it. 4. **While it waits, develop takes only what that pull request itself needs.** A finding against it lands as its own feature -> develop pass, and that landing moving its head is expected, since its head **is** develop. **That pass is dispatched as a worker like any other**, which is the one push the freeze permits and the reason the orchestrator still opens no branch of its own. - `drive-pr` step 6 sends the seat driving a promotion pull request back through its own steps 1 - to 4 for such a fix, and here that seat dispatches rather than drives it. + `drive-pr` "The Drive Loop" sends the seat driving a promotion pull request back through its + own feature -> develop pass for such a fix, and here that seat dispatches rather than drives it. 5. **A promotion fix outranks any file claim.** A group holding a file it needs yields, because the promotion pull request is what the whole run is queued behind. A holder that is merely parked yields by handing the file over. A holder that already pushed and has an open pull request @@ -424,13 +420,13 @@ has carried. which is the worktree-only disposition "Cleanup Is the Orchestrator's" separates out and the retire-then-dispatch shape "Raising a Blocked Question" uses, and then dispatches a fresh worker on that same branch, briefed either to merge develop in to pick the fix up or to narrow - the change to drop the file. Never rebase it: - its branch is already pushed, so a rebase needs the force-push `git-commit-conventions` forbids - outright. + the change to drop the file. Never rebase it, + since its branch is already pushed and a rebase there needs what `GOVERNANCE.md` "Git and Commit + Rules" forbids. 6. **Nothing else pushes, and nothing else is dispatched.** The promotion fix of step 4 is the one exception to both, and everything in this step is said of the next round's work rather than of it. That round's preparation is orchestrator work and continues: rank, group, and verify claims. - Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, whose second step + Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, which pushes and opens a pull request, so a next-round worker dispatched under the freeze would either break it or sit in a state that procedure does not describe. None is left running across the wait either, since a worker held idle for an unbounded maintainer wait is one doing nothing at a @@ -468,8 +464,9 @@ body when it lands rather than leaving the issue to be closed by hand. - **Working notes outside the repository hold the round**: the ranking, the working groups, the tier choices, and the worker assignments. A scratch file the harness gives a session serves - where there is one, and any note kept out of the tree serves where there is not. It is working - state, and nothing about it is committed. + where there is one, and any note kept out of the tree serves where there is not. It is the + in-flight session state `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" describes, and + nothing about it is committed. - **GitHub holds what outlives the session.** A claim comment records a group's file set, a pull request body records what a round carried, a `Fixes #N` line records what the promotion closes, a deferral issue records what was put off and why, a thread reply records how a finding was diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump forces the move. + +**The version floor is load-bearing rather than cautionary.** Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform `xunit.v3` 4.0.0 carries, runs zero tests, and **still writes a well-formed Cobertura file reporting full coverage**, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a solution with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md b/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md index b234fb29..9ea24f53 100644 --- a/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md @@ -2,19 +2,19 @@ name: drive-pr description: >- Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into - develop and, when asked, on to a mergeable develop -> main promotion PR, applying the - pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with - evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an - explicit answer in the same turn, escalating to whoever dispatched the drive instead where the - maintainer cannot be reached from that seat. Use this whenever asked to drive, land, take, chase, or push + develop and, when asked, on to a mergeable develop -> main promotion PR, disposing of every + reviewer finding along the way under pr-review-conduct's outcomes, carried here whole as a + generated include, and escalating to whoever dispatched the drive where the drive's own seat + cannot reach the maintainer. Use this whenever asked to drive, land, take, chase, or push a PR toward develop or main, or to run the review loop hands off instead of narrating each round. When the request does not say how far ("drive this PR", "land it"), ask once whether the target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when only one PR is named, because a finding raised against the develop -> main promotion PR routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends - at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges - main itself, that is the separate merge-and-release skill, its own go-ahead. + at develop merged, or at a promotion PR meeting every pr-review-conduct Merge Gate item except + the maintainer's explicit permission to merge, never merges main itself, that is the separate + merge-and-release skill, its own go-ahead. --- # Drive PR @@ -128,32 +128,57 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 1 to 4 in its own worktree and branch, then return here. 7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request a review on the new head and continue the loop. -8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green - on the current head. +8. Repeat 6 and 7 until the promotion PR meets every pr-review-conduct Merge Gate item except the + maintainer's explicit permission to merge. 9. Report the promotion PR number and its ready state. Do not merge it. ## Disposing of Every Finding -pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: - -- Real, so fix it, then step 2's own order again before replying with the fixing commit SHA - (outcome 1). This is the round the pass is most often skipped on, since the fix looks small and - the branch was already reviewed once, and a fix push carries content no pass has read exactly as - the first push did. -- Not real, or real but out of scope here, so decline in the thread with evidence: the command - and its output, the code path, or the rule that governs it. An assertion never closes a finding - on its own (outcome 2). -- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). -- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not - know which of the above applies, so ask the maintainer directly, whatever the runtime's own - interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask - later is resolution by silence (outcome 3). A drive that cannot reach the - maintainer directly, a dispatched one being the ordinary case, escalates to whoever dispatched - it and stops that unit of work there instead, per `pr-review-conduct`, which owns what the - receiving seat then does and how far the escalation travels. -- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a - comment, or take the rule itself to the maintainer, rather than re-arguing the instance every - round (outcome 5). +The rule below is a generated include, so a defect in it is fixed in `pr-review-conduct` and +regenerated rather than edited here. A drive that cannot reach the maintainer directly, a +dispatched one being the ordinary case, escalates per `pr-review-conduct` "Escalate to the +maintainer when". + + + +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. +5. **Keeps recurring, so fix the class, not the instance.** A finding raised repeatedly against + correct code means the code is not communicating something: add the comment, sharpen the name, + narrow the interface, or fix the rule if the rule is wrong. Bouncing the same point across + rounds is the signal to escalate the rule itself, not to keep re-arguing it. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + + ## Mechanics Live Elsewhere diff --git a/.claude-plugin/fleet-skills/skills/local-strict-review/SKILL.md b/.claude-plugin/fleet-skills/skills/local-strict-review/SKILL.md index 336effcc..fa1c1a86 100644 --- a/.claude-plugin/fleet-skills/skills/local-strict-review/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/local-strict-review/SKILL.md @@ -55,6 +55,8 @@ Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of ``` +Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. + **Model tier:** the strongest tier this session can reach, per `AGENTS.md` "Match the model tier to the judgment" and "Never tier down the seat holding the judgment", applied here to the reviewer rather than the author. Run the pass on the same tier that authored the change when only one tier is reachable, a second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. "This session can reach" means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names a stronger tier for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch reaches several tiers but exposes no way to name one, take what it gives and run the pass, on the same reasoning as the single-reachable-tier sentence above. A seat that cannot dispatch a subagent at all cannot perform this pass. Instead of pushing, it reports that it could not run the pass, to whoever dispatched it, or to the maintainer where nobody did. Either way it is a push that does not happen rather than a pass quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. @@ -110,27 +112,38 @@ Bounds: read-only. Report a rule that looks incomplete rather than guessing at w git fetch origin # stop and report a failed fetch rather than measuring past it python3 scripts/canonical_review.py check --target # each uncovered unit, with its digest # run the pass above over each unit it named, then, per unit: -python3 scripts/canonical_review.py record --reviewer agent-skill --unit '=' [--findings N] +python3 scripts/canonical_review.py record --reviewer agent-skill --target --unit '=' [--findings N] ``` -These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the second is measured with the first's unit model, while `record` stamps the ledger with a commit read from the second. +These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the two mix, the engine's own section rules over the other tree's manifest and files. -`` is the branch this work targets, resolved once as the pass above resolves it and passed to `check` explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. +`` is the branch this work targets, resolved once as the pass above resolves it and passed to both commands explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read, and `record` stamps each pass with a merge-base against a branch the work never targeted. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. The digest is bound to the read for the same reason `--expect-digest` is above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. Record each unit whatever the pass found, including nothing. Fixing a finding is itself such an edit, so `record` then refuses the digest you were holding: that refusal is the content having moved rather than a fault in the record, and the answer is a read of the unit's new text, which is what a carrier will actually receive, recorded at its new digest. -**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger and its burn-down are tracked files the commit has to carry, so writing them after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. +**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger, `reports/canonical-review.json`, is a tracked file the commit has to carry, so writing it after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. -**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry in the hub's `reports/canonical-review.md` rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. +**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry `canonical_review.py report` renders rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. ## Disposing of Findings -Every finding maps to one of `pr-review-conduct`'s five outcomes, at whichever moment this pass ran: fixed (1), evidence-disproven (2), escalated to the maintainer for an explicit call (3), filed as a deferred issue (4), or, if it keeps recurring, taken as a signal to fix the class (5). Outcome 2 is the agent's own on its own evidence, covering a finding that is not real and one that is structurally out of scope. A finding judged real and left unfixed is never the agent's alone, so outcome 3 needs the maintainer's explicit answer in the same turn, reached only once outcome 2 is ruled out, or, where this pass ran in a seat that cannot reach the maintainer, an escalation to whoever dispatched it that stops the work there, which stops the push this pass runs before, and outcomes 4 and 5 reach the maintainer too, for the deferral and for the rule itself. Running this pass is required before every push toward a pull request, per `agent-conduct`. Two claims sit next to each other here and they point opposite ways, so they are stated apart rather than in one sentence. **The pass is mandatory**, and where a capture point enforces it, a push carrying content no recorded pass covers is refused. That refusal is the gate working rather than a fault to route around. **The findings stay advisory**, and the count a pass raises gates nothing at all, since a pass records that a review ran and never that the content is clean. The disposition above is what closes each finding, the same posture local lint holds today. It posts nothing to GitHub, it only reports to the session driving the work. A finding raised here and not fixed is not thereby resolved: the same finding shape reaching a PR-hosted reviewer later still gets its own fresh disposition, per `pr-review-conduct`'s "a disposition decided on one PR does not carry to the next." +Each bullet is a rule down to its `Why:` line, which is rationale rather than rule, so a stale rationale is a cleanup rather than a defect. + +- **Every finding ends in one of the outcomes that `pr-review-conduct` "Every finding ends in one of five outcomes" enumerates, reached here with no thread to reply in.** + - `Why:` a local finding and a PR-hosted one deserve the same dispositions, and one home for the list is what stops two copies of it drifting apart. +- **The agent disposing of a pass's findings classes each one `style`, `introduced`, or `pre-existing`, in that order.** `style` is a preference between defensible forms. `introduced` is any other finding on text this change wrote, rewrote, or removed, on text this change should have written, on a precondition this change left false elsewhere, or load-bearing for a decision this change puts to the maintainer. `pre-existing` is every other finding. + - `Why:` the reviewer is asked to omit preferences and returns some anyway, and `style` is classed first so that a preference on text this change wrote is not owed a fix. +- **Another round is owed only while an `introduced` finding is open.** Unless evidence disproves it, an `introduced` finding is fixed within the budget below, or escalated where `pr-review-conduct` "Escalate to the maintainer when" says so, a `pre-existing` one is filed once and blocks nothing, and a `style` one is declined with evidence, per `pr-review-conduct` "Every finding ends in one of five outcomes", the evidence being `code-review` "Review the Change"'s own rule to omit preferences. + - `Why:` a finding count over prose never reaches zero, so a loop closing on "did it find anything" does not close, where one closing on the false claim, the unfollowable instruction, or the wrong behavior this change put there does. +- **A push allows two rounds of edits in answer to the passes it owes, one budget across both.** Where an `introduced` finding is still open after the second round, editing stops and what remains goes to the maintainer with its counts per class, per `pr-review-conduct` "Escalate to the maintainer when". + - `Why:` past the second round nearly every finding is against text the previous round's fix wrote, so the rounds are producing the defects they find rather than removing them. +- **The pass is mandatory, and the count it records gates nothing.** A pass is recorded whatever it raised, so the record attests that a review ran rather than that the content is clean. + - `Why:` a gate reading the count would make a pass raising nothing the cheapest way through it, the opposite of what recording one is for. ## When to Run It -- Before the first push toward a pull request (`drive-pr`'s Drive Loop step 2, `pr-review-conduct`'s Expected review loop step 1). -- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (`drive-pr`'s "Disposing of Every Finding", `pr-review-conduct`'s outcome 1). +- Before the first push toward a pull request, the push that opens it in `drive-pr` "The Drive Loop" and in `pr-review-conduct` "Expected review loop". +- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (the fix outcome of `pr-review-conduct` "Every finding ends in one of five outcomes", which `drive-pr` "Disposing of Every Finding" carries). - Whenever `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for work that will become, or already is, a pull request. - Before pushing a change that edits canonical content other repositories carry, or that newly carries some by widening the manifest, over each unit `check` names, per "The Carried-Content Pass" above. diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 8bfd49a6..3ca50760 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md index 1073b824..b6f9f91a 100644 --- a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md @@ -41,11 +41,14 @@ visible comments, routinely still carries a finding nobody has answered. Treatin 2. A review is confirmed on the **current head SHA**, matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the re-review lands, and the matched review is **read**, not just counted. A review can carry the head SHA and still decline - the PR outright, or say it read only part of the changed files. `pr_review.py`'s - `review_on_head` names Copilot's own coverage specifically, the currently required reviewer, - not "no review of any kind covers this head": a trialed advisory reviewer (CodeRabbit, - Qodo) carrying the exact head under `other_reviewed`, with an empty review body and no new - threads, is its own ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). + the PR outright, or say it read only part of the changed files. The coverage this item + requires is Copilot's, and CodeRabbit and Qodo are advisory, since the hub's + `docs/pr-reviewer-evaluation.md` "Status" names Copilot the incumbent and says no candidate is + a required reviewer: an advisory reviewer's absence blocks nothing, while its findings owe + item 3 exactly as Copilot's do. `pr_review.py`'s `review_on_head` names Copilot's own coverage + specifically, not "no review of any kind covers this head": an advisory reviewer carrying the + exact head under `other_reviewed`, with an empty review body and no new threads, is its own + ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). 3. **Every** finding on that head SHA is closed: threads resolved, issue-level comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered. Those appear in no thread, so polling threads @@ -55,6 +58,22 @@ visible comments, routinely still carries a finding nobody has answered. Treatin give each one the same triage the low-confidence findings above already get (#1058). Qodo's own `Resolved`/`Dismissed` self-tracked badge is a fast pre-triage signal, not a substitute for reading the finding, spot-verify against `gh pr diff` rather than trusting it outright. + What closing a finding owes turns on whether it is `pre-existing`. A finding on text inside a + canonical Markdown unit, one the hub's `scripts/canonical_review.py list` names, classed + `pre-existing` by the classes `local-strict-review` "Disposing of Findings" defines for a + local pass, applied here to a PR-hosted finding, is outcome 4 of "Every finding ends in one + of five outcomes" below applied once per unit rather than once per finding: the round gathers + that unit's such findings onto the unit's tracker, an open hub issue whose title carries the + unit key, retitled by the change that moves the key and filed by whichever round first needs + it, and answers each finding with that issue's link, resolving a thread on that reply, so a + `pre-existing` remark on a sentence the change never touched costs one link rather than a + decline or an issue per finding. The batch runs in the hub, which authors the text of every + verbatim unit. A carrying repository routes a finding on a verbatim unit by fidelity rather + than by class, since a resync writes the whole text there: it declines the finding under + that section's outcome 2, ownership sitting elsewhere, and files it on the same tracker, + while a finding on an intent unit is filed there too, the carrier adapting its own copy + meanwhile, since the defect is still fixed at the source. Every other finding, a `style` + remark on untouched text included, takes its own outcome in that section. 4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. File an issue naming it and quoting the body, rather than guessing what the new wording @@ -100,19 +119,22 @@ Run `local-strict-review` against the branch's current diff before step 1's push The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. After a timeout with `requested=yes`, rerun `wait` for another bounded interval by default because the request may still be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. Reporting only that the PR was opened is an early -exit unless the maintainer explicitly instructed the agent not to monitor or drive its review. +then apply the Merge Gate above. **Never exit this PR-hosted loop early.** Its pre-push +counterpart is bounded instead by `local-strict-review` "Disposing of Findings". A round count +is not a stopping condition here, and neither is patience running out. Reporting only that the +PR was opened is an early exit unless the maintainer explicitly instructed the agent not to +monitor or drive its review. After an authorized merge, run the `repo-worktree` post-merge cleanup procedure unless the user explicitly asks to retain the checkout or branch. The pull request loop is incomplete while its finished worktree or local task branch remains. It is also incomplete until the base clone returns to fetched and fast-forwarded `develop`. ## Every finding ends in one of five outcomes -1. **Real, so fix it.** Take the fix through `local-strict-review` the same way step 1's push - went, then reply with the fixing commit SHA. A branch already reviewed once - has not been reviewed for the fix, which is the round this gets dropped on and the churn - `local-strict-review` exists to stop. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. 2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** @@ -141,6 +163,9 @@ on a sibling repo or PR, even within one batch or one session, gets its own outc evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior instance's outcome is context for the new one, never a standing answer to reuse in its place. +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + ## Triaging findings **A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. diff --git a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md index aa853916..4a8aa6f8 100644 --- a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-lifecycle description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the include regions it fills from a rule's home so a skill carries the rule's text without a copy, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. --- # Skill Lifecycle @@ -12,9 +12,11 @@ The agent most likely to get a skill wrong is the one editing a skill, and befor ## The Pipeline -- **`.agents/skills//SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **`.agents/skills//SKILL.md` is the only tree a skill is authored in**, with optional `references/` and `scripts/` directories beside it, and the one part of it not written by hand is the text inside an include region, described below. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. - **Generated distributions serve GitHub Copilot and Claude Code.** `scripts/build_dist.py` generates `.github/skills/` for GitHub Copilot and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. Neither generated tree is hand-edited, and `build_dist.py --check` exits non-zero when either tree differs from `.agents/skills/`. - **The skill set is implicit.** Every `.agents/skills//` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **A rule's text reaches a skill as a generated include, never as a copy.** A region opened by a line holding only `` and closed by a line holding only ``, each indented at most three spaces, is filled by `build_dist.py` with the body under that heading. The key is the root-relative path, spelled as the tree spells it, then ` > `, then the heading text at any level from two, matched case-insensitively. The fill lands in `.agents/skills/` itself, since Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the generated trees mirror the filled source. A source is any regular file under the repository root outside the two generated trees and reached through no symlink, so a key may name a `GOVERNANCE.md` section, an `AGENTS.md` subsection, or a section of a sibling skill, and a region filled from a file carrying regions of its own reads that file's filled text. Regenerating after a source edit changes the bytes of every skill unit including it, and `--check` fails the pull request until that regenerate runs, so the whole-unit review pass `scripts/canonical_review.py` records for each of those units is owed again, which is the cost of a carrier reading generated text in the skill's own context. +- **`--check` holds every region to its source.** It fails when a region differs from what its source renders now, so a hand edit inside one and a source edit nobody regenerated for both fail the pull request the same way a stale mirror does. A region it cannot render is a failure rather than a stale result, exit 2 rather than 1, because regenerating cannot repair it: a key with no ` > ` or an empty heading, a path naming no file, a heading that no longer resolves or that recurs in its source, a body with nothing in it or leaving a code fence open, a region in a file the generator does not walk, reached through a key, since it walks only the Markdown files of the skill directories, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path outside the root, through a symlink, under a generated tree, or spelled otherwise than the tree spells it, a skill file or source that is not UTF-8, a file holding a region while mixing line endings, and a line outside a code block that begins like a marker and matches neither form, which read as content would leave a region unfilled. - **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). ## Deciding a Topic Deserves a Skill @@ -34,16 +36,17 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim ## Changing or Retiring a Skill -- **Edit only the source tree.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **Edit only the source tree, and outside its include regions.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. The text inside an include region is generated too, so a change there is made at the region's source and regenerated, never typed into the region. +- **Retiring is deleting the source directory and regenerating.** A region in a sibling keyed on the retired skill stops that regenerate, since its key no longer resolves, so re-key or remove it first. The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. - **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. An include key spelling the old path is such a reference, and one left behind fails `--check` as a region it cannot render rather than as a stale mirror. ## The Doc-Packaging Pattern -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has three shapes, and each pairing states which it uses: - **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills//SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md` outside sections 3, 4, and 5). The skill states per topic which doc section owns it. +- **Included content.** The doc keeps the full rules and the skill needs them whole to work in isolation, so it carries the section as a generated include rather than as a summary or a copy, declared with the region markers "The Pipeline" above describes and keyed on the doc's section (`agent-conduct` over the three `GOVERNANCE.md` sections it surfaces, `workflow-ci-contract` over `WORKFLOW.md` sections 3, 4, and 5). The doc side states the shape with one sentence naming the skill that includes the section, and the skill side is the region itself. A section carried this way is read outside its own document, so it names a sibling section by document and heading rather than as above or below, and it links to no file by a relative path, since the path would resolve against the skill's directory rather than the doc's. The doc wins by construction, since `scripts/build_dist.py` writes the region from it and its `--check` reports a region that differs from it as stale. -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +In every shape the doc is the authority when the two are found to disagree, the moved-content shape included: the doc's summary says what the rule is, and the skill's full text is what gets corrected. A deliberate change to a packaged rule is not such a disagreement. It lands where the full text lives, and in the same change the author either edits the other side's summary to match, since a summary has no mechanical check, or regenerates the include, which has one. A rule stated fully in both places by hand is the drift this pattern exists to prevent, and an include is the one full second statement that cannot drift undetected. diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index 0a3b832f..a02a47d6 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -8,40 +8,24 @@ description: >- ## Why This Exists -`WORKFLOW.md` in the hub is a behavioral contract stating required outcomes rather than a required implementation. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where those two overlap, which `WORKFLOW.md`'s own canonical-scope note states. +`WORKFLOW.md` is the fleet's CI/CD behavioral contract. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary, and `WORKFLOW.md` sections 3, 4, and 5 are each carried whole in `references/` as a generated include. `WORKFLOW.md`'s own canonical-scope note says which of it and `GOVERNANCE.md` is authoritative where the two overlap. ## How the Contract Is Read -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Outcomes, not bytes.** A workflow is judged against `WORKFLOW.md` section 4's expected inputs and outputs, never against a snippet byte for byte, per `GOVERNANCE.md` "Foundational Principles". +- **Applicability.** A guarantee, or a 5B scenario from `WORKFLOW.md` section 5, governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version and release jobs) is generic and standard at the job level. Build leaves (the `build-` tasks) are repo-owned. Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf receives `ref`/`branch`/`smoke` and whatever else its target needs, a derived `push` among them where that leaf pushes, so assert each input in the layer that declares it. A package target declares no push input on either layer, its push living in a separate `publish-` job in the repo's own publisher. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry and output, the `smoke-build` enable-forward, and a package target's `publish-` job (D6.4). +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is reached as a hub-hosted `workflow_call` task, per `GOVERNANCE.md` "Hub-Hosted Tooling". The repo's own surface is the caller stub, pinned to a hub release commit, and a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the stage each workflow migrates in. Until a workflow's stage ships, its copy is graded against the same contract. +- **Two layers.** The pipeline splits into an orchestrator layer and a build-leaf layer, defined in `WORKFLOW.md` section 3's `Two Layers: Orchestration vs Build` and carried in `references/architecture.md`, while `WORKFLOW.md` section 1's `Two layers when auditing` maps which layer declares which input. Assert an input a guarantee names in the layer that declares it. -## Style Rules That Break in One-Line Diffs +## Style Rules -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two are documented exceptions. The publisher takes a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. The merge-bot takes `cancel-in-progress: false` and keys on the PR number rather than `github.ref`, per D8.1, so each PR queues independently and every event runs to completion. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so a callee declares `permissions:` only where every caller grants that scope at startup and otherwise declares none, running under the calling job's grant. A callee's extra scope (`actions: write` for cleanup) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and the `comment-and-doc-style` Skill keeps the line-ending policy, reached from `GOVERNANCE.md` "Documentation Style Conventions" under "Line Endings". Read both before editing a workflow or a composite action. -## The Core Behavioral Spine +## The Contract Text -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, running under `if: always()` so a failed or skipped dependency cannot skip the gate itself, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule, while a publisher whose only trigger is `workflow_dispatch` (`releaseTrigger: dispatch-only`) reaches the dispatch alone, its bot-push and schedule paths never firing, which covers a source-only repo and an operational repo alike. Each run builds the one trigger branch, the default branch a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The gate's branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` each name the repo's actual default branch, and a divergence among the three is a defect. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once at entry, in a dedicated job or in a step of an entry job, and the downstream jobs `needs:` that job, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A caller with no file target passes `expect_release_assets: false`, which covers a Docker-only, a PyPI-only, and a source-only repo, while a NuGet-only caller keeps the default `true`, its leaf uploading a `release-asset-*` that carries the package. -- **Artifacts are an intra-run handoff**: a cross-job transfer artifact is deleted at the job that consumes it, while an intermediate consumed only within the same run may instead rely on the `retention-days: 1` every upload sets. That delete is gated to the condition that made the artifact redundant, which is the release-create step's own condition where that step is the consumer, and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where a package publish job's push is, since an `if:` carrying no status-check function inherits `success()` and skips on exactly the failed push that leaves the artifact already downloaded. Cleanup is best-effort, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. - -A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. `references/test-methodology.md` indexes `WORKFLOW.md` section 5's audit, trace, and probe procedure, and the sweep itself is run from section 5, which carries the whole core list, the per-type addenda, and the scenario table. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit -Workflow-only changes are not smoke-built, so run actionlint locally before pushing. Run it from the repository being checked, as `python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" --linter actionlint`, using the hub-hosted wrapper documented in `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working Invocations)" section. actionlint includes `shellcheck` for `run:` blocks, so `--linter actionlint` already covers them. A workflow change is still only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md new file mode 100644 index 00000000..9f8e6736 --- /dev/null +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md @@ -0,0 +1,112 @@ +# The Pipeline Architecture + +The section below is `WORKFLOW.md` section 3, whole. The D-guarantees it cites by number are `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Architecture + + + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `` and ``, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names are branch-suffixed. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-` job for a package. An unchanged version re-pushes nothing (no-op republish). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-` / `pypi-build-`), and a separate `publish-` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md index 31d837d5..c9e1f3e4 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,86 @@ -# The D-Guarantees, Condensed +# The D-Guarantees -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. +The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a given repository is `WORKFLOW.md` section 1's applicability rule. The architecture these items govern is `WORKFLOW.md` section 3 and the methodology that checks them is `WORKFLOW.md` section 5, carried whole in `architecture.md` and `test-methodology.md` beside this file. -## D1: PR Fast-Feedback (Smoke) +## The Behavioral Contract -- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. -- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. -- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, the upload best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. The Python invocation, `pytest --cov-report=xml`, names a report format and selects nothing to measure, so a Python repo with tests, lint-only excepted, carries `pytest-cov` in a dev group, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repository root as `coverage.xml`. The hub validator's Python leg, which runs where that root carries `pyproject.toml`, `tests/`, and `uv.lock`, reds its test step when no such report was written. + -## D2: Validation at Entry +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. +### D1 - PR Fast-Feedback (Smoke) -## D3: Versioning and Classification +- **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), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. A filter written the other way round, as a negation of the paths that must not build, marks a docs-only change as a target change: it satisfies D1.4 and violates this item. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere and the `dotnet test` or `pytest` path only where that tree is present, so a non-.NET repo calls the same one rather than replacing it. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **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 on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *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 marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *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**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, 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 that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. +### D2 - Input/State Validation at Entry -## D4: Release and Publish +- **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, 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.* -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. -- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch while the tip has not moved, since a dispatch names a branch rather than a commit and so builds that branch's tip at dispatch time. Once the tip has moved a re-dispatch builds the new tip instead, and NBGV deriving the version from git height makes that a further version, so the version whose push failed never reaches the registry. **Re-run all jobs** is the recovery there: GitHub replays under the original event's `GITHUB_SHA` and re-executes every job, and the publisher pins the build to that commit, so the same version is rebuilt, its package artifact rebuilt and re-uploaded rather than left missing by D5.2's delete, and its push retried, the release itself needing nothing from the re-run. Three bounds. D4.4's no-op re-run assumes the earlier push succeeded, so it does not describe this one. GitHub offers a re-run only within 30 days of the initial run. And **Re-run failed jobs** is unreliable rather than unavailable, D5.2's delete usually having taken the artifact its download needs while D5.3 leaves that delete best-effort. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. +### D3 - Versioning and Classification -## D5: Resource Cleanup +- **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`, and any other -> `X.Y.Z-g`. 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` 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.* -- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. +### D4 - Release / Publish -## D6: Seam Conformance +- **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 contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. 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 and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **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.* -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_` input, `build-` job, its `github-release` and `build-docker` `needs:` entries, paths-filter entry and output, the `smoke-build` enable-forward, and a package target's separate `publish-` job. +### D5 - Resource Cleanup -## D7: Concurrency, Permissions, Safety +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. +### D6 - Seam / Architecture Conformance -## D8: Bots and Automation +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. +### D7 - Concurrency, Permissions, Safety -## D9: Style and Static +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md index d1e443cf..f7690a0a 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md @@ -1,31 +1,59 @@ # Testing a Repo's Workflows -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. +The section below is `WORKFLOW.md` section 5, whole. Its items and scenarios answer to the D-guarantees in `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. -## 5A: Static Audit +## The Test Methodology -Read the workflow files, `version.json`, and whatever else a check names as its own evidence: a project or dependency file, `global.json`, `codecov.yml`, `.gitignore`, the branch ruleset, and the repo's Actions and Dependabot secret names. Assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, cite a repository setting by its own name where that setting rather than a file is the evidence, and remember the two layers, asserting each input in the layer that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + -The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches coverage collection and its best-effort Codecov upload in every C# and Python repo that has tests at a profile other than `lint-only`, since a repo whose coverage never reaches Codecov passes every other check in this list. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. Those are entry points into `WORKFLOW.md` 5A's core list rather than the whole of it. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. -The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. +### 5A. Static Audit (No Execution) -## 5B: Trace Scenarios +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `/@` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. -## 5C: Live Probe +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -Only for what a static trace cannot settle. Every probe that dispatches a workflow, re-runs a real publish, or acts on the deploy host directly is the maintainer's to run: the agent prepares the command and reads the result back afterwards, and a harness refusal to fire one is the control working, never something to re-shape. The probes are a trivial PR to confirm S1, which runs same-repo only wherever the repo has a Docker leg, since that leg logs in to the registry even on smoke, registry queries after a real publish, the version classification and artifact lifecycle read from a real publish's logs, and the deploy ref gate, which is verified only by tripping it. That gate's evidence is four items, the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded skipped rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref, because a gate that fails open and a gate nobody tripped leave the same empty run history behind. +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: -## Verdict +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | -Record the workflow operational when every applicable 5A item passes, every applicable 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. Any applicable mismatch is a defect. The verdict names the failing guarantees with the triggering input for each, the items recorded N/A, and the 5C probes prepared but not run, so a static-only audit and a fully probed one do not read alike. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..e0561dc3 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,33 @@ +# The two trees below are what scripts/build_dist.py generates from .agents/skills/ and CI holds current, so a finding in either belongs at its source and a review of every copy is one finding three times. +reviews: + # Auto review covers only the default branch unless a base is listed here, each entry a regex, and the default branch stays included whatever is listed. + auto_review: + enabled: true + base_branches: + - "^develop$" + # A fleet pull request routinely passes five pushes before it merges, and the default pauses after five reviewed commits, which reads as a reviewer that stopped. + auto_pause_after_reviewed_commits: 0 + path_filters: + - "!.github/skills/**" + - "!.claude-plugin/fleet-skills/**" + # Canonical prose is linted for style in CI and read whole by the local review pass, so a review comment on it earns its place only by naming something false or unfollowable. + path_instructions: + - path: "**/*.md" + instructions: | + Report a claim about a tool, a path, a command, or another rule only where it is false, stale, or unverifiable from the repository, and an instruction only where following it literally fails. + Do not report wording, tone, or formatting, which CI lints. + # The walkthrough extras below add nothing the fleet's review loop reads. + sequence_diagrams: false + suggested_labels: false + suggested_reviewers: false + in_progress_fortune: false + # CI runs these four linters and fails the pull request on them, so a review comment from the same tool is a second copy of a red check. + tools: + markdownlint: + enabled: false + actionlint: + enabled: false + shellcheck: + enabled: false + ruff: + enabled: false diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index a7a62be4..49bee6ba 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -353,6 +353,7 @@ def path_candidate(token: str, in_span: bool = True) -> str | None: "repo-config/main.json", "repo-config/README.md", "repo-config/settings.json", + "repo-config/labels.json", "spec/secrets.json", ".github/workflows/get-version-task.yml", ".github/workflows/publish-plan-task.yml", diff --git a/.github/actions/validate/action.yml b/.github/actions/validate/action.yml index 7e4bb468..d9ef7ccf 100644 --- a/.github/actions/validate/action.yml +++ b/.github/actions/validate/action.yml @@ -65,14 +65,12 @@ runs: fi python3 scripts/canonical_review.py check --target "$BASE_SHA" - # Read-only: fails where the committed burn-down no longer describes the ledger and the tree. - # Runs on every event, unlike the step above, because staleness is a property of the commit rather than of a comparison against a base. - # `!cancelled()` so an earlier failing step does not skip it, since a run that names one reason and hides the next costs a whole round to discover the second, which is the reason .husky/pre-push runs both gates before reading either verdict. - # Not `always()`, which also runs after a cancellation, where nothing is waiting for the answer. - - name: Check the canonical review burn-down is current step + # The burn-down goes to the run's job summary rather than into the tree, per scripts/README.md, on every event since it describes the commit rather than a comparison against a base. + # Read-only and informational, so `!cancelled()` renders it after an earlier step failed. + - name: Render the canonical review burn-down step if: ${{ !cancelled() }} shell: bash - run: python3 scripts/canonical_review.py report --check + run: python3 scripts/canonical_review.py report >> "$GITHUB_STEP_SUMMARY" # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. # A finding here names a character no tier covers, and classifying it is a fleet-law edit rather than a prose fix. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e69b1dea..c8438a9a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,18 @@ Follow the fidelity declared for the file. A byte-locked reference to shared inf this repository does not carry is intentional, not a broken link. Raise substantive defects in canonical content, but locate the fix at its canonical source instead of proposing a local edit. +`.github/skills/`, and in the hub `.claude-plugin/fleet-skills/`, are generated by the hub's +`scripts/build_dist.py` from its `.agents/skills/`, so a defect in either is fixed in the source or +the generator and never in the copy. A defect inside an include region, the text between the +marker lines `` and `` that every copy +carries as its authored source does, is fixed in the hub under the heading that key names, since +the region is generated from that heading's body and the key's path resolves against the hub's +root rather than this repository's copy of the same file. Where that heading's body is itself a +region, the fix sits one hop further, under the heading its own key names. Post no review comment +on a file under `.github/skills/` or, in the hub, `.claude-plugin/fleet-skills/`. When the pull +request changes the file the fix belongs in, comment on that file instead, and otherwise state +the finding in the review summary. + ## GitHub Copilot Review Runbook For every review: diff --git a/.github/skills/agent-conduct/SKILL.md b/.github/skills/agent-conduct/SKILL.md index d02e4e04..48f42ffa 100644 --- a/.github/skills/agent-conduct/SKILL.md +++ b/.github/skills/agent-conduct/SKILL.md @@ -1,47 +1,86 @@ --- name: agent-conduct description: >- - Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, whenever about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work is blocked on a decision or authorization only the user can give, and whenever an incident, a wrong answer, or a repeated correction just taught something a future session must honor. Deliberately narrow: the carried AGENTS.md "Context and Delegation Discipline" section is the always-on layer, and this skill fires at the moments rather than duplicating it, so do not load it as general background. Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, pr-review-conduct for review and merge claims, local-strict-review for the review passes a push owes, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill surfaces keep the full rules, and the skill carries each of them whole as a generated include rather than as a summary. --- # Agent Conduct ## Why This Exists -The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and each of those three sections is carried here whole, as a generated include that `scripts/build_dist.py` fills from the section and holds to it, so the text that surfaces at the moment is the rule's own rather than a shorter list of it. The carried `AGENTS.md` "Context and Delegation Discipline" section is the always-on layer and is not carried here. A defect in included text is fixed in `GOVERNANCE.md` and regenerated, never edited in this file, per the `skill-lifecycle` Skill. ## Before Claiming Done -Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: +Read the section below before reporting success on anything non-trivial. It is `GOVERNANCE.md` "Verification Discipline", whole. -- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. -- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. -- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. -- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. -- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. -- **A checkout this session did not create is not ground truth.** One found already sitting on disk may belong to another concurrent session, sit on a stale fetch or an unexpected branch, or hold unreviewed uncommitted edits. Clone fresh or read the live API instead of trusting `git status`/`git remote -v` run against a pre-existing checkout. -- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. -- **A `raw.githubusercontent.com` 404 does not distinguish a private repository from a missing file.** Where visibility is not confirmed public, read content via `gh api "repos///contents/?ref="`, capturing the result before decoding it (`content=$(gh api ... --jq '.content') && printf '%s' "$content" | base64 -d`) rather than piping straight into `base64 -d`, whose own exit status is all a direct pipe reports, letting a failed fetch decode as an empty success. Never `2>&1` either form, which corrupts the decode with the error text instead of the payload. Verify the ref resolves before reading either failure as proof the content itself does not exist. -- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. -- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. -- **PR-bound work runs `local-strict-review` before the claim, and records the pass.** Claiming a unit of work done, verified, green, or fixed for work that will become, or already is, a pull request means running `local-strict-review` against the branch's diff first, before a PR-hosted reviewer finds the same gap, and recording that pass with a hub checkout's `scripts/local_review.py`, run with this repository as the working directory since the engine records into whichever repository the cwd sits in, per that skill's own commands. In the repository that authors canonical content others carry, a change moving one of its units owes a second pass over that unit's whole text, recorded with `scripts/canonical_review.py` before the commit, since its ledger is tracked. Where a capture point exists it then checks what applies. Every push toward a pull request owes one, the fix pushes answering review findings included, which is the round it is most often skipped on. + -Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. +The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. + +- **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` "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, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. +- **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. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write 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. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<///` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. +- **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. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. The two review passes the section above requires, one over a push's diff and one over each canonical unit a change moved, their delegation shape, and how each is recorded are the `local-strict-review` skill's. ## Before Assuming - **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. -- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. -- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. -- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. +- **The irreversible step (merge, publish, release, delete) stays the maintainer's, and a grant given in a past session or for a different task authorizes nothing now.** Whether a credential's reach or a tool that happens to work authorizes anything is answered by `GOVERNANCE.md` "Repository Boundaries and Write Safety" rather than here. + +How to ask, how to reference what the question is about, and how to raise work that is blocked on the answer is `GOVERNANCE.md` "Communicating with the User", whole. + + + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#123](https://github.com/OWNER/REPO/pull/123)`), never a bare `#123`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#123`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + ## When a Failure Surfaces a Lesson -- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. -- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. -- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. +Where a lesson lands, and when it earns a mechanical hook, is `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. + + + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. + +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Two rules that bind at this moment have their homes elsewhere. A review flags an instance, so a fix covers the class, bounded to what the change touched or broke, stated under "Before Claiming Done" above. And a rule that keeps needing to be restated is a stale or missing skills install before it is a missing rule, per `AGENTS.md` "Where the Rules Live", with the `fleet-conformance-check` Skill as the check. ## Delegation, in One Paragraph -The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline", loaded in every session, and are not restated here. The two that bind at a conduct moment are its rule on briefing a subagent and its rule on never tiering down the seat holding the judgment. diff --git a/.github/skills/audit-a-repo/SKILL.md b/.github/skills/audit-a-repo/SKILL.md index 5cd0c133..070703f9 100644 --- a/.github/skills/audit-a-repo/SKILL.md +++ b/.github/skills/audit-a-repo/SKILL.md @@ -18,10 +18,10 @@ The audit is the fleet's measurement procedure, and the two failure shapes it gu ## Measuring -- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1: a check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1, extended to `AUDIT.md`'s own checks: an item or check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). - **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. - **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. -- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). The `workflow-ci-contract` skill summarizes that contract. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit, each applicable guarantee cited in the form 5A sets out, then the 5B trace scenarios (`AUDIT.md` section 5). Read a workflow the repo only calls at the SHA it pins, for both. The `workflow-ci-contract` skill summarizes that contract. - **Check live settings, rulesets, and secrets from a hub checkout at `main`** with `AUDIT.md` section 6. Run `repo-config/configure.sh check` with the target repository and model for settings and rulesets, and `spec/audit.py [RepoName]` for secrets, rather than constructing a local comparison. The hub payloads are the only repository-configuration source. ## Reporting diff --git a/.github/skills/backlog-burndown/SKILL.md b/.github/skills/backlog-burndown/SKILL.md index 47c14be1..50cf84b6 100644 --- a/.github/skills/backlog-burndown/SKILL.md +++ b/.github/skills/backlog-burndown/SKILL.md @@ -38,17 +38,15 @@ Everything below turns on which seat is acting, so both are named once here. amends the promotion pull request, and it owns worktree and branch cleanup, which "Dispatching a Worker" states in full. - **A worker** is one dispatched subagent holding one group, one worktree, and one feature branch, - which is `AGENTS.md` "Session Scope"'s one-branch-one-deliverable rule applied as written. It - drives its own pull request into develop and ends there. + the dispatched task `AGENTS.md` "Session Scope" describes. It drives its own pull request into + develop and ends there. ## Scope -One repository, the one the session is in, resolved from its own `origin`. Reads are unrestricted -per `GOVERNANCE.md` "Repository Boundaries and Write Safety", so reading another repository's -issues breaks no rule. Working them is out of this skill's scope, and a fleet-wide backlog -sweep is a different request. That section bounds writes to the owner of -this repository rather than to this repository alone, and a run staying inside the one repository -it was invoked for is narrower than the rule requires, deliberately. +One repository, the one the session is in, resolved from its own `origin`. A run staying inside the +one repository it was invoked for is narrower than `GOVERNANCE.md` "Repository Boundaries and Write +Safety" requires, deliberately. Reading another repository's issues is governed there and not here, +working them is out of this skill's scope, and a fleet-wide backlog sweep is a different request. ## What Invoking This Skill Authorizes @@ -58,7 +56,7 @@ it was invoked for is narrower than the rule requires, deliberately. - **The grant is bounded by the session it was named in.** A run interrupted and resumed in a new session needs the skill named again, which costs one sentence and is the difference between a grant and a mode. A grant read back from a note is one nobody gave. -- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's item 5 for +- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's explicit-permission item for this run's feature -> develop merges and nothing else, so a pull request with one open finding still does not merge. - It is never authorization to merge a develop -> main promotion pull request, to dispatch a @@ -111,10 +109,10 @@ Rank on these, highest first where they conflict: An issue that asks a question rather than states a defect is not ranked and is never guessed at. It has no group, no worker, and no claim, so nothing in "Raising a Blocked Question" applies to it -except how the question travels. It goes to the maintainer at the end of -ranking, in the same prompt as any other question the run is sending at that moment and in one of -its own otherwise, rather than waiting for a stop that may not come. It stays unranked until -answered. +except how the question travels. It goes to the maintainer at the end of ranking, per +`GOVERNANCE.md` "Communicating with the User", batched with any other question the run is sending +at that moment and in a prompt of its own otherwise, rather than waiting for a stop that may not +come. It stays unranked until answered. ## Grouping and File Claims @@ -154,9 +152,9 @@ for, and it binds harder than any throughput target. rather than plain fetch because `--prune` is what drops a remote-tracking ref whose branch is gone from the remote, deleted there by another session or through the web interface, and a plain fetch leaves that ref in `git branch -r` to defer valid groups forever. Stop and report a - failed fetch rather than reading `git branch -r` anyway: the remote-tracking refs still resolve - from what the last successful fetch left, so the scan returns a confident answer about a remote - it did not reach, missing a branch pushed since and keeping one deleted since. The round + failed fetch rather than reading `git branch -r` anyway, per `GOVERNANCE.md` "Verification + Discipline" on what a local clone answers for: here the scan would miss a branch pushed since + the last successful fetch and keep one deleted since it. The round stops there and reports, rather than dispatching against a stale answer, and stopping rather than deferring is what the cleanup and promotion steps need too, since both read the same remote. @@ -199,9 +197,8 @@ its own bound stated in the worker's brief. remove the rule that leaned on it. A narrowed qualifier is where a new false claim gets introduced, and it is the most common way a prose round produces the finding the following round then fixes. -- **Set a review-round budget before the first push.** A whole-unit prose review can run many - rounds where a finding was introduced by the previous round's fix, so state a number in the - brief, and when it is reached, land what is correct and file the remainder rather than churning. +- **The review-round budget is `local-strict-review` "Disposing of Findings"'s.** The brief names + it and states no second one. ## Dispatching a Worker @@ -210,18 +207,19 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. - **The worker drives its group to a develop merge**, by invoking `drive-pr` with the target stated as develop only. That skill owns the review loop, the finding disposition, and the merge, so brief the group and the bounds rather than restating the loop. -- **The worker creates its own worktree**, always, as `drive-pr` step 1 and `repo-worktree`'s - task-start mandate already require of the task itself. No worker inherits another's worktree, +- **The worker creates its own worktree**, always, as `drive-pr`'s worktree isolation and + `repo-worktree`'s task-start mandate already require of the task itself. No worker inherits another's worktree, which is why "Bounding the Wait on a Worker" either removes a dead worker's tree and its branch or leaves that tree untouched for the maintainer, and never passes it on. -- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr` step 4 - and of `repo-worktree`'s post-merge procedure. Say so in the brief, because a worker following - either alone will clean up. The worker still performs step 4's merge itself, and what the override - moves is that step's two cleanup halves, the worktree procedure and the verify-then-delete of the - merged remote branch, **both** rather than only the first. "Cleanup Is the Orchestrator's" below, in this +- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr`'s + post-merge cleanup and of `repo-worktree`'s post-merge procedure. Say so in the brief, because + a worker following either alone will clean up. The worker still performs the merge itself, and + what the override moves is the two cleanup halves `drive-pr` runs after it, the worktree + procedure and the verify-then-delete of the merged remote branch, **both** rather than only the + first. "Cleanup Is the Orchestrator's" below, in this same section, says why and what it covers. -- **The worker runs `local-strict-review` before every push**, including one that only fixes a - review finding. That pass dispatches a reviewer of its own, so a harness where a subagent cannot +- **The worker runs `local-strict-review` before every push**, per `GOVERNANCE.md` "Verification + Discipline". That pass dispatches a reviewer of its own, so a harness where a subagent cannot dispatch one leaves the worker unable to run it and unable to push. It reports that rather than pushing, and its worktree is then retired, since git refuses to attach that branch anywhere else while the reporting tree holds it. The branch is left standing for its own reason, that the @@ -255,13 +253,13 @@ Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. `repo-worktree`'s post-merge procedure returns the base clone to current develop before proving the cleanup, and `operational-vs-release-workflow` states that requirement independently. Four workers doing that concurrently mutate one shared checkout, which `GOVERNANCE.md` "Repository Boundaries and -Write Safety" forbids outright by giving each task its own checkout. A worker also cannot +Write Safety" forbids. A worker also cannot finish the procedure from inside its own worktree, since removing that worktree leaves it with no working directory in which to delete its branch. So the whole procedure moves to the orchestrator, which runs it from the base clone at the round's -cleanup step, while no worker is live in a tree it touches. It carries `drive-pr` step 4's remote -half too, verifying the merged branch's tip against the pull request's `headRefOid` before +cleanup step, while no worker is live in a tree it touches. It carries the remote half of `drive-pr`'s +post-merge cleanup too, verifying the merged branch's tip against the pull request's `headRefOid` before `git push origin --delete`, since taking that step from the worker without naming a new owner would leave a live remote branch behind every group. It covers every group that is done with its tree, which is the finished ones **and the abandoned ones**: a group told to abandon its branch keeps a @@ -299,10 +297,10 @@ judgment here: the tier is chosen per group rather than defaulted, because a str produces better work up front and takes fewer review rounds to land it, which often costs less than a cheaper worker looping. Three kinds of group are never tiered down: -- One touching **carried canonical content**: rule text, a Skill, or anything else this repository - authors and other repositories carry, since a wrong rule propagates to every carrier. -- One touching **a gate, a ruleset, a release condition, or a carried governance section**, which - is `AGENTS.md`'s own list of what counts as a design change however small the diff looks. +- One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds + it, since a wrong rule propagates to every carrier. +- One touching **anything `AGENTS.md` "Delegation" calls a design change**, however small the diff + looks. - One whose issues are **complex or entangled**, where the fix depends on reasoning across several files or on a contract not stated in the file being edited. @@ -310,7 +308,8 @@ State the chosen tier and its reason in the round's report. ## Bounding the Wait on a Worker -`AGENTS.md` requires a wait to separate its outcomes and to be bounded, so this one is. A worker +`AGENTS.md` "Delegation" binds this wait as it binds any other, and this section is how the bound +is met here. A worker reports merged, parked, or stopped. A worker that reports nothing at all is the case needing a bound, since it is indistinguishable from a slow one and dying mid-drive is ordinary here. @@ -336,10 +335,8 @@ dispatched fresh, its claim comment released with the worktree. It fails, and cl and the group goes to the maintainer, since past that point removal discards work. **A dirty one is left exactly as it stands** and the group is stopped for the maintainer per "Raising a Blocked Question", naming the worktree and what is uncommitted in it. The orchestrator does not commit that work, hand the tree to a -replacement to commit, or remove it: reaching into a tree a task was live in is what -`GOVERNANCE.md` "Repository Boundaries and Write Safety" forbids, and doing it by proxy is still -doing it. Where no other worker remains to bound the wait, the same liveness answer bounds it -alone. +replacement to commit, or remove it, per `GOVERNANCE.md` "Repository Boundaries and Write Safety". +Where no other worker remains to bound the wait, the same liveness answer bounds it alone. ## Raising a Blocked Question @@ -350,13 +347,12 @@ rather than a decision. - **The group stops, and nothing about it is disposed of.** No thread is resolved, no finding is answered on the orchestrator's own judgment, and no pull request merges. - **The other groups keep driving.** One stopped group never idles the round. -- **The question travels worker to orchestrator to maintainer, and reaches the maintainer at the - point the work stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, - since a dispatched subagent is not the seat that can prompt anyone. The orchestrator is that - seat, and it asks then and there through the interface's own prompt mechanism, per - `GOVERNANCE.md` "Communicating with the User". Holding the question for a round boundary is the - handoff-buried-in-a-paragraph that section forbids, and a boundary can be a long way off or, - for a group blocking the promotion pull request, never arrive at all. Where several groups stop +- **The question travels worker to orchestrator to maintainer, and is asked when the group + stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, since a + dispatched subagent is not the seat that can prompt anyone. The orchestrator is that seat, and + it asks then and there, per `GOVERNANCE.md` "Communicating with the User". Holding the question + for a round boundary is what that section forbids, and a boundary can be a long way off or, for + a group blocking the promotion pull request, never arrive at all. Where several groups stop close together, their questions go in one prompt, which is batching without deferral. - **The question is also written on its issue**, so it survives the session that asked it. - **A stopped group keeps its branch and its claim**, and its worktree is left exactly as it @@ -377,16 +373,15 @@ for the maintainer, so that one carries a single round rather than accumulating **This section assumes the release workflow model**, where feature work reaches develop through squash-merged pull requests and a promotion pull request carries develop to main. A repository -whose registry `workflowModel` reads `operational` differs on both counts, per -`operational-vs-release-workflow`: it commits to develop directly, and it opens a promotion pull -request only occasionally rather than per round, so confirm with the maintainer whether one is -wanted at all there. - -Neither difference changes how this run's own work is read. Every worker invokes `drive-pr` -whatever the model, so this run's fixes still arrive as squash-merged feature pull requests -carrying the `Closes on promotion:` line, and the two hops still read them. What the model adds is -a second kind of commit in the same range, a direct push that never had a pull request, whose -issues are recoverable only from the commit message itself. Read both, the pull requests for this +whose registry `workflowModel` reads `operational` reaches develop differently, per `GOVERNANCE.md` +"Operational Repositories". Confirm with the maintainer whether a promotion pull request per round +is wanted there. + +That difference changes nothing about how this run's own work is read. Every worker invokes +`drive-pr` whatever the model, so this run's fixes still arrive as squash-merged feature pull +requests carrying the `Closes on promotion:` line, and the two hops still read them. What the +operational model adds is a second kind of commit in the same range, a direct push to develop that +never had a pull request, whose issues are recoverable only from the commit message itself. Read both, the pull requests for this run's work and the commit messages for the direct pushes, since reading either alone returns a partial set, and the range rather than this round is still what covers earlier work no promotion has carried. @@ -398,21 +393,22 @@ has carried. can still owe a promotion pull request, for work an earlier round landed and no promotion has yet carried. A count of zero is the only case with nothing to promote, and the round reports that instead of attempting one. -2. Drive its review loop per `drive-pr` steps 5 through 8, **with a review-round budget set before - the first one**, the same discipline "Bounding a Prose Group" applies to a feature branch. That - loop repeats until the promotion pull request carries no open finding, and nothing in it - terminates on its own, so when the budget is reached, stop and put the state to the maintainer - rather than continuing to spend the run's only forward gear on one pull request. -3. Put the ready pull request to the maintainer through the interface's own prompt mechanism, - naming the merge as the action that unblocks the run. The maintainer's merge is the run's clock, so one +2. Drive its review loop per the promotion half of `drive-pr` "The Drive Loop", **with a + review-round budget set before the first round**. That loop repeats until the promotion pull + request meets every `pr-review-conduct` Merge Gate item except the maintainer's explicit + permission to merge, and nothing in that loop terminates on its own, so when the budget is + reached, stop and put the state to the maintainer rather than continuing to spend the run's only + forward gear on one pull request. +3. Put the ready pull request to the maintainer, per `GOVERNANCE.md` "Communicating with the + User", with its merge as the action asked for. The maintainer's merge is the run's clock, so one reported in a closing paragraph and never actually asked about stalls every round behind it. Do not merge it. 4. **While it waits, develop takes only what that pull request itself needs.** A finding against it lands as its own feature -> develop pass, and that landing moving its head is expected, since its head **is** develop. **That pass is dispatched as a worker like any other**, which is the one push the freeze permits and the reason the orchestrator still opens no branch of its own. - `drive-pr` step 6 sends the seat driving a promotion pull request back through its own steps 1 - to 4 for such a fix, and here that seat dispatches rather than drives it. + `drive-pr` "The Drive Loop" sends the seat driving a promotion pull request back through its + own feature -> develop pass for such a fix, and here that seat dispatches rather than drives it. 5. **A promotion fix outranks any file claim.** A group holding a file it needs yields, because the promotion pull request is what the whole run is queued behind. A holder that is merely parked yields by handing the file over. A holder that already pushed and has an open pull request @@ -424,13 +420,13 @@ has carried. which is the worktree-only disposition "Cleanup Is the Orchestrator's" separates out and the retire-then-dispatch shape "Raising a Blocked Question" uses, and then dispatches a fresh worker on that same branch, briefed either to merge develop in to pick the fix up or to narrow - the change to drop the file. Never rebase it: - its branch is already pushed, so a rebase needs the force-push `git-commit-conventions` forbids - outright. + the change to drop the file. Never rebase it, + since its branch is already pushed and a rebase there needs what `GOVERNANCE.md` "Git and Commit + Rules" forbids. 6. **Nothing else pushes, and nothing else is dispatched.** The promotion fix of step 4 is the one exception to both, and everything in this step is said of the next round's work rather than of it. That round's preparation is orchestrator work and continues: rank, group, and verify claims. - Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, whose second step + Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, which pushes and opens a pull request, so a next-round worker dispatched under the freeze would either break it or sit in a state that procedure does not describe. None is left running across the wait either, since a worker held idle for an unbounded maintainer wait is one doing nothing at a @@ -468,8 +464,9 @@ body when it lands rather than leaving the issue to be closed by hand. - **Working notes outside the repository hold the round**: the ranking, the working groups, the tier choices, and the worker assignments. A scratch file the harness gives a session serves - where there is one, and any note kept out of the tree serves where there is not. It is working - state, and nothing about it is committed. + where there is one, and any note kept out of the tree serves where there is not. It is the + in-flight session state `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" describes, and + nothing about it is committed. - **GitHub holds what outlives the session.** A claim comment records a group's file set, a pull request body records what a round carried, a `Fixes #N` line records what the promotion closes, a deferral issue records what was put off and why, a thread reply records how a finding was diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.github/skills/dotnet-codestyle/references/testing.md b/.github/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.github/skills/dotnet-codestyle/references/testing.md +++ b/.github/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump forces the move. + +**The version floor is load-bearing rather than cautionary.** Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform `xunit.v3` 4.0.0 carries, runs zero tests, and **still writes a well-formed Cobertura file reporting full coverage**, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a solution with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.github/skills/drive-pr/SKILL.md b/.github/skills/drive-pr/SKILL.md index b234fb29..9ea24f53 100644 --- a/.github/skills/drive-pr/SKILL.md +++ b/.github/skills/drive-pr/SKILL.md @@ -2,19 +2,19 @@ name: drive-pr description: >- Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into - develop and, when asked, on to a mergeable develop -> main promotion PR, applying the - pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with - evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an - explicit answer in the same turn, escalating to whoever dispatched the drive instead where the - maintainer cannot be reached from that seat. Use this whenever asked to drive, land, take, chase, or push + develop and, when asked, on to a mergeable develop -> main promotion PR, disposing of every + reviewer finding along the way under pr-review-conduct's outcomes, carried here whole as a + generated include, and escalating to whoever dispatched the drive where the drive's own seat + cannot reach the maintainer. Use this whenever asked to drive, land, take, chase, or push a PR toward develop or main, or to run the review loop hands off instead of narrating each round. When the request does not say how far ("drive this PR", "land it"), ask once whether the target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when only one PR is named, because a finding raised against the develop -> main promotion PR routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends - at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges - main itself, that is the separate merge-and-release skill, its own go-ahead. + at develop merged, or at a promotion PR meeting every pr-review-conduct Merge Gate item except + the maintainer's explicit permission to merge, never merges main itself, that is the separate + merge-and-release skill, its own go-ahead. --- # Drive PR @@ -128,32 +128,57 @@ promotion PR once the fix lands, is the early exit this skill exists to prevent. 1 to 4 in its own worktree and branch, then return here. 7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request a review on the new head and continue the loop. -8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green - on the current head. +8. Repeat 6 and 7 until the promotion PR meets every pr-review-conduct Merge Gate item except the + maintainer's explicit permission to merge. 9. Report the promotion PR number and its ready state. Do not merge it. ## Disposing of Every Finding -pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: - -- Real, so fix it, then step 2's own order again before replying with the fixing commit SHA - (outcome 1). This is the round the pass is most often skipped on, since the fix looks small and - the branch was already reviewed once, and a fix push carries content no pass has read exactly as - the first push did. -- Not real, or real but out of scope here, so decline in the thread with evidence: the command - and its output, the code path, or the rule that governs it. An assertion never closes a finding - on its own (outcome 2). -- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). -- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not - know which of the above applies, so ask the maintainer directly, whatever the runtime's own - interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask - later is resolution by silence (outcome 3). A drive that cannot reach the - maintainer directly, a dispatched one being the ordinary case, escalates to whoever dispatched - it and stops that unit of work there instead, per `pr-review-conduct`, which owns what the - receiving seat then does and how far the escalation travels. -- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a - comment, or take the rule itself to the maintainer, rather than re-arguing the instance every - round (outcome 5). +The rule below is a generated include, so a defect in it is fixed in `pr-review-conduct` and +regenerated rather than edited here. A drive that cannot reach the maintainer directly, a +dispatched one being the ordinary case, escalates per `pr-review-conduct` "Escalate to the +maintainer when". + + + +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. +5. **Keeps recurring, so fix the class, not the instance.** A finding raised repeatedly against + correct code means the code is not communicating something: add the comment, sharpen the name, + narrow the interface, or fix the rule if the rule is wrong. Bouncing the same point across + rounds is the signal to escalate the rule itself, not to keep re-arguing it. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + + ## Mechanics Live Elsewhere diff --git a/.github/skills/local-strict-review/SKILL.md b/.github/skills/local-strict-review/SKILL.md index 336effcc..fa1c1a86 100644 --- a/.github/skills/local-strict-review/SKILL.md +++ b/.github/skills/local-strict-review/SKILL.md @@ -55,6 +55,8 @@ Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of ``` +Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. + **Model tier:** the strongest tier this session can reach, per `AGENTS.md` "Match the model tier to the judgment" and "Never tier down the seat holding the judgment", applied here to the reviewer rather than the author. Run the pass on the same tier that authored the change when only one tier is reachable, a second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. "This session can reach" means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names a stronger tier for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch reaches several tiers but exposes no way to name one, take what it gives and run the pass, on the same reasoning as the single-reachable-tier sentence above. A seat that cannot dispatch a subagent at all cannot perform this pass. Instead of pushing, it reports that it could not run the pass, to whoever dispatched it, or to the maintainer where nobody did. Either way it is a push that does not happen rather than a pass quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. @@ -110,27 +112,38 @@ Bounds: read-only. Report a rule that looks incomplete rather than guessing at w git fetch origin # stop and report a failed fetch rather than measuring past it python3 scripts/canonical_review.py check --target # each uncovered unit, with its digest # run the pass above over each unit it named, then, per unit: -python3 scripts/canonical_review.py record --reviewer agent-skill --unit '=' [--findings N] +python3 scripts/canonical_review.py record --reviewer agent-skill --target --unit '=' [--findings N] ``` -These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the second is measured with the first's unit model, while `record` stamps the ledger with a commit read from the second. +These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `/` form the pass above shows for its own reach. Point an engine in one checkout at another checkout's tree and the two mix, the engine's own section rules over the other tree's manifest and files. -`` is the branch this work targets, resolved once as the pass above resolves it and passed to `check` explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. +`` is the branch this work targets, resolved once as the pass above resolves it and passed to both commands explicitly. Left off it defaults to `develop`, so a branch based on `main` is measured from a fork point nobody read, and `record` stamps each pass with a merge-base against a branch the work never targeted. The fetch matters for the same reason it does above: the engine resolves `origin/` if it already exists and never fetches it, so a stale remote-tracking ref moves the fork point without saying so. Lagging, which is the ordinary way to be stale, moves it back and gates units this change never touched, and the reverse case, where the branch restores text the target has since changed, drops one it did move. Neither is announced, so the fetch is what keeps the fork point meaning what the reviewer read against. `check` names each uncovered unit with the digest to hand back, so nothing has to be looked up separately, and `list` is there for reading the whole set rather than for this loop. The digest is bound to the read for the same reason `--expect-digest` is above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. Record each unit whatever the pass found, including nothing. Fixing a finding is itself such an edit, so `record` then refuses the digest you were holding: that refusal is the content having moved rather than a fault in the record, and the answer is a read of the unit's new text, which is what a carrier will actually receive, recorded at its new digest. -**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger and its burn-down are tracked files the commit has to carry, so writing them after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. +**This pass records before the commit, where the pass above records after it**, and the two orders are opposite because the two records live in different places. A receipt sits in the worktree's git directory and can never be committed, so it is written once the commit has fixed what a push will deliver. This ledger, `reports/canonical-review.json`, is a tracked file the commit has to carry, so writing it after that commit leaves the tree differing from HEAD, which is a state the pre-push hook refuses before either gate runs. The shortest order meeting both, and the one the refusal table below assumes, is: run this pass and record each unit, commit that together with the change, then read the digest, run the diff pass, record its receipt, and push. Committing the change first and the ledger in a second commit satisfies the same constraint and costs a commit. -**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry in the hub's `reports/canonical-review.md` rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. +**A unit nothing has read here yet is not this branch's debt.** `check` refuses the units this change moved, meaning the ones whose text it edited and the ones it newly carried, since widening the manifest hands a carrier content for the first time exactly as writing it would. Everything else is a burn-down entry `canonical_review.py report` renders rather than a block on unrelated work. Working one of those off is worthwhile, and it is its own change rather than a tax on an unrelated one. ## Disposing of Findings -Every finding maps to one of `pr-review-conduct`'s five outcomes, at whichever moment this pass ran: fixed (1), evidence-disproven (2), escalated to the maintainer for an explicit call (3), filed as a deferred issue (4), or, if it keeps recurring, taken as a signal to fix the class (5). Outcome 2 is the agent's own on its own evidence, covering a finding that is not real and one that is structurally out of scope. A finding judged real and left unfixed is never the agent's alone, so outcome 3 needs the maintainer's explicit answer in the same turn, reached only once outcome 2 is ruled out, or, where this pass ran in a seat that cannot reach the maintainer, an escalation to whoever dispatched it that stops the work there, which stops the push this pass runs before, and outcomes 4 and 5 reach the maintainer too, for the deferral and for the rule itself. Running this pass is required before every push toward a pull request, per `agent-conduct`. Two claims sit next to each other here and they point opposite ways, so they are stated apart rather than in one sentence. **The pass is mandatory**, and where a capture point enforces it, a push carrying content no recorded pass covers is refused. That refusal is the gate working rather than a fault to route around. **The findings stay advisory**, and the count a pass raises gates nothing at all, since a pass records that a review ran and never that the content is clean. The disposition above is what closes each finding, the same posture local lint holds today. It posts nothing to GitHub, it only reports to the session driving the work. A finding raised here and not fixed is not thereby resolved: the same finding shape reaching a PR-hosted reviewer later still gets its own fresh disposition, per `pr-review-conduct`'s "a disposition decided on one PR does not carry to the next." +Each bullet is a rule down to its `Why:` line, which is rationale rather than rule, so a stale rationale is a cleanup rather than a defect. + +- **Every finding ends in one of the outcomes that `pr-review-conduct` "Every finding ends in one of five outcomes" enumerates, reached here with no thread to reply in.** + - `Why:` a local finding and a PR-hosted one deserve the same dispositions, and one home for the list is what stops two copies of it drifting apart. +- **The agent disposing of a pass's findings classes each one `style`, `introduced`, or `pre-existing`, in that order.** `style` is a preference between defensible forms. `introduced` is any other finding on text this change wrote, rewrote, or removed, on text this change should have written, on a precondition this change left false elsewhere, or load-bearing for a decision this change puts to the maintainer. `pre-existing` is every other finding. + - `Why:` the reviewer is asked to omit preferences and returns some anyway, and `style` is classed first so that a preference on text this change wrote is not owed a fix. +- **Another round is owed only while an `introduced` finding is open.** Unless evidence disproves it, an `introduced` finding is fixed within the budget below, or escalated where `pr-review-conduct` "Escalate to the maintainer when" says so, a `pre-existing` one is filed once and blocks nothing, and a `style` one is declined with evidence, per `pr-review-conduct` "Every finding ends in one of five outcomes", the evidence being `code-review` "Review the Change"'s own rule to omit preferences. + - `Why:` a finding count over prose never reaches zero, so a loop closing on "did it find anything" does not close, where one closing on the false claim, the unfollowable instruction, or the wrong behavior this change put there does. +- **A push allows two rounds of edits in answer to the passes it owes, one budget across both.** Where an `introduced` finding is still open after the second round, editing stops and what remains goes to the maintainer with its counts per class, per `pr-review-conduct` "Escalate to the maintainer when". + - `Why:` past the second round nearly every finding is against text the previous round's fix wrote, so the rounds are producing the defects they find rather than removing them. +- **The pass is mandatory, and the count it records gates nothing.** A pass is recorded whatever it raised, so the record attests that a review ran rather than that the content is clean. + - `Why:` a gate reading the count would make a pass raising nothing the cheapest way through it, the opposite of what recording one is for. ## When to Run It -- Before the first push toward a pull request (`drive-pr`'s Drive Loop step 2, `pr-review-conduct`'s Expected review loop step 1). -- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (`drive-pr`'s "Disposing of Every Finding", `pr-review-conduct`'s outcome 1). +- Before the first push toward a pull request, the push that opens it in `drive-pr` "The Drive Loop" and in `pr-review-conduct` "Expected review loop". +- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (the fix outcome of `pr-review-conduct` "Every finding ends in one of five outcomes", which `drive-pr` "Disposing of Every Finding" carries). - Whenever `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for work that will become, or already is, a pull request. - Before pushing a change that edits canonical content other repositories carry, or that newly carries some by widening the manifest, over each unit `check` names, per "The Carried-Content Pass" above. diff --git a/.github/skills/operational-vs-release-workflow/SKILL.md b/.github/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.github/skills/operational-vs-release-workflow/SKILL.md +++ b/.github/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), diff --git a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index 8bfd49a6..3ca50760 100644 --- a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md index 1073b824..b6f9f91a 100644 --- a/.github/skills/pr-review-conduct/SKILL.md +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -41,11 +41,14 @@ visible comments, routinely still carries a finding nobody has answered. Treatin 2. A review is confirmed on the **current head SHA**, matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the re-review lands, and the matched review is **read**, not just counted. A review can carry the head SHA and still decline - the PR outright, or say it read only part of the changed files. `pr_review.py`'s - `review_on_head` names Copilot's own coverage specifically, the currently required reviewer, - not "no review of any kind covers this head": a trialed advisory reviewer (CodeRabbit, - Qodo) carrying the exact head under `other_reviewed`, with an empty review body and no new - threads, is its own ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). + the PR outright, or say it read only part of the changed files. The coverage this item + requires is Copilot's, and CodeRabbit and Qodo are advisory, since the hub's + `docs/pr-reviewer-evaluation.md` "Status" names Copilot the incumbent and says no candidate is + a required reviewer: an advisory reviewer's absence blocks nothing, while its findings owe + item 3 exactly as Copilot's do. `pr_review.py`'s `review_on_head` names Copilot's own coverage + specifically, not "no review of any kind covers this head": an advisory reviewer carrying the + exact head under `other_reviewed`, with an empty review body and no new threads, is its own + ordinary "reviewed, nothing to flag" shape, not a missing review (#1066). 3. **Every** finding on that head SHA is closed: threads resolved, issue-level comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered. Those appear in no thread, so polling threads @@ -55,6 +58,22 @@ visible comments, routinely still carries a finding nobody has answered. Treatin give each one the same triage the low-confidence findings above already get (#1058). Qodo's own `Resolved`/`Dismissed` self-tracked badge is a fast pre-triage signal, not a substitute for reading the finding, spot-verify against `gh pr diff` rather than trusting it outright. + What closing a finding owes turns on whether it is `pre-existing`. A finding on text inside a + canonical Markdown unit, one the hub's `scripts/canonical_review.py list` names, classed + `pre-existing` by the classes `local-strict-review` "Disposing of Findings" defines for a + local pass, applied here to a PR-hosted finding, is outcome 4 of "Every finding ends in one + of five outcomes" below applied once per unit rather than once per finding: the round gathers + that unit's such findings onto the unit's tracker, an open hub issue whose title carries the + unit key, retitled by the change that moves the key and filed by whichever round first needs + it, and answers each finding with that issue's link, resolving a thread on that reply, so a + `pre-existing` remark on a sentence the change never touched costs one link rather than a + decline or an issue per finding. The batch runs in the hub, which authors the text of every + verbatim unit. A carrying repository routes a finding on a verbatim unit by fidelity rather + than by class, since a resync writes the whole text there: it declines the finding under + that section's outcome 2, ownership sitting elsewhere, and files it on the same tracker, + while a finding on an intent unit is filed there too, the carrier adapting its own copy + meanwhile, since the defect is still fixed at the source. Every other finding, a `style` + remark on untouched text included, takes its own outcome in that section. 4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. File an issue naming it and quoting the body, rather than guessing what the new wording @@ -100,19 +119,22 @@ Run `local-strict-review` against the branch's current diff before step 1's push The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. After a timeout with `requested=yes`, rerun `wait` for another bounded interval by default because the request may still be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, -then apply the Merge Gate above. **Never exit the loop early.** A round count is not a stopping -condition, and neither is patience running out. Reporting only that the PR was opened is an early -exit unless the maintainer explicitly instructed the agent not to monitor or drive its review. +then apply the Merge Gate above. **Never exit this PR-hosted loop early.** Its pre-push +counterpart is bounded instead by `local-strict-review` "Disposing of Findings". A round count +is not a stopping condition here, and neither is patience running out. Reporting only that the +PR was opened is an early exit unless the maintainer explicitly instructed the agent not to +monitor or drive its review. After an authorized merge, run the `repo-worktree` post-merge cleanup procedure unless the user explicitly asks to retain the checkout or branch. The pull request loop is incomplete while its finished worktree or local task branch remains. It is also incomplete until the base clone returns to fetched and fast-forwarded `develop`. ## Every finding ends in one of five outcomes -1. **Real, so fix it.** Take the fix through `local-strict-review` the same way step 1's push - went, then reply with the fixing commit SHA. A branch already reviewed once - has not been reviewed for the fix, which is the round this gets dropped on and the churn - `local-strict-review` exists to stop. For a finding on platform-specific code - (PowerShell, a macOS- or WSL-only path), "fixed" means executed on that platform, per +1. **Real, so fix it.** Take the fix through `local-strict-review` the same way the push that + opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply with + the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. 2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** @@ -141,6 +163,9 @@ on a sibling repo or PR, even within one batch or one session, gets its own outc evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior instance's outcome is context for the new one, never a standing answer to reuse in its place. +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + ## Triaging findings **A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, diff --git a/.github/skills/python-codestyle/references/testing.md b/.github/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.github/skills/python-codestyle/references/testing.md +++ b/.github/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. diff --git a/.github/skills/skill-lifecycle/SKILL.md b/.github/skills/skill-lifecycle/SKILL.md index aa853916..4a8aa6f8 100644 --- a/.github/skills/skill-lifecycle/SKILL.md +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-lifecycle description: >- - Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the include regions it fills from a rule's home so a skill carries the rule's text without a copy, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. --- # Skill Lifecycle @@ -12,9 +12,11 @@ The agent most likely to get a skill wrong is the one editing a skill, and befor ## The Pipeline -- **`.agents/skills//SKILL.md` is the only hand-authored source**, with optional `references/` and `scripts/` directories beside it. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **`.agents/skills//SKILL.md` is the only tree a skill is authored in**, with optional `references/` and `scripts/` directories beside it, and the one part of it not written by hand is the text inside an include region, described below. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. - **Generated distributions serve GitHub Copilot and Claude Code.** `scripts/build_dist.py` generates `.github/skills/` for GitHub Copilot and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. Neither generated tree is hand-edited, and `build_dist.py --check` exits non-zero when either tree differs from `.agents/skills/`. - **The skill set is implicit.** Every `.agents/skills//` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **A rule's text reaches a skill as a generated include, never as a copy.** A region opened by a line holding only `` and closed by a line holding only ``, each indented at most three spaces, is filled by `build_dist.py` with the body under that heading. The key is the root-relative path, spelled as the tree spells it, then ` > `, then the heading text at any level from two, matched case-insensitively. The fill lands in `.agents/skills/` itself, since Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the generated trees mirror the filled source. A source is any regular file under the repository root outside the two generated trees and reached through no symlink, so a key may name a `GOVERNANCE.md` section, an `AGENTS.md` subsection, or a section of a sibling skill, and a region filled from a file carrying regions of its own reads that file's filled text. Regenerating after a source edit changes the bytes of every skill unit including it, and `--check` fails the pull request until that regenerate runs, so the whole-unit review pass `scripts/canonical_review.py` records for each of those units is owed again, which is the cost of a carrier reading generated text in the skill's own context. +- **`--check` holds every region to its source.** It fails when a region differs from what its source renders now, so a hand edit inside one and a source edit nobody regenerated for both fail the pull request the same way a stale mirror does. A region it cannot render is a failure rather than a stale result, exit 2 rather than 1, because regenerating cannot repair it: a key with no ` > ` or an empty heading, a path naming no file, a heading that no longer resolves or that recurs in its source, a body with nothing in it or leaving a code fence open, a region in a file the generator does not walk, reached through a key, since it walks only the Markdown files of the skill directories, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path outside the root, through a symlink, under a generated tree, or spelled otherwise than the tree spells it, a skill file or source that is not UTF-8, a file holding a region while mixing line endings, and a line outside a code block that begins like a marker and matches neither form, which read as content would leave a region unfilled. - **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero when the machine is behind. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). ## Deciding a Topic Deserves a Skill @@ -34,16 +36,17 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim ## Changing or Retiring a Skill -- **Edit only the source tree.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. -- **Retiring is deleting the source directory and regenerating.** The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **Edit only the source tree, and outside its include regions.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. The text inside an include region is generated too, so a change there is made at the region's source and regenerated, never typed into the region. +- **Retiring is deleting the source directory and regenerating.** A region in a sibling keyed on the retired skill stops that regenerate, since its key no longer resolves, so re-key or remove it first. The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. - **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. -- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. An include key spelling the old path is such a reference, and one left behind fails `--check` as a region it cannot render rather than as a stale mirror. ## The Doc-Packaging Pattern -Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two shapes, and each pairing states which it uses: +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has three shapes, and each pairing states which it uses: - **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills//SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. -- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md` outside sections 3, 4, and 5). The skill states per topic which doc section owns it. +- **Included content.** The doc keeps the full rules and the skill needs them whole to work in isolation, so it carries the section as a generated include rather than as a summary or a copy, declared with the region markers "The Pipeline" above describes and keyed on the doc's section (`agent-conduct` over the three `GOVERNANCE.md` sections it surfaces, `workflow-ci-contract` over `WORKFLOW.md` sections 3, 4, and 5). The doc side states the shape with one sentence naming the skill that includes the section, and the skill side is the region itself. A section carried this way is read outside its own document, so it names a sibling section by document and heading rather than as above or below, and it links to no file by a relative path, since the path would resolve against the skill's directory rather than the doc's. The doc wins by construction, since `scripts/build_dist.py` writes the region from it and its `--check` reports a region that differs from it as stale. -In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. +In every shape the doc is the authority when the two are found to disagree, the moved-content shape included: the doc's summary says what the rule is, and the skill's full text is what gets corrected. A deliberate change to a packaged rule is not such a disagreement. It lands where the full text lives, and in the same change the author either edits the other side's summary to match, since a summary has no mechanical check, or regenerates the include, which has one. A rule stated fully in both places by hand is the drift this pattern exists to prevent, and an include is the one full second statement that cannot drift undetected. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index 0a3b832f..a02a47d6 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -8,40 +8,24 @@ description: >- ## Why This Exists -`WORKFLOW.md` in the hub is a behavioral contract stating required outcomes rather than a required implementation. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where those two overlap, which `WORKFLOW.md`'s own canonical-scope note states. +`WORKFLOW.md` is the fleet's CI/CD behavioral contract. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary, and `WORKFLOW.md` sections 3, 4, and 5 are each carried whole in `references/` as a generated include. `WORKFLOW.md`'s own canonical-scope note says which of it and `GOVERNANCE.md` is authoritative where the two overlap. ## How the Contract Is Read -- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. -- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Outcomes, not bytes.** A workflow is judged against `WORKFLOW.md` section 4's expected inputs and outputs, never against a snippet byte for byte, per `GOVERNANCE.md` "Foundational Principles". +- **Applicability.** A guarantee, or a 5B scenario from `WORKFLOW.md` section 5, governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. - **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. -- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. -- **Two layers.** Orchestration (the PR entry workflow, publisher, version and release jobs) is generic and standard at the job level. Build leaves (the `build-` tasks) are repo-owned. Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf receives `ref`/`branch`/`smoke` and whatever else its target needs, a derived `push` among them where that leaf pushes, so assert each input in the layer that declares it. A package target declares no push input on either layer, its push living in a separate `publish-` job in the repo's own publisher. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry and output, the `smoke-build` enable-forward, and a package target's `publish-` job (D6.4). +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is reached as a hub-hosted `workflow_call` task, per `GOVERNANCE.md` "Hub-Hosted Tooling". The repo's own surface is the caller stub, pinned to a hub release commit, and a composite-action hook at `.github/actions/` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the stage each workflow migrates in. Until a workflow's stage ships, its copy is graded against the same contract. +- **Two layers.** The pipeline splits into an orchestrator layer and a build-leaf layer, defined in `WORKFLOW.md` section 3's `Two Layers: Orchestration vs Build` and carried in `references/architecture.md`, while `WORKFLOW.md` section 1's `Two layers when auditing` maps which layer declares which input. Assert an input a guarantee names in the layer that declares it. -## Style Rules That Break in One-Line Diffs +## Style Rules -- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. -- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks. -- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two are documented exceptions. The publisher takes a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. The merge-bot takes `cancel-in-progress: false` and keys on the PR number rather than `github.ref`, per D8.1, so each PR queues independently and every event runs to completion. -- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. -- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. -- **Permissions validate before `if:`**, so a callee declares `permissions:` only where every caller grants that scope at startup and otherwise declares none, running under the calling job's grant. A callee's extra scope (`actions: write` for cleanup) is granted by the caller at the one entry point that needs it. -- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. -- **Docker layer cache** targets a registry tag (`buildcache-`), never `type=gha`. -- **Workflow YAML is LF.** Preserve endings on every edit. +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and the `comment-and-doc-style` Skill keeps the line-ending policy, reached from `GOVERNANCE.md` "Documentation Style Conventions" under "Line Endings". Read both before editing a workflow or a composite action. -## The Core Behavioral Spine +## The Contract Text -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, running under `if: always()` so a failed or skipped dependency cannot skip the gate itself, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. -- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule, while a publisher whose only trigger is `workflow_dispatch` (`releaseTrigger: dispatch-only`) reaches the dispatch alone, its bot-push and schedule paths never firing, which covers a source-only repo and an operational repo alike. Each run builds the one trigger branch, the default branch a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The gate's branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` each name the repo's actual default branch, and a divergence among the three is a defect. The release tags the built commit's SHA (`GitCommitId`), never a branch name. -- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once at entry, in a dedicated job or in a step of an entry job, and the downstream jobs `needs:` that job, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. -- **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A caller with no file target passes `expect_release_assets: false`, which covers a Docker-only, a PyPI-only, and a source-only repo, while a NuGet-only caller keeps the default `true`, its leaf uploading a `release-asset-*` that carries the package. -- **Artifacts are an intra-run handoff**: a cross-job transfer artifact is deleted at the job that consumes it, while an intermediate consumed only within the same run may instead rely on the `retention-days: 1` every upload sets. That delete is gated to the condition that made the artifact redundant, which is the release-create step's own condition where that step is the consumer, and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where a package publish job's push is, since an `if:` carrying no status-check function inherits `success()` and skips on exactly the failed push that leaves the artifact already downloaded. Cleanup is best-effort, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. - -A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. `references/test-methodology.md` indexes `WORKFLOW.md` section 5's audit, trace, and probe procedure, and the sweep itself is run from section 5, which carries the whole core list, the per-type addenda, and the scenario table. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit -Workflow-only changes are not smoke-built, so run actionlint locally before pushing. Run it from the repository being checked, as `python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" --linter actionlint`, using the hub-hosted wrapper documented in `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working Invocations)" section. actionlint includes `shellcheck` for `run:` blocks, so `--linter actionlint` already covers them. A workflow change is still only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. +A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. diff --git a/.github/skills/workflow-ci-contract/references/architecture.md b/.github/skills/workflow-ci-contract/references/architecture.md new file mode 100644 index 00000000..9f8e6736 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/architecture.md @@ -0,0 +1,112 @@ +# The Pipeline Architecture + +The section below is `WORKFLOW.md` section 3, whole. The D-guarantees it cites by number are `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Architecture + + + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `` and ``, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names are branch-suffixed. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-` job for a package. An unchanged version re-pushes nothing (no-op republish). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-` / `pypi-build-`), and a separate `publish-` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index 31d837d5..c9e1f3e4 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,70 +1,86 @@ -# The D-Guarantees, Condensed +# The D-Guarantees -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. +The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a given repository is `WORKFLOW.md` section 1's applicability rule. The architecture these items govern is `WORKFLOW.md` section 3 and the methodology that checks them is `WORKFLOW.md` section 5, carried whole in `architecture.md` and `test-methodology.md` beside this file. -## D1: PR Fast-Feedback (Smoke) +## The Behavioral Contract -- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. -- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. -- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, the upload best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. The Python invocation, `pytest --cov-report=xml`, names a report format and selects nothing to measure, so a Python repo with tests, lint-only excepted, carries `pytest-cov` in a dev group, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repository root as `coverage.xml`. The hub validator's Python leg, which runs where that root carries `pyproject.toml`, `tests/`, and `uv.lock`, reds its test step when no such report was written. + -## D2: Validation at Entry +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. -- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. -- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). -- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. -- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. +### D1 - PR Fast-Feedback (Smoke) -## D3: Versioning and Classification +- **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), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. A filter written the other way round, as a negation of the paths that must not build, marks a docs-only change as a target change: it satisfies D1.4 and violates this item. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere and the `dotnet test` or `pytest` path only where that tree is present, so a non-.NET repo calls the same one rather than replacing it. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **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 on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *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 marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *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**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, 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 that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* -- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. -- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. -- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. -- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. -- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. +### D2 - Input/State Validation at Entry -## D4: Release and Publish +- **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, 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.* -- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. -- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch while the tip has not moved, since a dispatch names a branch rather than a commit and so builds that branch's tip at dispatch time. Once the tip has moved a re-dispatch builds the new tip instead, and NBGV deriving the version from git height makes that a further version, so the version whose push failed never reaches the registry. **Re-run all jobs** is the recovery there: GitHub replays under the original event's `GITHUB_SHA` and re-executes every job, and the publisher pins the build to that commit, so the same version is rebuilt, its package artifact rebuilt and re-uploaded rather than left missing by D5.2's delete, and its push retried, the release itself needing nothing from the re-run. Three bounds. D4.4's no-op re-run assumes the earlier push succeeded, so it does not describe this one. GitHub offers a re-run only within 30 days of the initial run. And **Re-run failed jobs** is unreliable rather than unavailable, D5.2's delete usually having taken the artifact its download needs while D5.3 leaves that delete best-effort. -- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. +### D3 - Versioning and Classification -## D5: Resource Cleanup +- **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`, and any other -> `X.Y.Z-g`. 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` 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.* -- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. -- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). -- **D5.4** Every `upload-artifact` sets `retention-days: 1`. -- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. -- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. +### D4 - Release / Publish -## D6: Seam Conformance +- **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 contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. 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 and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **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.* -- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. -- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. -- **D6.3** Artifact names are branch-suffixed. -- **D6.4** A target add or drop updates the whole surface together: `enable_` input, `build-` job, its `github-release` and `build-docker` `needs:` entries, paths-filter entry and output, the `smoke-build` enable-forward, and a package target's separate `publish-` job. +### D5 - Resource Cleanup -## D7: Concurrency, Permissions, Safety +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* -- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. -- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. -- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. +### D6 - Seam / Architecture Conformance -## D8: Bots and Automation +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* -- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. -- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. -- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. -- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. +### D7 - Concurrency, Permissions, Safety -## D9: Style and Static +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* -SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.github/skills/workflow-ci-contract/references/test-methodology.md b/.github/skills/workflow-ci-contract/references/test-methodology.md index d1e443cf..f7690a0a 100644 --- a/.github/skills/workflow-ci-contract/references/test-methodology.md +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -1,31 +1,59 @@ # Testing a Repo's Workflows -The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. +The section below is `WORKFLOW.md` section 5, whole. Its items and scenarios answer to the D-guarantees in `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. -## 5A: Static Audit +## The Test Methodology -Read the workflow files, `version.json`, and whatever else a check names as its own evidence: a project or dependency file, `global.json`, `codecov.yml`, `.gitignore`, the branch ruleset, and the repo's Actions and Dependabot secret names. Assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, cite a repository setting by its own name where that setting rather than a file is the evidence, and remember the two layers, asserting each input in the layer that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + -The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches coverage collection and its best-effort Codecov upload in every C# and Python repo that has tests at a profile other than `lint-only`, since a repo whose coverage never reaches Codecov passes every other check in this list. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. Those are entry points into `WORKFLOW.md` 5A's core list rather than the whole of it. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. -The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. +### 5A. Static Audit (No Execution) -## 5B: Trace Scenarios +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. -For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. -- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. -- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. -- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. -- **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. -- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. -- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `/@` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. -## 5C: Live Probe +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -Only for what a static trace cannot settle. Every probe that dispatches a workflow, re-runs a real publish, or acts on the deploy host directly is the maintainer's to run: the agent prepares the command and reads the result back afterwards, and a harness refusal to fire one is the control working, never something to re-shape. The probes are a trivial PR to confirm S1, which runs same-repo only wherever the repo has a Docker leg, since that leg logs in to the registry even on smoke, registry queries after a real publish, the version classification and artifact lifecycle read from a real publish's logs, and the deploy ref gate, which is verified only by tripping it. That gate's evidence is four items, the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded skipped rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref, because a gate that fails open and a gate nobody tripped leave the same empty run history behind. +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: -## Verdict +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | -Record the workflow operational when every applicable 5A item passes, every applicable 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. Any applicable mismatch is a defect. The verdict names the failing guarantees with the triggering input for each, the items recorded N/A, and the 5C probes prepared but not run, so a static-only audit and a fully probed one do not read alike. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + + diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 2d2535d7..e4870b03 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -198,7 +198,7 @@ jobs: # Install its QEMU emulator only when the build includes it. - name: Setup QEMU step if: ${{ contains(env.PLATFORMS, 'arm64') }} - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 with: platforms: arm64 diff --git a/.husky/pre-commit b/.husky/pre-commit index c29f8a39..97274cc6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -12,7 +12,7 @@ # `repo_gate.py --check sha-pin` is absent for a different reason. # It resolves same-owner pins against the GitHub API, and a hook needing a network fails offline. # The doc linters that need Docker stay in CI and in the VS Code Lint tasks. -set -e +set -eu # Git already runs a hook from the top level, measured by committing from `scripts/` and printing `pwd`. # This is belt and braces for an invocation that does not come from git. diff --git a/.pr_agent.toml b/.pr_agent.toml new file mode 100644 index 00000000..8e0fda2e --- /dev/null +++ b/.pr_agent.toml @@ -0,0 +1,20 @@ +# The two trees below are what scripts/build_dist.py generates from .agents/skills/ and CI holds current, so a finding in either belongs at its source and a review of every copy is one finding three times. +[ignore] +glob = ['.github/skills/**', '.claude-plugin/fleet-skills/**'] + +[review_agent] +# A crash claimed against a name the build accepts was disproven by running the build, so a claimed failure carries its reproduction or is not a finding. +issues_user_guidelines = "Report a crash, an exception, or a failing path only after reproducing it against the changed code, and state the reproduction in the finding." +# A rule finding is answered against the rule's own sentence, and a rule against text the pull request did not change is a note rather than a thread. +compliance_user_guidelines = "Quote the repository rule's own sentence in the finding, and report a rule against text this pull request did not change in the summary only." +# An informational finding opened a thread the merge ruleset then required resolved, which is a merge block for a note. +comments_routing_preset = "custom" + +[review_agent.comments_routing] +action_required = "both" +remediation_recommended = "both" +informational = "summary" + +[review_agent_ux] +# The severity badge is an image tag on the finding's title line, which hides the title from a text matcher. +use_images_and_animations = false diff --git a/AGENTS.md b/AGENTS.md index addacaa0..535f3a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file is the entry point every coding agent reads first, and it holds only three things: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) (a General section plus per-language sections for .NET, Python, and Shell, the language sections packaged as the `dotnet-codestyle`, `python-codestyle`, and `shell-codestyle` Skills), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). -Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** live in that project's own topical docs. They do **not** go in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), which targets GitHub Copilot and VS Code specifically. A rule a reviewer must honor therefore has to sit in an agent-agnostic file to be provider-independent. A section of its own in this file is not the alternative. An undeclared section here is drift to reconcile rather than a local liberty. +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A Skill that needs a rule's full text to work in isolation carries it as a generated include from the rule's home rather than as a copy, per the `skill-lifecycle` Skill, so the text cannot drift undetected. A project's **project-specific conventions and public-API/behavioral contracts** live in that project's own topical docs. They do **not** go in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), which targets GitHub Copilot and VS Code specifically. A rule a reviewer must honor therefore has to sit in an agent-agnostic file to be provider-independent. A section of its own in this file is not the alternative. An undeclared section here is drift to reconcile rather than a local liberty. ## Fleet Bootstrap @@ -97,7 +97,7 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md) un | Opening a pull request, or requesting, monitoring, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | | Reviewing a pull request, patch, or change set | No section of its own: the `code-review` Skill, which routes to the applicable general, language, documentation, and workflow skills | | Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | -| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, and this section and `WORKFLOW.md` keep the full rules | +| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, with that section keeping the style rules and `WORKFLOW.md` the contract | | Choosing an OS, runtime, or toolchain target | `Supported Development Platforms` | | The devcontainer | `Devcontainer` | | Editor settings and tasks | `Editor and Tasks` | @@ -112,4 +112,4 @@ Adding or changing a managed host tool is packaged as `add-host-tool`. It keeps Driving a pull request through its review loop, from a feature branch into `develop` and, when asked, on to a mergeable `develop -> main` promotion PR, disposing of every reviewer finding along the way per `pr-review-conduct`, is packaged as `drive-pr`, new content rather than a rule extracted from a section. Merging a ready promotion PR and dispatching the release it unblocks, refreshing this machine's installed Skills first when the repo is this hub, is `merge-and-release`, its own new-content package, invoked separately from `drive-pr` so the promotion merge and the release dispatch each keep their own explicit go-ahead. Working a whole open-issue backlog down by rounds, ranking the issues, grouping them so no two groups touch the same file, dispatching one subagent per group to drive its own pull request into `develop`, opening at most one `develop -> main` promotion pull request per round, and re-ranking from scratch afterwards because each round's reviews file new issues, is `backlog-burndown`, also new content rather than a rule extracted from a section. It orchestrates `drive-pr` rather than replacing it, and it scopes to the repository the session is in, and a fleet-wide issue sweep is a different request. -Running one read-only, adversarial review pass against a branch's current diff against its target branch, full file context included, on the strongest model tier the session can reach, before a unit of PR-bound work is pushed toward a pull request or claimed done, is packaged as `local-strict-review`, new content rather than a rule extracted from a section. `drive-pr`, `pr-review-conduct`, and `agent-conduct` each reference it at the moment they already govern, rather than restating what it does. The rule itself lives in [`GOVERNANCE.md`](./GOVERNANCE.md) "Verification Discipline", the hub-hosted `scripts/local_review.py` is the engine that records a pass so a capture point can check one, and a repository carrying a `.husky/pre-push` hook enforces it at the push itself, the skill staying the primary and agent-agnostic layer with the hook a bypassable backstop under it. That skill carries a second pass under the same rule, over canonical content this repository authors and others carry, read one whole unit at a time rather than as a diff, because a diff-scoped read leaves the first real review of a rule to whichever repository carries it next, which is the one repository that cannot act on what it finds. `scripts/canonical_review.py` is that pass's engine, and the backlog it has yet to reach is `reports/canonical-review.md` in the hub, not a repo-relative link here since that path is hub-local like the Skills tree above. +Running one read-only, adversarial review pass against a branch's current diff against its target branch, full file context included, on the strongest model tier the session can reach, before a unit of PR-bound work is pushed toward a pull request or claimed done, is packaged as `local-strict-review`, new content rather than a rule extracted from a section. `drive-pr`, `pr-review-conduct`, and `agent-conduct` each reference it at the moment they already govern, rather than restating what it does. The rule itself lives in [`GOVERNANCE.md`](./GOVERNANCE.md) "Verification Discipline", the hub-hosted `scripts/local_review.py` is the engine that records a pass so a capture point can check one, and a repository carrying a `.husky/pre-push` hook enforces it at the push itself, the skill staying the primary and agent-agnostic layer with the hook a bypassable backstop under it. That skill carries a second pass under the same rule, over canonical content this repository authors and others carry, read one whole unit at a time rather than as a diff, because a diff-scoped read leaves the first real review of a rule to whichever repository carries it next, which is the one repository that cannot act on what it finds. `scripts/canonical_review.py` is that pass's engine, and the units the pass has yet to reach are listed in the burn-down that engine's `report` renders from the hub's `reports/canonical-review.json`, not a repo-relative link here since that path is hub-local like the Skills tree above. diff --git a/AUDIT.md b/AUDIT.md index 43777731..7af0fb58 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -12,7 +12,7 @@ flowchart TD s0m["0m: fleet membership, every owned non-fork repo has a registry entry"] --> s0["0: has the repo been stood up? if not, STANDUP.md"] s0 --> s1["1: scope, ground-truth branch (main)"] s1 --> s2["2: resolve the repo's type(s)"] - s2 --> s3["3: applicability gate, per check"] + s2 --> s3["3: applicability gate, per item or check"] s3 --> s4["4: per-dimension checks, letter and intent"] s4 --> s5["5: assert Actions implement WORKFLOW.md"] s5 --> s6["6: validate settings, rulesets, secrets"] @@ -61,7 +61,7 @@ Otherwise read its `types[]`. If the entry is `classificationPending` (a backlog ## 3. Applicability Gate -Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. +Reuse [`WORKFLOW.md`][workflow] section 1, extended to this audit's own checks: an item or check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. @@ -94,11 +94,11 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the ## 5. Assert the Actions Implement WORKFLOW.md -Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: the 5A static audit (structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions, reading a workflow it only calls at the SHA it pins: the 5A static audit (structural facts per applicable D-guarantee, each cited in the form 5A sets out) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. Where a guarantee names a construct, D6.1's `release-asset--` and D9.2's ruleset-bound job `name:` among them, that name is the outcome and a divergence is a **defect** here. That is a separate judgment from the `verbatim` content hash section 0 describes, which classifies a mismatch as stale or modified and reports either at **drift**, since equivalence is intent-governed and a byte diff is a hint to review rather than a verdict. ## 6. Validate Settings, Rulesets, and Secrets -- **General settings and rulesets** - fetch the hub and check out `main`. Run `repo-config/configure.sh check / release|operational` from that checkout. Pass the target repository and its registry `workflowModel` explicitly. The command checks the shared settings, state-dependent settings, Dependabot security features, and both rulesets against the hub payloads. It preserves and reports `bypass_actors` without asserting them because bypass authority is a per-repository human decision. +- **General settings, labels, and rulesets** - fetch the hub and check out `main`. Run `repo-config/configure.sh check / release|operational` from that checkout. Pass the target repository and its registry `workflowModel` explicitly. The command checks what `configure.sh apply` writes: the declared settings, the derived settings and the registry description, the declared labels, the Dependabot security features, the shared `main` ruleset, and the `develop` ruleset the model selects. It preserves and reports `bypass_actors` without asserting them because bypass authority is a per-repository human decision. - **Secrets** - from the same hub checkout, run [`spec/audit.py`][audit-runner] `[repo]` and read its Secrets section. It resolves the required set from the hub's own [`spec/secrets.json`][secrets] plus the registry entry's `publish[]`/`types[]`/`requiredSecrets[]`, confirming each required name exists (name only, not the values) in the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. diff --git a/CODESTYLE.md b/CODESTYLE.md index 9e478e32..9fb9f30b 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -37,7 +37,7 @@ These apply repo-wide, in every directory: Markdown lints clean via `markdownlin *This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, and AOT-compatible project configuration. +The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, the runner declaration, package references and version floor that an MTP-based test project needs under `WORKFLOW.md` D1.6, with the local diagnostic for a run that reports no tests, and AOT-compatible project configuration. This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules, code examples, and mechanics. @@ -45,7 +45,7 @@ This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codes *This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* -The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions, and versioning. +The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions including the `pytest-cov` dependency and coverage selector a build-profile repo with tests owes under `WORKFLOW.md` D1.6, and versioning. This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules and the profile-adaptation guidance. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 100ccb38..5521bac7 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -17,9 +17,9 @@ The specific rules in this file implement a few governing principles. Read these - **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. - **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. -- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. ## Repository Boundaries and Write Safety @@ -71,8 +71,8 @@ dual-target bot wiring, and the operational-repo delta in full. The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. -Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or (Docker only) a -weekly schedule, and versioning is semantic and maintainer-controlled (NBGV owns the build number, +Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or a `main`-only +weekly schedule (Docker), and versioning is semantic and maintainer-controlled (NBGV owns the build number, the maintainer owns the `major.minor` floor). **Operational** repos differ, with a dispatch-only release and no auto-publish bots. See "Operational Repositories" below. @@ -80,7 +80,8 @@ This is packaged as part of the `operational-vs-release-workflow` Skill at `.agents/skills/operational-vs-release-workflow/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules, including the release-target build layer, the -no-op republish guarantee, and wrapper-repo upstream-version tracking. +no-op republish guarantee, the recovery routes for a package push that fails after the release is +already cut, and wrapper-repo upstream-version tracking. ## Operational Repositories @@ -175,14 +176,15 @@ ASD-STE100's structural half is the adopted house style: short sentences, one in The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. -- **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, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **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` "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, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. - **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. - **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. - **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. -- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per "PR Review Etiquette" below. -- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` writes and is never a block on unrelated work. Recording a pass writes two tracked files, that ledger and that burn-down, so where those two files land relative to the commit is a real ordering rather than a preference. Both are committed before the push, since a capture point that gates a push refuses a tree differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is reviewed the way a carrier reads it, whole, in the repo that can fix it.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and answers whether one still covers each unit a change moved or newly carried, so a capture point can refuse exactly those rather than trusting the rule to have been remembered. A unit edited today is therefore read today, while a unit nothing has read here yet is left to the burn-down that engine's `report` renders and is never a block on unrelated work. Recording a pass writes one tracked file, the engine's ledger, so where it lands relative to the commit is a real ordering rather than a preference. It is committed before the push, since a capture point that gates a push refuses tracked content differing from HEAD before it runs either gate, while the diff receipt above is not tracked and is recorded after the last commit instead. So the ledger goes in ahead of the commit that carries it and the receipt is written after that commit, which is why the two records sit on opposite sides of it. Which repos hold such a capture point at all is a separate question, and the rule binds whether or not one is installed. Like the pass above, this one is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. - **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. - **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. - **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. @@ -191,14 +193,14 @@ The checks that separate work actually done from work that merely reports succes - **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 checkout already sitting on disk is not yours to trust for being there.** A clone or worktree this session did not create, found while looking around a machine, may belong to another concurrent session's task, sit on a stale fetch or a branch nobody expects, or hold uncommitted edits nobody has reviewed, and none of that is visible from the directory listing that found it. Running `git status`, `git remote -v`, or `git branch --show-current` against it, or reading a file inside it, answers for whatever that checkout happens to hold at that moment, not for the repository, and the found checkout is not the "local clone" the bullet above means, since this session never fetched it and has no basis for trusting what it last saw. Clone the repository fresh into a location this session controls, or read the live state through the GitHub API, rather than adopting a pre-existing checkout as ground truth. -- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on: a `release`-model repo carries in-flight content on `develop`, per "Branching Model" above, well before it reaches `main`, so a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and name the branch the negative claim was checked against in the finding itself. +- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on: a `release`-model repo carries in-flight content on `develop`, per `GOVERNANCE.md` "Branching Model", well before it reaches `main`, so a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and name the branch the negative claim was checked against in the finding itself. - **A raw-file fetch 404s the same way for a private repository as for a genuinely missing file.** `curl`ing `raw.githubusercontent.com////` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. - **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. - **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. -- **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. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. ## PR Review Etiquette @@ -214,27 +216,27 @@ The provider-specific mechanics this contract needs to actually drive GitHub Cop - **Ask for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. - **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. -This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. ## Workflow YAML Conventions -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. +These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLOW.md) section 4 also states as guarantees, and not only at D9, so where it does, a violation of an *applicable* one is a defect that makes the workflow **not operational**, on the same terms as any other. Each D-item names its own constructs, so read section 4 for which rule binds where rather than a mapping kept here. The target-state framing below settles *when* an unswept workflow is fixed rather than *whether* its violation counts: new and modified workflows respect these rules now, and the rest of the repo is brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. -This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this section winning where the two overlap, and both are surfaced by the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. +An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. -- **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 project release 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 in lockstep with the job `name:` every surface whose staleness breaks enforcement, never one without the others, or required-status-check enforcement silently breaks. Those are the live ruleset, the hub's `repo-config/` payloads, the hub's `spec/files.json` `requiredCheckName`, and each adopter-facing stub, the one in the hub's `catalog/` and the release-with-smoke shape in the hub's `docs/reusable-workflows.md` alike. Prose naming the old string elsewhere goes stale rather than breaking, and follows behind. 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) keys the group on the **PR number** (`-${{ github.event.pull_request.number }}` rather than `-${{ github.ref }}`, which under `pull_request_target` is the base branch and would serialize every bot PR against it), and uses `cancel-in-progress: false` because the merge-bot's 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) `.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". +- **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. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike. 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**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix, ending instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions//action.yml`), so these suffix rules do not reach them. +- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`), matching the filename rule above and covering a file that is also dispatchable, and every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee: a called reusable workflow's jobs appear nested inside the caller's run as ` / `, and the runs list shows the caller's workflow name rather than the callee's own. +- **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 trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads, its `spec/files.json` `requiredCheckName`, and each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`, which exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind. +- **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. `cancel-in-progress: false` queues instead of cancelling, and queuing is not ordering: GitHub holds at most one pending run per group and cancels the previously pending one when a newer run queues, so the guarantee it buys is that a **running** job finishes rather than that every event runs in arrival order. **Documented exceptions**, each recording its rationale inline in its own header comment: (1) a merge-bot workflow keys the group on the **PR number** (`-${{ github.event.pull_request.number }}` rather than `-${{ github.ref }}`, which under `pull_request_target` is the base branch and would serialize every bot PR against it) and takes `cancel-in-progress: false`, because cancelling mid-flight would leave auto-merge enabled or disabled inconsistently (`.github/workflows/merge-bot-pull-request.yml`). (2) A publisher uses a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) with `cancel-in-progress: false`, because it publishes shared ref-independent outputs (both branches' Docker tags and caches, and GitHub releases) and its triggers need not agree on a ref, so a ref-scoped group would let two runs double-push, and cancelling one can leave a partially pushed tag set or a half-created release (`.github/workflows/publish-release.yml`). (3) A deploy workflow keys the group on the **environment** it deploys with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and the pointer unflipped. No workflow in this repository implements it, the deploy task being reusable rather than top-level, so the rationale lives here rather than in a header comment. +- **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. A deliberately POSIX `#!/bin/sh` surface, a git hook that must run before any toolchain exists being the case in practice, is not a bash surface: it takes `set -eu`, dropping `-E` and `pipefail`, which `sh` does not carry. +- **Conditionals**: multi-line `if:` uses the folded scalar `if: >-`, which joins the wrapped source lines back into one line. `WORKFLOW.md` D9.3 requires it. A literal block (`if: |`) evaluates the same, the expression lexer skipping newlines along with other whitespace, so this is a legibility rule rather than a correctness one, and it binds as a guarantee regardless. +- **Boolean inputs**: a workflow triggered both via `workflow_call` and `workflow_dispatch` declares each boolean input in *both* trigger blocks, since one declaration does not propagate to the other. Which context reads it then decides the comparison, and the two are not the same. The `inputs` context **preserves the declared boolean** on both paths, so `if: ${{ inputs.foo }}` is read directly. The `github.event.inputs` context delivers **every** input as a string whatever its declared type, so a read through it is compared against `'true'`. Comparing a `github.event.inputs` read against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.foo == true` is false even on the run where the input arrived as `true`. A both-forms comparison on an `inputs` read is merely redundant. `WORKFLOW.md` D7.3 is the contract this bullet's rationale serves, and wins on any disagreement. +- **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** at entry, before any expensive build or publish work, rather than as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Where later **jobs** depend on the assertion, it is a job of its own that they `needs:`, since `needs:` takes job ids and cannot name a step. Where the work it guards is in the same job, an entry step in that job is enough. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions), and the input-validating entry step in `publish-docker-readme-task.yml`'s own first job, which also resolves the repository list, so a consumer of it depends on that job rather than on the validation alone. - **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`. So declare an inner block only where **every** caller grants that scope at startup, and otherwise omit it and run under the calling job's grant, declaring the scope at the call site. -- **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')`, and pair it with a status-check function such as `always()` or `!failure() && !cancelled()`. An `if:` carrying no such function has `success()` applied implicitly, and that implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release or the registry 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, under the **condition that made it redundant**, which is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's: the `github-release` job deletes `release-asset--*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`, so a no-op re-run that skips the create skips the delete with it and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's: a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job under `if: ${{ !cancelled() && steps..outcome == 'success' }}`, because the artifact is redundant once it has been downloaded and the release cut, whether or not the push that followed succeeded. A delete left to the implicit `success()` would skip on exactly that failed push, and the `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names, reached there by a skipped `needs:` job and here by a failed prior step. 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-` 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 (`:buildcache-` 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. +- **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')`, and pair it with a status-check function. An `if:` carrying no such function has `success()` applied implicitly, and that implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. Either `always()` or `!failure() && !cancelled()` serves, the explicit `success`/`skipped` allowlist beside it being what excludes a failed or cancelled dependency either way. They differ on a cancelled **run** and on a failed sibling `needs:` job, both of which `always()` still runs through. That is why `WORKFLOW.md` D1.5 requires `always()` of the pull request aggregator, which has to report a failed or skipped dependency rather than skip with it. +- **Artifact retention**: an explicitly uploaded workflow artifact is an intra-run handoff only, so it 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, under the **condition that made it redundant**, which is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's: the `github-release` job deletes `release-asset--*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`, so a no-op re-run that skips the create skips the delete with it and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's: a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job under `if: ${{ !cancelled() && steps..outcome == 'success' }}`, because the artifact has served its handoff once it has been downloaded, whether or not the push that followed succeeded. Recovering a failed push is a rebuild rather than a re-download, and "Release Model" above routes to what that costs. A delete left to the implicit `success()` would skip on exactly that failed push, and the `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names, reached there by a skipped `needs:` job and here by a failed prior step. 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, and which the `retention-days: 1` backstop below never reaches, since it is set on an explicit upload step and an auto-emitted record has none, so they fall back to the repository's own retention period. 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 and from a registry tag (`type=registry`, e.g. `:buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. The cache is **asymmetric**: `cache-to` writes only on a push and only to the branch being built, so a pull request smoke run writes nothing at all and a `develop` publish writes only `buildcache-develop`, while `cache-from` reads both branches so a first build on a new branch still hits. A **multi-image** repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since one tag cannot distinguish two images. 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). ## Running the Linters Locally (Known-Working Invocations) @@ -247,7 +249,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i - **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub-fetch-run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. Neither snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the two shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. - **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to the working tree diff against `HEAD` rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. That scope is the working tree rather than the staged index under a plain git hook, so a partially staged file is judged on all of its edits. Under the `pre-commit` framework it is the staged state instead, since that runner stashes unstaged changes before running a hook, which is measured rather than assumed. CI re-checks the whole tree regardless, which is what makes the scope affordable in a hook either way. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the doc gates make, one per fetched script. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or (`uv tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. -- **The hub carries a second hook, `.husky/pre-push`, gating the local-review rule rather than lint.** It runs `scripts/local_review.py check` and `scripts/canonical_review.py check`, refusing a branch push whose diff no recorded review pass covers and one whose changed canonical units none covers, per "Verification Discipline" above. Both run before either verdict is read, so a blocked push names every reason it was refused rather than only the first, which would otherwise cost a second push to discover the next. It fires only for a branch update, so a tag push and a branch delete pass through, and a branch holding no net content against its target has nothing for a review to cover. It refuses rather than guesses in every state it cannot speak for, since the engine reads the checkout it runs in while a push delivers a commit, and those describe the same content only sometimes. The order that keeps them describing the same content is to run the carried-content pass and record it, commit that together with the change, then review the diff, record its receipt, and push. What the two gates actually require is narrower than that sequence, since the canonical ledger and burn-down are tracked and need only be committed before the push, while the diff receipt is not tracked and must be recorded after the last commit. The sequence above is the shortest one meeting both, which is why every capture point's wording assumes it. It reads `develop` and accepts no override from the environment, since an environment variable is set inline on the very command being gated, by whoever is being gated, which is the same reason an authorization is never read from a channel the agent itself can set. The cost is that it measures a branch based on anything but `develop` against `develop` anyway, so its refusal there is not a verdict about that branch. Each refusal names its own case, and the `local-strict-review` Skill carries the fleet's one enumeration of them with what clears each, deliberately in one place rather than restated here. It folds the engine's three-valued exit honestly: a check that could not run blocks too, and says so in different words than a check that ran and found no pass, because a gate that waves a push through when it could not run is a gate that stopped gating. It is a backstop rather than a seal, and it is bypassable by construction: `--no-verify` is the documented route out of a pickle and is not the only one, since a git hook cannot police its own invocation. A Claude Code session meets a narrower surface, that flag being denied unconditionally by the agent-safety hook's explicit-bypass rule, and that denial is Claude Code's alone, since Codex and opencode carry no such hook yet. So the committed hook raises the cost of skipping the rule for one agent and lowers it for none, and the prose layer above stays the agent-agnostic one that actually binds. It is hub-only for now. `local_review.py` is hub-hosted per "Hub-Hosted Tooling", and a downstream repo reaches it as a hub checkout's copy run with its own worktree as the working directory, so a catalog snippet carrying this hook fleet-wide is a later step rather than part of this one. `canonical_review.py` is hub-hosted too and is not reached that way at all: it reads `spec/files.json` to learn what is carried, `spec/` is hub-hosted rather than carried, and run against a downstream worktree it exits 2 saying that tree describes no carried set. It runs in the repository that authors the content, which is this one. The canonical-unit half is also run by the hub's own `.github/actions/validate` composite action, as a step on every pull request, which is where it actually binds, since a hook a push can bypass raises the cost of skipping the rule without ever settling it. That step is scoped to a pull request because a unit's change is measured against the branch it is proposed into, and a push carrying no pull request names none. +- **The hub carries a second hook, `.husky/pre-push`, gating the local-review rule rather than lint.** It runs `scripts/local_review.py check` and `scripts/canonical_review.py check`, refusing a branch push whose diff no recorded review pass covers and one whose changed canonical units none covers, per "Verification Discipline" above. Both run before either verdict is read, so a blocked push names every reason it was refused rather than only the first, which would otherwise cost a second push to discover the next. It fires only for a branch update, so a tag push and a branch delete pass through, and a branch holding no net content against its target has nothing for a review to cover. It refuses rather than guesses in every state it cannot speak for, since the engine reads the checkout it runs in while a push delivers a commit, and those describe the same content only sometimes. The order that keeps them describing the same content is to run the carried-content pass and record it, commit that together with the change, then review the diff, record its receipt, and push. What the two gates actually require is narrower than that sequence, since the canonical ledger is tracked and need only be committed before the push, while the diff receipt is not tracked and must be recorded after the last commit. The sequence above is the shortest one meeting both, which is why every capture point's wording assumes it. It reads `develop` and accepts no override from the environment, since an environment variable is set inline on the very command being gated, by whoever is being gated, which is the same reason an authorization is never read from a channel the agent itself can set. The cost is that it measures a branch based on anything but `develop` against `develop` anyway, so its refusal there is not a verdict about that branch. Each refusal names its own case, and the `local-strict-review` Skill carries the fleet's one enumeration of them with what clears each, deliberately in one place rather than restated here. It folds the engine's three-valued exit honestly: a check that could not run blocks too, and says so in different words than a check that ran and found no pass, because a gate that waves a push through when it could not run is a gate that stopped gating. It is a backstop rather than a seal, and it is bypassable by construction: `--no-verify` is the documented route out of a pickle and is not the only one, since a git hook cannot police its own invocation. A Claude Code session meets a narrower surface, that flag being denied unconditionally by the agent-safety hook's explicit-bypass rule, and that denial is Claude Code's alone, since Codex and opencode carry no such hook yet. So the committed hook raises the cost of skipping the rule for one agent and lowers it for none, and the prose layer above stays the agent-agnostic one that actually binds. It is hub-only for now. `local_review.py` is hub-hosted per "Hub-Hosted Tooling", and a downstream repo reaches it as a hub checkout's copy run with its own worktree as the working directory, so a catalog snippet carrying this hook fleet-wide is a later step rather than part of this one. `canonical_review.py` is hub-hosted too and is not reached that way at all: it reads `spec/files.json` to learn what is carried, `spec/` is hub-hosted rather than carried, and run against a downstream worktree it exits 2 saying that tree describes no carried set. It runs in the repository that authors the content, which is this one. The canonical-unit half is also run by the hub's own `.github/actions/validate` composite action, as a step on every pull request, which is where it actually binds, since a hook a push can bypass raises the cost of skipping the rule without ever settling it. That step is scoped to a pull request because a unit's change is measured against the branch it is proposed into, and a push carrying no pull request names none. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. diff --git a/OPERATIONS.md b/OPERATIONS.md index bbfb3243..fe3c719d 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -28,7 +28,6 @@ uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/age uvx coverage@latest report python3 scripts/build_dist.py --check python3 scripts/canonical_review.py check -python3 scripts/canonical_review.py report --check python3 scripts/repo_gate.py python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path python3 scripts/prose_lint.py . --check charset-unknown --summary @@ -37,7 +36,7 @@ python3 spec/validate.py python3 scripts/docker_lint.py ``` -`report --check` is read-only and runs on every event in CI, where the coverage check beside it runs only for a pull request, because a stale burn-down is a property of the commit rather than of a comparison against a base. It also carries `!cancelled()`, so an earlier failing step does not skip it and one run names both verdicts. The local block above has no such arrangement: it runs under `set -Eeuo pipefail`, so a failing `check` stops it there and the gates below never run, and reaching a second verdict means fixing the first or running the later command on its own. It fails where the committed report no longer describes the ledger and the tree, which a deleted unit produces while every other gate stays green, since deleting one changes no recorded digest and leaves `check` covered. Renaming a section of a file the manifest carries by name, meaning `AGENTS.md` or `GOVERNANCE.md`, does the same. Renaming one in a file carried whole does not, since `check` then names the new unit and demands a pass for it. `python3 scripts/canonical_review.py report` rewrites it. +`python3 scripts/canonical_review.py report` renders the burn-down from the ledger to standard output, and CI writes the same rendering to the run's job summary. The local block above runs under `set -Eeuo pipefail`, so a failing gate stops it there and the gates below never run, and reaching a second verdict means fixing the first or running the later command on its own. The canonical-review check sits in CI's own list only for a pull request, since a canonical unit's change is measured against the branch it is proposed into and a push carrying no pull request names none. The local run above takes the default target, `develop`, which is the same measurement for an ordinary feature branch and the wrong one for a branch based on `main`, where it needs `--target main` to mean anything. @@ -88,7 +87,7 @@ repo-config/configure.sh apply owner/repo release|operational `check` is read-only and exits non-zero on drift. `apply` is idempotent and drives entirely from the committed payloads, so it is a no-op on a conformant repo. -`apply` is not a narrow toggle. One run patches every key in `repo-config/settings.json`, sets the default branch, enables both Dependabot features, and creates or updates both branch rulesets. On a repository that has deliberately drifted it silently reasserts the fleet configuration. +`apply` is not a narrow toggle. One run patches every key in `repo-config/settings.json`, sets the default branch, enables both Dependabot features, creates or updates every label in `repo-config/labels.json`, and creates or updates both branch rulesets. On a repository that has deliberately drifted it silently reasserts the fleet configuration. The model argument selects which develop payload is applied, so passing the wrong one applies the wrong ruleset. diff --git a/STANDUP.md b/STANDUP.md index 27e33f11..c19eba56 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -207,7 +207,7 @@ Three conditions fail here, and the two commands together are what separate them Each is step 0A's escalation rather than something to work around. -Run `repo-config/configure.sh apply owner/repo release|operational` from a hub checkout at `main`, naming the repo being stood up and its model, to apply the fleet settings, Dependabot security features, and two rulesets idempotently (import the JSON, never hand-build it, per [`docs/repo-config.md`][repo-config-doc]). Then run `repo-config/configure.sh check owner/repo release|operational` from the same checkout. Pass the model explicitly because the repository is outside the registry during this step. Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s), meaning Actions plus Dependabot where the mechanism needs it, and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once, which is why this step follows step 3 rather than preceding it. A ruleset requiring a name no run has ever reported leaves the first pull request waiting on a status nothing produces, and on an operational repo the `develop -> main` promotion is a pull request too, so the same wait applies there. +Run `repo-config/configure.sh apply owner/repo release|operational` from a hub checkout at `main`, naming the repo being stood up and its model, to apply the fleet settings, Dependabot security features, label set, and two rulesets idempotently (import the JSON, never hand-build it, per [`docs/repo-config.md`][repo-config-doc]). Then run `repo-config/configure.sh check owner/repo release|operational` from the same checkout. Pass the model explicitly because the repository is outside the registry during this step. Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s), meaning Actions plus Dependabot where the mechanism needs it, and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once, which is why this step follows step 3 rather than preceding it. A ruleset requiring a name no run has ever reported leaves the first pull request waiting on a status nothing produces, and on an operational repo the `develop -> main` promotion is a pull request too, so the same wait applies there. For a **private** repo, confirm the account-wide toggle at `https://github.com/settings/security_analysis`, `Dependabot on self-hosted runners`, is off, along with `Automatically enable for new repositories` beside it. If self-hosted routing is wanted instead, register a matching self-hosted runner rather than disabling the toggle. Left on with no self-hosted runner registered on the account, Dependabot's own update jobs queue for up to 24 hours, then get cancelled. That cancelled-with-zero-steps pattern is the only Actions-API-visible signal, not an explicit cause, and ordinary CI is unaffected. The account-setting root cause surfaces only as a `Self-hosted runner unavailable` message on the repo's own Dependabot page. GitHub never routes a public repo through this setting, so a public standup is unaffected (ptr727/ProjectTemplate#1015). A repo standing up from a **partial state** may already carry queued or cancelled jobs from before this check ran. Fixing the toggle does not rerun those. A manual `Check for Updates` click on the repo's own Dependabot page does. diff --git a/WORKFLOW.md b/WORKFLOW.md index 60b81313..8f232812 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,50 +1,37 @@ # WORKFLOW.md -The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under `.github/workflows/`. +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**, the workflow style rules having their home in `GOVERNANCE.md`. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for the pipeline those workflows implement, which reaches past `.github/workflows/` to every file and repository setting a guarantee names, `version.json` and a branch ruleset's `context:` string among them. -Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs**, not when it matches a reference implementation byte for byte. The conventions in section 2 keep workflows legible. The contract in section 4 is what they must *do*. +Its defining principle: **it describes required outcomes.** Two repos may implement the same guarantee with different YAML wherever that guarantee names no construct, and where one is named, D6.1's `release-asset--` and D9.2's ruleset-bound job `name:` among them, matching it **is** the outcome. Section 4 is what a workflow must satisfy, and the verdict below is how that is judged. The style conventions that section 2 points at are part of that contract wherever section 4 states one as a guarantee, and a violation of one that it does is a defect on the same terms as any other. -Given this document, an agent must be able to do three things to any project: +Given this document and the `GOVERNANCE.md` sections it points at, an agent must be able to do three things to any project: -1. **Audit** - statically check the workflows against the conventions (section 2) and the structural facts each guarantee implies (section 5A). +1. **Audit** - statically check the structural fact each applicable guarantee implies, the style conventions among them (section 5A). 2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). -3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds and every *applicable* scenario's observed output equals the expected) or **not operational** (any mismatch, which is a *defect*, not a style nit). +3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds, every *applicable* scenario's predicted output equals the expected, and no 5C probe that was run contradicts either) or **not operational** (any *applicable* mismatch, which is a *defect*). -> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `GOVERNANCE.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap. Section 2 restates them so this file reads on its own. On any conflict in that overlap, `GOVERNANCE.md` wins. +> **Canonical scope.** An overlap between this document and `GOVERNANCE.md` resolves **by subject**, never by blanket precedence. This document wins on the applicability and N/A rules (section 1), the pipeline architecture (section 3), the contract (section 4), the test methodology (section 5), and the per-project-type walkthroughs (section 6). `GOVERNANCE.md` wins on the workflow style conventions, at its "Workflow YAML Conventions", which section 2 points at rather than restating; on the release policy, at its "Release Model"; on the branching model, at its "Branching Model"; and on what an operational repo is, at its "Operational Repositories". Section 3 summarizes those last three rather than owning them. Each file names where it defers. The guarantees are distilled from failures observed in practice. Section 4's preamble states how each item is written. ## 1. Purpose and How to Use This Document -- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos, but the input/output behavior may not. -- **Applicability.** A guarantee (or a 5A check, or a 5B scenario) is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type. A near-empty pipeline (source-only) is mostly N/A and that is fine. -- **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. -- **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (section 5A). -- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version and release jobs) and a **build-leaf** layer (the `build-` tasks, whether separate files or jobs inside the release task). Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf receives `ref`/`branch`/`smoke` and whatever else its own target needs, a derived `push` among them where that leaf pushes. A package target declares no push input on either layer, because section 3's `Output Seam by Destination` puts its push in a `publish-` job in the publisher, gated by `needs:` rather than by a flag. When a check names an input, assert it in the layer that declares it. +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos wherever no guarantee names them, and where one does, as D9.2 does for the ruleset-bound job `name:`, that name is itself the outcome. +- **Applicability.** A guarantee, or a 5B scenario, is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which constructs each project type adds: start from the constructs every repo has, its pull request workflow among them, **union what every declared type adds**, and record an item N/A only where nothing in that union supplies its construct. Which triggers a construct carries is read from the workflow itself rather than from the type, per section 6. Owning the release task rather than calling the hub-hosted copy changes where a construct's evidence is cited, per 5A, never whether it is applicable. A near-empty pipeline (source-only) is mostly N/A and that is fine. +- **Operational is binary.** A workflow is operational only where section 5's Assessment records all three of its conjuncts met. A single applicable failure is a defect and makes the workflow **not operational**, whether it is an input/output mismatch or a static property of the committed source such as an unpinned action SHA, regardless of how clean the YAML looks. +- **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (D3.2). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version and release jobs) and a **build-leaf** layer (the `build-` tasks, whether separate files or jobs inside the release task). Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf receives `ref`/`branch`/`smoke` and whatever else its own target needs, a derived `push` among them where that leaf pushes. A package target declares no push input on either layer, because section 3's `Output Seam by Destination` puts its push in a `publish-` job in the publisher, gated by `needs:` rather than by a flag. Assert an input a guarantee names in the layer that declares it. - **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. ## 2. Workflow Style Conventions -Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). - -- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would downgrade (here, `dotnet/nbgv@master`). Invent no others. -- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. -- **Workflow `name:`.** Reusable names end in **"task"**. Entry-point names end in **"action"**. -- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**, including a ruleset-bound required-check job, whose `name:` and the ruleset `context:` are one string renamed together (never independently). -- **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). -- **Shells.** Every multi-line bash `run:` (and every committed `.sh` script) starts `set -Eeuo pipefail`. -- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. -- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks, and `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege. A reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. -- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through), and pair it with a status-check function (D7.4). -- **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. -- **Line endings.** Workflow YAML is LF (Actions and Dependabot rewrite it that way). Other files follow `.editorconfig`, and committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and this section points at it rather than restating it. Workflow YAML takes the same line-ending policy as every other file, which `GOVERNANCE.md` "Documentation Style Conventions" routes to under "Line Endings". Read the style rules before editing a workflow. Section 4 carries several of them as guarantees of its own, and where it does, a violation is a defect rather than a nit. They are not sufficient on their own: a perfectly styled workflow can still violate the guarantees they do not cover. ## 3. Architecture ### Branch Model -Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline this document specifies: +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: ```mermaid flowchart LR @@ -62,15 +49,15 @@ flowchart LR develop -->|merge commit, enforced lint CI| main ``` -The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in [GOVERNANCE.md "Operational Repositories"][governance-operational-repositories], which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (Section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. -Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees below that assume a build/test pipeline are **N/A** exactly as for `source-only` (Section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in [GOVERNANCE.md "Branching Model"][governance-branching-model], not here. +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. ### Two Layers: Orchestration vs Build - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). ### The Seam Contract @@ -141,11 +128,13 @@ Pick each output's path by **where the artifact goes**: - **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-` / `pypi-build-`), and a separate `publish-` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. - **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. - **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). -- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see Section 6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. ## 4. Behavioral Contract: Expected Outcomes -The required behaviors, organized by domain. Each is a **MUST**, stated as the output a conforming pipeline produces. An item carries an `Input:` only where the guarantee applies to a particular trigger or state rather than to every run, and a *Prevents:* clause only where the failure it rules out is not evident from the output itself. An item carrying neither still binds every repo whose shape its domain covers. A workflow that violates any *applicable* guarantee is **not operational**. +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) @@ -154,7 +143,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *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 marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *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**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, 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 --coverage --coverage-output-format cobertura --results-directory ./coverage` or `pytest --cov-report=xml` over a repo whose own pytest configuration selects what to measure) 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** and **dependabot** secret stores, the second because a run triggered by a Dependabot PR reads the Dependabot store and the upload would otherwise skip silently on every bot PR, and a caller passing it names it (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), the way every hub task's declared secrets are passed, since `secrets: inherit` is documented for a caller in the same organization or enterprise and this fleet is a personal account, so a cross-repository call names each secret it passes. A call by local path stays inside one repository and may inherit instead. The publisher stub's validation job names the secret and every pull request stub's validation job passes no `secrets:` key at all, so a repo whose coverage must reach Codecov from its pull requests adds the mapping there itself. Required for **every** C# and Python repo that has tests. That C# invocation runs under **Microsoft.Testing.Platform**, which an MTP-based test project on the .NET 10 SDK and later requires, since running one through the VSTest target fails outright. A repo whose test project is MTP-based, in practice any repo on xunit.v3 4.0.0 or later, therefore also ships a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, references **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later** in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, and drops `xunit.runner.visualstudio`, the VSTest adapter MTP replaces. A repo whose test project is not yet MTP-based keeps the VSTest collector and its existing pin on the reusable validator, and that lagging state is a migration still owed rather than drift, until its own bump makes the project MTP-based and forces the move. The version floor is load-bearing rather than cautionary. Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform xunit.v3 4.0.0 carries, runs zero tests, and still writes a well-formed Cobertura file reporting full coverage, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. Two details of the invocation are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a repo with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match. The validator therefore prefixes each report to `coverage-.cobertura.xml` before the upload step reads the directory. The Python invocation carries a load-bearing detail of its own, an omission rather than a collision: it names the report format and selects nothing to measure. `pytest-cov` reports on what `--cov` selects, so `--cov-report=xml` on its own measures nothing, writes no file, and exits zero, which the best-effort upload then reads exactly as it reads a healthy run. A Python repo with tests therefore declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repo is a uv project and a `requirements*.txt` entry where it is on pip, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repo root as `coverage.xml`, already the one path the upload step names. The validator **fails the test step when that file was not written**, since nothing downstream of it can tell an absent report from an uploaded one, so a repo that redirects the report through `[tool.coverage.xml]` reds the gate rather than uploading nothing from a green run. That step sits in the hub validator's Python leg, which runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest the leg can install from, being a committed `uv.lock` or a root `requirements*.txt`. Those are the two dependency mechanisms the hub's `spec/project-types.json` names, and the leg reads the tree for either rather than for the lockfile alone, so a pip-based Python repo with tests is served here rather than skipped. Tests are what this guarantee turns on: a repo carrying no tests for that type owes no coverage whatever its dependency mechanism, since a token and a `codecov.yml` would then gate on a report nothing produces. A `lint-only` profile for that type (per the hub's `registry/repos.json`) owes none either, whatever tests it carries, since nothing there is built or packaged to report on. Where the guarantee does not apply, the hub's `spec/secrets.json` `typeMechanisms` mapping is not claimed for that repo, and the absence is not drift. 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/`, `coverage.xml`, 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 test project stranded on a runner the current SDK refuses, a stale and unused token, a coverage regression blocking an unrelated PR, and a coverage artifact committed by a blanket add.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry @@ -177,13 +166,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. 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 and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker push. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. What no gate covers is a failed **push**, because that job runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason section 3's package-registry bullet gives. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file therefore leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a re-run rather than a cleanup, and which of the two applies turns on whether the branch tip has moved. A dispatch names a branch, `main` or `develop` per D2.3, and never a commit, so what it builds is that branch's tip at dispatch time. A re-dispatch therefore refreshes the failed version's release (D4.4) and runs its push again while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip instead. NBGV derives the version from git height, so that is a further version, and the version whose push failed never reaches the registry. **Re-run all jobs** (`gh run rerun `) is the recovery there. GitHub replays a run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones, the publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the version from the same commit and history and each build leaf checks out the `GitCommitId` `get-version` emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release needs nothing from that re-run, the failed run having already cut it, so whether D4.4's release-create step refreshes or skips does not bear on the recovery. What no route settles in advance is whether the registry accepts the retried push. Three qualifications come with **Re-run all jobs**. D4.4 and 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one, so this recovery is the case they do not cover and its retried push is the first the registry ever receives for that version. GitHub offers a re-run only within **30 days** of the initial run, past which a moved tip leaves that version with no route at all. And **Re-run failed jobs** (`--failed`) is unreliable here rather than unavailable. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, and it removes the package artifact a `--failed` re-run would download. D5.3 leaves that delete best-effort, so the artifact survives where that delete ran and failed, and `--failed` works in that case alone. +- **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 and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* - **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 - **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* -- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is the re-dispatch or the full re-run D4.5 names, and D4.5 sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* - **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* - **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. - **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* @@ -191,84 +180,72 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o ### D6 - Seam / Architecture Conformance -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset--`. Canonical for single-target. +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. - **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. - **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. -- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for a package target, the separate `publish-` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* ### D7 - Concurrency, Permissions, Safety - **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* - **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* - **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* -- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* ### D8 - Bots / Automation - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. -### D9 - Style / Static (See Section 2) +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). - **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. - **D9.5** Line endings follow `.editorconfig`. +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + ## 5. Test Methodology -An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. ### 5A. Static Audit (No Execution) -Read the workflow files, `version.json`, and whatever else a check below names as its evidence: a project or dependency file, `global.json`, `codecov.yml`, `.gitignore`, the branch ruleset, and the repo's Actions and Dependabot secret names. Assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation, and cite a repository setting by its own name where that setting rather than a file is the evidence. Remember the two layers: assert each input in the layer that declares it. +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. -**Core (every repo):** +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. -- **D1:** a `changes` paths-filter job exists wherever the repo has a smoke build, with one entry per target naming the paths that target is built from, so a change touching no target marks nothing (a filter written as a negation instead marks a docs-only change and fails D1.1); the PR entry workflow's smoke call sets every publish flag its release task declares to false (`github`/`dockerhub`, and a package-push flag there is itself a finding, per section 1); a pushing leaf receives `smoke: true` and a derived `push` (false on smoke), and a build-only leaf receives `smoke: true` with no `push` to derive; every `upload-artifact` the smoke call reaches, in a build task and in any job collecting other jobs' artifacts alike, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings; the aggregator runs under `if: always()`, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, blocks on `failure`/`cancelled`, and passes on a **skipped smoke build**, so a no-build repo's aggregator, having only the validation job to read, requires that job to have succeeded; the aggregator's own job `name:` is the string the branch ruleset's required-check `context:` carries; a validation job runs unconditionally. -- **D1.6:** the validator the repo's validation job reaches collects coverage and uploads it, in every C# and Python repo that has tests. Its C# leg runs `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, with `--coverage-output` unset, wherever the repo's test project is MTP-based. An MTP-based repo also ships a root `global.json` declaring the Microsoft.Testing.Platform runner, references `Microsoft.Testing.Extensions.CodeCoverage` at 18.9.0 or later in place of `coverlet.collector`, and carries no `xunit.runner.visualstudio`, while a repo still on the VSTest collector keeps its existing validator pin, a migration owed rather than drift. Its Python leg runs `pytest --cov-report=xml`, and the repo declares `pytest-cov` among its test dependencies, a dev dependency group in a uv project and a `requirements*.txt` entry on pip, selects the coverage source in its own `pyproject.toml` rather than leaving `--cov` unset, and leaves the report at the repo root as `coverage.xml`. Either way the report reaches a `codecov/codecov-action` step made best-effort by `continue-on-error` and/or `fail_ci_if_error: false`, and `CODECOV_TOKEN` is present in **both** the repo's Actions and its Dependabot secret names, the second because a Dependabot-triggered run reads that store and the upload otherwise skips silently on every bot pull request. `codecov.yml` sets the project and patch statuses `informational: true`, or names the threshold the repo enforces instead, and lists any intentionally-untested, non-shipped project under `ignore`. `.gitignore` excludes the coverage output. Record the whole item N/A for a repo with no tests, and for a type the audited repo carries at the `lint-only` profile. -- **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`), 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. `version.json` sets the major.minor floor, and NBGV and `version.json` are both retained even by a repo with no compiler, since they own the tag (D3.3). NuGet.org derives the prerelease flag from the SemVer2 `-g` suffix rather than the workflow setting one, and the PyPI version is built from `AssemblyFileVersion` with `.dev0` appended on `develop` only (D3.4). -- **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 carries that same condition, narrowed by `inputs.expect_release_assets`. Where `workflow_dispatch` is the publisher's only trigger (`releaseTrigger: dispatch-only`) every run is a dispatch, so the exists-check's skip leg can never fire: record that leg N/A rather than failed, and expect the release-create step to still carry the `exists == 'false' || github.event_name == 'workflow_dispatch'` condition, since D6.4 keeps the `github-release` job body verbatim. A caller with no file target passes `expect_release_assets: false`, which covers a Docker-only, a PyPI-only, and a source-only repo alike, while a NuGet-only caller keeps the default `true` because its leaf uploads a `release-asset-*` carrying the package, and a source-only caller also sets every `enable_*` input false. `github-release` and the terminal registry pusher (Docker) each carry `!failure() && !cancelled()` rather than the implicit `success()`, so a failed build skips both, while a target that merely skipped, being disabled or unchanged, still lets the release and the Docker push proceed, and a package target's separate publish job `needs:` the release-task call for the same reason (D4.5). A first `plan` job decides once whether the run publishes, admitting a code-affecting bot push to `main`, a dispatch of `main` or `develop`, and a `main`-only schedule, and every publishing job gates on that decision (D4.1). -- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated so it runs exactly when the consumption happened rather than when the whole job succeeded (D5.2), `continue-on-error: true`, tolerating a failed listing, and looping all ids; **every** upload sets `retention-days: 1`; **no** cleanup step enumerates and deletes the run's whole artifact set, in whatever jq or API shape it is written. -- **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 every surface D6.4 names: the `enable_` input, the `build-` job, that job's entries in the `github-release` and `build-docker` `needs:` lists, the `changes` paths-filter entry **and** its output, the `smoke-build` enable-forward, and any separate `publish-` job the package-registry seam requires. The `inputs.branch` rule above binds a called leaf, while a `publish-` job is in the publisher and reads `github.ref_name` correctly. -- **D7:** the publisher concurrency group is global and ref-independent with `cancel-in-progress: false`. A reusable job declares `permissions:` only where every caller grants that scope at startup, per D7.2. A boolean used by both `workflow_call` and `workflow_dispatch` is declared in both trigger blocks, and a boolean read through `github.event.inputs.` is compared against `'true'` alone, a comparison against the boolean `true` never firing on a string, while an `inputs.` read carries the declared boolean and is used directly, a both-forms comparison there being redundant rather than a finding, so a repo whose booleans all arrive by `workflow_call` records the comparison half N/A. A job or step output is a string too and takes the same `== 'true'` (D7.3). Every cross-job condition that admits a skipped dependency pairs its allowlist with a status-check function such as `always()` or `!failure() && !cancelled()`, since the implicit `success()` is false the moment a `needs:` job skipped (D7.4). -- **D8/D9:** the merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier including semver-major, dispatches `--squash`/`--merge` by the PR's base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number rather than `github.ref`. Codegen runs as a matrix over both branches, and Dependabot targets both branches with security PRs to the default branch (D8.2). The upstream tracker's `bump-branch-prefix` and `branches` match a merge-bot rule, unless it sets `auto-merge: false`, which prefixes the head so no rule matches (wrapper repos). A gate comparing `github.actor` against hard-coded bot identities emits a `::warning::` on the non-matching branch, and the annotation is optional only where the failure announces itself anyway (D8.4). Actions are SHA-pinned. Names/shells/conditionals follow section 2, and line endings follow `.editorconfig` (D9.5), except workflow YAML, which section 2 fixes at LF. - -**Per-type addenda (apply only the ones present):** - -- **.NET publish:** the smoke runtime set is a strict non-empty subset of the full runtime set. The selected set runs sequentially inside one composite-action job. A non-smoke run uploads one `release-asset--dotnet-publish` artifact, while a smoke run skips the archive and upload steps. -- **NuGet:** `publish-nuget` is a job in the repo's own publisher, never inside the release task and never in a reusable workflow a different repository hosts. `id-token: write` appears on that job only, absent from the build and PR paths, beside `actions: write` for the artifact cleanup. The push uses `--skip-duplicate` and is gated by that job's `needs:` on the release-task call, never on an existence check, so a PR never reaches it. The job consume-then-deletes `nuget-build-` under the download step's own success, per D5.1 and D5.2. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` `.7z` 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 under the download step's own success, per D5.2. The `pypi` environment has a deployment-branch rule. -- **Docker:** 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 job is gated main-only, both by the caller's branch input and inside the hub-hosted `publish-docker-readme-task.yml` itself. 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, and the pointer flip is a separate step. The terminal check asserts the environment, then the release id, then the URL contract, waiting for convergence to a bounded timeout rather than sampling once, and reporting an unreachable host distinctly from an HTTP status (D4.6). 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). +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `/@` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per section 1, and an absent trigger is such a construct: a dispatch-only publisher records S5, S6 and S9 N/A, since their push and schedule paths can never fire there. Minimum set: +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | -| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | | S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | | S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | -| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **skipped (smoke), succeeds** so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | | S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | | S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | | S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | | S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | | S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | | S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | -| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | | S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | | S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | ### 5C. Live Probe (Where Warranted) -Every probe here that dispatches a workflow or re-runs a real publish is the maintainer's to run, and the agent prepares the command and reads the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (GOVERNANCE.md "Repository Boundaries and Write Safety"). +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). - Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* - Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. @@ -279,26 +256,52 @@ Every probe here that dispatches a workflow or re-runs a real publish is the mai Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: -1. **Audit** with 5A. Record pass/fail/N-A with `file:line`. +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. 2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. -3. **Probe** with 5C where a live signal exists that the static trace cannot produce: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. 4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + ## 6. Per-Project-Type Test Walkthroughs -Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. +Each type adds constructs to the pipeline, and the constructs are what decide a verdict. This section states what each type **adds**. Section 1's applicability rule turns that into the N/A set on its own: an item governing a construct the repo does not contain is N/A, and one governing a construct it contains is checked. No row here states an N/A list of its own, deliberately, so that reading one row can never take away a construct another row supplies. Walking the rows a repo's types select is the self-check that the contract holds for its shape. + +Three rules govern reading a row, each of them because reading one row alone has produced a wrong verdict. + +- **Types union, they do not choose.** A repo declaring more than one type contains the constructs of **every** type it declares, so an item is N/A only where no declared type supplies its construct. `source-only` beside another type is the case that catches a reader out: it adds no build target and takes none away. +- **The trigger scenarios come from the publisher's own `on:` block, not from the type.** S5, S6 and S9 turn on which triggers the publisher actually carries, and two repos of one type routinely differ there. Section 5B's preamble owns that rule and it binds here. +- **N/A names an absent construct, never an unexercised one.** The `pattern:` download (D6.1) and the cleanup jobs (D5.1 to D5.5) live in the release task, so a repo that owns that task and a repo that calls the hub-hosted copy both contain them and are both checked on them. Ownership decides only where the evidence is cited, in the repo's own file or at the SHA it pins, which is the form 5A gives. Section 5A requires an N/A verdict to name the construct that is absent, so an item that cannot be recorded that way is applicable. + +The table files each of S1 to S13 under exactly one row, which is what covers the set without a per-type list restating it. Filing is not the whole applicability test: a scenario is N/A when **any** construct it needs is absent, and that can be more than the row it sits under, S1 needing the pull request workflow as well as the build target. + +| Construct the repo contains | Scenarios it reaches | +| --- | --- | +| A pull request workflow with a validation job and the required aggregator | S2, S3 | +| A build target: a `changes` filter entry, a smoke build, and a leaf that builds it | S1, S4 | +| A publisher, meaning a workflow that cuts the release | S7, S8, S10, plus S5, S6 and S9 wherever its own `on:` admits a push or a schedule | +| An upstream-version tracker and its merge-bot | S11 | +| A deploy workflow targeting a filesystem on a host the project owns | S12, S13 | + +The rows below say which constructs a type brings with it. Read the repository for the rest: NBGV, `version.json` and the classification gate are reached on the smoke path too, so a repo with no publisher can still contain them, and a repo whose `releaseTrigger` is `none` has no publisher whatever its types say. + +What each type adds beyond that, and how its leaf behaves, is below. -- **.NET publish.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on the default branch and Debug otherwise. A non-smoke run builds the full runtime set, archives the combined output as a `.7z`, and uploads it as `release-asset--dotnet-publish`. The archive is named from the project file stem unless `dotnet_publish_asset_name` overrides it. A smoke run builds a two-runtime subset and skips the archive and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. S7 attaches the 7z from a non-smoke run. The non-default leg sets `prerelease=true`, and the default leg sets `prerelease=false`. GitHub marks the stable default release "Latest" automatically. -- **NuGet.** The leaf uploads both `release-asset--nuget` and `nuget-build-` on a non-smoke run and pushes nothing, and a separate `publish-nuget` job in the repo's own publisher consumes the second and runs `dotnet nuget push *.nupkg --skip-duplicate`, then deletes it under the download step's own success (D5.2). Section 3's package-registry seam says why the push sits there rather than in the leaf. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the release-asset `.7z` also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. -- **PyPI.** The leaf builds and uploads `pypi-build-`. 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 under the download step's own success (D5.2), 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-`; 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 job (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) runs **only** when the default branch publishes, whether called directly or reached through the hub-hosted `publish-docker-readme-task.yml`; 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 the readme. 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--library` (`retention-days: 1` per D5.4, upload gated on smoke being false per D1.3). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + its `github-release` and `build-docker` `needs:` entries 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 caller's own validation job is replaced by a type-appropriate validator only where the reusable one cannot express this repo's validation, with the aggregator re-pointed to the replacement (D1.2). `smoke-build` keeps `needs: [changes]`, as D1.2 and the hub's release-with-smoke stub both have it. `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, and .NET publish 5A addenda and their scenario clauses. -- **Source-only / no build.** There is no package/image build leaf. A repo may own the reusable release task or call its hub-hosted copy. The dispatch-only `publish-release.yml` reaches the reusable plan, validation, and release tasks. Its publish job passes `github: true`, every `enable_*` input as false, and `expect_release_assets: false`. This produces tag + source zip + README + LICENSE with no asset download. With no target, the paths-filter matches nothing. A retained `smoke-build` job is therefore **structurally always skipped**. The repo may instead drop that never-running job. Validation remains the caller's own job reaching the reusable validator. The aggregator `needs:` that validation job (D1.2), and a retained `smoke-build` job `needs:` the `changes` job rather than the validation job. NBGV and `version.json` own the tag. The publish job depends on the same reusable validation task that the PR workflow runs. This prevents a dispatch from releasing a ref that fails validation. Applicable scenarios are S1 (validation only), S7, S8, and S10. S7 covers the release, S8 the dispatch guard, and S10 the classification gate. S9 is recorded N/A, since S9's input is a schedule or push re-run and this publisher triggers on neither, so its no-op skip leg can never fire. S2-S6, D5/D6 artifact items, and all per-type 5A addenda are N/A. The artifact-lifecycle and registry clauses of S7 are also 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 the hub-hosted `deploy-site-task.yml`, with the `environment:` declared inside that task rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`. The crossing secrets, `DEPLOY_SSH_PRIVATE_KEY` and the optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair the live check needs, are therefore mapped explicitly under the call's `secrets:`, the pair only where a token-gated live check needs it, since the task declares them and `secrets: inherit` is not used on a cross-repository call. What that task's own job reads for each of them comes from its `environment:` binding rather than from the caller's job context. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task 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), 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), runs a required deploy hook that builds the tree with whatever generator and precompression the site owns, 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, then runs the same hook again to prune old releases and to check 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/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, S9, 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 direct commits to `develop` onto the **source-only** release shape above. It has two workflows. The first is a **lint/validation** PR workflow that feeds the required `Check pull request workflow status job`. It uses the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator, with **no unit tests**. Examples include Home Assistant `hass --script check_config`, `esphome config`, or a firmware build. Its triggers differ from the `release` model. It runs on pushes to `develop`, pull requests to `[ main, develop ]`, and `workflow_dispatch`. Push validation is advisory. Pull request validation is enforced on `main` and reported but not required on `develop`. The second workflow is the standard **source-only publisher** with `releaseTrigger: dispatch-only`. NBGV and `version.json` own the tag. The reusable release task creates tag + source zip + README + LICENSE. **The PR trigger names both branches, and naming `main` alone is a defect.** Omitting `develop` starts no validation when a PR opens against `develop`. The aggregator then never reports, and the PR appears clean with an empty check list. D1.2 forbids that output. Naming both causes a duplicate run after a PR merge. The change validates on the PR and again on the resulting push, regardless of merge method. The operational `develop` ruleset prescribes no merge method. The concurrency group uses the workflow name plus `${{ github.ref }}` (Section 2). A pull request uses `refs/pull//merge`, while its push uses `refs/heads/develop`. The runs occupy different groups and neither cancels the other. Pay that cost. The lint-only gate costs only a few runner-minutes. Suppressing the push requires distinguishing a merge commit from a direct commit, which restores the ambiguity the trigger set removes. S1 applies to every PR, including promotion and `develop` PRs. The source-only S7, S8, and S10 scenarios also apply, with S9 recorded N/A for the same dispatch-only reason. Bot-push and schedule paths in S5/S6 are N/A, as are every build and registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. +- **`dotnet-publish`.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on the default branch and Debug otherwise. A non-smoke run builds the full runtime set, archives the combined output as a `.7z`, and uploads it as `release-asset--dotnet-publish`. The archive is named from the project file stem unless `dotnet_publish_asset_name` overrides it. A smoke run builds a **strict, non-empty subset** of that runtime set and skips the archive and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. Where the repo has a publisher, S7 attaches the 7z from a non-smoke run. The non-default leg sets `prerelease=true`, and the default leg sets `prerelease=false`. GitHub marks the stable default release "Latest" automatically. +- **`nuget`.** The leaf uploads both `release-asset--nuget` and `nuget-build-` on a non-smoke run and pushes nothing, and a separate `publish-nuget` job in the repo's own publisher consumes the second and runs `dotnet nuget push *.nupkg --skip-duplicate`, then deletes it under the download step's own success (D5.2). Section 3's `Output Seam by Destination` says why the push sits there rather than in the leaf. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the release-asset `.7z` also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **`pypi`.** The leaf builds and uploads `pypi-build-`. 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 under the download step's own success (D5.2), 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`.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache. A single-image repo caches to `:buildcache-`, and a multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since the tag alone cannot distinguish two images (`cache-to` writes only the built branch and only on push, `cache-from` reads both branches). It contributes no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`. The readme job (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) runs **only** when the default branch publishes, whether called directly or reached through the hub-hosted `publish-docker-readme-task.yml`. Where the Docker Hub overview differs from the project README, the repo publishes a `Docker/README.md` through that task, the Hub description being size-limited. The docker-readme task validates its two mutually-exclusive input sources, `repositories` against `manifest`+`manifest-jq`, and defaults to the calling repository where neither is supplied, 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). Test: S7 default leg pushes `latest` + the version tag and updates the readme. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **`library` (a worked example, not a fleet type).** No such leaf ships, so this row walks D6.4's add-a-target procedure rather than an existing shape: a single new leaf that validates, zips, and uploads `release-asset--library` (`retention-days: 1` per D5.4, upload gated on smoke being false per D1.3). Adding it means a new `enable_library` input, a `build-library` job and its `github-release` and `build-docker` `needs:` entries in the release task, and a `library` paths-filter entry, `changes` output, and `smoke-build` enable-forward in the PR workflow (without that last one, D1.1 never smoke-builds the library). **Only a repo that owns its release task can make the first half of that edit.** The hub-hosted release task declares a closed input set, so a repo calling it can add the paths-filter entry, the output, and the enable-forward in its own PR workflow and nothing else, and adding a target there is a change to the hub task first. Keep `expect_release_assets: true` (it has a file target, unlike Docker). The caller's own validation job is replaced by a type-appropriate validator only where the reusable one cannot express this repo's validation, with the aggregator re-pointed to the replacement (D1.2). `smoke-build` keeps `needs: [changes]`, as D1.2 and the hub's release-with-smoke stub both have it. `version.json` and the NBGV `get-version` **job** 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 skips release-create and the asset-delete (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run is D4.4's refresh case rather than S9's, re-uploading then re-deleting the asset. +- **`upstream-wrapper`.** The repo tracks an upstream project's releases rather than versioning its own code. A scheduled resolver writes a `name -> version` state file and opens a per-branch bump pull request, the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3), and the build leaf MUST read that state file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S11 traces the bump from resolver to the publish that ships it, which is the `main` pin's next gated publish rather than the merge itself, since D4.1 admits no publish from a human merge. Nothing in this row depends on which build-target types the wrapper also declares. +- **`homeassistant`.** Adds a **file target**: distribution is a GitHub release that HACS installs from, so the release carries the integration zip. The contract's seam for that is D6.1's `release-asset--` upload collected by `pattern:`, which a repo owning its release task supplies itself, the hub-hosted task declaring a closed set of `enable_*` inputs that has no entry for this type. A repo reaching the same tag-plus-asset outcome through a differently-named artifact diverges from D4.3, D6.1 and D6.3 alike, which is a defect against the seam rather than against the release it produces. Its Python side follows home-assistant/core conventions rather than this fleet's defaults, pip with `requirements*.txt`, a `custom_components/` layout, and standalone `.ruff.toml` and `pyrightconfig.json`, which is expected rather than drift, and `mypy --strict` runs in CI. Test: S1 smoke-builds the zip, and S7 attaches it where the repo has a publisher. +- **`eda`.** Adds a **file target**: distribution is a GitHub release data zip that a local EDA install pulls, supplied by the repo's own release task for the same reason the `homeassistant` row gives. It also adds design-data validation to the pull request gate, the analogue of the code linters, `kicad-cli` ERC/DRC or library linting in practice. Where the repo generates build-time artifacts (gerbers, drill files, a BOM) that generation and its version injection are deterministic from the design inputs, which a data-only repo not yet building artifacts owes only once it starts, and such a repo has no leaf for S1 to smoke-build. Test: S2 and S3 cover the validation gate, and S7 attaches the zip where the repo has a publisher. +- **`codegen`.** Adds a generation workflow that runs as a matrix over both branches and is deterministic from an external source (D8.2), carrying no per-run timestamp or GUID. It contributes no build target and no publish scenario of its own, so it changes no row of the table above. +- **`csharp`, `python`, `cpp`, `docs`.** Add no pipeline construct. They decide what the validation job runs and what the repo's project configuration must hold, `csharp` the analyzer and central-MSBuild rules and D1.6's C# coverage leg, `python` the profile split and D1.6's Python one, `cpp` a shared `clang-format` feeding the lint gate, and `docs` a lint-only CI with no build or test. A repo declaring one of these and nothing else reaches only the scenarios its publisher and its pull request workflow already supply. +- **`source-only`.** Adds no build leaf of its own. In a repo declaring no build-target type beside it, the publish job reaches the reusable release task with `github: true`, every `enable_*` input false, and `expect_release_assets: false`, producing tag + source zip + README + LICENSE with no asset download, the paths-filter matches nothing so a retained `smoke-build` job is **structurally always skipped**, and the repo may drop that never-running job. A repo declaring a build-target type as well enables that target instead, this row taking nothing away from it. NBGV and `version.json` own the tag. Validation remains the caller's own job reaching the reusable validator. The aggregator `needs:` that validation job (D1.2), and a retained `smoke-build` job `needs:` the `changes` job rather than the validation job. The publish job depends on the same reusable validation task the PR workflow runs, which prevents a dispatch from releasing a ref that fails validation. The release task carries the D6.1 `pattern:` download and the D5.1 to D5.5 cleanup, so a repo contains them whether it owns that task or calls the hub-hosted copy, and is checked on them either way. **Owning it changes only where the evidence is cited**, in the repo's own file or at the SHA it pins, per section 5A's citation rule. Test, where the repo has a publisher: S7 covers the release, S8 the dispatch guard, and S10 the classification gate. +- **`hugo` (a 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. 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 the hub-hosted `deploy-site-task.yml`. That task declares the `environment:` inside itself rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`, and because the task's own job is where the environment resolves, the caller maps the crossing secrets explicitly under `secrets:`: `DEPLOY_SSH_PRIVATE_KEY`, and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair only where a token-gated live check needs it. `secrets: inherit` is not used on a cross-repository call, so the explicit mapping is what supplies the value the task's environment-bound job then reads. That task hard-asserts two interfaces. It requires the environment to carry `SITE_BASE_URL`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST` and `DEPLOY_SSH_KNOWN_HOSTS` as variables and `DEPLOY_SSH_PRIVATE_KEY` as a secret, each non-empty, failing fast naming every missing one, and it requires `SITE_AUTH_TOKEN_ID` and `SITE_AUTH_TOKEN` to be mapped together or not at all. And it requires a **deploy hook**, one composite action the caller owns, run three times for `build`, `prune` and `verify`, declaring all four of its inputs in its own `action.yml` because a composite action rejects an invocation supplying an input it does not declare. The task 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. The ref gate gets no counterpart re-assertion, so a caller reaching the task directly is trusted for the ref and not for the environment, which is a deliberate asymmetry rather than an omission. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), 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), runs the hook's `build` invocation, 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, then runs the hook's `prune` and `verify` invocations, the second checking the running host (D4.6) against the site's own URL contract. 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 in the `prune` hook, while a credential confined **write-only** can neither delete nor read back, so that repo's `prune` hook is a no-op, 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. +- **Operational (a `workflowModel`, not a type).** A `workflowModel: operational` repo layers direct commits to `develop` onto the `source-only` release shape above. It has two workflows. The first is a **lint/validation** PR workflow that feeds the required `Check pull request workflow status job`. It uses the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator, with **no unit tests**. Examples include Home Assistant `hass --script check_config`, `esphome config`, or a firmware build. Its triggers differ from the `release` model. It runs on pushes to `develop`, pull requests to `[ main, develop ]`, and `workflow_dispatch`. Push validation is advisory. Pull request validation is enforced on `main` and reported but not required on `develop`. The second workflow is the standard `source-only` publisher. NBGV and `version.json` own the tag. The reusable release task creates tag + source zip + README + LICENSE. **The PR trigger names both branches, and naming `main` alone is a defect.** Omitting `develop` starts no validation when a PR opens against `develop`. The aggregator then never reports, and the PR appears clean with an empty check list. D1.2 forbids that output. Naming both causes a duplicate run after a PR merge. The change validates on the PR and again on the resulting push, regardless of merge method. The operational `develop` ruleset prescribes no merge method. The concurrency group uses the workflow name plus `${{ github.ref }}` (`GOVERNANCE.md` "Workflow YAML Conventions"). A pull request uses `refs/pull//merge`, while its push uses `refs/heads/develop`. The runs occupy different groups and neither cancels the other. Pay that cost. The lint-only gate costs only a few runner-minutes. Suppressing the push requires distinguishing a merge commit from a direct commit, which restores the ambiguity the trigger set removes. Having no build target, such a repo reaches **S2 and S3** on every pull request, promotion and `develop` pull requests included, rather than S1, whose input is a target change. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. [codestyle]: ./CODESTYLE.md [governance-branching-model]: ./GOVERNANCE.md#branching-model -[governance-operational-repositories]: ./GOVERNANCE.md#operational-repositories diff --git a/docs/fleet-map.md b/docs/fleet-map.md index a716a0d6..e38e64c5 100644 --- a/docs/fleet-map.md +++ b/docs/fleet-map.md @@ -227,7 +227,7 @@ flowchart LR ### G9: WORKFLOW.md and AUDIT.md Have No Skill (Closed) - **Gap** - The largest law doc ([`WORKFLOW.md`][workflow], the D1-D9 contract) and the measurement procedure ([`AUDIT.md`][audit]) had no skill surface, while every other procedure and language did. Thirteen [`GOVERNANCE.md`][governance] sections were likewise doc-only. -- **Resolution** - The `workflow-ci-contract` and `audit-a-repo` skills package the two docs in the kept-authority shape (the doc keeps the full rules, the skill is the summary that routes into it). The [`AGENTS.md`][agents] rule map carries a disposition per section: `Workflow YAML Conventions` and the three conduct sections are annotated with their surfacing skill, and a paragraph after the table states why each remaining unannotated section is doc-only by decision, so absence reads as a choice rather than an oversight. Both closing tests hold: the skills ship, and the map carries the dispositions. +- **Resolution** - `audit-a-repo` packages `AUDIT.md` in the kept-authority shape, the doc keeping the full rules and the skill routing into it. `workflow-ci-contract` packages `WORKFLOW.md` sections 3, 4, and 5 as generated includes and the rest of that document in the same kept-authority shape. The [`AGENTS.md`][agents] rule map carries a disposition per section: `Workflow YAML Conventions` and the three conduct sections are annotated with their surfacing skill, and a paragraph after the table states why each remaining unannotated section is doc-only by decision, so absence reads as a choice rather than an oversight. Both closing tests hold: the skills ship, and the map carries the dispositions. - **Provenance** - All four phase-2 skills shipped in one pull request at the maintainer's direction, superseding the one-pull-request-per-skill note this doc carried, with `skill-lifecycle` authored first inside it so the others follow its procedure. ### G10: The Skill Lifecycle Has No Skill (Closed) @@ -243,7 +243,7 @@ flowchart LR ### G12: General Conduct Rules Have No Skill (Closed) - **Gap** - The conduct layer (ask when unsure, never assume, verification before claiming done, delegation and token discipline) lived in carried [`AGENTS.md`][agents] sections and doc-only GOVERNANCE sections, with no skill firing at the moments those rules are violated. -- **Resolution** - The `agent-conduct` skill ships with the narrow decision-moment triggers the proposal specifies (about to claim done, about to assume, a failure just surfaced a lesson), summarizing `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement`, which keep the full rules and carry the surfacing pointer, while the carried AGENTS.md sections stay the always-on layer. +- **Resolution** - The `agent-conduct` skill ships with the narrow decision-moment triggers the proposal specifies (about to claim done, about to assume, a failure just surfaced a lesson), carrying `Verification Discipline`, `Communicating with the User`, and `Durable Knowledge and Self-Improvement` whole as generated includes (as summaries, before the include mechanism landed), the sections keeping the full rules and the surfacing pointer, while the carried AGENTS.md sections stay the always-on layer. ### G13: The Local Review Gate Reaches the Hub Only (Open) @@ -264,14 +264,14 @@ Four skills close G9, G10, and G12, shipped through the [`.agents/skills/`][skil ### workflow-ci-contract -- **Scope** - The [`WORKFLOW.md`][workflow] behavioral contract: the D-guarantees, the seam contract, artifact lifecycle, NBGV versioning, validate-at-entry, and the per-type walkthroughs as references. +- **Scope** - The [`WORKFLOW.md`][workflow] behavioral contract: the D-guarantees, the seam contract, artifact lifecycle, NBGV versioning, and validate-at-entry, with the architecture, the guarantee catalog, and the test methodology carried as references. - **Trigger** - Writing or editing workflow YAML, adding or dropping a release target, or reasoning about why a publish did or did not fire. - **Packages** - The YAML half of the pipeline. `operational-vs-release-workflow` keeps the git half (branching, promotion, publish policy), and the two descriptions state the split. -- **Overlap** - The source doc is large, so the skill is a summary plus binding rules with `references/` splits, the shape `comment-and-doc-style` already uses. +- **Overlap** - The source doc is large, so the skill is a summary with `references/` splits, the shape `comment-and-doc-style` already uses. Sections 3, 4, and 5 are each carried whole as a generated include. ### skill-lifecycle -- **Scope** - Creating, changing, splitting, and retiring a skill: the source-vs-generated split, the regen and `--check` semantics of [`scripts/build_dist.py`][build-dist], the install and stamp semantics of [`scripts/skills_install.py`][skills-install], the doc-packaging pattern (summary in the law doc, full rules in the skill), and trigger-description conventions. +- **Scope** - Creating, changing, splitting, and retiring a skill: the source-vs-generated split, the regen and `--check` semantics of [`scripts/build_dist.py`][build-dist] and the include regions it fills from a rule's home, the install and stamp semantics of [`scripts/skills_install.py`][skills-install], the doc-packaging pattern in its three shapes (summary in the law doc with full rules in the skill, the reverse, or a generated include of the doc's section), and trigger-description conventions. - **Trigger** - About to create or edit anything under `.agents/skills/` or `.claude-plugin/`. - **Packages** - [`.agents/skills/README.md`][skills-readme] procedure content, which then defers to it. - **Overlap** - None, and the absence was gap G10. Adjacent to `comment-and-doc-style` for SKILL.md prose only. diff --git a/docs/pr-reviewer-evaluation.md b/docs/pr-reviewer-evaluation.md index a93f2269..1c5f8e11 100644 --- a/docs/pr-reviewer-evaluation.md +++ b/docs/pr-reviewer-evaluation.md @@ -16,13 +16,16 @@ This document measures whether additional automated reviewers improve the fleet' ## Status -**State:** Active evaluation\ +**State:** Active evaluation, on public repositories only\ **Incumbent:** GitHub Copilot\ -**Candidates:** CodeRabbit and Qodo\ +**Candidates:** CodeRabbit and Qodo, each on its open-source tier\ +**Installed but unconfigured:** the Claude GitHub App\ **Samples:** [ProjectTemplate pull request #891][pr-891], [pull request #892][pr-892], and [pull request #893][pr-893] No candidate is a required reviewer. A candidate remains advisory until it meets the first-class support criteria below. +As of September 2026 both candidates run on their open-source tiers. CodeRabbit and Qodo review the maintainer's public repositories and never a private one, so a private repository has Copilot as its only pull request reviewer, and Copilot's own review budget, a self-configured premium request cap, runs out under concurrent pull requests. CodeRabbit's open-source tier auto-reviews only a repository with at least ten stars, so on `ProjectTemplate`, which holds fewer, it reviews only on an explicit trigger. The Claude GitHub App is installed on the account and is not configured as a reviewer, since the `local-strict-review` Skill already runs a review pass before every push toward a pull request, so the App's value is unmeasured. + ## Evaluation Method Each finding receives one disposition after verification against the current head, repository rules, and relevant primary documentation. @@ -126,9 +129,9 @@ The current weakness is availability. A terminal error can leave the required re The review body provides an actionable summary and links each finding to an inline thread. This makes manual triage straightforward. -Automatic review skipped a feature-to-`develop` pull request because `develop` is not the repository default. The review loop must explicitly trigger CodeRabbit unless its configuration changes. +Automatic review skips a pull request whose base is not the repository default unless [`.coderabbit.yaml`][coderabbit-auto-review] lists the base under `reviews.auto_review.base_branches`, which the hub's file does for `develop`. The default branch is always included, and each entry is a regex. On the open-source tier, automatic review also needs the repository to hold at least ten stars, which this one does not, so a review here is triggered by commenting `@coderabbitai review`, and until then CodeRabbit's summary comment says so in place of a review while its status check reports success. -Incremental follow-up also requires an explicit command on this pull request. Completion is reported by updating the command reply rather than by creating a new formal review. +Incremental follow-up needed an explicit command on [pull request #892][pr-892]. Completion is reported by updating the command reply rather than by creating a new formal review. The collapsed analysis is verbose and can dominate API output. Machine support should read normalized summaries and thread metadata without loading the analysis transcript. @@ -144,13 +147,29 @@ After a corrective push, Qodo updated the existing review comment and its resolv The first sample shows more policy false positives than CodeRabbit. It also supplied the only command-line length finding, which gives it measurable incremental value. +### Generated Mirrors + +`.github/skills/` and `.claude-plugin/fleet-skills/` are the trees `scripts/build_dist.py` generates from `.agents/skills/`, and CI holds them current, so a finding in either belongs at its source and a review of every copy is one finding three times. Two committed files tell CodeRabbit and Qodo to skip them, and Copilot's exclusion is a repository setting this account does not have, so `.github/copilot-instructions.md` asks instead. + +- **CodeRabbit** reads `reviews.path_filters` from [`.coderabbit.yaml`][coderabbit-config] at the repository root, where a pattern prefixed with `!` excludes. +- **Qodo** reads [`.pr_agent.toml`][qodo-config] from the root of the default branch, so the file binds only once it is promoted to `main`, and its [`[ignore]` glob list][qodo-ignore] names the paths to skip. +- **GitHub Copilot** honors [content exclusion][copilot-exclusion], a repository setting under Copilot rather than a file in the tree, whose paths are `fnmatch` patterns, anchored to the repository root by a leading slash and matched anywhere without one. GitHub documents the setting for organizations on a Business or Enterprise plan, and this repository is under a user account, so it is unavailable here. In its place, `.github/copilot-instructions.md` "Reviewing Carried Fleet Content" asks Copilot to post no comment on either tree, and GitHub's [code review customization tutorial][copilot-customize] documents instruction compliance as non-deterministic, so an instruction may be overlooked where a setting cannot. + +### Review Configuration + +Each reviewer's behavior is shaped by a committed file rather than accepted as given, and each setting below carries the finding or the cost that earned it. + +- **CodeRabbit**, in [`.coderabbit.yaml`][coderabbit-config]: auto review on pull requests into `develop`, which the open-source tier honors only at ten stars or more, no pause after five reviewed commits, since a fleet pull request routinely passes five pushes and the pause reads as a reviewer that stopped. A path instruction for Markdown asks for false, stale, unverifiable, or unfollowable claims only, since CI lints style and the local review pass reads canonical prose whole. Sequence diagrams, suggested labels and reviewers, and the in-progress fortune are off. The markdownlint, actionlint, shellcheck, and ruff tools are off, since CI runs the same four and fails the pull request on them. +- **Qodo**, in [`.pr_agent.toml`][qodo-config]: an issues guideline asks for a reproduction with any claimed crash, after a claimed `IsADirectoryError` on this repository's build was disproven by running it. A compliance guideline asks for the rule's own sentence and routes a rule against unchanged text to the summary. Informational findings go to the [summary][qodo-verbosity] rather than a thread, since a thread blocks the merge until resolved. Images are off so a finding's title is plain text a matcher can see. Qodo's [review standards][qodo-rules] import from `AGENTS.md`, `CLAUDE.md`, `copilot-instructions.md`, and `SKILL.md` files, each scoped to its folder at any depth, when changes merge, and only new rules are added, so an edited or deleted rule is changed in its portal instead. +- **GitHub Copilot**: the carried `.github/copilot-instructions.md`, which bootstraps the `code-review` Skill, is the lever this repository uses. GitHub also documents path-scoped `.github/instructions/*.instructions.md` files, unused here. + ## Plan and Repository Scope -The maintainer intends to leave the paid trial when it expires and use only an available no-cost open-source tier. Candidate use is therefore limited to public repositories unless the maintainer approves a later plan change. +The paid trials are over, and both candidates run on their no-cost open-source tiers. Candidate use is therefore limited to public repositories unless the maintainer approves a later plan change. -[CodeRabbit's current plan documentation][coderabbit-plans] provides an open-source tier for public repositories with rate limits. Confirm its terms again when the trial ends because product plans are external state. +[CodeRabbit's current plan documentation][coderabbit-plans] provides an open-source tier for public repositories with rate limits. Product plans are external state, so confirm the terms again before relying on them. -Qodo remains under evaluation. Confirm its current public-repository availability, limits, and required permissions before relying on it outside this repository. Its [code-review documentation][qodo-review] describes the review product but does not settle the fleet's plan decision. +Qodo remains under evaluation on the same footing. Its [code-review documentation][qodo-review] describes the review product but does not settle the fleet's plan decision. Private repositories remain Copilot-only unless a candidate's approved plan, data terms, and GitHub App permissions receive a separate review. @@ -193,7 +212,7 @@ The existing Copilot adapter remains behaviorally unchanged during extraction. P 1. Record every CodeRabbit and Qodo finding on subsequent public pull requests. 2. Measure time to review, current-head coverage, duplicates, and interaction effort. -3. Recheck candidate plan terms when the CodeRabbit trial expires. +3. Recheck candidate plan terms periodically, since product plans are external state. 4. Decide whether either candidate meets the first-class support criteria. 5. Design `pr_review.py` provider adapters only for candidates that graduate. 6. Decide separately whether a graduated reviewer is advisory or required. @@ -210,5 +229,13 @@ The existing Copilot adapter remains behaviorally unchanged during extraction. P +[coderabbit-auto-review]: https://docs.coderabbit.ai/configuration/auto-review +[coderabbit-config]: https://docs.coderabbit.ai/reference/configuration [coderabbit-plans]: https://docs.coderabbit.ai/management/plans +[copilot-customize]: https://docs.github.com/en/copilot/tutorials/customize-code-review +[copilot-exclusion]: https://docs.github.com/en/copilot/how-tos/configure-content-exclusion/exclude-content-from-copilot +[qodo-config]: https://docs.qodo.ai/install-and-configure/configuration-overview/configuration-file +[qodo-ignore]: https://docs.pr-agent.ai/usage-guide/additional_configurations/ [qodo-review]: https://docs.qodo.ai/code-review +[qodo-rules]: https://docs.qodo.ai/governance/rule-enforcement/building-review-standards +[qodo-verbosity]: https://docs.qodo.ai/code-review/review-verbosity diff --git a/docs/repo-config.md b/docs/repo-config.md index 8c7ffcee..766199a9 100644 --- a/docs/repo-config.md +++ b/docs/repo-config.md @@ -10,6 +10,7 @@ The hub holds all fleet-wide repository configuration: - `main.json` declares the shared `main` ruleset. - `develop.json` declares the release-model `develop` ruleset. - `operational/develop.json` declares the operational-model `develop` ruleset. +- `labels.json` declares the fleet label set. - `configure.sh` applies or checks those payloads through the GitHub API. Downstream repositories carry no `repo-config/` directory. The registry's `workflowModel` selects the `develop` payload. Commands that operate before registry enrollment pass the model explicitly. @@ -24,13 +25,13 @@ Downstream repositories carry no copy of `spec/secrets.json`. `baseline` applies **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. -Remove all classic branch-protection rules and stray rulesets. Run `configure.sh apply` from a hub checkout at `main`, naming the target repository and its model. The script applies `settings.json`, the Dependabot security features, and both rulesets. A registered repository can omit the model and use the registry lookup. A repository outside the registry passes the model explicitly: +Remove all classic branch-protection rules and stray rulesets. Run `configure.sh apply` from a hub checkout at `main`, naming the target repository and its model. The script applies `settings.json`, the Dependabot security features, the label set, and both rulesets. A registered repository can omit the model and use the registry lookup. A repository outside the registry passes the model explicitly: ```sh repo-config/configure.sh apply owner/repo release|operational ``` -Then validate the result with `repo-config/configure.sh check owner/repo release|operational`, run from the same checkout, which asserts every applied ruleset, setting, and security feature and exits non-zero on drift (the ruleset and settings checks are driven by the committed payloads, so they stay repo-agnostic). Or import each ruleset by hand with `gh api -X POST repos///rulesets --input repo-config/.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). +Then validate the result with `repo-config/configure.sh check owner/repo release|operational`, run from the same checkout, which asserts every applied ruleset, setting, label, and security feature and exits non-zero on drift (the ruleset and settings checks are driven by the committed payloads, so they stay repo-agnostic). Or import each ruleset by hand with `gh api -X POST repos///rulesets --input repo-config/.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). ## Regenerating the Payloads diff --git a/repo-config/README.md b/repo-config/README.md index c8b01a7d..a0d3820f 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -3,7 +3,8 @@ Hub-only repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration: workflows, Dependabot). Downstream repositories carry no `repo-config/` directory. Apply and check commands run from a hub checkout at `main` and name the target repository. - `main.json`, `develop.json`, and `operational/develop.json`: the canonical branch rulesets as the managed part of the writable API subset (`name`, `target`, `enforcement`, `conditions`, `rules`). `main.json` is shared. `develop.json` serves release repos, and `operational/develop.json` serves operational repos. `repo-config/configure.sh check owner/repo release|operational` compares the selected payloads with the live rulesets. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing diffs it: who may bypass a ruleset is a human decision taken in the UI, which `repo-config/configure.sh` preserves on `apply` and reports without asserting on `check`. -- `configure.sh`: run from a hub checkout at `main`, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. It resolves every payload path against the hub's `repo-config/` directory. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. `repo-config/configure.sh apply owner/repo release|operational` creates or updates the settings, Dependabot security features, and rulesets idempotently. `repo-config/configure.sh check owner/repo release|operational` is the read-only inverse and exits non-zero on drift. The model defaults to the registry `workflowModel` lookup. Pass it explicitly for a repository outside the registry. +- `labels.json`: the fleet label set, one `name`, `color`, and `description` per label. `repo-config/configure.sh apply owner/repo release|operational` creates or updates every declared label by name and deletes nothing, so a label a repo adds of its own stays. `check` asserts each declared label on all three fields and reports the undeclared ones without judging them. +- `configure.sh`: run from a hub checkout at `main`, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. It resolves every payload path against the hub's `repo-config/` directory. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. `repo-config/configure.sh apply owner/repo release|operational` creates or updates the settings, Dependabot security features, labels, and rulesets idempotently. `repo-config/configure.sh check owner/repo release|operational` is the read-only inverse and exits non-zero on drift. The model defaults to the registry `workflowModel` lookup. Pass it explicitly for a repository outside the registry. ## Rulesets @@ -20,6 +21,18 @@ The result is **exactly two rulesets named `develop` and `main`**, and the names 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). That publish job belongs to the repo's own workflow file, since trusted publishing validates the OIDC token's `job_workflow_ref` claim against the repository owning the package and rejects a reusable workflow's ref, so `id-token: write` is granted at that one entry point and nowhere else. The registry-side policy is the other half of that pairing and is configured on nuget.org or PyPI rather than here: it names the repository and the workflow file the push runs from, so moving the push between workflow files means repointing the policy in the same change. 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`. +## Labels + +The triage labels classify an issue by the kind of work it needs, so a backlog sweep can pick the gates and scripts, which converge, ahead of the prose defects, which re-enter the review loop when worked one bundle at a time. An issue carries exactly one of these five, or `enhancement` for a feature, beside whatever surface labels it also carries. + +- **`gate`**: a rule that exists in prose with no mechanical check, or a check that misses a shape. +- **`script`**: a defect in hub tooling. +- **`prose`**: a defect in rule or procedure text. +- **`decision`**: needs the maintainer's decision before it can be worked. +- **`chore`**: registry, labels, rollout, and other fleet housekeeping. + +The class labels `introduced` and `pre-existing` record which class, per the `local-strict-review` Skill's "Disposing of Findings", a filed review finding carried. A `style` finding is declined rather than filed, so it has no label. `agents`, `skills`, and `codegen` mark the surface, and the rest are GitHub's own defaults and the Dependabot pair, declared so every fleet repo carries at least this set. + ## Repo Settings The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `repo-config/configure.sh apply owner/repo release|operational` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state, `has_discussions` (visibility) and `default_branch` (main-must-exist), are computed by the script, not stored in the file. `apply` also enables Dependabot vulnerability alerts and automated security updates, fleet policy applied via the API rather than a `settings.json` key. `repo-config/configure.sh check owner/repo release|operational` validates all of these and exits non-zero on drift. diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 7caa264f..f2853c4f 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Configure or validate a repository against the committed fleet config in this directory, via the GitHub API. # -# Apply: repo-config/configure.sh apply [owner/repo] [release|operational] # create-or-update settings + rulesets (writes) +# Apply: repo-config/configure.sh apply [owner/repo] [release|operational] # create-or-update settings + labels + rulesets (writes) # Check: repo-config/configure.sh check [owner/repo] [release|operational] # validate an existing repo, non-zero on drift (reads) # # Both modes need admin on the repo, because the rulesets endpoints require it. @@ -9,14 +9,15 @@ # The model may be passed as the sole positional, as in `configure.sh check operational`. # The command may be omitted for the apply default, so `configure.sh owner/repo` still applies. # -# The apply mode writes three groups, in order. +# The apply mode writes four groups, in order. # First settings.json via PATCH, plus has_discussions (public repos only) and default_branch (main, only when it exists). # Then the Dependabot vulnerability alerts and automated security updates. +# Then the fleet label set from labels.json, create-or-update by name, leaving any label the payload does not declare alone. # Then the branch rulesets, main.json shared and the model-specific develop ruleset, create-or-update by name. # The develop ruleset is develop.json where the model is PR-gated, or operational/develop.json for direct signed pushes. # Applying the same configuration twice changes nothing, so the mode is idempotent. # -# The check mode is the read-only inverse, and it verifies the same three groups apply writes. +# The check mode is the read-only inverse, and it verifies the same four groups apply writes. # The ruleset and static-settings assertions are driven by the committed payloads, so they stay repo-agnostic. # A ruleset is checked on enforcement, on the rule-type set compared in both directions, and on the whole parameters object of every parameterized rule. # Comparing the parameters object rather than named fields means a parameter added to a payload is audited with no change here. @@ -24,7 +25,8 @@ # That still survives the GitHub API normalizing a stored ruleset, since the comparison is over parsed JSON with sorted keys rather than a byte diff. # The derived settings apply computes are asserted by name rather than from a payload, meaning has_discussions and default_branch. # The two Dependabot security features are asserted the same way, since apply enables them and no payload declares them. -# What is unaudited is a static setting absent from settings.json, since only that group is payload-driven. +# A label is checked on name, color, and description against labels.json, and a label the payload never declared is reported without being asserted, since a repo may carry labels of its own. +# What is unaudited is a static setting absent from settings.json and a label labels.json does not declare, since those two groups are asserted in the payload's direction only, where a ruleset's rule-type set is compared both ways. # Secret names are checked separately, by spec/audit.py from a hub checkout. # This script leaves them a manual-verify note for values, which are never readable via the API. set -Eeuo pipefail @@ -75,6 +77,7 @@ operational) develop_ruleset="$script_dir/operational/develop.json" ;; esac main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" +labels_file="$script_dir/labels.json" # ----- Resolve the declared description (optional, shared by apply and check) ----- # Absence keeps the About panel following the README. @@ -170,15 +173,41 @@ apply_ruleset() { # payload-file - create-or-update the ruleset by name fi } +# Test with `labels_payload_ok`, which is true only when labels.json parses to a non-empty array whose every label meets the API's field contract. +# That is a non-empty name, a six-digit hex color, and a description of at most 100 characters, each a string holding no tab or line break. +# The type test keeps a missing description from rendering as the literal string null, the contract tests keep a label from failing at the API partway through the loop, and the character test keeps a value from splitting the tab-joined rows the two label loops read. +labels_payload_ok() { + jq -e 'type=="array" and length > 0 and all(.[]; (.name|type=="string") and (.color|type=="string") and (.description|type=="string") and (.name|length) > 0 and (.color|test("^[0-9a-fA-F]{6}$")) and (.description|length) <= 100 and ((.name+.color+.description)|test("[\t\r\n]")|not))' "$labels_file" >/dev/null 2>&1 +} + +apply_labels() { # create-or-update every label labels.json declares, by name + # `gh label create --force` updates a label that exists and creates one that does not, so the write is idempotent by name. + # A label the payload does not declare is left standing, because a repo may carry labels of its own and this script deletes nothing. + # Every field is read from the payload, so a label added there reaches every fleet repo on the next apply with no change here. + # The payload is parsed into a variable before the loop for the reason check_settings gives: a jq failure inside `done < <(...)` skips the body silently. + local rows lname color desc + # The payload was validated by cmd_apply's pre-flight, before any write, so this read cannot be the first to find it malformed. + rows="$(jqr '.[] | "\(.name)\t\(.color)\t\(.description)"' "$labels_file")" + while IFS=$'\t' read -r lname color desc; do + gh label create "$lname" --repo "$repo" --color "$color" --description "$desc" --force >/dev/null + done <<<"$rows" + echo "Applied $(wc -l <<<"$rows" | tr -d ' ') labels from labels.json" +} + cmd_apply() { local f private disc payload # Pre-flight every required payload before any write, so a partial carry aborts before it half-applies. - for f in "$settings_file" "$develop_ruleset" "$main_ruleset"; do + for f in "$settings_file" "$labels_file" "$develop_ruleset" "$main_ruleset"; do if [ ! -e "$f" ]; then echo "Required payload $f not found. Aborting to avoid a partially-applied configuration." >&2 exit 1 fi done + # The label payload's content is validated here too, since apply_labels runs after the settings and Dependabot writes and an abort there would leave them applied. + if ! labels_payload_ok; then + echo "Label payload $labels_file did not parse, is empty, or holds a label outside the field contract (non-empty name, six-digit hex color, description of at most 100 characters, no tab or line break). Aborting before any write." >&2 + exit 1 + fi echo "Applying configuration to $repo (model: $model)" # The writes below silence stdout only, because the success-response JSON is noise. # They still fail loud, since gh errors go to stderr and a failed write aborts the script. @@ -206,6 +235,8 @@ cmd_apply() { gh api --method PUT "repos/$repo/vulnerability-alerts" >/dev/null gh api --method PUT "repos/$repo/automated-security-fixes" >/dev/null echo "Enabled Dependabot vulnerability alerts + automated security updates" + # ----- Fleet label set ----- + apply_labels # ----- Branch rulesets (main shared, develop selected by workflow model) ----- apply_ruleset "$develop_ruleset" apply_ruleset "$main_ruleset" @@ -388,12 +419,44 @@ check_security() { fi } +check_labels() { + local live rows lname color desc got extra + if [ ! -e "$labels_file" ]; then + fail "label payload $labels_file missing" + return + fi + # The list is paginated, since a repo carrying more labels than one page holds would otherwise report a later-page label as missing. + if ! live="$(gh api --paginate "repos/$repo/labels" --jq '.[]' | jq -s '.')"; then + fail "could not read repository labels" + return + fi + if ! labels_payload_ok; then + fail "label payload $labels_file did not parse, is empty, or holds a label outside the field contract" + return + fi + rows="$(jqr '.[] | "\(.name)\t\(.color)\t\(.description)"' "$labels_file")" + # Each declared label is asserted on all three fields, so a color or description edited by hand reads as drift. + while IFS=$'\t' read -r lname color desc; do + # shellcheck disable=SC2016 # $n is a jq --arg variable, not a shell expansion + got="$(jqr --arg n "$lname" '[.[] | select(.name == $n)] | first // empty | "\(.color)\t\(.description // "")"' <<<"$live")" + assert "label '$lname' = $color '$desc'" test "$got" = "$color"$'\t'"$desc" + done <<<"$rows" + # A label the payload never declared is reported rather than asserted, matching the bypass list: the fleet set is a floor, and a repo may add its own. + # shellcheck disable=SC2016 # $want is a jq --slurpfile variable, not a shell expansion + if ! extra="$(jqr --slurpfile want "$labels_file" '[.[].name] - [$want[0][].name] | join(", ")' <<<"$live")"; then + fail "could not compute the undeclared label list" + return + fi + note "labels not declared by labels.json: ${extra:-none} (left alone by this script)" +} + cmd_check() { echo "Validating configuration for $repo (model: $model)" check_ruleset "$develop_ruleset" check_ruleset "$main_ruleset" check_settings check_security + check_labels # Secret names are asserted by spec/audit.py, not here. # Values are never readable via the API regardless. note "run spec/audit.py [RepoName] (the registry name, not owner/repo) for required secret names, then verify by hand that their values are valid" diff --git a/repo-config/labels.json b/repo-config/labels.json new file mode 100644 index 00000000..2a877709 --- /dev/null +++ b/repo-config/labels.json @@ -0,0 +1,18 @@ +[ + { "name": "agents", "color": "f86915", "description": "Agents instructions" }, + { "name": "bug", "color": "d73a4a", "description": "Something isn't working" }, + { "name": "chore", "color": "c2e0c6", "description": "Registry, labels, rollout, and other fleet housekeeping" }, + { "name": "codegen", "color": "3526d4", "description": "Codegen bot" }, + { "name": "decision", "color": "b60205", "description": "Needs the maintainer's decision before it can be worked" }, + { "name": "dependencies", "color": "0366d6", "description": "Pull requests that update a dependency file" }, + { "name": "documentation", "color": "0075ca", "description": "Improvements or additions to documentation" }, + { "name": "duplicate", "color": "cfd3d7", "description": "This issue or pull request already exists" }, + { "name": "enhancement", "color": "a2eeef", "description": "New feature or request" }, + { "name": "gate", "color": "5319e7", "description": "A rule with no mechanical check, or a check that misses a shape" }, + { "name": "github_actions", "color": "000000", "description": "Pull requests that update GitHub Actions code" }, + { "name": "introduced", "color": "e99695", "description": "Review finding classed introduced per local-strict-review Disposing of Findings" }, + { "name": "pre-existing", "color": "bfd4f2", "description": "Review finding classed pre-existing per local-strict-review Disposing of Findings" }, + { "name": "prose", "color": "fbca04", "description": "A defect in rule or procedure text" }, + { "name": "script", "color": "1d76db", "description": "A defect in hub tooling" }, + { "name": "skills", "color": "a25957", "description": "Agent skill" } +] diff --git a/reports/canonical-review.json b/reports/canonical-review.json index 5de0fc7c..ff4c4372 100644 --- a/reports/canonical-review.json +++ b/reports/canonical-review.json @@ -1,21 +1,61 @@ { "note": "What full-content reviews of hub canonical content have covered, one entry per unit, holding the most recent pass. Written by scripts/canonical_review.py record, never by hand, and git keeps the history. A unit is covered while its digest here matches the content's, so an edit to the unit retires the pass. See ptr727/ProjectTemplate#1138 for why the record exists.", "passes": [ + { + "unit": ".agents/skills/agent-conduct/SKILL.md > (preamble)", + "digest": "sha256:ac019bedc804c3bba57aed7ed2bc1272ae7cc5701e66fa3ee3e97706d939996e", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:23:10Z" + }, + { + "unit": ".agents/skills/agent-conduct/SKILL.md > Before Assuming", + "digest": "sha256:9de845ccd2449f1a86528f52c2ce581afec7311a0757755620805b2aa93a701d", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:21Z" + }, { "unit": ".agents/skills/agent-conduct/SKILL.md > Before Claiming Done", - "digest": "sha256:d000a7b3f3fc3a90c7f2c5516424aaf1a3b97a3b47c3fc3310d34aa676d2a65d", + "digest": "sha256:5a3128345565bdbc3f4af16ac4262b4cf88c5e1640385325a4a4ae4fd33ab988", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "0e6aacf45334149f6158aa23b8132d6aee1d1eb0", - "stamp": "2026-09-01T16:37:57Z" + "findings": 2, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:21Z" + }, + { + "unit": ".agents/skills/agent-conduct/SKILL.md > Delegation, in One Paragraph", + "digest": "sha256:dccb7b425fbee654152fdb64f63b9a438ae2da607a4c8be88c2b58878be3bdbc", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:16:35Z" }, { "unit": ".agents/skills/agent-conduct/SKILL.md > When a Failure Surfaces a Lesson", - "digest": "sha256:7bb4825e38c44c74366d2f0051d521968d87df765144bb080ca059a417e2cd75", + "digest": "sha256:09fe765939d60461b11fecc4584aa32a78c51720424c8a5ccd66bfa685e628c2", "reviewer": "agent-skill", - "findings": 6, - "hubCommit": "52b0c550ab07ab58b940a353a83247d7451e76e5", - "stamp": "2026-09-02T01:11:56Z" + "findings": 1, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:22Z" + }, + { + "unit": ".agents/skills/agent-conduct/SKILL.md > Why This Exists", + "digest": "sha256:61b40202de51514aa2c67b106900451d65de170de98fefd2e88a13a2a8eafafa", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:16:35Z" + }, + { + "unit": ".agents/skills/audit-a-repo/SKILL.md > Measuring", + "digest": "sha256:641112a85d02d327be4e7d1154b26a19f116a8087c993de516a760857b1a6ed4", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", + "stamp": "2026-09-06T20:51:13Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > (preamble)", @@ -27,27 +67,27 @@ }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Bounding a Prose Group", - "digest": "sha256:b94ebeced663da22a9a5b0b32f494d6dbe2045bb8d54ecab5484eb049e9a52d7", + "digest": "sha256:7d5647dcd838c8eaca1d342749f7ebaecbad10263919ad17e629be88431d356b", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:46Z" + "findings": 1, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:40Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Bounding the Wait on a Worker", - "digest": "sha256:6af6a85a17f9671f6ba50f622a34769e7c8c6fce2100496cd2f9330523755daa", + "digest": "sha256:668f48bfe9c6e2b1eadd7d19f3c9cafcc3aa25695587f2e5537cc1fff16cb3dc", "reviewer": "agent-skill", - "findings": 5, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T00:04:25Z" + "findings": 3, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T18:41:14Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Dispatching a Worker", - "digest": "sha256:bd825f92555e4d0a762afe4a2de5ac6a9873b14eed1221a4c2f87d9a70f71489", + "digest": "sha256:84dda6ae64210c2403ae84ce2f345d27865f68f341146c2c006ef6d8379c62de", "reviewer": "agent-skill", - "findings": 3, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T02:41:40Z" + "findings": 2, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:40Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Ending the Run", @@ -59,11 +99,11 @@ }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Grouping and File Claims", - "digest": "sha256:c6c315f4141dd986741e51c290867a8721f3313087e9ff62554681d4b56ca2b1", + "digest": "sha256:54c22333992b900419d97a0e01764bfa67113511a5cf3e8b5a354c8d24a7c75a", "reviewer": "agent-skill", - "findings": 4, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T02:41:40Z" + "findings": 2, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T18:28:22Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Mechanics Live Elsewhere", @@ -75,43 +115,43 @@ }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Raising a Blocked Question", - "digest": "sha256:86282c469754ae2c1b5ba5e6694f7d54325e19b2978d5fbef3b50a95a6831c74", + "digest": "sha256:5406ec6bf4c8b4941dbb88d475545e5521ca37ae64e0a21f7fbd7967e9889aa9", "reviewer": "agent-skill", - "findings": 7, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-03T23:11:32Z" + "findings": 1, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:40Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Ranking", - "digest": "sha256:e58cd0555581811e82a9796072cefc5c3ef14f265f5e4791104a71935cd1de15", + "digest": "sha256:5ce2044cd7e78e86a24a15d3b8d939cf922c8fce60841bd37f659feaa51726a5", "reviewer": "agent-skill", - "findings": 3, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T02:06:41Z" + "findings": 1, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:41Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Run State", - "digest": "sha256:6b0b57e0f06237d6f134af15a356aa3ec21bb54c5f2e6bd8460087f392ed20c8", + "digest": "sha256:375a886f08433f80e7788bdb394a72ce2afe0e9f922ff9fd3972451a297643b1", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:50Z" + "findings": 1, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:41Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Scope", - "digest": "sha256:1db5bb5cdd672089c306c59c2776b672ba09541b093fe9e05f4e713c4bc8b530", + "digest": "sha256:1bd758ddc437afe836ddf20b00adfd919c41f95548e92ccdf4b0f5485161f820", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:50Z" + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:55:41Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > The Promotion Boundary", - "digest": "sha256:4b802ff18e55a38f6027b443bebe63c0721e84bf0234b7262aaad726dc005f77", + "digest": "sha256:3c4a3cda775b840ca28d5b59963a93edc86f678a444ac52c3e1987958719ba95", "reviewer": "agent-skill", - "findings": 8, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T00:33:58Z" + "findings": 7, + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T18:43:00Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > The Round", @@ -123,19 +163,19 @@ }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > The Two Seats", - "digest": "sha256:988e9cc3aee1ab6aa7c9ada41827a4903f7ae6d06f8051178456888a39c9cb82", + "digest": "sha256:e1595ccf33b05823e811b41c2a21aed95c6be91b5bbe55ffa135f8a940adbf0d", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:51Z" + "hubCommit": "6a590402646712e93c07f4438ed9a6f5e4dc900e", + "stamp": "2026-09-05T17:57:05Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > What Invoking This Skill Authorizes", - "digest": "sha256:00d0aff29a7a73bf5575aa0753aac923fdc1364c17968d8e87c96f3658e3ef77", + "digest": "sha256:b6e8b635c80e201590f8babbebd29bf9ef63c931466241e6a599a29ce6dd7c30", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:52Z" + "findings": 3, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:11:49Z" }, { "unit": ".agents/skills/backlog-burndown/SKILL.md > Why This Exists", @@ -161,6 +201,14 @@ "hubCommit": "df5e2493b48aa45d80f586db68af0c21be97ea21", "stamp": "2026-09-01T05:45:31Z" }, + { + "unit": ".agents/skills/comment-and-doc-style/references/line-endings.md > Scripts and extensionless executables", + "digest": "sha256:8fd106b584bef3810e28f35f1e0e94c18bc3992f38233c6765557e1c1800c99a", + "reviewer": "agent-skill", + "findings": 1, + "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", + "stamp": "2026-09-06T02:24:00Z" + }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Build requirements", "digest": "sha256:96e6651c2e024fae3bca713f4a8cab34b37a9bb9eb693a3e25de04465b03113c", @@ -169,6 +217,14 @@ "hubCommit": "a2c9dab3bfde574d179b0765f9bb50f413ce7a80", "stamp": "2026-09-02T03:33:05Z" }, + { + "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Testing conventions", + "digest": "sha256:8090b7a015bad6707b54bb9d3c2a62d40ca2d95b499a70f712668dbeb82f9275", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Tooling and editor", "digest": "sha256:adaa0bb212c931d838d35dc9777b8ac5605ff46ccffc4448f227c78793085a57", @@ -177,21 +233,37 @@ "hubCommit": "68be56e977269486eeeca14bdc4982a0adbdb6b3", "stamp": "2026-09-02T13:41:40Z" }, + { + "unit": ".agents/skills/dotnet-codestyle/references/testing.md > (preamble)", + "digest": "sha256:2f477f9122d71f3070f272c5e9ba2e227482eff06a1463ca0ac5cef3d68ebcca", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, + { + "unit": ".agents/skills/dotnet-codestyle/references/testing.md > Microsoft.Testing.Platform and coverage", + "digest": "sha256:37d08392e883923be61ddfa87d05d268c41e565f1dccfa387d89727f7c654b90", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" + }, { "unit": ".agents/skills/drive-pr/SKILL.md > (preamble)", - "digest": "sha256:5c6af4cd25e9aeaafbfe460691d87bbddf80b4c164f29791fe42df931749e5bb", + "digest": "sha256:71a5535d07debc9fa5cd679296a83c096b9eef5280aa2b3fc8ae707b86c67335", "reviewer": "agent-skill", - "findings": 4, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T00:58:37Z" + "findings": 0, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:11:49Z" }, { "unit": ".agents/skills/drive-pr/SKILL.md > Disposing of Every Finding", - "digest": "sha256:ae3a4a85828fbe73e68906cecfab20bf0c801c22482126c3e16f530aa8b71737", + "digest": "sha256:883689d8b9529430556667c1eba4d2d9aeba2760982697d999244a486a252a73", "reviewer": "agent-skill", - "findings": 4, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T00:58:38Z" + "findings": 2, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:30:52Z" }, { "unit": ".agents/skills/drive-pr/SKILL.md > How Far to Drive", @@ -203,11 +275,11 @@ }, { "unit": ".agents/skills/drive-pr/SKILL.md > The Drive Loop", - "digest": "sha256:f05bc847db251e692cf5e3a4e9aecd74448705aa7b9f478efbdb6908721c99be", + "digest": "sha256:d1a39100cc4e8a03f6386e763f734a1b8298897be960074f51d01566a143192c", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "dded739ff0ae8614b52b187a95b00460c0fad8ce", - "stamp": "2026-09-01T17:57:39Z" + "findings": 7, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:11:49Z" }, { "unit": ".agents/skills/drive-pr/SKILL.md > What Invoking This Skill Authorizes", @@ -227,11 +299,11 @@ }, { "unit": ".agents/skills/local-strict-review/SKILL.md > Disposing of Findings", - "digest": "sha256:568da8c763e51795bff9e294a07976fd1479b93b48d383e6d5b60fe23901d6b3", + "digest": "sha256:04502d7245f620e87df6a5d28e7ee6e4b92dd748c1dc86b9a884f0310b697d62", "reviewer": "agent-skill", - "findings": 5, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T00:58:38Z" + "findings": 2, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:11:49Z" }, { "unit": ".agents/skills/local-strict-review/SKILL.md > Mechanics Live Elsewhere", @@ -251,19 +323,19 @@ }, { "unit": ".agents/skills/local-strict-review/SKILL.md > Running It", - "digest": "sha256:612b1fed21acfd0fd5d1adf78109b4abcb4fe497218b30fbd99e3263f7b197d3", + "digest": "sha256:f9e0ed6fcb2b798cbf46efa239a5c0a039cf261f3b917ab8d110e2cc0e59136c", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "16f2e32a037857dfcd68b114f006ca2d0cf12ada", - "stamp": "2026-09-04T02:11:36Z" + "findings": 3, + "hubCommit": "28872b4ab08d10d48e8dc0eecd94cdf257633e7d", + "stamp": "2026-09-04T21:20:13Z" }, { "unit": ".agents/skills/local-strict-review/SKILL.md > The Carried-Content Pass", - "digest": "sha256:1bd1396389709aa388ba5b984cd11cc1335bb7b13da107c32bcc336feb816154", + "digest": "sha256:0e2902a43d228b74045c7310325fbd7f399f279855e261bbce12cb4f0161ab0d", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "e97708dfe4c92e275118a0705556822993de9f32", - "stamp": "2026-08-31T16:17:12Z" + "findings": 3, + "hubCommit": "ddca03490f0517fe786ef1d58aff3b8eaf19616b", + "stamp": "2026-09-04T16:24:35Z" }, { "unit": ".agents/skills/local-strict-review/SKILL.md > What It Does", @@ -275,11 +347,11 @@ }, { "unit": ".agents/skills/local-strict-review/SKILL.md > When to Run It", - "digest": "sha256:2de88c5663772d0b2c255fa44a567d6ab04d3b50c67f038af194e910650c83ab", + "digest": "sha256:b0c7bff2d7fc345ab99ca9cbf33208fcb3afdd8d985418995f1d3186d4df3f4c", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "44ae00bd432e1eeaafeb00208d8e713a22f49076", - "stamp": "2026-09-01T18:51:57Z" + "findings": 3, + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:11:49Z" }, { "unit": ".agents/skills/operational-vs-release-workflow/SKILL.md > (preamble)", @@ -289,6 +361,22 @@ "hubCommit": "eb6d2a055dc590113f515aed7cac1e019b7d2111", "stamp": "2026-09-03T18:32:16Z" }, + { + "unit": ".agents/skills/operational-vs-release-workflow/SKILL.md > Publishing (release model)", + "digest": "sha256:0274bfe3eba4544b3680c4fd25da9da161adcba4269345b2f08bb60ce3688730", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > (preamble)", + "digest": "sha256:03b65b37ef0079e48a7628641dab7f0b79b326782127ed841928aa9235703e8b", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, { "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Map your outputs to the right seam", "digest": "sha256:42eb378e35d5810c91bfc40f3a56785f2fae5d6fe772b7ba9e9ed3c6b8a6d13c", @@ -321,6 +409,14 @@ "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", "stamp": "2026-09-01T14:33:08Z" }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Recovering a failed registry push", + "digest": "sha256:78f378a68cb9bb4401ebb9815103a4a973a100e507c64eb9a62137c1c454c5b6", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" + }, { "unit": ".agents/skills/pr-review-conduct/SKILL.md > Answering a suppressed finding", "digest": "sha256:7d1d9acd0646b16f9a41debbc44b97926a8bb43b515ab00e1298d32efb898fc7", @@ -339,19 +435,19 @@ }, { "unit": ".agents/skills/pr-review-conduct/SKILL.md > Every finding ends in one of five outcomes", - "digest": "sha256:f7292e072c812adc3d9f3d7302181978eaf5c313c6c60efd6c7deece6578b1da", + "digest": "sha256:26a9201900a60d33f5b89ed5a7b274d430b62dad1136a0b80ef45af4dfc8c9f6", "reviewer": "agent-skill", "findings": 4, - "hubCommit": "0e6aacf45334149f6158aa23b8132d6aee1d1eb0", - "stamp": "2026-09-01T16:06:00Z" + "hubCommit": "fbf1f6d9dc713d6e187d2c3d6542490a2839fe55", + "stamp": "2026-09-05T16:30:52Z" }, { "unit": ".agents/skills/pr-review-conduct/SKILL.md > Expected review loop", - "digest": "sha256:129b6445b11e494700c899653ebd57db9efd000053e16662bec6de650719abb4", + "digest": "sha256:efde1221d83911ef2a0e72b45729130ab9e8f10950f9546697190c5074bb141f", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "0e6aacf45334149f6158aa23b8132d6aee1d1eb0", - "stamp": "2026-09-01T16:37:57Z" + "findings": 4, + "hubCommit": "28872b4ab08d10d48e8dc0eecd94cdf257633e7d", + "stamp": "2026-09-04T23:44:02Z" }, { "unit": ".agents/skills/pr-review-conduct/SKILL.md > Mechanics Live Elsewhere", @@ -361,6 +457,14 @@ "hubCommit": "b03a838ad24e3ff23ae6eeedb89571130a285ce6", "stamp": "2026-09-01T17:49:26Z" }, + { + "unit": ".agents/skills/pr-review-conduct/SKILL.md > Merge Gate, check this before merging or enabling auto-merge", + "digest": "sha256:9fec3aa6f4a0c13134d0c41183b4ada8dcb70a544e6796e7f558996969edae73", + "reviewer": "agent-skill", + "findings": 10, + "hubCommit": "4ee4669af5930d842c07ff5daabda6ee0ef621bd", + "stamp": "2026-09-05T00:57:49Z" + }, { "unit": ".agents/skills/python-codestyle/SKILL.md > Local development loop", "digest": "sha256:64911d87dea3c5c46bbe2abccab0bed590ee65a3e93e9083b403ceee77bde450", @@ -371,11 +475,11 @@ }, { "unit": ".agents/skills/python-codestyle/references/testing.md", - "digest": "sha256:58de79a2634bd61f5d3d411b550b99537bba437a23a36f0e6995dd74ea67b1c2", + "digest": "sha256:94f61df9b261dbbce373b216b2393492ebd547778dee92d2bac61c892e30a00d", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "e56d99db54bd0d89f44904b7e76de366ca41c124", - "stamp": "2026-09-03T17:48:49Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" }, { "unit": ".agents/skills/repo-worktree/SKILL.md > Creating a Worktree", @@ -417,6 +521,38 @@ "hubCommit": "a76dda7d67033f43c260d21e3ae6a291e1869c0a", "stamp": "2026-09-01T06:46:39Z" }, + { + "unit": ".agents/skills/skill-lifecycle/SKILL.md > (preamble)", + "digest": "sha256:6f8099d2cba08c13cc553b4d9b0e8d3d1388d5436777965b9abeeac838d9b9c3", + "reviewer": "agent-skill", + "findings": 1, + "hubCommit": "00832f397a18c656fb15d6972a6acab7ecbcc078", + "stamp": "2026-09-05T03:54:48Z" + }, + { + "unit": ".agents/skills/skill-lifecycle/SKILL.md > Changing or Retiring a Skill", + "digest": "sha256:fd8c57baf48d969a67218d1defdf7be10f277baa832190aba32dedbb9d8001a2", + "reviewer": "agent-skill", + "findings": 4, + "hubCommit": "00832f397a18c656fb15d6972a6acab7ecbcc078", + "stamp": "2026-09-05T03:58:10Z" + }, + { + "unit": ".agents/skills/skill-lifecycle/SKILL.md > The Doc-Packaging Pattern", + "digest": "sha256:357a856eed4481c8c1a75b87928b00b69d5b7cdd4866b82fd84134b179138dd0", + "reviewer": "agent-skill", + "findings": 3, + "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", + "stamp": "2026-09-06T17:51:06Z" + }, + { + "unit": ".agents/skills/skill-lifecycle/SKILL.md > The Pipeline", + "digest": "sha256:bb0710a02060264e1b093e5f2863d5d327d0c80aa4dd629c8e1ea24eac222a1a", + "reviewer": "agent-skill", + "findings": 1, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:16:36Z" + }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > (preamble)", "digest": "sha256:47ca6dfccbb8527b464aaa2c8fd113d536b8102f35635407b7b4e62663226425", @@ -427,91 +563,91 @@ }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > After Any Workflow Edit", - "digest": "sha256:7dc31b8f1e7e25effd4b85078f9099b89cf3663452ed0400cf255df019fbef0e", + "digest": "sha256:923c54f5bc02cf735badb6e0a897af7b26e15b8dd4e44418e52e4b8528241cff", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "f858a194ce29fa5702e2ce1c6ff8d1f3b221b2ee", - "stamp": "2026-09-01T23:24:57Z" + "findings": 0, + "hubCommit": "ee4c1ce3dc1a67520b00f2038136c252172f52f5", + "stamp": "2026-09-05T19:46:56Z" }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > How the Contract Is Read", - "digest": "sha256:89bd7614cb37469788ec836234482c1be2ebc6905bd786ad031504bfe3dd2239", + "digest": "sha256:d1c4af2c63eae9841c3ca5755a76f94f290367f5fb7cb5be1810d7e3be5ef028", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:23:11Z" + "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", + "stamp": "2026-09-06T20:18:39Z" }, { - "unit": ".agents/skills/workflow-ci-contract/SKILL.md > Style Rules That Break in One-Line Diffs", - "digest": "sha256:d96271fbc25a48086cf32de4df0bc16b224a553a704aa44dfbd93654bd27c2b2", + "unit": ".agents/skills/workflow-ci-contract/SKILL.md > Style Rules", + "digest": "sha256:6494d55d844f4dceebab5ca006f7319848f8120ce925cbcba73db36861e42dee", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:33:08Z" + "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", + "stamp": "2026-09-06T00:53:56Z" + }, + { + "unit": ".agents/skills/workflow-ci-contract/SKILL.md > The Contract Text", + "digest": "sha256:442d4210b0725f9df860f2a7c770c3a829e9bb28213f2c0f8aa2e27f13b11c31", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > The Core Behavioral Spine", - "digest": "sha256:91fbccc9e042f0ad82322b032f1efbd30590db572ae022b13c0ef537a6928ce9", + "digest": "sha256:c67f5b8b324d7a796098ba4bb04174b1c07b7da05627416cefe7d149a93a1719", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "3f1cb6a107d860b6dec65357fdc4787a8492ba39", - "stamp": "2026-09-03T18:56:39Z" + "findings": 2, + "hubCommit": "ee4c1ce3dc1a67520b00f2038136c252172f52f5", + "stamp": "2026-09-05T19:43:31Z" }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > Why This Exists", - "digest": "sha256:713456da9774e1d979fdef3a31cf90099bd3287e28715754a2d8266979311a53", + "digest": "sha256:d89e6f3b20c6ec6ca8307fae5346303fc0e0c9eb439f3b960b75f0733a7d836c", "reviewer": "agent-skill", - "findings": null, - "hubCommit": "a6c7bb1d0831b5c41a905b0c957661dc14f5f566", - "stamp": "2026-09-03T22:06:23Z" + "findings": 0, + "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", + "stamp": "2026-09-06T17:51:06Z" }, { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > (preamble)", - "digest": "sha256:f5ff09431c97a4c1c86d29b387a686805c467e7761d54e37ca61e75d4d0b2173", + "unit": ".agents/skills/workflow-ci-contract/references/architecture.md > (preamble)", + "digest": "sha256:6d9507914d458b12d94684f297b13de873beae3f48dc0a040a5757ca90e8b566", "reviewer": "agent-skill", - "findings": 5, - "hubCommit": "99f8de2209405d2dfa8b6d648dc42d55ee3de060", - "stamp": "2026-09-02T18:38:20Z" + "findings": 3, + "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", + "stamp": "2026-09-06T17:51:06Z" }, { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D1: PR Fast-Feedback (Smoke)", - "digest": "sha256:6c57b1c6eed34177bc1f1dacef05725efc19e0b385b103a6d0a01c9265b41e93", + "unit": ".agents/skills/workflow-ci-contract/references/architecture.md > The Architecture", + "digest": "sha256:aab6516648fdedbce7acaa83f91b0fea64accb7aa82a67e43a1f58a52e14649f", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "4fdc718663d0149994e496e890c20c7a77f27c96", - "stamp": "2026-09-03T03:12:20Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D4: Release and Publish", - "digest": "sha256:2c8b0653258589c31a3f84491da60752deb95131bb5d2a2154a8465bacab69bf", + "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > (preamble)", + "digest": "sha256:62ab330411b5d4fb24146921d173bf78005af48b9abffb00dce980f06947ae87", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "16daf0dc20e9f11a1485b6835b4f064d6907b273", - "stamp": "2026-09-03T01:05:09Z" + "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", + "stamp": "2026-09-06T18:08:47Z" }, { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D5: Resource Cleanup", - "digest": "sha256:37ab54b40d243ff4d1f3864dedcf6e0342a0091003ea28d1283f46f16857bd0e", + "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > The Behavioral Contract", + "digest": "sha256:94614a4246ec43214b8043e09521dccd0932f5fa035cf6be3103902befa6d1d0", "reviewer": "agent-skill", - "findings": 62, - "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", - "stamp": "2026-09-02T15:16:10Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D6: Seam Conformance", - "digest": "sha256:6d5a39e3a432df61b6a8a0a839584a25edb8d30db4d7e768cc3232fc83b26b74", + "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > (preamble)", + "digest": "sha256:12773b882e5a37f82e1f710eb8081887a46daf54e7da5535509f46a77d24d18d", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:23:11Z" - }, - { - "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D7: Concurrency, Permissions, Safety", - "digest": "sha256:c033b50b73c24d96575a7abe6b481323d7b4107eae174340a27430db49af936f", - "reviewer": "agent-skill", - "findings": 14, - "hubCommit": "30c465ae79de328d2745c31df1b05f0d960c42b7", - "stamp": "2026-09-02T15:40:54Z" + "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", + "stamp": "2026-09-06T20:21:42Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > 5A: Static Audit", @@ -537,6 +673,14 @@ "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", "stamp": "2026-09-03T18:19:22Z" }, + { + "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > The Test Methodology", + "digest": "sha256:37b196d79a98a292065b666a6be490736688646025e2f15d768034bff3725b00", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > Verdict", "digest": "sha256:fca3f384bfead20bbf4da5381ada9abf417451d72f8e88a51a246fa910faf4a2", @@ -547,11 +691,11 @@ }, { "unit": ".github/copilot-instructions.md > Reviewing Carried Fleet Content", - "digest": "sha256:83570e2b15143c6f9b79e4d381b7a578d3ae7970d74ae676405a402f6d13630f", + "digest": "sha256:afb6352526c16cf708b82426214bf0b15725da70b85e2e053335263947a3573b", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "f0ff674ec0d59bb2c9ec3736ef22d9de205059c8", - "stamp": "2026-09-02T03:21:55Z" + "findings": 5, + "hubCommit": "00832f397a18c656fb15d6972a6acab7ecbcc078", + "stamp": "2026-09-05T04:49:01Z" }, { "unit": "AGENTS.md > Context and Delegation Discipline", @@ -563,11 +707,19 @@ }, { "unit": "AGENTS.md > Where the Rules Live", - "digest": "sha256:ee899615c15fae90078977de131abbbbc4f461ec6a751db26e5a63eaf8aa1961", + "digest": "sha256:ce4edaf6e8818b89bab224a63e6c4b7df0616d7f1f27a5fef17b7eda7e47b7dc", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "136661da1b978f793fd5bbd7daa1d95b7338a919", - "stamp": "2026-09-03T19:44:54Z" + "findings": 5, + "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", + "stamp": "2026-09-06T05:06:22Z" + }, + { + "unit": "AUDIT.md > (preamble)", + "digest": "sha256:a2ef5c014adb4303ae7eac8a6bdf8569964529a5733ef6ce65c35efef6147454", + "reviewer": "agent-skill", + "findings": 1, + "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", + "stamp": "2026-09-06T20:42:51Z" }, { "unit": "AUDIT.md > 10. Converge: Apply the Fixes", @@ -577,6 +729,14 @@ "hubCommit": "c3b2898feef97459d01ce7c0b63de25b6c5524bf", "stamp": "2026-09-01T13:57:20Z" }, + { + "unit": "AUDIT.md > 3. Applicability Gate", + "digest": "sha256:5a93264a923298617dc9ed670ff4a3acd3640012370d8453904825083ac2c0bd", + "reviewer": "agent-skill", + "findings": 0, + "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", + "stamp": "2026-09-06T20:42:51Z" + }, { "unit": "AUDIT.md > 4. Per-Dimension Checks (Letter and Intent)", "digest": "sha256:083d3ca4108c8b1d047e086924360dba45106e62aad5546636a6cbf468271143", @@ -585,6 +745,30 @@ "hubCommit": "78898becaa2b1a62cb4c806d86273210c6390ac5", "stamp": "2026-09-01T14:48:23Z" }, + { + "unit": "AUDIT.md > 5. Assert the Actions Implement WORKFLOW.md", + "digest": "sha256:f4f013b448b1e2cf76519c624db36cecdc7288eb5235f077d708f4079df0d54c", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, + { + "unit": "AUDIT.md > 6. Validate Settings, Rulesets, and Secrets", + "digest": "sha256:cadbf5a3b36bdc9ec871eb907565c582bed70c2e4098c6a99fd756cd2b623489", + "reviewer": "agent-skill", + "findings": 2, + "hubCommit": "db9e5695f1501cc894953bdd57db46e4faf74159", + "stamp": "2026-09-05T02:20:07Z" + }, + { + "unit": "CODESTYLE.md > .NET", + "digest": "sha256:df1c9031b93a98e07f6b4ff3a211b61d9938e04b7fbf305d7a60384f02892e66", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "CODESTYLE.md > General", "digest": "sha256:54fcc07fc743507089e95ab8f1e7aeb64f0d83e31716514dadb666871a495684", @@ -593,6 +777,14 @@ "hubCommit": "112ffd874eaec3784a747b341fbdd0b19d75502c", "stamp": "2026-09-02T03:09:17Z" }, + { + "unit": "CODESTYLE.md > Python", + "digest": "sha256:6afed757bc277970b32d5b61b8850bd6282ebd19e86e7fa6588b8b2c66558db8", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "CODESTYLE.md > Shell", "digest": "sha256:7cf33c9cf26f21399d2de62d1219da67a16f28acfacf81cbe349948d502cb7ef", @@ -601,13 +793,21 @@ "hubCommit": "df5e2493b48aa45d80f586db68af0c21be97ea21", "stamp": "2026-09-01T05:45:32Z" }, + { + "unit": "GOVERNANCE.md > Communicating with the User", + "digest": "sha256:bd373e66ff3c0aff366b81958997a4fdf8b4e289549e0fddd94d28ba7bdaa503", + "reviewer": "agent-skill", + "findings": 2, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:22Z" + }, { "unit": "GOVERNANCE.md > Durable Knowledge and Self-Improvement", - "digest": "sha256:cd5ef7be0c674a9ba29c3196ae81757ae18f7bb8ca82f573ffd77f96d363cc8e", + "digest": "sha256:1bc2f0a6ab7fec3315c3ce57bdbeec6a645832b27c9fb4c3b13ec281b39f76e0", "reviewer": "agent-skill", - "findings": 11, - "hubCommit": "52b0c550ab07ab58b940a353a83247d7451e76e5", - "stamp": "2026-09-02T01:11:58Z" + "findings": 5, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:22Z" }, { "unit": "GOVERNANCE.md > PR Review Etiquette", @@ -617,6 +817,14 @@ "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", "stamp": "2026-09-03T18:28:40Z" }, + { + "unit": "GOVERNANCE.md > Release Model", + "digest": "sha256:c06ac302758a576aa8f05777766a194e7e9da4756a821aa0bb43948e7ec25624", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "GOVERNANCE.md > Repository Layout", "digest": "sha256:b02bb961b5a0ac3b595b75cd01f6a9be91b5d6690a7f7b24bfe1f9c5660196b7", @@ -627,75 +835,75 @@ }, { "unit": "GOVERNANCE.md > Verification Discipline", - "digest": "sha256:d62a160f347578c5c2f922f280f21ced2e1d4a1adc9df6aed124ea2dce637628", + "digest": "sha256:bc13f5bc89894a95d7c4957e036ad8a09226a8587ee6ce6ed2fc1e62fefeef50", "reviewer": "agent-skill", - "findings": 15, - "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", - "stamp": "2026-09-03T18:39:43Z" + "findings": 2, + "hubCommit": "6aecb8b9b68b04156701a67ee14fbf5144b86332", + "stamp": "2026-09-05T14:21:22Z" }, { "unit": "GOVERNANCE.md > Workflow YAML Conventions", - "digest": "sha256:cbeb861edab7ed851e66c772f148c6102b5c1fe9ae8724d2fbd681a76fab673b", + "digest": "sha256:ab4254985a70c3cb81d8081b6dc493c225c3ffebf32f284e5b58b81a7609e409", "reviewer": "agent-skill", - "findings": 14, - "hubCommit": "30c465ae79de328d2745c31df1b05f0d960c42b7", - "stamp": "2026-09-02T15:40:54Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:39:31Z" }, { "unit": "WORKFLOW.md > (preamble)", - "digest": "sha256:ea8cdbdf569d5476aa300eee0f797f0318b1a488ea690955f1cc2244b620d3b3", + "digest": "sha256:2b31490cc52ca158dc3f4c089472d1f9a0fa8d9e0ac6d4ecd3e827fcbd18849b", "reviewer": "agent-skill", - "findings": 8, - "hubCommit": "99f8de2209405d2dfa8b6d648dc42d55ee3de060", - "stamp": "2026-09-02T19:16:34Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 1. Purpose and How to Use This Document", - "digest": "sha256:35a8c77043da983af04a46300f652d4e3b8a454815b10c380524832cfdbe4a70", + "digest": "sha256:037855116da21001793779382cb2def5105fd8043f9ba5df203e8adb2d0e60f6", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "c3b2898feef97459d01ce7c0b63de25b6c5524bf", - "stamp": "2026-09-01T13:57:20Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 2. Workflow Style Conventions", - "digest": "sha256:047d124867942f07a0e1ce0128de5664ac12b06046336dccf6d59139cff68297", + "digest": "sha256:65d2e1c4353d1e4ce2ed4ec4b544718411b9b288143a8af4db57b7d0af4f9e97", "reviewer": "agent-skill", - "findings": 11, - "hubCommit": "3cd97cf0ef28d000c83ddd54fa7925fe0c4623f9", - "stamp": "2026-09-02T15:56:03Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 3. Architecture", - "digest": "sha256:cbdfc5161d7951fc9f968257f9090aa8086f10f5fcb869d3a632d12ccfdb2dbe", + "digest": "sha256:563658039c240fa0c4c437f54ccaee046be759ddceaa1c58d5d5f516595fa2c3", "reviewer": "agent-skill", - "findings": 62, - "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", - "stamp": "2026-09-02T15:16:10Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 4. Behavioral Contract: Expected Outcomes", - "digest": "sha256:3616be733206b40f62b360374e08056785038ec7b528c7746ab0a5dfaffc34c3", + "digest": "sha256:5dc3d6cf3a20d039fc41f9cd26df5fb84f21d1a8f8b11dfdfd18fabf87e1db5a", "reviewer": "agent-skill", - "findings": 16, - "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", - "stamp": "2026-09-03T18:21:27Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 5. Test Methodology", - "digest": "sha256:e4c043871a8fe90f89741b36893a0a4b8cf8ddba18ca8259a4513a300931ed24", + "digest": "sha256:98172b5a40068285b2b37666bfe85032168f78aa5f402f7cdb54caf2a083e941", "reviewer": "agent-skill", - "findings": 12, - "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", - "stamp": "2026-09-03T18:21:33Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 6. Per-Project-Type Test Walkthroughs", - "digest": "sha256:dcf042d329e97ac6f76e08664a00776c3d68f20a4980b513eee76925d7a6d1d8", + "digest": "sha256:aa0bd2d323f81f253c79fab3ebd8d0015912aba1760233f7fcc6c3d79a958d16", "reviewer": "agent-skill", - "findings": 9, - "hubCommit": "398abeedb76d01d87437993255c49ade98d72c99", - "stamp": "2026-09-02T16:58:05Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:46:53Z" } ] } diff --git a/reports/canonical-review.md b/reports/canonical-review.md deleted file mode 100644 index f0f9bb5e..00000000 --- a/reports/canonical-review.md +++ /dev/null @@ -1,381 +0,0 @@ -# Canonical content review coverage - -Generated by `python3 scripts/canonical_review.py report`, and never hand-edited. Records are written by `canonical_review.py record` into [`reports/canonical-review.json`][ledger]. Git dates this file. - -A unit is what a reviewer reads whole, decided by the carry manifest rather than by the document. In the ordinary case that is one level-two section of a carried Markdown canonical, and `canonical_review.py list` names the whole set. It is **covered** when a recorded full-content pass names its current text, **stale** when a pass named earlier text, and **never** when no pass has read it here at all. A never-read unit is the backlog [ptr727/ProjectTemplate#1138][issue] records: the first real review of it happens in whichever repository carries it next. - -## Coverage - -- units: 304 -- covered: 87 -- stale: 0 -- never read here: 217 - -## Burn-down - -### .agents/skills/add-host-tool/SKILL.md - -- **(preamble)** - never -- **Establish the Contract** - never -- **Implement Each Platform** - never -- **Update the Complete Surface** - never -- **Verify** - never - -### .agents/skills/agent-conduct/SKILL.md - -- **(preamble)** - never -- **Before Assuming** - never -- **Delegation, in One Paragraph** - never -- **Why This Exists** - never - -### .agents/skills/audit-a-repo/SKILL.md - -- **(preamble)** - never -- **After the Report** - never -- **Before Measuring Anything** - never -- **Measuring** - never -- **Reporting** - never -- **Why This Exists** - never - -### .agents/skills/carried-instruction-file-guard/SKILL.md - -- **(preamble)** - never -- **Before you touch any of these four files** - never -- **If you are not sure which case you are in** - never -- **What is actually safe to overwrite without this procedure** - never -- **Why this exists** - never - -### .agents/skills/code-review/SKILL.md - -- **(preamble)** - never -- **Establish the Contract** - never -- **Publish Every Finding** - never -- **Review the Change** - never - -### .agents/skills/comment-and-doc-style/SKILL.md - -- **(preamble)** - never -- **Carried files reference no coordination machinery** - never -- **Character set** - never -- **Comments** - never -- **Line endings** - never -- **Markdown files: linting and spelling** - never -- **Markdown formatting** - never -- **Naming tools in prose** - never -- **Quantitative claims** - never -- **Sentence structure** - never -- **Why this exists** - never - -### .agents/skills/comment-and-doc-style/references/carried-doc-references.md - -- **(preamble)** - never -- **The two exceptions** - never -- **What is banned** - never -- **What is not a coordination reference** - never -- **Which files this governs** - never - -### .agents/skills/comment-and-doc-style/references/line-endings.md - -- **(preamble)** - never -- **Auditing** - never -- **Choosing an ending for a new file type** - never -- **Editing discipline** - never -- **Operational (config) repos** - never -- **Scripts and extensionless executables** - never -- **The defaults** - never - -### .agents/skills/comment-and-doc-style/references/markdown-links.md - -- **(preamble)** - never -- **Mechanics** - never -- **Naming a reference** - never -- **The definition block** - never -- **Where the rule applies** - never - -### .agents/skills/copilot-instructions-keeper/SKILL.md - -- **(preamble)** - never -- **Carrying it fresh, new repo or full resync** - never -- **Checking a repo's copy for drift** - never -- **The one thing this file has that others don't: repo-local ledger entries** - never -- **What this skill does not cover** - never -- **Why this exists** - never - -### .agents/skills/dotnet-codestyle/SKILL.md - -- **(preamble)** - never -- **Analyzer suppressions (.NET)** - never -- **Best practices** - never -- **Code patterns** - never -- **Coding standards and conventions** - never -- **Error handling and logging** - never -- **Project configuration** - never -- **Testing conventions** - never -- **Why this exists** - never - -### .agents/skills/dotnet-codestyle/references/conventions.md - -- **(preamble)** - never -- **C# language features** - never -- **Code structure** - never -- **Comments and documentation** - never -- **Naming conventions** - never - -### .agents/skills/dotnet-codestyle/references/project-config.md - -- **(whole file)** - never - -### .agents/skills/dotnet-codestyle/references/testing.md - -- **(whole file)** - never - -### .agents/skills/drive-pr/SKILL.md - -- **Mechanics Live Elsewhere** - never -- **Stop and Ask, Beyond the How-Far Question** - never -- **Why This Exists** - never - -### .agents/skills/fleet-conformance-check/SKILL.md - -- **(preamble)** - never -- **Answering "why isn't a fleet rule applying"** - never -- **Refresh cadence** - never -- **What it checks** - never -- **What it escalates instead of touching** - never -- **What it is safe to fix on its own** - never -- **Why this exists** - never - -### .agents/skills/git-commit-conventions/SKILL.md - -- **(preamble)** - never -- **History rewrites re-identify only what changed** - never -- **Identity, verified not set** - never -- **Never force push** - never -- **Never run destructive git commands without being asked** - never -- **Signing, verified not configured** - never -- **Staging versus committing** - never -- **Why this exists** - never - -### .agents/skills/git-commit-conventions/references/history-rewrite.md - -- **(whole file)** - never - -### .agents/skills/local-strict-review/SKILL.md - -- **Why This Exists** - never - -### .agents/skills/merge-and-release/SKILL.md - -- **(preamble)** - never -- **How Far to Go** - never -- **Mechanics Live Elsewhere** - never -- **Stop and Report, Never Guess** - never -- **The Procedure** - never -- **What Invoking This Skill Authorizes** - never -- **Why This Exists** - never - -### .agents/skills/operational-vs-release-workflow/SKILL.md - -- **Branching (release model)** - never -- **Operational repositories (the complete delta)** - never -- **Publishing (release model)** - never -- **Which model this repo uses** - never -- **Why this exists** - never - -### .agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md - -- **(preamble)** - never -- **App-token workflows use Client ID, not App ID** - never -- **Codegen determinism** - never -- **Configuring branch protection: don't hand-build the rules** - never -- **Dual-target bots** - never -- **Executing a `develop -> main` promotion safely** - never -- **Why both rulesets omit "Require branches to be up to date before merging"** - never - -### .agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md - -- **(preamble)** - never -- **Reusable-task parameter contract** - never -- **Wrapper repos that track an upstream release** - never - -### .agents/skills/pr-review-conduct/SKILL.md - -- **(preamble)** - never -- **Merge Gate, check this before merging or enabling auto-merge** - never -- **Triaging findings** - never -- **Why this exists** - never - -### .agents/skills/python-codestyle/SKILL.md - -- **(preamble)** - never -- **Code style** - never -- **Layout** - never -- **Linter cleanliness** - never -- **Tests** - never -- **Toolchain** - never -- **Two profiles** - never -- **Versioning** - never -- **Why this exists** - never - -### .agents/skills/python-codestyle/references/code-style.md - -- **(preamble)** - never -- **Comments** - never -- **Docstrings** - never -- **Formatting and linting** - never -- **Imports** - never -- **Naming** - never -- **Patterns to avoid** - never -- **Type hints** - never - -### .agents/skills/python-codestyle/references/profiles.md - -- **(preamble)** - never -- **Adapt before propagating** - never -- **Two profiles: full specification** - never -- **Versioning** - never - -### .agents/skills/repo-worktree/SKILL.md - -- **(preamble)** - never -- **Agent-Specific Worktree Tools** - never -- **Listing and Cleanup** - never -- **Preparing Git Hooks** - never -- **The Mandate** - never -- **Why This Exists** - never - -### .agents/skills/resync-a-repo/SKILL.md - -- **(preamble)** - never -- **Apply, in this order** - never -- **Confirm the procedure before starting** - never -- **Ship it** - never -- **Why this exists** - never - -### .agents/skills/shell-codestyle/SKILL.md - -- **(preamble)** - never -- **Why this exists** - never - -### .agents/skills/skill-lifecycle/SKILL.md - -- **(preamble)** - never -- **Changing or Retiring a Skill** - never -- **Creating a Skill** - never -- **Deciding a Topic Deserves a Skill** - never -- **The Doc-Packaging Pattern** - never -- **The Pipeline** - never -- **Why This Exists** - never - -### .agents/skills/standup-a-repo/SKILL.md - -- **(preamble)** - never -- **Apply, in order** - never -- **Before starting** - never -- **Onboarding a new repo type** - never -- **Ship it** - never -- **Why this exists** - never - -### .agents/skills/upstream-contribution-workflow/SKILL.md - -- **(preamble)** - never -- **The two-branch shape** - never -- **Use the upstream repo's own conventions, not the fleet's** - never -- **What stays governed by the fleet's own rules** - never -- **Why this exists** - never - -### .agents/skills/workflow-ci-contract/references/d-guarantees.md - -- **D2: Validation at Entry** - never -- **D3: Versioning and Classification** - never -- **D8: Bots and Automation** - never -- **D9: Style and Static** - never - -### .agents/skills/workflow-ci-contract/references/test-methodology.md - -- **(preamble)** - never - -### .editorconfig - -- **(whole file)** - never - -### .editorconfig-checker.json - -- **(whole file)** - never - -### .gitattributes - -- **(whole file)** - never - -### .github/copilot-instructions.md - -- **(preamble)** - never -- **Commit Messages and Pull Request Titles** - never -- **GitHub Copilot Review Runbook** - never -- **When in Doubt** - never - -### .markdownlint-cli2.jsonc - -- **(whole file)** - never - -### AGENTS.md - -- **Fleet Bootstrap** - never - -### AUDIT.md - -- **(preamble)** - never -- **0. When to Run and What "Done" Means** - never -- **1. Scope and Ground-Truth Branch** - never -- **2. Resolve the Repo's Type(s)** - never -- **3. Applicability Gate** - never -- **5. Assert the Actions Implement WORKFLOW.md** - never -- **6. Validate Settings, Rulesets, and Secrets** - never -- **7. Verdict Model** - never -- **8. Report** - never -- **9. Escalate** - never - -### CLAUDE.md - -- **(whole file)** - never - -### CODESTYLE.md - -- **(preamble)** - never -- **.NET** - never -- **Python** - never - -### GOVERNANCE.md - -- **Branching Model** - never -- **Communicating with the User** - never -- **Devcontainer** - never -- **Documentation Style Conventions** - never -- **Editor and Tasks** - never -- **Foundational Principles** - never -- **Git and Commit Rules** - never -- **Hub-Hosted Tooling** - never -- **Operational Repositories** - never -- **Pull Request Title and Commit Message Conventions** - never -- **Release Model** - never -- **Repository Boundaries and Write Safety** - never -- **Repository Details** - never -- **Representative Data in Agent-Authored Text** - never -- **Supported Development Platforms** - never - -### cspell.json - -- **(whole file)** - never - -### version.json - -- **(whole file)** - never - -## Declared but not held here - -A manifest path this hub does not itself carry, which is ordinary for one scoped to a project type this hub is not, and for a section the manifest names that the file does not hold. - -- codecov.yml - -[ledger]: ./canonical-review.json -[issue]: https://github.com/ptr727/ProjectTemplate/issues/1138 diff --git a/scripts/README.md b/scripts/README.md index 2a0ab67a..720834ff 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -248,9 +248,9 @@ Records and verifies that a full-content review pass covered each unit of the ca **A unit is what a reviewer reads whole, and [`spec/files.json`][files] decides which**, so `list` is the authority on the set and the rules below are stated once, here, rather than paraphrased into the rule text that routes to them. Membership starts at fidelity: an entry declaring `verbatim` or `intent`, or a tree declaring `verbatim-tree`, contributes units, while one declaring `interface` or no fidelity at all contributes none, its body being the carrying repo's own. Within that, an entry marked `whole`, or carrying no section list, contributes each level-two section of a Markdown file plus the region before the first where there is one, keyed ` > (preamble)`, and contributes the file entire where it carries no level-two heading. An entry carrying a section list and not marked `whole` contributes exactly those sections, which is [`spec/section-model.md`][section-model]'s fidelity unit and takes [`spec/divergences.json`][divergences]'s own ` >
` key. Anything that is not Markdown is one unit per file. A file the manifest carries by named sections contributes exactly those, so the two sections [`GOVERNANCE.md`][governance] keeps for itself are not units: no downstream copy holds them, since the audit's undeclared-heading check is what stops one from appearing there, and demanding a carrier's read of one would invent an obligation the manifest does not state. An `interface` entry contributes nothing for the same reason, its body being the carrying repo's own. The skills tree is keyed at [`.agents/skills/`][agents-skills], where a fix lands, rather than at the generated [`.github/skills/`][github-skills-dist] the manifest names, and `build_dist.py --check` is what holds the two equal. -**Coverage is over content, never over a commit.** A unit is covered while a recorded pass names its current digest, so editing the unit retires its pass and editing a neighboring section does not. `check` refuses only the units this branch's own diff moved, measured from the merge-base for `local_review.py`'s reason, and every unit nothing has read here yet is a burn-down entry in [`reports/canonical-review.md`][canonical-review-report] rather than a block on unrelated work. The ledger at [`reports/canonical-review.json`][canonical-review-ledger] holds one entry per unit, its most recent pass, sorted by unit key so a branch touching one unit merges cleanly against a branch touching another, and git keeps the history rather than the file. +**Coverage is over content, never over a commit.** A unit is covered while a recorded pass names its current digest, so editing the unit retires its pass and editing a neighboring section does not. `check` refuses only the units this branch's own diff moved, measured from the merge-base for `local_review.py`'s reason, and every unit nothing has read here yet is a burn-down entry `report` renders rather than a block on unrelated work. The ledger at [`reports/canonical-review.json`][canonical-review-ledger] holds one entry per unit, its most recent pass, sorted by unit key, and git keeps the history rather than the file. -`report --check` is the burn-down's own gate, on `build_dist.py --check`'s contract, and it exists because deleting a unit changes the set without changing any recorded digest, so `check` stays covered while the committed report still counts and lists a unit that is gone. Renaming a section of a file the manifest carries by named sections does the same, since the declared name then matches no heading. Renaming one in a file carried whole does not, `check` naming the new unit and demanding a pass for it. It runs on every event in CI where the coverage check runs only for a pull request, staleness being a property of the commit rather than of a comparison against a base, and it carries `!cancelled()` so an earlier failing step does not skip it. +**The ledger is state, and the burn-down is a rendering of it. A rendering is never committed.** `report` writes the burn-down to standard output, and the hub's [`.github/actions/validate`][validate-hook] hook writes that same rendering to the run's job summary on every event, so nothing writes it into the tree. The distinction is what lets concurrent branches merge: a ledger sorted by unit key puts two units' passes at two places, which git merges unless both are first-ever entries with no entry between them, where a rendering carrying global counts changes the same line to the same value on both sides, merges silently to a count one short of the ledger's, and fails any gate over it on the next unrelated pull request ([ptr727/ProjectTemplate#1268][ledger-merge-issue], [#1290][burndown-claim-issue]). Two capture points call it, and neither is load-bearing alone. [`.husky/pre-push`][pre-push] runs `check` beside its sibling and is bypassable by construction, and the hub's own [`.github/actions/validate`][validate-hook] hook runs the same check on every pull request, which is where it actually binds. The `local-strict-review` Skill's "The Carried-Content Pass" is the primary, agent-agnostic layer above both. @@ -259,11 +259,10 @@ python3 scripts/canonical_review.py list # every unit key and its digest. 0 python3 scripts/canonical_review.py status # covered, stale, or never read here, as JSON python3 scripts/canonical_review.py check # 0 covered, 1 a changed unit is uncovered, 2 could not run python3 scripts/canonical_review.py record --reviewer agent-skill --findings 2 --unit '=' -python3 scripts/canonical_review.py report # rewrites reports/canonical-review.md -python3 scripts/canonical_review.py report --check # 0 current, 1 stale. Read-only +python3 scripts/canonical_review.py report # renders the burn-down from the ledger to standard output ``` -`record` writes the ledger and rewrites the burn-down together, since a ledger and a report that disagree are two answers about one coverage. Both are tracked, so both have to be committed before the push, [`.husky/pre-push`][pre-push] refusing a tree that differs from HEAD before it runs either gate. The receipt above is not tracked and is recorded after the last commit instead, so the two records sit on opposite sides of it, and recording this one first, then committing it with the change, is the shortest order that satisfies both. `record` binds the digest to the read for `--expect-digest`'s reason above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. It refuses an unknown unit, a digest the content has moved past, and a reviewer outside `local_review.py`'s own backend vocabulary, since two spellings of one reviewer make the two records impossible to read together. A pass is recorded whatever it found, including nothing, for the same reason a receipt is: the record says a review ran over exactly this text, never that the text is clean. +`record` holds a lock across its read, merge, and write of the ledger, the lock `local_review.py` guards its receipt with, since two overlapping records would otherwise each read a ledger without the other's pass and the second write would drop one ([#1151][record-lock-issue]). The lock lives in the worktree's own git directory rather than beside the ledger, so a record killed mid-write leaves its lock where no add can stage it. The ledger is tracked and the receipt above is not, so the two records sit on opposite sides of the commit that carries the ledger, per [`GOVERNANCE.md`][governance] "Verification Discipline", and the `local-strict-review` Skill's "The Carried-Content Pass" gives the order. `record` binds the digest to the read for `--expect-digest`'s reason above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. It refuses an unknown unit, a digest the content has moved past, and a reviewer outside `local_review.py`'s own backend vocabulary, since two spellings of one reviewer make the two records impossible to read together. A pass is recorded whatever it found, including nothing, for the same reason a receipt is: the record says a review ran over exactly this text, never that the text is clean. Each entry's `hubCommit` is stamped as the merge-base against the target (`--target`, default `develop`). A branch's own tip is exactly what a squash merge discards and an amend moves, so a stamp anchored there resolves nowhere once the branch that recorded it is gone. The merge-base is ordinarily a commit the target's remote-tracking ref already holds, so it survives both ([ptr727/ProjectTemplate#1222][hubcommit-issue], [#1210][hubcommit-eight-issue]). A ledger entry can still name a commit unreachable from wherever a reader's own checkout stands. Such entries are left as recorded rather than rewritten, since inventing a commit for one would fabricate provenance nothing supports. Checking one by hand is `git merge-base --is-ancestor HEAD`, which exits `0` when it resolves as an ancestor here and nonzero otherwise, whether the object is entirely absent from the checkout or exists but sits off this branch. `record` resolves the target the same way `check` does, so an unresolvable target refuses the record. @@ -271,9 +270,11 @@ The exit code is three-valued on the same contract as its sibling. The boundarie ## `build_dist.py` -Regenerates [`.github/skills/`][github-skills-dist] and [`.claude-plugin/fleet-skills/`][fleet-skills-dist] from [`.agents/skills/`][agents-skills], the hub's own hand-authored fleet Skills. Codex and opencode read `.agents/skills/` directly, GitHub Copilot reads `.github/skills/`, and Claude Code reads the generated plugin published through [`.claude-plugin/marketplace.json`][marketplace]. `.agents/skills/` stays the one place a skill is hand-edited. Both generated trees are never hand-edited. +Regenerates [`.github/skills/`][github-skills-dist] and [`.claude-plugin/fleet-skills/`][fleet-skills-dist] from [`.agents/skills/`][agents-skills], the hub's own hand-authored fleet Skills. Codex and opencode read `.agents/skills/` directly, GitHub Copilot reads `.github/skills/`, and Claude Code reads the generated plugin published through [`.claude-plugin/marketplace.json`][marketplace]. `.agents/skills/` stays the one place a skill is hand-edited, its include regions excepted, since those are generated from the files they name. Both generated trees are never hand-edited. The plugin also carries one digest stamp per skill under `.claude-plugin/fleet-skills/.source-digests/`, so two changes to two skills touch two stamp files and merge, where one stamp over every skill's bytes conflicts on every concurrent skill edit. The stamps sit under the plugin rather than under `.github/skills/`, which [`spec/files.json`][files] carries whole to every repository. -`--check` is the read-only mode: it exits `1` when either generated tree differs from `.agents/skills/`, comparing a digest over every file rather than a file count or timestamp, and `2` on a real failure (a symlink under `.agents/skills/`, an unreadable file), so a caller reading the exit code can tell that apart from the stale finding. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. +A skill has to read whole in isolation and a rule has one home, so the text a skill needs from that home is generated into it rather than copied. A region between `` and `` in a skill source is filled with the body under that heading, the key being the root-relative path, then ` > `, then the heading text at any level from two, the delimiter `canonical_review.py` also keys a unit on. The fill lands in `.agents/skills/` itself, because Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the mirrors then carry the filled text. A source is any regular file under the repository root outside the two generated trees, reached through no symlink and spelled as the tree spells it, and a region filled from a file carrying regions of its own reads that file's filled text. Each marker sits on a line of its own, indented at most three spaces, since a marker shown in a code block, fenced or indented, is content. + +`--check` is the read-only mode: it exits `1` when an include region differs from what its source renders now, or when either generated tree differs from `.agents/skills/`, comparing a digest over every file rather than a file count or timestamp, and `2` on a real failure (a symlink under `.agents/skills/`, an unreadable file, an include region it cannot render: a heading that no longer resolves or recurs, an empty body or one leaving a code fence open, a malformed or cyclic region, a line that begins like a marker and matches neither form, a region in a file the walk does not visit, a refused source path, a file mixing line endings or not UTF-8), so a caller reading the exit code can tell that apart from the stale finding. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. ## `carry.py` @@ -298,9 +299,9 @@ Installs the fleet's Skills for the current machine, cross-platform and idempote [agents-skills]: ../.agents/skills/README.md [agents]: ../AGENTS.md [audit]: ../spec/audit.py +[burndown-claim-issue]: https://github.com/ptr727/ProjectTemplate/issues/1290 [canonical-review-issue]: https://github.com/ptr727/ProjectTemplate/issues/1138 [canonical-review-ledger]: ../reports/canonical-review.json -[canonical-review-report]: ../reports/canonical-review.md [copilot-instructions]: ../.github/copilot-instructions.md [divergences]: ../spec/divergences.json [editorconfig]: ../.editorconfig @@ -313,10 +314,12 @@ Installs the fleet's Skills for the current machine, cross-platform and idempote [host-tools]: ../spec/host-tools.json [hubcommit-eight-issue]: https://github.com/ptr727/ProjectTemplate/issues/1210 [hubcommit-issue]: https://github.com/ptr727/ProjectTemplate/issues/1222 +[ledger-merge-issue]: https://github.com/ptr727/ProjectTemplate/issues/1268 [marketplace]: ../.claude-plugin/marketplace.json [operations]: ../OPERATIONS.md [pre-push]: ../.husky/pre-push [prose-gate-action]: ../.github/actions/prose-gate/action.yml +[record-lock-issue]: https://github.com/ptr727/ProjectTemplate/issues/1151 [repos]: ../registry/repos.json [section-model]: ../spec/section-model.md [tests]: ./tests/ diff --git a/scripts/build_dist.py b/scripts/build_dist.py index ea3787db..11a2b221 100755 --- a/scripts/build_dist.py +++ b/scripts/build_dist.py @@ -6,12 +6,20 @@ directory, so this script materializes a plugin (.claude-plugin/fleet-skills/) that .claude-plugin/marketplace.json publishes. GitHub Copilot discovers repository skills under .github/skills/, so the script also materializes that tree. .agents/skills/ stays the single -place a skill's content is ever hand-edited. +place a skill is ever hand-edited, its include regions excepted, since those are generated from +the files they name. -Usage: python3 scripts/build_dist.py regenerate distributions from .agents/skills/ +A skill reads whole in isolation and a rule has one home, so the text a skill needs from that +home is generated into it rather than copied: a region between `` +and `` is filled with the body under that heading, in .agents/skills/ itself, and +--check fails when a region differs from what its source renders now. The key is the root-relative +path, then ` > `, then the heading text, the delimiter canonical_review.py also keys a unit on. + +Usage: python3 scripts/build_dist.py fill include regions, then regenerate distributions python3 scripts/build_dist.py --check read-only: exit 0 clean, 1 stale, 2 on a real failure (a symlink under .agents/skills/, an - unreadable file), so a caller reading the exit + unreadable file, an include region it cannot + render), so a caller reading the exit code can tell a finding apart from the check itself not having run. """ @@ -21,16 +29,20 @@ import argparse import hashlib import json +import os +import re import shutil import sys -from pathlib import Path +from pathlib import Path, PurePosixPath ROOT = Path(__file__).resolve().parent.parent SKILLS_SRC = ROOT / ".agents" / "skills" PLUGIN_NAME = "fleet-skills" DIST_PLUGIN = ROOT / ".claude-plugin" / PLUGIN_NAME PLUGIN_MANIFEST = DIST_PLUGIN / ".claude-plugin" / "plugin.json" -DIGEST_STAMP = DIST_PLUGIN / ".source-digest" +# One digest file per skill rather than one stamp over every skill's bytes, so two branches editing two skills touch two files and merge (ptr727/ProjectTemplate#1240). +# Under the plugin root and not under .github/skills/, which spec/files.json carries whole to every fleet repository. +DIGEST_DIR = DIST_PLUGIN / ".source-digests" GITHUB_SKILLS = ROOT / ".github" / "skills" @@ -88,18 +100,27 @@ def tree_digest(root, names): return h.hexdigest()[:16] -def source_digest(names): - return tree_digest(SKILLS_SRC, names) +def skill_digest(name): + """The digest of one skill's authored files, which is what its stamp under DIGEST_DIR holds.""" + return tree_digest(SKILLS_SRC, [name]) -def has_exact_skill_directories(root, names): - """Whether `root` exists and contains only the expected skill directories.""" +def has_exact_entries(root, names, directories): + """Whether `root` exists and holds exactly one entry per name, each a directory or each a file.""" if not root.is_dir() or root.is_symlink(): return False entries = list(root.iterdir()) - return all(entry.is_dir() for entry in entries) and {entry.name for entry in entries} == set( - names + # A symlink satisfies is_dir() and is_file(), which follow it, so an entry pointing outside the tree would otherwise pass as generated content. + shaped = all( + not entry.is_symlink() and (entry.is_dir() if directories else entry.is_file()) + for entry in entries ) + return shaped and {entry.name for entry in entries} == set(names) + + +def has_exact_skill_directories(root, names): + """Whether `root` exists and contains only the expected skill directories.""" + return has_exact_entries(root, names, directories=True) def expected_manifest(names): @@ -128,8 +149,315 @@ def write_plugin_manifest(names): ) +# --- Include regions ------------------------------------------------------------------------- +# A skill has to read whole in isolation, and a rule has one home, so the text a skill needs from that home is generated into it rather than copied. +# The region is filled in the authored tree itself, because Codex and opencode read .agents/skills/ directly and a region left empty there would hand them a skill with a hole in it. +# The generated trees then mirror the filled source. + +# The same ` > ` vocabulary canonical_review.py keys a unit on, defined here because that engine imports this module. +SECTION_DELIM = " > " +# Sources resolve against the repository root, so a key reads the same in a skill, a finding, and the review ledger. +INCLUDE_ROOT = ROOT +_INCLUDE_START = re.compile(r"^$") +_INCLUDE_END = re.compile(r"^$") +# A near miss: a comment beginning like a marker that neither pattern above accepts, case-folded so a capitalized one is caught too, and on any run of dashes so the three-dash opener a hand types on both markers is caught rather than read as content. +_INCLUDE_LIKE = re.compile(r"^\n{body}\n" + + def skill_text(self, name: str = "foo") -> str: + return (self.skills_src / name / "SKILL.md").read_text(encoding="utf-8") + + def test_regenerate_fills_a_region_from_its_source_heading(self) -> None: + """The region holds the heading's body, not its heading line, with one blank line each side.""" + self.make_skill( + "foo", "# Foo\n\n## Scope\n\n" + self.region("RULES.md > Alpha") + "\nTail.\n" + ) + build_dist.regenerate() + self.assertEqual( + self.skill_text(), + "# Foo\n\n## Scope\n\n\n\nAlpha rule.\n\n- One\n- Two\n\n" + "\n\nTail.\n", + ) + self.assertFalse(build_dist.is_stale()) + + def test_a_filled_region_reaches_both_generated_trees(self) -> None: + self.make_skill("foo", self.region("RULES.md > Beta")) + build_dist.regenerate() + for tree in (self.dist_plugin / "skills", self.github_skills): + self.assertIn("Beta rule.", (tree / "foo" / "SKILL.md").read_text(encoding="utf-8")) + + def test_regenerate_is_idempotent(self) -> None: + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + once = self.skill_text() + build_dist.regenerate() + self.assertEqual(self.skill_text(), once) + + def test_a_file_without_a_region_is_never_rewritten(self) -> None: + """Mixed line endings in such a file survive, since nothing is generated into it.""" + self.make_skill("foo") + path = self.skills_src / "foo" / "SKILL.md" + path.write_bytes(b"one\r\ntwo\nthree\r\n") + build_dist.regenerate() + self.assertEqual(path.read_bytes(), b"one\r\ntwo\nthree\r\n") + + def test_a_hand_edited_region_reports_stale(self) -> None: + """Acceptance: build_dist.py --check fails when an include region is edited by hand.""" + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + path = self.skills_src / "foo" / "SKILL.md" + path.write_text( + self.skill_text().replace("Alpha rule.", "Alpha rule, reworded."), encoding="utf-8" + ) + self.assertEqual(build_dist.include_drift(), [".agents/skills/foo/SKILL.md"]) + self.assertTrue(build_dist.is_stale()) + + def test_an_edited_source_section_reports_stale(self) -> None: + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + (self.tmp / "RULES.md").write_text( + self.HOME.replace("Alpha rule.", "Alpha rule, v2."), encoding="utf-8" + ) + self.assertTrue(build_dist.is_stale()) + build_dist.regenerate() + self.assertIn("Alpha rule, v2.", self.skill_text()) + self.assertFalse(build_dist.is_stale()) + + def test_a_renamed_source_heading_is_a_failure_not_a_stale_result(self) -> None: + """Acceptance: --check fails when the source moves, and regenerating cannot repair a key.""" + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + (self.tmp / "RULES.md").write_text( + self.HOME.replace("## Alpha", "## Alpha Renamed"), encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "no heading 'Alpha'"): + build_dist.is_stale() + with self.assertRaisesRegex(ValueError, "no heading 'Alpha'"): + build_dist.regenerate() + + def test_check_reports_a_broken_key_as_2_and_a_stale_region_as_1(self) -> None: + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + path = self.skills_src / "foo" / "SKILL.md" + path.write_text(self.skill_text().replace("- Two", "- Two, edited"), encoding="utf-8") + argv = sys.argv + try: + sys.argv = ["build_dist.py", "--check"] + with contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(build_dist.main(), 1) + self.assertIn(".agents/skills/foo/SKILL.md", err.getvalue()) + (self.tmp / "RULES.md").write_text("## Other\n\nx\n", encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(build_dist.main(), 2) + finally: + sys.argv = argv + + def test_a_heading_matches_case_folded_and_at_any_level_from_two(self) -> None: + (self.tmp / "RULES.md").write_text( + "## Top\n\nTop text.\n\n### Inner Rule\n\nInner text.\n\n#### Deeper\n\nDeeper text.\n\n### Next\n\nNext text.\n", + encoding="utf-8", + ) + self.make_skill("foo", self.region("RULES.md > inner rule")) + build_dist.regenerate() + self.assertIn( + "Inner text.\n\n#### Deeper\n\nDeeper text.\n\n", self.skill_text() + ) + self.assertNotIn("Next text.", self.skill_text()) + + def test_two_matching_headings_are_refused(self) -> None: + (self.tmp / "RULES.md").write_text( + "## Same\n\na\n\n## Other\n\nb\n\n## same\n\nc\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > Same")) + with self.assertRaisesRegex(ValueError, "2 headings match"): + build_dist.regenerate() + + def test_a_heading_and_a_marker_inside_a_code_fence_are_content(self) -> None: + (self.tmp / "RULES.md").write_text( + "## Alpha\n\nReal.\n\n```text\n## Beta\n\n```\n\nStill alpha.\n\n## Beta\n\nBeta.\n", + encoding="utf-8", + ) + self.make_skill( + "foo", + "```markdown\n\n```\n\n" + + self.region("RULES.md > Alpha"), + ) + build_dist.regenerate() + text = self.skill_text() + self.assertIn("\n```", text) + self.assertIn( + "```text\n## Beta\n\n```\n\nStill alpha.\n\n", + text, + ) + self.assertFalse(build_dist.is_stale()) + + def test_an_include_of_a_file_with_regions_reads_its_filled_text(self) -> None: + """A region filled from a sibling skill carries what that skill renders, without its markers.""" + self.make_skill( + "bar", "## Shared\n\n" + self.region("RULES.md > Alpha") + "\n## Own\n\nOwn.\n" + ) + self.make_skill("foo", self.region(".agents/skills/bar/SKILL.md > Shared")) + build_dist.regenerate() + self.assertEqual( + self.skill_text(), + "\n\nAlpha rule.\n\n- One\n- Two\n\n\n", + ) + + def test_an_include_cycle_is_refused(self) -> None: + self.make_skill("bar", "## B\n\n" + self.region(".agents/skills/foo/SKILL.md > A")) + self.make_skill("foo", "## A\n\n" + self.region(".agents/skills/bar/SKILL.md > B")) + with self.assertRaisesRegex(ValueError, "include cycle"): + build_dist.regenerate() + + def test_a_malformed_region_is_refused(self) -> None: + cases = { + "no end": "\n", + "no start": "\n", + "nested": "\n\n\n", + "no delimiter": self.region("RULES.md"), + } + for label, body in cases.items(): + with self.subTest(label): + self.make_skill("foo", body) + with self.assertRaises(ValueError): + build_dist.regenerate() + + def test_a_near_miss_marker_is_refused_rather_than_read_as_content(self) -> None: + """A line that begins like a marker and matches neither form is a failure, not content. + + Read as content it would leave the region unfilled while --check exits 0, which is the + hole in a skill the mechanism exists to prevent, so it is refused wherever the scan meets + it: in the skill, and in a source the skill includes. + """ + cases = { + "no colon": "\n", + "capitalized": "\n\n", + "trailing text after open": " tail\n", + "trailing text after close": "\n tail\n", + "three-dash opener on both": "\n\n", + } + for label, body in cases.items(): + with self.subTest(label): + self.make_skill("foo", body) + with self.assertRaises(ValueError) as caught: + build_dist.regenerate() + self.assertIn("begins like an include marker", str(caught.exception)) + (self.tmp / "RULES.md").write_text( + self.HOME + "\n\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > Alpha")) + with self.assertRaises(ValueError) as caught: + build_dist.regenerate() + self.assertIn("begins like an include marker", str(caught.exception)) + self.assertIn("RULES.md:", str(caught.exception)) + + def test_a_source_outside_the_root_or_under_a_generated_tree_is_refused(self) -> None: + outside = Path(self.enterContext(tempfile.TemporaryDirectory())) / "outside.md" + outside.write_text("## Alpha\n\nx\n", encoding="utf-8") + (self.tmp / "link.md").symlink_to(outside) + self.github_skills.mkdir(parents=True, exist_ok=True) + (self.github_skills / "gen.md").write_text("## Alpha\n\nx\n", encoding="utf-8") + for rel in ( + "../outside.md", + str(outside), + "link.md", + ".github/skills/gen.md", + "missing.md", + ): + with self.subTest(rel): + self.make_skill("foo", self.region(f"{rel} > Alpha")) + with self.assertRaises(ValueError): + build_dist.regenerate() + + def test_a_crlf_skill_keeps_its_endings_when_filled(self) -> None: + self.make_skill("foo") + path = self.skills_src / "foo" / "SKILL.md" + path.write_bytes(b"# Foo\r\n\r\n\r\n\r\n") + build_dist.regenerate() + self.assertEqual( + path.read_bytes(), + b"# Foo\r\n\r\n\r\n\r\nBeta rule.\r\n\r\n\r\n", + ) + self.assertFalse(build_dist.is_stale()) + + def test_a_region_in_a_reference_file_is_filled_too(self) -> None: + self.make_skill("foo") + ref = self.skills_src / "foo" / "references" / "notes.md" + ref.parent.mkdir() + ref.write_text(self.region("RULES.md > Beta"), encoding="utf-8") + build_dist.regenerate() + self.assertIn("Beta rule.", ref.read_text(encoding="utf-8")) + self.assertIn( + "Beta rule.", + (self.github_skills / "foo" / "references" / "notes.md").read_text(encoding="utf-8"), + ) + + def test_mixed_line_endings_in_a_file_with_a_region_are_refused(self) -> None: + """Rendering would rewrite the rest of the file to one ending, which is the silent flattening the rule forbids.""" + self.make_skill("foo") + path = self.skills_src / "foo" / "SKILL.md" + path.write_bytes(b"one\r\n\n\r\n") + with self.assertRaisesRegex(ValueError, "mixes line endings"): + build_dist.regenerate() + self.assertEqual( + path.read_bytes(), b"one\r\n\n\r\n" + ) + + def test_an_empty_heading_body_is_refused(self) -> None: + (self.tmp / "RULES.md").write_text("## Empty\n\n## Next\n\nx\n", encoding="utf-8") + self.make_skill("foo", self.region("RULES.md > Empty")) + with self.assertRaisesRegex(ValueError, "body is empty"): + build_dist.regenerate() + + def test_an_indented_marker_is_content(self) -> None: + """Four spaces open an indented code block in CommonMark, so a marker there is a sample, not a region.""" + body = " \n \n" + self.make_skill("foo", body) + build_dist.regenerate() + self.assertEqual(self.skill_text(), body) + self.assertFalse(build_dist.is_stale()) + + def test_a_symlinked_skill_directory_is_refused_before_any_fill(self) -> None: + """The fill writes through whatever the walk found, so the symlink check has to run before it.""" + target = self.tmp / "elsewhere" + target.mkdir() + original = self.region("RULES.md > Beta") + (target / "SKILL.md").write_text(original, encoding="utf-8") + self.skills_src.mkdir(parents=True, exist_ok=True) + (self.skills_src / "foo").symlink_to(target, target_is_directory=True) + with self.assertRaises(ValueError): + build_dist.regenerate() + self.assertEqual((target / "SKILL.md").read_text(encoding="utf-8"), original) + + def test_a_directory_symlink_on_the_way_is_refused(self) -> None: + """A symlink inside the root can still alias a generated tree or the including file itself.""" + self.github_skills.mkdir(parents=True, exist_ok=True) + (self.github_skills / "gen.md").write_text("## Alpha\n\nx\n", encoding="utf-8") + (self.tmp / "docs").mkdir() + (self.tmp / "docs" / "link").symlink_to(self.github_skills, target_is_directory=True) + (self.tmp / "docs" / "alias").symlink_to(self.skills_src / "foo", target_is_directory=True) + for key in ("docs/link/gen.md > Alpha", "docs/alias/SKILL.md > A"): + with self.subTest(key): + self.make_skill("foo", "## A\n\n" + self.region(key)) + with self.assertRaisesRegex(ValueError, "through a symlink"): + build_dist.regenerate() + + def test_a_key_spelled_unlike_the_tree_is_refused(self) -> None: + """A case-insensitive host would resolve it and Linux CI would not, so neither may.""" + self.make_skill("foo", self.region("rules.md > Alpha")) + with self.assertRaises(ValueError): + build_dist.regenerate() + + def test_a_missing_source_is_reported_as_missing_and_a_misspelled_one_as_misspelled( + self, + ) -> None: + self.make_skill("foo", self.region("nowhere.md > Alpha")) + with self.assertRaisesRegex(ValueError, "not a file under the repository root"): + build_dist.regenerate() + with self.assertRaisesRegex(ValueError, "spelled as the tree spells it"): + build_dist._exact_case("rules.md", ("rules.md",)) + + def test_a_lone_cr_counts_as_a_third_ending(self) -> None: + self.make_skill("foo") + path = self.skills_src / "foo" / "SKILL.md" + path.write_bytes( + b"one\r\n\rtwo\r\n\r\n\r\n" + ) + with self.assertRaisesRegex(ValueError, "mixes line endings"): + build_dist.regenerate() + path.write_bytes(b"\r\r") + build_dist.regenerate() + self.assertEqual( + path.read_bytes(), + b"\r\rBeta rule.\r\r\r", + ) + + def test_a_source_with_text_around_its_own_region_renders_single_blank_lines(self) -> None: + self.make_skill( + "bar", "## Shared\n\nIntro.\n\n" + self.region("RULES.md > Beta") + "\nOutro.\n" + ) + self.make_skill("foo", self.region(".agents/skills/bar/SKILL.md > Shared")) + build_dist.regenerate() + self.assertEqual( + self.skill_text(), + "\n\nIntro.\n\nBeta rule.\n\nOutro.\n\n\n", + ) + + def test_a_region_in_a_file_the_walk_does_not_visit_is_refused(self) -> None: + """Such a region would read filled to an includer and stay empty on disk.""" + (self.tmp / "RULES.md").write_text( + "## Alpha\n\n" + self.region("RULES.md > Beta") + "\n## Beta\n\nb\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > Alpha")) + with self.assertRaisesRegex(ValueError, "does not walk is never filled"): + build_dist.regenerate() + readme = self.skills_src / "README.md" + readme.write_text("## Alpha\n\n" + self.region("RULES.md > Beta") + "\n", encoding="utf-8") + self.make_skill("foo", self.region(".agents/skills/README.md > Alpha")) + with self.assertRaisesRegex(ValueError, "does not walk is never filled"): + build_dist.regenerate() + + def test_a_level_one_heading_ends_a_body_and_cannot_be_a_key(self) -> None: + (self.tmp / "RULES.md").write_text( + "## A\n\nAlpha.\n\n# Title\n\n## B\n\nb\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > A")) + build_dist.regenerate() + self.assertEqual( + self.skill_text(), "\n\nAlpha.\n\n\n" + ) + self.make_skill("foo", self.region("RULES.md > Title")) + with self.assertRaisesRegex(ValueError, "no heading 'Title'"): + build_dist.regenerate() + + def test_an_indented_heading_is_a_boundary_as_it_is_to_the_audit(self) -> None: + """spec/audit.py and canonical_review.py split on an indented `## ` line, so the generator does too.""" + (self.tmp / "RULES.md").write_text( + "## A\n\nAlpha.\n\n ## Indented\n\nnot alpha\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > A")) + build_dist.regenerate() + self.assertEqual( + self.skill_text(), "\n\nAlpha.\n\n\n" + ) + + def test_a_doubled_blank_line_inside_an_indented_code_block_is_kept(self) -> None: + (self.tmp / "RULES.md").write_text( + "## A\n\nText.\n\n code one\n\n\n code two\n", encoding="utf-8" + ) + self.make_skill("foo", self.region("RULES.md > A")) + build_dist.regenerate() + self.assertIn(" code one\n\n\n code two", self.skill_text()) + + def test_a_doubled_blank_line_inside_a_fence_is_kept(self) -> None: + """Only the blank a dropped marker leaves is collapsed, since a fenced sample's blanks are its text.""" + (self.tmp / "RULES.md").write_text( + "## Alpha\n\n```python\ndef a():\n pass\n\n\ndef b():\n pass\n```\n", + encoding="utf-8", + ) + self.make_skill("foo", self.region("RULES.md > Alpha")) + build_dist.regenerate() + self.assertIn(" pass\n\n\ndef b():", self.skill_text()) + + def test_a_body_leaving_a_fence_open_is_refused(self) -> None: + (self.tmp / "RULES.md").write_text("## Alpha\n\n```text\nopen\n", encoding="utf-8") + self.make_skill("foo", self.region("RULES.md > Alpha")) + with self.assertRaisesRegex(ValueError, "leaves a code fence open"): + build_dist.regenerate() + + def test_fence_step_adds_the_spec_directory_to_sys_path_once(self) -> None: + before = len(sys.path) + for _ in range(3): + build_dist._fence_step("plain", None, 0) + self.assertLessEqual(len(sys.path) - before, 1) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_canonical_review.py b/scripts/tests/test_canonical_review.py index 4bc06eba..d5eb0ecd 100755 --- a/scripts/tests/test_canonical_review.py +++ b/scripts/tests/test_canonical_review.py @@ -596,6 +596,65 @@ def test_record_refuses_a_reviewer_the_engine_does_not_know(self) -> None: cr.EXIT_CANNOT_RUN, ) + def test_two_records_in_either_order_produce_one_ledger(self) -> None: + """The ledger is the state two branches merge, so the order two passes were recorded in + has to leave no trace in it beyond the stamps, or the same two passes on two branches + would be two different files.""" + alpha, beta = "DOC.md > Alpha", "DOC.md > Beta" + + def payload() -> dict[str, Any]: + data = json.loads((self.tmp / cr.LEDGER).read_bytes().decode("utf-8")) + for entry in data["passes"]: + entry.pop("stamp") + return data + + self.record(alpha) + self.record(beta) + first = payload() + (self.tmp / cr.LEDGER).unlink() + self.record(beta) + self.record(alpha) + self.assertEqual(first, payload()) + self.assertEqual([entry["unit"] for entry in first["passes"]], [alpha, beta]) + + def test_two_branches_recording_different_units_merge_without_conflict(self) -> None: + """Each branch records a pass over a different unit, and git merges the two ledgers + without a conflict.""" + ledger = cr.LEDGER + # Entries that sort between the two, since git merges two insertions only where unchanged lines separate them. + self.record("CONF.json") + self.record("DOC.md > Alpha") + run(self.tmp, "add", ledger) + run(self.tmp, "commit", "-m", "seed") + run(self.tmp, "branch", "lane-b") + self.record(f"{cr.AUTHORED_SKILLS}/demo/SKILL.md > Use It") + run(self.tmp, "commit", "-am", "lane a") + run(self.tmp, "checkout", "lane-b") + self.record("SECT.md > Carried") + run(self.tmp, "commit", "-am", "lane b") + run(self.tmp, "merge", "--no-edit", "task") + self.assertEqual(run(self.tmp, "ls-files", "--unmerged"), "") + self.assertEqual(len(cr.read_ledger(self.tmp)), 4) + + def test_a_held_lock_refuses_the_record_rather_than_writing_past_it(self) -> None: + """Two overlapping records would each read a ledger without the other's pass and the + second write would drop one, so a record that cannot take the lock records nothing.""" + lock = Path(str(cr.ledger_lock(self.tmp)) + ".lock") + lock.write_bytes(b"") + self.addCleanup(lock.unlink, missing_ok=True) + with unittest.mock.patch.object(cr, "LOCK_TIMEOUT", 0.2): + self.assertEqual(self.record("DOC.md > Alpha"), cr.EXIT_CANNOT_RUN) + self.assertEqual(cr.read_ledger(self.tmp), {}) + lock.unlink() + self.assertEqual(self.record("DOC.md > Alpha"), cr.EXIT_COVERED) + self.assertFalse(lock.exists(), "the record did not release its lock") + + def test_the_lock_lives_in_the_git_directory_rather_than_the_tree(self) -> None: + """A lock beside the ledger would be untracked content in `reports/` after a crash, + which a blanket add then commits.""" + git_dir = Path(run(self.tmp, "rev-parse", "--absolute-git-dir").strip()) + self.assertEqual(cr.ledger_lock(self.tmp).parent, git_dir) + def test_an_orphaned_pass_is_reported_rather_than_dropped(self) -> None: """Deciding a section moved rather than vanished is a reader's call, not this tool's.""" self.record("DOC.md > Alpha") @@ -686,34 +745,36 @@ def test_a_symlinked_carried_path_is_refused(self) -> None: class ReportCase(RepoCase): - def test_report_check_catches_a_burn_down_that_no_longer_describes_the_tree(self) -> None: - """A deleted unit changes no recorded pass, so `check` stays covered while the committed - report still counts and lists a unit that is gone.""" - self.quiet(["report"]) - self.assertEqual(self.quiet(["report", "--check"]), cr.EXIT_COVERED) - self.write("DOC.md", "intro\n\n## Alpha\n\na body\n") - self.assertEqual(self.quiet(["check"]), cr.EXIT_COVERED, "the premise moved") - self.assertEqual(self.quiet(["report", "--check"]), cr.EXIT_NOT_COVERED) - self.quiet(["report"]) - self.assertEqual(self.quiet(["report", "--check"]), cr.EXIT_COVERED) - - def test_recording_rewrites_the_burn_down(self) -> None: - """A ledger and a report that disagree are two answers about the same coverage.""" + def test_a_record_writes_the_ledger_and_nothing_else_into_the_tree(self) -> None: + """The burn-down carried global counts, so two branches each recording one pass merged + silently to a count one short of the ledger's. The ledger is the only tracked state.""" self.record("DOC.md > Alpha") - text = (self.tmp / cr.REPORT).read_bytes().decode("utf-8") + code, text = self.loud(["report"]) + self.assertEqual(code, cr.EXIT_COVERED) self.assertIn("- covered: 1", text) + untracked = run(self.tmp, "status", "--porcelain", "--untracked-files=all").splitlines() + self.assertEqual(untracked, [f"?? {cr.LEDGER}"]) + + def test_the_report_describes_the_tree_it_is_rendered_from(self) -> None: + """A deleted unit changes no recorded pass, so `check` stays covered, and a rendering + made after the deletion no longer counts the unit that is gone.""" + _, before = self.loud(["report"]) + self.assertIn("**Beta** -", before) + self.write("DOC.md", "intro\n\n## Alpha\n\na body\n") + self.assertEqual(self.quiet(["check"]), cr.EXIT_COVERED, "the premise moved") + _, after = self.loud(["report"]) + self.assertNotIn("**Beta** -", after) def test_the_report_names_every_outstanding_unit(self) -> None: - self.assertEqual(self.quiet(["report"]), cr.EXIT_COVERED) - text = (self.tmp / cr.REPORT).read_bytes().decode("utf-8") + code, text = self.loud(["report"]) + self.assertEqual(code, cr.EXIT_COVERED) for unit in self.units(): section = unit.split(cr.SECTION_DELIM, 1)[-1] if cr.SECTION_DELIM in unit else unit self.assertIn(section, text, f"{unit} is missing from the burn-down") def test_the_report_counts_a_recorded_pass(self) -> None: self.record("DOC.md > Alpha") - self.quiet(["report"]) - text = (self.tmp / cr.REPORT).read_bytes().decode("utf-8") + _, text = self.loud(["report"]) self.assertIn("- covered: 1", text) self.assertNotIn("**Alpha** -", text, "a covered unit is still listed as outstanding") diff --git a/spec/divergences.json b/spec/divergences.json index a283cd1d..9321c79c 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -11,6 +11,7 @@ { "path": "repo-config/operational/develop.json", "disposition": "retire", "reason": "The hub hosts the operational-model develop ruleset payload. A downstream repository is checked against the hub's main payload.", "tracking": null }, { "path": "repo-config/main.json", "disposition": "retire", "reason": "The hub hosts the shared main ruleset payload. A downstream repository is checked against the hub's main payload.", "tracking": null }, { "path": "repo-config/settings.json", "disposition": "retire", "reason": "The hub hosts the shared repository settings payload. A downstream repository is checked against the hub's main payload.", "tracking": null }, + { "path": "repo-config/labels.json", "disposition": "retire", "reason": "The hub hosts the fleet label set. A downstream repository is checked against the hub's main payload.", "tracking": null }, { "path": "repo-config/README.md", "disposition": "retire", "reason": "The hub hosts the repository-configuration reference beside the payloads and script it documents.", "tracking": null }, { "path": "spec/secrets.json", "disposition": "retire", "reason": "The adapted baseline/mechanisms carry never varied per repo: baseline applies to every fleet repo by definition, and mechanisms/targetMechanisms/typeMechanisms are computed centrally by spec/audit.py from the hub's own spec/secrets.json plus registry/repos.json, which a downstream copy could only restate or let drift. A downstream repository is checked against the hub's copy by running spec/audit.py from a hub checkout instead (ptr727/ProjectTemplate#993).", "tracking": null }, { "path": ".github/workflows/build-release-task.yml", "disposition": "retire", "reason": "The release chain is hub-hosted as a workflow_call task, per docs/reusable-workflows.md \"Stage 4: The Release Chain and the Docker Core\", so a downstream copy of this filename is retired rather than re-vendored: the caller stub a repo carries after adoption is publish-release.yml and test-pull-request.yml calling the hub task by pin, and no adopting repo carries a same-named local file. The ten carriers measured on develop at hub 7c67328 are PhotoCleaner, PlexCleaner, LanguageTags, MediaTools, Utilities, aiopurpleair, ESPHome-NonRoot, VSCode-Server-DotNetCore, KiCadLibrary, and homeassistant-purpleair. Delete the copy as each repo adopts the hub task; adoption is a separate, later change per repo (docs/reusable-workflows.md \"Rollout\" Stage 4).", "tracking": null }, diff --git a/spec/project-types.json b/spec/project-types.json index 3301a965..72b0c7f5 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -66,7 +66,7 @@ "detect": ["Dockerfile", "build-docker-task.yml"], "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], "checks": [ - { "id": "docker.cache.registry", "verdict": "intent", "assert": "Layer cache targets a registry tag (buildcache-), never type=gha.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "docker.cache.registry", "verdict": "intent", "assert": "Layer cache targets a registry tag (buildcache-), never type=gha.", "workflowRef": "WORKFLOW.md#d9---style--static" }, { "id": "docker.hub.readme", "verdict": "intent", "assert": "Where the Docker Hub overview differs from the project README, a Docker/README.md is published via the docker-readme task (Hub description is size-limited).", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, { "id": "docker.always.repush", "verdict": "letter", "assert": "The image always re-pushes on publish (base-image refresh), independent of the release-create skip.", "workflowRef": "WORKFLOW.md#d4---release--publish" } ] @@ -116,7 +116,7 @@ { "id": "hugo.build.strict", "verdict": "letter", "assert": "The site build fails on a generator warning rather than rendering around it (hugo --gc --minify --panicOnWarning), and the pull request gate and the deploy run the same build command rather than two variants.", "workflowRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke" }, { "id": "hugo.urls.parity", "verdict": "letter", "assert": "A URL contract gate compares the built tree against a committed list of the URLs that must render and the URLs that must redirect, and asserts a minimum length on each list before comparing it, since a truncated list makes every assertion below it pass vacuously. This is the type's check of record, standing in for the unit tests a site does not have.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, { "id": "hugo.output.uncommitted", "verdict": "letter", "assert": "The rendered output is produced in CI only: its roots are gitignored and untracked, and they are excluded from the prose, spelling, and Markdown gates along with any vendored third-party tree. A committed render is drift rather than a deliverable.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" }, - { "id": "hugo.generator.pinned", "verdict": "letter", "assert": "The generator is pinned by exact version and by a checksum of the downloaded artifact, verified before install, never installed from a floating action or a latest tag, since the site is reproducible only if the generator is. The pin is declared once, and where two workflows need it something asserts the two copies agree.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "hugo.generator.pinned", "verdict": "letter", "assert": "The generator is pinned by exact version and by a checksum of the downloaded artifact, verified before install, never installed from a floating action or a latest tag, since the site is reproducible only if the generator is. The pin is declared once, and where two workflows need it something asserts the two copies agree.", "workflowRef": "WORKFLOW.md#d9---style--static" }, { "id": "hugo.vendored.provenance", "verdict": "letter", "assert": "A vendored third-party tree records the upstream repository and the exact ref or commit it was taken from, or is carried by a mechanism that pins it, so a bot or a tracker can move it. An unpinned copy with no recorded origin cannot be updated, diffed against upstream, or audited for a security fix.", "intentRef": "GOVERNANCE.md#release-model" }, { "id": "hugo.deploy.environment", "verdict": "letter", "assert": "The deploy job binds a GitHub Environment and takes every host-specific value and its credential from that environment, so the workflow file names no host, path, or address. A reusable callee 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.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" }, { "id": "hugo.deploy.atomic", "verdict": "intent", "assert": "A release installs beside the retained ones under its own immutable id, and is published by moving a single pointer through a temporary and a rename, so no request observes a half-written site and the previous release stays on disk as a rollback target. The transport never deletes at the environment root.", "workflowRef": "WORKFLOW.md#d4---release--publish" },