From eab612e5056c87baf8a3bf7d7612e08e7727f722 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 3 Nov 2026 14:39:07 -0500 Subject: [PATCH] bot: widen the ceiling to the reads the merge gate needs refresh-bot was granted checks:read and statuses:read on 2026-11-03 and all 10 owned orgs approved them, so the ceiling can now move with the app. Order matters and is the reason this is a separate change: a ceiling that claims a permission the app does not hold makes create-github-app-token answer 422 on every run. Grant on the app, let every org approve, then widen. The bot/README "Adding a job" step says exactly this; this is the first time it has been exercised. dependabot-merge gets the two reads back. merge.py reads commits/{sha}/check-runs and commits/{sha}/status, and without them decide() sees no checks and refuses every pull request -- fail-closed, but useless. workflows:write is still absent and must stay absent. This app's key is an organisation-level secret, so that permission would be within reach of every workflow in the estate, letting a release job rewrite the CI that gates it. A Dependabot pull request touching .github/workflows/ is therefore never merged by this token; those go to `@dependabot merge`, which Dependabot performs with its own credentials. The gate remains dry-run: it acts only where DEPENDABOT_MERGE_APPLY is true, and no repository variable sets it, so it has never merged anything. CeilingMatchesTheAppTest still refuses workflows, issues, administration, members and secrets by name, and was re-proven red: planting workflows:write in the ceiling fails 6 tests, reverting goes green. 24 tests pass. --- .github/actions/bot-token/action.yml | 2 ++ .github/workflows/dependabot-merge.yml | 14 ++++++++------ bot/README.md | 18 ++++++++++-------- bot/policy.json | 8 +++++--- bot/tests/test_resolve.py | 12 +++++++++--- dependabot/README.md | 4 ++-- 6 files changed, 36 insertions(+), 22 deletions(-) diff --git a/.github/actions/bot-token/action.yml b/.github/actions/bot-token/action.yml index 729b5fe..5a845d1 100644 --- a/.github/actions/bot-token/action.yml +++ b/.github/actions/bot-token/action.yml @@ -45,3 +45,5 @@ runs: repositories: ${{ steps.grant.outputs.repositories }} permission-contents: ${{ steps.grant.outputs.permission-contents }} permission-pull-requests: ${{ steps.grant.outputs.permission-pull-requests }} + 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 7ac4d9d..169aff5 100644 --- a/.github/workflows/dependabot-merge.yml +++ b/.github/workflows/dependabot-merge.yml @@ -11,12 +11,14 @@ name: Dependabot merge gate # 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. +# merge.py reads commits/{sha}/check-runs and /status, so the job needs checks:read and statuses:read. +# refresh-bot was granted both on 2026-11-03 and all 10 orgs approved them, so the ceiling moved with the +# app rather than ahead of it; widening the ceiling first makes create-github-app-token answer 422. +# +# refresh-bot has no workflows:write, deliberately: its key is organisation-level, and that permission +# within reach of every workflow in the estate would let a release job rewrite the CI that gates it. So a +# pull request touching .github/workflows/ is never merged by this token; leave those to +# `@dependabot merge`, which Dependabot performs with its own credentials. on: schedule: diff --git a/bot/README.md b/bot/README.md index 8011455..b91a2ac 100644 --- a/bot/README.md +++ b/bot/README.md @@ -7,7 +7,8 @@ held to two apps — `simbi-github-cli` for local `gh` and `git`, and this one f decided by measurement rather than by which app was newer: ``` -refresh-bot contents=write metadata=read pull_requests=write (3) +refresh-bot checks=read contents=write metadata=read + pull_requests=write statuses=read (5, was 3) simtabi-bot + checks, issues, statuses, workflows=write (7) ``` @@ -51,7 +52,7 @@ On GitHub Free a **private** repository cannot read organisation secrets and nee | Job | Owners | Permissions | Repositories | |---|---|---|---| -| `dependabot-merge` | `*` | contents, pull requests (write) | all | +| `dependabot-merge` | `*` | contents, pull requests (write); checks, statuses (read) | all | | `dependabot-reconcile` | `*` | contents, pull requests (write) | all | | `agent-kit-read` | `simtabi` | contents (read) | `agent-kit` | | `release-pr` | `*` | contents, pull requests (write) | all | @@ -60,12 +61,13 @@ On GitHub Free a **private** repository cannot read organisation secrets and nee | `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. +> **`dependabot-merge` can read checks since 2026-11-03.** `merge.py` reads `commits/{sha}/check-runs` and +> `/status`; `refresh-bot` was granted `checks: read` and `statuses: read` that day and all 10 orgs approved +> them, so the ceiling moved with the app rather than ahead of it. It still has **no `workflows: write`**, by +> design: this app's key is organisation-level, and that permission within reach of every workflow in the +> estate would let a release job rewrite the CI that gates it. So a pull request touching `.github/workflows/` +> is never merged by this token — leave those to `@dependabot merge`, which Dependabot performs with its own +> credentials. The gate is still dry-run until `DEPENDABOT_MERGE_APPLY` is set; it has never merged anything. `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 diff --git a/bot/policy.json b/bot/policy.json index d2f3c29..0fcd3e0 100644 --- a/bot/policy.json +++ b/bot/policy.json @@ -3,13 +3,15 @@ "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" + "pull-requests": "write", + "checks": "read", + "statuses": "read" }, "jobs": { "dependabot-merge": { - "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.", + "why": "Merge a policy-allowed Dependabot pull request once every check on its head is green. checks and statuses are read because merge.py reads repos/{repo}/commits/{sha}/check-runs and /status; without them decide() sees no checks and refuses every pull request, which is fail-closed but useless. No workflows:write, deliberately: this app's 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 pull request touching .github/workflows/ is never merged by this token -- leave those to '@dependabot merge', which Dependabot performs with its own credentials.", "owners": "*", - "permissions": {"contents": "write", "pull-requests": "write"} + "permissions": {"contents": "write", "pull-requests": "write", "checks": "read", "statuses": "read"} }, "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/tests/test_resolve.py b/bot/tests/test_resolve.py index 1c6b14b..af688ca 100644 --- a/bot/tests/test_resolve.py +++ b/bot/tests/test_resolve.py @@ -163,8 +163,12 @@ class CeilingMatchesTheAppTest(unittest.TestCase): 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 checks=read contents=write metadata=read + pull_requests=write statuses=read (5) + + checks and statuses were granted on 2026-11-03, after simtabi-bot was retired, so the + Dependabot merge gate can read whether a head is green; all 10 orgs approved them. The app + still has no workflows:write and must never gain it. 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 @@ -172,8 +176,10 @@ class CeilingMatchesTheAppTest(unittest.TestCase): that gates it. Measured with `gh api apps/refresh-bot --jq .permissions`. """ - REFRESH_BOT_HELD = {"contents": "write", "pull-requests": "write"} + REFRESH_BOT_HELD = {"contents": "write", "pull-requests": "write", + "checks": "read", "statuses": "read"} NEVER = ("workflows", "issues", "administration", "members", "secrets") + # workflows is the one that matters: this app's key is organisation-level. def test_the_ceiling_is_exactly_what_the_app_holds(self): self.assertEqual(POLICY["ceiling"], self.REFRESH_BOT_HELD) diff --git a/dependabot/README.md b/dependabot/README.md index 2d18eed..d83dd78 100644 --- a/dependabot/README.md +++ b/dependabot/README.md @@ -76,7 +76,7 @@ Both workflows run as `refresh-bot`, through `.github/actions/bot-token`, with t | 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 (write) | +| merge | `dependabot-merge` | contents, pull requests (write); checks, statuses (read) | 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 @@ -133,7 +133,7 @@ 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, 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. +**Prerequisite for the merge gate, met on 2026-11-03:** `refresh-bot` holds **checks: read** and **statuses: read**, because `merge.py` reads `commits/{sha}/check-runs` and `/status`. All 10 orgs approved them, and `bot/policy.json`'s `ceiling` was widened only after that — widening it first makes every run answer 422. The gate is still dry-run: it runs only where `DEPENDABOT_MERGE_APPLY` is `true`, and no repository variable sets it, so it has never merged anything. 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.