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 \