Repository navigation
ci(release): a suffixed tag ships as a pre-release #21
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |