Skip to content

Render the tap tokens the only way goreleaser allows (#11) #11

Render the tap tokens the only way goreleaser allows (#11)

Render the tap tokens the only way goreleaser allows (#11) #11

Workflow file for this run

name: Release
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
# Required by `npm publish --provenance`: npm mints a signed provenance
# attestation from a GitHub OIDC token, and without this it fails the publish
# 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:
go-version: "1.26"
- name: Unit Tests
run: go test ./internal/... -count=1
release:
runs-on: ubuntu-latest
needs: test
steps:
- 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:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
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
# never ship different bytes for one tag.
- name: Collect release binaries
run: |
set -euo pipefail
# Read goreleaser's own manifest rather than parsing dist/ directory
# names: those carry microarchitecture suffixes (_v1, _v8.0) that move
# between goreleaser versions, and artifacts.json states goos/goarch
# outright.
jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
dist/artifacts.json |
while IFS=$'\t' read -r target path; do
mkdir -p "artifacts/$target"
cp "$path" "artifacts/$target/"
done
find artifacts -type f | sort
test "$(find artifacts -type f | wc -l)" -eq 6
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Build npm packages
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
# repo that has not configured the token fails the whole release.
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: ${{ env.NODE_AUTH_TOKEN != '' }}
run: bash packaging/npm/publish.sh dist/npm
# `!cancelled()` on every PyPI step, not just the upload. v0.1.3 had the
# guard on the upload alone: npm failed, the build step was skipped as a
# normal downstream skip, and the upload then ran and died on
# "Cannot find file dist/pypi/*.whl". A guard on the last step of a chain
# protects nothing.
- uses: actions/setup-python@v5
if: ${{ !cancelled() }}
with:
python-version: "3.12"
- name: Build PyPI wheels
if: ${{ !cancelled() }}
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
# npm packages, tripped npm's spam heuristic on the sixth, and PyPI never
# ran at all — one registry's flakiness took the whole release with it.
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
# `secrets` is not an available context in a step-level `if` — hence env.
if: ${{ !cancelled() && env.TWINE_PASSWORD != '' }}
run: |
set -euo pipefail
python3 -m pip install --quiet twine
python3 -m twine check dist/pypi/*.whl
# --skip-existing so a re-run after a partial failure is safe; PyPI
# rejects a repeated version outright and would fail the retry.
python3 -m twine upload --skip-existing dist/pypi/*.whl