Skip to content

Ship settings as a weekly release, rolled out on publish - #51

Merged
gavinbee merged 7 commits into
mainfrom
34-settings-release-and-rollout
Sep 6, 2026
Merged

Ship settings as a weekly release, rolled out on publish#51
gavinbee merged 7 commits into
mainfrom
34-settings-release-and-rollout

Conversation

@gavinbee

@gavinbee gavinbee commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #34

What & why

GitHub's inactivity rule switched reconcile-repo-defaults.yml off 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 the repo-created dispatch died alongside the cron; that is how officials-admin came to exist with no org settings at all.

Split it in two, so neither half can go dormant:

  • release.yml publishes a settings-YYYY-MM-DD release weekly, whether or not settings.yml changed. 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 checks out a released tag and applies it 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.

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 for months.

Also closes the smaller note on the issue: create-repo.sh now prints the ghq get line for the layout docs/development.md documents.

Full rationale, alternatives and open items: docs/design/0003-settings-releases-and-rollout.md.

A second GitHub App

An event raised by GITHUB_TOKEN starts no further workflow run, so a release cut with it would never reach rollout.yml. An App installation token raises real events, so release: published is all the rollout needs.

Reusing swimblocks-reconciler for 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:

App Used by Installed on Permissions
swimblocks-reconciler rollout.yml All repositories Metadata read; Administration write
swimblocks-releaser (new) release.yml swimblocks/.github only Metadata read; Contents write

Before this can merge

release.yml fails at its first step until swimblocks-releaser exists and RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY are set on this repo. The runbook is in docs/reconciler.md.

Testing

  • ruff check . clean.
  • pytest -q — 32 passed, including new coverage for summary_table and parse_args.
  • Both new workflows parse as YAML; every run: block passes bash -n.
  • bash -n scripts/create-repo.sh clean.

Not yet exercised end-to-end on GitHub. After the App exists, one workflow_dispatch of release.yml should publish the first tag and start a rollout.yml run behind it — a release with no rollout behind it means the token was GITHUB_TOKEN, not the App.

Checklist

  • Linked the issue this PR closes
  • Tests added/updated and passing
  • ruff check . clean (Python repos)
  • Docs/README updated if user-facing behaviour changed

🤖 Generated with Claude Code

https://claude.ai/code/session_01CgLE8cg9huy2EZnRoXhmwp

gavinbee and others added 3 commits September 5, 2026 13:01
`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
Comment thread .github/workflows/release.yml Outdated
Comment thread docs/reconciler.md Outdated
gavinbee and others added 4 commits September 5, 2026 23:36
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
@gavinbee
gavinbee marked this pull request as ready for review September 6, 2026 04:02
@gavinbee
gavinbee merged commit 92cf9a4 into main Sep 6, 2026
1 check passed
@gavinbee
gavinbee deleted the 34-settings-release-and-rollout branch September 6, 2026 04:02
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reconcile-repo-defaults workflow is disabled, so new repos miss org settings

1 participant