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/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/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() 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 \