From 4fe12cd8986c3dc3de67a91edb813c7466588ab5 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Wed, 9 Sep 2026 23:35:52 +0530 Subject: [PATCH] Stop an expired tap token from killing the whole release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QgkQePXPha8ShvoBerrVXL --- .github/workflows/release.yml | 70 ++++++++++++++++++++++++++++++++++- .goreleaser.yml | 26 +++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5278ced..584cb14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,16 @@ on: push: tags: - "v*" + # A release that fails halfway must be retryable without moving a published + # tag. v0.2.0 published its GitHub assets and then lost npm, PyPI and both + # taps to an expired credential, and there was no way to finish it: the only + # trigger was the tag push that had already happened. + workflow_dispatch: + inputs: + tag: + description: "Existing tag to (re)publish, e.g. v0.2.0" + required: true + type: string permissions: contents: write @@ -12,11 +22,16 @@ permissions: # outright rather than degrading to an unsigned one. id-token: write +env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref_name }} - uses: actions/setup-go@v5 with: @@ -32,11 +47,61 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ inputs.tag || github.ref_name }} - uses: actions/setup-go@v5 with: go-version: "1.26" + # Probe the tap credentials BEFORE goreleaser runs. + # + # goreleaser treats a failed cask/scoop push as fatal, so a PAT that + # expires silently takes down npm, PyPI and every downstream step along + # with the two install channels it actually governs. Checking first turns + # that into a warning and a skipped tap. Reads .goreleaser.yml's + # SKIP_HOMEBREW / SKIP_SCOOP. + - name: Check tap credentials + id: taps + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} + run: | + set -uo pipefail + + probe() { + local name="$1" repo="$2" token="$3" + + if [ -z "$token" ]; then + echo "::warning title=$name skipped::no token configured for $repo" + return 1 + fi + + local status + status="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$repo")" + + if [ "$status" != "200" ]; then + echo "::warning title=$name skipped::token cannot read $repo (HTTP $status) — rotate the PAT and re-run this workflow with the same tag" + return 1 + fi + + return 0 + } + + if probe Homebrew ModelsLab/homebrew-tap "$HOMEBREW_TAP_GITHUB_TOKEN"; then + echo "SKIP_HOMEBREW=false" >> "$GITHUB_ENV" + else + echo "SKIP_HOMEBREW=true" >> "$GITHUB_ENV" + fi + + if probe Scoop ModelsLab/scoop-bucket "$SCOOP_BUCKET_GITHUB_TOKEN"; then + echo "SKIP_SCOOP=false" >> "$GITHUB_ENV" + else + echo "SKIP_SCOOP=true" >> "$GITHUB_ENV" + fi + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -47,6 +112,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} + # SKIP_HOMEBREW and SKIP_SCOOP reach goreleaser through $GITHUB_ENV. # Both registries package the SAME binaries goreleaser just built, taken # from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can @@ -73,7 +139,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Build npm packages - run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm + run: node packaging/npm/build.mjs "${RELEASE_TAG}" artifacts dist/npm # `secrets` is not an available context in a step-level `if`, so the token # is mapped to env and the guard reads that. Without the guard, a fork or a @@ -96,7 +162,7 @@ jobs: - name: Build PyPI wheels if: ${{ !cancelled() }} - run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi + run: python3 packaging/pypi/build.py "${RELEASE_TAG}" artifacts dist/pypi # `if: always()` because npm and PyPI are independent registries and a # failure at one is not a reason to skip the other. v0.1.2 published five diff --git a/.goreleaser.yml b/.goreleaser.yml index 9e08624..d8d2e79 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -37,6 +37,12 @@ archives: checksum: name_template: "checksums.txt" +release: + # Re-running a release must be safe. The default `keep-existing` leaves a + # half-published release from a failed run in place, so the retry cannot + # correct it; `replace` makes the tag's assets match whatever this run built. + mode: replace + snapshot: version_template: "{{ incpatch .Version }}-next" @@ -50,18 +56,30 @@ changelog: - "Merge pull request" homebrew_casks: - - repository: + # skip_upload is driven by the workflow's tap-credential probe. + # + # goreleaser treats a failed cask push as fatal, and the tap PATs expired + # seven months after they were minted. On v0.2.0 that 401 took the ENTIRE + # release with it: npm, PyPI and every step after goreleaser never ran, over + # a credential that only affects two optional install channels. A dead tap + # token now degrades the release instead of ending it. + # + # envOrDefault, not .Env: an unset secret must not fail config loading. + - skip_upload: '{{ envOrDefault "SKIP_HOMEBREW" "false" }}' + repository: owner: ModelsLab name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "unset" }}' homepage: https://modelslab.sh description: "ModelsLab CLI — AI generation and account management from the terminal" scoops: - - repository: + # See the note on homebrew_casks above. + - skip_upload: '{{ envOrDefault "SKIP_SCOOP" "false" }}' + repository: owner: ModelsLab name: scoop-bucket - token: "{{ .Env.SCOOP_BUCKET_GITHUB_TOKEN }}" + token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "unset" }}' homepage: https://modelslab.sh description: "ModelsLab CLI — AI generation and account management from the terminal" license: MIT