diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index d1472b0..ed90f84 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -3,9 +3,9 @@ // Prose paragraphs and data-heavy tables or URLs are intentionally long. // Reflowing at 80 columns hurts readability and churns diffs. "MD013": false, - // MD033 (inline HTML) stays enabled so native markdown wins. + // MD033 (inline HTML) stays enabled so native Markdown wins. // HTML comments, used as reference-link dividers, pass it. - // The details and summary elements are allowed for GitHub collapsibles, which have no markdown equivalent. + // The details and summary elements are allowed for GitHub collapsibles, which have no Markdown equivalent. // Every other element still flags. "MD033": { "allowed_elements": ["details", "summary"] }, // Require fenced code blocks over the legacy 4-space-indented style. diff --git a/AGENTS.md b/AGENTS.md index dc41645..7d7ed09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,16 +8,16 @@ Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, an ## Fleet Bootstrap -This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. +This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. - **No repository yet, or a local tree with no remote.** Follow the hub's `STANDUP.md` from section 0. That file is hub-only and deliberately not carried, because a repository needing it cannot be relied on to hold a current copy. Note that nothing in it creates the GitHub repository, which is an outward-facing write requiring explicit permission, so section 0A is the list handed to the maintainer before anything else starts. - **A repository with no carried instruction set, or a partial one.** Carry the baseline per the hub's `STANDUP.md` sections 1A and 2, which resolve what this repository is owed from its declared types and workflow model. Absent files are not drift to re-vendor, they are a baseline that never arrived, and the two are fixed differently. -- **A repository with the instruction set, current or stale.** Follow the hub's `AUDIT.md` end to end, then apply what it finds per its section 10. An audit that reports drift and stops is half the procedure. +- **A repository with the instruction set, current or stale.** Follow the hub's `RESYNC.md`, which runs `AUDIT.md` end to end for the findings and then applies each one in an order that matters, since the rules govern what comes after them, a deletion must precede the re-vendor that would otherwise refresh the file, and only some findings are mechanically detectable at all. An audit that reports drift and stops is half the procedure. - **A repository that believes it is conformant.** Run the audit anyway and commit the report, because conformance asserted without a report is conformance nobody can check. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. -Two rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. +Three rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. **Reach the hub as a checkout of your own and fetch it immediately before reading it**, because a clone is whatever it last fetched rather than the branch it names, and work only in that checkout rather than in one that another task is using, per [`GOVERNANCE.md`](./GOVERNANCE.md) "Repository Boundaries and Write Safety" and "Hub-Hosted Tooling". And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. ## Context and Delegation Discipline @@ -68,13 +68,14 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). R | --- | --- | | Why the rules are shaped this way | `Foundational Principles` | | Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement` | -| Any push, API mutation, comment, label, or merge | `Repository Boundaries and Write Safety` | +| Any push, API mutation, comment, label, or merge, or which checkout the work happens in | `Repository Boundaries and Write Safety` | | Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` | | Committing, signing, rebasing, force-pushing | `Git and Commit Rules` | | Branch choice, promotion, keeping branches in sync | `Branching Model` | | Releasing, version bumps, publishing | `Release Model` | | A live config repo rather than a code repo | `Operational Repositories` | | Onboarding a repo or running a conformance sweep | `Repository Onboarding and Conformance` (hub only, not carried) | +| Running a fleet gate, the review digest, or the config script | `Hub-Hosted Tooling` | | Writing a commit message or pull request title | `Pull Request Title and Commit Message Conventions` | | Any prose, comment, doc, or line-ending change | `Documentation Style Conventions` | | Proving work actually happened | `Verification Discipline` | diff --git a/GOVERNANCE.md b/GOVERNANCE.md index e498f13..18d13ff 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -20,11 +20,14 @@ The specific rules in this file implement a few governing principles. Read these ## Repository Boundaries and Write Safety -A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform. Reads are unrestricted. The bounds below are on writes. +A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. - **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. - **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. If a query returns no id, stop rather than invent one to proceed. -- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (`repo-config/configure.sh` does exactly this). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (the hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. +- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. ## Representative Data in Agent-Authored Text @@ -55,11 +58,12 @@ Agent-authored text illustrates with data the agent constructed, never with data - **Executing a `develop -> main` promotion safely, two traps both learned the hard way:** - **Never delete `develop`.** A promotion PR's head *is* `develop`, so `gh pr merge --delete-branch` (and the repo's "Automatically delete head branches" toggle, which is why that toggle is [kept off](./repo-config/settings.json)) deletes `develop` itself. Merge a promotion with a plain `gh pr merge --merge`, no `--delete-branch`. If `develop` is ever lost this way, restore it to the merged PR's head SHA, which is still reachable as the merge commit's second parent: `gh api -X POST "repos///git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view --json headRefOid --jq .headRefOid)"`. - **Spurious EOL-only conflicts resolve by taking `develop`.** When develop declared workflow YAML as LF while main is still CRLF, `develop -> main` conflicts *whole-file* on those paths. develop's `required_linear_history` + PR rulesets forbid resolving on `develop` (no merge commit, no force-push), so resolve on a throwaway branch off `main`: `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take develop's side for the EOL-conflicted files (`git checkout --theirs `) **after confirming each is content-identical modulo EOL or that develop is a strict superset** (`diff <(git show :2:f|tr -d '\r') <(git show :3:f|tr -d '\r')`), then open that branch -> `main`. Verify no genuine main-only content is dropped (build/test where the repo supports it). +- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges into the **default branch** (`main`). A feature -> `develop` PR merges into `develop`, so the keyword never fires there. Reference the issue in the `develop` PR body if useful, but put the actual closing keyword on the promotion PR. If a `develop` PR merges with the keyword on it, the keyword does nothing and the issue stays open, so put it on the promotion PR body instead. **Closing by hand is the ordinary route wherever the keyword cannot fire, not a repair for a botched promotion.** An issue closes when its work is verifiably complete, and the keyword automates that for the common case where completion and promotion coincide rather than adding a second condition to it. Two cases fall outside the keyword and both close by hand: a promotion that merged without it, and work complete on `develop` with no promotion imminent, where waiting holds a finished issue open for however long the next promotion takes. Cite the squash SHA that completed the work, and re-read that commit against the branch before closing rather than trusting the claim that asked for the close, so the closing comment carries evidence a reader can check. - **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. - *Main:* the check is graph-based: it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds (rebasing develop onto main, or rewriting develop's history) exist but contradict the squash-only develop ruleset and the linearity invariant. - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status job` still gates merges. The only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - See [`repo-config/README.md`](./repo-config/README.md) "Rulesets" for the configured state. -- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos///rulesets"` (`gh ruleset` is read-only). The names are load-bearing, since this file and the workflows reference them. Operational repos import `repo-config/operational/develop.json` as their `develop` ruleset (the `main` ruleset is shared); [`configure.sh`](./repo-config/configure.sh) selects the right develop payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset. +- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the committed `repo-config/*.json` ruleset payloads via `gh api -X POST "repos///rulesets"` (`gh ruleset` is read-only). The names are load-bearing, since this file and the workflows reference them. Operational repos import `repo-config/operational/develop.json` as their `develop` ruleset (the `main` ruleset is shared), and the hub's `repo-config/configure.sh`, run from a hub checkout against the repository named on its command line, selects the right develop payload from the registry `workflowModel` automatically. **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset. - **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and the codegen workflow runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop, and both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no ecosystem or update-type guard): the required CI checks are the gate, not the bump magnitude, so a major that breaks the build fails its checks and never merges. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. @@ -76,20 +80,20 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - **Required check.** The `changes` job is in the `Check pull request workflow status job` aggregator's `needs` and **must succeed** (not just "not fail"), because a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass, and `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as `inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting.** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. -- **Orchestration vs. build: the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (single-branch publish plan), the `get-version` + `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the `build--task.yml` leaf tasks) is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern, so its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing). That makes it (the tag-the-commit + create-the-release + attach-the-assets logic) reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included**: name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. +- **Orchestration vs. build: the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (single-branch publish plan), the `get-version` + `github-release` jobs inside `build-release-task.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer (the `build--task.yml` leaf tasks) is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern, so its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing). That makes it (the tag-the-commit + create-the-release + attach-the-assets logic) reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included**: name your one asset `release-asset--` and the verbatim `github-release` globs it. Do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - **What a repo still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do. `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. - **Map your outputs to the right seam** - pick by where each artifact *goes*, not by language: - - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`. It deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file step but specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. + - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`. It deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file step but specifically `dotnet publish` of the console app, so replace it wholesale, don't adapt it. - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point), and PyPI contributes **no** `release-asset-*`. - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly and contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. + - *Filesystem on a host the project owns* (a static site, a config tree): a deploy leaf builds the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than the repository secret store. Its last step asserts what the host actually serves, the release id and the environment, never that the transport exited zero. Retention at the destination is bounded by a declared count, and one side is recorded as owning the prune: the deploy where its credential can observe the destination, the host where that credential is deliberately write-only. - *Source-only / no build* (validate + tag + release): this seam does not apply. A source-only repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is the standalone [`publish-release.yml`](./.github/workflows/publish-release.yml) on `workflow_dispatch`: a `validate` job (the repo's reusable validation task) gates a publish job that **inlines** NBGV for the tag and `action-gh-release` for the release (tag + auto source archive + README + LICENSE). - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. -- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. - **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor. NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.`, and `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`. Dependency bumps, CI/workflow fixes, and doc edits leave it untouched. - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version (a new feature, a behavior or API change, a breaking change), and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit. - **No post-release bump, and no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`, and a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height. - **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, `HISTORY.md`, and release notes name the version as `Version 1.0` (the `version.json` floor). NBGV owns the patch/build position, so a concrete three-part number in a doc is both wrong (the real build height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect, not a fix, and it has blocked a release. -- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not the feature -> develop PR.** GitHub auto-closes an issue only when the closing keyword merges into the **default branch** (`main`). A feature/develop PR merges into `develop`, so the keyword never fires there. Reference the issue in the develop PR body if useful, but put the actual closing keyword on the promotion PR. If a develop PR merges with the keyword on it, the keyword does nothing and the issue stays open, so put it on the promotion PR body instead. Close the issue by hand citing the squash SHA only when the promotion has already merged without it. - **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses `check-upstream-version-task.yml`: a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json`, since it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key, and the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately, which is the two-phase latency tradeoff. ## Operational Repositories @@ -99,12 +103,28 @@ The registry `workflowModel` field is `release` (the default) or `operational`. **Operational** repos track a live service's running state rather than shipping versioned units of delivery: live-service config such as Home Assistant, ESPHome, Vantage, and home automation. - **Commit configuration directly to `develop`.** There is no feature branch, and the maintainer commits straight to `develop` and *occasionally* opens a `develop -> main` PR to bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked) and CI runs on the push as **advisory** feedback that never rejects a commit. +- **A PR into `develop` stays available, and CI runs on it.** Dropping the requirement permits the direct push, it does not withdraw the pull request, so a change worth reviewing takes one and both paths into `develop` are legitimate. The lint workflow's `pull_request` trigger therefore names `develop` alongside `main`, since a trigger set naming `main` alone matches nothing on a `develop` PR and that PR would merge with no validation at all, which is the one output [WORKFLOW.md](./WORKFLOW.md) D1.2 forbids on *any* PR. The result is **reported and not required** there, because a required status check on `develop` would gate the direct push too and dissolve the allowance the model is built on. The asymmetry is the point: on the direct-commit path CI can only be advisory after the fact, while on the PR path the change has not landed yet, so validating it is both pre-merge and actionable. +- **Take the pull request whenever the change is not one a reader takes in at a glance and reverts cleanly.** The grant exists for the one-line config edit whose review round costs more than it protects, and that reason stops applying well before a change gets large, so what decides it is the shape of the change rather than a line count. Restructuring rather than adjusting a value, touching several files at once, introducing a device, an integration, or an automation that did not exist before, and anything whose failure shows up on the live service rather than in a lint run are each the pull request case. So is a change the author cannot state in one sentence, which is the same signal read from the other side. This stays guidance by construction, because adding a `pull_request` rule to the [operational `develop` ruleset](./repo-config/operational/develop.json) would gate the direct push too and withdraw the very allowance the model exists to give, so the judgment sits with whoever is about to commit and nothing mechanical will make it for them. - **The `main` promotion gate is unchanged.** The [`main` ruleset](./repo-config/main.json) is shared with `release` repos, so the `develop -> main` PR still **enforces** the required `Check pull request workflow status job`. For an operational repo that check is lint/validation only, meaning editorconfig/EOL plus domain linters (a Home Assistant or ESPHome config validation, a firmware build) and never unit tests, so `develop` stays the live surface and a broken config can never reach `main`. - **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and run no codegen or auto-publish bots, so they publish **only** on a manual `workflow_dispatch`, the source-only release the publisher already supports (tag + source zip + README + LICENSE, NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good snapshot, and a release is a separate, deliberate dispatch. - **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier, operational included, so both branches stay in sync and a promotion stays a clean forward merge. Line-ending governance for an operational repo is in [Line Endings](#line-endings), where its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet CRLF default. +## Hub-Hosted Tooling + +The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it, and a repository that misses the sweep does not fail loudly, it audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. + +**What a repository carries and what it reaches is decided by what the content is.** It carries the content it is audited against and the configuration that describes it, meaning its rule text, the files the manifest declares, and the ruleset payloads its own audit diffs the live configuration against. It reaches machinery whose content is identical in every repository, meaning the prose and repository gates, the review digest, and the configuration script, because a file holding no per-repo content is a copy whose only future is to go stale. A tool named in a carried rule is therefore named as the hub's, since the alternative is a pointer to a path the reader does not have, and a pointer that resolves nowhere teaches the reader that a pointer in carried text is decorative. + +**Reaching it is a checkout of the hub rather than a copy of one file.** A tool reads the payloads, tables, and sibling modules beside it, so a single file lifted out of the tree runs against whatever the caller happens to have, which is the copy problem again in a shorter loop. Read `main`, the promoted and gated state, and fetch immediately before running, because a clone is whatever it last fetched rather than the branch it names, and a stale clone answers confidently instead of failing. Name the tool by its path in that checkout and name the target explicitly, since a tool that defaults to the current directory or the current repository resolves somewhere either way, and a result computed against the wrong repository is well-formed. Which directory the command runs in is the tool's own contract rather than a rule here, so a gate reading a working tree runs in the repository under test while a tool taking its target as an argument runs anywhere and is given one. What the rule forbids is letting a default decide which repository the answer is about. + +**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. + +**CI reaches the same tooling as a pinned action.** A runner holds no hub checkout, so a workflow consumes the hub's composite action and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. + +**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. + ## Pull Request Title and Commit Message Conventions ### Format @@ -131,11 +151,11 @@ Clarify devcontainer setup steps in README ## Documentation Style Conventions -- **Carried files carry no coordination references.** In the files the fleet carries (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md`), two things are banned. **Any reference to the template repo**, in prose or in a link: the coordination flow is machinery a consumer of that repo should never have to see, and naming where a file came from is the derived-from framing the present-tense rule below independently forbids. Where a carried file must express a template-level behavior ("report a rule discrepancy upstream"), state the behavior rather than the destination. The maintainer supplies the destination out of band. And **a sibling fleet repo named as an illustrative example** ("repo X does it this way", "see repo Y's adoption"), which couples the repos and rots as they diverge. To point at a current good example, name it in the onboarding or conformance issue, never in a carried doc. **A contextually relevant link to a related project is not a coordination reference, and is expected.** Where another repo is part of this repo's subject matter (the image that consumes this config, the builder that generates this hardware, a library this depends on), link it normally. The test is whether the link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. This rule governs carried template content. A repo's own `README.md` and topical docs are its own content, not carried verbatim, and it does not reach them. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. +- **Carried files carry no coordination references.** In the files the fleet carries (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md`), two things are banned. **Any reference to the template repo**, in prose or in a link: the coordination flow is machinery a consumer of that repo should never have to see, and naming where a file came from is the derived-from framing the present-tense rule below independently forbids. Where a carried file must express a template-level behavior ("report a rule discrepancy upstream"), state the behavior rather than the destination. **The first exception is a verbatim section, and `AGENTS.md` "Fleet Bootstrap" is why it exists.** That section's whole function is to name where the canonical rules live, for an agent in a repository whose carried copies are stale, partial, or absent, which is exactly when no other file present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather than merely strict. The exception is scoped to the verbatim region and never leaks past it: the same document's own prose is governed normally, so a repository that names the destination in a paragraph it owns is still in breach. A reference that reaches a verbatim section is a defect in the canonical, fixed once at the source rather than reported against every repository carrying it. The maintainer supplies the destination out of band. **The second exception is a hub-hosted tool the reader is told to run, which is a different kind of reference.** A rule naming a gate, a script, or a reference snippet the reader executes or copies states an instruction rather than a provenance, and an instruction with no destination is unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The test is whether the reference is something the reader does or something that happened to this file, so where the content came from stays out and what the reader runs stays in. Such a pointer names the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving and never a repository's to edit out or to re-point at a local path, which is what left the reference resolving differently in every copy before. In `AGENTS.md` and `GOVERNANCE.md` it belongs in verbatim rule text, the same region the first exception already covers, so the whole fleet reads one wording and no repository is asked to answer for a reference it did not write. What is reached rather than carried, and how, is in [Hub-Hosted Tooling](#hub-hosted-tooling). And **a sibling fleet repo named as an illustrative example** ("repo X does it this way", "see repo Y's adoption"), which couples the repos and rots as they diverge. To point at a current good example, name it in the onboarding or conformance issue, never in a carried doc. **A contextually relevant link to a related project is not a coordination reference, and is expected.** Where another repo is part of this repo's subject matter (the image that consumes this config, the builder that generates this hardware, a library this depends on), link it normally. The test is whether the link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. This rule governs carried template content. A repo's own `README.md` and topical docs are its own content, not carried verbatim, and it does not reach them. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. ### Markdown -- **Reference-style links in human-facing docs.** Every markdown file uses reference-style links only, **except** the four agent-instruction files that keep inline links: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, and `.github/copilot-instructions.md`. Those are read one section at a time rather than end to end, so an inline target resolves where it is read while a definition at the bottom of the file is never reached. The exception is that closed list rather than a category to argue from, so every other markdown file follows the rule whatever its audience. Under the rule: every URI (internal path, anchor, external URL, or shield image) is defined at the **bottom of the file**, split into groups by type under an HTML-comment header (e.g. ``, ``, ``, ``) with each group's definitions alphabetized by reference name. **Reference names are contextual and encode the target and its group**: `foo-shield` for a shield image, `foo-link` for an external URL, and a bare `foo` for a local path or anchor (e.g. `[license-shield]`, `[releases-link]`, `[repo-config]`), never numeric (`[1]`) or opaque. No inline `[text](uri)` targets in prose. **A URL inside a fenced code block stays inline**, because reference links do not resolve in code blocks, so do not extract it, and exclude fenced code from any link-integrity check (bracket literals like `["a", "b"]` otherwise read as undefined references). **Removing a link also removes its reference definition**, since an orphaned definition fails the no-unused-defs rule. The one exception is the Table of Contents, whose entries stay inline anchor links (see Table of Contents below). +- **Reference-style links in human-facing docs.** Every Markdown file uses reference-style links only, **except** the four agent-instruction files that keep inline links: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, and `.github/copilot-instructions.md`. Those are read one section at a time rather than end to end, so an inline target resolves where it is read while a definition at the bottom of the file is never reached. The exception is that closed list rather than a category to argue from, so every other Markdown file follows the rule whatever its audience. Under the rule: every URI (internal path, anchor, external URL, or shield image) is defined at the **bottom of the file**, split into groups by type under an HTML-comment header (e.g. ``, ``, ``, ``) with each group's definitions alphabetized by reference name. **The sort key is the reference name alone**, the text inside the brackets, never the whole definition line: where one name is a prefix of another, the shorter one sorts first (`[governance]` above `[governance-branching-model]`, `[repo-config]` above `[repo-config-settings]`). Sorting the line instead inverts every such pair, because `-` precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up together, and a plain `sort -c` over the block passes on the inverted order. **Reference names are contextual and encode the target and its group**: `foo-shield` for a shield image, `foo-link` for an external URL, and a bare `foo` for a local path or anchor (e.g. `[license-shield]`, `[releases-link]`, `[repo-config]`), never numeric (`[1]`) or opaque. No inline `[text](uri)` targets in prose. **A URL inside a fenced code block stays inline**, because reference links do not resolve in code blocks, so do not extract it, and exclude fenced code from any link-integrity check (bracket literals like `["a", "b"]` otherwise read as undefined references). **Removing a link also removes its reference definition**, since an orphaned definition fails the no-unused-defs rule. The one exception is the Table of Contents, whose entries stay inline anchor links (see Table of Contents below). - **Table of Contents.** Generate it with the Markdown All in One extension, which fills and auto-updates the list on save, so leave the `## Table of Contents` heading for the extension to populate and never hand-author or hand-edit the entries. Exclude a heading with an inline `` marker on it (the badge/build header block and the `## Table of Contents` heading itself carry it). The workspace sets which heading levels appear. - One logical paragraph per line, with no hard-wrap line-length limit. For an intentional hard line break within a block (stacked badges, status, or license lines), end the line with a trailing backslash (`\`). This explicit form is preferred over trailing whitespace and is not treated as a paragraph split. - Headings follow the title-case-with-short-bind-words rule from the PR-title section. @@ -193,14 +213,14 @@ Agent-authored text is ASCII by default: documentation, code, comments, commit m - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** (every file type is CRLF unless pinned otherwise), with **LF** pinned for the execution-sensitive exceptions: `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared. The redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. - **Choosing an ending for a new file type:** CRLF is the **default**, since cross-platform editors on Windows produce it and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF**, since GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig`, because Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile*, interpreted and broken by a CR in a `RUN` heredoc or line continuation, is LF. -- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory*, often the exact tree mounted into that app's container, so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry `lineEndings` field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config), and **CRLF** for a Windows-native editor, for example Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default, which is exactly the over-normalization these per-repo endings prevent. +- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory*, often the exact tree mounted into that app's container, so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry `lineEndings` field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container (ESPHome, Home Assistant, a devcontainer-only or HACS config), and **CRLF** for a Windows-native editor, for example Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only, and `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default, which is exactly the over-normalization these per-repo endings prevent. - **Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited subtree), the clean answer is a repo per consumer, each single-platform with its own `lineEndings`. For example a controller config edited by a Windows-native editor (CRLF) lives in its own repo, **not** as a subtree inside a Linux-`lf` config repo. That keeps each repo's default, CI, and checkout matched to one platform and avoids per-path EOL machinery entirely. **Fallback only if a subtree genuinely cannot be split out:** keep the global default at the primary consumer and pin the odd subtree with an `.editorconfig` path override (e.g. `[/**] end_of_line = crlf`) matching its consumer, treated like any tool-owned format. The global `* -text` in `.gitattributes` already preserves those bytes, so no extra git pin is needed. - **Scripts and extensionless executables must be LF, and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing, so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** (`Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks), so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them, for example KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. -- **Pair each such pin with a matching `.editorconfig` override, since the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL: `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. +- **Pair each such pin with a matching `.editorconfig` override, since the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`), while the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`), and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL: `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. - **New files:** create them with the `.editorconfig`-mandated ending. - **Editing an existing file:** **preserve the file's current line endings**, and do not reflow them as a side effect of a content change, even if the file is already non-compliant. A tool that rewrites a file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify before staging: `git diff --stat` should touch only the lines you changed, and a byte check should confirm the expected ending (`file` is unreliable here, per Auditing below). If a diff balloons to the whole file, you flipped the endings, so restore them and re-stage. - **Fixing a non-compliant file:** bring it to its `.editorconfig` ending as a **deliberate** change, and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader maintenance change has to normalize endings alongside content edits (a repo-wide cleanup sometimes does), call it out explicitly in the commit/PR description and verify the content separately with `git diff --ignore-cr-at-eol`. -- **Auditing line endings: don't trust `file` or naive `git ls-files --eol`.** The authoritative check is a **byte scan** that classifies by which endings are present: **CRLF-only** (every `\n` is preceded by `\r`), **LF-only** (no `\r`), or **mixed** (both forms present). Flag mixed explicitly rather than lumping it in with CRLF; skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json`/`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` column holds multiple tokens that shift naive field-splitting into false positives. Scope a repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw `find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line string replace is EOL-safe, but an agent tool that inserts **multiple lines** or writes a **new file** into a CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is JSONC (it has `//` comments); strip them before JSON-parsing it. +- **Auditing line endings: don't trust `file` or naive `git ls-files --eol`.** The authoritative check is a **byte scan** that classifies by which endings are present: **CRLF-only** (every `\n` is preceded by `\r`), **LF-only** (no `\r`), or **mixed** (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json`/`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` column holds multiple tokens that shift naive field-splitting into false positives. Scope a repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw `find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line string replace is EOL-safe, but an agent tool that inserts **multiple lines** or writes a **new file** into a CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is JSONC (it has `//` comments), so strip them before JSON-parsing it. - **Both `.editorconfig` and `.gitattributes` are required.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) together govern line endings. A repo missing either file, or one whose `.editorconfig` sets no global `end_of_line` default (e.g. declares it only under `[*.md]`), will accumulate files mixed between LF and CRLF, the exact failure these two files prevent. The canonical form is a `[*] end_of_line = crlf` default plus the LF exception pins, mirroring `.gitattributes`. Carry both files **whole** (the `[*.cs]` block is inert without `.cs` files), including the `*.sh text eol=lf` pin and any extensionless-script path pins. Adopting `.gitattributes` for the first time requires a one-time normalization pass. ### Quantitative Claims @@ -211,6 +231,7 @@ Agent-authored text is ASCII by default: documentation, code, comments, commit m The checks that separate work actually done from work that merely reports success. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, so finding the check never depends on how well one repository worded a pointer to it. - **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. - **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. - **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. @@ -221,6 +242,7 @@ The checks that separate work actually done from work that merely reports succes - **Never edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. - **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. - **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. +- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated, and two failures of exactly that shape are on record from one session: a repository reported as still drifted on a file whose fix had already merged, and a repository reported as missing a file it carries because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading, and name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. - **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. - **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. - **A review flags an instance, so fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. @@ -235,12 +257,15 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom **Do not merge, and do not enable auto-merge, unless ALL of these hold:** -1. Required status checks are green (`mergeStateStatus: CLEAN`), **and** -2. A Copilot review is confirmed on the **current head SHA** by matching the review's commit SHA to the head, not an earlier push, because a push makes required checks go green **before** the re-review lands, so a green merge-state can precede the current-head review and never signals readiness on its own, **and** +1. Required status checks are green (`mergeStateStatus: CLEAN`), and where they are not, the reason is **read** rather than inferred, because `BLOCKED` is one word for a failed check, a required check nothing is running, an unresolved thread, and a missing approval alike, so the response to it is decided from the check states and never from the word, **and** +2. A Copilot review is confirmed on the **current head SHA** by matching the review's commit SHA to the head, not an earlier push, because a push makes required checks go green **before** the re-review lands, so a green merge-state can precede the current-head review and never signals readiness on its own, and the matched review is **read** rather than only counted, because Copilot declines a pull request it will not take on with a formal review carrying that same head SHA and no findings, which matches the SHA and covers nothing, and because a review can carry the head SHA and still say it read only part of the diff, which is the same clean pass in everything a SHA match can see, so the body's own count of the files it read is checked against the files the pull request changed, **and** 3. **Every** Copilot finding on that head SHA is closed out, with all review threads resolved, **and** any issue-level Copilot comments (which have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed in the review body investigated and answered, since those appear in no thread and a loop that polls threads alone reports a clean pass while they stand, so zero outstanding findings remain, **and** -4. The maintainer has given **explicit** permission to merge. +4. Nothing in the reviewer's output was a shape the review tooling could not read, since every reader keys on a marker and a marker that changes spelling is a section the reader stops finding and reports as absent, which is how three separate misreadings each reported a clean pass over a review they had not understood. An unrecognized shape blocks this gate on its own, and the remedy is an issue filed against the repository hosting the reader, naming the shape and quoting the body it came from, rather than a judgment about what the new wording probably meant, **and** +5. The maintainer has given **explicit** permission to merge. + +`mergeStateStatus: CLEAN` reflects **only** required statuses, and never open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate, so treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging", and merging is maintainer-authorized). -`mergeStateStatus: CLEAN` reflects **only** required statuses, and never open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). +**`BLOCKED` is not self-explaining either, and the remedy differs by cause.** A failed check is a defect to fix, an unresolved thread is a finding to close, and a required check sitting queued with no runner assigned is neither: it is hosted-runner capacity, which no re-request, rebase, or empty commit clears, and which a reader waiting on it cannot tell from patience. Report a blocked merge by naming the blocking check and its state rather than reporting the word, and where the cause is capacity, say so and stop rather than pushing at it, since the bypass is the maintainer's to run and a starved runner is not a reason to weaken a gate. **Merging is not releasing.** A merge to a release branch does **not** by itself publish. Publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. @@ -248,13 +273,13 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom 1. Push changes to the PR branch. 2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (reliable end-to-end, per the runbook). The UI is only a fallback. -3. Wait for review activity on that head. A completed review that raises **no findings** is a valid terminal outcome for that head, so proceed. Do not re-trigger it or treat the absence of comments as a missing review. +3. Wait for review activity on that head. A completed review that raises **no findings** is a valid terminal outcome for that head, so proceed. Do not re-trigger it or treat the absence of comments as a missing review. A review whose body says it did not review is the one exception, and it is terminal in the other direction: nothing follows it, re-requesting the same head repeats it, and the body names what has to change first. 4. Triage findings. 5. Apply fixes or write a rationale for declines. 6. Reply to each thread and resolve what was addressed. 7. Re-run the loop after every fix push until no actionable findings remain. -Drive the loop to green, meaning a review confirmed on the latest head SHA and every actionable finding closed, then stop and apply the **Merge Gate** above: all four preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. +Drive the loop to green, meaning a review confirmed on the latest head SHA and every actionable finding closed, then stop and apply the **Merge Gate** above: all five preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract, and that file owns the mechanics. @@ -288,7 +313,7 @@ After the final push on a PR, sweep older threads from earlier rounds whose code **Answering a suppressed finding is a different act from replying in a thread, and it carries its own pairing.** A threaded reply sits under the comment it answers and the UI shows whether it is resolved. A suppressed finding has neither, so an answer that does not carry its own context is unverifiable: the maintainer cannot tell that it was seen, which finding it addresses, or whether any were skipped, and has to ask. An answer therefore **quotes the finding** in a blockquote, with its `file:line` anchor and enough of Copilot's own words to identify it, **carries one bold verdict per finding** (`Fixed in `, `Disproven`, or `No change needed`) so the outcomes are scannable without reading prose, **states the `(N)` count** the block heading gives so N answers can be checked against N findings, and **links the review** that raised them, since a PR accumulates rounds and an unlinked answer is ambiguous about which one it closes. One comment per review round keeps the answers together. -**Read every round, not only the head.** A suppressed finding has no resolved state, so a push does not retire it: the finding simply stops appearing in a head-scoped query while remaining unanswered. Treating "superseded by a push" as "answered" is how rounds of findings go unanswered. `scripts/pr_review.py status ` reports every round and marks which are from earlier ones. +**Read every round, not only the head.** A suppressed finding has no resolved state, so a push does not retire it: the finding simply stops appearing in a head-scoped query while remaining unanswered. Treating "superseded by a push" as "answered" is how rounds of findings go unanswered. The hub's `scripts/pr_review.py status --repo /` reports every round and marks which are from earlier ones, and it names the repository because a pull request number resolves in every repository and a digest of the wrong one is well-formed. That script is hub-hosted rather than carried, so no repository holds a copy to run and it is invoked from a hub checkout per "Hub-Hosted Tooling" above. **The review's own overview cannot be trusted to say whether findings exist.** A body that reads "Copilot reviewed N out of N changed files and generated no new comments" routinely carries a collapsed block of suppressed findings directly beneath that sentence. Read the body for the block rather than the summary line, because the summary line and `reviewDecision` and an empty unresolved-thread list all agree that a review with four outstanding findings is clean. @@ -305,25 +330,26 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul ## Communicating with the User -- **Reference every pull request as a clickable link.** When you mention a PR (in chat, a summary, or 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. +- **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. ## 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. -- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags; Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` 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. - **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). @@ -334,7 +360,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. -- **The `.husky/pre-commit` hook** runs **language formatting only**: CSharpier + `dotnet format` (or ruff) via native tooling, no Docker and no doc linters, so it stays fast. +- **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff 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 what the commit changes 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. 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, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **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. The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. @@ -390,7 +416,7 @@ Contributors commit to this repo with signed commits, and a greenfield repo sign Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. -- **Description** matches the README's first non-empty line after the `#` H1 heading, as plain text, stripping markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same README intro line, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. +- **Description** matches the README's **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the README is carried onward rather than corrected there. - **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field. - **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`.