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 \