From d39448a41790394b400a5bfebf7004b4246cb0c4 Mon Sep 17 00:00:00 2001 From: Elliot Mackenzie <6545046+barfle@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:11:13 +1000 Subject: [PATCH] Require release-bot ownership for CI GitHub tokens. HOMEBREW_TAP_TOKEN and WINGET_TOKEN must belong to dataplicity-release-bot; workflows now fail closed if a personal account PAT is configured. --- .github/BRANCH_PROTECTION_RULESETS.md | 4 +++ .github/RELEASE_TOKENS.md | 27 +++++++++++++++ .github/workflows/audit-release-tokens.yml | 26 +++++++++++++++ .github/workflows/bootstrap-winget.yml | 4 +++ .github/workflows/update-homebrew-tap.yml | 6 ++++ .github/workflows/update-winget.yml | 6 ++++ scripts/verify-release-bot-token.sh | 39 ++++++++++++++++++++++ 7 files changed, 112 insertions(+) create mode 100644 .github/RELEASE_TOKENS.md create mode 100644 .github/workflows/audit-release-tokens.yml create mode 100755 scripts/verify-release-bot-token.sh diff --git a/.github/BRANCH_PROTECTION_RULESETS.md b/.github/BRANCH_PROTECTION_RULESETS.md index 5078598..9e085cd 100644 --- a/.github/BRANCH_PROTECTION_RULESETS.md +++ b/.github/BRANCH_PROTECTION_RULESETS.md @@ -34,3 +34,7 @@ gh api --method PUT "repos/${REPO}/rulesets/" --input .github/ruleset-main.j gh api --method PUT "repos/${REPO}/rulesets/" --input .github/ruleset-release.json gh api --method POST "repos/${REPO}/rulesets" --input .github/ruleset-tags.json ``` + +## Release automation tokens + +See `.github/RELEASE_TOKENS.md`. Cross-repo CI secrets must be owned by `dataplicity-release-bot`. diff --git a/.github/RELEASE_TOKENS.md b/.github/RELEASE_TOKENS.md new file mode 100644 index 0000000..7c2e44e --- /dev/null +++ b/.github/RELEASE_TOKENS.md @@ -0,0 +1,27 @@ +# Release automation tokens + +CI must not use personal GitHub credentials. Cross-repo release automation uses PATs owned by the +limited machine user **`dataplicity-release-bot`**. + +| Secret | Used by | Required access | +| --- | --- | --- | +| `HOMEBREW_TAP_TOKEN` | `Update Homebrew tap` | Contents + Pull requests write on `wildfoundry/homebrew-tap` only | +| `WINGET_TOKEN` | `Update WinGet package` / bootstrap | Push to `wildfoundry/winget-pkgs` (fork of `microsoft/winget-pkgs`) | + +`GITHUB_TOKEN` remains the default for same-repo Actions (releases, dispatch, checkout of the +source repository). + +## Enforcement + +Workflows call `scripts/verify-release-bot-token.sh`, which fails if the secret is missing or owned +by any account other than `dataplicity-release-bot`. + +Run **Actions → Audit release tokens** to re-check owners after rotating secrets. + +## Rotating a token + +1. Sign in as **`dataplicity-release-bot`** (not a staff admin account). +2. Create a fine-grained PAT scoped to the single target repository and least privilege above. +3. Set the repository secret with `gh secret set --repo wildfoundry/dataplicity-cli`. +4. Re-run **Audit release tokens**. +5. Revoke the previous PAT. diff --git a/.github/workflows/audit-release-tokens.yml b/.github/workflows/audit-release-tokens.yml new file mode 100644 index 0000000..bec4485 --- /dev/null +++ b/.github/workflows/audit-release-tokens.yml @@ -0,0 +1,26 @@ +name: Audit release tokens + +"on": + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + name: Verify release-bot token owners + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Audit HOMEBREW_TAP_TOKEN + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + EXPECTED_BOT: dataplicity-release-bot + run: scripts/verify-release-bot-token.sh TAP_TOKEN "$EXPECTED_BOT" + - name: Audit WINGET_TOKEN + env: + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + EXPECTED_BOT: dataplicity-release-bot + run: scripts/verify-release-bot-token.sh WINGET_TOKEN "$EXPECTED_BOT" diff --git a/.github/workflows/bootstrap-winget.yml b/.github/workflows/bootstrap-winget.yml index bac7b91..7e0f729 100644 --- a/.github/workflows/bootstrap-winget.yml +++ b/.github/workflows/bootstrap-winget.yml @@ -48,6 +48,7 @@ jobs: shell: pwsh env: WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + EXPECTED_BOT: dataplicity-release-bot run: | $ErrorActionPreference = "Stop" $headers = @{ @@ -58,6 +59,9 @@ jobs: $user = Invoke-RestMethod -Headers $headers -Uri "https://api.github.com/user" Write-Host "WINGET_TOKEN owner: $($user.login)" + if ($user.login -ne $env:EXPECTED_BOT) { + throw "WINGET_TOKEN must be owned by '$($env:EXPECTED_BOT)', not '$($user.login)'." + } $fork = Invoke-RestMethod -Headers $headers -Uri "https://api.github.com/repos/wildfoundry/winget-pkgs" if (-not $fork.fork -or $fork.parent.full_name -ne "microsoft/winget-pkgs") { diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index a1682bc..a7a071a 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -30,6 +30,10 @@ jobs: name: Open tap PR runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Resolve release metadata id: release env: @@ -97,6 +101,7 @@ jobs: - name: Validate tap token env: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + EXPECTED_BOT: dataplicity-release-bot shell: bash run: | set -euo pipefail @@ -104,6 +109,7 @@ jobs: echo "Missing required repository secret: HOMEBREW_TAP_TOKEN" >&2 exit 1 fi + scripts/verify-release-bot-token.sh TAP_TOKEN "${EXPECTED_BOT}" - name: Checkout tap repository uses: actions/checkout@v4 diff --git a/.github/workflows/update-winget.yml b/.github/workflows/update-winget.yml index e5d7f39..51a4622 100644 --- a/.github/workflows/update-winget.yml +++ b/.github/workflows/update-winget.yml @@ -22,9 +22,14 @@ jobs: name: Publish release to WinGet runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Validate WinGet token env: WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + EXPECTED_BOT: dataplicity-release-bot shell: bash run: | set -euo pipefail @@ -32,6 +37,7 @@ jobs: echo "Missing required repository secret: WINGET_TOKEN" >&2 exit 1 fi + scripts/verify-release-bot-token.sh WINGET_TOKEN "${EXPECTED_BOT}" - name: Publish to WinGet uses: vedantmgoyal9/winget-releaser@main diff --git a/scripts/verify-release-bot-token.sh b/scripts/verify-release-bot-token.sh new file mode 100755 index 0000000..bbd4677 --- /dev/null +++ b/scripts/verify-release-bot-token.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Verify a GitHub token is owned by the limited release-bot account. +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +token_var="$1" +expected_login="$2" +token="${!token_var-}" + +if [[ -z "$token" ]]; then + echo "Missing required token in environment variable: ${token_var}" >&2 + exit 1 +fi + +login="$( + curl -fsS \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/user \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("login",""))' +)" + +if [[ -z "$login" ]]; then + echo "${token_var}: could not resolve GitHub login for token" >&2 + exit 1 +fi + +echo "${token_var} owner: ${login}" + +if [[ "$login" != "$expected_login" ]]; then + echo "${token_var} must be a PAT owned by '${expected_login}', not '${login}'." >&2 + echo "Mint a fine-grained token as ${expected_login} with least privilege, then update the repository secret." >&2 + exit 1 +fi