From f10620f2188d65e3c90043df28bd8e76899f05d3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 3 Nov 2026 09:56:46 -0500 Subject: [PATCH] bot: retire simtabi-bot; refresh-bot becomes the single CI app The estate is held to two apps: simbi-github-cli for local gh and git, and one for CI. Which app survived was decided by measurement, not by which was newer: refresh-bot contents=write metadata=read pull_requests=write (3) simtabi-bot + checks, issues, statuses, workflows=write (7) refresh-bot is the narrower app, and 35 workflow files across the estate already used it against 2 for simtabi-bot -- so it is both the safer and the cheaper survivor. simtabi-bot was also effectively unused: the merge gate skipped every run because SIMTABI_BOT_CLIENT_ID was never set anywhere, and the reconciler had always fallen through to refresh-bot. policy.json, resolve.py and the bot-token action are app-agnostic -- they take a client ID and a key as inputs -- so only the credentials the two workflows pass changed, plus the ceiling. The ceiling now equals what refresh-bot actually holds, and that is the whole safety property: its key is an organisation-level secret on every owned org, so a ceiling wider than the app would put the extra reach within range of every workflow in the estate. workflows:write is the one that matters, because with it a release job could rewrite the CI that gates it. dependabot-merge is left defined but inoperative, and the reason is a permission rather than a bug: merge.py reads commits/{sha}/check-runs and /status, which need checks:read and statuses:read, and refresh-bot holds neither -- so decide() sees no checks and refuses every pull request. That is fail-closed. Granting those two reads is the owner's step. Without workflows:write a pull request touching .github/workflows/ can never be merged by this token; those go to `@dependabot merge`, which Dependabot performs with its own credentials. CeilingMatchesTheAppTest pins the ceiling to the app's permission set and refuses workflows, issues, administration, members and secrets by name. Proven red before being trusted: planting workflows:write in the ceiling fails 6 tests, reverting goes green. 24 tests pass. --- .github/actions/bot-token/action.yml | 12 ++-- .github/workflows/dependabot-merge.yml | 30 ++++---- .github/workflows/dependabot-reconcile.yml | 41 ++++------- README.md | 2 +- bot/README.md | 80 ++++++++++++++------- bot/policy.json | 12 ++-- bot/resolve.py | 2 +- bot/tests/test_resolve.py | 84 ++++++++++++---------- dependabot/README.md | 21 +++--- 9 files changed, 157 insertions(+), 127 deletions(-) diff --git a/.github/actions/bot-token/action.yml b/.github/actions/bot-token/action.yml index 49ddcb9..729b5fe 100644 --- a/.github/actions/bot-token/action.yml +++ b/.github/actions/bot-token/action.yml @@ -1,6 +1,6 @@ -name: simtabi-bot token +name: CI bot token description: > - Mint a simtabi-bot installation token narrowed to what bot/policy.json grants the named job on one owner. + Mint a refresh-bot installation token narrowed to what bot/policy.json grants the named job on one owner. Refuses a job or owner the policy does not allow. Needs this repository checked out at the workspace root. inputs: @@ -11,10 +11,10 @@ inputs: description: Org the token acts on required: true client-id: - description: simtabi-bot client ID + description: refresh-bot client ID (vars.REFRESH_APP_CLIENT_ID) required: true private-key: - description: simtabi-bot private key + description: refresh-bot private key (secrets.REFRESH_APP_PRIVATE_KEY) required: true outputs: @@ -45,7 +45,3 @@ runs: repositories: ${{ steps.grant.outputs.repositories }} permission-contents: ${{ steps.grant.outputs.permission-contents }} permission-pull-requests: ${{ steps.grant.outputs.permission-pull-requests }} - permission-issues: ${{ steps.grant.outputs.permission-issues }} - permission-workflows: ${{ steps.grant.outputs.permission-workflows }} - permission-checks: ${{ steps.grant.outputs.permission-checks }} - permission-statuses: ${{ steps.grant.outputs.permission-statuses }} diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml index e0dba03..7ac4d9d 100644 --- a/.github/workflows/dependabot-merge.yml +++ b/.github/workflows/dependabot-merge.yml @@ -7,10 +7,16 @@ name: Dependabot merge gate # Dry run unless `apply` is ticked on a manual run, or, for scheduled runs, the repository # variable DEPENDABOT_MERGE_APPLY is 'true'. Going live is that variable, not a code change. # -# Runs as simtabi-bot, job `dependabot-merge` in bot/policy.json. Until the app exists (variable -# SIMTABI_BOT_CLIENT_ID and secret SIMTABI_BOT_PRIVATE_KEY in the `simtabi-bot` environment), every -# run posts a notice and skips the merge jobs instead of failing one per org. Creating the app is the -# owner's step: bot/README.md. +# Runs as refresh-bot, job `dependabot-merge` in bot/policy.json. REFRESH_APP_CLIENT_ID (variable) and +# REFRESH_APP_PRIVATE_KEY (secret) are organisation-level on every owned org; without both, the run +# posts a notice and skips instead of failing once per org. +# +# This gate cannot merge anything yet, and the reason is a permission rather than a bug: merge.py reads +# commits/{sha}/check-runs and /status, which need checks:read and statuses:read, and refresh-bot holds +# neither. decide() therefore sees no checks and refuses every pull request -- fail-closed. Granting the +# app those two read permissions is the owner's step (bot/README.md). refresh-bot also has no +# workflows:write by design, so a pull request touching .github/workflows/ can never be merged by this +# token; leave those to `@dependabot merge`, which Dependabot performs with its own credentials. on: schedule: @@ -38,7 +44,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 # The bot's key is an environment secret, deployable from main only. - environment: simtabi-bot permissions: contents: read outputs: @@ -49,17 +54,17 @@ jobs: - name: Check the bot credentials are configured id: credentials env: - HAS_CLIENT_ID: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' }} - HAS_PRIVATE_KEY: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY != '' }} + HAS_CLIENT_ID: ${{ vars.REFRESH_APP_CLIENT_ID != '' }} + HAS_PRIVATE_KEY: ${{ secrets.REFRESH_APP_PRIVATE_KEY != '' }} run: | if [ "$HAS_CLIENT_ID" = "true" ] && [ "$HAS_PRIVATE_KEY" = "true" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" exit 0 fi missing=() - [ "$HAS_CLIENT_ID" = "true" ] || missing+=("variable SIMTABI_BOT_CLIENT_ID") - [ "$HAS_PRIVATE_KEY" = "true" ] || missing+=("secret SIMTABI_BOT_PRIVATE_KEY") - msg="Merge gate skipped: ${missing[*]} not configured in the simtabi-bot environment. See bot/README.md." + [ "$HAS_CLIENT_ID" = "true" ] || missing+=("variable REFRESH_APP_CLIENT_ID") + [ "$HAS_PRIVATE_KEY" = "true" ] || missing+=("secret REFRESH_APP_PRIVATE_KEY") + msg="Merge gate skipped: ${missing[*]} not set. Both are organisation-level on every owned org. See bot/README.md." echo "::notice title=Dependabot merge gate::$msg" echo "$msg" >> "$GITHUB_STEP_SUMMARY" echo "ready=false" >> "$GITHUB_OUTPUT" @@ -79,7 +84,6 @@ jobs: if: ${{ needs.accounts.outputs.ready == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 - environment: simtabi-bot permissions: contents: read strategy: @@ -101,8 +105,8 @@ jobs: with: job: dependabot-merge owner: ${{ matrix.org }} - client-id: ${{ vars.SIMTABI_BOT_CLIENT_ID }} - private-key: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY }} + client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} - name: Merge gate for ${{ matrix.org }} env: diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml index bdc33ad..33f3c88 100644 --- a/.github/workflows/dependabot-reconcile.yml +++ b/.github/workflows/dependabot-reconcile.yml @@ -31,37 +31,29 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 # Reads only whether each credential is set, never a value; needs the environment to see them at all. - environment: simtabi-bot permissions: contents: read outputs: orgs: ${{ steps.list.outputs.orgs }} ready: ${{ steps.credentials.outputs.ready }} steps: - # The pair is chosen on SIMTABI_BOT_CLIENT_ID alone, below and in every token step, so a client ID - # is never sent with the other app's key. That makes a half-configured bot the one real hazard: - # without this check it is twelve create-github-app-token failures instead of one notice. - - name: Check which bot credentials are configured + # Both halves are checked together, so a client ID is never sent without its key: without this + # it is twelve create-github-app-token failures instead of one notice. Only whether each is set + # reaches this step, never the key itself. + - name: Check the bot credentials are configured id: credentials env: - HAS_BOT_ID: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' }} - HAS_BOT_KEY: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY != '' }} - HAS_REFRESH_ID: ${{ vars.REFRESH_APP_CLIENT_ID != '' }} - HAS_REFRESH_KEY: ${{ secrets.REFRESH_APP_PRIVATE_KEY != '' }} + HAS_CLIENT_ID: ${{ vars.REFRESH_APP_CLIENT_ID != '' }} + HAS_PRIVATE_KEY: ${{ secrets.REFRESH_APP_PRIVATE_KEY != '' }} run: | - if [ "$HAS_BOT_ID" = "true" ]; then - if [ "$HAS_BOT_KEY" = "true" ]; then - msg="Running as simtabi-bot." - ready=true - else - msg="Reconcile skipped: variable SIMTABI_BOT_CLIENT_ID is set but secret SIMTABI_BOT_PRIVATE_KEY is not, so every org would fail to mint. Set the key, or unset the variable to fall back to Refresh Bot. See bot/README.md." - ready=false - fi - elif [ "$HAS_REFRESH_ID" = "true" ] && [ "$HAS_REFRESH_KEY" = "true" ]; then - msg="Running as Refresh Bot (deprecated): SIMTABI_BOT_CLIENT_ID is not set yet. See bot/README.md." + if [ "$HAS_CLIENT_ID" = "true" ] && [ "$HAS_PRIVATE_KEY" = "true" ]; then + msg="Running as refresh-bot." ready=true else - msg="Reconcile skipped: neither the simtabi-bot nor the Refresh Bot credential pair is complete in the simtabi-bot environment. See bot/README.md." + missing=() + [ "$HAS_CLIENT_ID" = "true" ] || missing+=("variable REFRESH_APP_CLIENT_ID") + [ "$HAS_PRIVATE_KEY" = "true" ] || missing+=("secret REFRESH_APP_PRIVATE_KEY") + msg="Reconcile skipped: ${missing[*]} not set. Both are organisation-level on every owned org. See bot/README.md." ready=false fi echo "ready=$ready" >> "$GITHUB_OUTPUT" @@ -83,11 +75,8 @@ jobs: if: ${{ needs.accounts.outputs.ready == 'true' }} runs-on: ubuntu-latest timeout-minutes: 60 - environment: simtabi-bot - # simtabi-bot once its client ID is in the environment, Refresh Bot until then. The pair is chosen on - # one condition, so a client ID is never sent with the other app's key. env: - BOT_CLIENT_ID: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' && vars.SIMTABI_BOT_CLIENT_ID || vars.REFRESH_APP_CLIENT_ID }} + BOT_CLIENT_ID: ${{ vars.REFRESH_APP_CLIENT_ID }} permissions: contents: read strategy: @@ -109,7 +98,7 @@ jobs: job: agent-kit-read owner: simtabi client-id: ${{ env.BOT_CLIENT_ID }} - private-key: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' && secrets.SIMTABI_BOT_PRIVATE_KEY || secrets.REFRESH_APP_PRIVATE_KEY }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} - uses: actions/checkout@v7 with: repository: simtabi/agent-kit @@ -127,7 +116,7 @@ jobs: job: dependabot-reconcile owner: ${{ matrix.org }} client-id: ${{ env.BOT_CLIENT_ID }} - private-key: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' && secrets.SIMTABI_BOT_PRIVATE_KEY || secrets.REFRESH_APP_PRIVATE_KEY }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} - name: Reconcile ${{ matrix.org }} env: diff --git a/README.md b/README.md index 955016b..68a60df 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ that apply to every simtabi repository lacking its own copy. ## Documentation - [Health files](docs/health-files.md) — where each health file lives, why, and how the org copies are rendered from the masters in [`templates/health/`](templates/health/). -- [simtabi-bot](bot/README.md) — the one automation app: where its key lives, the per-job token policy, adding a job. +- [The CI bot](bot/README.md) — `refresh-bot`, the one automation app: where its key lives, the per-job token policy, adding a job. - [Dependabot control plane](dependabot/README.md) — the reconciler and the merge gate that run as the bot. ## License diff --git a/bot/README.md b/bot/README.md index df49c10..8011455 100644 --- a/bot/README.md +++ b/bot/README.md @@ -1,15 +1,28 @@ -# simtabi-bot +# The CI bot -> Every Simtabi automation runs here, as one GitHub App, `simtabi-bot`, with a token narrowed per job by `policy.json`. +> Every Simtabi CI automation runs as one GitHub App, `refresh-bot`, with a token narrowed per job by `policy.json`. -The app is defined in `imanimanyara/github-apps` (`manifests/simtabi-bot.json`, `docs/simtabi-bot.md`). Its -private key exists in one place: the `simtabi-bot` environment of this repository, deployable from `main` -only. No other repository holds it, so no other repository's workflows can mint its tokens. +**The app changed on 2026-11-03: `simtabi-bot` was retired and `refresh-bot` took its jobs.** The estate is +held to two apps — `simbi-github-cli` for local `gh` and `git`, and this one for CI — and the direction was +decided by measurement rather than by which app was newer: + +``` +refresh-bot contents=write metadata=read pull_requests=write (3) +simtabi-bot + checks, issues, statuses, workflows=write (7) +``` + +`refresh-bot` is the **narrower** app, and 35 workflow files across the estate already used it against 2 for +`simtabi-bot` — so it was both the safer and the cheaper survivor. Everything in this directory is +app-agnostic: `policy.json`, `resolve.py` and the `bot-token` action take a client ID and a key as inputs, so +only the credentials the workflows pass changed. + +Its key is an **organisation-level secret on every owned org**, which is the reach the 35 files need and the +reason the ceiling below must stay exactly what the app holds. The app is defined in +`imanimanyara/github-apps` (`manifests/refresh-bot.json`, `docs/refresh-bot.md`). ## How a job gets a token ```yaml -environment: simtabi-bot steps: - uses: actions/checkout@v7 with: @@ -17,12 +30,14 @@ steps: - id: token uses: ./.github/actions/bot-token with: - job: dependabot-merge + job: release-pr owner: laranail - client-id: ${{ vars.SIMTABI_BOT_CLIENT_ID }} - private-key: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY }} + client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} ``` +On GitHub Free a **private** repository cannot read organisation secrets and needs repository copies of both. + `bot-token` runs `resolve.py`, which looks the job up in `policy.json` and either refuses or hands `actions/create-github-app-token` exactly that job's permissions and repositories. It refuses: @@ -36,9 +51,21 @@ steps: | Job | Owners | Permissions | Repositories | |---|---|---|---| -| `dependabot-merge` | `*` | contents, pull requests, workflows (write); checks, statuses (read) | all | +| `dependabot-merge` | `*` | contents, pull requests (write) | all | | `dependabot-reconcile` | `*` | contents, pull requests (write) | all | | `agent-kit-read` | `simtabi` | contents (read) | `agent-kit` | +| `release-pr` | `*` | contents, pull requests (write) | all | +| `estate-read` | `*` | contents, pull requests (read) | all | +| `private-dep-read` | `simtabi` | contents (read) | `sis-sdk`, `docsmith` | +| `consumer-read` | `ichava` | contents (read) | `icon-browser`, `ichava.com` | +| `package-repo-push` | `simtabi` | contents, pull requests (write) | `homebrew-tap`, `scoop-bucket` | + +> **`dependabot-merge` is inoperative, and that is a permission rather than a bug.** `merge.py` reads +> `commits/{sha}/check-runs` and `/status`, which need `checks: read` and `statuses: read`; `refresh-bot` +> holds neither, so `decide()` sees no checks and refuses every pull request — fail-closed. Granting the app +> those two read permissions is the owner's step, and each org then approves them once. It also has no +> `workflows: write` by design, so a pull request touching `.github/workflows/` can never be merged by this +> token: leave those to `@dependabot merge`, which Dependabot performs with its own credentials. `ceiling` is the app's own permission set and must match the manifest in `github-apps`. A job that needs a new permission adds it to the manifest, the ceiling and the job in the same change, and each org approves the @@ -49,7 +76,7 @@ does not need to: a ceiling that claims a permission the app does not hold makes `create-github-app-token` answer 422 and the job fails on its first run, and a manifest that holds more than the ceiling only means the extra is never requested. Both directions are loud or harmless, never a token wider than the policy. Check the live app against the manifest after any edit with -`gh api apps/simtabi-bot --jq .permissions | bin/create-app --check-permissions manifests/simtabi-bot.json`. +`gh api apps/refresh-bot --jq .permissions | bin/create-app --check-permissions manifests/refresh-bot.json`. `repositories` narrows a job to named repositories of its owner. It is validated at load time, because getting it wrong is silent: an empty list resolves to `""`, which the action reads as *every* repository of @@ -60,25 +87,28 @@ would be sent to all twelve orgs. ## Adding a job 1. Add it to `policy.json` with a `why`, its owners, its permissions and, where it can, its repositories. -2. Call `bot-token` with that `job:` from a workflow in this repository, in a job with - `environment: simtabi-bot`. +2. Call `bot-token` with that `job:` from a workflow, passing `vars.REFRESH_APP_CLIENT_ID` and + `secrets.REFRESH_APP_PRIVATE_KEY`. 3. `python3 -m unittest discover -s bot/tests -v`. A workflow naming a job the policy lacks fails the tests, so a rename cannot wait for its schedule to find out. +4. If the job needs a permission outside the ceiling, **the ceiling does not move on its own.** Grant it on + the app first, let every org approve it, then widen `ceiling` and `REFRESH_BOT_HELD` in the tests in one + change. A ceiling claiming a permission the app lacks makes every run 422. + +## When a credential is missing -## Until the app exists +Both workflows post a notice and skip their matrix job rather than failing once per org, and both halves of +the pair are checked together so a client ID is never sent without its key: without that check it is ten +`create-github-app-token` failures instead of one notice. Each run's summary names the app it ran as. -Both workflows post a notice and skip their matrix job rather than failing once per org. The merge gate -skips while `SIMTABI_BOT_CLIENT_ID` or `SIMTABI_BOT_PRIVATE_KEY` is missing. The reconciler uses Refresh -Bot's `REFRESH_APP_*` until `SIMTABI_BOT_CLIENT_ID` is set, choosing both halves of the pair on that one -condition so a client ID is never sent with the other app's key — which makes a *half*-configured bot the -real hazard, and the one its readiness check is written against: a client ID with no key skips with a -notice saying so, instead of twelve identical minting failures. Each run's summary names the app it ran as. +**`REFRESH_APP_CLIENT_ID` and `REFRESH_APP_PRIVATE_KEY` are organisation-level on every owned org.** On +GitHub Free a *private* repository cannot read organisation secrets and needs repository copies of both, +which is why several private repositories carry their own. -**The first workflow run that names the environment creates it with no branch rule at all**, so every branch -can deploy to it, and the key would sit where a workflow on any branch could read it. That is the state -`simtabi-bot` was found in on 2026-10-28. The owner's setup (`github-apps` `docs/simtabi-bot.md`, -"Creating it") restricts it to `main`, and `bin/create-app` now refuses to store a key in an environment -that has no branch policy rather than checking only that the environment exists. +> **The retired app's key is still in this repository's `simtabi-bot` environment**, and deleting it is the +> owner's step — see `github-apps` `docs/simtabi-bot.md`. Nothing reads it now: no workflow here names +> `SIMTABI_BOT_*`, which `grep` over this repository confirms. A key nobody reads is still a key nobody +> rotates, so remove the environment when the app is deleted rather than leaving it to age. ## Tests diff --git a/bot/policy.json b/bot/policy.json index a280ed8..d2f3c29 100644 --- a/bot/policy.json +++ b/bot/policy.json @@ -1,19 +1,15 @@ { "version": 1, - "about": "Which simtabi-bot token each job may mint. A job not named here gets no token. Owners '*' means every org in dependabot/policy.json accounts.orgs; client accounts are refused whatever is written here.", + "about": "Which refresh-bot token each job may mint. A job not named here gets no token. Owners '*' means every org in dependabot/policy.json accounts.orgs; client accounts are refused whatever is written here. The app is refresh-bot (5110899) since 2026-11-03, when simtabi-bot was retired to keep the estate to two apps; this file and the bot-token action are app-agnostic, so only the credentials the workflows pass changed.", "ceiling": { "contents": "write", - "pull-requests": "write", - "issues": "write", - "workflows": "write", - "checks": "read", - "statuses": "read" + "pull-requests": "write" }, "jobs": { "dependabot-merge": { - "why": "Merge a policy-allowed Dependabot pull request once every check on its head is green. Workflows: write because GitHub refuses an app merge that changes .github/workflows/.", + "why": "Merge a policy-allowed Dependabot pull request once every check on its head is green. INOPERATIVE until refresh-bot is granted checks:read and statuses:read -- merge.py reads repos/{repo}/commits/{sha}/check-runs and /status, and neither is readable with the two permissions below, so decide() sees no checks and refuses every pull request. That is fail-closed, not silent. It also has no workflows:write, so a pull request touching .github/workflows/ can never be merged by this token: leave those to '@dependabot merge', which Dependabot performs with its own credentials.", "owners": "*", - "permissions": {"contents": "write", "pull-requests": "write", "workflows": "write", "checks": "read", "statuses": "read"} + "permissions": {"contents": "write", "pull-requests": "write"} }, "dependabot-reconcile": { "why": "Push chore/dependabot-standard and open or refresh its pull request. dependabot.yml is not under .github/workflows/, so no workflows permission.", diff --git a/bot/resolve.py b/bot/resolve.py index c6e7758..7d7ecb8 100644 --- a/bot/resolve.py +++ b/bot/resolve.py @@ -1,4 +1,4 @@ -"""Turn a job name and an owner into the narrowed simtabi-bot token request, or refuse. +"""Turn a job name and an owner into the narrowed refresh-bot token request, or refuse. python3 bot/resolve.py --job dependabot-merge --owner laranail >> "$GITHUB_OUTPUT" diff --git a/bot/tests/test_resolve.py b/bot/tests/test_resolve.py index 99866cf..1c6b14b 100644 --- a/bot/tests/test_resolve.py +++ b/bot/tests/test_resolve.py @@ -26,16 +26,18 @@ def write_policy(policy): class ResolveTest(unittest.TestCase): def test_merge_gets_exactly_its_permissions_on_every_repository(self): out = resolve.resolve(POLICY, ORGS, "dependabot-merge", "laranail") - self.assertEqual(out["permission-workflows"], "write") - self.assertEqual(out["permission-checks"], "read") - self.assertEqual(out["permission-issues"], "") + self.assertEqual(out["permission-contents"], "write") + self.assertEqual(out["permission-pull-requests"], "write") self.assertEqual(out["repositories"], "") + # It cannot reach .github/workflows/, by design: see the job's `why`. + self.assertNotIn("permission-workflows", out) def test_every_ceiling_permission_is_emitted_so_none_is_inherited(self): out = resolve.resolve(POLICY, ORGS, "dependabot-reconcile", "simtabi") self.assertEqual({k for k in out if k.startswith("permission-")}, {f"permission-{p}" for p in POLICY["ceiling"]}) - self.assertEqual(out["permission-workflows"], "") + read_only = resolve.resolve(POLICY, ORGS, "estate-read", "simtabi") + self.assertEqual(read_only["permission-contents"], "read") def test_a_job_limited_to_repositories_is_limited(self): out = resolve.resolve(POLICY, ORGS, "agent-kit-read", "simtabi") @@ -155,52 +157,62 @@ def test_every_workflow_that_mints_a_token_skips_when_the_credentials_are_not_re f"{name} mints a token without gating on a readiness check") -class RefreshBotReplacementTest(unittest.TestCase): - """The jobs that take over Refresh Bot's work, and the one property that makes that safe. +class CeilingMatchesTheAppTest(unittest.TestCase): + """The ceiling is refresh-bot's own permission set, and that is the whole safety property. + + simtabi-bot was retired on 2026-11-03 to keep the estate to two apps, and refresh-bot became the + single CI app. The two differ in a way that decides this file: + + refresh-bot contents=write metadata=read pull_requests=write (3) + simtabi-bot + checks, issues, statuses, workflows=write (7) - Refresh Bot holds three permissions -- contents=write, pull_requests=write, metadata=read - (`gh api apps/refresh-bot --jq .permissions`, measured 2026-11-03). simtabi-bot holds seven, - `workflows=write` among them. So replacing one with the other is only a narrowing if each - replacement job asks for no more than Refresh Bot ever had, and nothing but a test keeps that - true once the jobs are reachable from an org-level credential. + refresh-bot is the narrower app, and its key is organisation-level on every owned org -- so a + ceiling wider than it holds would put that extra reach within range of every workflow in the + estate. `workflows: write` is the one that matters: with it, a release job could rewrite the CI + that gates it. Measured with `gh api apps/refresh-bot --jq .permissions`. """ - REPLACEMENTS = ("release-pr", "estate-read", "private-dep-read", "consumer-read", - "package-repo-push") REFRESH_BOT_HELD = {"contents": "write", "pull-requests": "write"} + NEVER = ("workflows", "issues", "administration", "members", "secrets") - def test_the_replacement_jobs_are_all_present(self): - # A sweep over a name that no longer exists asserts nothing at all. - missing = [j for j in self.REPLACEMENTS if j not in POLICY["jobs"]] - self.assertEqual(missing, [], f"policy is missing {missing}") - self.assertGreaterEqual(len(self.REPLACEMENTS), 5) + def test_the_ceiling_is_exactly_what_the_app_holds(self): + self.assertEqual(POLICY["ceiling"], self.REFRESH_BOT_HELD) - def test_no_replacement_job_asks_for_more_than_refresh_bot_held(self): - for job in self.REPLACEMENTS: - with self.subTest(job=job): - for perm, level in POLICY["jobs"][job]["permissions"].items(): + def test_no_job_can_ask_for_a_permission_the_app_does_not_hold(self): + jobs = POLICY["jobs"] + self.assertGreaterEqual(len(jobs), 8, "too few jobs; this sweep would prove little") + for name, job in jobs.items(): + with self.subTest(job=name): + for perm, level in job["permissions"].items(): self.assertIn(perm, self.REFRESH_BOT_HELD, - f"{job} wants {perm}, which Refresh Bot never had") + f"{name} wants {perm}, which refresh-bot does not hold") self.assertLessEqual(resolve.RANK[level], - resolve.RANK[self.REFRESH_BOT_HELD[perm]], - f"{job} wants {perm}={level}, above Refresh Bot's") - - def test_the_guard_above_can_fail(self): - # Prove it, rather than trusting a green run: workflows=write is inside simtabi-bot's - # ceiling, so the policy loads -- only this assertion stands between that and an - # org-reachable token that can rewrite CI. - p = copy.deepcopy(POLICY) - p["jobs"]["release-pr"]["permissions"]["workflows"] = "write" - resolve.load_policy(write_policy(p)) # loads fine; the ceiling allows it - self.assertNotIn("workflows", self.REFRESH_BOT_HELD) + resolve.RANK[self.REFRESH_BOT_HELD[perm]]) + + def test_the_ceiling_refuses_each_dangerous_permission(self): + # Proven red rather than assumed: each of these loads fine under a widened ceiling, so the + # ceiling is the only thing refusing them. + for perm in self.NEVER: + with self.subTest(permission=perm): + p = copy.deepcopy(POLICY) + p["jobs"]["release-pr"]["permissions"][perm] = "write" + with self.assertRaisesRegex(ValueError, "ceiling"): + resolve.load_policy(write_policy(p)) def test_release_pr_cannot_touch_workflows(self): - # The one that matters: release-pr is owners "*", so it is reachable for every managed org. + # release-pr is owners "*", so it is reachable for every managed org. out = resolve.resolve(POLICY, ORGS, "release-pr", "laranail") - self.assertEqual(out["permission-workflows"], "") + self.assertNotIn("permission-workflows", out) self.assertEqual(out["permission-contents"], "write") self.assertEqual(out["permission-pull-requests"], "write") + def test_the_jobs_that_replaced_refresh_bot_s_own_work_are_present(self): + # A sweep over a name that no longer exists asserts nothing at all. + expected = ("release-pr", "estate-read", "private-dep-read", "consumer-read", + "package-repo-push") + missing = [j for j in expected if j not in POLICY["jobs"]] + self.assertEqual(missing, [], f"policy is missing {missing}") + def test_the_narrowed_jobs_refuse_an_owner_they_do_not_name(self): for job, allowed in (("private-dep-read", "simtabi"), ("consumer-read", "ichava"), ("package-repo-push", "simtabi")): diff --git a/dependabot/README.md b/dependabot/README.md index 2f02f0c..2d18eed 100644 --- a/dependabot/README.md +++ b/dependabot/README.md @@ -13,7 +13,7 @@ Both tools are dry runs unless told otherwise, and neither ever pushes to a defa | `merge.py` | For one account, finds open pull requests by `dependabot[bot]`, decides each with the pure function `decide()`, and with `--apply` merges eligible ones with `gh pr merge --squash --match-head-commit `. | | `common.py` | Policy loading and validation, the `gh` wrapper, repository listing and the active filter, summary output. | | `.github/workflows/dependabot-reconcile.yml` | Monday 14:00 UTC (after Dependabot's own 06:00 America/New_York run) and on demand, one job per org. | -| `.github/workflows/dependabot-merge.yml` | Weekly, Tuesday 14:17 UTC, and on demand, one org at a time, never two runs at once. Skips with a notice while `simtabi-bot`'s credentials are not configured. | +| `.github/workflows/dependabot-merge.yml` | Weekly, Tuesday 14:17 UTC, and on demand, one org at a time, never two runs at once. Skips with a notice while `refresh-bot`'s credentials are not configured. | | `.github/workflows/ci.yml` | Unit tests and actionlint on every pull request to this repository. | Every run prints a table and appends it to the job summary: for the reconciler `repo · status · note`, for the merge gate `repo · PR · action · reason`. @@ -23,7 +23,7 @@ Every run prints a table and appends it to the job summary: for the reconciler ` | Key | Value | Meaning | |---|---|---| | `version` | `1` | Named in the header of every generated file. Bump it when the rendered shape changes. | -| `accounts.orgs` | 10 owned orgs | Reconciled and merge-gated by the workflows, with a `simtabi-bot` token per org. | +| `accounts.orgs` | 10 owned orgs | Reconciled and merge-gated by the workflows, with a `refresh-bot` token per org. | | `accounts.users` | `imanimanyara` | Not covered by the GitHub App; run locally (below). | | `exclude` | `[{"repo": "owner/name", "reason": "..."}]` | Repositories neither script touches. A reason is required. | | `schedule` | weekly, Monday 06:00, America/New_York | The documented convention. | @@ -70,17 +70,18 @@ To keep a repository-specific Dependabot entry (an extra directory, an `ignore`, ## Credentials -Both workflows run as `simtabi-bot`, through `.github/actions/bot-token`, with the permissions +Both workflows run as `refresh-bot`, through `.github/actions/bot-token`, with the permissions [`bot/policy.json`](../bot/README.md) grants each job: | Workflow | Policy job | Permissions | |---|---|---| | reconcile | `dependabot-reconcile`, plus `agent-kit-read` for the generator | contents, pull requests (write); `agent-kit` contents (read) | -| merge | `dependabot-merge` | contents, pull requests, workflows (write); checks, statuses (read) | +| merge | `dependabot-merge` | contents, pull requests (write) | -The key is a secret in this repository's `simtabi-bot` environment, deployable from `main` only. Until the app -exists, the merge gate skips with a notice and the reconciler keeps using Refresh Bot (`REFRESH_APP_*`). The -Dependency Merge Bot this section used to describe was never created; its job is `simtabi-bot`'s. +The credentials are `REFRESH_APP_CLIENT_ID` (variable) and `REFRESH_APP_PRIVATE_KEY` (secret), +organisation-level on every owned org. The Dependency Merge Bot this section used to describe was never +created, and `simtabi-bot` — which briefly held these two jobs — was retired on 2026-11-03 to keep the estate +to two apps; `bot/README.md` has the measurement that decided which app survived. ## Going live @@ -132,11 +133,13 @@ Both workflows ship as dry runs. > `GET /users/{login}` answers 200 for all three deleted orgs here, so it cannot be used for this — > `GET /orgs/{login}` and `GET /user/orgs` are the endpoints that tell the truth. -**Prerequisite for the merge gate:** `simtabi-bot` created and installed on every org in `accounts.orgs`, with its client ID and key in the `simtabi-bot` environment. The gate needs **checks: read**, **statuses: read** and **workflows: write** (merging an Actions update changes `.github/workflows/`, which GitHub refuses an App without it); if an org has not approved those, the token step fails rather than the gate running half-blind. While `SIMTABI_BOT_CLIENT_ID` or `SIMTABI_BOT_PRIVATE_KEY` is not set at all, the gate posts a notice and skips its merge jobs instead. +**Prerequisite for the merge gate, and it is not met:** `refresh-bot` must hold **checks: read** and **statuses: read**, because `merge.py` reads `commits/{sha}/check-runs` and `/status`. It holds neither today, so `decide()` sees no checks and refuses every pull request — fail-closed, not silent. Granting those two read permissions on the app, and having each org approve them, is the owner's step; widen `bot/policy.json`'s `ceiling` only after that, or every run answers 422. + +The gate also has no **workflows: write**, deliberately: its key is organisation-level, and a `workflows: write` credential within reach of every workflow in the estate would let a release job rewrite the CI that gates it. So a Dependabot pull request that touches `.github/workflows/` — the `github-actions` ecosystem, roughly half the volume — is never merged by this token. Use `@dependabot merge` on those, which Dependabot performs with its own credentials. ## The personal account -`simtabi-bot` is not installed on `imanimanyara`, so run both locally with the personal login, from this repository's root: +`refresh-bot` is not installed on `imanimanyara`, so run both locally with the personal login, from this repository's root: ```bash GH_TOKEN=$(gh auth token) python3 dependabot/reconcile.py --owner imanimanyara \