fix(release): verify release token capabilities

This commit is contained in:
修雨
2026-07-17 17:18:12 +08:00
parent c3e57b874b
commit 4f76d7cb4c
6 changed files with 707 additions and 28 deletions
+49 -19
View File
@@ -173,6 +173,7 @@ jobs:
timeout-minutes: 5
permissions:
checks: read
contents: read
steps:
- name: Validate trusted governance preflight context
env:
@@ -214,15 +215,6 @@ jobs:
exit 1
}
- name: Require release governance token
env:
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
exit 1
}
- name: Require successful CI Gate on the preflight commit
uses: actions/github-script@v7
env:
@@ -245,6 +237,22 @@ jobs:
core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`);
}
- name: Check out trusted preflight tooling
uses: actions/checkout@v4
with:
ref: ${{ inputs.governance_preflight_commit }}
persist-credentials: false
fetch-depth: 0
- name: Require release governance token
env:
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
exit 1
}
- name: Require immutable releases governance
uses: actions/github-script@v7
with:
@@ -265,6 +273,21 @@ jobs:
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Verify Homebrew PR automation permission
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$HOMEBREW_PR_TOKEN" || {
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
}
test "$HOMEBREW_PR_TOKEN" != "$RELEASE_GOVERNANCE_TOKEN" || {
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
exit 1
}
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
release-contract:
needs: [dispatch-contract, authorize-recovery]
if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
@@ -417,16 +440,6 @@ jobs:
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Check Homebrew PR automation token
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
- name: Validate release contract
id: contract
env:
@@ -514,6 +527,23 @@ jobs:
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Verify Homebrew PR automation permission
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
if [ -z "$HOMEBREW_PR_TOKEN" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
if [ "$HOMEBREW_PR_TOKEN" = "$RELEASE_GOVERNANCE_TOKEN" ]; then
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
exit 1
fi
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-homebrew-pr-token.sh" \
"$GITHUB_REPOSITORY" --canary
- name: Require successful beta delivery before stable promotion
if: ${{ steps.contract.outputs.channel == 'stable' }}
uses: actions/github-script@v7
+15 -6
View File
@@ -65,16 +65,25 @@ git diff --check
## Homebrew Formula PR Automation
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
allow fine-grained personal access tokens to target this repository, so use a
classic personal access token owned by a maintainer or release-bot account with
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
maintainer or release-bot account, limited to this repository with
`Contents: write` and `Pull requests: write`. If organization policy prevents
that account from targeting the repository, use a dedicated classic token with
only the `public_repo` scope. Do not reuse a broad developer token.
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
secret. Replace it immediately if it is exposed, its owner loses repository
access, or the release-bot ownership changes. The Release workflow uses this
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
and rotate it before its configured expiration. Replace it immediately if it is
exposed, its owner loses repository access, or the release-bot ownership
changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
The default-branch governance preflight and every tag contract authenticate the
token before publication, reject over-scoped classic tokens, confirm its
identity, and run a controlled write canary. The canary pushes a unique
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
draft PR, closes it, and deletes the branch with the same token. This proves both
Contents and Pull requests write access before publication without merging
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
+1
View File
@@ -128,6 +128,7 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+254
View File
@@ -0,0 +1,254 @@
#!/bin/sh
set -eu
REPOSITORY="${1:-}"
MODE="${2:-}"
TOKEN="${HOMEBREW_PR_TOKEN:-}"
usage() {
printf '%s\n' 'usage: verify-homebrew-pr-token.sh <owner/repository> [--canary]' >&2
}
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
[ -n "$REPOSITORY" ] || {
usage
exit 2
}
case "$REPOSITORY" in
*/*) ;;
*) usage; exit 2 ;;
esac
case "$MODE" in
""|--canary) ;;
*) usage; exit 2 ;;
esac
[ -n "$TOKEN" ] || err "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases"
command -v gh >/dev/null 2>&1 || err "gh is required to verify HOMEBREW_PR_TOKEN"
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-homebrew-token-XXXXXX")"
CANARY_ROOT=""
CANARY_BRANCH=""
CANARY_COMMIT=""
CANARY_BRANCH_MAY_EXIST="false"
CANARY_PR_MAY_EXIST="false"
CANARY_PR_NUMBER=""
cleanup() {
status=$?
trap - EXIT INT TERM
set +e
cleanup_failed="false"
if [ "$CANARY_PR_MAY_EXIST" = "true" ]; then
cleanup_pr_number="$CANARY_PR_NUMBER"
if [ -z "$cleanup_pr_number" ]; then
if ! cleanup_pr_number="$(find_canary_pr_number)"; then
printf 'error: could not inspect Homebrew token canary PR for branch %s\n' \
"$CANARY_BRANCH" >&2
cleanup_failed="true"
fi
fi
if [ -n "$cleanup_pr_number" ] &&
! close_canary_pr "$cleanup_pr_number"; then
printf 'error: could not clean up Homebrew token canary PR #%s\n' \
"$cleanup_pr_number" >&2
cleanup_failed="true"
fi
fi
if [ "$CANARY_BRANCH_MAY_EXIST" = "true" ]; then
if ! delete_canary_branch >/dev/null 2>&1; then
printf 'error: could not clean up Homebrew token canary branch %s\n' \
"$CANARY_BRANCH" >&2
cleanup_failed="true"
fi
fi
if ! rm -rf "$TMP_ROOT"; then
printf 'error: could not clean up Homebrew token canary workspace\n' >&2
cleanup_failed="true"
fi
if [ "$cleanup_failed" = "true" ] && [ "$status" -eq 0 ]; then
status=1
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
response="$TMP_ROOT/repository-response"
if ! GH_TOKEN="$TOKEN" gh api \
-i \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY" \
--jq '[.full_name, (.permissions.push // false), .default_branch] | @tsv' \
> "$response"; then
err "HOMEBREW_PR_TOKEN could not authenticate to $REPOSITORY"
fi
scopes="$(
awk '
tolower($0) ~ /^x-oauth-scopes:/ {
sub(/^[^:]*:[[:space:]]*/, "")
sub(/\r$/, "")
print
exit
}
' "$response"
)"
credential_kind="fine-grained"
if [ -n "$scopes" ]; then
[ "$scopes" = "public_repo" ] || {
err "classic HOMEBREW_PR_TOKEN must have only public_repo scope"
}
credential_kind="classic-public_repo"
fi
repository_state="$(tail -n 1 "$response")"
actual_repository="$(printf '%s\n' "$repository_state" | cut -f1)"
can_push="$(printf '%s\n' "$repository_state" | cut -f2)"
default_branch="$(printf '%s\n' "$repository_state" | cut -f3)"
[ "$actual_repository" = "$REPOSITORY" ] || {
err "HOMEBREW_PR_TOKEN resolved unexpected repository $actual_repository"
}
[ "$can_push" = "true" ] || {
err "HOMEBREW_PR_TOKEN owner does not have push permission to $REPOSITORY"
}
[ -n "$default_branch" ] || err "HOMEBREW_PR_TOKEN returned an empty default branch"
actor="$(
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
user \
--jq '.login'
)" || err "HOMEBREW_PR_TOKEN could not resolve its owner"
[ -n "$actor" ] || err "HOMEBREW_PR_TOKEN returned an empty owner"
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls?state=open&per_page=1" \
--silent \
|| err "HOMEBREW_PR_TOKEN cannot access pull requests in $REPOSITORY"
if [ "$MODE" = "--canary" ]; then
command -v git >/dev/null 2>&1 || err "git is required to run the Homebrew token canary"
nonce="$(basename "$TMP_ROOT" | tr -cd 'A-Za-z0-9')"
run_id="${GITHUB_RUN_ID:-manual-$(date +%s)-$nonce}"
run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
case "$run_id-$run_attempt" in
*[!A-Za-z0-9_-]*) err "invalid Homebrew token canary identity" ;;
esac
CANARY_BRANCH="automation/homebrew-token-canary-$run_id-$run_attempt"
CANARY_ROOT="$TMP_ROOT/canary"
canonical_remote="https://github.com/$REPOSITORY.git"
askpass="$TMP_ROOT/git-askpass.sh"
printf '%s\n' \
'#!/bin/sh' \
'case "$1" in' \
' *Username*) printf "%s\n" "x-access-token" ;;' \
' *) printf "%s\n" "$HOMEBREW_PR_TOKEN" ;;' \
'esac' > "$askpass"
chmod 700 "$askpass"
export HOMEBREW_PR_TOKEN GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_COUNT=0
git_with_homebrew_token() {
git -c credential.helper= -c http.extraHeader= "$@"
}
find_canary_pr_number() {
owner="${REPOSITORY%%/*}"
numbers="$(
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls?state=open&head=$owner:$CANARY_BRANCH&base=$default_branch&per_page=2" \
--jq '.[].number'
)" || return 1
case "$numbers" in
"") return 0 ;;
*[!0-9]*) return 1 ;;
*) printf '%s\n' "$numbers" ;;
esac
}
close_canary_pr() {
GH_TOKEN="$TOKEN" gh api \
-X PATCH \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls/$1" \
-f state=closed \
--silent
}
delete_canary_branch() {
[ -n "$CANARY_COMMIT" ] || return 1
remote_ref="$(
git_with_homebrew_token -C "$CANARY_ROOT" ls-remote \
--heads "$canonical_remote" "refs/heads/$CANARY_BRANCH"
)" || return 1
[ -n "$remote_ref" ] || return 0
remote_sha="$(printf '%s\n' "$remote_ref" | awk 'NR == 1 { print $1 }')"
[ "$remote_sha" = "$CANARY_COMMIT" ] || return 1
git_with_homebrew_token -C "$CANARY_ROOT" push \
--force-with-lease="refs/heads/$CANARY_BRANCH:$CANARY_COMMIT" \
"$canonical_remote" ":refs/heads/$CANARY_BRANCH"
}
git_with_homebrew_token clone \
--depth=1 \
--no-tags \
--single-branch \
--branch "$default_branch" \
"$canonical_remote" \
"$CANARY_ROOT" >/dev/null \
|| err "HOMEBREW_PR_TOKEN cannot clone $REPOSITORY over canonical HTTPS"
git -C "$CANARY_ROOT" checkout -b "$CANARY_BRANCH" >/dev/null \
|| err "could not create local Homebrew token canary branch"
mkdir -p "$CANARY_ROOT/.dws"
printf 'run=%s attempt=%s actor=%s\n' "$run_id" "$run_attempt" "$actor" \
> "$CANARY_ROOT/.dws/homebrew-token-canary.txt"
git -C "$CANARY_ROOT" config user.name "DWS Release Bot"
git -C "$CANARY_ROOT" config user.email "dws-release-bot@example.com"
git -C "$CANARY_ROOT" add .dws/homebrew-token-canary.txt
git -C "$CANARY_ROOT" commit -m "chore: verify Homebrew token [skip ci]" >/dev/null \
|| err "could not create Homebrew token canary commit"
CANARY_COMMIT="$(git -C "$CANARY_ROOT" rev-parse HEAD)"
CANARY_BRANCH_MAY_EXIST="true"
git_with_homebrew_token -C "$CANARY_ROOT" push \
"$canonical_remote" "HEAD:refs/heads/$CANARY_BRANCH" >/dev/null \
|| err "HOMEBREW_PR_TOKEN cannot push a canary branch to $REPOSITORY"
CANARY_PR_MAY_EXIST="true"
canary_pr_url="$(
GH_TOKEN="$TOKEN" gh pr create \
--repo "$REPOSITORY" \
--base "$default_branch" \
--head "$CANARY_BRANCH" \
--title "chore: verify Homebrew PR token" \
--body "Automated permission canary. This PR is closed and its branch deleted by the same workflow run." \
--draft
)" || err "HOMEBREW_PR_TOKEN cannot create a canary pull request in $REPOSITORY"
CANARY_PR_NUMBER="${canary_pr_url##*/}"
case "$CANARY_PR_NUMBER" in
""|*[!0-9]*) err "Homebrew token canary returned an invalid pull request URL" ;;
esac
close_canary_pr "$CANARY_PR_NUMBER" \
|| err "HOMEBREW_PR_TOKEN could not close canary PR #$CANARY_PR_NUMBER"
CANARY_PR_MAY_EXIST="false"
CANARY_PR_NUMBER=""
delete_canary_branch >/dev/null \
|| err "HOMEBREW_PR_TOKEN could not delete canary branch $CANARY_BRANCH"
CANARY_BRANCH_MAY_EXIST="false"
fi
printf 'Homebrew PR token capability passed for %s as %s (%s)\n' \
"$REPOSITORY" "$actor" "$credential_kind"
+21 -3
View File
@@ -678,10 +678,13 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
"governance_preflight_nonce:",
`format('Release governance preflight {0}', inputs.governance_preflight_nonce)`,
"name: Release governance preflight",
"name: Check out trusted preflight tooling",
"github.event_name == 'workflow_dispatch'",
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
`ref: ${{ inputs.governance_preflight_commit }}`,
"persist-credentials: false",
"governance preflight cannot be combined with npm repair",
} {
if !strings.Contains(workflow, required) {
@@ -689,7 +692,6 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
for _, forbidden := range []string{
"actions/checkout",
"contents: write",
"goreleaser",
"gh release",
@@ -701,6 +703,11 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
}
}
ciGate := strings.Index(preflight, "Require successful CI Gate on the preflight commit")
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary {
t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials")
}
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") {
@@ -1014,14 +1021,25 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
}
for _, required := range []string{
"Check Homebrew PR automation token",
"Verify Homebrew PR automation permission",
"secrets.HOMEBREW_PR_TOKEN",
"HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases",
"verify-homebrew-pr-token.sh",
"--canary",
"HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities",
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
}
}
if got := strings.Count(workflow, "Verify Homebrew PR automation permission"); got != 2 {
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
}
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
tagCI := strings.Index(tagContract, "Require successful CI Gate on the sealed commit")
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew {
t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials")
}
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
if start == -1 {
+367
View File
@@ -1,6 +1,7 @@
package scripts_test
import (
"bytes"
"crypto/sha256"
"fmt"
"os"
@@ -8,7 +9,9 @@ import (
"path/filepath"
"sort"
"strings"
"syscall"
"testing"
"time"
)
var releasePlatformAssets = []string{
@@ -1240,6 +1243,370 @@ esac
}
}
func TestVerifyHomebrewPRTokenRequiresMinimalWorkingIdentity(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
test "${GH_TOKEN:-}" = valid-token
command="$1"
shift
test "$command" = api
endpoint=""
for argument in "$@"; do
case "$argument" in
repos/*|user) endpoint="$argument" ;;
esac
done
case "$endpoint" in
repos/owner/repo)
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: %s\n\nowner/repo\t%s\tmain\n' \
"${GH_SCOPES-public_repo}" "${GH_CAN_PUSH:-true}"
;;
repos/owner/repo/pulls?state=open\&per_page=1) ;;
user) printf '%s\n' release-bot ;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
run := func(token, scopes, canPush string) (string, error) {
cmd := exec.Command("sh", script, "owner/repo")
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"HOMEBREW_PR_TOKEN=" + token,
"GH_SCOPES=" + scopes,
"GH_CAN_PUSH=" + canPush,
}
output, err := cmd.CombinedOutput()
return string(output), err
}
output, err := run("valid-token", "public_repo", "true")
if err != nil || !strings.Contains(output, "Homebrew PR token capability passed for owner/repo as release-bot (classic-public_repo)") {
t.Fatalf("valid Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "repo", "true")
if err == nil || !strings.Contains(output, "must have only public_repo scope") {
t.Fatalf("overprivileged Homebrew PR token accepted: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "", "true")
if err != nil || !strings.Contains(output, "(fine-grained)") {
t.Fatalf("fine-grained Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "public_repo", "false")
if err == nil || !strings.Contains(output, "does not have push permission") {
t.Fatalf("non-pushing Homebrew identity accepted: err=%v\noutput:\n%s", err, output)
}
output, err = run("", "public_repo", "true")
if err == nil || !strings.Contains(output, "HOMEBREW_PR_TOKEN is required") {
t.Fatalf("missing Homebrew token accepted: err=%v\noutput:\n%s", err, output)
}
}
func TestVerifyHomebrewPRTokenCanaryCreatesAndCleans(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
r := newReleaseTestRepo(t)
binDir := t.TempDir()
stateDir := t.TempDir()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("LookPath(git) error = %v", err)
}
fakeGit := filepath.Join(binDir, "git")
mustWriteFile(t, fakeGit, []byte(`#!/bin/sh
set -eu
network=false
for argument in "$@"; do
case "$argument" in
clone|push|ls-remote) network=true ;;
esac
done
if [ "$network" = true ]; then
printf '%s\n' "$*" >> "$GH_STATE/git-network-args"
case " $* " in
*" https://github.com/owner/repo.git "*) ;;
*) exit 91 ;;
esac
test "${GIT_CONFIG_NOSYSTEM:-}" = 1
test "${GIT_CONFIG_GLOBAL:-}" = /dev/null
test "${GIT_CONFIG_COUNT:-}" = 0
test -x "${GIT_ASKPASS:-}"
test "$("$GIT_ASKPASS" Username)" = x-access-token
test "$("$GIT_ASKPASS" Password)" = valid-token
case " $* " in
*" HEAD:refs/heads/"*)
if [ "${BLOCK_PUSH:-false}" = true ]; then
"$REAL_GIT" \
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
"$@"
: > "$GH_STATE/push-complete-${GITHUB_RUN_ID:-unknown}"
sleep 2
exit 0
fi
;;
esac
case " $* " in
*" :refs/heads/"*)
if [ "${FAIL_DELETE:-false}" = true ]; then
exit 92
fi
;;
esac
exec "$REAL_GIT" \
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
"$@"
fi
exec "$REAL_GIT" "$@"
`), 0o755)
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
test "${GH_TOKEN:-}" = valid-token
command="$1"
shift
case "$command" in
api)
endpoint=""
for argument in "$@"; do
case "$argument" in
repos/*|user) endpoint="$argument" ;;
esac
done
case "$endpoint" in
repos/owner/repo)
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: public_repo\n\nowner/repo\ttrue\tmain\n'
;;
repos/owner/repo/pulls?state=open\&per_page=1) ;;
repos/owner/repo/pulls?state=open\&head=owner:*\&base=main\&per_page=2)
if [ -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}" ]; then
printf '%s\n' 42
fi
;;
repos/owner/repo/pulls/42)
if [ "${FAIL_CLOSE:-false}" = true ]; then
exit 93
fi
rm -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
printf '%s\n' closed > "$GH_STATE/pr-closed-${GITHUB_RUN_ID:-unknown}"
;;
user) printf '%s\n' release-bot ;;
*) exit 1 ;;
esac
;;
pr)
test "$1" = create
shift
printf '%s\n' "$@" > "$GH_STATE/pr-args"
: > "$GH_STATE/pr-create-started-${GITHUB_RUN_ID:-unknown}"
if [ "${FAIL_PR_CREATE:-false}" = true ]; then
exit 94
fi
: > "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
if [ "${BLOCK_PR_CREATE:-false}" = true ]; then
sleep 2
fi
printf '%s\n' 'https://github.com/owner/repo/pull/42'
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
baseEnv := []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GH_STATE=" + stateDir,
"HOMEBREW_PR_TOKEN=valid-token",
"GITHUB_RUN_ATTEMPT=2",
"REAL_GIT=" + realGit,
"TEST_REMOTE=" + r.remote,
}
runCanary := func(runID string, extraEnv ...string) ([]byte, error) {
cmd := exec.Command("sh", script, "owner/repo", "--canary")
cmd.Dir = r.root
cmd.Env = append(append([]string{}, baseEnv...), "GITHUB_RUN_ID="+runID)
cmd.Env = append(cmd.Env, extraEnv...)
return cmd.CombinedOutput()
}
remoteHasBranch := func(branch string) bool {
cmd := exec.Command(
"git", "--git-dir", r.remote, "show-ref", "--verify",
"refs/heads/"+branch,
)
return cmd.Run() == nil
}
output, err := runCanary("12345")
if err != nil || !strings.Contains(string(output), "Homebrew PR token capability passed") {
t.Fatalf("Homebrew token canary error = %v\noutput:\n%s", err, output)
}
prArgs, err := os.ReadFile(filepath.Join(stateDir, "pr-args"))
if err != nil {
t.Fatalf("ReadFile(PR args) error = %v", err)
}
for _, required := range []string{
"--draft",
"--base\nmain",
"--head\nautomation/homebrew-token-canary-12345-2",
} {
if !strings.Contains(string(prArgs), required) {
t.Errorf("Homebrew token canary PR args are missing %q:\n%s", required, prArgs)
}
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-12345")); err != nil {
t.Fatalf("Homebrew token canary did not close its PR: %v", err)
}
gitNetworkArgs, err := os.ReadFile(filepath.Join(stateDir, "git-network-args"))
if err != nil {
t.Fatalf("ReadFile(git network args) error = %v", err)
}
for _, required := range []string{
"clone",
"https://github.com/owner/repo.git",
"HEAD:refs/heads/automation/homebrew-token-canary-12345-2",
"--force-with-lease=refs/heads/automation/homebrew-token-canary-12345-2:",
":refs/heads/automation/homebrew-token-canary-12345-2",
} {
if !strings.Contains(string(gitNetworkArgs), required) {
t.Errorf("Homebrew token canary Git operations are missing %q:\n%s", required, gitNetworkArgs)
}
}
if remoteHasBranch("automation/homebrew-token-canary-12345-2") {
t.Fatal("Homebrew token canary left its remote branch behind")
}
output, err = runCanary("20001", "FAIL_PR_CREATE=true")
if err == nil || !strings.Contains(string(output), "cannot create a canary pull request") {
t.Fatalf("PR creation failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if remoteHasBranch("automation/homebrew-token-canary-20001-2") {
t.Fatal("PR creation failure left its canary branch behind")
}
output, err = runCanary("20002", "FAIL_CLOSE=true")
if err == nil ||
!strings.Contains(string(output), "could not close canary PR") ||
!strings.Contains(string(output), "could not clean up Homebrew token canary PR") {
t.Fatalf("PR cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if remoteHasBranch("automation/homebrew-token-canary-20002-2") {
t.Fatal("PR cleanup failure left its canary branch behind")
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20002")); err != nil {
t.Fatalf("PR cleanup failure did not leave an observable open canary PR: %v", err)
}
output, err = runCanary("20003", "FAIL_DELETE=true")
if err == nil ||
!strings.Contains(string(output), "could not delete canary branch") ||
!strings.Contains(string(output), "could not clean up Homebrew token canary branch") {
t.Fatalf("branch cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if !remoteHasBranch("automation/homebrew-token-canary-20003-2") {
t.Fatal("branch deletion failure was hidden instead of leaving an observable failed canary")
}
termCmd := exec.Command("sh", script, "owner/repo", "--canary")
termCmd.Dir = r.root
termCmd.Env = append(append([]string{}, baseEnv...),
"GITHUB_RUN_ID=20004",
"BLOCK_PR_CREATE=true",
)
var termStdout bytes.Buffer
var termStderr bytes.Buffer
termCmd.Stdout = &termStdout
termCmd.Stderr = &termStderr
if err := termCmd.Start(); err != nil {
t.Fatalf("Start(TERM canary) error = %v", err)
}
marker := filepath.Join(stateDir, "pr-create-started-20004")
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(marker); err == nil {
break
}
if time.Now().After(deadline) {
_ = termCmd.Process.Kill()
_ = termCmd.Wait()
t.Fatalf("TERM canary did not reach PR creation:\n%s%s", termStdout.String(), termStderr.String())
}
time.Sleep(20 * time.Millisecond)
}
if err := termCmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("Signal(TERM canary) error = %v", err)
}
err = termCmd.Wait()
exitErr, ok := err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 143 {
t.Fatalf("TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
err, termStdout.String(), termStderr.String())
}
if remoteHasBranch("automation/homebrew-token-canary-20004-2") {
t.Fatal("TERM canary left its remote branch behind")
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-20004")); err != nil {
t.Fatalf("TERM canary did not close the PR created during the signal race: %v", err)
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20004")); !os.IsNotExist(err) {
t.Fatalf("TERM canary left an open PR marker: %v", err)
}
pushTermCmd := exec.Command("sh", script, "owner/repo", "--canary")
pushTermCmd.Dir = r.root
pushTermCmd.Env = append(append([]string{}, baseEnv...),
"GITHUB_RUN_ID=20005",
"BLOCK_PUSH=true",
)
var pushTermStdout bytes.Buffer
var pushTermStderr bytes.Buffer
pushTermCmd.Stdout = &pushTermStdout
pushTermCmd.Stderr = &pushTermStderr
if err := pushTermCmd.Start(); err != nil {
t.Fatalf("Start(push TERM canary) error = %v", err)
}
pushMarker := filepath.Join(stateDir, "push-complete-20005")
deadline = time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(pushMarker); err == nil {
break
}
if time.Now().After(deadline) {
_ = pushTermCmd.Process.Kill()
_ = pushTermCmd.Wait()
t.Fatalf("push TERM canary did not complete its remote push:\n%s%s",
pushTermStdout.String(), pushTermStderr.String())
}
time.Sleep(20 * time.Millisecond)
}
if err := pushTermCmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("Signal(push TERM canary) error = %v", err)
}
err = pushTermCmd.Wait()
exitErr, ok = err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 143 {
t.Fatalf("push TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
err, pushTermStdout.String(), pushTermStderr.String())
}
if remoteHasBranch("automation/homebrew-token-canary-20005-2") {
t.Fatal("push TERM canary left its remote branch behind")
}
}
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)