From 4b71ec4cea064477d4530e20a83358b787a9a3d2 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 19 Oct 2026 11:07:49 -0400 Subject: [PATCH 1/2] Run the merge gate and reconciler as simtabi-bot, scoped per job bot/policy.json names each job's permissions, owners and repositories; the bot-token action refuses anything it does not name and passes the rest to create-github-app-token. A job with no permissions is a load error, because no permission inputs would mean every permission the app holds. The key is an environment secret here only. The reconciler keeps Refresh Bot's credentials until the bot's client ID is set, so it does not stop in between. The merge gate still skips with a notice until the app exists. --- .github/actions/bot-token/action.yml | 51 +++++++++ .github/workflows/ci.yml | 2 + .github/workflows/dependabot-merge.yml | 43 ++++---- .github/workflows/dependabot-reconcile.yml | 30 +++--- bot/policy.json | 30 ++++++ bot/resolve.py | 82 +++++++++++++++ bot/tests/test_resolve.py | 117 +++++++++++++++++++++ 7 files changed, 319 insertions(+), 36 deletions(-) create mode 100644 .github/actions/bot-token/action.yml create mode 100644 bot/policy.json create mode 100644 bot/resolve.py create mode 100644 bot/tests/test_resolve.py diff --git a/.github/actions/bot-token/action.yml b/.github/actions/bot-token/action.yml new file mode 100644 index 0000000..49ddcb9 --- /dev/null +++ b/.github/actions/bot-token/action.yml @@ -0,0 +1,51 @@ +name: simtabi-bot token +description: > + Mint a simtabi-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: + job: + description: Job name in bot/policy.json + required: true + owner: + description: Org the token acts on + required: true + client-id: + description: simtabi-bot client ID + required: true + private-key: + description: simtabi-bot private key + required: true + +outputs: + token: + description: The installation token, revoked when the job ends + value: ${{ steps.mint.outputs.token }} + +runs: + using: composite + steps: + - name: Resolve ${{ inputs.job }} for ${{ inputs.owner }} from the bot policy + id: grant + shell: bash + env: + JOB: ${{ inputs.job }} + OWNER: ${{ inputs.owner }} + run: python3 "$GITHUB_WORKSPACE/bot/resolve.py" --job "$JOB" --owner "$OWNER" >> "$GITHUB_OUTPUT" + + # Every permission the app's ceiling holds is passed, empty when the job does not get it; resolve.py + # refuses a job with none, because no permission inputs at all would mean the whole app. + - name: Mint the token + id: mint + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ inputs.client-id }} + private-key: ${{ inputs.private-key }} + owner: ${{ inputs.owner }} + 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/ci.yml b/.github/workflows/ci.yml index ee1c39e..b162083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: persist-credentials: false - name: Dependabot control-plane tests run: python3 -m unittest discover -s dependabot/tests -v + - name: Bot policy tests + run: python3 -m unittest discover -s bot/tests -v actionlint: name: actionlint diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml index 5f4a6ea..e0dba03 100644 --- a/.github/workflows/dependabot-merge.yml +++ b/.github/workflows/dependabot-merge.yml @@ -7,10 +7,10 @@ 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. # -# Until the Dependency Merge Bot exists (variable MERGE_APP_CLIENT_ID and secret -# MERGE_APP_PRIVATE_KEY on this repository), every run posts a notice and skips the merge jobs -# instead of failing one per org. Creating the app and setting both is the owner's step: -# dependabot/README.md, "Credentials". +# 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. on: schedule: @@ -37,6 +37,8 @@ jobs: name: accounts 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: @@ -44,20 +46,20 @@ jobs: ready: ${{ steps.credentials.outputs.ready }} steps: # Only whether each is set reaches this step, never the key itself. - - name: Check the merge app credentials are configured + - name: Check the bot credentials are configured id: credentials env: - HAS_CLIENT_ID: ${{ vars.MERGE_APP_CLIENT_ID != '' }} - HAS_PRIVATE_KEY: ${{ secrets.MERGE_APP_PRIVATE_KEY != '' }} + HAS_CLIENT_ID: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' }} + HAS_PRIVATE_KEY: ${{ secrets.SIMTABI_BOT_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 MERGE_APP_CLIENT_ID") - [ "$HAS_PRIVATE_KEY" = "true" ] || missing+=("secret MERGE_APP_PRIVATE_KEY") - msg="Merge gate skipped: ${missing[*]} not configured on this repository. See dependabot/README.md, Credentials." + [ "$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." echo "::notice title=Dependabot merge gate::$msg" echo "$msg" >> "$GITHUB_STEP_SUMMARY" echo "ready=false" >> "$GITHUB_OUTPUT" @@ -77,6 +79,7 @@ jobs: if: ${{ needs.accounts.outputs.ready == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 + environment: simtabi-bot permissions: contents: read strategy: @@ -89,23 +92,17 @@ jobs: with: persist-credentials: false - # Dependency Merge Bot, not Refresh Bot: its key is a secret on this repository only, because it - # carries workflows: write (GitHub refuses an App merge that changes .github/workflows/ without - # it, and github-actions updates are the default policy's auto-merged ecosystem). Refresh Bot's - # key is an org secret every public repo can read, so it must never hold that permission. - # checks/statuses: read the head SHA's results. pull-requests + contents: write merge. + # bot/policy.json `dependabot-merge`: contents + pull-requests write to merge, workflows write + # because GitHub refuses an app merge that changes .github/workflows/ (github-actions updates are + # the default policy's auto-merged ecosystem), checks + statuses read for the head SHA's results. - name: Mint a token for ${{ matrix.org }} id: token - uses: actions/create-github-app-token@v3 + uses: ./.github/actions/bot-token with: - client-id: ${{ vars.MERGE_APP_CLIENT_ID }} - private-key: ${{ secrets.MERGE_APP_PRIVATE_KEY }} + job: dependabot-merge owner: ${{ matrix.org }} - permission-contents: write - permission-pull-requests: write - permission-checks: read - permission-statuses: read - permission-workflows: write + client-id: ${{ vars.SIMTABI_BOT_CLIENT_ID }} + private-key: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY }} - name: Merge gate for ${{ matrix.org }} env: diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml index fc8c2ce..734d316 100644 --- a/.github/workflows/dependabot-reconcile.yml +++ b/.github/workflows/dependabot-reconcile.yml @@ -49,6 +49,11 @@ jobs: needs: accounts 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 }} permissions: contents: read strategy: @@ -61,16 +66,16 @@ jobs: with: persist-credentials: false - # agent-kit is private and holds the generator. Read-only, that one repository only. + # agent-kit is private and holds the generator. bot/policy.json `agent-kit-read`: contents read, + # that one repository only. - name: Mint a read-only token for simtabi/agent-kit id: kit-token - uses: actions/create-github-app-token@v3 + uses: ./.github/actions/bot-token with: - client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} - private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} + job: agent-kit-read owner: simtabi - repositories: agent-kit - permission-contents: read + client-id: ${{ env.BOT_CLIENT_ID }} + private-key: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' && secrets.SIMTABI_BOT_PRIVATE_KEY || secrets.REFRESH_APP_PRIVATE_KEY }} - uses: actions/checkout@v7 with: repository: simtabi/agent-kit @@ -78,18 +83,17 @@ jobs: token: ${{ steps.kit-token.outputs.token }} persist-credentials: false - # contents: write pushes chore/dependabot-standard; pull-requests: write opens or edits the - # PR. No workflows permission: the reconciler writes .github/dependabot.yml, which is not + # bot/policy.json `dependabot-reconcile`: contents write pushes chore/dependabot-standard, + # pull-requests write opens or edits the PR. No workflows permission: dependabot.yml is not # under .github/workflows/, so GitHub does not require it. - name: Mint a token for ${{ matrix.org }} id: token - uses: actions/create-github-app-token@v3 + uses: ./.github/actions/bot-token with: - client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} - private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} + job: dependabot-reconcile owner: ${{ matrix.org }} - permission-contents: write - permission-pull-requests: write + client-id: ${{ env.BOT_CLIENT_ID }} + private-key: ${{ vars.SIMTABI_BOT_CLIENT_ID != '' && secrets.SIMTABI_BOT_PRIVATE_KEY || secrets.REFRESH_APP_PRIVATE_KEY }} - name: Reconcile ${{ matrix.org }} env: diff --git a/bot/policy.json b/bot/policy.json new file mode 100644 index 0000000..7b703ef --- /dev/null +++ b/bot/policy.json @@ -0,0 +1,30 @@ +{ + "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.", + "ceiling": { + "contents": "write", + "pull-requests": "write", + "issues": "write", + "workflows": "write", + "checks": "read", + "statuses": "read" + }, + "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/.", + "owners": "*", + "permissions": {"contents": "write", "pull-requests": "write", "workflows": "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.", + "owners": "*", + "permissions": {"contents": "write", "pull-requests": "write"} + }, + "agent-kit-read": { + "why": "Check out the private generator the reconciler runs.", + "owners": ["simtabi"], + "repositories": ["agent-kit"], + "permissions": {"contents": "read"} + } + } +} diff --git a/bot/resolve.py b/bot/resolve.py new file mode 100644 index 0000000..60786d0 --- /dev/null +++ b/bot/resolve.py @@ -0,0 +1,82 @@ +"""Turn a job name and an owner into the narrowed simtabi-bot token request, or refuse. + + python3 bot/resolve.py --job dependabot-merge --owner laranail >> "$GITHUB_OUTPUT" + +Prints `permission-=` for every permission the action can pass (empty when the job does +not get it) and `repositories=` (empty: every repository of the owner). Exits non-zero, printing +nothing, for a job the policy does not name, an owner it does not allow, or a job with no permissions: +actions/create-github-app-token treats "no permission inputs" as "every permission the app holds". + +Standard library only. +""" +import argparse +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(HERE), "dependabot")) +import common # noqa: E402 (one list of owned orgs and client accounts, not two) + +DEFAULT_POLICY = os.path.join(HERE, "policy.json") +LEVELS = ("read", "write") +RANK = {"read": 1, "write": 2} + + +def load_policy(path=DEFAULT_POLICY): + with open(path, encoding="utf-8") as fh: + policy = json.load(fh) + if policy.get("version") != 1: + raise ValueError(f"{path}: unsupported policy version {policy.get('version')!r}") + ceiling = policy["ceiling"] + for name, job in policy["jobs"].items(): + perms = job.get("permissions") or {} + if not perms: + raise ValueError(f"job {name}: no permissions (the token would inherit the whole app)") + for perm, level in perms.items(): + if level not in LEVELS: + raise ValueError(f"job {name}: {perm} level {level!r} is not one of {LEVELS}") + if perm not in ceiling or RANK[level] > RANK[ceiling[perm]]: + raise ValueError(f"job {name}: {perm}: {level} exceeds the app's ceiling") + owners = job.get("owners") + if owners != "*" and not (isinstance(owners, list) and owners): + raise ValueError(f"job {name}: owners must be '*' or a non-empty list") + if isinstance(owners, list) and common.CLIENT_ACCOUNTS & set(owners): + raise ValueError(f"job {name}: client accounts are never managed") + return policy + + +def resolve(policy, managed_orgs, job_name, owner): + """{'permission-': level-or-'', 'repositories': 'a,b'}; ValueError when refused.""" + job = policy["jobs"].get(job_name) + if job is None: + raise ValueError(f"no job {job_name!r} in the bot policy") + if owner in common.CLIENT_ACCOUNTS: + raise ValueError(f"{owner} is a client account and is never managed") + allowed = managed_orgs if job["owners"] == "*" else job["owners"] + if owner not in allowed: + raise ValueError(f"job {job_name} may not act on {owner}") + out = {f"permission-{perm}": job["permissions"].get(perm, "") for perm in policy["ceiling"]} + out["repositories"] = ",".join(job.get("repositories", [])) + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--job", required=True) + ap.add_argument("--owner", required=True) + ap.add_argument("--policy", default=DEFAULT_POLICY) + args = ap.parse_args(argv) + try: + managed = common.load_policy()["accounts"]["orgs"] + out = resolve(load_policy(args.policy), managed, args.job, args.owner) + except ValueError as exc: + print(f"bot-token: {exc}", file=sys.stderr) + return 1 + for key, value in out.items(): + print(f"{key}={value}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bot/tests/test_resolve.py b/bot/tests/test_resolve.py new file mode 100644 index 0000000..af023a5 --- /dev/null +++ b/bot/tests/test_resolve.py @@ -0,0 +1,117 @@ +import copy +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import resolve # noqa: E402 + +POLICY = resolve.load_policy() +ORGS = ["simtabi", "laranail"] +WORKFLOWS = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + ".github", "workflows") + + +def write_policy(policy): + fh = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + json.dump(policy, fh) + fh.close() + return fh.name + + +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["repositories"], "") + + 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"], "") + + def test_a_job_limited_to_repositories_is_limited(self): + out = resolve.resolve(POLICY, ORGS, "agent-kit-read", "simtabi") + self.assertEqual(out["repositories"], "agent-kit") + self.assertEqual(out["permission-contents"], "read") + + def test_unknown_job_is_refused(self): + with self.assertRaisesRegex(ValueError, "no job"): + resolve.resolve(POLICY, ORGS, "deploy-everything", "simtabi") + + def test_owner_outside_the_job_is_refused(self): + with self.assertRaisesRegex(ValueError, "may not act on laranail"): + resolve.resolve(POLICY, ORGS, "agent-kit-read", "laranail") + + def test_star_means_managed_orgs_only(self): + with self.assertRaisesRegex(ValueError, "may not act on"): + resolve.resolve(POLICY, ORGS, "dependabot-merge", "someone-else") + + def test_client_accounts_are_refused_even_when_listed(self): + for owner in ("adelsaiq", "dannielagumo", "joemanch"): + with self.assertRaisesRegex(ValueError, "client account"): + resolve.resolve(POLICY, ORGS + [owner], "dependabot-merge", owner) + + def test_policy_naming_a_client_account_does_not_load(self): + p = copy.deepcopy(POLICY) + p["jobs"]["agent-kit-read"]["owners"] = ["dannielagumo"] + with self.assertRaisesRegex(ValueError, "client accounts"): + resolve.load_policy(write_policy(p)) + + def test_job_without_permissions_does_not_load(self): + # The action would pass no permission input, and the token would carry the whole app. + p = copy.deepcopy(POLICY) + p["jobs"]["dependabot-reconcile"]["permissions"] = {} + with self.assertRaisesRegex(ValueError, "no permissions"): + resolve.load_policy(write_policy(p)) + + def test_permission_above_the_ceiling_does_not_load(self): + for perm, level in (("checks", "write"), ("administration", "write")): + p = copy.deepcopy(POLICY) + p["jobs"]["dependabot-reconcile"]["permissions"][perm] = level + with self.assertRaisesRegex(ValueError, "ceiling"): + resolve.load_policy(write_policy(p)) + + def test_cli_prints_outputs_or_nothing(self): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + self.assertEqual(resolve.main(["--job", "dependabot-merge", "--owner", "simtabi"]), 0) + self.assertIn("permission-contents=write\n", out.getvalue()) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + self.assertEqual(resolve.main(["--job", "nope", "--owner", "simtabi"]), 1) + self.assertEqual(out.getvalue(), "") + + def test_the_action_passes_exactly_the_ceiling(self): + # A ceiling permission the action does not pass would be silently dropped; one it passes that the + # ceiling lacks would always be empty. + action = os.path.join(os.path.dirname(WORKFLOWS), "actions", "bot-token", "action.yml") + with open(action, encoding="utf-8") as fh: + passed = {line.strip().split(":", 1)[0][len("permission-"):] + for line in fh if line.strip().startswith("permission-")} + self.assertEqual(passed, set(POLICY["ceiling"])) + + def test_every_job_a_workflow_asks_for_exists(self): + # A renamed job would otherwise fail only when its schedule next fires. + asked = set() + files = [f for f in os.listdir(WORKFLOWS) if f.endswith((".yml", ".yaml"))] + self.assertGreaterEqual(len(files), 3) + for name in files: + with open(os.path.join(WORKFLOWS, name), encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line.startswith("job:") and "bot-token" not in line: + asked.add(line.split(":", 1)[1].strip().strip("'\"")) + self.assertGreaterEqual(len(asked), 3) + self.assertEqual(asked - set(POLICY["jobs"]), set()) + + +if __name__ == "__main__": + unittest.main() From 7ec34340b8dfbf539b104fd51ff936e59d7f30d0 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 19 Oct 2026 11:07:49 -0400 Subject: [PATCH 2/2] Document simtabi-bot and its per-job policy --- README.md | 2 ++ bot/README.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ dependabot/README.md | 26 +++++++++-------- 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 bot/README.md diff --git a/README.md b/README.md index db802d6..955016b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 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. +- [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 new file mode 100644 index 0000000..07325de --- /dev/null +++ b/bot/README.md @@ -0,0 +1,67 @@ +# simtabi-bot + +> Every Simtabi automation runs here, as one GitHub App, `simtabi-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. + +## How a job gets a token + +```yaml +environment: simtabi-bot +steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - id: token + uses: ./.github/actions/bot-token + with: + job: dependabot-merge + owner: laranail + client-id: ${{ vars.SIMTABI_BOT_CLIENT_ID }} + private-key: ${{ secrets.SIMTABI_BOT_PRIVATE_KEY }} +``` + +`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: + +- a job `policy.json` does not name; +- an owner the job does not list (`"*"` means the orgs in `dependabot/policy.json` `accounts.orgs`); +- a client account (`adelsaiq`, `dannielagumo`, `joemanch`), whatever the policy says; +- a policy whose job has no permissions, or one above the ceiling. A token request with no permission + inputs at all would carry every permission the app holds, so an empty job is a load error, not a default. + +## The policy + +| Job | Owners | Permissions | Repositories | +|---|---|---|---| +| `dependabot-merge` | `*` | contents, pull requests, workflows (write); checks, statuses (read) | all | +| `dependabot-reconcile` | `*` | contents, pull requests (write) | all | +| `agent-kit-read` | `simtabi` | contents (read) | `agent-kit` | + +`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 +new permission once. The action passes exactly the ceiling's permissions; a test fails if the two differ. + +## 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`. +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. + +## Until the app exists + +The merge gate posts a notice and skips while `SIMTABI_BOT_CLIENT_ID` or `SIMTABI_BOT_PRIVATE_KEY` is missing +from the environment. 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. The first workflow run that names the environment +creates it with no branch rule; the owner's setup (`github-apps` `docs/simtabi-bot.md`, "Creating it") then +restricts it to `main`. + +## Tests + +```bash +python3 -m unittest discover -s bot/tests -v +``` diff --git a/dependabot/README.md b/dependabot/README.md index 8008242..79c37ab 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 the merge app'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 `simtabi-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` | 12 owned orgs | Reconciled and merge-gated by the workflows, with a Refresh Bot token per org. | +| `accounts.orgs` | 12 owned orgs | Reconciled and merge-gated by the workflows, with a `simtabi-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. | @@ -69,15 +69,17 @@ To keep a repository-specific Dependabot entry (an extra directory, an `ignore`, ## Credentials -| Workflow | App | Where its key lives | Permissions | -|---|---|---|---| -| reconcile | Refresh Bot | org secret `REFRESH_APP_PRIVATE_KEY`, variable `REFRESH_APP_CLIENT_ID` (every owned org) | contents, pull requests (write) | -| merge | Dependency Merge Bot | repository secret `MERGE_APP_PRIVATE_KEY`, variable `MERGE_APP_CLIENT_ID` on `simtabi/.github` only | contents, pull requests, workflows (write); checks, statuses (read) | +Both workflows run as `simtabi-bot`, through `.github/actions/bot-token`, with the permissions +[`bot/policy.json`](../bot/README.md) grants each job: -The merge gate gets its own app because it needs `workflows: write`, and Refresh Bot's key is readable by -every public repository's workflows. Create it with `imanimanyara/github-apps` -(`docs/merge-bot.md`): `CLIENT_ID_VAR=MERGE_APP_CLIENT_ID PRIVATE_KEY_SECRET=MERGE_APP_PRIVATE_KEY bin/create-app manifests/merge-bot.json simtabi/.github`, -then install it on every org in `accounts.orgs`. +| 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) | + +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. ## Going live @@ -90,11 +92,11 @@ Both workflows ship as dry runs. | Merge gate on schedule | Set the repository variable `DEPENDABOT_MERGE_APPLY` to `true`. | | Back to dry run | Delete the variable or set it to anything else. | -**Prerequisite for the merge gate:** Refresh Bot's manifest grants `contents` and `pull_requests` only. The gate also needs **checks: read**, **statuses: read** and **workflows: write** (merging an Actions update changes `.github/workflows/`, which GitHub refuses an App without it). Until the App is granted those and each org approves the new permissions, the token step fails rather than the gate running half-blind. While `MERGE_APP_CLIENT_ID` or `MERGE_APP_PRIVATE_KEY` is not set at all, the gate posts a notice and skips its merge jobs instead. The reconciler needs nothing beyond the current two. +**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. ## The personal account -The GitHub App is not installed on `imanimanyara`, so run both locally with the personal login, from this repository's root: +`simtabi-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 \