Skip to content

ci(release): a suffixed tag ships as a pre-release #21

ci(release): a suffixed tag ships as a pre-release

ci(release): a suffixed tag ships as a pre-release #21

Workflow file for this run

name: release
# Publishes the binaries that scripts/install.sh downloads.
#
# Until this existed, `curl -fsSL https://nan.builders/install | bash` could
# never work: the script asks the GitHub API for the latest release, and the
# repo had none, so it had nothing to fetch. Anyone who wanted the CLI had to
# clone the repo and build it with Go.
#
# The artifact names here are a contract with scripts/install.sh, which builds
# the download URL as:
#
# nan-cli_${version}_${os}_${arch}.tar.gz
#
# where ${version} is the tag verbatim, `v` included, because the script reads
# it from the release's `tag_name`. Renaming these breaks the installer
# silently: the release looks fine on GitHub and the one-liner 404s.
#
# The Windows archives are .zip and sit outside that contract: install.sh is
# bash and never asks for them. They are published so that installing on
# Windows stops meaning `go build`.
on:
push:
tags:
- 'v*'
# How .github/workflows/tag.yml publishes: it creates the tag and then calls
# this directly, because a tag it pushed with the repository's own token will
# not set off the trigger above.
workflow_call:
inputs:
tag:
description: 'Tag to build and publish (e.g. v0.1.1)'
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build and publish (e.g. v0.1.1)'
required: true
type: string
permissions:
contents: write
# For the attestation below. The runner asks GitHub's OIDC provider for a
# token that says which workflow, in which repository, at which commit is
# running, and that token is what signs the archives - there is no key here
# to keep, rotate or lose.
id-token: write
attestations: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Build the tag, not the branch. When a tag push starts this the two
# are the same commit, but when tag.yml calls it main has already
# moved on in principle, and an archive attested as this tag has to
# be the commit the tag names. Empty for a tag push, where the ref
# that triggered the run is the tag itself.
ref: ${{ inputs.tag }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Through the environment, and checked before it goes anywhere near a
# shell. A workflow_dispatch input pasted straight into a `run:` block is
# substituted before bash ever sees it, so whatever was typed into the
# box becomes part of the script - and every step below puts this value
# on a command line. Only something shaped like one of our tags gets
# past here.
- name: Resolve the tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$REF_NAME}"
if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+[.][0-9]+[.][0-9]+([-][A-Za-z0-9.]+)?$'; then
echo "not a version tag: $tag" >&2
exit 1
fi
echo "value=$tag" >> "$GITHUB_OUTPUT"
# The version shown by `nan --version` is a constant in the source
# (internal/build.Version). A tag that disagrees with it ships a binary
# that lies about which build it is, and the first person to report a
# bug reports the wrong version.
- name: Check the tag matches internal/build.Version
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
declared="$(grep -oP '^var Version = "\K[^"]+' internal/build/build.go)"
# A pre-release (v1.2.3-rc.1) is a candidate for the version the
# source declares, so it is checked without its suffix.
tag="${TAG%%-*}"
if [ "v$declared" != "$tag" ]; then
echo "tag $tag does not match internal/build.Version ($declared)" >&2
echo "bump the constant or retag" >&2
exit 1
fi
- name: Build
env:
VERSION: ${{ steps.tag.outputs.value }}
run: |
set -euo pipefail
mkdir -p dist
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"
arch="${target#*/}"
binary="nan"
if [ "$os" = "windows" ]; then
binary="nan.exe"
fi
# -trimpath keeps build paths out of the binary; -s -w drops the
# symbol and DWARF tables, which is most of the size.
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w" -o "dist/$binary" ./cmd/nan
# Windows gets a .zip, not a .tar.gz: nothing on a stock Windows
# unpacks a tarball by double-clicking, and scripts/install.sh is
# bash, so nobody reaches these through the one-liner anyway.
# THIRD-PARTY-NOTICES.txt travels with every copy: one dependency
# is Apache-2.0, and its notice has to go wherever the binary goes.
cp THIRD-PARTY-NOTICES.txt dist/
if [ "$os" = "windows" ]; then
(cd dist && zip -q "nan-cli_${VERSION}_${os}_${arch}.zip" "$binary" THIRD-PARTY-NOTICES.txt)
else
tar -czf "dist/nan-cli_${VERSION}_${os}_${arch}.tar.gz" -C dist "$binary" THIRD-PARTY-NOTICES.txt
fi
rm "dist/$binary" dist/THIRD-PARTY-NOTICES.txt
done
cd dist && sha256sum *.tar.gz *.zip > checksums.txt
cat checksums.txt
# Installing what we are about to publish, the same way a member would,
# so a broken artifact fails here instead of on their machine.
- name: Smoke test the linux/amd64 archive
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
set -euo pipefail
tar -xzf "dist/nan-cli_${TAG}_linux_amd64.tar.gz" -C /tmp
/tmp/nan --version
/tmp/nan --help > /dev/null
# The Windows binaries cannot be run here, but an archive that unpacks
# to nothing, or to a name Windows will not execute, can still be caught.
- name: Check the Windows archives carry an .exe
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
set -euo pipefail
for arch in amd64 arm64; do
zip="dist/nan-cli_${TAG}_windows_${arch}.zip"
unzip -l "$zip" | grep -q "nan.exe" || {
echo "$zip does not contain nan.exe" >&2
exit 1
}
done
# checksums.txt proves the bytes arrived whole. It proves nothing about
# where they came from: whoever can replace an archive on a release can
# replace the checksum sitting next to it, and the installer would verify
# the download against the attacker's own number and call it good.
#
# This signs the archives themselves. GitHub keeps the attestation, so
# anyone can ask what built a binary they already have:
#
# gh attestation verify nan-cli_v1.2.3_linux_amd64.tar.gz --repo helmcode/nan-cli
#
# and get back the workflow, the repository and the commit - none of
# which a release someone else published can produce.
- name: Attest what was built
uses: actions/attest-build-provenance@v4
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.value }}
# A tag with a suffix (v1.2.3-rc.1) is a build for somebody to try,
# not a release: it goes out as a pre-release and never as latest.
# The installers and the update notice ask for /releases/latest,
# which skips pre-releases, so members keep the release they have
# and only whoever is given the tag installs it.
run: |
set -euo pipefail
channel=()
case "$TAG" in
*-*) channel=(--prerelease --latest=false) ;;
esac
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
"${channel[@]}" \
dist/*.tar.gz dist/*.zip dist/checksums.txt