Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actions/bot-token/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
14 changes: 8 additions & 6 deletions .github/workflows/dependabot-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down Expand Up @@ -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 |
Expand 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
Expand Down
8 changes: 5 additions & 3 deletions bot/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 9 additions & 3 deletions bot/tests/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,23 @@ 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
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`.
"""

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)
Expand Down
4 changes: 2 additions & 2 deletions dependabot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Loading