From bbf9b4bec13f9648fe654b1a19b614ee208409f0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 10:17:10 -0700 Subject: [PATCH 01/35] Vantage adoption follow-ups: every-tier bots wording, registry, carry blessing (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incorporates the hub-side findings filed from the Vantage-Config operational onboarding (Vantage-Config #9). Fixes #328, fixes #329, fixes #331. (#330, the downstream carry-drift sweep, is handled by per-repo PRs, not here.) ## AGENTS.md: every-tier bots (#328) "Foundational Principles" claimed operational repos "run no bots and commit directly to `develop`" - contradicting the owner-confirmed every-tier merge-bot policy (and reality: Vantage-Config runs Dependabot dual-target plus the App-signed merge-bot). Reworded: operational repos run no **codegen or auto-publish** bots; Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier. A next operational adoptee reading the old wording would have skipped the merge-bot standup. ## Registry: Vantage-Config driftNotes (#329) The entry recorded onboarding as "pending once content lands"; it completed 2026-07-16 per Vantage-Config #9. The stale note is replaced with the completed state (baseline docs, advisory lint CI, dispatch-only publisher, repo-config operational carry verified in sync, Dependabot + App merge-bot with the secret pair in both stores, adapted self-audit). `spec/validate.py` passes. ## Downstream Carry: reference self-audit shape (#331) "Adapted self-audit carry" required every downstream repo to carry adapted `AUDIT.md` + `spec/secrets.json` but shipped no reference shape - Vantage-Config had to invent the first one. Now: - The Vantage-Config carry is blessed as the reference adaptation (settings diff, normalized ruleset diff against the carried payloads, names-only secrets check, all targeting the current repo); a `release` repo adapts the same shape with its `develop.json` payload and its publish mechanisms' secret names. - `spec/files.json` letter-checks both carried files, so the fleet audit surfaces repos that don't carry them yet (today: all but Vantage-Config; the #330 sweep starts closing that). ## Verification - `python3 spec/validate.py`: OK (21 cataloged, 0 backlog). - markdownlint, cspell (CI scope), editorconfig-checker clean on the changed files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- registry/repos.json | 2 +- repo-config/README.md | 6 +++++- spec/files.json | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8eeb92c..65ce6196 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ 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". (The auto-publish rules below describe `release` repos only; **operational** repos - registry `workflowModel: operational` - commit directly to `develop`, run no codegen or auto-publish bots, and cut a GitHub release **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`) - never automatically. Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier, operational included. See "Branching Model".) - **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. diff --git a/registry/repos.json b/registry/repos.json index 210a708d..5de3680e 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -294,7 +294,7 @@ "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", diff --git a/repo-config/README.md b/repo-config/README.md index 413c0671..658fdd07 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -12,7 +12,7 @@ Every fleet repo carries this directory; the hub keeps the canonical copy. Rules - **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. +- **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 reference adaptation is the [Vantage-Config carry][vantage-config] (`AUDIT.md` + `spec/secrets.json`, operational model): a settings diff, a normalized ruleset diff against the carried payloads, and a names-only secrets check, all targeting the current repo - adapt it, don't invent. A `release` repo adapts the same shape: its `develop` payload stays at `repo-config/develop.json`, and its `spec/secrets.json` keeps the baseline App pair plus the secret names for its own publish mechanisms (from the hub's canonical `spec/secrets.json`). The fleet audit letter-checks both carried files (`spec/files.json`). - **The regen snippet targets the current repo**, so it works unchanged in a carried copy. ## Rulesets @@ -65,3 +65,7 @@ The fleet-standard general settings live in [`settings.json`][settings-json] and [agents-branching-model]: ../AGENTS.md#branching-model [agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules [settings-json]: ./settings.json + + + +[vantage-config]: https://github.com/ptr727/Vantage-Config diff --git a/spec/files.json b/spec/files.json index a587e3b5..a40ca8a4 100644 --- a/spec/files.json +++ b/spec/files.json @@ -16,6 +16,8 @@ { "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": "AUDIT.md", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "spec/secrets.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, { "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"] }, From ed44ceec3da4683187049b9c181986a9260163e1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 17:42:02 -0700 Subject: [PATCH 02/35] configure.sh model inference on carries; harden ruleset snippets (#334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #332 (core gap + the six bundled nits from the #330 sweep reviews; two of the six live in the blessed downstream reference and propagate via the Vantage-Config carry, not this repo - see below). ## configure.sh: model inference on downstream carries In a carry the registry is absent, so the no-arg form defaulted to `release` and then hit the intended fail-safe abort on operational carries (missing `develop.json`) - arg 2 was effectively mandatory there. Now the model is inferred from which `develop` payload is carried (a carry holds exactly its own model's); ambiguous layouts (both or neither - e.g. a partial copy) abort rather than guess. Also: the model may be passed as the sole argument (`configure.sh operational`) - previously arg 1 was always parsed as a repo name (bundled nit, Utilities #417). Matrix-tested with the apply path stubbed: infer release, infer operational, both-present abort (exit 1), neither-present abort (exit 1), explicit arg-2 override, model-as-sole-arg, unknown-model abort. Hub behavior unchanged (registry present -> registry lookup, as before). shellcheck + bash -n clean. ## Ruleset snippet hardening (duplicate-name id corruption) The `id=$(gh api ... select(.name==...).id)` pattern in the README regen snippet and AUDIT.md section 6 produced a **multiline id** when duplicate same-name rulesets exist, silently corrupting the follow-up API path (bundled nit, Utilities #417 + LanguageTags #278). Both snippets now fetch the list once and enforce **exactly one** ruleset per name: the regen snippet fails loudly (never regen from a guess); the audit snippet reports `found N (defect/drift)` and continues - matching the declared drift semantics. Live-tested read-only against this repo (develop + main in sync; duplicate simulation counts correctly). ## README wording Documents the inference fallback and the model-only argument form (bundled nit, Utilities #417). ## Bundled nits NOT in this PR (with rationale) - `gh api --input -` portability (MediaTools #19): declined - fleet standard is current gh; not worth temp-file churn. - jq-derived secret name lists + `--paginate` in the secrets check (MediaTools #19, LanguageTags #278, PlexCleaner #854): these live in the **blessed downstream self-audit reference** (the Vantage-Config carry), not in hub files - propagated there directly after this merges (the hub's fleet audit reads `spec/secrets.json` programmatically already). ## Propagation caveat This re-drifts the five existing carries (Vantage-Config + the four #330 sweep repos) until their next conformance touch; Vantage-Config is updated immediately after this merges since it is the blessed reference the queued operational standups (HomeAssistant-Config #16, ESPHome-Config #46, HomeAutomation-Config #21) will adapt from. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AUDIT.md | 15 +++++++++++++-- repo-config/README.md | 13 ++++++++++--- repo-config/configure.sh | 37 +++++++++++++++++++++++++++---------- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 430fd800..4be7e4a1 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -68,9 +68,20 @@ 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 diff --git a/repo-config/README.md b/repo-config/README.md index 658fdd07..cc597d4c 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -4,7 +4,7 @@ Repository and branch configuration held as committed files, kept out of `.githu - `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. +- `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 may also be passed as the sole argument (`repo-config/configure.sh operational`). The model defaults to the registry `workflowModel` lookup at the hub; in a downstream carry (no registry) it is inferred from which `develop` payload is carried, and an ambiguous layout (both or neither payload) aborts rather than guesses. ## Downstream Carry @@ -30,11 +30,18 @@ To change the canonical rulesets, edit the live rulesets (fleet-wide changes hap ```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" ] && [ ! -e "$out" ] && out="repo-config/operational/develop.json" - id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id") + [ "$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" diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 6e2b228c..5c4c653c 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) +# 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 -euo 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 From 90d5220ac4a2fbd03c418cd76dd5c55915c0bb8b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 18:45:18 -0700 Subject: [PATCH 03/35] Registry: HomeAutomation-Config operational conformance completed (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles the HomeAutomation-Config registry entry after the #21 conformance carry landed on its develop (54637c4, 2026-07-16) - same close-out pattern as #329 did for Vantage-Config. - "Operational rollout pending" was stale: lint CI (test-pull-request.yml feeding the required check), the dispatch-only publisher (publish-release.yml + version.json), and develop-as-ground-truth were already live; the carry added the repo-config operational baseline (rulesets/settings verified in sync via the carried self-audit), the adapted AUDIT.md + spec/secrets.json, and the Dependabot merge-bot. - The "strip the legacy Vantage/ subtree" instruction is updated to record it as done (verified: no such tree on develop). - The outstanding owner task (App secret pair in both stores; merge-bot inert until then) is recorded so the entry reflects reality. `spec/validate.py` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- registry/repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/repos.json b/registry/repos.json index 5de3680e..66339a6f 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -171,7 +171,7 @@ "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.", "Operational conformance completed 2026-07-16 (HomeAutomation-Config #21): lint CI + dispatch-only publisher were already live; repo-config operational carry (rulesets/settings verified in sync), adapted self-audit (AUDIT.md + spec/secrets.json), and Dependabot merge-bot landed on develop. OWNER TASK OPEN: CODEGEN_APP_CLIENT_ID + CODEGEN_APP_PRIVATE_KEY not yet set in either secret store - the merge-bot is inert until then."] }, { "name": "KiCadLibrary", From 07192ceff8ac1ce70ec69f35082f46599536aa40 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 22:02:29 -0700 Subject: [PATCH 04/35] audit.py: blob-filter the branch-drift compare against cherry-picks (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #336: the branch-drift check derived "main carries content develop lacks" from the three-dot compare alone, which lists files changed on main since the merge-base and is blind to cherry-picked (patch-equivalent) promotions. Any repo promoting via cherry-pick branches re-triggered the finding on every audit - HomeAutomation-Config #21's forward-sync task was authored and worked against exactly this false positive. ## Fix Post-filter the compare's `files[]` by **blob equality at the two heads** (one `git/trees?recursive=1` call per head, as the issue suggested): a path whose blob SHA matches at `develop` and `main` is content develop already has, not content it lacks. Only the remainder raises the DRIFT finding. - The finding now **names the lacking files** (up to 8) - addressing the issue's impact note that "N+ changed file(s)" gave a downstream reader nothing to spot-check. - If either tree comes back `truncated` (very large repos), the filter is skipped and the unfiltered finding is kept, marked "tree too large to blob-filter cherry-pick noise" - conservative, never silently suppressing. - Deletions on main still flag (path absent from main's blobs but present on develop's is a blob mismatch), as do renames. ## Verification (live, read-only) - **False positive resolved**: `python3 spec/audit.py HomeAutomation-Config` -> clean (previously: `DRIFT branch: main carries 2+ changed file(s)...` from the cherry-picked cspell-scope promotions; both files are blob-identical at the heads). - **Genuine positives still fire, now with names**: homeassistant-purpleair -> `main carries 2 file(s) develop lacks: requirements-test.txt, requirements.txt`; DevKitCIoT -> `main carries 12 file(s) develop lacks: .editorconfig, ...` (first 8 shown). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- spec/audit.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index be05929a..03298047 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -9,8 +9,8 @@ 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. @@ -84,12 +84,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 trees calls; 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"]) From af2f008528d4c4e0b7b410ab3f254690aefe9bd4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 15:11:15 -0700 Subject: [PATCH 05/35] Registry: HomeAssistant-Config operational conformance completed (#342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile the stale `HomeAssistant-Config` registry `driftNotes` after the [`#16`](https://github.com/ptr727/HomeAssistant-Config/issues/16) operational onboarding. Mirrors the `#329` reconciliation for Vantage-Config. ## What changed The prior driftNotes described work that is now complete and planned a `groundTruthBranch` flip that is declined: - "develop is behind main by 2 commits / fast-forward then flip groundTruthBranch to develop" - develop is content-ahead; the flip is **declined** (see below). - "pending: lint CI" - `test-pull-request.yml` with the `Check pull request workflow status job` required check is live. - "pending: dispatch-only source-release scaffolding (version.json + publish-release.yml)" - both present. Replaced with a completed-onboarding summary: baseline carried (repo-config operational, `AUDIT.md`, `spec/secrets.json`, App-signed Dependabot merge-bot) and promoted `develop -> main` via HomeAssistant-Config PR `#17`. Self-audit verified in sync (settings, both rulesets, `CODEGEN_APP_*` present in both stores). ## groundTruthBranch stays `main` (deliberate) Kept `groundTruthBranch: main`, **not** flipped to `develop`. develop is the working branch (direct signed commits); main is the promoted stable snapshot the audit should target. This is a deliberate divergence from the other operational repos (Vantage-Config / ESPHome-Config / HomeAutomation-Config use `develop`); the broader convention question is raised in #340. ## Findings note (context, see #341) The `#16` audit block was stale: the `CODEGEN_APP_*` secrets were already present in both stores and develop was already ahead of main. Only the `repo-config/... absent` LETTER finding was real, and it is now cleared on `main` by PR `#17`. Validation: `jq` parses clean, `spec/validate.py` -> "21 cataloged, 0 backlog repos classify cleanly." 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- registry/repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/repos.json b/registry/repos.json index 66339a6f..a7ae328e 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -227,7 +227,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", From 1b3b6b06b8880087aaf09b1916ddb3bb18aa024d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 16:34:01 -0700 Subject: [PATCH 06/35] Support and audit the csharp+python repo shape (#339) (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #339. Registers the fleet's first mixed-language shape (PlexCleaner: a .NET console app plus a stdlib-only Python tooling subtree) and codifies how the `csharp` and `python` baselines coexist. Reference implementation: ptr727/PlexCleaner#855. Per the issue, this is **not** a new `polyglot` type - a mixed repo is expressed as `types: ["csharp","console","docker","python"]` and the audit runs the union of the per-type checks. The work is: register the shape, codify the coexistence rules, add a matrix row. ## Three decisions (settled with the owner before writing) 1. **Two python profiles, one type, split by a third-party runtime import.** PROJECT = a PEP 621 uv project with runtime deps + committed LF-pinned `uv.lock` (the existing Financial-Modeling shape). SCRIPTS = stdlib-only utility scripts embedded in a non-Python repo, run via `uvx`, no lockfile, `pyproject.toml` carrying only `[tool.ruff]`/`[tool.mypy]`. Detected structurally from `pyproject.toml`. 2. **Tool-version pinning for SCRIPTS is CI-only.** CI pins exact versions in the `uvx` command (`uvx ruff@X`, bumpable there); tasks/README run latest - a deliberate CI-vs-local gap so local never silently falls behind. PROJECT keeps pinning through `uv.lock` + `uv sync --frozen` (unchanged). 3. **Coverage N/A for SCRIPTS.** `python.coverage.codecov` is N/A for a lint/type-only subtree (no pytest); `codecov.yml` presence stays required by any co-present type with tests (the C# side here). ## Changes | File | Change | |---|---| | `registry/repos.json` | PlexCleaner `types` += `python`; driftNote records the SCRIPTS subtree + which checks go N/A | | `spec/project-types.json` | `python` type: `profileNote` + new `python.profile.detect`; SCRIPTS-profile N/A wording on `pyright`/`coverage`/`uvlock`; new `python.scripts.uvx` check | | `CODESTYLE.md` | "Two profiles" paragraph (project vs scripts: uvx, tool-config-only pyproject, mypy, CI-pinned/local-latest, `.py` on repo EOL default, coverage N/A) | | `README.md` `## Rules` | Python bullet names the type-checker choice; new "If Both C# and Python" both-apply subsection | | `reports/conformance-matrix.md` | `csharp` + `python` shape row (PlexCleaner) | | `cspell.json` | `bumpable`, `stdlib`, `uvx` | ## Issue items mapped - **(1) line endings** - CODESTYLE "Scripts" bullet states `.py`/`.toml` follow the repo CRLF default (only a shebang-executed script is LF-pinned), so nobody "fixes" `.py` to LF; `recurring.eol` already covers the general rule. - **(2) uvx-vs-uv-project** - `python.profile.detect` + CODESTYLE "Two profiles". - **(3) CI-only pinning** - `python.scripts.uvx` + CODESTYLE. - **(4) coverage** - `python.coverage.codecov` N/A wording. - **(5) tasks.json union** - confirmed, **no change needed**: `spec/files.json` already carries separate `csharp` and `python` `.vscode/tasks.json` references, so a mixed repo satisfies both by union (that is the intended audit behavior). - **(6) docs both-apply** - README "If Both C# and Python" + CODESTYLE "Two profiles". ## Validation - `python3 spec/validate.py` -> OK (21 cataloged, 0 backlog). - markdownlint + editorconfig-checker clean on changed files; CI-scoped cspell (README + HISTORY) clean. Related: #306 (closed - source-only Python adaptation gaps), #310 (open - the conformance-matrix row here is a `not-tested` entry pending that issue's cold-start self-test). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CODESTYLE.md | 6 ++++++ README.md | 6 +++++- cspell.json | 3 +++ registry/repos.json | 4 ++-- reports/conformance-matrix.md | 1 + spec/project-types.json | 17 ++++++++++------- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CODESTYLE.md b/CODESTYLE.md index 379b823c..0f90daa1 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -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. Financial-Modeling is the reference. +- **Scripts** - stdlib-only utility scripts embedded in a **non-Python** repo (e.g. the Python tooling subtree of a `csharp` app - PlexCleaner's `RegressionTests/`). 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..130b7f88 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,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/cspell.json b/cspell.json index 07f8024f..e77a9ac1 100644 --- a/cspell.json +++ b/cspell.json @@ -20,6 +20,7 @@ "buildmetadata", "buildtransitive", "Buildx", + "bumpable", "charliermarsh", "chowned", "chowns", @@ -114,6 +115,7 @@ "softprops", "somecommand", "sshconfig", + "stdlib", "subsetting", "timonwong", "Triaging", @@ -121,6 +123,7 @@ "unbuilt", "unvalidated", "USERPROFILE", + "uvx", "venv", "Viljoen", "winget", diff --git a/registry/repos.json b/registry/repos.json index a7ae328e..5c1c63b2 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", 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/project-types.json b/spec/project-types.json index bca02029..cf41742b 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -31,13 +31,16 @@ "python": { "detect": ["pyproject.toml", "setup.py"], "canonicalPlacement": "pyproject.toml", - "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" } + "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); Financial-Modeling is the reference. SCRIPTS: stdlib-only utility scripts embedded in a non-Python repo (a csharp+python repo like PlexCleaner#855) - 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.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": { From 3f16b43944289e31f5f1d86b34c0aa6873023744 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 17:22:07 -0700 Subject: [PATCH 07/35] Make carried template docs repo-agnostic; codify the no-cross-reference rule (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file the fleet carries verbatim must name no sibling fleet repo and link none. Raised by the ESPHome-Config adoption: the `repo-config/README.md` "blessed reference" pointed a `[vantage-config]` link at the **private** Vantage-Config, which **404s in every public carrier** (PlexCleaner, LanguageTags, Utilities already carry it). Two reasons, both general: any fleet repo may be private -> a cross-repo link 404s in a public carrier; and a cross-repo reference couples the repos and rots as they diverge. ## The rule (now codified in AGENTS.md) A carried file (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, `README.md`, `.github/copilot-instructions.md`, `repo-config/*`, `spec/*`, the carried `AUDIT.md`) is **repo-agnostic**: it describes the shape/pattern self-containedly and names a sibling repo **only where that repo is the subject**. A current good example is named in the **onboarding/conformance issue** (one-off, not carried) or the hub-only **`reports/conformance-matrix.md`** (a fleet tracking table) - never a carried doc. This pairs with the present-tense rule: state the current shape, not its provenance. ## Changes | File | Change | |---|---| | `repo-config/README.md` | Remove the `[vantage-config]` link + External group; the self-audit-carry bullet describes the adapted `AUDIT.md` shape self-containedly, pointing to the issue for a current example | | `AGENTS.md` | New rule under Documentation Style Conventions; the mixed-consumer-config example keeps its Windows-editor/CRLF **context** but drops the repo names | | `CODESTYLE.md` | Logging-seam and Python "Two profiles" examples describe the pattern, no repo names | | `spec/project-types.json` | `python` profileNote genericized; new `recurring.norepoxref` audit check | | `spec/secrets.json` | Coverage-threshold note no longer names repos | Hub-only files (`registry/repos.json`, `reports/conformance-matrix.md`) keep repo names - naming repos **is** their subject and they are not carried. ## Rollout Hub only. The 6 repos that already carry the bad link (public: PlexCleaner, LanguageTags, Utilities - live 404; private: Vantage-Config, HomeAutomation-Config, MediaTools) correct on each repo's **next conformance touch**. ESPHome-Config's in-flight adoption carries the fixed hub `repo-config/README.md`, so it never inherits the link. ## Validation - `python3 spec/validate.py` -> OK; markdownlint + editorconfig-checker clean. - Grep confirms no residual illustrative cross-repo reference in any carried file, and the Vantage URL is gone from all carried paths. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- CODESTYLE.md | 6 +++--- repo-config/README.md | 6 +----- spec/project-types.json | 5 +++-- spec/secrets.json | 2 +- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65ce6196..fa771603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,8 @@ Clarify devcontainer setup steps in README ## Documentation Style Conventions +- **Carried files reference no other fleet repo unless that repo is the subject.** Every file the fleet carries verbatim - `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, `README.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md` - is **repo-agnostic**: it describes the shape or pattern self-containedly, names no sibling fleet repo as an illustrative example, and adds no link (inline URL or reference definition) to another fleet repo. This bans only *cross-repo* references - ordinary links to files within the same repo, to anchors, and to external sites are unaffected. Two reasons: any fleet repo may be **private**, so a cross-repo link 404s in a public carrier; and a cross-repo reference **couples** the repos and rots as they diverge. A repo name is allowed only when that repo *is* the subject (the file is about it). To point at a current good example, name it in the **onboarding/conformance issue** (one-off, not carried) or the hub-only `reports/conformance-matrix.md` (a fleet tracking table, not carried) - never in a carried doc. 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). @@ -139,7 +141,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. diff --git a/CODESTYLE.md b/CODESTYLE.md index 0f90daa1..742506c4 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -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 @@ -354,8 +354,8 @@ This is the style guide for any **Python project(s)** in this repo. **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. Financial-Modeling is the reference. -- **Scripts** - stdlib-only utility scripts embedded in a **non-Python** repo (e.g. the Python tooling subtree of a `csharp` app - PlexCleaner's `RegressionTests/`). 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. +- **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 diff --git a/repo-config/README.md b/repo-config/README.md index cc597d4c..1c9d8f4b 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -12,7 +12,7 @@ Every fleet repo carries this directory; the hub keeps the canonical copy. Rules - **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 reference adaptation is the [Vantage-Config carry][vantage-config] (`AUDIT.md` + `spec/secrets.json`, operational model): a settings diff, a normalized ruleset diff against the carried payloads, and a names-only secrets check, all targeting the current repo - adapt it, don't invent. A `release` repo adapts the same shape: its `develop` payload stays at `repo-config/develop.json`, and its `spec/secrets.json` keeps the baseline App pair plus the secret names for its own publish mechanisms (from the hub's canonical `spec/secrets.json`). The fleet audit letter-checks both carried files (`spec/files.json`). +- **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 link no other repo. 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 `release` repo keeps its `develop` payload at `repo-config/develop.json`, and its `spec/secrets.json` keeps the baseline App pair plus the secret names for its own publish mechanisms (from the hub's canonical `spec/secrets.json`). The fleet audit letter-checks both carried files (`spec/files.json`). A current well-formed example is named in the onboarding/conformance issue, not linked here. - **The regen snippet targets the current repo**, so it works unchanged in a carried copy. ## Rulesets @@ -72,7 +72,3 @@ The fleet-standard general settings live in [`settings.json`][settings-json] and [agents-branching-model]: ../AGENTS.md#branching-model [agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules [settings-json]: ./settings.json - - - -[vantage-config]: https://github.com/ptr727/Vantage-Config diff --git a/spec/project-types.json b/spec/project-types.json index cf41742b..fbcbff34 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -31,7 +31,7 @@ "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); Financial-Modeling is the reference. SCRIPTS: stdlib-only utility scripts embedded in a non-Python repo (a csharp+python repo like PlexCleaner#855) - 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.", + "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.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" }, @@ -141,7 +141,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, README.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) names no sibling fleet repo as an illustrative example and adds no link (inline URL or reference definition) to another fleet repo; only cross-repo references are banned - same-repo file/anchor links and external links are fine. A repo name appears only where that repo is the file's subject. Rationale: a cross-repo link 404s in a public carrier when the target is private, and couples the repos. A current example is named in the onboarding/conformance issue or the hub-only reports/conformance-matrix.md, never a carried doc.", "intentRef": "AGENTS.md#documentation-style-conventions" } ] }, "readme-structure": { 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": { From 3439a073ba7b7e1ed40d70b153f8e43da5f5c2e1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 19:36:07 -0700 Subject: [PATCH 08/35] audit.py: check Dependabot ecosystem coverage against the repo tree (#346) (#347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the audit-coverage half of #346. (The concrete ESPHome-Config drift is filed as a separate downstream issue for that repo to fix.) ## The gap `spec/audit.py` checked Dependabot **secret stores** but never that a repo's `.github/dependabot.yml` declares the **ecosystems its tree implies**. So ESPHome-Config - which ships `test-pull-request.yml` / `publish-release.yml` / `merge-bot-pull-request.yml` with SHA-pinned actions but tracks only `devcontainers` - passed the standup audit clean, even though its action pins get no bump PRs and the merge-bot it just stood up has no action-update PRs to act on. ## The check New crossCutting check `setup.dependabot.ecosystems` (repo-setup dimension): for each ecosystem the tree implies, `dependabot.yml` must declare it (dual-target `main`+`develop` per the fleet norm): - **`github-actions`** when `.github/workflows/` ships actions, - **`devcontainers`** when a `.devcontainer` is present. A missing implied ecosystem is a **DRIFT** finding. `dependabot.yml` is YAML (audit.py is stdlib-only, no parser), so the declared `package-ecosystem` values are scanned by regex - enough to assert presence. The check only runs when the file exists (its absence is already a file-presence LETTER). Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and left to inspection for now (noted in AUDIT.md). Codified in `AUDIT.md` section 6 (with a shell snippet) and `spec/project-types.json`. ## Verification (live, read-only) ``` ESPHome-Config -> DRIFT dependabot: github-actions ecosystem not declared though .github/workflows/ ships SHA-pinned actions Vantage-Config -> clean HomeAutomation-Config -> clean PlexCleaner -> clean Utilities -> clean aiopurpleair -> clean ``` The `devcontainers` ecosystem ESPHome-Config *does* declare is correctly not flagged. `spec/validate.py` passes; markdownlint + editorconfig-checker clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AUDIT.md | 12 ++++++++++++ spec/audit.py | 24 ++++++++++++++++++++++++ spec/project-types.json | 3 ++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/AUDIT.md b/AUDIT.md index 4be7e4a1..1ca6626c 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -89,6 +89,17 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: - **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. +- **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; 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: @@ -125,6 +136,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 diff --git a/spec/audit.py b/spec/audit.py index 03298047..3e544369 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -16,8 +16,10 @@ Usage: python3 spec/audit.py [RepoName ...] (default: every cataloged repo) """ +import base64 import json import pathlib +import re import subprocess import sys @@ -177,6 +179,28 @@ 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?)")) + # --- 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; that each declared ecosystem dual-targets main+develop (the fleet norm) is verified by + # inspection, not here. Only runs when dependabot.yml exists; 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 presence on the ground-truth branch --- seen_paths = set() for item in spec["files"]["baseline"]: diff --git a/spec/project-types.json b/spec/project-types.json index fbcbff34..f1f50679 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -123,7 +123,8 @@ "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.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": { From 7e032f468ce9005e39bc7722e363a9539f430566 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 20:44:58 -0700 Subject: [PATCH 09/35] Registry: ESPHome-Config operational rollout + Dependabot ecosystem resolved (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles the ESPHome-Config registry entry after its conformance standup (ESPHome-Config#46) and the Dependabot ecosystem fix (ESPHome-Config#49 / promotion #50) completed - same close-out pattern as #329/#335. The second `driftNote` recorded the operational rollout as pending and Dependabot as `devcontainers`-only. Both are resolved and **verified from the live repo** before dropping the note: - esphome-config validation job feeds the required check; `version.json` + `publish-release.yml` present; **2.0.12** released 2026-07-18; `develop` is ground truth. - `.github/dependabot.yml` declares `github-actions` **dual-target** on both `develop` and `main`. - `python3 spec/audit.py ESPHome-Config` (with the #346 check now on develop) -> **clean**; no `dependabot` DRIFT. The first note (ESPHome device config consumed by the ESPHome-NonRoot image) stays - still true. `spec/validate.py` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- registry/repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/repos.json b/registry/repos.json index 5c1c63b2..113136e1 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -212,7 +212,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", From dc9dc08b228bf1e83cbb8d050a48c02cf6ffd381 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 17 Jul 2026 21:28:10 -0700 Subject: [PATCH 10/35] Split carry-process out of the carried repo-config README into a hub-only doc (#345) (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #345 (both findings). Surfaced when ESPHome-Config became the first **public** repo to carry `repo-config/README.md`. ## Finding 1: carry-process narration shipped downstream `repo-config/README.md` is carried verbatim by every fleet repo, but it shipped carry-**process** guidance - `## Downstream Carry`, the ruleset import/regen procedure, and the maintainer `## Brownfield Migration`. That is meta-guidance for whoever performs the carry, not a current fact about the repo the file lands in (it's also what hosted the private-repo link #344 removed). **Split** (the settled approach): - **New hub-only `docs/repo-config-carry.md`** holds the process: Downstream Carry, Applying the Config, Regenerating the Payloads, Brownfield Migration. It's not in `spec/files.json` baseline, so it's never carried; being hub-only it may name repos and link the hub freely. - **`repo-config/README.md` trims to current facts** about a repo's own config: file descriptions, what the rulesets enforce, the secrets each mechanism needs, the general settings. - **Cross-refs fixed** (carried docs can't link a hub-only doc): `AGENTS.md` plain-text-mentions the hub doc for the full procedure/brownfield and drops two stale anchors (`Rules / Rulesets`, `Template - GitHub Setup`); `STANDUP.md` and `spec/files.json` (both hub-only) link the new doc directly. ## Finding 2: repo-scoped secrets schema clarity The rule for the adapted `spec/secrets.json` now lives in the hub doc: a **source-only** repo whose publish targets all map to a null mechanism carries just `baseline` (+ `note`) - it omits `targetMechanisms`, since a lone routing map with no `mechanisms` reads as "the audit ignores the mechanism list" (Copilot tripped on it 3x across the standup PRs). A repo with a real mechanism carries `mechanisms` **and** its `targetMechanisms` routing, which the audit picks up. ## Rollout Hub only. Downstream repos' carried `repo-config/README.md` (and any source-only `spec/secrets.json` still carrying an all-null `targetMechanisms`) correct on each repo's next conformance touch - consistent with the prior sweeps. ESPHome-Config already stripped both in its standup. ## Validation `spec/validate.py` OK; markdownlint + editorconfig-checker clean on all changed files; CI-scoped cspell (README+HISTORY) clean; grep confirms no dangling references to the moved sections. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 ++-- STANDUP.md | 4 +-- cspell.json | 1 + docs/repo-config-carry.md | 60 +++++++++++++++++++++++++++++++++++++++ repo-config/README.md | 46 ++++-------------------------- spec/files.json | 4 +-- 6 files changed, 73 insertions(+), 48 deletions(-) create mode 100644 docs/repo-config-carry.md diff --git a/AGENTS.md b/AGENTS.md index fa771603..f20ab9c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,14 +37,14 @@ 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 (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. The full export/import procedure and the regen snippet live in the hub-only `docs/repo-config-carry.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 - the maintainer-only brownfield migration procedure is in that same hub doc. - **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. - *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; 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 [`repo-config/README.md`](./repo-config/README.md) "Secrets" for which secrets each mechanism needs. ## Release Model diff --git a/STANDUP.md b/STANDUP.md index 3b47392d..a869ecd6 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) 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/cspell.json b/cspell.json index e77a9ac1..a6d471e0 100644 --- a/cspell.json +++ b/cspell.json @@ -100,6 +100,7 @@ "pyrightconfig", "pytest", "quoteoftheday", + "regen", "resharper", "rhysd", "Rubba", diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md new file mode 100644 index 00000000..1a7e7d01 --- /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 reference no other fleet repo.** A carried file names no sibling fleet repo as an example and links none (any fleet repo may be private, so a cross-repo link 404s in a public carrier; and it couples the repos). 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` 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; 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/repo-config/README.md b/repo-config/README.md index 1c9d8f4b..8562d8c1 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -2,18 +2,9 @@ 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. -- `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 may also be passed as the sole argument (`repo-config/configure.sh operational`). The model defaults to the registry `workflowModel` lookup at the hub; in a downstream carry (no registry) it is inferred from which `develop` payload is carried, and an ambiguous layout (both or neither payload) aborts rather than guesses. - -## 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 link no other repo. 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 `release` repo keeps its `develop` payload at `repo-config/develop.json`, and its `spec/secrets.json` keeps the baseline App pair plus the secret names for its own publish mechanisms (from the hub's canonical `spec/secrets.json`). The fleet audit letter-checks both carried files (`spec/files.json`). A current well-formed example is named in the onboarding/conformance issue, not linked here. -- **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,33 +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')" -# 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 -``` +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 @@ -63,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/spec/files.json b/spec/files.json index a40ca8a4..b622105d 100644 --- a/spec/files.json +++ b/spec/files.json @@ -16,8 +16,8 @@ { "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": "AUDIT.md", "intentRef": "repo-config/README.md", "appliesTo": "*" }, - { "path": "spec/secrets.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "AUDIT.md", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, + { "path": "spec/secrets.json", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, { "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"] }, From 878e1ccadb83a850faca318a3b993ba66cdb51b4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 07:57:40 -0700 Subject: [PATCH 11/35] Adopt 'set -Eeuo pipefail' on every bash surface (#338) (#350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #338 - but not as filed. Worth reading the first section before the diff. ## The issue's premise was inverted #338 states the fleet mandates `set -Eeuo pipefail` and that `repo-config/configure.sh` violates it. Verified against the hub: - `AGENTS.md` said **`set -euo pipefail`**, and `configure.sh` used exactly that - **they already agreed**. - The `-Eeuo` mandate exists only in **HomeAutomation-Config's carried `AGENTS.md`**, which has drifted from the hub. - There are **no `trap ... ERR` anywhere in the fleet**, so `-E` is functionally inert today. Taking the issue at face value would have changed the canonical hub script to match a *drifted downstream copy*. Instead, per the owner's call: adopt `-Eeuo` at the hub as the better default and let the carries converge on it. (The divergence itself is more evidence for #305.) ## Changes - **Swept all 26 occurrences** to `set -Eeuo pipefail`: the 4 hub workflows, `repo-config/configure.sh`, the 7 catalog workflow snippets, and both devcontainer `post-create.sh` scripts. - **`AGENTS.md` rule prose** now states the rationale (`-E` lets an `ERR` trap inherit into functions/subshells/command substitutions; defense in depth since the fleet ships no trap yet) **and resolves the scope ambiguity** #338's last paragraph raises - it covers "every bash surface: a multi-line `run:` block and every committed `.sh` script alike", so inline snippets are explicitly in scope. - **`WORKFLOW.md`** style bullet and D9.3 match. ## Verification - shellcheck + `bash -n` clean on all three scripts. - actionlint clean on the hub workflows (snippet fragments show only the pre-existing "reusable workflow not found" note). - `configure.sh` re-run in place: still resolves `release` and `operational` correctly. - markdownlint, editorconfig-checker, `spec/validate.py`, CI-scoped cspell: clean. CRLF/LF endings preserved (swept with `sed`, which leaves line endings untouched). Downstream carries pick this up on their next conformance touch, as usual. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/merge-bot-pull-request.yml | 6 +++--- .github/workflows/publish-release.yml | 2 +- .github/workflows/test-pull-request.yml | 2 +- .github/workflows/validate-task.yml | 2 +- AGENTS.md | 2 +- WORKFLOW.md | 4 ++-- catalog/snippets/devcontainer/dotnet/post-create.sh | 2 +- catalog/snippets/devcontainer/python/post-create.sh | 2 +- catalog/snippets/workflows/build-executable-task.yml | 1 + catalog/snippets/workflows/build-nugetlibrary-task.yml | 4 ++-- catalog/snippets/workflows/build-pypilibrary-task.yml | 4 ++-- catalog/snippets/workflows/build-release-task.yml | 6 +++--- catalog/snippets/workflows/check-upstream-version-task.yml | 2 +- catalog/snippets/workflows/publish-docker-readme-task.yml | 6 +++--- catalog/snippets/workflows/publish-plan-task.yml | 2 +- .../snippets/workflows/run-codegen-pull-request-task.yml | 4 ++-- repo-config/configure.sh | 2 +- 17 files changed, 27 insertions(+), 26 deletions(-) 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/AGENTS.md b/AGENTS.md index f20ab9c2..e6b0f7a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,7 +227,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/WORKFLOW.md b/WORKFLOW.md index e3f2f43e..2431bb3b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -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**. @@ -201,7 +201,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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`. 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/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..8eaef7f5 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) 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/repo-config/configure.sh b/repo-config/configure.sh index 5c4c653c..5082ea0f 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -15,7 +15,7 @@ # Usage: repo-config/configure.sh [owner/repo] [release|operational] (repo defaults to the current repo via gh; # 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 -euo pipefail +set -Eeuo pipefail repo_arg="${1:-}" model="${2:-}" From bd48caf4bd93ddf455474c31107c3761008389f2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 07:57:43 -0700 Subject: [PATCH 12/35] Operational repos audit against main, matching AUDIT.md section 1 (#340) (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #340 - and it turns out to be a **reconciliation, not a policy change**. ## What I found `AUDIT.md` section 1 already says, with **no model exception**: > Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. But three operational entries in `registry/repos.json` named `develop` - Vantage-Config, ESPHome-Config, HomeAutomation-Config. So the registry was **contradicting the hub's own documented rule**; #340's proposal is what the docs already required. (HomeAssistant-Config was correctly on `main`.) ## Changes - **`registry/repos.json`**: the three operational entries flip to `groundTruthBranch: main`. - **`AUDIT.md` section 1**: states the both-models rule explicitly, since that is precisely where the ambiguity arose - an operational repo commits directly to `develop`, but its ground truth is still `main`, the promoted snapshot; `develop` is mid-flight by design, so conformance work landed there but not yet promoted is *un-promoted work*, not a defect. A `groundTruthBranch` naming `develop` contradicts the section, for either model. ## Verification (the risk was manufacturing false findings) Before flipping, I confirmed **all four conformance files** (`repo-config/main.json`, `repo-config/operational/develop.json`, `AUDIT.md`, `spec/secrets.json`) are **already present on `main`** in all three repos - so measuring `main` surfaces nothing new. ```text BEFORE (develop ground truth): Vantage/ESPHome/HomeAutomation -> all clean AFTER (main ground truth): all four operational repos -> all clean, 0 findings ``` `spec/validate.py`, markdownlint, editorconfig-checker: clean. ## Out of scope here: four `release` repos also name `develop` Checking the fleet surfaced that **KiCadLibrary, EspDinIoT, PhotoCleaner, and HolidayLights** (all `release` model, all with a `main` branch) also carry `groundTruthBranch: develop` - the same contradiction. They are **deliberately not flipped in this PR**, because unlike the operational three their `main` is stale, so the flip is not mechanical: | repo | baseline files present on `main` | |---|---| | KiCadLibrary | 4/4 | | PhotoCleaner | 2/4 | | HolidayLights | 1/4 | | EspDinIoT | 0/4 | Flipping them would surface real-but-voluminous findings (their released state genuinely is non-conformant - which is arguably the correct signal, but it is a scope and noise decision for the owner, not a mechanical reconciliation). Raised for a separate call. ## Note on the counter-view #340 raises deploy-from-develop as the one case for a `develop` ground truth, and suggests a separate `deployBranch` field rather than overloading `groundTruthBranch`. No fleet repo deploys from `develop` today, so I have not added the field - cheap to add later if one genuinely needs it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AUDIT.md | 2 ++ registry/repos.json | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 1ca6626c..9bb0363b 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -16,6 +16,8 @@ This audit is not occasional. Run it whenever you **create, adopt, or materially 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: diff --git a/registry/repos.json b/registry/repos.json index 113136e1..c804a3f6 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -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, @@ -204,7 +204,7 @@ "url": "https://github.com/ptr727/ESPHome-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", + "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "lf", "hasDevelop": true, @@ -286,7 +286,7 @@ "url": "https://github.com/ptr727/Vantage-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", + "groundTruthBranch": "main", "workflowModel": "operational", "lineEndings": "crlf", "hasDevelop": true, From 7a3905b63af0aa5fb90b348afbe31d93518715da Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 08:23:21 -0700 Subject: [PATCH 13/35] Stamp audit runs and flag stale driftNotes (#341) (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #341. Both halves are the same failure mode: **a point-in-time snapshot presented as current state**, so an agent picking it up "fixes" what is already fixed. ## 1. Freshness signal on every run `spec/audit.py` now prints: ```text audit run 2026-07-18T15:11:28Z | hub bd48caf == HomeAutomation-Config (source-only; operational) @ main@6fadcbe == clean (deterministic checks; the full operational verdict is AUDIT.md's) ... Findings are a point-in-time snapshot: re-run this audit before acting on them, and quote the run stamp above in any issue derived from it (AUDIT.md section 8). ``` - a **run stamp** (UTC + the hub commit the ground truth came from), and - **per repo, the exact commit read** (`@ @`) - so a finding is attributable to a specific state. `AUDIT.md` section 8 now requires anything derived from a run - a report, and **especially an onboarding/conformance issue** - to quote the stamp, and requires the agent picking that issue up to **re-run the audit and act on the live result, not the pasted findings**. Findings are evidence for *why* the issue was filed, never a claim about current state. That is exactly the #16 failure: two of three findings were already resolved at pickup. ## 2. driftNotes flagged when they outlive the deviation A `driftNote` records a **current** deviation; once resolved it is deleted, not left describing finished work. `audit.py` now raises a drift finding when a repo **audits clean** but a note still asserts outstanding work (`pending`, `not yet`, `missing`, `behind`, `owed`, `todo`, `still`, `absent`). The marker list is deliberately narrow and the check only fires on an otherwise-clean repo, so a note recording a *permanent* deviation ("no get-version-task; relies on validate-task") never trips it. Codified as `setup.driftnotes.current`. ## The check earned its keep immediately It caught **a stale note I wrote myself** for HomeAutomation-Config yesterday - `"OWNER TASK OPEN: CODEGEN_APP_CLIENT_ID + CODEGEN_APP_PRIVATE_KEY not yet set in either secret store"` - which the owner resolved (I verified the secrets and closed the downstream issue) but I never reconciled in the registry. Verified both secrets present in both stores, then removed the note entirely: with conformance complete it recorded no deviation at all. **Noise check:** run across the fleet, the heuristic flagged exactly that one note. ## Validation `spec/validate.py`, markdownlint, editorconfig-checker clean; `audit.py` parses and runs; CRLF/LF preserved. This lands before the fleet-wide audit sweep deliberately - it determines whether the issues that sweep produces are accurate on arrival. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AUDIT.md | 4 ++++ registry/repos.json | 2 +- spec/audit.py | 40 ++++++++++++++++++++++++++++++++++------ spec/project-types.json | 1 + 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 9bb0363b..9fa88390 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -115,6 +115,10 @@ 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. diff --git a/registry/repos.json b/registry/repos.json index c804a3f6..ccd9e732 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -171,7 +171,7 @@ "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; the legacy Vantage/ subtree is stripped.", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational conformance completed 2026-07-16 (HomeAutomation-Config #21): lint CI + dispatch-only publisher were already live; repo-config operational carry (rulesets/settings verified in sync), adapted self-audit (AUDIT.md + spec/secrets.json), and Dependabot merge-bot landed on develop. OWNER TASK OPEN: CODEGEN_APP_CLIENT_ID + CODEGEN_APP_PRIVATE_KEY not yet set in either secret store - the merge-bot is inert until then."] + "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", diff --git a/spec/audit.py b/spec/audit.py index 3e544369..e01f8861 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -22,6 +22,7 @@ import re import subprocess import sys +from datetime import datetime, timezone ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -30,6 +31,9 @@ "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): @@ -74,7 +78,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) @@ -216,7 +220,20 @@ def audit_repo(entry, spec): 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)")) - return findings + # --- 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 def main(): @@ -235,21 +252,32 @@ 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}\n") + 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/project-types.json b/spec/project-types.json index f1f50679..6e3966bd 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -124,6 +124,7 @@ "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.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" } ] }, From 42808812d46dc4ba6e3212b75b1d36bc361f6db2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 12:27:39 -0700 Subject: [PATCH 14/35] publish-plan: annotate a non-publishing pin push; codify fail-loud gates (#354) (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #354, taking **option 1** (the minimal, recommended one) plus the generalization. ## The fix A `main` pin push by an actor outside the allowlist now emits a `::warning::` rather than falling through silently: ```sh elif [[ "$REF" == "main" ]]; then echo "::warning::Pin push on main by unrecognized actor '$ACTOR'; not publishing. If this is the codegen App under a new identity, update the allowlist in publish-plan-task.yml." fi ``` ## Verification: the publish decision is unchanged Simulated the patched logic across the full matrix - the gate behaves **identically** for every legitimate case, and the warning fires only on the silent-failure path: ```text push ptr727-codegen[bot] main -> publish=true push dependabot[bot] main -> publish=true push ptr727-codegen-v2[bot] main -> publish=false + ::warning:: <- the renamed-App case push ptr727 main -> publish=false + ::warning:: <- hand edit, worth seeing push ptr727-codegen[bot] develop -> publish=false schedule github main -> publish=true workflow_dispatch ptr727 develop -> publish=true ``` ## Generalization codified: WORKFLOW.md D8.4 > **An identity allowlist used as a gate fails loud.** with the failure mode it prevents, and two judgment calls worth recording: - **Where an annotation is optional:** the merge-bot hard-codes the same identities, but its failure is **self-announcing** - it stops merging and bot PRs visibly pile up. The publish gate is the acute case precisely because the weekly schedule *masks* it, leaving only a timeliness symptom. - **The escalation:** option 2 (mint an App token, resolve the identity via `GET /app`) removes the hard-coded string entirely, and is the right move only if an allowlist proves fragile in practice - noted rather than adopted, per the issue. ## Validation actionlint clean on the snippet; `spec/validate.py`, markdownlint, editorconfig-checker clean; LF preserved on the workflow YAML. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 1 + catalog/snippets/workflows/publish-plan-task.yml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/WORKFLOW.md b/WORKFLOW.md index 2431bb3b..9d2312a0 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -196,6 +196,7 @@ 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) diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml index 8eaef7f5..b5997d2c 100644 --- a/catalog/snippets/workflows/publish-plan-task.yml +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -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 From c81420d8e99f94f6b97945dfbd2e4dd8d978de24 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 16:30:57 -0700 Subject: [PATCH 15/35] AGENTS.md: add Verification Discipline; sweep prose on a behavior change (#356) (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #356, including the amendment. ## What lands **New `## Verification Discipline` section** in AGENTS.md, placed between `Documentation Style Conventions` and `PR Review Etiquette` - write it right, verify it, then review. It opens with the unifying property: **every failure below is green**, so no linter, status check, or review layer catches any of them. The six from the proposal, plus the amendment's review companion: 1. A test must assert the mechanism it names. 2. Gates, filters, and gate-like watchers fail loud, never narrow quietly. 3. Run the repo's whole lint gate before every push. 4. Editing CRLF files programmatically: `.` matches `\r`. 5. A green check is not evidence the work happened. 6. A workflow change is only fully exercised by CI. 7. A review flags an instance; fix the class. **Rule 7 from the amendment** - search for prose asserting the old behavior after changing it - goes under **`Documentation Style Conventions`** per your placement note, directly beside the present-tense rule as its maintenance counterpart: that rule governs *how to phrase* a doc, this one *how to keep it true* when the behavior underneath moves. **Placement judgment on the companion:** you grouped "fix the class" with rule 7 but did not place it. I put it in `Verification Discipline` rather than the docs section, because it generalizes past stale prose to any flagged defect class (a silent-narrowing pattern, a mis-worded contract). Happy to move it if you'd rather keep the pair together. ## Complementing rather than duplicating You named three adjacent rules; each generalization now **cross-references its instance** instead of restating it: - fail-loud -> `WORKFLOW.md` **D8.4** (the identity-allowlist gate, landed yesterday via #354) - whole-lint-gate -> "Running the Linters Locally" (which documents *how* to invoke each, not that **all** run) - green-check -> the `changes`-job note under Branching Model (its instance for that one job) - CRLF regex -> the Line Endings warning, whose mechanism it names I also folded the mirror failure into rule 4: a **text-mode rewrite** silently flattens CRLF to LF, the inverse of the `CRCRLF` case. I hit exactly that two days ago - a scripted edit flattened `spec/project-types.json` and CI caught it, not my local run, which is also rule 3 in action. ## README `Rules -> Always` gains the two that read as fleet-wide invariants rather than technique: run the whole lint gate before pushing, and make gates fail loud. Both are **bullets under an existing heading**, so the auto-generated Table of Contents needs no change (and is never hand-edited per its own rule). ## Validation `spec/validate.py`, markdownlint, CI-scoped cspell, editorconfig-checker: clean. CRLF preserved on both files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 13 +++++++++++++ README.md | 2 ++ 2 files changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e6b0f7a7..d4206249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ Clarify devcontainer setup steps in README - 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 @@ -154,6 +155,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. diff --git a/README.md b/README.md index 130b7f88..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 From 3f562c3552141bfcd22864bb15daaaa6a4306ab4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 16:51:13 -0700 Subject: [PATCH 16/35] Carried files must not reference the template repo; mechanize the check (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template is private, so any reference to it from a carried file 404s for that repo's readers and exposes template machinery a consumer should never have to see. Raised from downstream work where the references were being stripped by hand, repo by repo. ## Scope of the leak Swept every cataloged repo. **13 of 20 reference the template; 8 are public:** | repo | public | files | |---|---|---| | Utilities | yes | copilot-instructions, HISTORY | | LanguageTags | yes | copilot-instructions | | aiopurpleair | yes | copilot-instructions | | homeassistant-purpleair | yes | AGENTS, copilot-instructions | | PlexCleaner | yes | copilot-instructions | | VSCode-Server-DotNetCore | yes | copilot-instructions | | NxWitness | yes | AGENTS, copilot-instructions | | ESPHome-Config | yes | AGENTS | | Financial-Modeling / HomeAutomation-Config / KiCadLibrary / HomeAssistant-Config / MediaTools | no | assorted | The dominant instance is a stale `copilot-instructions.md` paragraph telling agents to report drift upstream, which **hard-links the private template twice** - and one link targets an `AGENTS.md#staying-in-sync-and-reporting-drift-upstream` section **the hub no longer has**, so it 404s even for someone with access. The hub itself carries neither the paragraph nor that section: this is stale carried content that outlived its source (#305 territory, and exactly the "prose asserting the old behavior" rule from #356). ## The rule #344 barred *sibling*-repo references but arguably let the template through as "the subject". `AGENTS.md` now says explicitly: > **This includes the template repo itself.** A carried file names it in neither prose nor a link ... Where a carried file needs to express a template-level behavior - "report a rule discrepancy upstream" - state the behavior, not the destination; the maintainer supplies the destination out of band. That preserves the agent-to-agent reporting flow (which is real and valuable) while keeping it out of a public file. ## Mechanized, not left to review This spread silently across 13 repos, so `spec/audit.py` now checks the two agent-instruction files for the template name and raises a drift finding. The template name is derived from **the git remote**, not the checkout directory name - a clone into a differently-named folder would otherwise stop detecting with no signal, which is the fail-open case the check exists to prevent (AGENTS.md "Verification Discipline": gates fail loud, never narrow quietly). ```text PlexCleaner -> DRIFT carried: .github/copilot-instructions.md references the template repo ... ESPHome-Config -> DRIFT carried: AGENTS.md references the template repo ... Vantage-Config -> clean ``` ## Downstream Not fixed here - the 13 carried copies need the reference removed in their own repos. Raising the delivery shape (per-repo issues vs. folding into each repo's existing conformance issue) rather than filing 13 issues unilaterally. `spec/validate.py`, markdownlint, editorconfig-checker: clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- spec/audit.py | 29 ++++++++++++++++++++++++++++- spec/project-types.json | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4206249..954660a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Clarify devcontainer setup steps in README ## Documentation Style Conventions -- **Carried files reference no other fleet repo unless that repo is the subject.** Every file the fleet carries verbatim - `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, `README.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md` - is **repo-agnostic**: it describes the shape or pattern self-containedly, names no sibling fleet repo as an illustrative example, and adds no link (inline URL or reference definition) to another fleet repo. This bans only *cross-repo* references - ordinary links to files within the same repo, to anchors, and to external sites are unaffected. Two reasons: any fleet repo may be **private**, so a cross-repo link 404s in a public carrier; and a cross-repo reference **couples** the repos and rots as they diverge. A repo name is allowed only when that repo *is* the subject (the file is about it). To point at a current good example, name it in the **onboarding/conformance issue** (one-off, not carried) or the hub-only `reports/conformance-matrix.md` (a fleet tracking table, not carried) - never in a carried doc. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. +- **Carried files reference no other fleet repo unless that repo is the subject.** Every file the fleet carries verbatim - `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, `README.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md` - is **repo-agnostic**: it describes the shape or pattern self-containedly, names no sibling fleet repo as an illustrative example, and adds no link (inline URL or reference definition) to another fleet repo. **This includes the template repo itself.** A carried file names it in neither prose nor a link: the template is private, so any reference 404s for a downstream repo's users, and the template flow is machinery a consumer of that repo should never have to see. Where a carried file needs to express a template-level behavior - "report a rule discrepancy upstream" - state the behavior, not the destination; the maintainer supplies the destination out of band. This bans only *cross-repo* references - ordinary links to files within the same repo, to anchors, and to external sites are unaffected. Two reasons: any fleet repo may be **private**, so a cross-repo link 404s in a public carrier; and a cross-repo reference **couples** the repos and rots as they diverge. A repo name is allowed only when that repo *is* the subject (the file is about it). To point at a current good example, name it in the **onboarding/conformance issue** (one-off, not carried) or the hub-only `reports/conformance-matrix.md` (a fleet tracking table, not carried) - never in a carried doc. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. ### Markdown diff --git a/spec/audit.py b/spec/audit.py index e01f8861..829004a2 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -40,6 +40,22 @@ 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. @@ -183,6 +199,14 @@ 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?)")) + # --- 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), @@ -257,7 +281,10 @@ def main(): 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}\n") + 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: diff --git a/spec/project-types.json b/spec/project-types.json index 6e3966bd..6cd13b8d 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -144,7 +144,7 @@ { "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.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, CODESTYLE.md, WORKFLOW.md, README.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) names no sibling fleet repo as an illustrative example and adds no link (inline URL or reference definition) to another fleet repo; only cross-repo references are banned - same-repo file/anchor links and external links are fine. A repo name appears only where that repo is the file's subject. Rationale: a cross-repo link 404s in a public carrier when the target is private, and couples the repos. A current example is named in the onboarding/conformance issue or the hub-only reports/conformance-matrix.md, never a carried doc.", "intentRef": "AGENTS.md#documentation-style-conventions" } + { "id": "recurring.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, CODESTYLE.md, WORKFLOW.md, README.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) names no other fleet repo as an illustrative example and adds no link (inline URL or reference definition) to one - including the template repo itself, which is private, so a reference 404s for the carrying repo's readers and exposes template machinery they cannot follow; state the behavior, not the destination. spec/audit.py mechanically checks the two agent-instruction files for the template name; only cross-repo references are banned - same-repo file/anchor links and external links are fine. A repo name appears only where that repo is the file's subject. Rationale: a cross-repo link 404s in a public carrier when the target is private, and couples the repos. A current example is named in the onboarding/conformance issue or the hub-only reports/conformance-matrix.md, never a carried doc.", "intentRef": "AGENTS.md#documentation-style-conventions" } ] }, "readme-structure": { From 5275005939a2c1719d7bb1aa23f3974b29a28435 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 20:23:58 -0700 Subject: [PATCH 17/35] Adopt the two prose/comment style rules (#357 item 2) (#361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts **item 2** of #357, in the sections the owner chose. Item 1 is #360. ## Comments: no header summary blocks > **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. Placed in `AGENTS.md` "Comments", which already bans design narrative, rule citations, and verbosity creep - a header summary is the same failure at file scope: it restates the declaration below it and goes stale as the file grows. **One carve-out added:** a licence or provenance header that a tool or policy requires is not a summary, so it is unaffected. That was the only real objection to a blanket ban. ## Character Set: no clause-joining semicolons > **No semicolon joining two independent clauses in agent-authored prose** - documentation, comments, commit messages, and PR descriptions. Placed beside the ASCII/em-dash rules, as the same class of house-style constraint on agent prose habits. **Written to the proposal's actual scope.** It says "as sentence separators", which is narrower than all semicolons, so the rule bans the clause-joining use and explicitly preserves two legitimate ones: a semicolon separating items in a list that already contains commas, and a statement terminator in code. ## On sweeping existing prose I measured before writing the rule, because a rule the hub immediately violates is the trap this repo hit with `set -Eeuo` (#338/#350): ```text AGENTS.md 79 WORKFLOW.md 80 CODESTYLE.md 33 AUDIT.md 22 copilot-instructions 16 README.md 9 STANDUP.md 5 repo-config/README 4 TOTAL 248 prose lines containing a semicolon ``` Not all are clause-joining - many are list separators the rule preserves - but a full sweep would still be a sprawling rewrite of dense governance prose with real risk of changing meaning in text that is load-bearing. So the rule states that **existing prose is corrected as each file is next edited, not swept**. That matches how the fleet already handles propagation debt, and it is honest about the hub's current state rather than pretending compliance. Flagging plainly: this changes my own output style going forward, and the 248 lines are a known, deliberate residue rather than an oversight. `spec/validate.py`, markdownlint, editorconfig-checker: clean. CRLF preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 954660a7..93b28530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,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. @@ -133,6 +134,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. From 8f13782fee56934e5e7a568ed46af44db2984125 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 20:26:15 -0700 Subject: [PATCH 18/35] Scrub template attributions from carried prose (#357 item 1) (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses **item 1** of #357. Item 2 (the two prose/comment style proposals) is deliberately not in this PR - it is a house-style decision I am putting to the owner separately. ## The gap this closes #359 fixed the **hard** leak: a carried file naming or linking the private template, which 404s for that repo's readers. It also mechanized a check for it. A downstream agent caught the **soft** half, which that check cannot see because it greps for the repo name: carried prose that attributes a rule to *"the template"*. > "A repo owner may widen their own CI file list, but **the template ships** README + HISTORY as the default" > "Guarantees say 'default branch' portably; **the template implements it** as the literal `main`" No repo is named, so nothing 404s - but it still exposes a coordinator the reader cannot reach, and it frames the repo's own rule as someone else's. Genuinely good catch; the durable fix belongs here, as the filer said, or it returns on every reconvergence. ## What changed Eleven attributions across `AGENTS.md`, `CODESTYLE.md`, and `WORKFLOW.md` now state the rule directly: | before | after | |---|---| | "the template ships README + HISTORY as the default" | "README + HISTORY are the default" | | "the template's default Python profile" | "the default Python profile" | | "the template uses `client-id: ...`" | "use `client-id: ...`" | | "the template implements it as the literal `main`" | "it is implemented as the literal `main`" | | "the template constrains only that the output be input-deterministic" | "the constraint is only that the output be input-deterministic" | | "not when it matches the template byte for byte" | "not when it matches a reference implementation byte for byte" | | "the template ships the tracker but not this consumer wiring" | "the tracker ships without this consumer wiring" | | "by exporting the template's two rulesets" | "by importing the committed `repo-config/*.json` ruleset payloads" | | "Because the template has no such leaf" | "Because no such leaf ships" | Behavior is unchanged throughout; only the framing moves from *reported* to *owned*. **One occurrence remains by necessity:** the rule that bans the practice (`AGENTS.md` "This includes the template repo itself"). It cannot be stated without referring to the concept, and it names no repo and gives no location. Two later passes caught what the first regex missed - "this template" (AGENTS.md, CODESTYLE.md) and "the `release` template" (WORKFLOW.md). Remaining matches for the word are different meanings, not attributions: Serilog structured message templates, and the `reports/_template.md` filename. ## Note on the mechanical check `spec/audit.py` greps for the repo **name**, so it catches the hard leak but not this class - phrasing is a judgment call and a keyword sweep for "the template" would false-positive on legitimate uses (this PR's own rule statement, for one). Flagging that limitation rather than pretending the check covers it. `spec/validate.py`, markdownlint, editorconfig-checker: clean. CRLF preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 8 ++++---- CODESTYLE.md | 6 +++--- WORKFLOW.md | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 93b28530..049f75c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,17 +38,17 @@ The specific rules in this file implement a few governing principles. Read these - *Main:* the check is graph-based - it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds - rebasing develop onto main, or rewriting develop's history - exist but contradict the squash-only develop ruleset and the linearity invariant. - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status job` still gates merges - the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - See [`repo-config/README.md`](./repo-config/README.md) "Rulesets" for the configured state. -- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (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. The full export/import procedure and the regen snippet live in the hub-only `docs/repo-config-carry.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 - the maintainer-only brownfield migration procedure is in that same hub doc. +- **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 .../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. The full export/import procedure and the regen snippet live in the hub-only `docs/repo-config-carry.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 - the maintainer-only brownfield migration procedure is in that same hub doc. - **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 [`repo-config/README.md`](./repo-config/README.md) "Secrets" for which secrets each mechanism needs. +- **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. 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"). - **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. diff --git a/CODESTYLE.md b/CODESTYLE.md index 742506c4..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 @@ -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 `). diff --git a/WORKFLOW.md b/WORKFLOW.md index 9d2312a0..c0471cd2 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. @@ -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** (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 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`. ## 4. Behavioral Contract - Expected Outcomes @@ -160,7 +160,7 @@ 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 @@ -273,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. +- **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.** 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]. +- **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, `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]. From 7e97f1489e5f29e7a5e84b646ca3f96ae382e11c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 18 Jul 2026 20:49:32 -0700 Subject: [PATCH 19/35] State the cross-repo rule by intent, not mechanism (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a downstream session: Copilot insisted on removing a `README.md` link pointing to a related device-builder repo, citing the no-cross-repo-link rule. That link was legitimate content. The rule I wrote in #344/#359 made that misreading reasonable, so this fixes the rule rather than the reviewer. ## Two defects in the rule as written **1. It led with the mechanism and buried the carve-out.** The sentence opened with "adds no link (inline URL or reference definition) to another fleet repo" and only qualified it fifteen lines later with "unless that repo is the subject". Read top-down, the ban looks absolute. **2. It claimed `README.md` is carried verbatim. It is not.** `spec/files.json` gives `README.md` neither `whole: true` nor `sections`, so it is a per-repo file governed only by `readme-structure.md`. A repo's own README is its own content. The rule never should have reached it, which is exactly where the over-application landed. ## Restated by intent Two things banned in carried files: - **Any reference to the template repo**, prose or link. Private, so it 404s, and the coordination flow is machinery a consumer should not see. - **A sibling fleet repo named as an illustrative example** ("repo X does it this way", "see repo Y's adoption"). That couples repos and rots as they diverge. And explicitly permitted: > **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. Plus the scope correction: the rule governs carried template content, and a repo's own `README.md` and topical docs are outside it. `spec/project-types.json`'s `recurring.norepoxref` is reworded to match. ## The mechanical check needed no change `spec/audit.py` greps only for the **template name** in the two agent-instruction files. It never looked for sibling links, so it was not the source of the over-application and its behavior is unchanged - re-verified against PlexCleaner (flags) and Vantage-Config (clean). `spec/validate.py`, markdownlint, editorconfig-checker: clean. *Downstream note: ESPHome-NonRoot is amending its local AGENTS.md for the same reason. Once this lands, its next carry converges on this wording.* 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- spec/project-types.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 049f75c4..253b97e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Clarify devcontainer setup steps in README ## Documentation Style Conventions -- **Carried files reference no other fleet repo unless that repo is the subject.** Every file the fleet carries verbatim - `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, `README.md`, `.github/copilot-instructions.md`, the `repo-config/` and `spec/` files, the carried `AUDIT.md` - is **repo-agnostic**: it describes the shape or pattern self-containedly, names no sibling fleet repo as an illustrative example, and adds no link (inline URL or reference definition) to another fleet repo. **This includes the template repo itself.** A carried file names it in neither prose nor a link: the template is private, so any reference 404s for a downstream repo's users, and the template flow is machinery a consumer of that repo should never have to see. Where a carried file needs to express a template-level behavior - "report a rule discrepancy upstream" - state the behavior, not the destination; the maintainer supplies the destination out of band. This bans only *cross-repo* references - ordinary links to files within the same repo, to anchors, and to external sites are unaffected. Two reasons: any fleet repo may be **private**, so a cross-repo link 404s in a public carrier; and a cross-repo reference **couples** the repos and rots as they diverge. A repo name is allowed only when that repo *is* the subject (the file is about it). To point at a current good example, name it in the **onboarding/conformance issue** (one-off, not carried) or the hub-only `reports/conformance-matrix.md` (a fleet tracking table, not carried) - never in a carried doc. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. +- **Carried files carry no coordination references.** In the files the fleet carries - `AGENTS.md`, `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/conformance issue or the hub-only `reports/conformance-matrix.md`, 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 diff --git a/spec/project-types.json b/spec/project-types.json index 6cd13b8d..c47cff5a 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -144,7 +144,7 @@ { "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.norepoxref", "verdict": "intent", "assert": "A carried file (AGENTS.md, CODESTYLE.md, WORKFLOW.md, README.md, .github/copilot-instructions.md, repo-config/README.md, repo-config/develop.json, repo-config/main.json, spec/secrets.json, the carried AUDIT.md) names no other fleet repo as an illustrative example and adds no link (inline URL or reference definition) to one - including the template repo itself, which is private, so a reference 404s for the carrying repo's readers and exposes template machinery they cannot follow; state the behavior, not the destination. spec/audit.py mechanically checks the two agent-instruction files for the template name; only cross-repo references are banned - same-repo file/anchor links and external links are fine. A repo name appears only where that repo is the file's subject. Rationale: a cross-repo link 404s in a public carrier when the target is private, and couples the repos. A current example is named in the onboarding/conformance issue or the hub-only reports/conformance-matrix.md, never a carried doc.", "intentRef": "AGENTS.md#documentation-style-conventions" } + { "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": { From 8cef2f66768a1053d0f4b7298cee72bc021f6a04 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 11:37:43 -0700 Subject: [PATCH 20/35] Agent write-safety: repo-scope + never-fabricate-id + never-suppress-write rules, runbook fix, and a portable enforcement kit (#364/#365/#366) (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the cross-repo comment incident (#364, #365, #366): an autonomous PR-review loop hand-fabricated a GraphQL thread node id and fired a reply mutation with output suppressed (`>/dev/null 2>&1 || true`). Because node ids resolve globally, the guessed id landed on a real thread in a **stranger's repo** (`mankatcheung/job-finder` #15) and the write **succeeded under the maintainer's identity**, while the suppressed output read as a harmless failure. Three root causes -> three rules, applied in three guard layers. ## 1. Carried template rule (travels to the fleet) New `AGENTS.md` section **`## Repository Boundaries and Write Safety`** (after Foundational Principles, beside the existing never-force-push safety rules), added to the carried-sections allowlist in `spec/files.json`: - **Write only to the current project's own repository** - a broad identity is capability, not permission. - **Never fabricate, guess, or reuse an identifier passed to a write** - capture every id from a live query in the same session. - **A write is never a probe, and its output is never suppressed** - a write that appears to fail is verified, not assumed harmless. ## 2. Runbook fix (`.github/copilot-instructions.md`, carried whole) The reply/resolve example **modeled the footgun**: a disembodied `-F threadId="PRRT_..."` literal disconnected from the `reviewThreads(first:100)` query above it. Rewritten to capture `$TID` from that live query with an empty-result guard, use `-F threadId="$TID"`, show the mutation output, and confirm `isResolved` before closing. Added an intra-doc cross-reference to the new section. (The `requestReviews` example already captured its ids from live queries.) ## 3. Portable per-machine safety kit (`host-setup/agent-safety/`, not carried) The prose layers are necessary but not sufficient - the incident happened *under* a "Verification Discipline" section. This adds the mechanical backstop and makes it deployable everywhere as the first thing on a new system: - **`gh-write-guard.py`** - a PreToolUse hook that DENIES the three write footguns (suppressed-output mutation, literal-node-id mutation, cross-origin explicit target). Reads pass through. It fires even in autonomous / bypass-permissions sessions - the exact condition of the incident. `--selftest` runs an 11-case decision matrix. - **`claude-md-safety.md`** - the same rules as host-scope guidance, appended into `~/.claude/CLAUDE.md` so every session on a machine inherits them, including ad-hoc work outside any project. - **`install.py`** + thin `install.sh` / `install.ps1` wrappers - one tested cross-platform installer: self-tests the hook before registering it, merges the settings.json entry without clobbering other keys, updates the CLAUDE.md block in place. Fully idempotent. This is the rollout #365 tracks; its per-machine checklist becomes "run `install.sh` / `install.ps1`". ## Deliberately out of scope - **Credentials** stay out-of-band (per owner decision): a fine-grained PAT limited to owned repos is stronger structural defense but is per-machine credential setup, not a carried rule. - **No `spec/audit.py` change**: section carriage is intent-checked, not mechanized; a literal-id grep on a whole-carried runbook is fragile. Raised separately: "Verification Discipline" is also absent from the carried-sections allowlist - a follow-up, not bundled here. ## Verification (driven, not assumed) - Hook `--selftest`: 11/11 incl. the exact incident (deny), the empty-id guard idiom (allow), reads (allow). - Real stdin path emits `hookSpecificOutput`/`permissionDecision: "deny"` for the incident command; reads and non-Bash tools pass through; malformed input never interferes. - Installer: fresh install, same-version re-run (idempotent), and version-upgrade replace-in-place all verified against throwaway homes; `settings.json` merge preserves other keys. - `spec/validate.py`, markdownlint (whole-repo `**/*.md`), editorconfig-checker: clean. **Note on the section-name/anchor lockstep:** the copilot-instructions cross-reference `#repository-boundaries-and-write-safety` and the `spec/files.json` `sections` entry both depend on the section title; rename them together if ever changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .editorconfig | 6 + .gitattributes | 5 +- .github/copilot-instructions.md | 31 ++- AGENTS.md | 8 + .../agent-safety/.markdownlint-cli2.jsonc | 8 + host-setup/agent-safety/README.md | 72 ++++++ host-setup/agent-safety/claude-md-safety.md | 9 + host-setup/agent-safety/gh-write-guard.py | 224 ++++++++++++++++++ host-setup/agent-safety/install.ps1 | 30 +++ host-setup/agent-safety/install.py | 108 +++++++++ host-setup/agent-safety/install.sh | 19 ++ spec/files.json | 2 +- 12 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 host-setup/agent-safety/.markdownlint-cli2.jsonc create mode 100644 host-setup/agent-safety/README.md create mode 100644 host-setup/agent-safety/claude-md-safety.md create mode 100644 host-setup/agent-safety/gh-write-guard.py create mode 100644 host-setup/agent-safety/install.ps1 create mode 100644 host-setup/agent-safety/install.py create mode 100755 host-setup/agent-safety/install.sh 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..fd0af4be 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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/AGENTS.md b/AGENTS.md index 253b97e9..4b9d980c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,14 @@ The specific rules in this file implement a few governing principles. Read these - **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. 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..05896972 --- /dev/null +++ b/host-setup/agent-safety/install.py @@ -0,0 +1,108 @@ +#!/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() + existing = claude_md.read_text(encoding="utf-8") if claude_md.exists() else "" + 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_text(updated, encoding="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/spec/files.json b/spec/files.json index b622105d..fb9760c3 100644 --- a/spec/files.json +++ b/spec/files.json @@ -2,7 +2,7 @@ "$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.", "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": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "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": "README.md", "appliesTo": "*" }, From 426591f16582eb5f1cfb45a2941a5f9a959077f8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 14:32:36 -0700 Subject: [PATCH 21/35] Add a scope model and selector mechanism for carried rules (#368, PR-1) (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What and why First of four staged PRs for #368: give every governance rule a single explicit **scope**, so carried docs become granular single-scope pieces composed per repo rather than large pieces with internal carve-outs a reader must piece out. This PR is the **inert foundation** — it adds the mechanism and documents the model, with **zero fleet-finding delta**. Policy changes (turning on section-presence checking, the doc surgery, carrying Verification Discipline) follow in later PRs, each held before `main`. ## Changes - **`spec/scope-model.md`** (new, hub-only, not in the carried baseline): the two axes — **home** (host vs repo) and **reach** (hub-only / all-downstream / type-specific) — and the `appliesTo` **selector vocabulary**: four disjoint namespaces (project types, `workflowModel`, `releaseTrigger`, `consumerModel`). States the load-bearing semantics: `appliesTo` is **any-of** (cross-axis AND is deliberately not expressible — that's the signal to split a piece), and entry-level AND section-level `appliesTo` compose. Records `CODESTYLE.md`'s section→scope mapping (whole-carried, so documented not mechanized). - **`spec/files.schema.json`**: a `sections` element may be a bare string **or** a `{name, appliesTo}` object (`oneOf`). Backward-compatible — every existing entry stays valid. - **`spec/audit.py`**: match `appliesTo` against a repo's full selector set (`repo_selectors`), and **replace the hardcoded operational `develop.json` path-swap** with two data entries in `files.json` (`appliesTo: ["release"]` vs `["operational"]`). This is the proof that the selector model subsumes an existing ad-hoc carve-out. - **`spec/validate.py`**: first pass over `files.json` — every `appliesTo` token must resolve to a known selector, no project type may collide with a reserved token (keeps the namespaces disjoint), and every cataloged repo must declare `consumerModel` so a `push`/`pull`-scoped section can't fail open. - **`AUDIT.md` §3 + `files.json` note**: one-line pointers to the scope model. ## Inertness — verified The four selector namespaces are disjoint and no current `appliesTo` token is non-`*`, so broadening the matcher changes no match. Proven by computing the required-file set for **all 21 cataloged repos** under old-code+old-data vs new-code+new-data: **0 differences**. `spec/validate.py` stays green (and now also validates `files.json`). `markdownlint`, `editorconfig-checker` clean. Held on `develop` — no `develop → main` promotion — per the standing instruction to observe what returns to develop first. --------- Co-authored-by: Claude Opus 4.8 --- AUDIT.md | 3 ++ spec/audit.py | 35 ++++++++++++++++-- spec/files.json | 5 ++- spec/files.schema.json | 20 +++++++++- spec/scope-model.md | 46 +++++++++++++++++++++++ spec/validate.py | 83 ++++++++++++++++++++++++++++++++++++++---- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 spec/scope-model.md diff --git a/AUDIT.md b/AUDIT.md index 9fa88390..43417fe7 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -31,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: @@ -152,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/spec/audit.py b/spec/audit.py index 829004a2..d6945563 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -84,6 +84,33 @@ 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) + + def audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -230,14 +257,14 @@ def audit_repo(entry, spec): findings.append(("DRIFT", f"dependabot: {eco} ecosystem not declared though {why}; add it for both main and develop per the fleet norm")) # --- File 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. + sel = repo_selectors(entry, spec["registry"].get("defaults", {})) seen_paths = set() 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: continue seen_paths.add(path) diff --git a/spec/files.json b/spec/files.json index fb9760c3..7d3fd379 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,6 +1,6 @@ { "$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 checks presence (letter) and equivalence (intent); 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.", "baseline": [ { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "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": "*" }, @@ -14,7 +14,8 @@ { "path": "cspell.json", "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/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["release"] }, + { "path": "repo-config/operational/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["operational"] }, { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, { "path": "AUDIT.md", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": "spec/secrets.json", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, diff --git a/spec/files.schema.json b/spec/files.schema.json index 7013e1a7..aafc5de1 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -16,11 +16,27 @@ "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 } } } } 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/validate.py b/spec/validate.py index 6787404e..28b4cf19 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -12,6 +12,13 @@ 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") + def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) @@ -90,11 +97,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 +133,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 +191,51 @@ 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", "?") + check_selector(path, item.get("appliesTo", "*")) + 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: From e516e90a685c4e6d663750e07e9cb497fb5f7c64 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 14:57:41 -0700 Subject: [PATCH 22/35] Check carried markdown section presence at DRIFT (#368, PR-2) (#370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What and why Second of the staged PRs for #368. PR-1 gave sections a scope; this turns on the **section-presence check** in the audit. Previously `spec/audit.py` verified only file-**path** presence — `sections` were never read, so a repo carrying an `AGENTS.md` missing half its required sections passed clean. ## Change - `required_sections(item, sel)` — the sections a repo must carry from a baseline entry, filtered by each section's own `appliesTo` (a bare-string section is `*`; an object section carries its selector). - `heading_texts(md)` — case-insensitive heading extraction. - The file-presence loop unions required sections per path, and for a **markdown** file that exists, reports a **DRIFT** for any required section heading that is missing. ## Why DRIFT, not LETTER A heading rename reads as "missing," and equivalence is judged by hand (AUDIT.md §7). DRIFT surfaces in every report and blocks a "clean" verdict but does **not** flip the exit code (only DEFECT/LETTER/ERROR do), so this is safe to turn on fleet-wide. Non-markdown "sections" (e.g. the tasks.json task group) are intent markers, not headings, so the heading check is gated to `.md` files. ## Verified - **File-presence behavior unchanged:** the checked-path list is identical for all 21 cataloged repos, so this only *adds* section DRIFT findings — it never changes a LETTER finding or the exit code. - Hub's own `AGENTS.md` passes clean (all 8 required sections present); a doc missing a heading is flagged; section-level `appliesTo` filtering resolves correctly (ready for PR-3's scoped sections). Held on `develop` — no `develop → main` promotion. The fleet triage (running the live audit to see which repos' `AGENTS.md` drift) happens after merge. --------- Co-authored-by: Claude Opus 4.8 --- AUDIT.md | 2 +- spec/audit.py | 66 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 43417fe7..13b744f8 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -10,7 +10,7 @@ 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, 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. ## 1. Scope and Ground-Truth Branch diff --git a/spec/audit.py b/spec/audit.py index d6945563..82999f32 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -3,10 +3,11 @@ 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-side @@ -111,6 +112,28 @@ def applies(applies_to, sel): 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} + + def audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -256,20 +279,43 @@ def audit_repo(entry, spec): 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 presence on the ground-truth branch --- + # --- 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", {})) - seen_paths = set() + wanted_sections = {} # path -> set of required section names, unioned across applicable entries + path_order = [] for item in spec["files"]["baseline"]: if not applies(item.get("appliesTo", "*"), sel): continue path = item["path"] - if path in seen_paths: - continue - seen_paths.add(path) - if gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) is None: + if path not in wanted_sections: + wanted_sections[path] = set() + path_order.append(path) + wanted_sections[path].update(required_sections(item, sel)) + for path in path_order: + content = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) + if content is None: findings.append(("LETTER", f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)")) + continue + # 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"): + body = content.get("content") + if not body: + # 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(base64.b64decode(body).decode("utf-8", "replace")) + 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 From 8578e12d6215105a435e3814a4c247f3be1f5540 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 15:58:08 -0700 Subject: [PATCH 23/35] Extract operational-repo rules into one scoped section (#368, PR-3) (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What and why Third of the staged PRs for #368 — the first doc surgery. The `operational`-vs-`release` carve-out was scattered inline across **Foundational Principles**, **Branching Model**, and **Release Model**, so those all-downstream sections mixed two scopes a reader (and the audit) had to piece apart. This makes the operational rules one granular single-scope piece. ## Change - **New `## Operational Repositories` section** consolidates the operational workflow rules — direct-to-`develop`, advisory CI, the `develop`/`main` ruleset differences, dispatch-only release, dependabot/merge-bot on every tier. It is tagged **`appliesTo: ["operational"]`** in `spec/files.json`, so PR-2's section-presence check requires it only on the 4 operational repos. - The three inline carve-outs slim to one-line pointers, leaving Foundational, Branching, and Release single-scope (the `release` model). **No operational detail is lost** — it all moves into the new section. - The operational **line-ending** rule stays in `## Line Endings` (findable where EOL rules live) with a cross-pointer, rather than moving into the new section. ## Scope Deliberately just the operational extraction. The other two contaminants from the plan — hub-only pointers inside carried sections, and the csharp/python/docker examples baked into Release Model / Workflow YAML — are separate follow-up PRs, to keep each a small composable piece. ## Verified - Operational repos require the new section; release repos do not. - The hub's own `AGENTS.md` is complete (no missing sections in either view). - `spec/validate.py`, `markdownlint`, `editorconfig-checker` clean. Once merged, the 4 operational repos will DRIFT on this section until they adopt it — the intended convergence signal, not a regression. Held on `develop` — no `main` promotion. --------- Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 19 ++++++++++++++++--- spec/files.json | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b9d980c..41c01588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ 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". (The auto-publish rules below describe `release` repos only; **operational** repos - registry `workflowModel: operational` - commit directly to `develop`, run no codegen or auto-publish bots, and cut a GitHub release **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`) - never automatically. Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier, operational included. 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. @@ -34,7 +34,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her ## 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. @@ -56,7 +56,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her ## Release 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. 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. @@ -79,6 +79,19 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - **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: diff --git a/spec/files.json b/spec/files.json index 7d3fd379..6dc60d0f 100644 --- a/spec/files.json +++ b/spec/files.json @@ -2,7 +2,7 @@ "$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. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", "baseline": [ - { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "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": "AGENTS.md", "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", "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": "README.md", "appliesTo": "*" }, From ae8689cec7b777683c3320e059f02fb777e71b99 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 16:12:28 -0700 Subject: [PATCH 24/35] Remove hub-only doc pointers from carried sections (#368, PR-3b) (#372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR-3 (#368). The carried **Branching Model** and **Documentation Style Conventions** sections pointed at hub-only docs a downstream reader cannot follow. - **Branching Model:** drop the two references to the hub-only `docs/repo-config-carry.md` (the export/import procedure, and the brownfield migration procedure). The behavioral content stays — import the committed `repo-config/*.json` payloads, `configure.sh` selects the payload, brownfield repos temporarily disable the ruleset to re-sign. The full setup procedure already lives in the hub-only `STANDUP.md`. - **Documentation Style:** drop "or the hub-only `reports/conformance-matrix.md`" from the no-coordination-references rule; the onboarding or conformance issue remains the destination. The one `reports/conformance-matrix.md` reference that stays is inside the **hub-only** Repository Onboarding section, so it is correctly not a carried contaminant. Deliberately minimal — `configure.sh` (also hub-only) and the onboarding-flavored setup mechanics in that Branching bullet are left for a possible follow-up. Held on `develop`. --------- Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 41c01588..f29d4029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her - *Main:* the check is graph-based - it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds - rebasing develop onto main, or rewriting develop's history - exist but contradict the squash-only develop ruleset and the linearity invariant. - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status job` still gates merges - the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - See [`repo-config/README.md`](./repo-config/README.md) "Rulesets" for the configured state. -- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (rulesets are the *only* mechanism used), then create **exactly two rulesets named `develop` and `main`** by importing the committed `repo-config/*.json` ruleset payloads via `gh api -X POST .../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. The full export/import procedure and the regen snippet live in the hub-only `docs/repo-config-carry.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 - the maintainer-only brownfield migration procedure is in that same hub doc. +- **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. @@ -126,7 +126,7 @@ 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/conformance issue or the hub-only `reports/conformance-matrix.md`, never in a carried doc. **A contextually relevant link to a related project is not a coordination reference, and is expected.** Where another repo is part of this repo's subject matter - the image that consumes this config, the builder that generates this hardware, a library this depends on - link it normally. The test is whether the link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. This rule governs carried template content. A repo's own `README.md` and topical docs are its own content, not carried verbatim, and it does not reach them. This pairs with the present-tense rule below: state the current shape, not a history of which repo it came from. +- **Carried files carry no coordination references.** In the files the fleet carries - `AGENTS.md`, `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 From 5dab679956cb2c71a3e544f44e28efd2fbb23613 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 16:52:07 -0700 Subject: [PATCH 25/35] Carry the Verification Discipline section to the fleet (#368, PR-4) (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the original #368 question. **Verification Discipline** is clean all-downstream guidance (test-asserts-mechanism, gates-fail-loud, run-all-linters, green-is-not-proof, fix-the-class), so it joins the carried `AGENTS.md` sections allowlist in `spec/files.json`. Its two cross-references (WORKFLOW.md D8.4, the `changes`-job under Branching Model) point at other **carried** content, so no genericizing is needed. The section-presence check (PR-2) now requires it fleet-wide — a repo whose `AGENTS.md` predates it shows a **DRIFT** until it adopts the section, the intended convergence signal. Verified: required for both release and operational selectors, the hub's own `AGENTS.md` already has it, `validate.py` clean. One-line change. Held on `develop`. Closes the scope-model doc-surgery series (PR-1 mechanism, PR-2 section-presence, PR-3 operational extraction, PR-3b hub-pointer removal, PR-3c assessed and skipped, PR-4 this). The fixed-vs-customizable / per-unit-fidelity verification is the next design. Co-authored-by: Claude Opus 4.8 --- spec/files.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/files.json b/spec/files.json index 6dc60d0f..1063bfac 100644 --- a/spec/files.json +++ b/spec/files.json @@ -2,7 +2,7 @@ "$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. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", "baseline": [ - { "path": "AGENTS.md", "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", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, + { "path": "AGENTS.md", "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", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, { "path": "WORKFLOW.md", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, { "path": "README.md", "appliesTo": "*" }, From aeab0c3e686987147926ee870e4a9fff45c704c8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 18:58:23 -0700 Subject: [PATCH 26/35] Add the per-unit fidelity vocabulary (inert) (#374, PR-A) (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the fidelity series (#374). Adds a second per-unit axis to the carry model: **fidelity** — how faithfully carried content must survive — orthogonal to the `appliesTo` **scope** (which repos get it). This PR is the **inert foundation**; the engines that consume it follow. ## Changes - **`spec/files.schema.json`** — the `fidelity` enum (`presence | intent | verbatim | interface`) + the optional `contract` object (the interface engine's spec, used in PR-B). - **`spec/files.json`** — note cross-links the fidelity model; each entry gets an explicit `fidelity`: `verbatim` on `.markdownlint-cli2.jsonc`, `intent` on the whole-carried governed configs/docs, `presence` (default) on the rest. - **`spec/validate.py`** — enforces the vocabulary: valid enum, `contract` only on (and required for) an `interface` unit, and a `verbatim` unit's canonical source must resolve. - **`spec/fidelity-model.md`** (new, hub-only, sibling to `scope-model.md`) — the fixed-vs-overridable contract, the four levels, why each unit sits where, the workflow override-seam contract, placeholder normalization, and the **hash-not-version** stale-vs-violated semantics. ## Inert `audit.py` is untouched and no baseline path is added, so the required-file set and every finding are identical — this PR only adds capability and documents the model. `validate.py` stays green and now also enforces fidelity; markdownlint/editorconfig-checker clean. Held on `develop`. Fidelity levels: **presence** (exists), **intent** (carried faithfully, judged by meaning), **verbatim** (byte-identical to canonical after placeholder normalization → content-hash), **interface** (honors a named contract by name/wiring, body owned). PR-B adds the interface engine + workflow entries; PR-C adds the verbatim engine. --------- Co-authored-by: Claude Opus 4.8 --- spec/fidelity-model.md | 44 +++++++++++++++++++++++++++++++++ spec/files.json | 32 ++++++++++++------------ spec/files.schema.json | 15 +++++++++++- spec/validate.py | 55 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 spec/fidelity-model.md diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md new file mode 100644 index 00000000..a354fed7 --- /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 declared-placeholder normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file or a stable-handle region (a markdown section by heading, a workflow job 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. + +## Placeholder Normalization + +A verbatim check normalizes only the tokens a unit **declares** in its `placeholders` list, never a blanket `<...>` regex. The declared tokens are literal strings (for example ``, ``, ``), so masking touches exactly those and leaves intact the sibling metavariables a doc uses in prose (for example ``, ``). Line endings are neutralized before hashing, because EOL variance is governed by the line-ending rules, not a fidelity deviation. + +## Stale Versus Violated + +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 1063bfac..fd23f6cb 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,28 +1,28 @@ { "$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. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", + "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": ["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", "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": ["release"] }, - { "path": "repo-config/operational/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["operational"] }, - { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, - { "path": "AUDIT.md", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, - { "path": "spec/secrets.json", "intentRef": "docs/repo-config-carry.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": "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": ".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 aafc5de1..6cc49e64 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -36,7 +36,20 @@ "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } + "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/validate.py b/spec/validate.py index 28b4cf19..668d6b2c 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -18,12 +18,20 @@ 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") @@ -224,8 +232,53 @@ def check_selector(where, applies_to): if not isinstance(item, dict): errors.append(f"files.json: baseline entry {item!r} is not an object") continue - path = item.get("path", "?") + 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") From 7e5f18764d0480f8ef39a5e6fc16cf95ea5d0cf4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 22:02:47 -0700 Subject: [PATCH 27/35] Add the interface engine - verify a workflow's contract by name (#374, PR-B) (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of the fidelity series (#374). Consumes `fidelity: interface`: the audit now verifies a carried workflow honors its **fixed contract by name and wiring**, never the owned body — the virtual-function signature check. ## Change - **`spec/audit.py`** - `split_jobs(text)` — slices a workflow into `{job_key: block}` by the jobs-level indent, no YAML parser (stdlib), mirroring `heading_texts`. - `check_interface(path, contract, text)` — required job keys, the ruleset-bound check `name:`, the `release-asset--` handoff, and per-job required (`pattern:`/`merge-multiple:`) and forbidden (`artifact-ids:`) tokens. **All findings DRIFT.** - An `interface` unit's **absence** is DRIFT too (not a hard LETTER) — a workflow's naming varies more than a carried config, so everything here is advisory. - `--selftest` (no network) — 7 fixture cases. - **`spec/files.json`** — two workflow `interface` entries: `test-pull-request.yml` (`appliesTo: "*"`, the universal `check-workflow-status` job + its ruleset-bound name) and `build-release-task.yml` (build types, the `get-version`/`github-release` jobs + artifact handoff + no-`artifact-ids:` rule). - **`AUDIT.md`** — notes the new check, and fixes pre-existing clause-joining semicolons in this file's comments/docstrings so they don't fester. ## Verified - `python3 spec/audit.py --selftest` → **7/7** (conformant, missing job+name, renamed check, artifact-ids fork, missing merge-multiple, owned extra-leaf-job no-finding, split_jobs). - **Positive control:** the hub's own canonical `test-pull-request.yml` and `build-release-task.yml` are **conformant** (0 findings). - `validate.py` green (contracts pass PR-A's value-type guards), lints clean. Scoping of `build-release-task.yml` is best-effort (excludes source-only/docs to avoid false "absent" DRIFTs); any mis-scope only yields advisory DRIFTs to triage. It refines as downstreams are audited. Held on `develop`. PR-C adds the verbatim engine. --------- Co-authored-by: Claude Opus 4.8 --- AUDIT.md | 2 +- spec/audit.py | 191 ++++++++++++++++++++++++++++++++++++++++++++++-- spec/files.json | 2 + 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 13b744f8..0dc2190b 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -10,7 +10,7 @@ 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, per-scope markdown section 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. 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. +- **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, 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. ## 1. Scope and Ground-Truth Branch diff --git a/spec/audit.py b/spec/audit.py index 82999f32..b9017295 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -58,7 +58,7 @@ def 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. @@ -134,6 +134,114 @@ def heading_texts(markdown): 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 audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -168,7 +276,7 @@ def audit_repo(entry, spec): # 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 trees calls; if + # 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 = { @@ -233,7 +341,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", [])) @@ -263,8 +371,8 @@ def audit_repo(entry, spec): # 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; that each declared ecosystem dual-targets main+develop (the fleet norm) is verified by - # inspection, not here. Only runs when dependabot.yml exists; its absence is already a file-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"): @@ -283,10 +391,11 @@ def audit_repo(entry, spec): # 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 + # 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 + interface_contract = {} # path -> contract, for fidelity:interface entries path_order = [] for item in spec["files"]["baseline"]: if not applies(item.get("appliesTo", "*"), sel): @@ -296,12 +405,26 @@ def audit_repo(entry, spec): wanted_sections[path] = set() path_order.append(path) wanted_sections[path].update(required_sections(item, sel)) + if item.get("fidelity") == "interface": + interface_contract[path] = item.get("contract", {}) for path in path_order: content = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) if content is None: - findings.append(("LETTER", f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)")) + # An interface unit's presence is DRIFT, not LETTER - a workflow's naming is more variable than a + # carried config, so treat absence as a hint to verify rather than a hard defect. + if path in interface_contract: + 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 - # Heading-based presence is only meaningful for markdown; a "section" named on a non-md file (e.g. a + # Interface conformance: check the fixed contract by name and wiring, never the body. + if path in interface_contract: + body = content.get("content") + if not body: + findings.append(("DRIFT", f"interface: could not read {path} content on {ground} to verify its contract (no inline content returned); verify by hand")) + else: + findings.extend(check_interface(path, interface_contract[path], base64.b64decode(body).decode("utf-8", "replace"))) + # 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"): @@ -333,7 +456,59 @@ def audit_repo(entry, spec): return findings, audited_sha +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)") + 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"), diff --git a/spec/files.json b/spec/files.json index fd23f6cb..22da58f8 100644 --- a/spec/files.json +++ b/spec/files.json @@ -20,6 +20,8 @@ { "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:"] } }, "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", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, From d6275138d06d25afbba2ed33daa161a17f521f84 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 22:11:14 -0700 Subject: [PATCH 28/35] Require contextual reference names; clean up AUDIT.md semicolons (#376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown-doc hygiene on two carried docs. - **AGENTS.md** "Documentation Style Conventions" — the reference-link rule required definitions at the bottom, grouped by type, and alphabetized, but never required the reference **names** to be contextual, so an agent could satisfy the letter with numeric/opaque names (which is why it's been inconsistently upheld). State the convention README.md already uses: a name encodes the target and its group — `foo-shield` for a shield, `foo-link` for an external URL, a bare `foo` for a local path/anchor — never numeric (`[1]`) or opaque. - **AUDIT.md** — fix clause-joining semicolons in prose (house rule bans them), leaving the legitimate list-separators (a series whose items contain commas), the code-fence example, and the quoted phrase untouched. Verified: markdownlint + editorconfig-checker clean, `validate.py` green (AGENTS.md sections intact). Held on `develop`. --------- Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 2 +- AUDIT.md | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f29d4029..31d1d239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Clarify devcontainer setup steps in README ### 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. diff --git a/AUDIT.md b/AUDIT.md index 0dc2190b..6323aca6 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. @@ -40,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 @@ -91,9 +91,9 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: 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; 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. +- **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. @@ -108,7 +108,7 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: 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. @@ -123,7 +123,7 @@ Write `reports//audit.md` from [`reports/_template.md`][template]: a dimen ## 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 @@ -131,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. From 722ceb1cc918471c7d5d252954a7a070f41b064b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 07:23:17 -0700 Subject: [PATCH 29/35] Add the verbatim engine - content-hash fixed units against canonical (#374, PR-C) (#377) Adds the verbatim fidelity engine: content-hash a carried unit (whole file, or a workflow job region) against the hub's canonical after EOL normalization, classifying a mismatch as stale (matches a past hub revision) or modified (matches none) via the canonical's git history. Integrity by hash, never a version stamp. Runs at DRIFT. Enabled on .markdownlint-cli2.jsonc and the github-release job region. --- AUDIT.md | 2 +- spec/audit.py | 152 +++++++++++++++++++++++++++++++++++++---- spec/fidelity-model.md | 8 +-- spec/files.json | 2 +- 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 6323aca6..4058e292 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -10,7 +10,7 @@ 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, per-scope markdown section presence, workflow interface conformance, 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. +- **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 diff --git a/spec/audit.py b/spec/audit.py index b9017295..13ea85b8 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -18,6 +18,8 @@ Usage: python3 spec/audit.py [RepoName ...] (default: every cataloged repo) """ import base64 +import functools +import hashlib import json import pathlib import re @@ -242,6 +244,87 @@ def check_interface(path, contract, text): 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) @@ -395,7 +478,7 @@ def audit_repo(entry, spec): # 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 - interface_contract = {} # path -> contract, for fidelity:interface entries + check_item = {} # path -> entry, for a fidelity interface/verbatim entry (last applicable wins per path) path_order = [] for item in spec["files"]["baseline"]: if not applies(item.get("appliesTo", "*"), sel): @@ -405,37 +488,51 @@ def audit_repo(entry, spec): wanted_sections[path] = set() path_order.append(path) wanted_sections[path].update(required_sections(item, sel)) - if item.get("fidelity") == "interface": - interface_contract[path] = item.get("contract", {}) + 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 treat absence as a hint to verify rather than a hard defect. - if path in interface_contract: + # 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 - # Interface conformance: check the fixed contract by name and wiring, never the body. - if path in interface_contract: - body = content.get("content") - if not body: + # 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: - findings.extend(check_interface(path, interface_contract[path], base64.b64decode(body).decode("utf-8", "replace"))) + 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"): - body = content.get("content") - if not body: + 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(base64.b64decode(body).decode("utf-8", "replace")) + 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)")) @@ -502,6 +599,35 @@ def _selftest(): 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 diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index a354fed7..83f3c764 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -12,7 +12,7 @@ Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `pr - **presence** - the unit exists (a file, or a markdown section heading). The audit's baseline check. - **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts nothing beyond presence. -- **verbatim** - byte-identical to the hub's canonical after declared-placeholder normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file or a stable-handle region (a markdown section by heading, a workflow job by key). +- **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. @@ -28,11 +28,11 @@ Fidelity is a declared field defaulting to `presence`, never inferred from `whol 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. -## Placeholder Normalization +## Normalization -A verbatim check normalizes only the tokens a unit **declares** in its `placeholders` list, never a blanket `<...>` regex. The declared tokens are literal strings (for example ``, ``, ``), so masking touches exactly those and leaves intact the sibling metavariables a doc uses in prose (for example ``, ``). Line endings are neutralized before hashing, because EOL variance is governed by the line-ending rules, not a fidelity deviation. +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 Violated +## 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. diff --git a/spec/files.json b/spec/files.json index 22da58f8..b1992ff5 100644 --- a/spec/files.json +++ b/spec/files.json @@ -21,7 +21,7 @@ { "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:"] } }, "intentRef": "AGENTS.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, + { "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", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, From 18823003fa3ffa28c64abc1dc59df112238aeb84 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 09:52:49 -0700 Subject: [PATCH 30/35] Enable MD033 in the canonical markdownlint config (flag inline HTML elements) (#378) Remove the dead MD033:false (HTML comments pass MD033 regardless; disabling it only permitted unwanted inline HTML elements, of which the hub uses none). Backtick the placeholder tokens in reports/_template.md so MD033 does not misread them as elements. 0 markdownlint errors fleet-wide with MD033 enforcing. Surfaced during the Financial-Modeling fidelity adoption. --- .markdownlint-cli2.jsonc | 7 +++---- reports/_template.md | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) 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/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 -- +- `` From 5c0800d0aa52c5565df3db85d1e70d2800a48573 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 16:14:28 -0700 Subject: [PATCH 31/35] Fix hub doc inconsistencies surfaced by Financial-Modeling adoption (#379 items 2, 3, 6) (#380) catalog workflows README file list corrected to the actual .github/workflows/ tree; copilot-instructions CODESTYLE reference generalized from (.NET, Python); repo-config/README.md residual coordination reference dropped and the file added to the spec/files.json manifest as an intent unit (it was carried but unlisted, so downstream copies drifted unnoticed). Surfaced by #379. --- .github/copilot-instructions.md | 2 +- catalog/snippets/workflows/README.md | 2 +- repo-config/README.md | 2 +- spec/files.json | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd0af4be..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. 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/repo-config/README.md b/repo-config/README.md index 8562d8c1..c8dc6bf9 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,6 +1,6 @@ # 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). 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. diff --git a/spec/files.json b/spec/files.json index b1992ff5..0f360e15 100644 --- a/spec/files.json +++ b/spec/files.json @@ -17,6 +17,7 @@ { "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": "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": "*" }, From f58b3f9619cc85a25b7e92ab44aa8e9f4d080bc9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 16:14:51 -0700 Subject: [PATCH 32/35] Reconcile source-only release docs with the inlined publisher (#379 items 4, 5) (#381) Source-only repos publish via the standalone publish-release.yml (inlined nbgv + action-gh-release, gated by validate-task), not the multi-target build-release-task.yml edited down. Reconciled all occurrences: AGENTS.md Release Model, WORKFLOW.md section 6 + the no-file-target/D4.3/operational siblings, and the spec/project-types.json sourceonly assertions. No build-release-task.yml, no expect_release_assets for source-only. --- AGENTS.md | 2 +- WORKFLOW.md | 8 ++++---- spec/project-types.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31d1d239..2e2933e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - *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. diff --git a/WORKFLOW.md b/WORKFLOW.md index c0471cd2..57fe3748 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -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 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`. +- **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 @@ -166,7 +166,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; 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 @@ -275,8 +275,8 @@ Each type maps the *applicable* S-scenarios onto its targets; the differences ar - **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 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.** 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` 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, `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]. +- **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/spec/project-types.json b/spec/project-types.json index c47cff5a..9f51747b 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -92,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": { From 1860a406d2b67f5710e4f0630912365ff2ce4fca Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 18:27:49 -0700 Subject: [PATCH 33/35] Track repo-config/configure.sh + settings.json in the manifest (#379 follow-up) (#382) configure.sh (verbatim) and settings.json (intent) are carried by every repo but were unlisted, so they drifted unaudited - re-vendoring repo-config/README.md alone shipped a doc describing a stale script. The audit now catches a stale configure.sh directly. Surfaced from Financial-Modeling#28. --- spec/files.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/files.json b/spec/files.json index 0f360e15..4d0ca5fa 100644 --- a/spec/files.json +++ b/spec/files.json @@ -18,6 +18,8 @@ { "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": "*" }, From be9508ff0397fb23817bfbd2084d8eae12b75c1c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 19:29:50 -0700 Subject: [PATCH 34/35] Promotion prep: preserve CLAUDE.md endings; recast prose semicolons (#384) install.py now preserves CLAUDE.md's line endings on append/update (verified CRLF-safe); recast seven prose semicolons in docs/repo-config-carry.md. Pre-existing hygiene surfaced by the develop->main promotion review (#383). --- docs/repo-config-carry.md | 14 +++++++------- host-setup/agent-safety/install.py | 10 ++++++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index 1a7e7d01..afb5e1db 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -1,20 +1,20 @@ # repo-config: Carry, Apply, and Regenerate (Hub-Only) -The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** - it is not carried downstream (it describes what the hub does *to* a repo, not a fact about any one repo). The carried [`repo-config/README.md`][repo-config-readme] states only the current facts about a repo's own config; this carry/apply/regen procedure lives here so it never ships into a downstream copy. +The **process** for carrying the `repo-config/` baseline to a fleet repo, applying it, and regenerating the canonical payloads. This doc is **hub-only** - 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: +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 reference no other fleet repo.** A carried file names no sibling fleet repo as an example and links none (any fleet repo may be private, so a cross-repo link 404s in a public carrier; and it couples the repos). 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]. +- **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 reference no other fleet repo.** A carried file names no sibling fleet repo as an example and links none (any fleet repo may be private, so a cross-repo link 404s in a public carrier, and it couples the repos). 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. +**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` and applies `settings.json` alongside the rulesets): @@ -22,7 +22,7 @@ First remove all legacy classic branch-protection rules and any stray rulesets, 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; 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). +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 @@ -50,7 +50,7 @@ done ## Brownfield Migration (Maintainer Only) -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [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. +`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. diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 05896972..891cafe9 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -87,14 +87,20 @@ def main(): # 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() - existing = claude_md.read_text(encoding="utf-8") if claude_md.exists() else "" + # 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_text(updated, encoding="utf-8") + claude_md.write_bytes(updated.replace("\n", newline).encode("utf-8")) print(f" CLAUDE.md -> {claude_md} (safety block {action})") print("\nDone. Verify:") From 35283a66c6b6604552748b89c30abf5708ab0609 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 21 Jul 2026 19:48:51 -0700 Subject: [PATCH 35/35] Promotion prep 2: reconcile groundTruthBranch to main; fix doc drift (#385) Removed the stale groundTruthBranch: develop override from KiCadLibrary/EspDinIoT/PhotoCleaner/HolidayLights (all have main; AUDIT.md rule stands) and refreshed their driftNotes; aligned repo-config-carry.md's cross-repo-link rule with AGENTS.md; noted configure.sh's payload-inference fallback in repo-config-carry.md + STANDUP.md. Pre-existing drift from the #383 promotion review. --- STANDUP.md | 2 +- docs/repo-config-carry.md | 4 ++-- registry/repos.json | 8 ++------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/STANDUP.md b/STANDUP.md index a869ecd6..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 [`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. +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 diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index afb5e1db..9e41a33d 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -7,7 +7,7 @@ The **process** for carrying the `repo-config/` baseline to a fleet repo, applyi 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 reference no other fleet repo.** A carried file names no sibling fleet repo as an example and links none (any fleet repo may be private, so a cross-repo link 404s in a public carrier, and it couples the repos). 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]. +- **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. @@ -16,7 +16,7 @@ Every fleet repo carries the `repo-config/` directory. The hub keeps the canonic **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` and applies `settings.json` alongside the rulesets): +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] diff --git a/registry/repos.json b/registry/repos.json index ccd9e732..b6082239 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -178,20 +178,18 @@ "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": [], @@ -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", @@ -301,7 +298,6 @@ "url": "https://github.com/ptr727/HolidayLights", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "develop", "hasDevelop": true, "publish": [], "requiredSecrets": [],