Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions AUDIT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,10 +74,17 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions:
&& echo "settings: in sync" || echo "settings: DRIFT"
```

- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` and `bypass_actors[]` before diffing so a reordered but equivalent ruleset does not read as drift):
- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` on each rule's whole content before diffing, so a reordered but equivalent ruleset does not read as drift). The compared subset is `name`, `target`, `enforcement`, `conditions` and `rules`, and `bypass_actors` sits deliberately outside it, which is the same subset and the same sort key [`spec/audit.py`][audit-runner] uses. Who may bypass a ruleset is a per-repository human decision taken in the UI, no payload declares one, and [`repo-config/configure.sh`][repo-config] treats it that way in both modes, writing the live list back unchanged on `apply` and reporting it without asserting on `check`. Comparing it here would contradict that and report a ruleset finding against every repository that has any bypass actor, which is the field's normal state rather than a deviation:

```sh
norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)'
# bypass_actors stays outside the projection, since no payload declares one and jq cannot sort the null that leaves.
# Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by.
# Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift.
# canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys.
# It recurses rather than calling walk/1, which arrived in jq 1.6, and no declared floor puts a host above that.
# A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid.
canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;'
norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)'
# Model-aware expected payload: an operational repo's develop ruleset diffs against
# operational/develop.json (registry workflowModel; the same selection audit.py makes).
model=$(jq -r --arg n "<repo>" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' registry/repos.json)
Expand All@@ -92,7 +99,7 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions:
[ "$count" -eq 1 ] || { echo "$b: expected exactly 1 ruleset, found $count (defect/drift)"; continue; }
id=$(jq --arg n "$b" '.[] | select(.name==$n) | .id' <<<"$rulesets")
diff <(jq -S "$norm" "$file") \
<(gh api "repos/<owner>/<repo>/rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \
<(gh api "repos/<owner>/<repo>/rulesets/$id" --jq '{name,target,enforcement,conditions,rules}' | jq -S "$norm") \
&& echo "$b: in sync" || echo "$b: DRIFT"
done
```
Expand Down
3 changes: 2 additions & 1 deletion docs/repo-config-carry.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,8 +41,9 @@ for name in develop main; do
count=$(jq --arg n "$name" '[.[] | select(.name==$n)] | length' <<<"$rulesets")
[ "$count" -eq 1 ] || { echo "expected exactly 1 ruleset named $name, found $count (drift)" >&2; exit 1; }
id=$(jq --arg n "$name" '.[] | select(.name==$n) | .id' <<<"$rulesets")
# bypass_actors is left out, since a payload that declared one would assert this repo's bypass list against every repo diffed on it.
gh api "repos/$repo/rulesets/$id" \
--jq '{name, target, enforcement, bypass_actors, conditions, rules}' \
--jq '{name, target, enforcement, conditions, rules}' \
| jq -S --indent 4 '.' > "$out"
done
```
Expand Down
2 changes: 1 addition & 1 deletion repo-config/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

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`) diffs the live rulesets against. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing diffs it: who may bypass a ruleset is a human decision taken in the UI, which `configure.sh` preserves on `apply` and reports without asserting on `check`.
- `operational/develop.json`: the `develop` ruleset for **operational** repos (registry `workflowModel: operational`), taking direct signed pushes with no PR gate. Present in operational repos only, since a `release` repo does not have it. See "Rulesets" below.
- `configure.sh`: **hosted in the hub and run from a hub checkout, not carried here**, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. 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, and it resolves every payload path against its own directory rather than against the target repo, so a run from a hub checkout compares this repo against the hub's payloads and never reads the copies above. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. 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, falling back to inference from the carried `develop` payload where there is no registry to consult and aborting rather than guessing on an ambiguous layout. Pass the model rather than leaving it to the lookup. A hub checkout has the registry, so a repo the registry does not yet name resolves through `defaults.workflowModel` to `release`, which applies the release `develop` ruleset to an operational repo. Both arguments are optional to the parser rather than to the caller, which is the hazard: a lone positional naming a model is read as the model, so `configure.sh check operational` resolves against whichever repository the shell is in rather than erroring on a missing target.

Expand Down