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
4 changes: 4 additions & 0 deletions .github/BRANCH_PROTECTION_RULESETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ gh api --method PUT "repos/${REPO}/rulesets/<id>" --input .github/ruleset-main.j
gh api --method PUT "repos/${REPO}/rulesets/<id>" --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`.
27 changes: 27 additions & 0 deletions .github/RELEASE_TOKENS.md
Original file line number Diff line number Diff line change
@@ -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 <NAME> --repo wildfoundry/dataplicity-cli`.
4. Re-run **Audit release tokens**.
5. Revoke the previous PAT.
26 changes: 26 additions & 0 deletions .github/workflows/audit-release-tokens.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions .github/workflows/bootstrap-winget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
EXPECTED_BOT: dataplicity-release-bot
run: |
$ErrorActionPreference = "Stop"
$headers = @{
Expand All @@ -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") {
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/update-homebrew-tap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -97,13 +101,15 @@ jobs:
- name: Validate tap token
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
EXPECTED_BOT: dataplicity-release-bot
shell: bash
run: |
set -euo pipefail
if [[ -z "${TAP_TOKEN}" ]]; then
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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/update-winget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ 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
if [[ -z "${WINGET_TOKEN}" ]]; then
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
Expand Down
39 changes: 39 additions & 0 deletions scripts/verify-release-bot-token.sh
Original file line number Diff line number Diff line change
@@ -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 <token-env-var-name> <expected-login>" >&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
Loading