diff --git a/AUDIT.md b/AUDIT.md index cbf0778..e2ae548 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -22,6 +22,7 @@ Three dimensions, each independently checkable: ## 1. Settings and Rulesets ```sh +# From a hub checkout, which hosts the script rather than this repo carrying a copy. repo-config/configure.sh check ptr727/Blog release ``` diff --git a/TODO.md b/TODO.md index 88dea3e..c3d923c 100644 --- a/TODO.md +++ b/TODO.md @@ -155,7 +155,7 @@ Each of these was hit or nearly hit, and each is cheap to re-trip. - **`content/` is an imported archive.** Prose, spelling, and style sweeps do not reach it, and `cspell.json` ignores it deliberately. - **A gate is trusted only after it has been demonstrated failing.** Every gate here has been. A list-driven check also needs a length floor, or a truncated list passes while checking almost nothing. - **Do not name any workflow `build-*-task.yml`** while the repo declares `source-only`, since `detect` is literally `["no build-*-task.yml"]`. -- **Do not edit `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. Scope a glob in the workflow instead. A reviewer finding a real defect in one of them is answered by declining locally and filing it at the hub, never by editing the file to satisfy the review. +- **Do not edit `.markdownlint-cli2.jsonc` or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. `repo-config/configure.sh` was in this list until it was retired: the hub hosts it and this repo reaches it there, so there is no local copy to edit or to protect. Scope a glob in the workflow instead. A reviewer finding a real defect in one of them is answered by declining locally and filing it at the hub, never by editing the file to satisfy the review. - **The hub's `main` can promote while a convergence pull request is open**, so ground truth moves underneath work that was correct when it started. It happened twice in one session on 2026-08-03, and the second time added drift the branch could not have known about. Re-run the audit against the hub ref actually carried before claiming convergence, and name that ref in the change, or the claim ages into a false one. - **Before believing a negative, establish that the check could have produced a positive.** An empty result and a clean result are the same bytes, so the question is never "did it find anything" but "could it have". The instances below are the same failure in different costumes, some of which cost a false pass and one of which has not happened yet. It is stated as a demand for evidence rather than as a warning to be careful because *be careful* has no step to perform and *prove the check can fire* does. - **A query that cannot see its target.** A review-thread poll blind to suppressed findings, a reviewer filter written in the wrong API's login form, an audit loop whose `jq` path had moved, and a journal grep for `Started` from an account not in `adm` or `systemd-journal`, where the lines exist and are not shown to it. Assert the query matched before reading what it returned, which is what `jq -e` and a non-empty check are for. diff --git a/repo-config/README.md b/repo-config/README.md index be48d4e..69c72e1 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -2,9 +2,9 @@ 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. +- `main.json` plus one `develop` variant: the branch rulesets as the managed part of the writable API subset (`name`, `target`, `enforcement`, `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`) validates the live rulesets against, by asserting rule presence, merge methods and required checks rather than diffing bytes, so a ruleset GitHub has normalized does not read as drift. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing asserts it: who may bypass a ruleset is a human decision taken in the UI, which the configure script preserves on `apply` and reports without asserting on `check`. - This repo is `release`, so it carries `develop.json` and **not** `operational/develop.json`. The operational variant takes direct signed pushes with no PR gate, which is the wrong ruleset here. See "Rulesets" below. -- `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, where an ambiguous layout aborts rather than guesses). The model may be passed as the sole positional (`configure.sh check release`). +- `configure.sh`: **hosted in the hub and run from a hub checkout, not carried here**, per the Hub-Hosted Tooling rule in `GOVERNANCE.md`. The payloads above are what this repo is audited against and stay with it. The script holds nothing per-repo and is one copy for the fleet, so a carried copy would only be current until the next fix to it. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. Two modes over the GitHub API. `repo-config/configure.sh apply ptr727/Blog release` creates-or-updates the settings, the Dependabot security features, and the rulesets idempotently. `repo-config/configure.sh check ptr727/Blog release` is the read-only inverse and exits non-zero on any drift, with the assertions driven by the committed payloads rather than a byte diff, so a GitHub-normalized stored ruleset does not false-positive. ## 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 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 rather than a `settings.json` key. `configure.sh check` validates all of these and exits non-zero on drift. +The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `repo-config/configure.sh apply ptr727/Blog release` 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. That same `apply` also enables Dependabot vulnerability alerts and automated security updates, fleet policy applied via the API rather than a `settings.json` key. `repo-config/configure.sh check ptr727/Blog release` 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**, and 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 deleted file mode 100755 index d0716da..0000000 --- a/repo-config/configure.sh +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env bash -# Configure or validate a repository against the committed fleet config in this directory, via the GitHub API. -# -# Apply: repo-config/configure.sh apply [owner/repo] [release|operational] # create-or-update settings + rulesets (writes) -# Check: 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, because the rulesets endpoints require it. -# The command defaults to apply, the repo to the current gh repo, and the model to the registry lookup. -# With no registry to consult, the model is inferred from the carried develop payload. -# The model may be passed as the sole positional, as in `configure.sh check operational`. -# The command may be omitted for the apply default, so `configure.sh owner/repo` still applies. -# -# The apply mode writes three groups, in order. -# First settings.json via PATCH, plus has_discussions (public repos only) and default_branch (main, only when it exists). -# Then the Dependabot vulnerability alerts and automated security updates. -# Then the branch rulesets, main.json shared and the model-specific develop ruleset, create-or-update by name. -# The develop ruleset is develop.json where the model is PR-gated, or operational/develop.json for direct signed pushes. -# Applying the same configuration twice changes nothing, so the mode is idempotent. -# -# The check mode is the read-only inverse, and it verifies the same three groups apply writes. -# The ruleset and static-settings assertions are driven by the committed payloads, so they stay repo-agnostic. -# A ruleset is checked on enforcement, on the rule-type set compared in both directions, and on the whole parameters object of every parameterized rule. -# Comparing the parameters object rather than named fields means a parameter added to a payload is audited with no change here. -# Both directions matter, since a rule added live that the payload never declared is drift this catches. -# That still survives the GitHub API normalizing a stored ruleset, since the comparison is over parsed JSON with sorted keys rather than a byte diff. -# The derived settings apply computes are asserted by name rather than from a payload, meaning has_discussions and default_branch. -# The two Dependabot security features are asserted the same way, since apply enables them and no payload declares them. -# What is unaudited is a static setting absent from settings.json, since only that group is payload-driven. -# 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 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), 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 or parse error (a malformed registry) rather than silently applying the release default. - # Silently defaulting would hide a lookup that 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 - # With 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. - # An ambiguous layout (both or neither, as in a partial copy) aborts rather than guesses. - # 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 - 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 - release) develop_ruleset="$script_dir/develop.json" ;; - operational) develop_ruleset="$script_dir/operational/develop.json" ;; - *) echo "Unknown workflow model '$model' (expected release or operational)." >&2; exit 1 ;; -esac -main_ruleset="$script_dir/main.json" -settings_file="$script_dir/settings.json" - -# ----- Ruleset id lookup (shared by apply and check) ----- -# Map a ruleset name to the id of the first match, leaving it empty when nothing matches. -# It warns on duplicates, and aborts on an API error or at the per_page cap, where a single-fetch lookup is unreliable. -ruleset_id() { - local out ids count - # Requesting per_page=100 returns every ruleset in one array, since a repo carries only a handful. - # The response is then a single JSON document, where a paginated fetch would concatenate arrays and break the 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 - # Fail loud rather than silently narrow, because a full page means the single-fetch assumption no longer holds. - # A missed lookup would make apply create a duplicate ruleset by name. - # Abort so the caller stops, since it treats a non-zero return as "stop" and never as "not found". - if [ "$(jq 'length' <<<"$out")" -eq 100 ]; then - echo "Failed for $repo: 100 rulesets returned (the per_page cap), so the single-fetch lookup is unreliable. Reduce rulesets or add pagination before applying." >&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 get resolved rather than silently operating on the wrong one. - # Both grep -c and sed read all their input with 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 live_bypass body - 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" - # The bypass list is a human decision, so this script neither grants nor revokes it. - # A PUT replaces the whole document, so omitting the field would delete the live list rather than leave it alone. - # The live value is therefore read and written back unchanged, which is what "hands off" has to mean against a replacing API. - # A read failure aborts rather than proceeding, since applying without it would silently clear the list. - if ! live_bypass="$(gh api "repos/$repo/rulesets/$id" --jq '.bypass_actors // []')"; then - echo "Could not read the live bypass list for ruleset '$rname' on $repo. Aborting rather than applying a payload that would clear it." >&2 - exit 1 - fi - if ! body="$(jq --argjson b "$live_bypass" '.bypass_actors = $b' "$file")"; then - echo "Could not compose the ruleset payload for '$rname'. Aborting." >&2 - exit 1 - fi - gh api --method PUT "repos/$repo/rulesets/$id" --input - <<<"$body" >/dev/null - else - echo "Creating ruleset '$rname' on $repo" - # No bypass list is sent on create, so a new ruleset starts with GitHub's own empty default. - # Nothing is deleted here, because nothing existed to delete. - 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)" - # The writes below silence stdout only, because the success-response JSON is noise. - # They still fail loud, since gh errors go to stderr and a failed write aborts the script. - # These writes run unguarded under `set -e`. - # ----- General repository settings ----- - # Discussions are enabled on public repos only by fleet policy, and never on a private one. - private="$(gh api "repos/$repo" --jq '.private')" - disc=false; [ "$private" = "false" ] && disc=true - # The default branch is main, but only point it there once main exists. - # Never set the default to a missing branch, as on a repo still living 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 - 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; } - -# Run a test command with `assert MESSAGE TEST...`, passing on success and failing on non-zero. -# It is a proper if/else rather than the `A && B || C` footgun. -# Do not redirect the assert call's own stdout, which would swallow the pass or fail line. -# A command that prints, such as jq, goes through jq_has, which silences only itself. -assert() { local msg="$1"; shift; if "$@"; then pass "$msg"; else fail "$msg"; fi; } - -# Test with `jq_has FILTER...`, which is true only when the filter selects a truthy value. -# The jq output is discarded, not the caller's. -# It reads its JSON from stdin. -jq_has() { jq -e "$@" >/dev/null 2>&1; } - -# Test with `gh_ok ENDPOINT...`, which is true only when the gh api call succeeds, a 204 included. -# Output and errors are both discarded, so it is safe to pass to assert. -# The vulnerability-alerts endpoint is the example, returning 204 when enabled and 404 when 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 want_enf - if [ ! -e "$file" ]; then fail "ruleset payload $file missing"; return; fi - rname="$(jq -r '.name // empty' "$file")" - if [ -z "$rname" ]; then fail "ruleset payload $file has no name"; return; fi - if ! id="$(ruleset_id "$rname")"; then fail "ruleset '$rname' - could not resolve id"; return; fi - if [ -z "$id" ]; then fail "ruleset '$rname' missing"; return; fi - if ! live="$(gh api "repos/$repo/rulesets/$id")"; then fail "ruleset '$rname' - could not read live state"; return; fi - want_enf="$(jq -r '.enforcement' "$file")" - assert "ruleset '$rname' enforcement = $want_enf" test "$(jq -r '.enforcement' <<<"$live")" = "$want_enf" - # The live rule-type set must equal the payload's, compared in both directions. - # Checking only that each payload type is present live misses a rule someone added by hand. - # That is drift this script exists to catch, and it passed as clean before. - local want_types got_types - if ! want_types="$(jq -r '[.rules[].type] | sort | join(",")' "$file")"; then - fail "ruleset payload $file did not parse"; return - fi - if [ -z "$want_types" ]; then fail "ruleset payload $file declares no rules"; return; fi - got_types="$(jq -r '[.rules[].type] | sort | join(",")' <<<"$live")" - assert "'$rname' rule set = $want_types" test "$got_types" = "$want_types" - # The bypass list is reported and never asserted, because no payload declares one. - # Who may bypass a ruleset is a human decision taken in the UI, so code states what is there and judges nothing. - # It is surfaced on every run rather than left invisible, since it is the field that decides who the rules do not apply to. - local bypass - bypass="$(jq -r '[.bypass_actors[]? | "\(.actor_type) \(.actor_id) \(.bypass_mode)"] | join("; ")' <<<"$live")" - note "ruleset '$rname' bypass list: ${bypass:-none} (not managed by this script)" - # Every parameterized rule is compared on its whole parameters object rather than on selected fields. - # Naming fields one at a time meant a payload could declare a parameter the check never read. - # Review-thread resolution, stale-review dismissal, and the status-check policy flags all went unverified that way. - # Comparing the object keeps the check payload-driven: a parameter added to a payload is audited with no code change. - # Keys are sorted on both sides, so key order from the API cannot read as drift. - # Set-like arrays are sorted too, since the API guarantees no order and the previous per-field comparison sorted them explicitly. - # Dropping that would turn array order into false drift. - # A scalar array sorts directly, and required_status_checks sorts by context, its identifying field. - local ptypes norm - # The walk/1 builtin arrived in jq 1.6, so it is defined here rather than called. - # A host on jq 1.5 would otherwise not degrade, it would fail to compile the filter. - # The check_ruleset function would then report drift on every parameterized rule it never actually compared. - # That is the inverse of the false clean this comparison was written to close, so the portable definition is worth its length. - norm='def w(f): . as $in - | if type == "object" then reduce keys_unsorted[] as $k ({}; . + { ($k): ($in[$k] | w(f)) }) | f - elif type == "array" then map(w(f)) | f - else f end; - def n: w(if type=="array" then (if length==0 then . elif (all(.[]; type=="string" or type=="number")) then sort elif (all(.[]; type=="object" and has("context"))) then sort_by(.context) else . end) else . end); n' - ptypes="$(jq -r '[.rules[] | select(has("parameters")) | .type] | .[]' "$file")" - while IFS= read -r t; do - [ -z "$t" ] && continue - # shellcheck disable=SC2016 # $t is a jq --arg variable, not a shell expansion - want="$(jq -S -c --arg t "$t" "[.rules[] | select(.type==\$t) | .parameters] | first | $norm" "$file")" - # shellcheck disable=SC2016 - got="$(jq -S -c --arg t "$t" "[.rules[] | select(.type==\$t) | .parameters] | first | $norm" <<<"$live")" - assert "'$rname' rule '$t' parameters match the payload" test "$got" = "$want" - done <<<"$ptypes" -} - -check_settings() { - local live key want got private wantdisc - if [ ! -e "$settings_file" ]; then fail "settings payload $settings_file missing"; return; fi - if ! live="$(gh api "repos/$repo")"; then fail "could not read repository settings"; return; fi - # Static settings are driven from settings.json, so the check never drifts from the file. - # Add a key there and it is audited here automatically. - # The payload is parsed into a variable before the loop rather than streamed from a process substitution. - # A jq failure inside `done < <(...)` leaves the loop body unexecuted without tripping set -e. - # Every static setting would then report as checked and passing while nothing was compared, a false clean. - local pairs - if ! pairs="$(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$settings_file")"; then - fail "settings payload $settings_file did not parse"; return - fi - # A payload that parses to nothing is a floor failure rather than a clean run, so it is asserted. - if [ -z "$pairs" ]; then fail "settings payload $settings_file declares no keys"; return; fi - 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 <<<"$pairs" - # 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 - # The vulnerability-alerts endpoint returns 204 when enabled and 404 when disabled, so probe it with gh_ok. - # The automated-security-fixes endpoint returns a JSON body of { enabled, paused }. - # That one is 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 - fail "Dependabot automated security updates - could not read the setting" - fi -} - -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