From f32b6055b9fc3036c57c5df6875383f78d78a016 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 14:34:25 -0700 Subject: [PATCH 1/5] Adopt the fleet check mode into the configure.sh canonical Most of the fleet (5 of 6 divergent copies) carries a configure.sh with a check/5D-audit mode the hub canonical lacked - the hub was behind the fleet, not the other way round. Adopt the check mode into the canonical and converge the fleet onto it. - repo-config/configure.sh: keep the hub's registry-driven apply logic (model resolution, carry inference, create-or-update rulesets) and restructure into `apply`/`check` subcommands. `apply` additionally enables Dependabot vulnerability alerts + automated security updates (fleet policy the forks already applied). `check` is the read-only inverse and exits non-zero on drift: the ruleset and settings assertions are driven by the committed payloads, so they stay repo-agnostic and survive GitHub normalizing a stored ruleset (rule presence + merge methods + required checks, not a byte diff). Secrets are per-repo and not readable by a standalone carry, so they are a manual-verify note. The command defaults to apply, so the old `configure.sh [owner/repo] [model]` calls still apply. shellcheck-clean; check verified live against a release repo and an operational repo with no false positives. - README/STANDUP/repo-config-carry: document the apply/check interface and security enabling. - spec/divergences.json: configure.sh moves from upstream-candidate/investigate to re-vendor - the hub adopted the mode, so the fleet's forked and stale copies now re-vendor onto the new canonical (downstream follow-up). reports/divergences.md regenerated (UNTRIAGED empty). Co-Authored-By: Claude Opus 4.8 --- STANDUP.md | 2 +- docs/repo-config-carry.md | 6 +- repo-config/README.md | 4 +- repo-config/configure.sh | 239 +++++++++++++++++++++++++++----------- reports/divergences.md | 8 +- spec/divergences.json | 3 +- 6 files changed, 180 insertions(+), 82 deletions(-) diff --git a/STANDUP.md b/STANDUP.md index 9811078a..6e4fe165 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 ...` 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..299d7159 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). Defaults: command apply; repo the +# current gh repo; model 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 @@ -58,62 +62,161 @@ 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. - 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 - # branch (e.g. a repo still on a rework branch). - if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; 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 + +# ----- Ruleset id lookup (shared by apply and check) ----- +ruleset_id() { # ruleset-name -> id of the first match (empty if none); aborts on an API error + local out + # Paginate so a name match on a later page is never missed. 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 --paginate "repos/$repo/rulesets")"; then + echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 + return 1 fi - echo "Applying general settings to $repo (has_discussions=$disc)" - printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null -fi + # shellcheck disable=SC2016 # $n is a jq --arg variable, not a shell expansion + # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can + # SIGPIPE jq and fail the function. + jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" +} -# ----- 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 +# =============================== 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 - ruleset_name="$(jq -r '.name // empty' "$file")" - if [ -z "$ruleset_name" ]; then + 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 - # 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')" - fi + id="$(ruleset_id "$rname")" if [ -n "$id" ]; then - echo "Updating ruleset '$ruleset_name' (id $id) on $repo" + echo "Updating ruleset '$rname' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null else - echo "Creating ruleset '$ruleset_name' on $repo" + echo "Creating ruleset '$rname' on $repo" gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null fi -done +} -echo "Configuration applied to $repo" +cmd_apply() { + echo "Applying configuration to $repo (model: $model)" + # ----- General repository settings ----- + if [ -e "$settings_file" ]; then + local private disc payload + # 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 + # branch (e.g. a repo still on a rework branch). + if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; 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 + fi + 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." +} + +# =============================== 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 + 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")" + assert "ruleset '$rname' active" test "$(jq -r '.enforcement' <<<"$live")" = active + # 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 + # 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 +} + +check_settings() { + local live key want got private wantdisc + 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 +} + +check_security() { + local sec + assert "Dependabot vulnerability alerts enabled" gh_ok "repos/$repo/vulnerability-alerts" + sec="$(gh api "repos/$repo/automated-security-fixes")" + assert "Dependabot automated security updates enabled" jq_has '.enabled == true' <<<"$sec" +} + +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 }, From 25b4b8a463948b88b59fc984de6feca69a880260 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 14:40:01 -0700 Subject: [PATCH 2/5] Address Copilot round 1 on the configure.sh check mode - ruleset_id: fetch with per_page=100 in a single request instead of --paginate, so the response is one JSON array. A paginated fetch concatenates multiple arrays, which the single-array jq would mishandle. A repo never has more than a handful of rulesets. - check_ruleset: drive the enforcement assertion from the payload's enforcement field instead of hardcoding "active", keeping the whole function payload-driven. - check_security: read automated-security-fixes under an explicit failure guard so a read error is a clean FAIL rather than a set -e abort. (The endpoint returns 200 JSON { enabled, paused } - verified live - so the JSON check stands.) - STANDUP.md: spell out the check invocation instead of an ellipsis, so it is copy-pasteable. Re-verified: shellcheck clean, check passes live against a release and an operational repo. Co-Authored-By: Claude Opus 4.8 --- STANDUP.md | 2 +- repo-config/configure.sh | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/STANDUP.md b/STANDUP.md index 6e4fe165..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 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 ...` 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. +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/repo-config/configure.sh b/repo-config/configure.sh index 299d7159..6eb4e41b 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -68,9 +68,11 @@ settings_file="$script_dir/settings.json" # ----- Ruleset id lookup (shared by apply and check) ----- ruleset_id() { # ruleset-name -> id of the first match (empty if none); aborts on an API error local out - # Paginate so a name match on a later page is never missed. 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 --paginate "repos/$repo/rulesets")"; then + # 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 @@ -151,13 +153,14 @@ jq_has() { jq -e "$@" >/dev/null 2>&1; } 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 + 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")" - assert "ruleset '$rname' active" test "$(jq -r '.enforcement' <<<"$live")" = active + 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 @@ -198,9 +201,15 @@ check_settings() { check_security() { local sec + # vulnerability-alerts: 204 enabled / 404 disabled, so probe with gh_ok. automated-security-fixes returns + # a JSON body { enabled, paused }; capture it 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" - sec="$(gh api "repos/$repo/automated-security-fixes")" - assert "Dependabot automated security updates enabled" jq_has '.enabled == true' <<<"$sec" + 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 + fail "Dependabot automated security updates - could not read the setting" + fi } cmd_check() { From 85c78a86b9d2ea3555299dfdbb10f0a3ed958b06 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 14:44:25 -0700 Subject: [PATCH 3/5] Address Copilot round 2: warn on duplicate rulesets in ruleset_id Restore the duplicate-name warning the original apply loop had and that the ruleset_id refactor dropped. A repo with more than one ruleset named develop or main (a known drift state) is a real hazard - operating on the first silently could touch the wrong one. Warn to stderr with the count so the duplicates get resolved. grep -c and sed read all input, so neither SIGPIPEs jq under pipefail. Co-Authored-By: Claude Opus 4.8 --- repo-config/configure.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 6eb4e41b..ae5b7ab6 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -66,8 +66,8 @@ main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" # ----- Ruleset id lookup (shared by apply and check) ----- -ruleset_id() { # ruleset-name -> id of the first match (empty if none); aborts on an API error - local out +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 @@ -77,9 +77,16 @@ ruleset_id() { # ruleset-name -> id of the first match (empty if none); aborts o return 1 fi # shellcheck disable=SC2016 # $n is a jq --arg variable, not a shell expansion - # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can - # SIGPIPE jq and fail the function. - jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" + 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 =============================== From 16d51ac330d67c139eba265f8fd2b4bc6bea03f6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 14:49:45 -0700 Subject: [PATCH 4/5] Address Copilot round 3: recast clause-joining semicolons in comments Rewrite the semicolon-joined clauses in the header and inline comments as separate sentences (or commas/dashes) per the repo prose convention. Swept the whole file, not just the flagged header line. shellcheck still clean; check still passes live. Co-Authored-By: Claude Opus 4.8 --- repo-config/configure.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index ae5b7ab6..f17f7327 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -4,13 +4,13 @@ # 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). Defaults: command apply; repo the -# current gh repo; model 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). +# 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 - +# 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 @@ -45,7 +45,7 @@ if [ -z "$model" ]; then 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 or check the wrong develop ruleset. + # 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 @@ -70,7 +70,7 @@ ruleset_id() { # ruleset-name -> id of the first match (empty if none); warns on 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 + # 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 @@ -79,7 +79,7 @@ ruleset_id() { # ruleset-name -> id of the first match (empty if none); warns on # 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 + # 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 .)" @@ -116,7 +116,7 @@ cmd_apply() { # ----- General repository settings ----- if [ -e "$settings_file" ]; then local private disc payload - # has_discussions: enabled on public repos only (fleet policy); never on private. + # 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 @@ -134,7 +134,7 @@ cmd_apply() { gh api --method PUT "repos/$repo/vulnerability-alerts" >/dev/null gh api --method PUT "repos/$repo/automated-security-fixes" >/dev/null echo "Enabled Dependabot vulnerability alerts + automated security updates" - # ----- Branch rulesets (main shared; develop selected by workflow model) ----- + # ----- 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." @@ -146,16 +146,16 @@ 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 +# 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. +# 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 +# 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; } @@ -209,7 +209,7 @@ check_settings() { check_security() { local sec # vulnerability-alerts: 204 enabled / 404 disabled, so probe with gh_ok. automated-security-fixes returns - # a JSON body { enabled, paused }; capture it under an explicit failure guard so a read error is a clean + # 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 @@ -225,7 +225,7 @@ cmd_check() { 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 + # 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 From e8fd0f882cf3efc8ccb147cc0cdc512b9899f735 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 14:56:38 -0700 Subject: [PATCH 5/5] Address Copilot round 4: fail loud on a missing settings.json in both modes settings.json is a required base payload, so a missing one is a broken carry, not a no-op. - cmd_apply: pre-flight settings.json and both ruleset payloads before any write, so a partial carry aborts before it half-applies (previously a missing settings.json silently skipped the settings step). Unguard the settings block now that existence is guaranteed. - check_settings: fail cleanly with a drift message when settings.json is missing, instead of letting the jq process substitution abort the script under set -e. Also recast the remaining semicolon-joined echo messages as sentences. shellcheck clean; check passes live and now clean-FAILs (exit 1) when settings.json is absent. Co-Authored-By: Claude Opus 4.8 --- repo-config/configure.sh | 44 +++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index f17f7327..56f6bccb 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -54,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 @@ -84,7 +84,7 @@ ruleset_id() { # ruleset-name -> id of the first match (empty if none); warns on # 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 + echo "Warning: $count rulesets named '$1' on $repo. Using the first (resolve the duplicates)." >&2 fi printf '%s\n' "$ids" | sed -n '1p' } @@ -93,12 +93,12 @@ ruleset_id() { # ruleset-name -> id of the first match (empty if none); warns on 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 + 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 + echo "Ruleset payload $file has no name. Aborting to avoid a partially-applied configuration." >&2 exit 1 fi id="$(ruleset_id "$rname")" @@ -112,24 +112,29 @@ apply_ruleset() { # payload-file - create-or-update the ruleset by name } 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 ----- - if [ -e "$settings_file" ]; then - local private disc payload - # 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 - # branch (e.g. a repo still on a rework branch). - if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; 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 - fi - echo "Applying general settings (has_discussions=$disc)" - printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null + # 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 + # branch (e.g. a repo still on a rework branch). + if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; 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 fi + echo "Applying general settings (has_discussions=$disc)" + printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null # ----- 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 @@ -189,6 +194,7 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol 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.