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 \