diff --git a/STANDUP.md b/STANDUP.md index 9811078a..d3cacc6c 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 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. +Run `repo-config/configure.sh apply [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, the Dependabot security features, and the two rulesets idempotently (import the JSON, never hand-build - see [`docs/repo-config-carry.md`][repo-config-carry]), then `repo-config/configure.sh check [owner/repo] [release|operational]` to validate the repo and exit non-zero on any drift. 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 9e41a33d..e463a1de 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -16,13 +16,13 @@ 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`, or infers it from the carried payload when no registry is present, and applies `settings.json` alongside the rulesets): +First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh apply` (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` and the Dependabot security features alongside the rulesets): ```sh -repo-config/configure.sh [owner/repo] [release|operational] +repo-config/configure.sh apply [owner/repo] [release|operational] ``` -Or import each ruleset by hand with `gh api -X POST repos///rulesets --input repo-config/.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). +Then validate the result with `repo-config/configure.sh check [owner/repo] [release|operational]`, which asserts every applied ruleset, setting, and security feature and exits non-zero on drift (the ruleset and settings checks are driven by the committed payloads, so they stay repo-agnostic). Or import each ruleset by hand with `gh api -X POST repos///rulesets --input repo-config/.json` (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only, so creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a live ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). ## Regenerating the Payloads diff --git a/repo-config/README.md b/repo-config/README.md index c8dc6bf9..012754e3 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). 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). +- `configure.sh` - two modes over the GitHub API. `configure.sh apply [owner/repo] [release|operational]` creates-or-updates the settings, the Dependabot security features, and the rulesets idempotently (a full-payload update). `configure.sh check [owner/repo] [release|operational]` is the read-only inverse and exits non-zero on any drift, with the ruleset and settings assertions driven by the committed payloads so they stay repo-agnostic (rule presence, merge methods, and required checks, not a byte diff - so a GitHub-normalized stored ruleset does not false-positive). The command defaults to `apply`, the repo to the current one, and the model to the registry `workflowModel` lookup (or, absent a registry, inference from the carried `develop` payload - an ambiguous layout aborts rather than guesses). The model may be passed as the sole positional (`configure.sh check operational`). ## Rulesets @@ -23,7 +23,7 @@ Publish credentials required per mechanism are enumerated in `spec/secrets.json` ## Repo Settings -The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `configure.sh` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state - `has_discussions` (visibility) and `default_branch` (main-must-exist) - are computed by the script, not stored in the file. +The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `configure.sh apply` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state - `has_discussions` (visibility) and `default_branch` (main-must-exist) - are computed by the script, not stored in the file. `configure.sh apply` also enables Dependabot vulnerability alerts and automated security updates - fleet policy applied via the API, not a `settings.json` key. `configure.sh check` validates all of these and exits non-zero on drift. - **Default branch `main`** (the script sets it only when a `main` branch exists, never pointing the default at a missing branch). - **Merge methods**: `Allow merge commits` and `Allow squash merging` on, **rebase off** - each branch ruleset then picks its method (merge on `main`, squash on `develop`). diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 5082ea0f..56f6bccb 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -1,47 +1,51 @@ #!/usr/bin/env bash -# Apply the committed fleet configuration in this directory to the repository via the GitHub API: -# 1. General repository settings from settings.json (PATCH /repos/{owner}/{repo}), plus the two settings that depend on -# per-repo state - has_discussions (public repos only) and default_branch (main, only if it exists). -# 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 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. +# Configure or validate a repository against the committed fleet config in this directory, via the GitHub API. # -# 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 +# repo-config/configure.sh apply [owner/repo] [release|operational] # create-or-update settings + rulesets (writes) +# repo-config/configure.sh check [owner/repo] [release|operational] # validate an existing repo, non-zero on drift (reads) +# +# Both modes need admin on the repo (the rulesets endpoints require it). The command defaults to apply, the repo +# to the current gh repo, and the model to the registry lookup (else inferred from the carried develop payload). +# The model may be passed as the sole positional (e.g. `configure.sh check operational`), and the command may be +# omitted for the apply default (`configure.sh owner/repo` still applies). +# +# apply: (1) settings.json via PATCH, plus has_discussions (public repos only) and default_branch (main, only if +# it exists). (2) Dependabot vulnerability alerts + automated security updates. (3) the branch rulesets - +# main.json (shared) and the model-specific develop ruleset (develop.json PR-gated, or operational/ +# develop.json direct signed pushes), create-or-update by name. Idempotent. +# check: the read-only inverse - every applied ruleset, setting, and security feature must match. The ruleset and +# settings assertions are driven by the committed payloads, so they stay repo-agnostic and survive the +# GitHub API normalizing a stored ruleset (rule presence + merge methods + required checks, not a byte +# diff). Secrets are per-repo (see spec/secrets.json) and not checkable from a standalone carry, so they +# are a manual-verify note. set -Eeuo pipefail +# ----- Command + target + model ----- +cmd=apply +case "${1:-}" in apply|check) cmd="$1"; shift ;; esac 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 +# Allow the model as the sole positional (`configure.sh check operational`): a model name 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) ----- +# ----- Resolve the workflow model (selects the develop ruleset), shared by apply and check ----- registry="$script_dir/../registry/repos.json" name="${repo##*/}" 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 + # 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 (release|operational)." >&2 exit 1 fi else - # 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. + # 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 or check 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 @@ -50,7 +54,7 @@ if [ -z "$model" ]; then 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 + echo "Registry $registry not found. Inferred workflow model '$model' from the carried develop payload." >&2 fi fi case "$model" in @@ -58,12 +62,67 @@ case "$model" in operational) develop_ruleset="$script_dir/operational/develop.json" ;; *) echo "Unknown workflow model '$model' (expected release or operational)." >&2; exit 1 ;; esac -echo "Workflow model for $repo: $model" - -# ----- General repository settings ----- +main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" -if [ -e "$settings_file" ]; then - # has_discussions: enabled on public repos only (fleet policy); never on private. + +# ----- Ruleset id lookup (shared by apply and check) ----- +ruleset_id() { # ruleset-name -> id of the first match (empty if none); warns on duplicates; aborts on API error + local out ids count + # per_page=100 returns every ruleset in one array (a repo has only a handful), so the response is a single + # JSON document - a paginated fetch would concatenate multiple arrays and break the single-array jq below. + # Let gh print its own error on stderr. Add a context line and return non-zero so the caller stops rather + # than treat an API failure as "not found". + if ! out="$(gh api "repos/$repo/rulesets?per_page=100")"; then + echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 + return 1 + fi + # shellcheck disable=SC2016 # $n is a jq --arg variable, not a shell expansion + ids="$(jq -r --arg n "$1" '.[] | select(.name==$n) | .id' <<<"$out")" + if [ -z "$ids" ]; then return 0; fi + # Pre-existing drift can leave more than one ruleset with the same name. Use the first and warn so the + # duplicates are resolved rather than silently operating on the wrong one. grep -c and sed both read all + # input (no early pipe close), so neither SIGPIPEs jq under pipefail. + count="$(printf '%s\n' "$ids" | grep -c .)" + if [ "$count" -gt 1 ]; then + echo "Warning: $count rulesets named '$1' on $repo. Using the first (resolve the duplicates)." >&2 + fi + printf '%s\n' "$ids" | sed -n '1p' +} + +# =============================== apply =============================== +apply_ruleset() { # payload-file - create-or-update the ruleset by name + local file="$1" rname id + if [ ! -e "$file" ]; then + echo "Ruleset payload $file not found. Aborting to avoid a partially-applied configuration." >&2 + exit 1 + fi + rname="$(jq -r '.name // empty' "$file")" + if [ -z "$rname" ]; then + echo "Ruleset payload $file has no name. Aborting to avoid a partially-applied configuration." >&2 + exit 1 + fi + id="$(ruleset_id "$rname")" + if [ -n "$id" ]; then + echo "Updating ruleset '$rname' (id $id) on $repo" + gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null + else + echo "Creating ruleset '$rname' on $repo" + gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null + fi +} + +cmd_apply() { + local f private disc payload + # Pre-flight every required payload before any write, so a partial carry aborts before it half-applies. + for f in "$settings_file" "$develop_ruleset" "$main_ruleset"; do + if [ ! -e "$f" ]; then + echo "Required payload $f not found. Aborting to avoid a partially-applied configuration." >&2 + exit 1 + fi + done + echo "Applying configuration to $repo (model: $model)" + # ----- General repository settings ----- + # has_discussions: enabled on public repos only (fleet policy), never on private. private="$(gh api "repos/$repo" --jq '.private')" disc=false; [ "$private" = "false" ] && disc=true # default_branch main, but only point it at main when main exists - never set the default to a missing @@ -72,48 +131,114 @@ if [ -e "$settings_file" ]; then payload="$(jq --argjson d "$disc" '. + {has_discussions: $d, default_branch: "main"}' "$settings_file")" else payload="$(jq --argjson d "$disc" '. + {has_discussions: $d}' "$settings_file")" - echo "Warning: $repo has no 'main' branch; leaving default_branch unchanged." >&2 + echo "Warning: $repo has no 'main' branch. Leaving default_branch unchanged." >&2 fi - echo "Applying general settings to $repo (has_discussions=$disc)" + echo "Applying general settings (has_discussions=$disc)" printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null -fi + # ----- Dependabot alerts + automated security updates ----- + gh api --method PUT "repos/$repo/vulnerability-alerts" >/dev/null + gh api --method PUT "repos/$repo/automated-security-fixes" >/dev/null + echo "Enabled Dependabot vulnerability alerts + automated security updates" + # ----- Branch rulesets (main shared, develop selected by workflow model) ----- + apply_ruleset "$develop_ruleset" + apply_ruleset "$main_ruleset" + echo "Configuration applied to $repo. Run '$0 check${repo_arg:+ $repo}' to validate." +} -# ----- Branch rulesets ----- -# main.json is shared; the develop ruleset was selected by workflow model above. A missing or nameless -# payload aborts - silently skipping it would report success on a partially-applied configuration. -for file in "$develop_ruleset" "$script_dir/main.json"; do - if [ ! -e "$file" ]; then - echo "Ruleset payload $file not found; aborting to avoid a partially-applied configuration." >&2 - exit 1 +# =============================== check =============================== +FAILED=0 +note() { printf ' %s\n' "$*"; } +pass() { printf ' ok %s\n' "$*"; } +fail() { printf ' FAIL %s\n' "$*"; FAILED=1; } + +# assert MESSAGE TEST... - run the test command, pass on success, fail on non-zero (a proper if/else, not the +# `A && B || C` footgun). Do not redirect the assert call's own stdout - that would swallow the pass/fail line; +# a command that prints (jq) uses jq_has, which silences only itself. +assert() { local msg="$1"; shift; if "$@"; then pass "$msg"; else fail "$msg"; fi; } + +# jq_has FILTER... - true iff the filter selects a truthy value. jq's output is discarded, not the caller's. +# Reads JSON from stdin. +jq_has() { jq -e "$@" >/dev/null 2>&1; } + +# gh_ok ENDPOINT... - true iff the gh api call succeeds (2xx, including 204). Output and errors discarded, so it +# is safe to pass to assert (e.g. vulnerability-alerts returns 204 enabled / 404 disabled). +gh_ok() { gh api "$@" >/dev/null 2>&1; } + +check_ruleset() { # payload-file - the live ruleset must match the committed policy, driven by the payload + local file="$1" rname id live t want got wantc gotc want_enf + rname="$(jq -r '.name // empty' "$file")" + if [ -z "$rname" ]; then fail "ruleset payload $file has no name"; return; fi + id="$(ruleset_id "$rname")" + if [ -z "$id" ]; then fail "ruleset '$rname' missing"; return; fi + live="$(gh api "repos/$repo/rulesets/$id")" + want_enf="$(jq -r '.enforcement' "$file")" + assert "ruleset '$rname' enforcement = $want_enf" test "$(jq -r '.enforcement' <<<"$live")" = "$want_enf" + # Every rule type the committed payload declares must be present live (payload-driven, so repo-agnostic). + while IFS= read -r t; do + # shellcheck disable=SC2016 # $t is a jq --arg variable, not a shell expansion + assert "'$rname' enforces rule '$t'" jq_has --arg t "$t" '.rules[] | select(.type==$t)' <<<"$live" + done < <(jq -r '.rules[].type' "$file") + # pull_request: the live merge methods must match the payload (the develop=squash / main=merge policy). + if jq_has '.rules[] | select(.type=="pull_request")' "$file"; then + want="$(jq -c '[.rules[]|select(.type=="pull_request").parameters.allowed_merge_methods[]]|sort' "$file")" + got="$(jq -c '[.rules[]|select(.type=="pull_request").parameters.allowed_merge_methods[]]|sort' <<<"$live")" + assert "'$rname' merge methods = $want" test "$got" = "$want" fi - ruleset_name="$(jq -r '.name // empty' "$file")" - if [ -z "$ruleset_name" ]; then - echo "Ruleset payload $file has no name; aborting to avoid a partially-applied configuration." >&2 - exit 1 + # required_status_checks: the live required contexts must match the payload. + if jq_has '.rules[] | select(.type=="required_status_checks")' "$file"; then + wantc="$(jq -c '[.rules[]|select(.type=="required_status_checks").parameters.required_status_checks[].context]|sort' "$file")" + gotc="$(jq -c '[.rules[]|select(.type=="required_status_checks").parameters.required_status_checks[].context]|sort' <<<"$live")" + assert "'$rname' required checks = $wantc" test "$gotc" = "$wantc" fi - # Paginate so a name match on a later page is never missed (which would create a duplicate ruleset), and - # fail loudly if the API call itself fails (auth/404/network) rather than treating it as "not found". - if ! ids="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$ruleset_name\") | .id")"; then - echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 - exit 1 - fi - # Pre-existing drift can leave more than one ruleset with the same name; update the first and warn. Guard - # on non-empty so `grep -c` (which exits non-zero on empty input under `set -e`) can't abort the create path. - id="" - if [ -n "$ids" ]; then - count="$(printf '%s\n' "$ids" | grep -c .)" - if [ "$count" -gt 1 ]; then - echo "Warning: $count rulesets named '$ruleset_name' on $repo; updating the first (resolve the duplicates)." >&2 - fi - id="$(printf '%s\n' "$ids" | sed -n '1p')" +} + +check_settings() { + local live key want got private wantdisc + if [ ! -e "$settings_file" ]; then fail "settings payload $settings_file missing"; return; fi + live="$(gh api "repos/$repo")" + # Static settings, driven from settings.json so the check never drifts from the file - add a key there and + # it is audited here automatically. + while IFS=$'\t' read -r key want; do + # shellcheck disable=SC2016 # $k is a jq --arg variable, not a shell expansion + got="$(jq -r --arg k "$key" '.[$k]' <<<"$live")" + assert "setting $key = $want" test "$got" = "$want" + done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$settings_file") + # Dynamic settings apply sets: has_discussions (public repos only), default_branch (main, if it exists). + private="$(jq -r '.private' <<<"$live")" + wantdisc=true; [ "$private" = "true" ] && wantdisc=false + assert "has_discussions = $wantdisc" test "$(jq -r '.has_discussions' <<<"$live")" = "$wantdisc" + if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then + assert "default_branch = main" test "$(jq -r '.default_branch' <<<"$live")" = main fi - if [ -n "$id" ]; then - echo "Updating ruleset '$ruleset_name' (id $id) on $repo" - gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null +} + +check_security() { + local sec + # vulnerability-alerts: 204 enabled / 404 disabled, so probe with gh_ok. automated-security-fixes returns + # a JSON body { enabled, paused }, captured under an explicit failure guard so a read error is a clean + # FAIL rather than a set -e abort. + assert "Dependabot vulnerability alerts enabled" gh_ok "repos/$repo/vulnerability-alerts" + if sec="$(gh api "repos/$repo/automated-security-fixes" 2>/dev/null)"; then + assert "Dependabot automated security updates enabled" jq_has '.enabled == true' <<<"$sec" else - echo "Creating ruleset '$ruleset_name' on $repo" - gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null + fail "Dependabot automated security updates - could not read the setting" fi -done +} -echo "Configuration applied to $repo" +cmd_check() { + echo "Validating configuration for $repo (model: $model)" + check_ruleset "$develop_ruleset" + check_ruleset "$main_ruleset" + check_settings + check_security + # Secrets are per-repo (spec/secrets.json) and not readable by value. A standalone carry has no registry to + # derive the required set from, so they are verified by hand rather than asserted here. + note "verify manually: the repo's required secrets (see spec/secrets.json) are present with valid values" + if [ "$FAILED" -ne 0 ]; then echo "Configuration drift detected on $repo."; exit 1; fi + echo "Configuration matches on $repo." +} + +case "$cmd" in + apply) cmd_apply ;; + check) cmd_check ;; +esac diff --git a/reports/divergences.md b/reports/divergences.md index fa28eaf4..6cc13700 100644 --- a/reports/divergences.md +++ b/reports/divergences.md @@ -7,14 +7,10 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur ### re-vendor - **.markdownlint-cli2.jsonc** - AudioCleaner, PhotoCleaner, aiopurpleair - Verbatim config held as a hand-modified copy in these three (not a past hub revision). Restore the current canonical. - -### upstream-candidate - -- **repo-config/configure.sh** - NxWitness - Not stale - a forked design: repo-specialized (Docker Hub image list, secret names, Make/Matrix.json product matrix) and adds a check/5D-audit mode the hub canonical lacks. Overwriting would regress it. Reconcile by adopting the check mode into the hub canonical, then re-vendoring. +- **repo-config/configure.sh** - ESPHome-NonRoot, LanguageTags, NxWitness, VSCode-Server-DotNetCore, aiopurpleair, homeassistant-purpleair - The hub adopted the fleet check mode into the configure.sh canonical (apply plus a payload-driven check). These carry an older design that predates it (five an older check-mode fork with ruleset-*.json filenames and no registry, LanguageTags an older apply-only copy). Re-vendor the new canonical to converge them. The stale copies re-vendor mechanically. NxWitness may keep a thin repo-specific wrapper for its hardcoded Docker Hub secret checks, which the repo-agnostic canonical leaves as a manual-verify note. ### investigate -- **repo-config/configure.sh** - ESPHome-NonRoot, LanguageTags, VSCode-Server-DotNetCore, aiopurpleair, homeassistant-purpleair - Diverge from the 119-line apply-only canonical in a non-stale way, likely carrying the older check-mode design (same family as the NxWitness fork). Triage per repo: fold into the upstream-candidate decision, or re-vendor if merely a forked-then-abandoned copy. - **pyproject.toml** (manifest gap) - The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent. - **.github/workflows/publish-release.yml** (manifest gap) - Carried by some repos, absent from others, and varies widely (12 divergent, 5 absent). Needs a fidelity call (interface vs intent) and an appliesTo scope before tracking - it would surface many new findings. - **.github/workflows/validate-task.yml** (manifest gap) - As publish-release.yml (11 divergent, 9 absent): fidelity plus appliesTo decision pending. @@ -33,7 +29,7 @@ _None - every live divergence has a recorded disposition._ A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. - **.markdownlint-cli2.jsonc** (15): DevKitCIoT, ESPHome-Config, ESPHome-NonRoot, HolidayLights, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair -- **repo-config/configure.sh** (6): ESPHome-Config, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, Utilities, Vantage-Config +- **repo-config/configure.sh** (8): ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, ProjectTemplate, Utilities, Vantage-Config ## Promote candidates (intent uniform -> verbatim) diff --git a/spec/divergences.json b/spec/divergences.json index c9388809..2c95d3c1 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -4,8 +4,7 @@ "dispositions": [ { "path": ".editorconfig-checker.json", "repos": ["HomeAutomation-Config", "HolidayLights"], "disposition": "accepted", "reason": "Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim.", "tracking": null }, { "path": ".markdownlint-cli2.jsonc", "repos": ["aiopurpleair", "PhotoCleaner", "AudioCleaner"], "disposition": "re-vendor", "reason": "Verbatim config held as a hand-modified copy in these three (not a past hub revision). Restore the current canonical.", "tracking": null }, - { "path": "repo-config/configure.sh", "repos": ["NxWitness"], "disposition": "upstream-candidate", "reason": "Not stale - a forked design: repo-specialized (Docker Hub image list, secret names, Make/Matrix.json product matrix) and adds a check/5D-audit mode the hub canonical lacks. Overwriting would regress it. Reconcile by adopting the check mode into the hub canonical, then re-vendoring.", "tracking": null }, - { "path": "repo-config/configure.sh", "repos": ["LanguageTags", "aiopurpleair", "homeassistant-purpleair", "ESPHome-NonRoot", "VSCode-Server-DotNetCore"], "disposition": "investigate", "reason": "Diverge from the 119-line apply-only canonical in a non-stale way, likely carrying the older check-mode design (same family as the NxWitness fork). Triage per repo: fold into the upstream-candidate decision, or re-vendor if merely a forked-then-abandoned copy.", "tracking": null } + { "path": "repo-config/configure.sh", "repos": ["NxWitness", "aiopurpleair", "homeassistant-purpleair", "ESPHome-NonRoot", "VSCode-Server-DotNetCore", "LanguageTags"], "disposition": "re-vendor", "reason": "The hub adopted the fleet check mode into the configure.sh canonical (apply plus a payload-driven check). These carry an older design that predates it (five an older check-mode fork with ruleset-*.json filenames and no registry, LanguageTags an older apply-only copy). Re-vendor the new canonical to converge them. The stale copies re-vendor mechanically. NxWitness may keep a thin repo-specific wrapper for its hardcoded Docker Hub secret checks, which the repo-agnostic canonical leaves as a manual-verify note.", "tracking": null } ], "gaps": [ { "path": "LICENSE", "disposition": "accepted", "reason": "Each repo owns its license file. The hub does not standardize license text, so it is intentionally outside the manifest.", "tracking": null },