feat(settings): add estate-wide settings drift detector - #790
Conversation
Repository settings are not in git. Nothing reviews them, nothing diffs them, and a change leaves no artefact — no commit, no PR, no run. The estate measures 97-100% compliant with config/settings/repo.json at any given moment, so re-applying the canon gains almost nothing; what was missing is anything that NOTICES a setting moving. Measured 2026-09-14: the Actions allowlist on 239 repositories moved from `selected` + EMPTY patterns_allowed + verified_allowed=false to a 92-pattern allowlist with verified_allowed=true, BETWEEN a census and the write meant to fix it. Nobody could say when or by whom, because no instrument was watching. Separately, ZERO of the 301 repos calling mirror.yml had all seven *_MIRROR_ENABLED variables set and roughly a third had NONE, so their mirror runs reported GREEN while backing up nowhere — the forge jobs are gated on `if: vars.<FORGE>_MIRROR_ENABLED` and a skip is not a red. Reports only. Opens/updates one tracking issue and never mutates a setting or another repository. Two traps handled explicitly in the code: - The scan loop uses process substitution, NOT `gh repo list | while read`. A pipe runs the loop body in a subshell, so every drift flag is discarded on exit and the detector always reports success — a drift detector structurally incapable of reporting drift. Proved with a control before shipping: process-substitution 1, pipe 0. - The workflow fails loudly when its token cannot read actions/permissions. That read is admin-scoped and security_and_analysis is absent from LIST endpoints entirely, so a weak token yields a FALSE CLEAN — the guard would answer a different question than its consumer. Verified: both `uses:` refs are present verbatim in actions.lock (the lockfile matches literal ref strings, not resolved commits, so an unlisted SHA startup-kills the run); positive control over 6 repos returned 31 drift rows and exit 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QMTyDv9CoJo5PfeNzyp519
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (2)
📝 SummarySummary by CodeRabbit
WalkthroughAdds a report-only settings drift scanner and a scheduled workflow. The scanner compares repositories with ChangesSettings drift detection
Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant Workflow
participant Scanner
participant GitHubAPI
participant IssueTracker
Workflow->>Scanner: Run check-settings-drift.sh
Scanner->>GitHubAPI: Read repository settings
GitHubAPI-->>Scanner: Return live settings
Scanner-->>Workflow: Return drift.tsv and exit code
Workflow->>IssueTracker: Create issue or add comment when drift is found
Suggested reviewers: ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit checks each setting bright Comment |
|



Why this, and not a re-apply of the canon
Repository settings are not in git. Nothing reviews them, nothing diffs them, and a change leaves no artefact — no commit, no PR, no run.
The estate measures 97–100% compliant with
config/settings/repo.jsonat any given moment, so re-applying the canon gains almost nothing. What was missing is anything that notices a setting moving.Two measurements from 2026-09-14 motivate it:
allowed_actions: selectedwith an emptypatterns_allowedandverified_allowed: false, to a 92-pattern allowlist withverified_allowed: true— between a census and the write meant to fix it. Nobody could say when, by whom, or whether it was deliberate, because no instrument was watching.mirror.ymlhad all seven*_MIRROR_ENABLEDvariables, and roughly a third had none. Each forge job is gated onif: vars.<FORGE>_MIRROR_ENABLED == 'true', so those runs reported green while backing up nowhere. A skip is not a red.Why the first one matters more than it looks: an empty
patterns_allowedrefuses every third-party action, including transitively through a reusable workflow, because actions are judged against the caller repo's allow-list. Runs then die atstartup_failurewithjobs.total_count == 0and emit no check run — a required context never reports and the repo looks greener than a healthy one.What it does
scripts/check-settings-drift.shcompares live settings against the canon and emits TSV (repo,key,expected,actual); exit0clean,1drift,2usage/environment. It checks therepoblock (skippingper_repo_deltas_allowed, andsecurity_and_analysisvia the per-repo GET, skipped on private per the canon's ownrepo_private_overrides), the Actions permissions block, workflow permissions, and — only where.github/workflows/mirror.ymlexists — the seven forge variables..github/workflows/settings-drift-detect.ymlruns it weekly and on dispatch, uploads the TSV, and opens or updates a single tracking issue. It reports only — it never mutates a setting or another repository, consistent with the estate guardrail that unattended cross-repo mutation is a human decision. Settings are exactly where that guardrail matters most.Two traps handled explicitly
gh repo list | while read. A pipe runs the loop body in a subshell, so every drift flag set inside is discarded when the subshell exits and the script always reports success — a drift detector structurally incapable of reporting drift. Found before shipping and proved with a control: process-substitution1, pipe0.actions/permissionsis admin-scoped andsecurity_and_analysisis absent from LIST endpoints entirely, so a token without admin scope produces a clean result it has no right to. The workflow probes that read first and fails with exit 2 rather than reporting an empty sweep — otherwise the guard would answer a different question than its consumer.Verification
uses:refs are present verbatim in.github/workflows/actions.lock. The lockfile matches literal ref strings rather than resolved commits, so an unlisted SHA startup-kills the run; my first draft pinned anupload-artifactSHA the lockfile does not carry, and that was caught before pushing.bash -nclean; workflow parses.proven-tests-and-benchesmissing all seven mirror variables, which is the precise fault class this was written for.Calibration — what a hit does and does not prove
A row means live differs from canon. It does not by itself mean anything is broken: the canon documents
per_repo_deltas_allowed, and private repos legitimately differ. Equally, a clean result is not proof CI is healthy — a startup failure can be event-specific and invisible in every setting and every file (measured the same day: the same commit that died withjobs=0onpushstarted 7 jobs onworkflow_dispatch). The preamble in the script states this at length so a future reader does not over-read a clean sweep.🤖 Generated with Claude Code
https://claude.ai/code/session_01QMTyDv9CoJo5PfeNzyp519