Files
pad/.github/workflows/release.yml
T
xarmian 2c1706fbbb build(release): a shipped binary that does not start fails the release before it publishes (TASK-2035) (#1980)
A GoReleaser builds[].hooks.post runs scripts/release-smoke.sh on every
binary right after its build and before anything is archived, signed,
notarized, pushed or uploaded; a non-zero exit fails 'release' at the
build stage with nothing published. No skip switch (lead ruling): a flake
gets a re-run, not a bypass.

- Every target, statically (go version -m reads any GOOS/GOARCH): a Go
  executable whose main is github.com/PerpetualSoftware/pad/cmd/pad, with
  the GOOS/GOARCH it claims and the version and commit the release sets
  in its -ldflags (GoReleaser builds without VCS stamping).
- linux/amd64 natively and linux/arm64 under qemu-user also BOOT:
  --version names the version and commit, and 'server start' on a free
  loopback port with an empty HOME/data dir answers /api/v1/health with
  status ok, the same version and commit, and an embedded web bundle
  built from the same commit; then it must stop on SIGTERM.
- darwin and windows cannot run on the ubuntu runner; ci.yml's
  native-smoke job boots them from source on every PR.

The binary runs with an empty environment: the hook inherits the
GoReleaser step's env, which carries the macOS signing and notary
secrets. qemu-user comes from Ubuntu's own package, not a new third-party
action, since this job holds contents:write and the GHCR token.

Verified with goreleaser v2.15.4 --snapshot (publish, sign, notarize,
docker and sbom skipped): all six pass; a hook expecting the wrong
version fails all six at the build stage, and a binary that exits at
'server start' fails linux/amd64 and linux/arm64 there; both produce no
archive.
2026-10-09 13:50:51 -04:00

241 lines
12 KiB
YAML

name: Release
on:
push:
tags:
- "v*"
# Serialize all release runs. If two v* tags land close together (e.g.
# rc.3 then rc.4 within a minute), queue rather than race — they share
# mutable outputs (the GHCR `:latest` tag, the homebrew cask in the
# separate tap repo, the GitHub Releases page) and parallel runs would
# interleave nondeterministically. Group is intentionally NOT keyed by
# `github.ref`: we want different tag names to serialize too, not just
# repeat pushes of the same tag. cancel-in-progress=false so a queued
# tag never aborts a release mid-publish (which could leave GHCR and the
# brew tap in inconsistent states).
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: write
packages: write
# id-token: write is required for keyless cosign signing (GitHub OIDC
# exchanges this workflow's identity token for a short-lived Fulcio
# certificate) and for actions/attest-build-provenance to mint SLSA
# v1 provenance statements.
id-token: write
# attestations: write is required by actions/attest-build-provenance so
# the resulting provenance bundles can be stored against the repo.
attestations: write
# All third-party Actions are pinned to a 40-char commit SHA with a trailing
# '# vX.Y.Z' comment so a compromised maintainer or moved tag cannot silently
# execute attacker code in the release pipeline (this workflow has
# contents:write + packages:write + the GHCR token, so a malicious action
# here could publish tampered binaries). Bump the SHA + comment together.
jobs:
release:
name: Build & Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# actions/setup-go pins GOTOOLCHAIN=local, which blocks the toolchain
# go.mod requires (`go 1.26.5` floor, `toolchain go1.26.9`) from being
# fetched. `auto`, written after setup-go so it wins the $GITHUB_ENV
# last-write, lets Go pull it on demand. Mirrors the CI workflow; see
# #896. As of BUG-2565 this also decides which stdlib the RELEASED
# binaries carry — under `local` they would ship the 1.26.5 stdlib and
# its 8 reachable advisories.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Create web build placeholder for tests
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
# Built for real, not stubbed: internal/materialize's tests run the
# materializer bundle (TASK-2198). The "Build web UI" step below
# rebuilds it, with the rest of the web build, for the release binaries.
- name: Build the materializer bundle for tests
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Run tests
# Explicit -timeout, same reasoning as ci.yml's steps (TASK-2545):
# `go test` defaults to 10m per test binary, and this is the RELEASE
# gate — the one place an unchosen default is most expensive. SQLite
# only here (no PAD_TEST_POSTGRES_URL), so it is the faster driver,
# but it grows on the same curve.
run: go test -timeout=45m ./...
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# cosign + syft need to be on PATH before goreleaser runs — goreleaser
# shells out to both for the signs/docker_signs/sboms sections.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Install syft (for SBOM generation)
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
# Build the SvelteKit web UI before GoReleaser so the static assets
# get embedded into the Go binary. Done as a dedicated step (instead
# of a goreleaser `before:` hook) so the npm install/build does NOT
# inherit the MACOS_* signing secrets — those are scoped only to the
# `Run GoReleaser` step's env block below. This isolates the 5-year
# Developer ID cert from any npm supply-chain compromise during
# dependency install.
- name: Build web UI
run: cd web && npm ci && npm run build
# The changelog's start: the highest stable tag below this one, by
# version and whatever branch it is on. GoReleaser's own default is git
# describe on the tag's parent, which cannot see a tag cut on a
# release-X.Y.x branch; from main it chose v0.15.0-rc.1 while v0.16.0
# existed (TASK-3268). Fails the run rather than falling back.
- name: Resolve the previous release tag
id: previous_tag
# Assigned first: a failing $(...) inside echo would not fail the step.
run: |
previous="$(scripts/release-previous-tag.sh "$GITHUB_REF_NAME")"
echo "Changelog base: $previous..$GITHUB_REF_NAME"
echo "tag=$previous" >> "$GITHUB_OUTPUT"
# The release gate (TASK-2035, scripts/release-smoke.sh, called from a
# .goreleaser.yaml build hook) BOOTS the linux/arm64 binary as well as
# linux/amd64, which needs qemu-user registered with binfmt_misc.
# Ubuntu's own package rather than a third-party action: this job holds
# contents:write and the GHCR token, and the qemu here only ever runs
# our own freshly built binary.
- name: Install qemu-user for the arm64 release smoke
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq qemu-user-static
test -e /proc/sys/fs/binfmt_misc/qemu-aarch64
- name: Run GoReleaser
id: goreleaser
# GoReleaser binary is pinned to an exact version (not "~> v2") to
# match the SHA-pinning policy applied to the Actions themselves —
# see the comment at the top of this file. With Apple signing
# credentials now flowing through this step, a compromised or
# regressed GoReleaser release would carry meaningful blast radius;
# pinning forces an explicit, reviewed bump.
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: "v2.15.4"
# --timeout=2h overrides GoReleaser's 1h default. With Apple
# notarization (`wait: true`, up to 20m per the .goreleaser.yaml
# notarize block) layered on top of build + cosign blob-sign +
# SBOM + multi-arch docker manifest, slow notary days could push
# close to the default ceiling. 2h gives comfortable headroom
# without burning excessive Action minutes when notarization
# actually fails fast (the worker exits as soon as Apple replies).
args: release --clean --timeout=2h
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Force-set the release tag from the triggering ref to bypass
# goreleaser's git-describe-based auto-detection. When two
# lightweight tags point at the same commit (e.g. v0.4.0 cut
# right on top of v0.4.0-rc.1 with no intervening commits),
# git-describe's tiebreaker is non-deterministic across hosts
# — locally it picked v0.4.0, the CI runner picked v0.4.0-rc.1
# during the v0.4.0 ship and stamped artifacts with the RC
# version. github.ref_name is unambiguous: it's exactly the
# tag that triggered the workflow. See PLAYB-1160 failure modes.
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
GORELEASER_PREVIOUS_TAG: ${{ steps.previous_tag.outputs.tag }}
# Cross-repo PAT for pushing the brew formula to PerpetualSoftware/homebrew-tap.
# The default GITHUB_TOKEN above only has access to PerpetualSoftware/pad.
# Add this secret in repo settings before tagging a release that ships
# a brew formula — without it goreleaser fails at the brew publish step.
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# macOS code-signing + Apple notarization (per IDEA-830). The
# `notarize:` block in .goreleaser.yaml is gated on MACOS_CERT_P12
# being set, so PR builds + snapshot mode skip cleanly when these
# are absent. The .p12 cert and .p8 notary key are stored
# base64-encoded; GoReleaser's Quill backend decodes them in-process,
# so no external signing tool needs to be installed on the runner.
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
MACOS_NOTARY_KEY_P8: ${{ secrets.MACOS_NOTARY_KEY_P8 }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
# SLSA build provenance for every archive GoReleaser produced.
# Writes a Sigstore-backed attestation to the repo so downstream
# consumers can verify this binary was actually built by this
# workflow from this commit, e.g.:
# gh attestation verify pad_v1.0.0_linux_amd64.tar.gz \
# --repo PerpetualSoftware/pad
- name: Generate build provenance for archives
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: "dist/pad_*_*_*.tar.gz,dist/pad_*_*_*.zip"
# TASK-3487: point the Claude Code plugin marketplace at this release.
# `.claude-plugin/marketplace.json` on main is what `/plugin marketplace add
# PerpetualSoftware/pad` reads; the script pins its pad entry to this tag
# (git-subdir, path plugin) and pushes to main. STABLE TAGS ONLY: the brew
# cask never gets an rc, so the plugin pairs with the CLI people have.
#
# Its own job, after the release, and it never fails the run: the release is
# the product and the pin is bookkeeping. A pin that cannot land opens an
# issue instead, so a maintainer can run the script by hand.
pin-plugin:
name: Pin the plugin marketplace to this release
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
# One pin at a time; the script also refuses to move the pin backwards,
# for two releases that finish out of order.
concurrency:
group: pin-plugin
cancel-in-progress: false
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- name: Pin the marketplace to ${{ github.ref_name }}
id: pin
continue-on-error: true
run: scripts/pin-plugin-marketplace.sh --push "$GITHUB_REF_NAME"
- name: Report a pin that did not land
if: ${{ steps.pin.outcome == 'failure' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "::warning::The plugin marketplace was not pinned to $GITHUB_REF_NAME; see the opened issue."
gh issue create --repo "$GITHUB_REPOSITORY" \
--title "Plugin marketplace not pinned to $GITHUB_REF_NAME" \
--body "The release workflow's pin step failed (run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). The release itself is published. Pin by hand from an up-to-date main: \`scripts/pin-plugin-marketplace.sh --push $GITHUB_REF_NAME\` (TASK-3487)."