diff --git a/.github/workflows/sync-from-cloudflare.yml b/.github/workflows/sync-from-cloudflare.yml index 680c084..73357ae 100644 --- a/.github/workflows/sync-from-cloudflare.yml +++ b/.github/workflows/sync-from-cloudflare.yml @@ -7,10 +7,18 @@ name: sync-from-cloudflare # somebody merges a pull request. With it, the change shows up as a reviewable # diff, keeps its history, and can be given an owner. # -# The pull request needs DNS_BOT_TOKEN to be checked. GitHub does not start -# workflows for commits pushed with the built-in GITHUB_TOKEN, so without that -# secret the pull request gets no checks and cannot satisfy a required check. -# See docs/runbook.md for how to create it. +# The pull request is opened by a GitHub App, not by a person and not by the +# built-in GITHUB_TOKEN. Both of those matter: +# +# * GitHub does not start workflows for commits pushed with GITHUB_TOKEN, so +# a pull request opened that way gets no checks and can never satisfy a +# required check. An App installation token does start them. +# * Nobody can approve their own pull request. If a person's token opened +# this, that person could never review it, which on a two person team +# leaves exactly one possible reviewer. The App is not a person, so anybody +# on the team can approve. +# +# See docs/runbook.md for how to create the App. on: schedule: @@ -35,9 +43,20 @@ jobs: pull-requests: write issues: write steps: + - name: Get a token for the bot + id: bot + uses: actions/create-github-app-token@v3 + with: + # The client id is an identifier, not a credential, so it lives in a + # repository variable rather than a secret. Keeping it out of the + # secret store also keeps it out of the log masker, which makes a + # failure here readable. + client-id: ${{ vars.DNS_BOT_CLIENT_ID }} + private-key: ${{ secrets.DNS_BOT_PRIVATE_KEY }} + - uses: actions/checkout@v7 with: - token: ${{ secrets.DNS_BOT_TOKEN || secrets.GITHUB_TOKEN }} + token: ${{ steps.bot.outputs.token }} - uses: actions/setup-python@v7 with: @@ -67,8 +86,8 @@ jobs: - name: Open or update the pull request env: - GH_TOKEN: ${{ secrets.DNS_BOT_TOKEN || secrets.GITHUB_TOKEN }} - HAS_BOT_TOKEN: ${{ secrets.DNS_BOT_TOKEN != '' }} + GH_TOKEN: ${{ steps.bot.outputs.token }} + APP_SLUG: ${{ steps.bot.outputs.app-slug }} run: | set -eu @@ -83,8 +102,12 @@ jobs: --description "Records changed in the Cloudflare dashboard" \ --force - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + # Commit as the App. The ruleset has + # require_extra_approval_for_unattributed_changes turned on, so the + # commit author has to be the identity that pushed it. + bot_id=$(gh api "users/$APP_SLUG[bot]" --jq .id) + git config user.name "$APP_SLUG[bot]" + git config user.email "$bot_id+$APP_SLUG[bot]@users.noreply.github.com" git switch -C cloudflare-sync git add -- ./*.yaml git commit -m 'chore: pull manual Cloudflare changes into the zone files' @@ -102,11 +125,6 @@ jobs: printf -- '- If a change should not have been made, revert it in ' printf 'Cloudflare and close this pull request. It reopens tomorrow ' printf 'if the change is still there.\n' - if [ "$HAS_BOT_TOKEN" != 'true' ]; then - printf '\n> **No checks will run on this pull request.** ' - printf 'The `DNS_BOT_TOKEN` secret is not set, so GitHub will not ' - printf 'start workflows for these commits. See `docs/runbook.md`.\n' - fi } > body.md number=$(gh pr list --head cloudflare-sync --state open \ diff --git a/docs/runbook.md b/docs/runbook.md index 252c158..782ae09 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -21,7 +21,13 @@ lands. |---|---|---| | `CLOUDFLARE_TOKEN` | Cloudflare API token, **edit** DNS for both zones | `deploy` | | `CLOUDFLARE_TOKEN_READ_ONLY` | Cloudflare API token, **read** DNS for both zones | `plan`, `sync-from-cloudflare` | -| `DNS_BOT_TOKEN` | Fine grained personal access token | `sync-from-cloudflare` | +| `DNS_BOT_PRIVATE_KEY` | Private key of the WITCC DNS Bot GitHub App, the whole `.pem` | `sync-from-cloudflare` | + +There is also one repository **variable**, not a secret: + +| Variable | What it is | +|---|---| +| `DNS_BOT_CLIENT_ID` | Client ID of the same App. An identifier, not a credential | The two Cloudflare tokens already exist. Create them at **Cloudflare > My Profile > API Tokens** with the `Edit zone DNS` template, and @@ -31,18 +37,49 @@ scope each one to `witcc.dev` and `hackwit.org` only. > scripts from a pull request while holding it, so anybody who opened a pull > request could have read it. See [`SECURITY.md`](../SECURITY.md). -`DNS_BOT_TOKEN` is new and you have to create it. GitHub does not start -workflows for commits pushed with the built in `GITHUB_TOKEN`. Without this -token, the nightly sync pull request gets no checks, so it can never satisfy a -required check and can never merge. - -1. Go to **Settings > Developer settings > Personal access tokens > Fine - grained tokens** on an account that is a DNS Manager. -2. Resource owner: `WITCodingClub`. Repository access: only `WITCodingClub/dns`. -3. Repository permissions: `Contents: Read and write`, - `Pull requests: Read and write`, `Issues: Read and write`. -4. Set an expiry you will remember. Put a reminder in the club calendar. -5. Save it as the `DNS_BOT_TOKEN` repository secret. +### 1b. The bot App + +The nightly sync opens its pull request as a GitHub App. Two reasons, and both +of them rule out the simpler options: + +- **Not `GITHUB_TOKEN`.** GitHub does not start workflows for commits pushed + with the built in token, so that pull request would get no checks and could + never satisfy a required check. +- **Not a person's token.** Nobody can approve their own pull request. If the + sync ran on a DNS Manager's personal access token, every sync pull request + would be authored by that person and they could never review it. On a two + person team that leaves exactly one possible reviewer. + +An App is neither. It triggers checks, and it is not a person, so anybody on +the team can approve its pull requests. It also has no expiry to forget. + +1. Go to + . +2. **Name**: `WITCC DNS Bot`. **Homepage URL**: this repository is fine. +3. Turn **Webhook > Active** off. The App never receives events. +4. **Repository permissions**: `Contents: Read and write`, + `Pull requests: Read and write`, `Issues: Read and write`. Nothing else. +5. **Where can this App be installed**: only this account. +6. Create it, then **Generate a private key**. A `.pem` downloads. +7. **Install App** on the left, install it on `WITCodingClub/dns` only. The App + can do nothing until this step. +8. Record the client id and the key: + + ```console + $ gh variable set DNS_BOT_CLIENT_ID --repo WITCodingClub/dns --body 'Iv23...' + $ gh secret set DNS_BOT_PRIVATE_KEY --repo WITCodingClub/dns < ~/Downloads/witcc-dns-bot.*.pem + ``` + + The client id is on the App's settings page. The private key is the whole + `.pem` file, `BEGIN` and `END` lines included. +9. Delete the `.pem` from your Downloads folder. + +To check that it worked: + +```console +$ gh api orgs/WITCodingClub/installations --jq '.installations[].app_slug' +$ gh workflow run sync-from-cloudflare.yml && gh run watch +``` ### 2. Team access