diff --git a/.editorconfig b/.editorconfig index 2038d6fa..464d489c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -63,6 +63,12 @@ end_of_line = lf [spec/{validate,audit}.py] end_of_line = lf +# The agent-safety kit's Python is shebang-executable tooling run by path (the PreToolUse hook and its +# installer), so pin LF for the same reason as the entry points above - a CRLF shebang breaks direct +# execution on a Unix host. +[host-setup/agent-safety/*.py] +end_of_line = lf + # uv regenerates uv.lock with LF on every platform, so pin it or an EOL check (editorconfig-checker/CI) # reds on every `uv lock`/`uv sync` until the file is manually reconverted - same rationale as the # shebang/Dockerfile pins (a tool owns the ending). A Python repo on the CRLF default carries this; a repo diff --git a/.gitattributes b/.gitattributes index 126849d2..b69124f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,9 +15,12 @@ catalog/snippets/husky/pre-commit text eol=lf # Vanilla `.py` follows the CRLF default - Python's universal newlines accept CRLF, and it is # commonly edited on Windows. Pin LF only for a `.py` executed directly via its shebang, by path - -# here the CI validation entry point; do not re-add a blanket `*.py text eol=lf`. +# here the CI validation entry point, the fleet-audit runner, and the agent-safety hook and its +# installer. Do not re-add a blanket `*.py text eol=lf`. spec/validate.py text eol=lf spec/audit.py text eol=lf +host-setup/agent-safety/gh-write-guard.py text eol=lf +host-setup/agent-safety/install.py text eol=lf # uv regenerates uv.lock with LF on every platform; pin it so git enforces LF on checkout/renormalize and a # CRLF-default repo does not fight the tool on every `uv lock`/`uv sync`. A repo with no lockfile is unaffected. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ec55c76b..d939dd35 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ Repository conventions for GitHub Copilot (and any other AI agent reading this f The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root - read it first, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them) plus the GitHub Copilot Review Runbook. -For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root - one guide with a General section plus per-language sections (.NET, Python). +For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root - one guide with a General section plus a section per language the repo uses. Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [AGENTS.md](../AGENTS.md), not here** - this file is intentionally limited to the inline commit/PR-title summary and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. @@ -85,6 +85,9 @@ Known non-working request paths (don't rely on them - use the `requestReviews` m - `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. - `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. +- `requestReviews` with the reviewer's bot node id in **`userIds`** fails with `Could not resolve to User node` - the Copilot reviewer is a **Bot**, so its node id goes in **`botIds`** (as in the mutation above), never `userIds`. +- `suggestedActors(capabilities: [CAN_BE_ASSIGNED])` lists `copilot-swe-agent` (the coding agent), not `copilot-pull-request-reviewer` - do not source the reviewer's bot node id there. Read it from an existing review per step 1 above. +- There is no `removePullRequestFromReviewRequest` mutation, and removing the reviewer to force a fresh pass is unnecessary anyway - `requestReviews` with `union: true` re-fires the review on the current head. ### Verify Review Covered Current Head @@ -119,6 +122,8 @@ If a review did not run on the current head, retry: ### Reply and Thread Resolution Workflow +Every id below is captured from a live query into a variable and passed from there - never hand-typed, guessed, or pasted as a `PRRT_...` literal. A node id resolves globally, so a fabricated or stale id does not fail, it writes to a real thread on an unrelated repository. This runbook implements [AGENTS.md "Repository Boundaries and Write Safety"](../AGENTS.md#repository-boundaries-and-write-safety): write only to this repo, capture every id from a live query, and never suppress a mutation's output. + List unresolved threads. Use `first: 100` with cursor-based pagination; if `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: ```sh @@ -142,20 +147,38 @@ gh api graphql -f query=' ' ``` -Reply on a thread, then resolve it: +Reply on a thread, then resolve it. Capture the target thread's id into `$TID` from the listing query above - filter to the thread being answered by its `path`, and guard for an empty result so a mutation never runs on a guessed id. When a file carries more than one unresolved thread, `path` alone is ambiguous and `head -n 1` would pick the wrong one, so narrow by first-comment body - the query already fetches `comments(first: 1)` for this - by adding `and (.comments.nodes[0].body | contains(""))` to the `select`: ```sh +TID=$(gh api graphql -f query=' +{ + repository(owner: "", name: "") { + pullRequest(number: ) { + reviewThreads(first: 100) { + nodes { id isResolved path comments(first: 1) { nodes { body } } } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false and .path == "") + | .id' | head -n 1) +[ -n "$TID" ] || { echo "no matching unresolved thread on - do not guess an id" >&2; return 1 2>/dev/null || exit 1; } + +# Show the mutation's output. Never append an output-discard or force-success tail +# (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) to a write. gh api graphql -f query=' mutation($threadId: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { - comment { id } + comment { id url } } -}' -F threadId="PRRT_..." -F body="Fixed in : ." +}' -F threadId="$TID" -F body="Fixed in : ." +# Confirm isResolved: true in this response before treating the thread as closed - a write that +# appears to fail may have taken on the server. gh api graphql -f query=' mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } -}' -F threadId="PRRT_..." +}' -F threadId="$TID" ``` Issue-level Copilot comments (those in `issues//comments`) have no resolution action - GitHub provides no API or UI to resolve them. Reply if the finding warrants it; no resolution step is needed or possible. diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 81655bcf..596e436c 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -42,7 +42,7 @@ jobs: # Auto-merge every tier, semver-major included: the required checks are the gate, not the bump magnitude. - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; @@ -84,7 +84,7 @@ jobs: - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; @@ -126,7 +126,7 @@ jobs: - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 951c7ec1..264960a4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -31,7 +31,7 @@ jobs: - name: Assert dispatch ref step run: | - set -euo pipefail + set -Eeuo pipefail if [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then echo "::error::Dispatch publish-release from main (release) or develop (prerelease); got ${{ github.ref_name }}." exit 1 diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index dc4c1c20..115f52c1 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Check workflow results step run: | - set -euo pipefail + set -Eeuo pipefail if [[ "${{ needs.validate.result }}" != "success" ]]; then echo "Job 'validate' did not succeed (${{ needs.validate.result }}); refusing to pass." exit 1 diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index e66644d3..1125899f 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -43,7 +43,7 @@ jobs: - name: Validate registry and spec step run: | - set -euo pipefail + set -Eeuo pipefail for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f" done diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 4afb100e..e570ccef 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,10 +1,9 @@ { "config": { - // Prose paragraphs and data-heavy tables/URLs are intentionally long; - // reflowing at 80 cols hurts readability and churns diffs. + // Prose paragraphs and data-heavy tables/URLs are intentionally long. + // Reflowing at 80 cols hurts readability and churns diffs. "MD013": false, - // Inline HTML is used for reference-link section dividers. - "MD033": false, + // MD033 (inline HTML) stays enabled: HTML comments (reference-link dividers) pass it, and elements are flagged so native markdown wins. // Require fenced code blocks over the legacy 4-space-indented style. "MD046": { "style": "fenced" }, // MD060 (table column style) is not enforced - allow both compact diff --git a/AGENTS.md b/AGENTS.md index b8eeb92c..2e2933e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,18 @@ Treat this file as authoritative for everything else; don't restate its rules el The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. - **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull** - the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change - never mechanical churn. This is why a **human merge never auto-publishes** - a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule) - together with the no-op republish guarantee and maintainer-gated version bumps: a needless release spends the user's attention and, on a push channel, acts on their machine. -- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges** - forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only; never back-merge. See "Branching Model". (This dual-target sync and the auto-publish rules below describe `release` repos; **operational** repos - registry `workflowModel: operational` - run no bots and commit directly to `develop`. They still cut a GitHub release, but **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`) - never automatically. See "Branching Model".) +- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges** - forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ - direct-to-`develop`, dispatch-only release - see "Operational Repositories".) - **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning - the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion - while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line; the toolchain guarantees monotonic builds. See "Release Model". - **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation - two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. +## 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, 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. + +- **Write only to the current project's own repository.** Every state-changing call targets this project's `origin` and nothing else. A broad or logged-in identity is capability, not permission - a token that *can* reach another repository does not authorize writing to it. Writing to any other repository needs explicit, per-session human permission for that specific repository, and a "harmless test" write is still a write, so there is no probe exception. 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`) - the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless** - the operation may have succeeded on the server while the client reported an error - so confirm the actual state before retrying or moving on. + ## Git and Commit Rules - **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. @@ -26,7 +34,7 @@ The specific rules in this file implement a few governing principles. Read these ## Branching Model -- **Two workflow models, set per repo by the registry [`workflowModel`](./registry/repos.json) field.** Most repos are `release`: they ship versioned units of delivery through the feature -> `develop` -> `main` flow this section describes. **Operational** repos (live-service config - Home Assistant, ESPHome, Vantage, home automation) instead track the running state of a service: the maintainer commits configuration **directly to `develop`** (there is no feature branch) and *occasionally* opens a `develop -> main` PR to bless a known-good snapshot. For an operational repo the [`develop` ruleset](./repo-config/operational/develop.json) drops the PR and status-check gate - 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. The [`main` ruleset](./repo-config/main.json) is shared and **unchanged**, so the promotion PR still **enforces** the required `Check pull request workflow status job`; that check is lint/validation only (editorconfig/EOL plus domain linters - a Home Assistant or ESPHome config validation, a firmware build - never unit tests), so `develop` stays the live surface and a broken config can never reach `main`. Operational repos still cut GitHub releases (tag + source zip), but **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`), never automatically - see "Release Model". The rest of this section is the `release` model unless noted; the promotion mechanics (never delete `develop`, EOL-conflict resolution) apply to both. +- **Two workflow models, set per repo by the registry [`workflowModel`](./registry/repos.json) field.** Most repos are `release`: they ship versioned units of delivery through the feature -> `develop` -> `main` flow this section describes. **Operational** repos instead track a live service's running state and differ substantially - direct-to-`develop`, advisory CI, dispatch-only release - see "Operational Repositories". The rest of this section is the `release` model unless noted. The promotion mechanics (never delete `develop`, EOL-conflict resolution) apply to both. - `develop` is the integration branch. Feature branches -> `develop` is **squash-only**; develop is kept linear. - `develop` -> `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. - All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. @@ -37,18 +45,18 @@ The specific rules in this file implement a few governing principles. Read these - **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 [`README.md`](./repo-config/README.md) "Rules / 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 (this template uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Operational repos import [`operational/develop.json`](./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. Full export/import procedure: [README "Rules / Rulesets"](./repo-config/README.md). **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 - see the [brownfield migration procedure](./repo-config/README.md) in that section. + - 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 - this file and the workflows reference them. Operational repos import [`operational/develop.json`](./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. - **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 [`catalog/snippets/workflows/run-codegen-pull-request-task.yml`](./catalog/snippets/workflows/run-codegen-pull-request-task.yml) 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 - 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` - 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. -- **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. **What** a repo regenerates (data files, source, or both; code changes or pure data) and **how** (download and process an external source, transform local inputs, whatever) is entirely its own concern - the template constrains only that the output be input-deterministic, not how it is produced. +- **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. **What** a repo regenerates (data files, source, or both; code changes or pure data) and **how** (download and process an external source, transform local inputs, whatever) is entirely its own concern - the constraint is only that the output be input-deterministic, not how it is produced. - *Reference:* the codegen workflow tasks are kept under [`catalog/snippets/workflows/`](./catalog/snippets/workflows/) (`run-codegen-pull-request-task.yml` and its scheduler). A repo adopting codegen supplies its own input-deterministic generator; this repo ships none. -- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./repo-config/README.md) for the secret-setup procedure. +- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; use `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [`repo-config/README.md`](./repo-config/README.md) "Secrets" for which secrets each mechanism needs. ## Release Model -The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale; the load-bearing rules. The auto-publish paths (bot push, schedule) apply to `release` repos; **operational** repos carry `releaseTrigger: dispatch-only`, so they publish **only** on a manual `workflow_dispatch` - the same source-only release the publisher already supports (tag + source zip + README + LICENSE, NBGV-versioned) - never automatically. Their `develop -> main` promotion just blesses a known-good config snapshot; a release is a separate, deliberate dispatch (see "Branching Model"). +The **two-phase model is the default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale. The load-bearing rules follow. The auto-publish paths (bot push, schedule) apply to `release` repos. **Operational** repos differ - dispatch-only release, no auto-publish - see "Operational Repositories". - **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters - a filter can't tell a logic change from an action-version bump - so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). Workflow YAML is still linted in CI by the lint job's `actionlint` step; also run `actionlint` locally before pushing to catch issues early. - **A human merge never auto-publishes.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher; each run builds the **single trigger branch** (`main` a release, `develop` a prerelease). A first [`plan`](./catalog/snippets/workflows/publish-plan-task.yml) job decides once whether the run publishes and every other job gates on its output. It publishes on a **`workflow_dispatch`** of `main`/`develop` (the human-initiated release), a **code-affecting bot push to `main`** (the codegen App merges every Dependabot/codegen PR, so `github.actor` is the gate - a human merge/promotion to `main` skips), or a **weekly `schedule`** (Docker only, to refresh the base image). The `push` is main-only and paths-filtered, so a develop bot merge and an Actions-only bump publish nothing. A source-only repo publishes on dispatch only. @@ -61,7 +69,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - *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 - it is *not* a generic file step, it is specifically `dotnet publish` of the console app; 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) - PyPI contributes **no** `release-asset-*`. - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. The image tag is build-layer-owned - 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`](./catalog/snippets/workflows/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`. - - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks - only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. + - *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 - heavyweight but expected even for a non-.NET repo; 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`) - 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. - **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.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, and doc edits leave it untouched. @@ -71,6 +79,19 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - **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. - **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./catalog/snippets/workflows/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` - 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; 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 - the two-phase latency tradeoff. +## Operational Repositories + +The registry [`workflowModel`](./registry/repos.json) field is `release` (the default) or `operational`. This section is the operational delta - every other rule in this file is the `release` model unless it says otherwise. + +**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 - the maintainer commits straight to `develop` and *occasionally* opens a `develop -> main` PR to bless a known-good snapshot. The [`develop` ruleset](./repo-config/operational/develop.json) 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. +- **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 - editorconfig/EOL plus domain linters (a Home Assistant or ESPHome config validation, a firmware build), 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) - its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet CRLF default. + ## Repository Onboarding and Conformance Every fleet repo is a standard-style repo the hub audits **downward** against its declared type - the model the fleet uses because managing downstream divergence is too costly. Three obligations follow, and they are not optional: @@ -105,13 +126,16 @@ Clarify devcontainer setup steps in README ## Documentation Style Conventions +- **Carried files carry no coordination references.** In the files the fleet carries - `AGENTS.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: it is private, so a link 404s for a downstream repo's users, and the coordination flow is machinery a consumer of that repo should never have to see. 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. + ### Markdown -- **Reference-style links in human-facing docs.** Every markdown file **except** the agent-instruction files (`AGENTS.md` and `.github/copilot-instructions.md`, which optimize for agents and keep inline links) uses reference-style links only: 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. No inline `[text](uri)` targets in prose. **A URL inside a fenced code block stays inline** - 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** - 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 **except** the agent-instruction files (`AGENTS.md` and `.github/copilot-instructions.md`, which optimize for agents and keep inline links) uses reference-style links only: 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** - 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** - 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 - 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; 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. - **Write in the present tense, describing only the current state.** The reader has no knowledge beyond what they are reading, so state what *is* - what to know, do, follow, or avoid - never a change from a prior state. Write "X does Y", never "X *now* does Y", "X *no longer* does Z", "X *still* does W", or "changed/switched/restored to Y". This applies to docs and code/workflow comments alike; before/after framing belongs in changelogs, commit messages, and PR descriptions - where the prior state is the point - not in `README.md`, `AGENTS.md`, or other living docs. +- **When you change a behavior, search for prose that asserts the old one.** Updating the guarantee or rule you are consciously editing is not enough: comments, diagram labels, reusable-workflow input descriptions, and audit statements elsewhere may still describe the prior behavior, and each was accurate when written. Grep for the old behavior's distinctive phrasing and fix every instance. No linter catches this - markdownlint, cspell, actionlint, and editorconfig-checker all pass on a claim that is merely untrue - so the sweep is the only mechanism that will. This is the maintenance counterpart to the present-tense rule above: that one governs how to phrase a doc, this one how to keep it true when the behavior underneath it moves. ### Comments @@ -120,6 +144,7 @@ Applies to code and workflow (`#`) comments alike. - Comment only when the code does not explain itself or the logic is genuinely complex. Self-evident code needs no comment. - Write for the human reading *this* project's code now: state only the non-obvious *why*. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. - **Keep it short. One line is the default; a comment earns a second line only by carrying a constraint the code cannot.** Most comments are one sentence. Don't restate *what* the code does - a well-named symbol already says it. +- **No class-, type-, or file-header summary comment blocks.** A type or file gets a comment only for a specific non-obvious point, kept terse - never a block summarizing what the file contains or what the class is for. A summary restates the declaration below it, goes stale as the file grows, and is the file-scope form of the design narrative and verbosity creep this section already bans. A license or provenance header a tool or policy requires is not a summary and is unaffected. - **Do not grow a comment across edits.** When you touch code near an existing comment, the comment must come out **same length or shorter** - never append "one more clause" of rationale. If a block comment has crept to multiple sentences of prose, cut it back to its single load-bearing point as part of your change. Verbosity creep is the specific regression to prevent: every iteration that adds a clause is a regression, not an improvement. - Match the surrounding code's line length (typically ~120), not an 80-column wrap. @@ -130,6 +155,7 @@ Applies to code and workflow (`#`) comments alike. - right arrow (U+2192) -> `->`; double arrow (U+21D2) -> `=>` - less-than-or-equal (U+2264) -> `<=`; greater-than-or-equal (U+2265) -> `>=` - curly quotes (U+2018/U+2019/U+201C/U+201D) -> straight `'` and `"`; ellipsis (U+2026) -> `...` +- **No semicolon joining two independent clauses in agent-authored prose** - documentation, comments, commit messages, and PR descriptions. Recast as a comma or as two sentences: "the check runs on push; it gates the merge" becomes "the check runs on push and gates the merge", or two sentences. A semicolon separating items in a list that already contains commas keeps its standard use, and a statement terminator in **code** is untouched by this rule. Existing prose is corrected as each file is next edited, not swept. - **Allowed non-ASCII (two narrow exceptions):** - **Scientific or technical symbols with no clean ASCII equivalent** - e.g. ohm, micro, degree, pi. Keep the symbol; do not approximate it away. - **Unicode the developer deliberately typed** - emoji used for emphasis or as callout markers (for example the warning/info markers a maintainer placed in `README.md`). Preserve it; never strip the developer's own characters. This carve-out is for developer-authored text, not a license for the agent to add emoji. @@ -139,7 +165,7 @@ Applies to code and workflow (`#`) comments alike. - **[`.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** - 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** - 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` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This (and the catalog snippet workflows in `catalog/snippets/workflows/*`, pinned LF the same way) 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, where a CR breaks `RUN` heredocs and line continuations - 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`](./registry/repos.json) 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 - e.g. 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 - that 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` - e.g. the Vantage InFusion / Design Center config (Windows/CRLF) lives in its own `Vantage-Config` repo, **not** as a `Vantage/**` subtree inside the Linux-`lf` `HomeAutomation-Config`. 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. + - **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` - e.g. 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** - e.g. `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 - e.g. 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 - 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. - **New files:** create them with the `.editorconfig`-mandated ending. @@ -152,6 +178,18 @@ Applies to code and workflow (`#`) comments alike. - Any quantitative claim in `README.md` (counts, sizes, version floors, supported platforms) must be verified against current code. If a doc number is derived from a code constant, mark the dependency in a source-code comment so the next editor knows to update both. +## Verification Discipline + +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; a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. + +- **A test must assert the mechanism it names.** Label each case by the behavior it proves, and satisfy yourself it would fail if that mechanism broke. 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. +- **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. The workflow instance of this is [`WORKFLOW.md`](./WORKFLOW.md) D8.4 (an identity allowlist used as a gate). +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure - and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The invocations are in "Running the Linters Locally"; that section documents *how* to run each, this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. +- **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. (The `changes`-job rule under "Branching Model" is this rule's instance for that one job.) +- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else - `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. +- **A review flags an instance; 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; they do not enumerate. + ## PR Review Etiquette > This "PR Review Etiquette" section is the provider-agnostic review-loop *contract* every fleet repo follows, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. @@ -225,7 +263,7 @@ These conventions describe the target state. New and modified workflows must res - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); 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"**; 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** - update 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 - 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**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. +- **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 - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `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`](./catalog/snippets/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". diff --git a/AUDIT.md b/AUDIT.md index 430fd800..4058e292 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # AUDIT.md -How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is the procedure; the ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`AGENTS.md`][agents], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. +How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is the procedure. The ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`AGENTS.md`][agents], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, **defect**, and the applicable/absent rule. Do not invent a parallel scheme. @@ -10,12 +10,14 @@ The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not opera This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: - **Onboarding a repo is complete only when it either passes this audit** (operational - every applicable check) **or carries a committed `reports//audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect** - the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]; because both read the same manifests, a repo stood up by that file passes this audit by construction. -- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality** - `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent; [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo - closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. +- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality** - `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo - closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope markdown section presence, workflow interface conformance, verbatim content, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried markdown file is a **drift finding**, not a letter - a heading rename reads as missing, and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring - required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork - all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit - a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job) - is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. ## 1. Scope and Ground-Truth Branch Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence - a stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. +This holds for **both workflow models**. An `operational` repo commits directly to `develop`, but its ground truth is still `main` - the promoted, gated snapshot the promotion PR blesses. `develop` there is mid-flight by design (ungated direct pushes), so auditing it would measure work in progress: conformance scaffolding that has landed on `develop` but is not yet promoted is *un-promoted work*, not a conformance defect, and it counts when it reaches `main`. A registry `groundTruthBranch` naming `develop` therefore contradicts this section - for either model. + ## 2. Resolve the Repo's Type(s) Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: @@ -29,6 +31,8 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. + ## 4. Per-Dimension Checks (Letter and Intent) For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: @@ -36,18 +40,18 @@ For each applicable type in [`spec/project-types.json`][project-types] and every - **letter** - the exact file, section, config, or construct is present. - **intent** - an equivalent outcome holds even if the form differs. -A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale; read it to judge intent. The dimensions: +A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale, so read it to judge intent. The dimensions: -- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter); analyzer severities are enforced, not relaxed (intent). +- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter), and analyzer severities are enforced, not relaxed (intent). - **nuget** - publish uses OIDC Trusted Publishing, no `NUGET_API_KEY` (letter+intent); `--skip-duplicate`. - **pypi** - OIDC publish job with `environment: pypi`, `id-token: write`, `skip-existing: true`; no stored token. -- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter); standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. -- **console** - smoke runtime matrix is a strict subset; per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. -- **docker** - registry layer cache (`buildcache-`, never `type=gha`); the size-limited Docker Hub README is published via the docker-readme task; the image always re-pushes on publish. -- **branch-model** - `main` and `develop` both exist and are protected; the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). +- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter), and a standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. +- **console** - smoke runtime matrix is a strict subset, and per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. +- **docker** - registry layer cache (`buildcache-`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. +- **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). - **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). - **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. -- **recurring-violations** (high priority, always run) - comments concise and non-narrative; ASCII only (no em-dash, no smart quotes); US spelling; line endings per `.editorconfig`. These are frequent regressions; each is a grep-able check (see below). +- **recurring-violations** (high priority, always run) - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, and each is a grep-able check (see below). - **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). ## 5. Assert the Actions Implement WORKFLOW.md @@ -68,21 +72,43 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: ```sh norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' + # Model-aware expected payload: an operational repo's develop ruleset diffs against + # operational/develop.json (registry workflowModel; the same selection audit.py makes). + model=$(jq -r --arg n "" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' registry/repos.json) + # Paginate so later-page rulesets count: --paginate with --jq '.[]' emits one JSON object per ruleset + # across all pages; jq -s re-assembles them into the single array the selections below expect. + rulesets=$(gh api --paginate "repos///rulesets" --jq '.[]' | jq -s '.') for b in develop main; do - id=$(gh api "repos///rulesets" --jq ".[]|select(.name==\"$b\").id") - diff <(jq -S "$norm" "repo-config/$b.json") \ + file="repo-config/$b.json" + [ "$b" = "develop" ] && [ "$model" = "operational" ] && file="repo-config/operational/develop.json" + # Exactly one ruleset per name: zero or duplicates is itself a finding - report it, never diff a guess. + count=$(jq --arg n "$b" '[.[] | select(.name==$n)] | length' <<<"$rulesets") + [ "$count" -eq 1 ] || { echo "$b: expected exactly 1 ruleset, found $count (defect/drift)"; continue; } + id=$(jq --arg n "$b" '.[] | select(.name==$n) | .id' <<<"$rulesets") + diff <(jq -S "$norm" "$file") \ <(gh api "repos///rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \ && echo "$b: in sync" || echo "$b: DRIFT" done ``` -- **Secrets** - confirm each required secret exists (name only; values are not readable). Check the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. +- **Secrets** - confirm each required secret exists (name only, not the values). Check the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. + +- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` is present (its workflows reference actions) - otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge - and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**. A tree-implied ecosystem declared nowhere is a **drift finding** (the file exists, so its absence would instead be a file-presence letter). Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][agents-branching-model] - the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. + + ```sh + # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. + decl=$(gh api "repos///contents/.github/dependabot.yml?ref=" --jq '.content' | base64 -d | grep -oE '^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*"?[a-z-]+' | grep -oE '[a-z-]+$' | sort -u) + has() { gh api "repos///contents/$1?ref=" >/dev/null 2>&1; } + has .github/workflows && { grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (workflows present)"; } + has .devcontainer && { grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)"; } + # then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry + ``` ## 7. Verdict Model Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: -- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form; worth fixing, not a break). +- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form, worth fixing, not a break). - letter and intent both miss -> **defect** (not operational). A repo is **operational** only if every applicable check passes. A single applicable defect makes it not operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. @@ -91,9 +117,13 @@ A repo is **operational** only if every applicable check passes. A single applic Write `reports//audit.md` from [`reports/_template.md`][template]: a dimension x {letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank findings most severe first. +**Findings are a point-in-time snapshot - stamp them and re-verify before acting.** [`spec/audit.py`][audit-runner] prints a run stamp (`audit run | hub `) and, per repo, the exact commit it read (`@ @`). Anything derived from a run - a report, and especially an **onboarding or conformance issue** - quotes that stamp, so a reader can tell whether it still applies. An agent picking up such an issue **re-runs the audit first and acts on the live result, not the pasted findings**: a repo moves between filing and pickup, so a stale block leads an agent to "fix" what is already fixed (re-requesting secrets that exist, attempting a no-op forward-sync). State the findings as evidence for *why* the issue was filed, never as the current state. + +**Reconcile `driftNotes` in the same pass.** A registry `driftNote` records a *current* deviation from the baseline. Once the deviation is resolved the note is deleted, not left describing finished work - hand-maintained prose drifts silently otherwise. `spec/audit.py` flags this: when a repo audits clean but a note still asserts outstanding work ("pending", "not yet", "missing", "behind", ...), it raises a drift finding naming the note. + ## 9. Escalate -Surface spec questions rather than resolving them silently - e.g. the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting; raise it. +Surface spec questions rather than resolving them silently - e.g. the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting, so raise it. ## 10. Converge - Apply the Fixes @@ -101,7 +131,7 @@ Sections 1-9 (the audit and its report) are **read-only** - they never touch the - **Apply via a pull request on the target repo.** Branch from the target's `develop` (or `main` for a `main`-only repo), make the fix, and open a PR. Never push a fix directly to a protected branch, and never hand-edit a target outside a PR. - **Drive the PR's Copilot review to green** - the same loop this repo runs (see [AGENTS.md "PR Review Etiquette"][agents] and the [Copilot review runbook][copilot-runbook] in `.github/copilot-instructions.md`): request review on every push, address and resolve every thread, and confirm the review covers the head SHA. -- **Merge only with explicit maintainer approval.** The agent drives to green and stops; the maintainer merges. +- **Merge only with explicit maintainer approval.** The agent drives to green and stops. The maintainer merges. - **One focused PR per drift class**, cross-referencing the audit finding - a sprawling all-drifts PR draws many review rounds and never feels done. - **Fix systemic drift in the hub, not per repo.** When many repos share a drift, fix the spec/rule (or add a machine check) here and let a re-audit re-flag it, rather than hand-patching each repo for the shared cause. @@ -114,6 +144,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [agents]: ./AGENTS.md +[agents-branching-model]: ./AGENTS.md#branching-model [audit-runner]: ./spec/audit.py [codestyle]: ./CODESTYLE.md [copilot-runbook]: ./.github/copilot-instructions.md @@ -123,6 +154,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [repo-config-settings]: ./repo-config/settings.json [reports]: ./reports/ [repos]: ./registry/repos.json +[scope-model]: ./spec/scope-model.md [secrets]: ./spec/secrets.json [spec]: ./spec/ [standup]: ./STANDUP.md diff --git a/CODESTYLE.md b/CODESTYLE.md index 379b823c..cd341e04 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -22,7 +22,7 @@ Each language defines a **clean-compile** verification - the combination of buil ### Analyzer Diagnostics and Suppressions -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md][agents] and [README.md][readme], which has nothing to do with code analysis.) +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance is the one-time git-signing / line-ending migration described in [AGENTS.md][agents] and [README.md][readme], which has nothing to do with code analysis.) - **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). @@ -35,7 +35,7 @@ These apply repo-wide, in every directory: 1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`][markdownlint-cli2] at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. 2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md][agents]). The shared `cspell.json` sets `"language": "en-US"` so British spellings are flagged - a bare `"en"` accepts both US and British and silently passes the wrong spelling. Project-specific terms go in the shared `cspell.json` `words` list - it is the single source of truth the extension, CLI, and CI all read. The `.code-workspace` must **not** carry its own `cspell.words`/`cSpell.words` block; when externalizing words into `cspell.json`, delete any word list left in the workspace (a leftover one duplicates the list and silently drifts). -3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only** - these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but the template ships README + HISTORY as the default; keep the CI workflow, the `Lint: Spelling` VS Code task, and the AGENTS.md cspell one-liner on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone - cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md` - it does not choke on technical terms. +3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only** - these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but README + HISTORY are the default; keep the CI workflow, the `Lint: Spelling` VS Code task, and the AGENTS.md cspell one-liner on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone - cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md` - it does not choke on technical terms. ## .NET @@ -250,7 +250,7 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-d logger.LogError(exception, "{Function}", function); ``` -2. **Libraries log through abstractions, never a concrete backend.** A NuGet **library** depends only on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam - a settable global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It must **not** reference Serilog or any sink - that forces a logging framework on every consumer and drags in AOT-incompatible dependencies. The consuming **application** owns the concrete logger (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from `Serilog.Extensions.Logging`), and injects it. Reference: `LanguageTags` - `LogOptions` in the library; the CLI's `LoggerFactory` builds the Serilog-backed factory and injects it via `LogOptions.SetFactory`. +2. **Libraries log through abstractions, never a concrete backend.** A NuGet **library** depends only on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam - a settable global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It must **not** reference Serilog or any sink - that forces a logging framework on every consumer and drags in AOT-incompatible dependencies. The consuming **application** owns the concrete logger (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the library; the consuming CLI builds the Serilog-backed factory and injects it via `LogOptions.SetFactory`. 3. **CallerMemberName**: Use for automatic function name tracking @@ -344,7 +344,7 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-d This is the style guide for any **Python project(s)** in this repo. -**Adapt before propagating.** The rules below describe the template's default Python profile - a package that publishes to PyPI, type-checked by `pyright` in strict mode, dependencies in `[dependency-groups]`. A derived repo often differs; when it does, **adapt these fields to match the repo's actual toolchain rather than copying verbatim** (a verbatim copy that misdescribes the repo is inaccurate and gets rejected in review). The axes that commonly vary per repo: +**Adapt before propagating.** The rules below describe the default Python profile - a package that publishes to PyPI, type-checked by `pyright` in strict mode, dependencies in `[dependency-groups]`. A derived repo often differs; when it does, **adapt these fields to match the repo's actual toolchain rather than copying verbatim** (a verbatim copy that misdescribes the repo is inaccurate and gets rejected in review). The axes that commonly vary per repo: - **Type checker in CI** - `pyright` strict, **`mypy` in CI with `pyright` editor-only** (Pylance), or both. Whichever runs in CI is the one the clean-compile and the CI gate invoke. - **Dependency declaration** - `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` (dev tools installed with `uv sync --extra `). @@ -352,6 +352,11 @@ This is the style guide for any **Python project(s)** in this repo. - **Disabled markdownlint rules** - repo-specific; `.markdownlint-cli2.jsonc` at the repo root is the source of truth, not any example rule named here. - **VS Code config home** - editor **settings/extensions** may live in `.vscode/*.json` **or** the `.code-workspace`; **tasks / launch / debug** configs can only be external `.vscode/*.json` (they cannot live in the workspace file). A `[vscode-tasks]` reference must point wherever the repo actually keeps `tasks.json`. +**Two profiles.** A repo's Python is one of two shapes, and the rest of this section (uv project, `uv.lock`, `uv run`, `src` layout, pytest coverage) describes the **project** profile. The two differ by whether the Python has **third-party runtime dependencies**, which shows up structurally in `pyproject.toml`, so the audit detects the profile there (`python.profile.detect`): + +- **Project** - the Python has third-party runtime dependencies, or is the repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed `uv.lock` (pinned LF - see [Line Endings][line-endings]); CI runs `uv sync --frozen` + `uv run `, so the lockfile pins tool versions. +- **Scripts** - stdlib-only utility scripts embedded in a **non-Python** repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with **`uvx`** (no project install, no lockfile): the `pyproject.toml` carries **only** `[tool.ruff]` / `[tool.mypy]` config - no `[project]`, no `[build-system]`, no `uv.lock` (that metadata would misrepresent it as a shippable package). **mypy** is the type checker (there is no first-party package for pyright strict to anchor on). Because there is no lockfile to pin versions, **CI pins the exact tool versions in the `uvx` command** (`uvx ruff@`, `uvx mypy@`, bumpable there) while the VS Code tasks and README run the unpinned latest - a deliberate CI-vs-local gap so local tooling never silently falls behind. `.py` files follow the repo's line-ending default (CRLF in a CRLF-default repo; a shebang-executed script is LF-pinned by path - see [Line Endings][line-endings]). There is no pytest suite, so the coverage expectation is N/A; a co-present `csharp` type still carries `codecov.yml` for its own tests. + ### Toolchain | Tool | Role | Config | @@ -478,6 +483,7 @@ Before pushing or opening a PR: [analyzer-diagnostics-and-suppressions]: #analyzer-diagnostics-and-suppressions [clean-compile-verification]: #clean-compile-verification [history]: ./HISTORY.md +[line-endings]: ./AGENTS.md#line-endings [markdown-and-spelling]: #markdown-and-spelling [markdownlint-cli2]: ./.markdownlint-cli2.jsonc [readme]: ./README.md diff --git a/README.md b/README.md index 6db81d9a..ba8fa7bf 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ A human-readable index of the rules agents enforce, implement, and audit. The au - One logical paragraph per line, with a trailing `\` for an intentional hard break. - Pin every GitHub Action to a commit SHA with a version comment. - Share one lint config per tool across the editor, the CLI, and CI. +- Run the repo's whole lint gate before pushing, not just the parts that look relevant. +- Make gates fail loud - a gate that stops gating must error or annotate, never pass silently. - Favor VS Code tasks and launch configs for building, running, and testing over ad-hoc shell scripts. ### Never @@ -99,7 +101,11 @@ A human-readable index of the rules agents enforce, implement, and audit. The au ### If a Python Project -- Configure ruff and pyright in `pyproject.toml`. +- Configure ruff and a type checker in `pyproject.toml` - pyright strict, or mypy in CI with pyright editor-only; whichever runs in CI is the gate. + +### If Both C# and Python + +- Both sections above apply; a repo can be both (a C# app plus a Python subtree). The Python is either a full uv project (`uv.lock`, `uv run`) or a stdlib-only `uvx` scripts subtree (no `uv.lock`, `pyproject.toml` carries lint/type config only). See [CODESTYLE.md][codestyle] "Two profiles". ### If Publishing a Package (NuGet or PyPI) diff --git a/STANDUP.md b/STANDUP.md index 3b47392d..9811078a 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -18,7 +18,7 @@ Implement the Actions that satisfy [`WORKFLOW.md`][workflow] for the repo's type ## 4. Apply Settings, Rulesets, and Secrets -Run `repo-config/configure.sh [owner/repo] [release|operational]` (the repo defaults to the current one, the model to the registry lookup) to apply the fleet settings and the two rulesets idempotently (import the JSON, never hand-build - see [`repo-config/README.md`][repo-config-readme]). Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s) - Actions, and Dependabot where the mechanism needs it - and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once. +Run `repo-config/configure.sh [owner/repo] [release|operational]` (the repo defaults to the current one, the model to the registry lookup or, absent a registry, to the carried payload) to apply the fleet settings and the two rulesets idempotently (import the JSON, never hand-build - see [`docs/repo-config-carry.md`][repo-config-carry]). Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s) - Actions, and Dependabot where the mechanism needs it - and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once. ## 5. Verify - Run the Audit @@ -55,7 +55,7 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo; its [matrix]: ./reports/conformance-matrix.md [project-types]: ./spec/project-types.json [repo-config]: ./repo-config/ -[repo-config-readme]: ./repo-config/README.md +[repo-config-carry]: ./docs/repo-config-carry.md [repos]: ./registry/repos.json [secrets]: ./spec/secrets.json [spec]: ./spec/ diff --git a/WORKFLOW.md b/WORKFLOW.md index e3f2f43e..57fe3748 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -2,7 +2,7 @@ The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]; this file is its sibling for everything under [`.github/workflows/`][workflows]. -Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs** - not when it matches the template byte for byte. The conventions in section 2 keep workflows legible; the contract in section 4 is what they must *do*. +Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs** - not when it matches a reference implementation byte for byte. The conventions in section 2 keep workflows legible; the contract in section 4 is what they must *do*. Given this document, an agent must be able to do three things to any project: @@ -19,7 +19,7 @@ The guarantees are distilled from failures observed in practice and stated as th - **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos; the input/output behavior may not. - **Applicability.** A guarantee (or a 5A check, or a 5B scenario) is **applicable** only if the repo contains the construct it governs - a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type; a near-empty pipeline (source-only) is mostly N/A and that is fine. - **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. -- **Default branch.** Guarantees say "default branch" portably; the template implements it as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch; a divergence is a defect (section 5A). +- **Default branch.** Guarantees say "default branch" portably; it is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch; a divergence is a defect (section 5A). - **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it in the layer that declares it. - **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. @@ -32,7 +32,7 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien - **Workflow `name:`.** Reusable names end in **"task"**; entry-point names end in **"action"**. - **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"** - including a ruleset-bound required-check job, whose `name:` and the ruleset `context:` are one string renamed together (never independently). - **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). -- **Shells.** Every multi-line bash `run:` starts `set -euo pipefail`. +- **Shells.** Every multi-line bash `run:` - and every committed `.sh` script - starts `set -Eeuo pipefail`. - **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. - **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. @@ -132,7 +132,7 @@ Pick each output's path by **where the artifact goes**: - **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). - **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`; PyPI is **split** - the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. - **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image); contributes no `release-asset-*`. -- **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by the template's own publisher, which ships file targets and keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. +- **No file target via the release task** (Docker-only, PyPI-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by a publisher that ships file targets, which keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. A **source-only** repo has no release task at all - its standalone `publish-release.yml` inlines `action-gh-release`, so `expect_release_assets` does not apply (see Section 6). ## 4. Behavioral Contract - Expected Outcomes @@ -160,13 +160,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. - **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). - **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* -- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the tracker (the writer) ships without consumer wiring - a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* ### D4 - Release / Publish - **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.* -- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. +- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* ### D5 - Resource Cleanup @@ -196,12 +196,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* - **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing - a green, silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. ### D9 - Style / Static (See Section 2) - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules; a ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). -- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. +- **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`; multi-line `if:` uses `>-`. - **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. - **D9.5** Line endings follow `.editorconfig`. @@ -272,10 +273,10 @@ Each type maps the *applicable* S-scenarios onto its targets; the differences ar - **Console / executable application.** Target produces `release-asset--executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish--` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes; the per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest"; the workflow does not set it). - **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset--nugetlibrary`; configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it - a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. - **PyPI library.** The leaf builds + uploads `pypilibrary-build-`; a **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact - **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`; a PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. -- **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the template ships the tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag (amd64 only); S9 still re-pushes; S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. -- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. -- **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). A standalone dispatch-only publisher gates its release job on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed. -- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job` - the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), **no unit tests**; its triggers differ from the `release` template - `push` to `develop` (advisory feedback on direct commits) plus `pull_request` to `main` (the enforced promotion gate) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, `expect_release_assets: false`). Applicable scenarios: S1 (validation) on the promotion PR, plus the source-only release set - S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule - operational repos have neither) and every build/registry scenario. See the branch-model note in Section 3 and [AGENTS.md "Branching Model"][agents-branching-model]. +- **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag (amd64 only); S9 still re-pushes; S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. +- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release - tag + source zip + README + LICENSE, no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed. +- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job` - the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), **no unit tests**; its triggers differ from the `release` model - `push` to `develop` (advisory feedback on direct commits) plus `pull_request` to `main` (the enforced promotion gate) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). Applicable scenarios: S1 (validation) on the promotion PR, plus the source-only release set - S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule - operational repos have neither) and every build/registry scenario. See the branch-model note in Section 3 and [AGENTS.md "Branching Model"][agents-branching-model]. diff --git a/catalog/snippets/devcontainer/dotnet/post-create.sh b/catalog/snippets/devcontainer/dotnet/post-create.sh index fcb6609a..91c058fa 100755 --- a/catalog/snippets/devcontainer/dotnet/post-create.sh +++ b/catalog/snippets/devcontainer/dotnet/post-create.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail # Restore the .NET local-tool manifest (csharpier, dotnet-outdated). dotnet tool restore diff --git a/catalog/snippets/devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh index b18a729a..bfd59a8a 100755 --- a/catalog/snippets/devcontainer/python/post-create.sh +++ b/catalog/snippets/devcontainer/python/post-create.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail # Install uv (Astral) for the Python project. Idempotent - re-running # overwrites in place. The installer drops the binary in $HOME/.local/bin and diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index a2e22f1c..c0340390 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,6 +1,6 @@ # Workflow snippets -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `build-release-task`, `get-version-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | diff --git a/catalog/snippets/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml index b18a3d6f..20582689 100644 --- a/catalog/snippets/workflows/build-executable-task.yml +++ b/catalog/snippets/workflows/build-executable-task.yml @@ -53,6 +53,7 @@ jobs: - name: Build executable project step run: | + set -Eeuo pipefail dotnet publish ./Console/Console.csproj \ --runtime ${{ matrix.runtime }} \ -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ diff --git a/catalog/snippets/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml index fc21ba61..8bcfd0d7 100644 --- a/catalog/snippets/workflows/build-nugetlibrary-task.yml +++ b/catalog/snippets/workflows/build-nugetlibrary-task.yml @@ -52,7 +52,7 @@ jobs: - name: Build NuGet library project step run: | - set -euo pipefail + set -Eeuo pipefail dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ @@ -66,7 +66,7 @@ jobs: - name: Publish to NuGet.org step if: ${{ inputs.push }} run: | - set -euo pipefail + set -Eeuo pipefail dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ --source https://api.nuget.org/v3/index.json \ --api-key ${{ secrets.NUGET_API_KEY }} \ diff --git a/catalog/snippets/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml index 5cc0b71e..8efb9640 100644 --- a/catalog/snippets/workflows/build-pypilibrary-task.yml +++ b/catalog/snippets/workflows/build-pypilibrary-task.yml @@ -84,7 +84,7 @@ jobs: - name: Compute PyPI version step id: pypiver run: | - set -euo pipefail + set -Eeuo pipefail if [[ "$BRANCH" == "develop" ]]; then version="${AFV}.dev0" else @@ -100,7 +100,7 @@ jobs: # Done after tests so the test asserting __version__ is non-empty isn't affected. - name: Write version into _version.py step run: | - set -euo pipefail + set -Eeuo pipefail sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py env: VERSION: ${{ steps.pypiver.outputs.version }} diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index 89ff883b..049ff898 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -83,7 +83,7 @@ jobs: BRANCH: ${{ inputs.branch }} SMOKE: ${{ inputs.smoke }} run: | - set -euo pipefail + set -Eeuo pipefail # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm. if [[ "$SMOKE" == "true" ]]; then echo "Smoke build; skipping release version validation." @@ -193,7 +193,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.get-version.outputs.SemVer2 }} run: | - set -euo pipefail + set -Eeuo pipefail if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then @@ -240,7 +240,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail + set -Eeuo pipefail if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then echo "::warning::Could not list run artifacts; retention-days backstop will reap them." diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index c4962d58..e5a14845 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -69,7 +69,7 @@ jobs: RESOLVER_COMMAND: ${{ inputs.resolver-command }} STATE_FILE: ${{ inputs.state-file }} run: | - set -euo pipefail + set -Eeuo pipefail # Require a non-empty JSON object of single-line name -> version strings; a CR/LF would corrupt the # single-line GITHUB_OUTPUT, so reject it here instead of committing unconsumable state. diff --git a/catalog/snippets/workflows/publish-docker-readme-task.yml b/catalog/snippets/workflows/publish-docker-readme-task.yml index a4cb3f20..1c80cec5 100644 --- a/catalog/snippets/workflows/publish-docker-readme-task.yml +++ b/catalog/snippets/workflows/publish-docker-readme-task.yml @@ -70,7 +70,7 @@ jobs: MANIFEST: ${{ inputs.manifest }} MANIFEST_JQ: ${{ inputs.manifest-jq }} run: | - set -euo pipefail + set -Eeuo pipefail if [ -n "$REPOSITORIES" ] && [ -n "$MANIFEST" ]; then echo "::error::Pass either 'repositories' or 'manifest', not both." >&2 exit 1 @@ -94,7 +94,7 @@ jobs: MANIFEST: ${{ inputs.manifest }} MANIFEST_JQ: ${{ inputs.manifest-jq }} run: | - set -euo pipefail + set -Eeuo pipefail # Inputs validated above: at most one of repositories / manifest is set, and manifest implies manifest-jq. if [ -n "$REPOSITORIES" ]; then echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT" @@ -133,7 +133,7 @@ jobs: - name: Generate readme step if: ${{ inputs.transform-run != '' }} run: | - set -euo pipefail + set -Eeuo pipefail ${{ inputs.transform-run }} - name: Publish Docker Hub readme step diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml index ab7dbbb9..b5997d2c 100644 --- a/catalog/snippets/workflows/publish-plan-task.yml +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -57,7 +57,7 @@ jobs: ACTOR: ${{ inputs.actor }} REF: ${{ inputs.ref_name }} run: | - set -euo pipefail + set -Eeuo pipefail publish=false case "$EVENT" in workflow_dispatch) @@ -74,6 +74,11 @@ jobs: # ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. if [[ "$REF" == "main" ]] && { [[ "$ACTOR" == "ptr727-codegen[bot]" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then publish=true + elif [[ "$REF" == "main" ]]; then + # Fail loud: an unrecognized actor pushing to main is either a human commit (legitimately not + # publishing, but worth seeing) or a release bot under a new identity, which would otherwise + # stop publishing silently while a schedule keeps releasing - lost timeliness, no error. + echo "::warning::Push to main by unrecognized actor '$ACTOR'; not publishing. If this is a release bot under a new identity, update the allowlist in publish-plan-task.yml." fi ;; esac diff --git a/catalog/snippets/workflows/run-codegen-pull-request-task.yml b/catalog/snippets/workflows/run-codegen-pull-request-task.yml index beab7e94..9b71ab00 100644 --- a/catalog/snippets/workflows/run-codegen-pull-request-task.yml +++ b/catalog/snippets/workflows/run-codegen-pull-request-task.yml @@ -54,14 +54,14 @@ jobs: - name: Run codegen step run: | - set -euo pipefail + set -Eeuo pipefail dotnet run --project ./CodeGen/CodeGen.csproj -- \ --codepath ./CodeGen \ --apikey "${{ secrets.NINJA_API_KEY }}" - name: Format code step run: | - set -euo pipefail + set -Eeuo pipefail dotnet tool restore dotnet csharpier format --log-level=debug . git status diff --git a/cspell.json b/cspell.json index 07f8024f..a6d471e0 100644 --- a/cspell.json +++ b/cspell.json @@ -20,6 +20,7 @@ "buildmetadata", "buildtransitive", "Buildx", + "bumpable", "charliermarsh", "chowned", "chowns", @@ -99,6 +100,7 @@ "pyrightconfig", "pytest", "quoteoftheday", + "regen", "resharper", "rhysd", "Rubba", @@ -114,6 +116,7 @@ "softprops", "somecommand", "sshconfig", + "stdlib", "subsetting", "timonwong", "Triaging", @@ -121,6 +124,7 @@ "unbuilt", "unvalidated", "USERPROFILE", + "uvx", "venv", "Viljoen", "winget", diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md new file mode 100644 index 00000000..9e41a33d --- /dev/null +++ b/docs/repo-config-carry.md @@ -0,0 +1,60 @@ +# repo-config: Carry, Apply, and Regenerate (Hub-Only) + +The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** - it is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config. This carry/apply/regen procedure lives here so it never ships into a downstream copy. + +## Downstream Carry + +Every fleet repo carries the `repo-config/` directory. The hub keeps the canonical copy. Rules for the carried copy: + +- **Carry only your model's `develop` variant.** A `release` repo carries `develop.json`. An `operational` repo carries `operational/develop.json` instead. `main.json` and `settings.json` are shared by both models. `configure.sh` aborts when the payload its model needs is missing rather than applying a partial configuration. +- **Carried files name no fleet repo as an illustrative example.** A carried file adds no template-repo reference and names no sibling fleet repo as an example (any fleet repo may be private, so such a link 404s in a public carrier, and it couples the repos). A contextually relevant link a reader of *this* repo's content needs is fine. See [AGENTS.md "Documentation Style Conventions"][agents-documentation-style]. To point at a current good example, name it in the onboarding/conformance issue or the hub-only [`reports/conformance-matrix.md`][conformance-matrix]. +- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline - the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative. The adapted `AUDIT.md` is a settings diff, a normalized ruleset diff against the carried payloads (an operational carry swaps in `operational/develop.json`), and a names-only secrets check, all targeting the current repo - adapt this shape, don't invent. A current well-formed example is named in the onboarding/conformance issue. +- **Adapted `spec/secrets.json` shape.** The repo-scoped adaptation carries `baseline` (the App pair, which every fleet repo needs for the merge-bot) plus a `mechanisms` entry for each publish mechanism the repo actually uses, and the `targetMechanisms` routing entries for those mechanisms. **A source-only repo whose publish targets all map to a null mechanism (nothing to route) carries just `baseline` (plus a `note`)** - it omits `targetMechanisms` and `mechanisms` entirely, because a lone `targetMechanisms` map with no `mechanisms` reads as a schema bug (the audit enumerates `baseline` + `mechanisms`, never `targetMechanisms`, so an all-null routing map is dead weight). A `release` repo that uses a real mechanism (e.g. `nuget-oidc`, `docker-hub`, `codecov`) carries that `mechanisms` entry **and** its `targetMechanisms`/`typeMechanisms` routing, which the audit then picks up. +- **The regen snippet targets the current repo**, so it works unchanged in a carried copy. + +## Applying the Config + +**Configure by importing the JSON payloads, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them). Only the `develop` *content* varies by model. + +First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (which picks the `develop` payload from the repo's `workflowModel`, or infers it from the carried payload when no registry is present, and applies `settings.json` alongside the rulesets): + +```sh +repo-config/configure.sh [owner/repo] [release|operational] +``` + +Or import each ruleset by hand with `gh api -X POST repos///rulesets --input repo-config/.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). + +## Regenerating the Payloads + +To change the canonical rulesets, edit the live rulesets (fleet-wide changes happen at the hub), then regenerate the committed files from the current repo: + +```sh +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +# Paginate so a name match on a later page is never missed - the same trap configure.sh guards against. +# --paginate with --jq '.[]' emits one JSON object per ruleset across all pages; jq -s re-assembles them +# into the single array the selections below expect. +rulesets=$(gh api --paginate "repos/$repo/rulesets" --jq '.[]' | jq -s '.') +for name in develop main; do + out="repo-config/$name.json" + # An operational carry keeps its develop payload at operational/develop.json (develop.json is absent). + [ "$name" = "develop" ] && [ ! -f "$out" ] && out="repo-config/operational/develop.json" + # Exactly one ruleset per name: zero or duplicates is declared drift - fail loudly, never regen from a guess. + count=$(jq --arg n "$name" '[.[] | select(.name==$n)] | length' <<<"$rulesets") + [ "$count" -eq 1 ] || { echo "expected exactly 1 ruleset named $name, found $count (drift)" >&2; exit 1; } + id=$(jq --arg n "$name" '.[] | select(.name==$n) | .id' <<<"$rulesets") + gh api "repos/$repo/rulesets/$id" \ + --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ + | jq -S --indent 4 '.' > "$out" +done +``` + +## Brownfield Migration (Maintainer Only) + +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. + + + +[agents-documentation-style]: ../AGENTS.md#documentation-style-conventions +[agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules +[conformance-matrix]: ../reports/conformance-matrix.md +[repo-config-readme]: ../repo-config/README.md diff --git a/host-setup/agent-safety/.markdownlint-cli2.jsonc b/host-setup/agent-safety/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..8eb5a70a --- /dev/null +++ b/host-setup/agent-safety/.markdownlint-cli2.jsonc @@ -0,0 +1,8 @@ +{ + // claude-md-safety.md is a fragment the installer appends into ~/.claude/CLAUDE.md (which already + // has its own H1), so it intentionally opens at H2. MD041 (first line must be a top-level heading) + // does not apply to an appended snippet. This nested config affects only this directory. + "config": { + "MD041": false + } +} diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md new file mode 100644 index 00000000..ee1062b4 --- /dev/null +++ b/host-setup/agent-safety/README.md @@ -0,0 +1,72 @@ +# Agent Write-Safety Kit + +Per-machine, user-account-scoped guards against an agent making a mis-targeted GitHub **write** under the maintainer's identity. Deploy it as the **first thing on any new system** where Claude Code runs with the `gh` credentials logged in (WSL, Linux, macOS, Proxmox, Windows). + +## What It Installs + +Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): + +- **`hooks/gh-write-guard.py`** - a PreToolUse hook that denies the three write footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is outside the checkout's `origin`. Reads and everything else pass through. It fires even in autonomous / bypass-permissions sessions, which is how the incident happened. +- **A `## GitHub Write Safety (Any Project, Every Session)` section in `CLAUDE.md`** - the same three rules as behavioral guidance, loaded into every session on the machine (including ad-hoc work outside any project). It mirrors the committed `AGENTS.md` "Repository Boundaries and Write Safety" rules, which only reach fleet repos. + +The hook is the mechanical backstop. The CLAUDE.md rules and the carried AGENTS.md rules are the behavioral layer. Prose alone is not enough - the incident happened under prose rules - so both ship. + +## Install (Idempotent - Safe to Re-Run to Update) + +```sh +# Linux / WSL / macOS / Proxmox +host-setup/agent-safety/install.sh +``` + +```powershell +# Windows +host-setup\agent-safety\install.ps1 +``` + +Both are thin wrappers around `install.py`, so every OS runs one tested code path. The installer self-tests the hook before registering it, merges the settings.json entry without clobbering other keys, and updates the CLAUDE.md block in place (marker-delimited) rather than duplicating it. + +**Restart Claude Code sessions on the machine afterward** so the new hook and CLAUDE.md load. + +## Verify (POSIX Shell) + +```sh +python3 ~/.claude/hooks/gh-write-guard.py --selftest # decision matrix: all cases pass +grep -c 'agent-safety v' ~/.claude/CLAUDE.md # expect 2 (start + end marker) +``` + +On Windows PowerShell: + +```powershell +py -3 "$env:USERPROFILE\.claude\hooks\gh-write-guard.py" --selftest # all cases pass +(Select-String 'agent-safety v' "$env:USERPROFILE\.claude\CLAUDE.md").Count # expect 2 +``` + +Live end-to-end (in any repo): attempt a discarded-output write and confirm the Bash tool is blocked: + +```sh +gh api graphql -f query='mutation{noop}' -F t="PRRT_x" >/dev/null 2>&1 || true # blocked by the hook +``` + +## Manual settings.json Shape (for Reference) + +The installer writes this. It is here so you can inspect or hand-place it: + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"python3\" \"/.claude/hooks/gh-write-guard.py\"" } ] } + ] + } +} +``` + +## Scope and Limits + +- **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. +- **Precision over recall.** The hook denies the specific dangerous shapes with high confidence rather than gating every write, so it never blocks legitimate work. A shape it does not catch still falls under the behavioral rules. +- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all - a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. +- **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. + + +[issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 diff --git a/host-setup/agent-safety/claude-md-safety.md b/host-setup/agent-safety/claude-md-safety.md new file mode 100644 index 00000000..38dc8a49 --- /dev/null +++ b/host-setup/agent-safety/claude-md-safety.md @@ -0,0 +1,9 @@ + +## GitHub Write Safety (Any Project, Every Session) + +A `gh` / GitHub API write runs under the logged-in identity, so a mis-targeted write acts publicly as that account on someone else's repository - outward-facing and hard to reverse. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) in every session on this machine, including ad-hoc work outside any project. Reads are unrestricted. A committed repo's `AGENTS.md` "Repository Boundaries and Write Safety" states the same rules for its fleet, and the two are kept in sync deliberately, because this file also covers sessions that `AGENTS.md` never reaches. The `gh-write-guard` PreToolUse hook enforces the mechanical half. + +- **Write only to the current project's own repository.** Every state-changing call targets this checkout's `origin` and nothing else. A broad or logged-in identity is capability, not permission. Another repository needs explicit, per-session human permission for that specific repository, and a "harmless test" write is still a write. +- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a write consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the same session into a variable and passed from there. Ids resolve globally, so a wrong-but-valid id does not fail - it writes to the wrong target in another repository. If a query returns no id, stop rather than invent one. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works, and never append an output-discarding or force-success tail (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`) to a mutation. A write that appears to fail is verified, not assumed harmless - it may have succeeded on the server. + diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py new file mode 100644 index 00000000..5ce846e1 --- /dev/null +++ b/host-setup/agent-safety/gh-write-guard.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""PreToolUse guard: deny the GitHub-write footguns behind the cross-repo comment incident. + +Registered as a Claude Code PreToolUse hook on the Bash tool. It reads the tool-input JSON on stdin, +classifies the command, and DENIES (with a reason shown to the agent) when a command is a GitHub *write* +matching a known-dangerous pattern. Reads and everything that is not a clear write pass through. + +Precision over recall by design: it denies the specific shapes that caused the incident, not everything +it cannot parse. A false deny would break the agent, while a missed case still falls under the AGENTS.md +"Repository Boundaries and Write Safety" prose rules. The three denied shapes: + + 1. a state-changing gh call whose output is discarded or forced to success + (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) + 2. a GraphQL mutation passing a literal GitHub node id (PRRT_/PR_/BOT_/...) instead of a $variable + 3. a gh write with an explicit -R/--repo/repos// target outside the checkout's origin + +Run `gh-write-guard.py --selftest` to verify the decision matrix without Claude Code. +""" +import json +import os +import re +import subprocess +import sys + +# --- What counts as a GitHub write ------------------------------------------------------------------- +# gh subcommands that mutate. `gh api` is handled separately (it needs field/method inspection). +_GH_WRITE_SUB = re.compile( + r"""\bgh\s+(?: + pr\s+(?:create|comment|close|merge|edit|review|reopen|ready|lock|unlock) + | issue\s+(?:create|comment|close|edit|reopen|delete|lock|unlock|pin|unpin|transfer) + | release\s+(?:create|edit|delete|upload) + | repo\s+(?:create|delete|edit|rename|archive) + | (?:label|secret|variable|ruleset)\s+(?:create|delete|edit|set) + | gist\s+(?:create|edit|delete) + )\b""", + re.X, +) +_GH_API = re.compile(r"\bgh\s+api\b") +_EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.I) +# gh api with a field flag defaults to POST even without -X, so it is a write. +_API_FIELD_FLAG = re.compile(r"(?:^|\s)(?:-f|-F|--field|--raw-field|--input)\b") +_GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.S) +_MUTATION = re.compile(r"\bmutation\b") +_GIT_PUSH = re.compile(r"\bgit\s+push\b") + +# --- Risk-pattern detectors -------------------------------------------------------------------------- +# Output-discard / force-success tails. Bare `2>&1` is NOT here: it merges stderr into stdout, leaving +# the output visible, so it is not suppression (and denying it would break `... 2>&1 | tee log`). +_SUPPRESS = re.compile(r">\s*/dev/null|&>\s*/dev/null|2>\s*/dev/null|\|\|\s*(?:true\b|echo\b|:)") +# A quoted argument value ("..." or '...'). Stripped before the suppression scan so a --body/--title +# that merely mentions `|| true` or `>/dev/null` as text is not mistaken for a real command tail. Real +# suppression tails are unquoted shell operators, so stripping quotes never hides an actual footgun. The +# double-quoted form allows `\"` escapes so an embedded quote does not end the span early; shell single +# quotes take no escapes, so their form is literal. +_QUOTED_SPAN = re.compile(r'"(?:\\.|[^"\\])*"' r"|'[^']*'") +# A GitHub global node id literal: an UPPERCASE prefix (PR_, PRRT_, IC_, BOT_, ...) + a long base64url +# body, or a legacy MD... base64 id. The uppercase prefix plus a >=12-char body keeps it from matching +# an ordinary underscored word in a reply body (e.g. body="fixed_the_thing_now", lowercase prefix). +_NODE_ID_LITERAL = re.compile(r'^(?:[A-Z]{1,5}_[A-Za-z0-9_\-]{12,}|MD[A-Za-z0-9]{12,})$') +# -F/-f name=VALUE, capturing the value - handles "quoted" and bare +_FIELD_ASSIGN = re.compile(r"""(?:-F|-f|--field|--raw-field)\s+[A-Za-z_][\w]*=(?P'[^']*'|"[^"]*"|\S+)""") +_EXPLICIT_REPO = re.compile(r"(?:-R|--repo)\s+(?P['\"]?)(?P[^\s'\"]+)(?P=q)") +_API_REPO_PATH = re.compile(r"\bgh\s+api\b[^\n|]*?\brepos/(?P[A-Za-z0-9_.\-]+)/(?P[A-Za-z0-9_.\-]+)") + + +def _is_gh_write(cmd): + if _GH_WRITE_SUB.search(cmd) or _GIT_PUSH.search(cmd): + return True + if _GH_API.search(cmd): + if _EXPLICIT_WRITE_METHOD.search(cmd): + return True + if _GRAPHQL.search(cmd) and _MUTATION.search(cmd): + return True + if _API_FIELD_FLAG.search(cmd) and not _GRAPHQL.search(cmd): + return True # gh api -f k=v => POST + return False + + +def _origin_owner_repo(cwd): + try: + url = subprocess.run( + ["git", "-C", cwd or ".", "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except Exception: + return None + m = re.search(r"[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$", url) + return (m.group(1).lower(), m.group(2).lower()) if m else None + + +def classify(cmd, cwd=None, origin=None): + """Return (decision, reason). decision is 'allow' or 'deny'. + + origin, when given, is a (owner, repo) tuple used instead of resolving from cwd - the self-test + passes it for a deterministic, offline run. + """ + if not _is_gh_write(cmd): + return "allow", "" + + # 1. suppressed output on a write - scan with quoted argument values removed so a --body/--title + # that only mentions a suppression token as text does not false-deny a legitimate write. + if _SUPPRESS.search(_QUOTED_SPAN.sub("", cmd)): + return "deny", ( + "This is a GitHub write with its output discarded or forced to success " + "(>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo). " + "A write's result is exactly what must be read: a mutation can succeed on the server " + "while the client reports an error. Run it without the output-discarding tail and read " + "the response. See AGENTS.md 'Repository Boundaries and Write Safety'." + ) + + # 2. literal node id in a mutation + if _GRAPHQL.search(cmd) and _MUTATION.search(cmd): + for m in _FIELD_ASSIGN.finditer(cmd): + val = m.group("v").strip("'\"") + if val.startswith("$") or val.startswith("${"): + continue + if _NODE_ID_LITERAL.match(val): + return "deny", ( + f"This mutation passes a literal GitHub node id ({val[:16]}...) instead of a " + "variable captured from a live query. Node ids resolve globally, so a fabricated " + "or stale id writes to a real object in another repository. Capture the id from a " + "query in this session into a variable and pass -F ...=\"$VAR\". See AGENTS.md " + "'Repository Boundaries and Write Safety'." + ) + + # 3. explicit target outside origin + if origin is None: + origin = _origin_owner_repo(cwd) + targets = [] + mr = _EXPLICIT_REPO.search(cmd) + if mr and "/" in mr.group("r") and "<" not in mr.group("r"): + o, r = mr.group("r").split("/", 1) + targets.append((o.lower(), r.lower())) + for m in _API_REPO_PATH.finditer(cmd): + if "<" not in m.group("owner"): + targets.append((m.group("owner").lower(), m.group("repo").lower())) + # Only runs when origin resolves (a git checkout): with no project context there is nothing to + # compare an explicit target against, so this check is skipped and rules 1-2 still apply. A node-id + # target is invisible here regardless - that is what rule 2 guards. + if origin: + for t in targets: + if t != origin: + return "deny", ( + f"This write targets {t[0]}/{t[1]}, which is not this checkout's origin " + f"({origin[0]}/{origin[1]}). Write only to the current project's own repository. " + "Another repository needs explicit per-session permission. See AGENTS.md " + "'Repository Boundaries and Write Safety'." + ) + + return "allow", "" + + +# --- Self-test --------------------------------------------------------------------------------------- +_CASES = [ + # (command, expected_decision, label) + ("gh api graphql -f query='mutation($t:ID!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$t,body:\"x\"}){comment{id}}}' -F t=\"PRRT_kwDODvuuzM6SFvx0\" >/dev/null 2>&1 || true", "deny", "the incident: suppressed + literal id"), + ("gh api graphql -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"PRRT_kwDOabc123def\"", "deny", "literal node id in a mutation"), + ("gh api graphql -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"$TID\"", "allow", "mutation with captured $TID"), + ("gh issue comment 5 -R mankatcheung/job-finder --body \"hi\"", "deny", "cross-origin explicit -R"), + ("gh issue comment 5 -R \"mankatcheung/job-finder\" --body \"hi\"", "deny", "cross-origin quoted -R"), + ("gh pr create --title x --body y >/dev/null 2>&1", "deny", "suppressed gh pr create"), + ("gh api repos/ptr727/PlexCleaner/issues/1/comments -f body=\"ok\"", "allow", "gh api POST to origin"), + ("gh api graphql -f query='{repository(owner:\"o\",name:\"r\"){pullRequest(number:1){reviewThreads(first:100){nodes{id}}}}}'", "allow", "graphql READ query"), + ("gh pr view 5 --json reviews", "allow", "gh pr view (read)"), + ("return 1 2>/dev/null || exit 1", "allow", "shell guard, not a gh write"), + ("git push origin develop", "allow", "normal push (no suppression, no cross-repo)"), + ("git commit -m 'x' && git push >/dev/null 2>&1", "deny", "push with discarded output"), + ("gh issue comment 5 --body x 2>&1 | tee out.log", "allow", "bare 2>&1 piped to tee is not suppression"), + ("gh pr comment 5 --body ok 2>&1", "allow", "bare 2>&1 leaves output visible"), + ("gh api repos/ptr727/PlexCleaner/issues/1/comments -f body=x 2>/dev/null", "deny", "stderr discarded on a write"), + ("gh issue comment 5 --body \"run make || true to skip errors\"", "allow", "|| true inside a quoted body is not a tail"), + ("gh pr comment 5 --body \"pipe noisy output to >/dev/null\"", "allow", ">/dev/null inside a quoted body is not a redirect"), + ("gh issue comment 5 --body \"see notes\" >/dev/null", "deny", "real redirect after a quoted body still denies"), + ("gh issue comment 5 --body \"he said \\\"pipe to >/dev/null\\\" today\"", "allow", "escaped quotes in a body do not end the span early"), + ("gh pr close 5 || :", "deny", "force-success no-op tail on a write"), + ("gh pr comment 5 --body x || echo done", "deny", "force-success echo tail on a write"), + ("gh api graphql -f query='mutation{addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$t,body:$b}){comment{id}}}' -F t=\"$TID\" -F b=\"fixed_the_underscore_bug_here\"", "allow", "underscored reply body is not a node id"), + ("gh api graphql -f query='mutation{resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"TODO_fixit\"", "allow", "short all-caps token is not a node id"), +] + + +def _selftest(): + # Deterministic offline run: pin origin to ptr727/PlexCleaner (the incident repo) so the + # cross-origin case resolves without touching a real checkout. + origin = ("ptr727", "plexcleaner") + ok = True + for cmd, want, label in _CASES: + got, _ = classify(cmd, origin=origin) + mark = "ok " if got == want else "FAIL" + if got != want: + ok = False + print(f" {mark} [{got:5}] want={want:5} {label}") + print("SELFTEST PASS" if ok else "SELFTEST FAIL") + return 0 if ok else 1 + + +# --- Hook entrypoint (PreToolUse) -------------------------------------------------------------------- +def _main(): + try: + data = json.load(sys.stdin) + except Exception: + sys.exit(0) # not our event shape - do not interfere + if data.get("tool_name") != "Bash": + sys.exit(0) + cmd = (data.get("tool_input") or {}).get("command", "") + cwd = data.get("cwd") or os.getcwd() + decision, reason = classify(cmd, cwd) + if decision == "deny": + # Documented PreToolUse deny contract (confirm field names against current docs before shipping). + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + })) + sys.exit(0) + sys.exit(0) + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(_selftest()) + _main() diff --git a/host-setup/agent-safety/install.ps1 b/host-setup/agent-safety/install.ps1 new file mode 100644 index 00000000..2274fea6 --- /dev/null +++ b/host-setup/agent-safety/install.ps1 @@ -0,0 +1,30 @@ +# Thin wrapper: run the cross-platform installer with a Python 3 (Windows). +# All logic lives in install.py so every OS runs one tested code path. Idempotent, safe to re-run. +# .\install.ps1 +# $env:CLAUDE_HOME = "C:\path"; .\install.ps1 # override the target (testing) +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$script = Join-Path $here "install.py" + +# Prefer launchers that are unambiguously Python 3. install.py and the hook use Python 3 syntax, so a +# bare `python` (Python 2 on some systems) is the last resort. +if (Get-Command "py" -ErrorAction SilentlyContinue) { + & py -3 $script @args +} elseif (Get-Command "python3" -ErrorAction SilentlyContinue) { + & python3 $script @args +} elseif (Get-Command "python" -ErrorAction SilentlyContinue) { + # Verify a bare `python` is Python 3 before handing it Python 3 syntax - it is Python 2 on some setups, + # which would fail to parse install.py. py -3 and python3 above are Python 3 by construction. + & python -c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Error "Found python on PATH but it is not Python 3 (tried py -3, python3, python). Install Python 3." + exit 1 + } + & python $script @args +} else { + Write-Error "Python 3 is required and was not found on PATH (tried py -3, python3, python)." + exit 1 +} + +# Propagate the installer's exit code - a native command's non-zero exit does not stop the script. +exit $LASTEXITCODE diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py new file mode 100644 index 00000000..891cafe9 --- /dev/null +++ b/host-setup/agent-safety/install.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Install the agent write-safety kit for the current user account. Cross-platform, idempotent. + +Deploys the PreToolUse hook, registers it in the user settings.json, adds the safety rules to the user +CLAUDE.md (marker-delimited so re-runs update in place), and self-tests the hook before registering it. +The bash and PowerShell wrappers both call this, so every OS runs one tested code path. + +Usage: python3 install.py (installs to ~/.claude) + CLAUDE_HOME=/x python3 install.py (override target, for testing) +""" +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys + +HERE = pathlib.Path(__file__).resolve().parent + + +def hook_launcher(): + """A python invocation for the settings.json command. Prefer a bare `python3` (portable and + unambiguously Python 3), else this interpreter's absolute path (guaranteed the Python 3 running the + installer). Never a bare `python`, which is Python 2 on some systems and would fail the hook's + Python 3 syntax.""" + if shutil.which("python3"): + return "python3" + return sys.executable + + +def main(): + if sys.version_info < (3, 7): + sys.stderr.write("This installer and the hook require Python 3.7+. Run it with python3.\n") + return 1 + # expanduser so a CLAUDE_HOME set to a `~/...` form resolves to the home dir, not a literal `~` dir. + claude_home_env = os.environ.get("CLAUDE_HOME") + claude_home = pathlib.Path(claude_home_env).expanduser() if claude_home_env else pathlib.Path.home() / ".claude" + hooks_dir = claude_home / "hooks" + hook_dst = hooks_dir / "gh-write-guard.py" + settings = claude_home / "settings.json" + claude_md = claude_home / "CLAUDE.md" + + print(f"Installing agent write-safety kit into: {claude_home}") + hooks_dir.mkdir(parents=True, exist_ok=True) + + # 1. Deploy the hook and self-test it BEFORE wiring anything up. + shutil.copyfile(HERE / "gh-write-guard.py", hook_dst) + try: + os.chmod(hook_dst, 0o755) + except OSError: + pass + print(f" hook -> {hook_dst}") + r = subprocess.run([sys.executable, str(hook_dst), "--selftest"], capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write("Hook self-test FAILED; aborting before registration.\n" + r.stdout + r.stderr) + return 1 + print(" hook self-test: PASS") + + # 2. Register our hook command in settings.json so exactly one PreToolUse/Bash group carries it. + launcher = hook_launcher() + # Quote the launcher too: the sys.executable fallback can contain spaces (e.g. C:\Program Files\...). + hook_cmd = f'"{launcher}" "{hook_dst}"' + data = {} + if settings.exists() and settings.read_text(encoding="utf-8").strip(): + try: + data = json.loads(settings.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + sys.stderr.write( + f"{settings} exists but is not valid JSON ({e}). Fix or remove it, then re-run.\n" + ) + return 1 + pre = data.setdefault("hooks", {}).setdefault("PreToolUse", []) + # Strip our hook from every existing group first, so a re-run never leaves a duplicate behind even + # when settings.json already has more than one Bash group. Then register it in a single Bash group. + for g in pre: + hooks_list = g.get("hooks") + if isinstance(hooks_list, list): + hooks_list[:] = [h for h in hooks_list if "gh-write-guard" not in str(h.get("command", ""))] + group = next((g for g in pre if g.get("matcher") == "Bash"), None) + if group is None: + group = {"matcher": "Bash", "hooks": []} + pre.append(group) + group.setdefault("hooks", []).append({"type": "command", "command": hook_cmd}) + settings.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + print(f" settings -> {settings} (PreToolUse/Bash hook registered)") + + # 3. CLAUDE.md: replace the agent-safety marker block if present, else append it. + snippet = (HERE / "claude-md-safety.md").read_text(encoding="utf-8").strip() + # Preserve CLAUDE.md's existing line endings: work in \n internally, write back with its own ending. + if claude_md.exists(): + raw = claude_md.read_bytes() + newline = "\r\n" if b"\r\n" in raw else "\n" + existing = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + else: + newline, existing = "\n", "" + block_re = re.compile(r".*?", re.S) + if block_re.search(existing): + updated, action = block_re.sub(lambda _: snippet, existing), "updated" + else: + sep = "" if existing == "" or existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n") + updated, action = existing + sep + snippet + "\n", "appended" + claude_md.write_bytes(updated.replace("\n", newline).encode("utf-8")) + print(f" CLAUDE.md -> {claude_md} (safety block {action})") + + print("\nDone. Verify:") + print(f" {launcher} \"{hook_dst}\" --selftest") + print(f" grep -c 'agent-safety v' \"{claude_md}\" # expect 2") + print("Restart Claude Code sessions on this machine so the hook and CLAUDE.md load.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/host-setup/agent-safety/install.sh b/host-setup/agent-safety/install.sh new file mode 100755 index 00000000..d52c88d0 --- /dev/null +++ b/host-setup/agent-safety/install.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Thin wrapper: run the cross-platform installer with a Python 3 (Linux / WSL / macOS / Proxmox). +# All logic lives in install.py so every OS runs one tested code path. Idempotent, safe to re-run. +# ./install.sh installs to ~/.claude +# CLAUDE_HOME=/x ./install.sh overrides the target (testing) +set -Eeuo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Pick the first candidate that is actually Python 3 - install.py and the hook use Python 3 syntax, so a +# bare `python` that is Python 2 must be rejected, not handed the script (it would fail on import). +py="" +for c in python3 python; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)' 2>/dev/null; then + py="$c"; break + fi +done +[ -n "$py" ] || { echo "Python 3 is required and was not found on PATH (tried python3, python)." >&2; exit 1; } + +exec "$py" "$here/install.py" "$@" diff --git a/registry/repos.json b/registry/repos.json index 210a708d..b6082239 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -104,7 +104,7 @@ "name": "PlexCleaner", "url": "https://github.com/ptr727/PlexCleaner", "status": "cataloged", - "types": ["csharp", "console", "docker"], + "types": ["csharp", "console", "docker", "python"], "groundTruthBranch": "main", "hasDevelop": true, "publish": [ @@ -115,7 +115,7 @@ "consumerModel": "pull", "releaseTrigger": "two-phase", "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "Branch hygiene: 3 stale Dependabot nuget branches (PRs closed/superseded) and an unmerged feature/727-decouple-release-assets branch linger; main+develop otherwise clean after the 2026-07 sweep."] + "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "First csharp+python repo: a C# console app at the root plus a stdlib-only Python tooling subtree (RegressionTests/, uvx scripts profile - no uv.lock, pyproject carries only ruff+mypy config; PlexCleaner#855). python.uvlock.pinned and python.coverage.codecov are N/A for that subtree (no uv project, no tests); codecov.yml stays required for the C# side. Reference for the csharp+python shape (issue #339).", "Branch hygiene: 3 stale Dependabot nuget branches (PRs closed/superseded) and an unmerged feature/727-decouple-release-assets branch linger; main+develop otherwise clean after the 2026-07 sweep."] }, { "name": "ESPHome-NonRoot", @@ -163,7 +163,7 @@ "url": "https://github.com/ptr727/HomeAutomation-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", + "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "lf", "hasDevelop": true, @@ -171,27 +171,25 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); Linux-consumed on the Proxmox host, so lineEndings lf.", "Renamed from HomeAutomation for fleet naming consistency (config repos are *-Config). The Vantage controller config is split out to its own Windows/CRLF Vantage-Config repo, not carried here - strip the legacy Vantage/ subtree during onboarding.", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] + "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); Linux-consumed on the Proxmox host, so lineEndings lf.", "Renamed from HomeAutomation for fleet naming consistency (config repos are *-Config). The Vantage controller config is split out to its own Windows/CRLF Vantage-Config repo, not carried here; the legacy Vantage/ subtree is stripped.", "Private; README self-flags previously-committed secrets - secrets-hygiene concern."] }, { "name": "KiCadLibrary", "url": "https://github.com/ptr727/KiCadLibrary", "status": "cataloged", "types": ["eda"], - "groundTruthBranch": "develop", "hasDevelop": true, "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "two-phase", - "driftNotes": ["EDA/KiCad part library; delivers a github-release data zip.", "main is stale (data + README only): the full fleet CI, NBGV version.json, and the Python build/verify pipeline live only on develop (the recorded ground-truth branch) - promote to main to converge.", "Python tooling uses requirements-dev.txt, not pyproject.toml; no repo-config/ rulesets."] + "driftNotes": ["EDA/KiCad part library; delivers a github-release data zip.", "main is stale (data + README only): the full fleet CI, NBGV version.json, and the Python build/verify pipeline live only on develop - promote to main to converge.", "Python tooling uses requirements-dev.txt, not pyproject.toml; no repo-config/ rulesets."] }, { "name": "EspDinIoT", "url": "https://github.com/ptr727/EspDinIoT", "status": "cataloged", "types": ["eda"], - "groundTruthBranch": "develop", "hasDevelop": true, "publish": [], "requiredSecrets": [], @@ -204,7 +202,7 @@ "url": "https://github.com/ptr727/ESPHome-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", + "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "lf", "hasDevelop": true, @@ -212,7 +210,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime; distinct from that Docker repo.", "Operational rollout pending: an esphome-config validation job feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption. Dependabot currently covers only the devcontainers ecosystem."] + "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime; distinct from that Docker repo."] }, { "name": "HomeAssistant-Config", @@ -227,7 +225,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational rollout pending: 'master' renamed to 'main' and 'develop' created (done); develop is behind main by 2 commits - fast-forward develop up to main so it holds the live content, then flip groundTruthBranch to develop. Also pending: lint CI (a Home Assistant config-check feeding the required check) and dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip).", "Private; deployed by git pull into the HA config dir."] + "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational onboarding completed 2026-07-17 (HomeAssistant-Config #16): master->main rename + develop created, advisory lint CI (Check pull request workflow status job required check), dispatch-only source release (version.json + NBGV + publish-release.yml), repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the CODEGEN_APP_* pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json); baseline promoted develop->main via HomeAssistant-Config #17.", "groundTruthBranch intentionally main: develop is the working branch (direct signed commits), main the promoted stable snapshot the audit targets - deliberately not flipped to develop (ptr727/ProjectTemplate#340).", "Private; deployed by git pull into the HA config dir."] }, { "name": "DevKitCIoT", @@ -247,13 +245,12 @@ "url": "https://github.com/ptr727/PhotoCleaner", "status": "cataloged", "types": ["csharp", "console", "docker"], - "groundTruthBranch": "develop", "hasDevelop": true, "publish": [], "requiredSecrets": ["CODECOV_TOKEN"], "consumerModel": "pull", "releaseTrigger": "none", - "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config).", "Non-conformant: default/only branch is 'develop', no 'main' - must create main (should never be permanent).", "Dockerfile present but no docker build/push workflow, so no publish wired."] + "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config).", "Pre-conformance: main exists but the work-in-progress content lives on develop - promote to main to converge.", "Dockerfile present but no docker build/push workflow, so no publish wired."] }, { "name": "MediaTools", @@ -286,7 +283,7 @@ "url": "https://github.com/ptr727/Vantage-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", + "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "crlf", "hasDevelop": true, @@ -294,14 +291,13 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Vantage InFusion / Design Center controller config edited on Windows (UTF-8 CRLF .dc XML, really special XML), so lineEndings crlf.", "Recreated lean and single-platform: Design Center is freely available, so no installer archives are kept; split out of HomeAutomation-Config. Being repopulated from the Windows editing host - operational onboarding (baseline, lint CI, dispatch-only publisher, rulesets, develop/main) pending once content lands."] + "driftNotes": ["Vantage InFusion / Design Center controller config edited on Windows (UTF-8 CRLF .dc XML, really special XML), so lineEndings crlf.", "Recreated lean and single-platform: Design Center is freely available, so no installer archives are kept; split out of HomeAutomation-Config.", "Operational onboarding completed 2026-07-16 (Vantage-Config #9): baseline docs, advisory lint CI, dispatch-only publisher, repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the secret pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json)."] }, { "name": "HolidayLights", "url": "https://github.com/ptr727/HolidayLights", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", "hasDevelop": true, "publish": [], "requiredSecrets": [], diff --git a/repo-config/README.md b/repo-config/README.md index 413c0671..c8dc6bf9 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,19 +1,10 @@ # repo-config -Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration - workflows, Dependabot). This mirrors the layout the fleet repos use. +Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration - workflows, Dependabot). -- `main.json` plus one `develop` variant - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos); the hub keeps both, a carried copy only its own model's (see "Downstream Carry"). These are the canonical expected payloads that the audit (the hub's fleet-wide `AUDIT.md`, or a carried repo-scoped adaptation - see "Downstream Carry") diffs the live rulesets against. -- `operational/develop.json` - the `develop` ruleset for **operational** repos (registry `workflowModel: operational`): direct signed pushes, no PR gate. Present at the hub and in operational carries only - a carried `release` repo does not have it. See "Rulesets" below. -- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo] [release|operational]`; the model defaults to the registry `workflowModel` lookup. - -## Downstream Carry - -Every fleet repo carries this directory; the hub keeps the canonical copy. Rules for the carried copy: - -- **Carry only your model's `develop` variant.** A `release` repo carries `develop.json`; an `operational` repo carries `operational/develop.json` instead. `main.json` and `settings.json` are shared by both models. `configure.sh` aborts when the payload its model needs is missing rather than applying a partial configuration. -- **Hub-only references stay plain text.** The hub is a private repo: never URL-link it from a downstream repo - the link 404s for anyone without hub access. Files whose canonical fleet-wide form lives only at the hub are mentioned by name, not linked; links into files every repo carries (`AGENTS.md`) resolve everywhere and are fine. -- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline - the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative, and the local copies never link the hub. -- **The regen snippet targets the current repo**, so it works unchanged in a carried copy. +- `main.json` plus one `develop` variant - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos). These are the canonical expected payloads that the self-audit (`AUDIT.md`) diffs the live rulesets against. +- `operational/develop.json` - the `develop` ruleset for **operational** repos (registry `workflowModel: operational`): direct signed pushes, no PR gate. Present in operational repos only - a `release` repo does not have it. See "Rulesets" below. +- `configure.sh` - applies the rulesets and settings to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo] [release|operational]`; the model may also be passed as the sole argument (`repo-config/configure.sh operational`). The model defaults to the registry `workflowModel` lookup where a registry is present, else it is inferred from which `develop` payload is present (an ambiguous layout aborts rather than guesses). ## Rulesets @@ -24,26 +15,11 @@ Two workflow models share `main.json` but differ on `develop` (registry `workflo `main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion - so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"][agents-branching-model]. -**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them); only the `develop` *content* varies by model. First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (which picks the `develop` payload from the repo's `workflowModel`), or `gh api -X POST repos///rulesets --input repo-config/.json` per file (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only; creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). - -To change the canonical rulesets, edit the live rulesets (fleet-wide changes happen at the hub), then regenerate the committed files from the current repo: - -```sh -repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" -for name in develop main; do - out="repo-config/$name.json" - # An operational carry keeps its develop payload at operational/develop.json (develop.json is absent). - [ "$name" = "develop" ] && [ ! -e "$out" ] && out="repo-config/operational/develop.json" - id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id") - gh api "repos/$repo/rulesets/$id" \ - --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ - | jq -S --indent 4 '.' > "$out" -done -``` +The result is **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them); only the `develop` *content* varies by model. The required check binds by name and only turns green after the repo's PR workflow runs once. ## Secrets -Publish credentials required per mechanism are enumerated in `spec/secrets.json` (canonical at the hub; a downstream repo carries a repo-scoped adaptation - see "Downstream Carry"). A repo needs only the mechanisms its own publish targets use - a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. +Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use - a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. ## Repo Settings @@ -56,12 +32,7 @@ The fleet-standard general settings live in [`settings.json`][settings-json] and - **Wikis and Projects off. Discussions on public repos only** (off on private). **Sponsorships off** - the button is driven by `.github/FUNDING.yml`, not a REST toggle, and the fleet ships none. - **Actions / General**: allow GitHub Actions to create and approve pull requests (for the bots). -## Brownfield Migration (Maintainer Only) - -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it; surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. - [agents-branching-model]: ../AGENTS.md#branching-model -[agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules [settings-json]: ./settings.json diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 6e2b228c..5082ea0f 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -5,35 +5,52 @@ # 2. The branch rulesets. main.json is shared by both workflow models; the develop ruleset is model-specific - # release repos use develop.json (PR-gated), operational repos use operational/develop.json (direct signed # pushes). The model is read from ../registry/repos.json (per-repo workflowModel, else defaults.workflowModel, -# else release) and can be overridden with the second argument. Each .json holds the writable ruleset -# subset {name, target, enforcement, bypass_actors, conditions, rules}. An existing ruleset (matched by name) -# is updated with a full-payload PUT (partial PUTs 422); a missing one is created with POST. +# else release) and can be overridden with the model argument. In a downstream carry the registry is absent; +# the model is then inferred from which develop payload is carried (a carry holds exactly its own model's). +# Each .json holds the writable ruleset subset {name, target, enforcement, bypass_actors, conditions, +# rules}. An existing ruleset (matched by name) is updated with a full-payload PUT (partial PUTs 422); a +# missing one is created with POST. # Rerunning is idempotent. # # Usage: repo-config/configure.sh [owner/repo] [release|operational] (repo defaults to the current repo via gh; -# model defaults to the registry lookup) -set -euo pipefail +# model defaults to the registry lookup, else payload inference). The model may also be passed as the sole +# argument: repo-config/configure.sh operational +set -Eeuo pipefail -repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +repo_arg="${1:-}" +model="${2:-}" +# Allow the model as the sole argument (`repo-config/configure.sh operational`): a model name in arg 1 is not a repo. +case "$repo_arg" in + release|operational) model="$repo_arg"; repo_arg="" ;; +esac +repo="${repo_arg:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ----- Resolve the workflow model (selects the develop ruleset) ----- registry="$script_dir/../registry/repos.json" name="${repo##*/}" -model="${2:-}" if [ -z "$model" ]; then if [ -f "$registry" ]; then # Fail fast on a jq/parse error (malformed registry) instead of silently applying the release default # to a repo whose lookup actually broke. A repo simply absent from the registry is not an error: the # expression falls back through defaults.workflowModel to "release", so jq still exits 0 with a value. if ! model="$(jq -r --arg n "$name" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' "$registry")"; then - echo "Failed to read workflowModel from $registry (invalid JSON?). Pass the model explicitly as arg 2." >&2 + echo "Failed to read workflowModel from $registry (invalid JSON?). Pass the model explicitly (release|operational)." >&2 exit 1 fi else - # No registry to consult (e.g. running the script standalone) - default, but say so. - echo "Registry $registry not found; defaulting workflow model to release." >&2 - model="release" + # No registry to consult (a downstream carry): infer the model from which develop payload is carried - + # a carry holds exactly its own model's payload. Ambiguous layouts (both or neither, e.g. a partial + # copy) abort rather than guess; a wrong guess would apply the wrong develop ruleset. + if [ -f "$script_dir/develop.json" ] && [ ! -f "$script_dir/operational/develop.json" ]; then + model="release" + elif [ -f "$script_dir/operational/develop.json" ] && [ ! -f "$script_dir/develop.json" ]; then + model="operational" + else + echo "Registry $registry not found and the carried develop payloads are ambiguous (expected exactly one of develop.json or operational/develop.json). Pass the model explicitly (release|operational)." >&2 + exit 1 + fi + echo "Registry $registry not found; inferred workflow model '$model' from the carried develop payload." >&2 fi fi case "$model" in diff --git a/reports/_template.md b/reports/_template.md index 0399ec22..74b8a422 100644 --- a/reports/_template.md +++ b/reports/_template.md @@ -1,13 +1,13 @@ -# Audit: +# Audit: `` - **Audited branch:** main (``) -- **Types:** +- **Types:** `` - **Verdict:** operational | not operational -- **Date:** +- **Date:** `` ## Develop Drift -`develop` vs `main`: ahead , behind . +`develop` vs `main`: ahead ``, behind ``. `` ## Dimensions @@ -30,12 +30,12 @@ Verdict values: pass | drift | defect | N/A. Remove rows that are N/A for the re ## Defects (most severe first) -1. - input/condition -> observed vs expected; `file:line`. +1. `` - input/condition -> observed vs expected, at `file:line`. ## Drift Findings -- - `file:line`. +- `` - `file:line`. ## Proposed Registry / Spec Updates -- +- `` diff --git a/reports/conformance-matrix.md b/reports/conformance-matrix.md index 0e1804db..b037d5ec 100644 --- a/reports/conformance-matrix.md +++ b/reports/conformance-matrix.md @@ -13,6 +13,7 @@ The primary shapes are stood up as whole repos; the **composable targets** (`nug | `python` + `source-only` | Financial-Modeling | not-tested | - | Reference for the source-release (dispatch-only) profile; the downstream standup issue is open. | | `csharp` + `console` | - | not-tested | - | | | `csharp` + `docker` | - | not-tested | - | | +| `csharp` + `python` | PlexCleaner | not-tested | - | First mixed-language shape (#339). Python is a stdlib-only `uvx` **scripts** profile subtree (`RegressionTests/`): no `uv.lock`, `pyproject.toml` lint/type config only, mypy checker, `python.uvlock.pinned` + `python.coverage.codecov` N/A; `codecov.yml` stays required for the C# side. Both language rule-sets apply (CODESTYLE.md "Two profiles"). | | `homeassistant` | - | not-tested | - | Standalone-config conventions (home-assistant/core); scored by the `ha.*` checks. | | `eda` | - | not-tested | - | Data-zip release, pull consumer. | | `upstream-wrapper` | - | not-tested | - | Tag from a committed state file, not SemVer2. | diff --git a/spec/audit.py b/spec/audit.py index be05929a..13ea85b8 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -3,23 +3,29 @@ Compares each cataloged registry repo against the ground truth in this repo - general settings (repo-config/settings.json), branch rulesets (normalized diff vs the model's payloads), secret -names (spec/secrets.json; values are never read), baseline/per-type file presence on the -ground-truth branch (spec/files.json), and branch-model facts (main/develop existence, develop -behind main). Owner-initiated: run it when onboarding a repo, when drift is suspected, or before -fleet-wide changes. Read-only - it never modifies a target. +names (spec/secrets.json; values are never read), baseline/per-type file presence and per-scope +markdown section presence on the ground-truth branch (spec/files.json, spec/scope-model.md), and +branch-model facts (main/develop existence, develop behind main). Owner-initiated: run it when +onboarding a repo, when drift is suspected, or before fleet-wide changes. Read-only - it never +modifies a target. Findings: DEFECT (an applicable check fails outright), LETTER (a required file is absent - intent -unverified, judge per AUDIT.md section 7), DRIFT (non-breaking divergence, e.g. main carrying -content develop lacks, a stale secret, a registry field contradicting reality), ERROR (a gh call +unverified, judge per AUDIT.md section 7), DRIFT (non-breaking divergence, e.g. main-side +changes develop lacks, a stale secret, a registry field contradicting reality), ERROR (a gh call failed, so the repo could not be fully audited). Exits non-zero when any repo has a DEFECT, LETTER, or ERROR finding. Usage: python3 spec/audit.py [RepoName ...] (default: every cataloged repo) """ +import base64 +import functools +import hashlib import json import pathlib +import re import subprocess import sys +from datetime import datetime, timezone ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -28,14 +34,33 @@ "allow_rebase_merge", "allow_auto_merge", "allow_update_branch", "delete_branch_on_merge", ] RULESET_SUBSET = ["name", "target", "enforcement", "bypass_actors", "conditions", "rules"] +# Phrases in a registry driftNote that assert work still outstanding. Deliberately specific: a note +# recording a permanent deviation ("no get-version-task; relies on validate-task") must not match. +PENDING_MARKERS = ["pending", "not yet", "owed", "todo", "still", "behind", "missing", "absent"] def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) +def hub_name(): + """This repo's name on the remote, and whether it came from the remote. + + Read from origin rather than the checkout directory, which a differently-named clone, a fork, or a + worktree without an origin would silently break. The caller announces the directory-name fallback: + a silently degraded match is the fail-open case this check exists to prevent. + """ + r = subprocess.run(["git", "config", "--get", "remote.origin.url"], capture_output=True, text=True, cwd=ROOT) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip().rstrip("/").removesuffix(".git").split("/")[-1], True + return ROOT.name, False + + +HUB_NAME, HUB_NAME_FROM_REMOTE = hub_name() + + def gh(path, ok404=False): - """GET a REST path via gh; parsed JSON, or None on 404 when ok404. + """GET a REST path via gh, returning parsed JSON or None on 404 when ok404. No --paginate: on object endpoints it concatenates page documents into unparseable JSON. Every list read here fits one page; callers pass per_page=100 where a default page could truncate. @@ -62,6 +87,244 @@ def repo_slug(entry): return "/".join(entry["url"].rstrip("/").split("/")[-2:]) +def repo_selectors(entry, defaults): + """The scope-selector set a files.json appliesTo is matched against (see spec/scope-model.md). + + The four namespaces - project types, workflowModel, releaseTrigger, consumerModel - are disjoint, so + a flat token set is unambiguous. workflowModel and releaseTrigger resolve repo -> defaults -> fleet + default (as configure.sh does). consumerModel has no fleet default - validate.py requires it on every + cataloged repo, so a cataloged repo always contributes one. + """ + sel = set(entry.get("types", [])) + sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") + sel.add(entry.get("releaseTrigger") or defaults.get("releaseTrigger") or "two-phase") + # consumerModel has no defaults fallback - the registry schema does not allow defaults.consumerModel, and + # validate.py requires it on every cataloged repo. The guard only shields a malformed non-cataloged entry. + cm = entry.get("consumerModel") + if cm: + sel.add(cm) + return sel + + +def applies(applies_to, sel): + """True if an appliesTo selector applies to a repo's selector set. Disjunctive any-of, with `*` meaning all.""" + if applies_to == "*": + return True + tokens = applies_to if isinstance(applies_to, list) else [applies_to] + return bool(set(tokens) & sel) + + +_HEADING = re.compile(r"^#{1,6}\s+(.*?)\s*$") + + +def required_sections(item, sel): + """Section names this repo must carry from a baseline entry, filtered by each section's own appliesTo. + + A bare-string section is appliesTo `*`; an object section carries its own selector. The entry's own + appliesTo is assumed already matched by the caller (the file is carried at all). + """ + out = [] + for elt in item.get("sections", []): + name, sec = (elt, "*") if isinstance(elt, str) else (elt.get("name", ""), elt.get("appliesTo", "*")) + if name and applies(sec, sel): + out.append(name) + return out + + +def heading_texts(markdown): + """Lowercased heading texts in a markdown document, for case-insensitive section-presence matching.""" + return {m.group(1).strip().lower() for line in markdown.splitlines() for m in (_HEADING.match(line),) if m} + + +_JOB_KEY = re.compile(r"^([A-Za-z0-9_.\-]+):(\s.*)?$") + + +def split_jobs(text): + """Slice a workflow YAML into {job_key: block_text} by the jobs-level indent, without a YAML parser. + + Structural, not semantic: find the top-level `jobs:` key, take the indent of its first child as the + job-key indent, and cut the region at each key line at exactly that indent. A line dedented below the + job-key indent (a sibling top-level key) ends the jobs region. + """ + lines = text.splitlines(keepends=True) + ji = next((i for i, ln in enumerate(lines) if re.match(r"^jobs:\s*(#.*)?$", ln)), None) + if ji is None: + return {} + job_indent = None + for ln in lines[ji + 1:]: + if ln.strip() == "" or ln.lstrip().startswith("#"): + continue + job_indent = len(ln) - len(ln.lstrip()) + break + if not job_indent: + return {} + blocks, key, cur = {}, None, [] + for ln in lines[ji + 1:]: + indent = len(ln) - len(ln.lstrip()) + if ln.strip() and indent < job_indent: + if ln.lstrip().startswith("#"): + continue # a dedented comment is not part of any job and does not end the mapping - skip it + break # a sibling top-level key ends the jobs region + m = _JOB_KEY.match(ln[job_indent:]) if indent == job_indent else None + if m: + if key is not None: + blocks[key] = "".join(cur) + key, cur = m.group(1), [ln] # include the key line so an inline mapping on it is captured + elif key is not None: + cur.append(ln) + if key is not None: + blocks[key] = "".join(cur) + return blocks + + +def _code_view(text): + """Workflow text with comment-only lines dropped, so a token mentioned only in a comment is not signal. + + A carried task file documents its own contract in comments (build-release-task.yml names + `release-asset-` and `artifact-ids:` in prose), so a raw substring search over the whole text would + both false-pass a missing handoff and false-flag a forbidden token that appears only in a comment. + """ + return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#")) + + +def job_level_names(blocks): + """The job-level `name:` values - a job's own direct-child name key, not a step's name. + + The ruleset-bound check is a job name, so a step happening to share the string must not satisfy it. A + job's direct children sit at the shallowest indent of its block (below the key line); a step name is + deeper or a `- name:` list item, so neither reaches that indent as a bare `name:`. + """ + out = set() + for block in blocks.values(): + body = [ln for ln in block.splitlines()[1:] if ln.strip() and not ln.lstrip().startswith("#")] + if not body: + continue + child = min(len(ln) - len(ln.lstrip()) for ln in body) + for ln in body: + if len(ln) - len(ln.lstrip()) == child: + m = re.match(r"name:\s*['\"]?(.*?)['\"]?\s*$", ln[child:]) + if m: + out.add(m.group(1)) + return out + + +def check_interface(path, contract, text): + """Verify a workflow honors its fixed contract by name and wiring, never its body (spec/fidelity-model.md). + + All findings are DRIFT: the interface is what a repo must keep, the body is owned, and a rename or a + forked handoff is a hint to verify, not a hard failure. `verbatimJobs` is the verbatim engine's concern. + """ + findings = [] + jobs = split_jobs(text) + code = _code_view(text) + for k in contract.get("requiredJobKeys", []): + if k not in jobs: + findings.append(("DRIFT", f"interface: {path} missing required job '{k}'")) + name = contract.get("requiredCheckName") + if name and name not in job_level_names(jobs): + findings.append(("DRIFT", f"interface: {path} missing the ruleset-bound check name '{name}' as a job name")) + tok = contract.get("artifactNameToken") + if tok and tok not in code: + findings.append(("DRIFT", f"interface: {path} missing the '{tok}-' artifact handoff")) + # Token checks only apply to a job that is present; an absent job is already reported by requiredJobKeys, + # so skip it rather than emit a redundant "missing token" for every token it cannot contain. Scan the + # job's code view so a token in a comment is not read as signal. + for job, toks in contract.get("requireTokensInJob", {}).items(): + if job in jobs: + block = _code_view(jobs[job]) + for t in toks: + if t not in block: + findings.append(("DRIFT", f"interface: {path} job '{job}' missing required '{t}'")) + for job, toks in contract.get("forbidTokensInJob", {}).items(): + if job in jobs: + block = _code_view(jobs[job]) + for t in toks: + if t in block: + findings.append(("DRIFT", f"interface: {path} job '{job}' uses forbidden '{t}' (forks the verbatim github-release download - see AGENTS.md override seam)")) + return findings + + +def normalize(text): + """Reduce a carried unit to its comparable form: neutralize line endings, since EOL variance is governed + separately, not a fidelity deviation. No placeholder masking - see spec/fidelity-model.md "Normalization". + """ + return text.replace("\r\n", "\n").replace("\r", "\n") + + +@functools.lru_cache(maxsize=1024) # bounded; the keys that recur across repos are the canonical and its history +def _hash_normalized(norm_text): + return hashlib.sha256(norm_text.encode("utf-8")).hexdigest() + + +def content_hash(text): + # Cache on the normalized form, not raw text, so EOL-only variants (CRLF vs LF) share one entry. + return _hash_normalized(normalize(text)) + + +def classify_verbatim(down_text, canon_text, past_texts): + """None if the downstream copy matches the current canonical, 'stale' if it matches a past hub revision + (the base advanced - re-vendor), or 'modified' if it matches no revision the base ever produced (the + repo changed fixed content). The discriminator is a content hash, never a version stamp - a stamp can + claim to be current while the body was edited, so it is never trusted for integrity. + """ + dh = content_hash(down_text) + if dh == content_hash(canon_text): + return None + for past in past_texts: + if content_hash(past) == dh: + return "stale" + return "modified" + + +_HISTORY_CACHE = {} # rel_path -> [past revision content], reused as a canonical is compared against every audited repo + + +def git_file_history(rel_path): + """Every past revision's content of a hub-tracked file (to tell a stale copy from a modified one), cached per rel_path.""" + if rel_path in _HISTORY_CACHE: + return _HISTORY_CACHE[rel_path] + out = [] + # Decode as UTF-8/replace to match the downstream and canonical reads. A divergent decode would fabricate a mismatch. + r = subprocess.run(["git", "log", "--format=%H", "--", rel_path], cwd=ROOT, capture_output=True, + encoding="utf-8", errors="replace") + if r.returncode == 0: + for sha in r.stdout.split(): + s = subprocess.run(["git", "show", f"{sha}:{rel_path}"], cwd=ROOT, capture_output=True, + encoding="utf-8", errors="replace") + if s.returncode == 0: + out.append(s.stdout) + _HISTORY_CACHE[rel_path] = out + return out + + +def check_verbatim(label, down_text, canonical_rel, extract=None): + """Compare a downstream copy against the hub's canonical (a region if `extract` is given), EOL-normalized, + and classify a mismatch as stale or modified via the canonical's git history. All findings are DRIFT: a + byte diff is a hint to review, never proof of breakage. + """ + try: + # Same decode policy as the downstream copy and the git history, so a stray byte can never make + # otherwise-equal content hash differently across the three sources. + canon_text = (ROOT / canonical_rel).read_text(encoding="utf-8", errors="replace") + except OSError: + return [("DRIFT", f"verbatim: {label} canonical {canonical_rel} is unreadable from the hub (spec error?)")] + history = git_file_history(canonical_rel) + if extract is not None: + down_region, canon_region = extract(down_text), extract(canon_text) + if canon_region is None: + return [("DRIFT", f"verbatim: {label} region absent in the canonical (spec error?)")] + if down_region is None: + return [("DRIFT", f"verbatim: {label} region absent downstream, cannot compare")] + down_text, canon_text = down_region, canon_region + history = [h for h in (extract(t) for t in history) if h is not None] + verdict = classify_verbatim(down_text, canon_text, history) + if verdict is None: + return [] + if verdict == "stale": + return [("DRIFT", f"verbatim: {label} matches a past hub revision, not the current canonical - the base advanced, re-vendor it")] + return [("DRIFT", f"verbatim: {label} differs from the canonical and matches no past hub revision - the repo modified fixed content, review it")] + + def audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -72,7 +335,7 @@ def audit_repo(entry, spec): try: live = gh(f"repos/{slug}") except RuntimeError as e: - return [("ERROR", str(e))] + return [("ERROR", str(e))], "" # --- Branch facts --- branch_main = gh(f"repos/{slug}/branches/main", ok404=True) @@ -84,12 +347,35 @@ def audit_repo(entry, spec): findings.append(("DRIFT", f"registry: hasDevelop={entry.get('hasDevelop')} but develop {'exists' if dev_exists else 'is absent'}")) if main_exists and dev_exists: # Commit counts mislead here: merge-commit promotions leave main permanently "ahead" while the - # trees are identical. Content is the signal - a develop...main compare with changed files means - # main carries content develop lacks (forward-sync needed); develop merely ahead is normal. + # head trees are identical, so tree equality is the no-drift fast path. When the head trees + # differ, empty compare files[] means develop is merely ahead (no main-side changes since the + # merge-base) - normal, no finding, no further API calls. if branch_main["commit"]["commit"]["tree"]["sha"] != branch_dev["commit"]["commit"]["tree"]["sha"]: cmp = gh(f"repos/{slug}/compare/develop...main", ok404=True) if cmp and cmp.get("files"): - findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed)")) + # Non-empty files[] signals main-side changes, but is not usable directly: it is blind + # to cherry-picked promotions (develop may already hold identical content under + # different commit SHAs, e.g. promote/* branches) AND capped at 300 entries (#336). + # Instead, derive the main-side change set from the merge-base tree - paths whose + # object SHA (blob, or submodule pointer) differs base->main, additions and deletions + # included, no cap - then drop paths whose objects already match at develop: content + # develop already has is not "content develop lacks". Three recursive tree calls, so if + # any tree is truncated (or unexpectedly not a dict) the filter is skipped and the + # compare's unfiltered count kept (conservative, marked). + trees = { + "base": gh(f"repos/{slug}/git/trees/{cmp['merge_base_commit']['commit']['tree']['sha']}?recursive=1"), + "develop": gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1"), + "main": gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1"), + } + if not all(isinstance(t, dict) for t in trees.values()) or any(t.get("truncated") for t in trees.values()): + findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree unavailable or too large to filter cherry-pick noise)")) + else: + objs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] in ("blob", "commit")} for name, t in trees.items()} + changed_on_main = {p for p in set(objs["base"]) | set(objs["main"]) if objs["base"].get(p) != objs["main"].get(p)} + lacking = sorted(p for p in changed_on_main if objs["main"].get(p) != objs["develop"].get(p)) + if lacking: + shown = ", ".join(lacking[:8]) + (" ..." if len(lacking) > 8 else "") + findings.append(("DRIFT", f"branch: {len(lacking)} main-side path change(s) develop lacks (forward-sync needed): {shown}")) # --- General settings --- expected = dict(spec["settings"]) @@ -138,7 +424,7 @@ def audit_repo(entry, spec): for store in mech.get("stores", []): required_by_store[store] |= set(mech.get("requires", [])) # Registry requiredSecrets[] are the domain-specific additions (STANDUP.md: requiredSecrets plus the - # implicit baseline). Mechanism-mapped names already carry their stores above; unmapped ones are + # implicit baseline). Mechanism-mapped names already carry their stores above, and unmapped ones are # expected in the actions store and count as claimed (never stale). required_by_store["actions"] |= set(entry.get("requiredSecrets", [])) forbidden = set(secrets["baseline"].get("forbids", [])) @@ -154,25 +440,201 @@ def audit_repo(entry, spec): for name in sorted(present - claimed_names): findings.append(("DRIFT", f"secrets: {name} in the {store} store is claimed by no applicable mechanism (stale?)")) - # --- File presence on the ground-truth branch --- - seen_paths = set() + # --- Carried files must not reference the template repo --- + # The template is private, so a reference 404s for this repo's users and exposes machinery they cannot + # follow. Checks the agent-instruction files, where a stale "report drift upstream" paragraph spread. + for path in ("AGENTS.md", ".github/copilot-instructions.md"): + doc = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) + if doc and doc.get("content") and HUB_NAME.lower() in base64.b64decode(doc["content"]).decode("utf-8", "replace").lower(): + findings.append(("DRIFT", f"carried: {path} references the template repo by name or link (private - 404s for this repo's readers; state the behavior, not the destination)")) + + # --- Dependabot ecosystem coverage --- + # A repo's tree implies Dependabot ecosystems it must track: github-actions when it ships workflows + # (the action versions they reference otherwise go stale, and a merge-bot then has no PRs to auto-merge), + # devcontainers when it ships a .devcontainer. dependabot.yml is YAML (no stdlib parser), so scan the + # declared package-ecosystem values by regex - anchored to the line start so a commented-out entry + # (# package-ecosystem: ...) is not read as declared. This asserts an implied ecosystem's *presence* + # only. Whether each declared ecosystem dual-targets main+develop (the fleet norm) is verified by + # inspection, not here. Only runs when dependabot.yml exists, since its absence is already a file-presence + # LETTER below. Language ecosystems (nuget/uv/npm) are directory-scoped and not yet cross-checked here. + db = gh(f"repos/{slug}/contents/.github/dependabot.yml?ref={ground}", ok404=True) + if db and db.get("content"): + declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.M)) + implied = {} + workflows = gh(f"repos/{slug}/contents/.github/workflows?ref={ground}", ok404=True) + if isinstance(workflows, list) and any(e["name"].endswith((".yml", ".yaml")) for e in workflows): + implied["github-actions"] = ".github/workflows/ is present" + if gh(f"repos/{slug}/contents/.devcontainer?ref={ground}", ok404=True) is not None: + implied["devcontainers"] = ".devcontainer/ is present" + for eco, why in sorted(implied.items()): + if eco not in declared: + findings.append(("DRIFT", f"dependabot: {eco} ecosystem not declared though {why}; add it for both main and develop per the fleet norm")) + + # --- File and section presence on the ground-truth branch --- + # appliesTo is matched against the repo's full selector set (types + workflowModel + releaseTrigger + + # consumerModel), so the release/operational develop ruleset is two data entries, not a code swap. + # Required sections union across same-path entries. A carried markdown file must contain each heading + # scoped to this repo. A rename reads as missing and equivalence is judged by hand, so a missing section + # is DRIFT (a hint to verify), never a LETTER. + sel = repo_selectors(entry, spec["registry"].get("defaults", {})) + wanted_sections = {} # path -> set of required section names, unioned across applicable entries + check_item = {} # path -> entry, for a fidelity interface/verbatim entry (last applicable wins per path) + path_order = [] for item in spec["files"]["baseline"]: - applies = item.get("appliesTo", "*") - if applies != "*" and not set(applies) & set(types): + if not applies(item.get("appliesTo", "*"), sel): continue path = item["path"] - if path == "repo-config/develop.json" and model == "operational": - path = "repo-config/operational/develop.json" - if path in seen_paths: + if path not in wanted_sections: + wanted_sections[path] = set() + path_order.append(path) + wanted_sections[path].update(required_sections(item, sel)) + if item.get("fidelity") in ("interface", "verbatim"): + check_item[path] = item + for path in path_order: + content = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) + item = check_item.get(path) + fid = item.get("fidelity") if item else "presence" + if content is None: + # An interface unit's presence is DRIFT, not LETTER - a workflow's naming is more variable than a + # carried config, so absence is a hint to verify. Any other unit's absence is a file-presence LETTER. + if fid == "interface": + findings.append(("DRIFT", f"interface: {path} absent on {ground}, cannot verify its contract")) + else: + findings.append(("LETTER", f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)")) continue - seen_paths.add(path) - if gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) is None: - findings.append(("LETTER", f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)")) + # Guard on encoding, not truthiness: an empty file returns encoding "base64" with content "" (decode it + # to ""), whereas a too-large or non-inline payload returns encoding "none" (text stays None -> flagged). + text = base64.b64decode(content["content"]).decode("utf-8", "replace") if content.get("encoding") == "base64" else None + # Interface conformance (name + wiring) plus any verbatim job regions the contract pins. + if fid == "interface": + if text is None: + findings.append(("DRIFT", f"interface: could not read {path} content on {ground} to verify its contract (no inline content returned); verify by hand")) + else: + contract = item.get("contract", {}) + findings.extend(check_interface(path, contract, text)) + canonical_rel = item.get("reference") or path + for job in contract.get("verbatimJobs", []): + findings.extend(check_verbatim(f"{path} job '{job}'", text, canonical_rel, + extract=lambda t, j=job: split_jobs(t).get(j))) + # Whole-file verbatim: byte-identical to the hub's canonical after EOL normalization. + elif fid == "verbatim": + if text is None: + findings.append(("DRIFT", f"verbatim: could not read {path} content on {ground} to compare (no inline content returned); verify by hand")) + else: + findings.extend(check_verbatim(path, text, item.get("reference") or path)) + # Heading-based presence is only meaningful for markdown. A "section" named on a non-md file (e.g. a + # tasks.json task group) is an intent marker judged per AUDIT.md, not a heading grep. + needed = wanted_sections[path] + if needed and path.endswith(".md"): + if text is None: + # Fail loud rather than skip silently: the contents API returned no inline content (an + # oversized file, a symlink, a submodule), so the section check could not run - surface that + # instead of a false clean. + findings.append(("DRIFT", f"section: could not read {path} content on {ground} to verify sections (no inline content returned); verify by hand")) + else: + present = heading_texts(text) + for name in sorted(needed): + if name.strip().lower() not in present: + findings.append(("DRIFT", f"section: '{name}' not found as a heading in {path} on {ground} (renamed or missing; verify intent per AUDIT.md section 7)")) + + # --- Registry driftNotes freshness --- + # Gated on everything else passing: a clean repo has no outstanding work for a pending-marker note to + # describe. Narrow markers keep a permanent-deviation note ("relies on validate-task") from tripping. + if not findings: + for note in entry.get("driftNotes", []): + marker = next((w for w in PENDING_MARKERS if re.search(rf"\b{re.escape(w)}\b", note, re.I)), None) + if marker: + findings.append(("DRIFT", f"registry: driftNote says '{marker}' but the audit is clean - verify and reconcile: \"{note[:70]}{'...' if len(note) > 70 else ''}\"")) + + # Stamp the commit actually read for the ground-truth branch. Never fall back to the other branch: + # a stamp naming develop while carrying main's sha would misattribute every finding. + ground_branch = branch_dev if ground == "develop" else branch_main + audited_sha = (ground_branch or {}).get("commit", {}).get("sha", "") + return findings, audited_sha - return findings + +def _selftest(): + """Exercise the interface engine on synthetic fixtures, no network - verify the mechanism, not the fleet.""" + pr_check = " check-workflow-status:\n name: Check pull request workflow status job\n needs: [changes]\n runs-on: ubuntu-latest\n" + pr_head = "name: Test\non: pull_request\njobs:\n changes:\n runs-on: ubuntu-latest\n" + pr_contract = {"requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job"} + gh_rel = (" github-release:\n needs: [get-version, build-widget]\n runs-on: ubuntu-latest\n steps:\n" + " - uses: actions/download-artifact@v4\n with:\n pattern: release-asset-${{ inputs.branch }}-*\n merge-multiple: true\n") + rel_head = ("name: Build Release\non:\n workflow_call:\njobs:\n" + " get-version:\n runs-on: ubuntu-latest\n steps: []\n" + " build-widget:\n runs-on: ubuntu-latest\n steps: []\n") + rel_ok = rel_head + gh_rel + rel_contract = {"requiredJobKeys": ["get-version", "github-release"], "artifactNameToken": "release-asset-", + "requireTokensInJob": {"github-release": ["pattern:", "merge-multiple:"]}, + "forbidTokensInJob": {"github-release": ["artifact-ids:"]}} + cases = [ + ("conformant PR workflow", pr_head + pr_check, pr_contract, 0), + ("PR workflow missing the required job and its check name", pr_head, pr_contract, 2), + ("PR workflow with a renamed check", pr_head + pr_check.replace("Check pull request workflow status job", "Renamed"), pr_contract, 1), + ("check name present only in a run step, not as a job name", pr_head + " check-workflow-status:\n runs-on: ubuntu-latest\n steps:\n - run: echo Check pull request workflow status job\n", pr_contract, 1), + ("check name present only as a step name, not the job name", pr_head + " check-workflow-status:\n runs-on: ubuntu-latest\n steps:\n - name: Check pull request workflow status job\n run: true\n", pr_contract, 1), + ("conformant release task", rel_ok, rel_contract, 0), + ("release task with an artifact-ids fork in github-release", rel_ok.replace(" merge-multiple: true\n", " merge-multiple: true\n artifact-ids: 123\n"), rel_contract, 1), + ("release task missing merge-multiple in github-release", rel_ok.replace(" merge-multiple: true\n", ""), rel_contract, 1), + ("release task with an owned extra leaf job", rel_head + " build-extra:\n runs-on: ubuntu-latest\n steps: []\n" + gh_rel, rel_contract, 0), + ("absent job reports once, no redundant token findings", rel_head, {"requiredJobKeys": ["github-release"], "requireTokensInJob": {"github-release": ["pattern:", "merge-multiple:"]}}, 1), + ("a forbidden token only in a comment is ignored", rel_ok.replace(" merge-multiple: true\n", " merge-multiple: true\n # never an artifact-ids: fork here\n"), rel_contract, 0), + ("a required token only in a comment does not count", rel_ok.replace(" merge-multiple: true\n", " # merge-multiple: true (was here)\n"), rel_contract, 1), + ] + ok = True + for label, text, contract, want in cases: + got = len(check_interface("wf", contract, text)) + if got != want: + ok = False + print(f" {'ok ' if got == want else 'FAIL'} want={want} got={got} {label}") + trailing = split_jobs(rel_ok + "# a trailing top-level comment\n") + if set(trailing) != {"get-version", "build-widget", "github-release"} or "trailing top-level comment" in trailing.get("github-release", ""): + ok = False + print(f" FAIL split_jobs (trailing comment) -> {sorted(trailing)}") + else: + print(f" ok split_jobs (trailing comment) -> {sorted(trailing)}") + inline = split_jobs("name: X\non: push\njobs:\n quick: {runs-on: ubuntu-latest}\n full:\n runs-on: ubuntu-latest\n") + if set(inline) != {"quick", "full"} or "runs-on" not in inline.get("quick", ""): + ok = False + print(f" FAIL split_jobs (inline mapping) -> {sorted(inline)}") + else: + print(" ok split_jobs (inline-mapping job captured with its content)") + + # Verbatim engine: EOL normalization, hashing, and the stale-vs-modified classification. Exercised here + # rather than only in production, because a latent bug in the comparison would otherwise surface as a + # false clean on a real fleet run. + canon = "line one\nline two\nline three\n" + verbatim_cases = [ + # (label, down_text, canon_text, history, want) + ("identical -> match", canon, canon, [], None), + ("EOL-only diff (CRLF) -> match", canon.replace("\n", "\r\n"), canon, [], None), + ("EOL-only diff (bare CR) -> match", canon.replace("\n", "\r"), canon, [], None), + ("body edit -> modified", canon.replace("line two", "line TWO edited"), canon, [], "modified"), + ("matches a past revision -> stale", "old body\n", "current body\n", ["old body\n", "older\n"], "stale"), + ("matches a past revision modulo EOL -> stale", "old body\r\n", "current body\n", ["old body\n"], "stale"), + ("edit in no revision -> modified", "never existed\n", "current body\n", ["old body\n"], "modified"), + ] + for label, down, canon_t, history, want in verbatim_cases: + got = classify_verbatim(down, canon_t, history) + if got != want: + ok = False + print(f" {'ok ' if got == want else 'FAIL'} want={str(want):>8} got={str(got):>8} verbatim: {label}") + # Region extraction and hashing: a forked github-release block must hash differently from the canonical. + region = split_jobs(rel_ok).get("github-release") + forked_region = split_jobs(rel_ok.replace(" merge-multiple: true\n", " artifact-ids: 1\n")).get("github-release") + if region is None or forked_region is None or content_hash(region) == content_hash(forked_region): + ok = False + print(" FAIL verbatim: forked github-release region should hash differently") + else: + print(" ok verbatim: a forked github-release region hashes differently from the canonical") + + print("SELFTEST PASS" if ok else "SELFTEST FAIL") + return 0 if ok else 1 def main(): + if "--selftest" in sys.argv: + return _selftest() spec = { "registry": load("registry/repos.json"), "settings": load("repo-config/settings.json"), @@ -188,21 +650,35 @@ def main(): print(f"Not cataloged: {', '.join(sorted(missing))}", file=sys.stderr) return 2 + # Findings are a point-in-time snapshot. Stamp the run so anything derived from it (an onboarding + # issue, a report) carries its own freshness signal and a reader can tell whether it still applies. + run_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + hub = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=ROOT) + hub_sha = hub.stdout.strip() if hub.returncode == 0 else "unknown" + print(f"audit run {run_utc} | hub {hub_sha}") + if not HUB_NAME_FROM_REMOTE: + print(f"warning: no git remote; template-reference check falls back to the directory name '{HUB_NAME}' and may miss", file=sys.stderr) + print() + hard = 0 for entry in repos: model = entry.get("workflowModel") or spec["registry"].get("defaults", {}).get("workflowModel") or "release" - print(f"== {entry['name']} ({', '.join(entry.get('types', []))}; {model}) ==") + ground = entry.get("groundTruthBranch", "main") try: - findings = audit_repo(entry, spec) + findings, audited_sha = audit_repo(entry, spec) except Exception as e: # a gh/JSON failure mid-audit must not abort the sweep - findings = [("ERROR", str(e))] + findings, audited_sha = [("ERROR", str(e))], "" + stamp = f" @ {ground}@{audited_sha[:7]}" if audited_sha else "" + print(f"== {entry['name']} ({', '.join(entry.get('types', []))}; {model}){stamp} ==") if not findings: - print(" clean (deterministic checks; the full operational verdict is AUDIT.md's)") + print(" clean (deterministic checks; the full letter+intent verdict is AUDIT.md's)") for kind, text in findings: print(f" {kind:6} {text}") if kind in ("DEFECT", "LETTER", "ERROR"): hard += 1 print(f"\n{len(repos)} repo(s) audited; {hard} defect/letter/error finding(s).") + print("Findings are a point-in-time snapshot: re-run this audit before acting on them, and quote the") + print("run stamp above in any issue derived from it (AUDIT.md section 8).") return 1 if hard else 0 diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md new file mode 100644 index 00000000..83f3c764 --- /dev/null +++ b/spec/fidelity-model.md @@ -0,0 +1,44 @@ +# Fidelity Model + +How faithfully each carried unit must survive the carry, and how that is verified. This is a hub-only doc governing the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not carried to the fleet. It is the companion to [`spec/scope-model.md`][scope-model]: scope decides *which* repos get a unit, fidelity decides *how faithfully* they must carry it. + +## The Fixed and the Overridable + +Carried content is a class with virtual functions. The **fixed** part is the interface - when a thing is invoked, what it is named, and where it is wired. The **overridable** part is the implementation body, which a repo replaces to fit its own targets. Validation must allow the override while detecting a change to the interface or to content meant to stay fixed. Integrity is by **content hash, never a version number** - a version stamp is a claim a repo can keep while editing the body, so it is never trusted for detection. + +## The Four Fidelity Levels + +Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `presence`. + +- **presence** - the unit exists (a file, or a markdown section heading). The audit's baseline check. +- **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts nothing beyond presence. +- **verbatim** - byte-identical to the hub's canonical after line-ending normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file or a workflow job region (a job selected by key). +- **interface** - an overridable body that must honor a named contract. The audit checks the contract by name and wiring, never the body. + +Fidelity is a declared field defaulting to `presence`, never inferred from `whole`/`placeholders`. `.editorconfig` and `.markdownlint-cli2.jsonc` are both whole with no placeholders yet sit at opposite fidelity, because the discriminator is governance, not field shape. + +## Why Each Unit Sits Where It Does + +- **verbatim** - `.markdownlint-cli2.jsonc` (fleet-generic, no governed divergence), and the `github-release` job region of the release task (the canonical orchestration a repo must not fork). +- **interface** - the release and PR workflows. Their fixed contract is the job and check names plus the artifact handoff, while the leaf build jobs are owned. See the override seam in [`AGENTS.md`][agents]. +- **intent** - `.editorconfig` and `.gitattributes` (the `[*] end_of_line` default and path pins vary by platform), `cspell.json` (the words list and file scope vary), `CODESTYLE.md` / `WORKFLOW.md` / `AUDIT.md` / `.github/copilot-instructions.md` (carried docs judged by meaning), and the ruleset payloads (whose live state is diffed separately). +- **presence** - `README.md`, `HISTORY.md`, `.gitignore`, and the per-repo config that only needs to exist. + +## The Workflow Override Seam Contract + +The fixed interface of a workflow is stated in [`AGENTS.md`][agents] ("Orchestration vs. build - the override seam" and "Workflow YAML Conventions"), and the `interface` check enforces it by name and structure: the ruleset-bound required check `name: Check pull request workflow status job`, the `github-release` and `get-version` job keys, the `release-asset--` artifact-name handoff, and that `github-release` collects assets by `pattern:` / `merge-multiple:` and never by an `artifact-ids:` that names a build job's output. A repo owns the leaf `build--task` job list, its `needs` targets, and its paths-filter, and none of those are checked. + +## Normalization + +A verbatim check compares content by hash after **line-ending normalization only** - EOL variance is governed by the line-ending rules, not a fidelity deviation. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with ``, ``, ``) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway - a downstream copy holds the substituted value (`ptr727`), not the token (``), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. + +## Stale Versus Modified + +A verbatim mismatch is one of two things, told apart **by hash, not by a version**. The audit hashes each past revision of the hub's canonical from its own git history. If the downstream copy matches a **past** canonical revision, the base advanced and the copy is **stale** - re-vendor it. If it matches **no** revision the base ever produced, the repo **modified fixed content** - review it. A version stamp could claim to be current while being neither, so it is demoted to a human-facing label and never consulted for integrity. + + +[agents]: ../AGENTS.md +[audit]: ./audit.py +[files]: ./files.json +[files-schema]: ./files.schema.json +[scope-model]: ./scope-model.md diff --git a/spec/files.json b/spec/files.json index a587e3b5..4d0ca5fa 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,25 +1,33 @@ { "$schema": "./files.schema.json", - "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A.", + "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit mechanically checks presence (letter). Equivalence (intent) is judged by hand, and a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary. Each entry also has a fidelity (presence by default, or intent, verbatim, interface) governing how faithfully the content is checked - see spec/fidelity-model.md.", "baseline": [ - { "path": "AGENTS.md", "sections": ["Git and Commit Rules", "Branching Model", "Release Model", "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, - { "path": "CODESTYLE.md", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, - { "path": "WORKFLOW.md", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, + { "path": "AGENTS.md", "fidelity": "intent", "sections": ["Repository Boundaries and Write Safety", "Git and Commit Rules", "Branching Model", "Release Model", { "name": "Operational Repositories", "appliesTo": ["operational"] }, "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "Verification Discipline", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, + { "path": "CODESTYLE.md", "fidelity": "intent", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, + { "path": "WORKFLOW.md", "fidelity": "intent", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, { "path": "README.md", "appliesTo": "*" }, { "path": "HISTORY.md", "appliesTo": "*" }, - { "path": ".github/copilot-instructions.md", "whole": true, "placeholders": ["", "", ""], "appliesTo": "*" }, - { "path": ".editorconfig", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, - { "path": ".gitattributes", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, - { "path": ".markdownlint-cli2.jsonc", "whole": true, "appliesTo": "*" }, - { "path": "cspell.json", "whole": true, "appliesTo": "*" }, + { "path": ".github/copilot-instructions.md", "fidelity": "intent", "whole": true, "placeholders": ["", "", ""], "appliesTo": "*" }, + { "path": ".editorconfig", "fidelity": "intent", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, + { "path": ".gitattributes", "fidelity": "intent", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, + { "path": ".markdownlint-cli2.jsonc", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, + { "path": "cspell.json", "fidelity": "intent", "whole": true, "appliesTo": "*" }, { "path": ".gitignore", "appliesTo": "*" }, - { "path": "version.json", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, - { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, - { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "version.json", "fidelity": "intent", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, + { "path": "repo-config/develop.json", "fidelity": "intent", "intentRef": "repo-config/README.md", "appliesTo": ["release"] }, + { "path": "repo-config/operational/develop.json", "fidelity": "intent", "intentRef": "repo-config/README.md", "appliesTo": ["operational"] }, + { "path": "repo-config/main.json", "fidelity": "intent", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/README.md", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/configure.sh", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, + { "path": "repo-config/settings.json", "fidelity": "intent", "whole": true, "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "AUDIT.md", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, + { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, + { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "AGENTS.md#workflow-yaml-conventions", "appliesTo": "*" }, + { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "AGENTS.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, - { "path": "codecov.yml", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, + { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, { "path": ".dockerignore", "appliesTo": ["docker"] }, { "path": "Docker/README.md", "reference": "catalog/snippets/configs/docker-hub-readme.md", "appliesTo": ["docker"] } ] diff --git a/spec/files.schema.json b/spec/files.schema.json index 7013e1a7..6cc49e64 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -16,11 +16,40 @@ "properties": { "path": { "type": "string" }, "whole": { "type": "boolean" }, - "sections": { "type": "array", "items": { "type": "string" } }, + "sections": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } + } + } + ] + } + }, "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"] } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 }, + "fidelity": { "enum": ["presence", "intent", "verbatim", "interface"] }, + "contract": { + "type": "object", + "additionalProperties": false, + "properties": { + "requiredJobKeys": { "type": "array", "items": { "type": "string" } }, + "requiredCheckName": { "type": "string" }, + "artifactNameToken": { "type": "string" }, + "requireTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "forbidTokensInJob": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "verbatimJobs": { "type": "array", "items": { "type": "string" } } + } + } } } } diff --git a/spec/project-types.json b/spec/project-types.json index bca02029..9f51747b 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -31,13 +31,16 @@ "python": { "detect": ["pyproject.toml", "setup.py"], "canonicalPlacement": "pyproject.toml", + "profileNote": "Two structurally-detected profiles share this type (CODESTYLE.md Python 'Two profiles'). PROJECT: the Python has third-party runtime dependencies or is the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). SCRIPTS: stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports: [project]+deps/[build-system] + uv.lock -> PROJECT; tool-config-only, no [project]/[build-system], no uv.lock -> SCRIPTS. The profile changes which checks apply: see python.profile.detect and the per-check N/A notes.", "checks": [ - { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present.", "intentRef": "CODESTYLE.md" }, - { "id": "python.pyright.config", "verdict": "intent", "assert": "pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline; third-party strictness is relaxed only where a dependency has no usable types.", "intentRef": "CODESTYLE.md" }, - { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and pyright config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, - { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker (not banned); required for a Home Assistant integration (platinum strict-typing). When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, - { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false); CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests.", "intentRef": "WORKFLOW.md" }, - { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "If the project uses uv (a committed uv.lock), the lockfile is pinned to LF in both .editorconfig ([uv.lock]) and .gitattributes (uv.lock text eol=lf); uv regenerates it LF on every platform, so a CRLF-default repo otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (e.g. a Home Assistant integration on pip/requirements).", "intentRef": "AGENTS.md#line-endings" } + { "id": "python.profile.detect", "verdict": "letter", "assert": "The profile is read from pyproject.toml: a [project] table with runtime dependencies (or a [build-system]) is the PROJECT profile; a pyproject carrying only [tool.*] config with no [project]/[build-system] and no uv.lock is the SCRIPTS profile. A SCRIPTS subtree must not carry a uv.lock or project/build metadata (that would misrepresent it as a shippable package); a PROJECT must.", "intentRef": "CODESTYLE.md" }, + { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, + { "id": "python.pyright.config", "verdict": "intent", "assert": "PROJECT profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline; third-party strictness is relaxed only where a dependency has no usable types. N/A for the SCRIPTS profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md" }, + { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, + { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker (not banned); required for a Home Assistant integration (platinum strict-typing) and is the SCRIPTS profile's type checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, + { "id": "python.coverage.codecov", "verdict": "letter", "assert": "The test job collects coverage (pytest --cov-report=xml) and uploads it to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false); CODECOV_TOKEN is stored in the repo actions secrets. Required for every Python repo with tests. N/A for the SCRIPTS profile (lint/type-checked only, no pytest); in a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, + { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "PROJECT profile: the committed uv.lock is pinned to LF in both .editorconfig ([uv.lock]) and .gitattributes (uv.lock text eol=lf); uv regenerates it LF on every platform, so a CRLF-default repo otherwise reds editorconfig-checker on every uv lock/sync. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the SCRIPTS profile (no uv.lock by definition).", "intentRef": "AGENTS.md#line-endings" }, + { "id": "python.scripts.uvx", "verdict": "letter", "assert": "SCRIPTS profile only: the tools run via uvx (no project install, no lockfile). CI pins exact tool versions in the uvx command (e.g. uvx ruff@X, uvx mypy@Y), bumpable there; the VS Code tasks and README run the unpinned latest, a deliberate CI-vs-local gap so local never silently falls behind CI. N/A for the PROJECT profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } ] }, "console": { @@ -89,8 +92,8 @@ "source-only": { "detect": ["no build-*-task.yml"], "checks": [ - { "id": "sourceonly.release.tagonly", "verdict": "letter", "assert": "The caller passes expect_release_assets:false; the release is tag + source zip + README + LICENSE.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json and the NBGV get-version step are retained (they own the tag).", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } + { "id": "sourceonly.release.tagonly", "verdict": "letter", "assert": "The standalone publish-release.yml inlines action-gh-release (no build-release-task.yml, no expect_release_assets) and produces a release of tag + source zip + README + LICENSE.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json is retained and NBGV is inlined in publish-release.yml to compute the tag.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } ] }, "docs": { @@ -120,7 +123,9 @@ "appliesTo": "*", "checks": [ { "id": "setup.secrets.present", "verdict": "letter", "assert": "Every requiredSecret for the repo's publish mechanisms is configured (per spec/secrets.json).", "intentRef": "repo-config/README.md" }, - { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" } + { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" }, + { "id": "setup.driftnotes.current", "verdict": "intent", "assert": "A registry driftNote records a current deviation from the baseline; once resolved the note is deleted, not left describing finished work. spec/audit.py flags a note asserting outstanding work (pending / not yet / missing / behind / ...) on a repo that otherwise audits clean. Findings derived from an audit run carry its stamp and are re-verified at pickup, never trusted as current state.", "intentRef": "AUDIT.md" }, + { "id": "setup.dependabot.ecosystems", "verdict": "intent", "assert": "For each ecosystem the repo's tree implies, .github/dependabot.yml declares it (dual-target main+develop per the fleet norm): github-actions when .github/workflows/ is present (its workflows reference actions, else their versions go stale and a stood-up merge-bot has no PRs to auto-merge), devcontainers when a .devcontainer is present. A missing implied ecosystem is a drift finding. Language ecosystems (nuget/uv/npm) are directory-scoped, audited by inspection.", "intentRef": "AGENTS.md#branching-model" } ] }, "linter-parity": { @@ -138,7 +143,8 @@ { "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "AGENTS.md#comments" }, { "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "AGENTS.md#character-set" }, { "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling; the shared cspell.json sets language en-US (a bare en accepts British spellings too).", "intentRef": "CODESTYLE.md#markdown-and-spelling" }, - { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI; a per-extension-only form lacking the global [*] default is a drift finding. The global default is CRLF for release repos, or the consuming application's native platform for an operational (config) repo as recorded in the registry lineEndings field (LF for Linux-native/container config e.g. ESPHome/Home Assistant, CRLF for a Windows-native editor e.g. Vantage/Design Center) - do not re-normalize such a repo to CRLF. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" } + { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI; a per-extension-only form lacking the global [*] default is a drift finding. The global default is CRLF for release repos, or the consuming application's native platform for an operational (config) repo as recorded in the registry lineEndings field (LF for Linux-native/container config e.g. ESPHome/Home Assistant, CRLF for a Windows-native editor e.g. Vantage/Design Center) - do not re-normalize such a repo to CRLF. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" }, + { "id": "recurring.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, CODESTYLE.md, WORKFLOW.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) carries no coordination reference: no reference to the template repo in prose or link (private, so it 404s for the carrying repo's readers, and the coordination flow is machinery a consumer should not see - state the behavior, not the destination), and no sibling fleet repo named as an illustrative example of a rule or adoption. A contextually relevant link to a related project is NOT a coordination reference and is expected (the image that consumes this config, a library this depends on) - the test is whether the link serves a reader of this repo's content. The rule governs carried template content. A repo's own README.md and topical docs are its own content. spec/audit.py mechanically checks the two agent-instruction files for the template name.", "intentRef": "AGENTS.md#documentation-style-conventions" } ] }, "readme-structure": { diff --git a/spec/scope-model.md b/spec/scope-model.md new file mode 100644 index 00000000..a0eafe05 --- /dev/null +++ b/spec/scope-model.md @@ -0,0 +1,46 @@ +# Scope Model + +How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. + +## Two Axes + +A rule has a physical home, and - if it is a repo rule - a reach. + +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/` - it loads in every session regardless of repo and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. + +## Selectors + +A selector is one token from one of four **disjoint** namespaces. Because the namespaces share no token, a single flat `appliesTo` list is unambiguous. + +| Namespace | Tokens | Source of truth | +| --- | --- | --- | +| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`][project-types] | +| workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | +| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | +| consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | + +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default - [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. + +## appliesTo Semantics + +`appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). + +- **`*`** means all repos. +- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. +- Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. + +## Documenting a Whole-Carried File's Section Scopes + +A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. + +- [`CODESTYLE.md`][codestyle]: **General** is all-downstream, **.NET** is `csharp`, **Python** is `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. + + +[audit]: ./audit.py +[codestyle]: ../CODESTYLE.md +[files]: ./files.json +[files-schema]: ./files.schema.json +[project-types]: ./project-types.json +[repos-schema]: ../registry/repos.schema.json +[validate]: ./validate.py diff --git a/spec/secrets.json b/spec/secrets.json index f91ae83a..5f63d520 100644 --- a/spec/secrets.json +++ b/spec/secrets.json @@ -36,7 +36,7 @@ "forbids": [], "workflowNeeds": ["codecov/codecov-action"], "stores": ["actions"], - "note": "A csharp or python repo lists CODECOV_TOKEN in its registry requiredSecrets (unlike the implicit baseline secrets). Coverage upload is report-only by default (fail_ci_if_error: false, so a Codecov hiccup never fails the gate); only aiopurpleair and homeassistant-purpleair enforce a 99%+ coverage threshold." + "note": "A csharp or python repo lists CODECOV_TOKEN in its registry requiredSecrets (unlike the implicit baseline secrets). Coverage upload is report-only by default (fail_ci_if_error: false, so a Codecov hiccup never fails the gate); a repo may enforce a stricter threshold (e.g. 99%+) on top of that default." } }, "targetMechanisms": { diff --git a/spec/validate.py b/spec/validate.py index 6787404e..668d6b2c 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -12,11 +12,26 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent +# Scope-selector vocabularies (see spec/scope-model.md), kept in sync with registry/repos.schema.json +# $defs. The four namespaces - project types plus these three - must stay disjoint, so a flat appliesTo +# token set in spec/files.json is unambiguous. +WORKFLOW_MODELS = ("release", "operational") +RELEASE_TRIGGERS = ("two-phase", "publish-on-merge", "dispatch-only", "none") +CONSUMER_MODELS = ("push", "pull") +# How faithfully a carried unit is checked (spec/fidelity-model.md). Default presence. +FIDELITIES = ("presence", "intent", "verbatim", "interface") +# The keys an interface unit's `contract` may carry (kept in sync with files.schema.json). +CONTRACT_KEYS = {"requiredJobKeys", "requiredCheckName", "artifactNameToken", "requireTokensInJob", "forbidTokensInJob", "verbatimJobs"} + def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) +def is_str_list(v): + return isinstance(v, list) and all(isinstance(x, str) for x in v) + + def main(): errors = [] repos = load("registry/repos.json") @@ -90,11 +105,16 @@ def check_secret_set(label, entry, need_kind): print(f" - {e}") return 1 - # defaults.workflowModel feeds configure.sh's fallback, so an invalid value here breaks the apply while every - # per-repo entry still validates - check it once. - default_model = repos.get("defaults", {}).get("workflowModel") - if default_model is not None and default_model not in ("release", "operational"): - errors.append(f"defaults.workflowModel '{default_model}' invalid (expected release or operational)") + # defaults.workflowModel/releaseTrigger feed configure.sh's fallback and selector resolution, so an + # invalid value here breaks the apply or scopes wrong while every per-repo entry still validates - check + # them once. + reg_defaults = repos.get("defaults", {}) + default_model = reg_defaults.get("workflowModel") + if default_model is not None and default_model not in WORKFLOW_MODELS: + errors.append(f"defaults.workflowModel '{default_model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + default_trigger = reg_defaults.get("releaseTrigger") + if default_trigger is not None and default_trigger not in RELEASE_TRIGGERS: + errors.append(f"defaults.releaseTrigger '{default_trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") for i, repo in enumerate(repos["repos"]): if not isinstance(repo, dict): @@ -121,8 +141,20 @@ def check_secret_set(label, entry, need_kind): errors.append(f"{name}: type '{t}' not defined in project-types.json") model = repo.get("workflowModel") - if model is not None and model not in ("release", "operational"): - errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + if model is not None and model not in WORKFLOW_MODELS: + errors.append(f"{name}: workflowModel '{model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + + # releaseTrigger is a scope selector (spec/scope-model.md), so an invalid value would silently fail + # to match any releaseTrigger-scoped section rather than error. + trigger = repo.get("releaseTrigger") + if trigger is not None and trigger not in RELEASE_TRIGGERS: + errors.append(f"{name}: releaseTrigger '{trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") + + # consumerModel is a scope selector (spec/scope-model.md), so a cataloged repo must declare it or a + # push/pull-scoped section would fail open (never matched) on that repo. + cm = repo.get("consumerModel") + if cm not in CONSUMER_MODELS: + errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected {' or '.join(CONSUMER_MODELS)})") eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): @@ -167,6 +199,96 @@ def check_secret_set(label, entry, need_kind): if kind and mech != kind: errors.append(f"{name}: {target} labeled '{mech}' but its mechanism is '{kind}'") + # files.json appliesTo selectors must resolve to a known token, and no project type may collide with a + # reserved selector - a flat token set is only unambiguous while the namespaces stay disjoint. An + # unknown token fails open (it never matches), so a required file/section would silently apply nowhere. + reserved = set(WORKFLOW_MODELS) | set(RELEASE_TRIGGERS) | set(CONSUMER_MODELS) + clash = known_types & reserved + if clash: + errors.append(f"files.json: project type(s) collide with a reserved scope selector: {', '.join(sorted(clash))}") + universe = known_types | reserved + + def check_selector(where, applies_to): + if isinstance(applies_to, list) and not applies_to: + errors.append(f"files.json: {where} appliesTo is an empty list (use \"*\" for all repos, or list selectors) - it would apply nowhere") + return + tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) + for tok in tokens: + # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable + # token (e.g. a nested object) reaching the set-membership test below. + if not isinstance(tok, str): + errors.append(f"files.json: {where} appliesTo has a non-string token {tok!r}") + elif tok not in universe: + errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") + + # CI runs no JSON-schema validation, so shape-check files.json here rather than crash on a malformed + # entry (a non-object baseline item, a non-array sections, a section that is neither string nor object). + files = load("spec/files.json") + baseline = files.get("baseline", []) + if not isinstance(baseline, list): + errors.append("files.json: 'baseline' must be an array") + baseline = [] + for item in baseline: + if not isinstance(item, dict): + errors.append(f"files.json: baseline entry {item!r} is not an object") + continue + path = item.get("path") + if not isinstance(path, str): + errors.append(f"files.json: baseline entry has a missing or non-string path: {item!r}") + continue + check_selector(path, item.get("appliesTo", "*")) + + # fidelity governs how faithfully the unit is checked (spec/fidelity-model.md). CI runs no schema + # validation, so shape-check the fidelity fields here rather than let a malformed contract or an + # outside-root reference slip through and crash a later check. + fid = item.get("fidelity", "presence") + if fid not in FIDELITIES: + errors.append(f"files.json: {path} fidelity '{fid}' invalid (expected one of {', '.join(FIDELITIES)})") + has_contract = "contract" in item + if has_contract and fid != "interface": + errors.append(f"files.json: {path} has a contract but fidelity is '{fid}' (contract is only for fidelity 'interface')") + if fid == "interface" and not has_contract: + errors.append(f"files.json: {path} fidelity 'interface' requires a contract") + if has_contract: + contract = item["contract"] + if not isinstance(contract, dict): + errors.append(f"files.json: {path} contract must be an object") + else: + unknown = set(contract) - CONTRACT_KEYS + if unknown: + errors.append(f"files.json: {path} contract has unknown key(s): {', '.join(sorted(unknown))}") + # The engine trusts these value types (CI runs no schema validation), so verify them here. + for k in ("requiredJobKeys", "verbatimJobs"): + if k in contract and not is_str_list(contract[k]): + errors.append(f"files.json: {path} contract.{k} must be an array of strings") + for k in ("requiredCheckName", "artifactNameToken"): + if k in contract and not isinstance(contract[k], str): + errors.append(f"files.json: {path} contract.{k} must be a string") + for k in ("requireTokensInJob", "forbidTokensInJob"): + v = contract.get(k) + if k in contract and not (isinstance(v, dict) and all(isinstance(j, str) and is_str_list(t) for j, t in v.items())): + errors.append(f"files.json: {path} contract.{k} must be an object of job name to array of strings") + ref = item.get("reference") + if ref is not None and not isinstance(ref, str): + errors.append(f"files.json: {path} reference must be a string") + ref = None + elif isinstance(ref, str) and (ref.startswith("/") or ".." in pathlib.PurePosixPath(ref).parts): + errors.append(f"files.json: {path} reference '{ref}' must be a repo-relative path (no leading / or ..)") + if fid == "verbatim": + src = ref if isinstance(ref, str) else path + if isinstance(src, str) and not (ROOT / src).exists(): + errors.append(f"files.json: {path} fidelity 'verbatim' but its canonical source {src} is missing") + + sections = item.get("sections", []) + if not isinstance(sections, list): + errors.append(f"files.json: {path} sections must be an array") + continue + for elt in sections: + if isinstance(elt, dict): + check_selector(f"{path} section '{elt.get('name', '?')}'", elt.get("appliesTo", "*")) + elif not isinstance(elt, str): + errors.append(f"files.json: {path} section entry {elt!r} must be a string or object") + if errors: print("Spec validation FAILED:") for e in errors: