Ship settings as a weekly release, rolled out on publish - #51
Merged
Conversation
`reconcile-repo-defaults.yml` was switched off by GitHub's inactivity rule and stayed off. Its whole effect was on other repositories, so it produced no event in this repo and could never reset the 60-day clock — nine successful Monday runs inside the quiet window did not count, because workflow runs are not repository activity. Disabling is per-workflow, so the `repo-created` dispatch died with the cron, which is how `officials-admin` was created with no settings at all. Split it in two, so neither half can go dormant: - `release.yml` publishes a `settings-YYYY-MM-DD` release weekly. It is schedule-triggered, but publishing a release *is* repository activity, so it keeps its own clock reset the way `swim-club-tech-survey` does. - `rollout.yml` applies a released tag across the org. It carries no `schedule`, so inactivity disabling cannot reach the path that actually changes repos. Drift-catching and the `repo-created` hook move here. `release.yml` dispatches the rollout explicitly rather than leaning on `release: published`, because an event raised by `GITHUB_TOKEN` starts no further workflow run. Cutting the release with the App token would fix that too, but only by adding Contents write across the whole org to a credential that only needs to change settings. `apply-settings.py` gains `--version` and `--summary-file`, so every rollout leaves a repo -> result -> version table: nothing on a repo records which settings version it is on, which is why `officials-admin` went unnoticed. Also closes the smaller note on the issue: `create-repo.sh` now prints the `ghq get` line for the layout `docs/development.md` documents. Closes #34 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
An event raised by GITHUB_TOKEN starts no further workflow run, so the weekly release reached `rollout.yml` only via an explicit `gh workflow run` dispatch — two trigger paths on the rollout, of which one never fired automatically, plus `actions: write` on the workflow token. A GitHub App installation token raises real events. Reusing `swimblocks-reconciler` for that was the wrong shape: it is installed on every repo in the org, so Contents write there is org-wide push access on a credential that only needs Administration. A second App, `swimblocks-releaser`, carries Metadata read plus Contents write and is installed on `.github` alone, where `main` is already gated by CODEOWNERS review. `release: published` becomes the rollout's only automated trigger; the dispatch step and `actions: write` come out. `release.yml` fails at its first step until the App exists and RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY are set. Setup and key rotation for both Apps are in docs/reconciler.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
GitHub now shows a Client ID beside the App ID and recommends it for minting installation tokens, so the runbook's "note the App ID" leaves the next person choosing between two values on the same page. `create-github-app-token` accepts either in `app-id`, so nothing in the workflows changes and the existing numeric `APP_ID` keeps working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
gavinbee
commented
Sep 6, 2026
gavinbee
commented
Sep 6, 2026
Review feedback on #51: the comment on the app-token step explained the alternatives from the design doc rather than the code in front of the reader. Same for the header's account of how the previous reconciler died. Keep the part that is load-bearing — the cadence is a keepalive, so dropping the schedule or making the release conditional breaks it — and leave the rest to 0003. Also answer the question the runbook provoked: its verification steps dispatch workflows, which GitHub only offers on `main`, so neither can be run from a branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
"Trigger rollout.yml via workflow_dispatch" named a mechanism, not an action, and left the reader to work out where to trigger it and what to look at afterwards. Both verification steps now give the command and say what a pass looks like. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
`workflow_dispatch` only lists workflows already on `main`, so rollout.yml cannot be exercised before #51 merges. A `push:` trigger scoped to this branch fires it, and the resolve step falls back to the pushed commit because a push carries no release tag. Both blocks are marked TEMPORARY and come out before merge — revert this commit. NO-ISSUE: scaffolding for testing #51, reverted before merge Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp
This reverts commit e11bbce.
gavinbee
marked this pull request as ready for review
September 6, 2026 04:02
This was referenced Sep 6, 2026
gavinbee
added a commit
that referenced
this pull request
Sep 6, 2026
## What & why Tidies `0003`'s Open items, which came out of #51 carrying two entries that were no longer accurate. **Custom properties → [#53](#53 The entry said the idea "needs the property defined at org level first" and left availability as the thing to confirm. Availability is confirmed — the org's endpoints respond, with nothing defined yet: ``` gh api orgs/swimblocks/properties/schema # [] gh api repos/swimblocks/.github/properties/values # [] gh api orgs/swimblocks/properties/values # every repo, "properties": [] ``` So the entry now links the issue and names what that issue actually has to settle: whether `swimblocks-reconciler`, which is installed org-wide, should hold a Custom properties write permission. `AGENTS.md` §4 wants an aspirational item pointing at an issue rather than sitting as prose. **Release asset → decided, moved into Design.** This was recorded as open but is a no. The tag already addresses the exact file (`git show <tag>:.github/settings.yml`, or the contents API with `?ref=<tag>`), and `rollout.yml` reads it by checking the tag out, so an asset would be a fourth copy of something reachable three ways. It now sits in Design as a decision, with the condition that would change the answer — something outside git needing the file by URL — and the contrast with `swim-club-tech-survey`, where the attached CSV is the product people come to the release page for. That leaves one genuine open item, the rollout failure policy, which is recorded as intended behaviour rather than a question. ## Testing Docs only — no code touched. `ruff check .` and `pytest -q` unaffected. ## Checklist - [x] Linked the issue this PR closes — n/a, `no-issue` - [x] Tests added/updated and passing — n/a, docs only - [x] `ruff check .` clean (Python repos) — n/a, no code changed - [x] Docs/README updated if user-facing behaviour changed NO-ISSUE: design-doc bookkeeping following #51 and #53 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
4 tasks
gavinbee
added a commit
that referenced
this pull request
Sep 6, 2026
…a query (#55) Closes #53 ## What & why The job summary from #51 says what one rollout did. Nothing on a repo said what state it was *in*, so "which repos are behind" meant reading run logs and trusting no repo had been created since. That gap is how `officials-admin` sat unreconciled from creation until someone noticed by hand — the failure that opened #34. With `--version`, `apply-settings.py` now PATCHes the tag onto each repo as the `settings_version` repository custom property. The org-wide values endpoint then answers the question in one call: ```bash export WANT=$(gh release view -R swimblocks/.github --json tagName --jq .tagName) gh api orgs/swimblocks/properties/values --paginate \ --jq '.[] | select(([.properties[] | select(.property_name == "settings_version") | .value] | first) != env.WANT) | .repository_full_name' ``` **Per repo, not the org batch endpoint.** `PATCH /repos/{owner}/{repo}/properties/values` is the entirety of what the repository-level Custom properties permission grants — one endpoint, nothing else. `PATCH /orgs/{org}/properties/values` would do all seven repos in a single call but rides on the *organization* Custom properties permission, which also carries create, update and delete of every property definition in the org. That is the same test #51 applied when it put the releaser in its own app rather than widening the reconciler, so seven calls it is. **Only a repo that came through clean is stamped.** The value means "this repo was taken through the whole of `settings.yml` at this tag", so a repo with drift left on it must not claim it — leaving it unstamped is what keeps it in the query's output. Runs without `--version` stamp nothing: `create-repo.sh` applies whatever is on `main`, and there is no release to name. **A failed write fails the run** rather than skipping the way branch protection does. Every cause — property not defined, permission not granted, `values_editable_by` locking out the repository endpoint — is a setup gap nobody should sit in, so red is the right signal. It does mean the setup below has to be in place before merge, not after; it is. ### Also fixed: the permission table was wrong It listed Metadata read and Administration write. The live installation has held `issues: write` since label reconciliation landed in #48, undocumented: ``` gh api orgs/swimblocks/installations --jq '.installations[] | {app_slug, permissions}' {"app_slug":"swimblocks-reconciler","permissions":{"administration":"write","issues":"write","metadata":"read"}} ``` Rather than add a row beside a wrong one, the table is now permission → endpoint → reason, so the next addition has somewhere to go. ### Setup this depends on `rollout.yml` fails on every repo without it. Both are done on `swimblocks` and the runbook in `docs/reconciler.md` carries them for the next org: 1. Define `settings_version` at org level — string, `required=false`, **no default** (a default is reported for repos that were never reconciled and reads as an all-clear), and `values_editable_by=org_and_repo_actors`. That last one is not optional: it defaults to `org_actors`, which is org owners and property managers only, and the reconciler writes through the repository endpoint as a repo actor. The 403 it would produce is indistinguishable from a missing permission. 2. Grant `swimblocks-reconciler` Custom properties: Read & write, **and accept the installation request**. Editing an app's permissions changes nothing about its tokens until an org owner accepts; that is the usual reason a freshly granted permission still 403s. Live state, for the record: ``` gh api orgs/swimblocks/properties/schema/settings_version value_type=string required=false values_editable_by=org_and_repo_actors gh api orgs/swimblocks/installations swimblocks-reconciler: administration=write issues=write metadata=read repository_custom_properties=write ``` ## Testing - `ruff check .` clean; `pytest -q` 39 passed (5 new: payload shape, the property name the query depends on, the PATCH endpoint and body, failure reporting, stderr capture). - The drift query above was run against the live org and correctly listed all 7 repos as behind, nothing being stamped yet. - The reconciler's live permissions were read back from `orgs/swimblocks/installations`, which is where the `issues: write` discrepancy came from. - The property definition and the app's granted permissions were both read back after setup, and are quoted above. - Not exercised end-to-end. Writing the value from a local `gh` proves nothing about the app: an org-owner token satisfies `values_editable_by` whatever it is set to, so it cannot distinguish the repo-actor path the installation token takes. The first real proof is the rollout after merge — expect `OK settings_version: <tag>` per repo, and the drift query returning nothing. ## Checklist - [x] Linked the issue this PR closes - [x] Tests added/updated and passing - [x] `ruff check .` clean (Python repos) - [x] Docs/README updated if user-facing behaviour changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #34
What & why
GitHub's inactivity rule switched
reconcile-repo-defaults.ymloff and it stayed off. The reconciler's whole effect was on other repositories, so it produced no event in this repo and could never reset the 60-day clock — nine successful Monday runs inside the quiet window did not count, because workflow runs are not repository activity. Disabling is per-workflow, so therepo-createddispatch died alongside the cron; that is howofficials-admincame to exist with no org settings at all.Split it in two, so neither half can go dormant:
release.ymlpublishes asettings-YYYY-MM-DDrelease weekly, whether or notsettings.ymlchanged. It is schedule-triggered, but publishing a release is repository activity, so it keeps its own clock reset the wayswim-club-tech-surveydoes.rollout.ymlchecks out a released tag and applies it across the org. It carries noschedule, so inactivity disabling cannot reach the path that actually changes repos. Drift-catching and therepo-createdhook move here.apply-settings.pygains--versionand--summary-file, so every rollout leaves a repo → result → version table. Nothing on a repo records which settings version it is on, which is whyofficials-adminwent unnoticed for months.Also closes the smaller note on the issue:
create-repo.shnow prints theghq getline for the layoutdocs/development.mddocuments.Full rationale, alternatives and open items:
docs/design/0003-settings-releases-and-rollout.md.A second GitHub App
An event raised by
GITHUB_TOKENstarts no further workflow run, so a release cut with it would never reachrollout.yml. An App installation token raises real events, sorelease: publishedis all the rollout needs.Reusing
swimblocks-reconcilerfor that would have been the wrong shape — it is installed on every repo in the org, so Contents write there is org-wide push access on a credential that only needs Administration. So there are two apps, each as narrow as its job:swimblocks-reconcilerrollout.ymlswimblocks-releaser(new)release.ymlswimblocks/.githubonlyBefore this can merge
release.ymlfails at its first step untilswimblocks-releaserexists andRELEASE_APP_ID/RELEASE_APP_PRIVATE_KEYare set on this repo. The runbook is indocs/reconciler.md.Testing
ruff check .clean.pytest -q— 32 passed, including new coverage forsummary_tableandparse_args.run:block passesbash -n.bash -n scripts/create-repo.shclean.Not yet exercised end-to-end on GitHub. After the App exists, one
workflow_dispatchofrelease.ymlshould publish the first tag and start arollout.ymlrun behind it — a release with no rollout behind it means the token wasGITHUB_TOKEN, not the App.Checklist
ruff check .clean (Python repos)🤖 Generated with Claude Code
https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp