Skip to content

Commit 4fe12cd

Browse files
adhikjoshiclaude
andcommitted
Stop an expired tap token from killing the whole release
v0.2.0 published its GitHub release and 11 assets, then died. Both tap PATs — minted 2026-02-20, never rotated — now answer 401, goreleaser treats a failed cask/scoop push as fatal, and everything after it was skipped: npm never ran, PyPI failed on a dist/ that was never built. A credential governing two optional install channels took down both package registries. This is the same failure the file already documents twice. v0.1.2 published five npm packages, tripped npm's spam heuristic on the sixth, and PyPI never ran. v0.1.3 put the guard on the last step of a chain, which protected nothing. Each time the fix was scoped to the publisher that happened to fail. The taps were the remaining one. Probe both tap tokens against their repositories before goreleaser starts and set SKIP_HOMEBREW / SKIP_SCOOP from the result. A dead or missing token now emits a workflow warning naming the repo and the HTTP status, and skips that tap alone. .goreleaser.yml reads those through `skip_upload`, and takes the tokens via envOrDefault so an unset secret cannot fail config loading either. Two supporting changes: - workflow_dispatch with a `tag` input. A release that fails halfway had no way to be finished — the only trigger was the tag push that had already happened, so the choice was moving a published tag or burning a version number. Both jobs check out the requested tag, and the packaging steps take the version from RELEASE_TAG rather than GITHUB_REF_NAME so a dispatch names the tag it is publishing, not the branch it was launched from. - release.mode: replace. The default keep-existing leaves the assets from a failed run in place, so a retry cannot correct them. Verified: goreleaser check passes; a snapshot build with the skips set produces all 6 binaries and still writes the cask and scoop manifests. The probe was run against a valid token (false), an invalid one (401 warning, true) and an empty one (warning, true). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgkQePXPha8ShvoBerrVXL
1 parent 5504b29 commit 4fe12cd

2 files changed

Lines changed: 90 additions & 6 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ on:
44
push:
55
tags:
66
- "v*"
7+
# A release that fails halfway must be retryable without moving a published
8+
# tag. v0.2.0 published its GitHub assets and then lost npm, PyPI and both
9+
# taps to an expired credential, and there was no way to finish it: the only
10+
# trigger was the tag push that had already happened.
11+
workflow_dispatch:
12+
inputs:
13+
tag:
14+
description: "Existing tag to (re)publish, e.g. v0.2.0"
15+
required: true
16+
type: string
717

818
permissions:
919
contents: write
@@ -12,11 +22,16 @@ permissions:
1222
# outright rather than degrading to an unsigned one.
1323
id-token: write
1424

25+
env:
26+
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
27+
1528
jobs:
1629
test:
1730
runs-on: ubuntu-latest
1831
steps:
1932
- uses: actions/checkout@v4
33+
with:
34+
ref: ${{ inputs.tag || github.ref_name }}
2035

2136
- uses: actions/setup-go@v5
2237
with:
@@ -32,11 +47,61 @@ jobs:
3247
- uses: actions/checkout@v4
3348
with:
3449
fetch-depth: 0
50+
ref: ${{ inputs.tag || github.ref_name }}
3551

3652
- uses: actions/setup-go@v5
3753
with:
3854
go-version: "1.26"
3955

56+
# Probe the tap credentials BEFORE goreleaser runs.
57+
#
58+
# goreleaser treats a failed cask/scoop push as fatal, so a PAT that
59+
# expires silently takes down npm, PyPI and every downstream step along
60+
# with the two install channels it actually governs. Checking first turns
61+
# that into a warning and a skipped tap. Reads .goreleaser.yml's
62+
# SKIP_HOMEBREW / SKIP_SCOOP.
63+
- name: Check tap credentials
64+
id: taps
65+
env:
66+
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
67+
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
68+
run: |
69+
set -uo pipefail
70+
71+
probe() {
72+
local name="$1" repo="$2" token="$3"
73+
74+
if [ -z "$token" ]; then
75+
echo "::warning title=$name skipped::no token configured for $repo"
76+
return 1
77+
fi
78+
79+
local status
80+
status="$(curl -sS -o /dev/null -w '%{http_code}' \
81+
-H "Authorization: Bearer $token" \
82+
-H "Accept: application/vnd.github+json" \
83+
"https://api.github.com/repos/$repo")"
84+
85+
if [ "$status" != "200" ]; then
86+
echo "::warning title=$name skipped::token cannot read $repo (HTTP $status) — rotate the PAT and re-run this workflow with the same tag"
87+
return 1
88+
fi
89+
90+
return 0
91+
}
92+
93+
if probe Homebrew ModelsLab/homebrew-tap "$HOMEBREW_TAP_GITHUB_TOKEN"; then
94+
echo "SKIP_HOMEBREW=false" >> "$GITHUB_ENV"
95+
else
96+
echo "SKIP_HOMEBREW=true" >> "$GITHUB_ENV"
97+
fi
98+
99+
if probe Scoop ModelsLab/scoop-bucket "$SCOOP_BUCKET_GITHUB_TOKEN"; then
100+
echo "SKIP_SCOOP=false" >> "$GITHUB_ENV"
101+
else
102+
echo "SKIP_SCOOP=true" >> "$GITHUB_ENV"
103+
fi
104+
40105
- name: Run GoReleaser
41106
uses: goreleaser/goreleaser-action@v6
42107
with:
@@ -47,6 +112,7 @@ jobs:
47112
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48113
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
49114
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
115+
# SKIP_HOMEBREW and SKIP_SCOOP reach goreleaser through $GITHUB_ENV.
50116

51117
# Both registries package the SAME binaries goreleaser just built, taken
52118
# from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can
@@ -73,7 +139,7 @@ jobs:
73139
registry-url: "https://registry.npmjs.org"
74140

75141
- name: Build npm packages
76-
run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm
142+
run: node packaging/npm/build.mjs "${RELEASE_TAG}" artifacts dist/npm
77143

78144
# `secrets` is not an available context in a step-level `if`, so the token
79145
# is mapped to env and the guard reads that. Without the guard, a fork or a
@@ -96,7 +162,7 @@ jobs:
96162

97163
- name: Build PyPI wheels
98164
if: ${{ !cancelled() }}
99-
run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi
165+
run: python3 packaging/pypi/build.py "${RELEASE_TAG}" artifacts dist/pypi
100166

101167
# `if: always()` because npm and PyPI are independent registries and a
102168
# failure at one is not a reason to skip the other. v0.1.2 published five

‎.goreleaser.yml‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ archives:
3737
checksum:
3838
name_template: "checksums.txt"
3939

40+
release:
41+
# Re-running a release must be safe. The default `keep-existing` leaves a
42+
# half-published release from a failed run in place, so the retry cannot
43+
# correct it; `replace` makes the tag's assets match whatever this run built.
44+
mode: replace
45+
4046
snapshot:
4147
version_template: "{{ incpatch .Version }}-next"
4248

@@ -50,18 +56,30 @@ changelog:
5056
- "Merge pull request"
5157

5258
homebrew_casks:
53-
- repository:
59+
# skip_upload is driven by the workflow's tap-credential probe.
60+
#
61+
# goreleaser treats a failed cask push as fatal, and the tap PATs expired
62+
# seven months after they were minted. On v0.2.0 that 401 took the ENTIRE
63+
# release with it: npm, PyPI and every step after goreleaser never ran, over
64+
# a credential that only affects two optional install channels. A dead tap
65+
# token now degrades the release instead of ending it.
66+
#
67+
# envOrDefault, not .Env: an unset secret must not fail config loading.
68+
- skip_upload: '{{ envOrDefault "SKIP_HOMEBREW" "false" }}'
69+
repository:
5470
owner: ModelsLab
5571
name: homebrew-tap
56-
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
72+
token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "unset" }}'
5773
homepage: https://modelslab.sh
5874
description: "ModelsLab CLI — AI generation and account management from the terminal"
5975

6076
scoops:
61-
- repository:
77+
# See the note on homebrew_casks above.
78+
- skip_upload: '{{ envOrDefault "SKIP_SCOOP" "false" }}'
79+
repository:
6280
owner: ModelsLab
6381
name: scoop-bucket
64-
token: "{{ .Env.SCOOP_BUCKET_GITHUB_TOKEN }}"
82+
token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "unset" }}'
6583
homepage: https://modelslab.sh
6684
description: "ModelsLab CLI — AI generation and account management from the terminal"
6785
license: MIT

0 commit comments

Comments
 (0)