diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d23534e5..da743689 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/docs/automation.md b/docs/automation.md index c8bed099..8c8a3742 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 31905574..5a4b4125 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/release/verify-homebrew-pr-token.sh b/scripts/release/verify-homebrew-pr-token.sh new file mode 100755 index 00000000..9a25d243 --- /dev/null +++ b/scripts/release/verify-homebrew-pr-token.sh @@ -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 [--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" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 08b8dcb2..4080deff 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -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 { diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 66e5d5fd..38df9fab 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -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)