From 9e0ad91ee0786fad293ecfd5856d09068c00b3fb Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 18 Sep 2026 03:43:58 +0800 Subject: [PATCH] fix: verify tap write authentication before release --- .github/workflows/release.yml | 48 +++++++++++--------- internal/install/release_test.go | 77 ++++++++++++++++++++++++++++++++ scripts/brew-formula-push.sh | 31 ++++++++++--- 3 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 internal/install/release_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d874479..c7b5513 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,15 @@ name: release on: push: tags: ['v*'] + workflow_dispatch: + inputs: + release_tag: + description: Existing release to republish to Homebrew (no asset rebuild) + required: true + type: string + +env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} permissions: contents: write @@ -24,7 +33,13 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # goreleaser changelog needs the full tag history + persist-credentials: false + - name: Validate the release tag + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] + git merge-base --is-ancestor "$RELEASE_TAG" origin/main - uses: actions/setup-go@v5 + if: github.event_name == 'push' with: go-version-file: go.mod cache: false # no go.sum (zero deps); silences the restore warning @@ -37,26 +52,17 @@ jobs: # half-published state into a clean no-op failure. # Skipped for prerelease tags, which never touch the tap. - name: Preflight — tap token can write the formula repo - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ !contains(env.RELEASE_TAG, '-') }} env: - GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - run: | - if [ -z "${GH_TOKEN:-}" ]; then - echo "::error::TAP_GITHUB_TOKEN is unset. Set it to a fine-grained PAT with Contents: write on TheWeiHu/homebrew-devbrain, then re-run this tag." - exit 1 - fi - # Let jq assert the permission instead of parsing gh's stdout. An - # expired token still prints a JSON error body, and piping that through - # head/tr would mask gh's non-zero exit — the fallback would never fire. - # `jq -e` fails on false OR null, so every bad-token shape (401, 404, - # wrong repo) fails closed right here. - if ! gh api repos/TheWeiHu/homebrew-devbrain 2>/dev/null | - jq -e '.permissions.push == true' >/dev/null 2>&1; then - echo "::error::TAP_GITHUB_TOKEN cannot write TheWeiHu/homebrew-devbrain — expired, revoked, or scoped to the wrong repository. Fix it and re-run this tag; nothing has been published yet." - exit 1 - fi - echo "tap token OK — homebrew-devbrain is writable" + GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: scripts/brew-formula-push.sh --check-auth + - name: Download existing release checksums + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern checksums.txt --dir dist - uses: goreleaser/goreleaser-action@v6 + if: github.event_name == 'push' with: version: '~> v2' args: release --clean @@ -65,20 +71,20 @@ jobs: - name: Push the brew formula to the tap env: GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - run: scripts/brew-formula-push.sh "${GITHUB_REF_NAME#v}" + run: scripts/brew-formula-push.sh "${RELEASE_TAG#v}" brew-canary: # Real end-to-end check: a fresh mac can brew-install the version this tag # just released. Only for full releases — prereleases never touch the tap. needs: release - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ !contains(inputs.release_tag || github.ref_name, '-') }} runs-on: macos-latest steps: - name: brew install from the tap and assert the version run: | brew install TheWeiHu/devbrain/devbrain got="$(devbrain version)" - want="${GITHUB_REF_NAME#v}" + want="${RELEASE_TAG#v}" echo "brew served: $got · want: $want" case "$got" in *"$want"*) ;; *) exit 1 ;; esac devbrain doctor >/dev/null 2>&1 || true # runs; codex-only sandboxes may rc!=0 diff --git a/internal/install/release_test.go b/internal/install/release_test.go new file mode 100644 index 0000000..6ea1f0f --- /dev/null +++ b/internal/install/release_test.go @@ -0,0 +1,77 @@ +package install + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/TheWeiHu/devbrain/internal/clitest" +) + +func TestTapWritePreflight(t *testing.T) { + for _, tc := range []struct { + name string + checkOnly bool + deny bool + }{ + {"valid token", true, false}, + {"read-only token", true, true}, + {"current formula still checks write access", false, true}, + {"current formula is idempotent", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + stub := `#!/bin/sh +printf '%s\n' "$*" >> "$GIT_CALLS" +case " $* " in + *" clone "*) + for target do :; done + mkdir -p "$target/Formula" + ;; + *" push --dry-run "*) + [ "$DENY_WRITE" = 0 ] || exit 128 + ;; + *" push "*) exit 99 ;; +esac +` + if err := os.WriteFile(filepath.Join(dir, "git"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + args := []string{filepath.Join(clitest.Root(t), "scripts", "brew-formula-push.sh"), "--check-auth"} + if !tc.checkOnly { + sums := filepath.Join(dir, "checksums.txt") + var content strings.Builder + for _, platform := range []string{"darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64"} { + content.WriteString(strings.Repeat("a", 64) + " devbrain_1.5.29_" + platform + ".tar.gz\n") + } + if err := os.WriteFile(sums, []byte(content.String()), 0o644); err != nil { + t.Fatal(err) + } + args = []string{args[0], "1.5.29", sums} + } + deny := "0" + if tc.deny { + deny = "1" + } + calls := filepath.Join(dir, "calls") + cmd := exec.Command("sh", args...) + cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "GITHUB_TOKEN=fixture-secret", "GIT_CALLS="+calls, "DENY_WRITE="+deny, "BREW_PUSH_DRY=") + out, err := cmd.CombinedOutput() + if (err != nil) != tc.deny { + t.Fatalf("exit = %v, denied = %v: %s", err, tc.deny, out) + } + log := mustRead(t, calls) + if !strings.Contains(log, "push --dry-run origin HEAD") { + t.Fatalf("write authentication was not exercised: %s", log) + } + if strings.Contains(log, "fixture-secret") || strings.Contains(string(out), "fixture-secret") { + t.Fatal("credential appeared in git arguments or output") + } + if !tc.checkOnly && !tc.deny && !strings.Contains(string(out), "formula already current") { + t.Fatalf("unchanged formula was not idempotent: %s", out) + } + }) + } +} diff --git a/scripts/brew-formula-push.sh b/scripts/brew-formula-push.sh index 3972f7b..e2d308e 100755 --- a/scripts/brew-formula-push.sh +++ b/scripts/brew-formula-push.sh @@ -8,6 +8,30 @@ # BREW_PUSH_DRY=1 renders the formula to stdout instead of pushing. set -eu +prepare_tap() { + : "${GITHUB_TOKEN:?brew-formula-push: GITHUB_TOKEN required to push the tap}" + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + cat >"$tmp/askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s\n' x-access-token ;; + *Password*) printf '%s\n' "$GITHUB_TOKEN" ;; +esac +ASKPASS + chmod 700 "$tmp/askpass" + export GIT_ASKPASS="$tmp/askpass" GIT_TERMINAL_PROMPT=0 + git -c credential.helper= clone -q --depth 1 https://github.com/TheWeiHu/homebrew-devbrain.git "$tmp/tap" + # A repository owner's API permissions do not prove this token can write. + git -C "$tmp/tap" -c credential.helper= push --dry-run origin HEAD +} + +if [ "${1:-}" = "--check-auth" ]; then + prepare_tap + echo "brew-formula-push: tap write authentication verified" + exit 0 +fi + version="${1:?usage: brew-formula-push.sh [checksums.txt]}" case "$version" in *-*) echo "brew-formula-push: prerelease/snapshot $version — skipping tap push"; exit 0 ;; @@ -88,10 +112,7 @@ if [ "${BREW_PUSH_DRY:-}" = "1" ]; then exit 0 fi -: "${GITHUB_TOKEN:?brew-formula-push: GITHUB_TOKEN required to push the tap}" -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -git clone -q --depth 1 "https://x-access-token:${GITHUB_TOKEN}@github.com/TheWeiHu/homebrew-devbrain.git" "$tmp/tap" +prepare_tap formula >"$tmp/tap/Formula/devbrain.rb" if git -C "$tmp/tap" diff --quiet; then echo "brew-formula-push: formula already current for v${version}" @@ -100,5 +121,5 @@ fi git -C "$tmp/tap" add Formula/devbrain.rb git -C "$tmp/tap" -c user.name=devbrain-release -c user.email=release@devbrain.invalid \ commit -qm "Brew formula update for devbrain version v${version}" -git -C "$tmp/tap" push -q origin HEAD +git -C "$tmp/tap" -c credential.helper= push -q origin HEAD echo "brew-formula-push: pushed formula for v${version}"