Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions .github/actions/bot-token/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 }}
30 changes: 17 additions & 13 deletions .github/workflows/dependabot-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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:
Expand All @@ -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:
Expand Down
41 changes: 15 additions & 26 deletions .github/workflows/dependabot-reconcile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ that apply to every simtabi repository lacking its own copy.
## <a name="documentation"></a>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
Expand Down
80 changes: 55 additions & 25 deletions bot/README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
# 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:
persist-credentials: false
- 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:

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
12 changes: 4 additions & 8 deletions bot/policy.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
Loading
Loading