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
51 changes: 51 additions & 0 deletions .github/actions/bot-token/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 20 additions & 23 deletions .github/workflows/dependabot-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -37,27 +37,29 @@ 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:
orgs: ${{ steps.list.outputs.orgs }}
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"
Expand All @@ -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:
Expand All @@ -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:
Expand Down
30 changes: 17 additions & 13 deletions .github/workflows/dependabot-reconcile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -61,35 +66,34 @@ 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
path: .agent-kit
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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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.
- [Dependabot control plane](dependabot/README.md) — the reconciler and the merge gate that run as the bot.

## License

Expand Down
67 changes: 67 additions & 0 deletions bot/README.md
Original file line number Diff line number Diff line change
@@ -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
```
30 changes: 30 additions & 0 deletions bot/policy.json
Original file line number Diff line number Diff line change
@@ -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"}
}
}
}
Loading
Loading