Skip to content

Final 0.3.0 readiness changes #19

Final 0.3.0 readiness changes

Final 0.3.0 readiness changes #19

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
# Release runs are NEVER canceled mid-flight: once the OIDC certs
# are issued and uploaded to the public Sigstore log, cancelling
# leaves orphan certs that look like a partial release. `cancel-in-
# progress: false` is explicit so anyone re-running the release
# (e.g. for a hotfix tag) doesn't accidentally cancel an in-flight
# canonical run.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
id-token: write # required for npm provenance + Sigstore keyless signing
attestations: write # SLSA build-provenance attestations
jobs:
# Gate: single source of truth — same script locally and in CI.
# Runs once on a single OS; build matrix below is gated on this passing.
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: '22.x'
cache: npm
cache-dependency-path: |
package-lock.json
extension/vscode/package-lock.json
- name: Verify tag matches package.json version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $PKG_VERSION"
exit 1
fi
- name: Run release verification
run: make release-verify
# Build per-OS via matrix. CGO requires platform-native toolchains; we use
# one runner per OS family. Linux runner produces both amd64 (native) and
# arm64 (cross-compiled with gcc-aarch64-linux-gnu).
#
# Each runner builds its own slice via goreleaser `build --id <each>`, then
# packages/signs via `release --skip=publish,validate,build`. The aggregator
# job below downloads all artifacts and creates a single GitHub Release.
go-release-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
# build_args is the verbatim --id flag list passed to goreleaser
# build. Goreleaser v2's --id is singular and does NOT accept
# comma-separated values, so multi-id builds need one --id flag
# per id (the linux runner produces both amd64 and arm64).
- os: ubuntu-latest
build_args: --id terrain-linux-amd64 --id terrain-linux-arm64
artifact_name: terrain-linux
- os: macos-latest
build_args: --id terrain-darwin
artifact_name: terrain-darwin
- os: windows-latest
build_args: --id terrain-windows
artifact_name: terrain-windows
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
# Linux arm64 needs a cross-compiler.
- name: Install aarch64 cross-compiler (linux only)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
# Install syft for SBOM generation.
- name: Install syft
uses: anchore/sbom-action/download-syft@v0
with:
syft-version: 'v1.18.1'
# Install cosign for Sigstore keyless signing.
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Run GoReleaser (build only, this matrix's IDs)
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: build --clean ${{ matrix.build_args }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COSIGN_ENABLED: "1"
# Goreleaser v2 cannot skip the build phase from `release` (the valid
# --skip phases don't include "build"), and there's no clean way to
# archive+SBOM+sign already-built binaries through goreleaser's own
# pipeline in split-OS matrix mode. Inline the archive/SBOM/sign work
# with the same tools (tar/zip, syft, cosign) the goreleaser config
# would have invoked.
- name: Archive, SBOM, and sign artifacts
shell: bash
run: |
set -euo pipefail
cd dist
DIST_ABS="$(pwd)"
VERSION="${GITHUB_REF_NAME#v}"
echo "=== Built binaries ==="
find . -maxdepth 2 -type f \( -name terrain -o -name terrain.exe \) -print
# Goreleaser writes binaries to dist/<id>_<goos>_<goarch>[_<variant>]/<binary>
# e.g. dist/terrain-linux-amd64_linux_amd64_v1/terrain
# dist/terrain-darwin_darwin_arm64_v8.0/terrain
# dist/terrain-windows_windows_amd64_v1/terrain.exe
while IFS= read -r binary_path; do
binary_dir=$(dirname "$binary_path")
dir_basename=$(basename "$binary_dir")
bin_name=$(basename "$binary_path")
case "$dir_basename" in
*_linux_amd64*) goos="linux"; goarch="amd64" ;;
*_linux_arm64*) goos="linux"; goarch="arm64" ;;
*_darwin_amd64*) goos="darwin"; goarch="amd64" ;;
*_darwin_arm64*) goos="darwin"; goarch="arm64" ;;
*_windows_amd64*) goos="windows"; goarch="amd64" ;;
*) echo "::error::unrecognised goreleaser output dir: $dir_basename"; exit 1 ;;
esac
archive_base="terrain_${VERSION}_${goos}_${goarch}"
stage="$(mktemp -d)"
cp "$binary_path" "$stage/$bin_name"
cp "${GITHUB_WORKSPACE}/README.md" "$stage/README.md"
cp "${GITHUB_WORKSPACE}/LICENSE" "$stage/LICENSE"
if [[ "$goos" == "windows" ]]; then
archive="${archive_base}.zip"
# 7z is preinstalled on GitHub-hosted Windows runners. Stage the
# binary plus README/LICENSE so the zip root matches goreleaser's
# documented archive contents.
(cd "$stage" && 7z a "${DIST_ABS}/${archive}" "$bin_name" README.md LICENSE > /dev/null)
else
archive="${archive_base}.tar.gz"
tar -czf "$archive" -C "$stage" "$bin_name" README.md LICENSE
fi
rm -rf "$stage"
echo " archived: $archive"
done < <(find . -maxdepth 2 -type f \( -name terrain -o -name terrain.exe \))
echo "=== Generating SBOMs ==="
for archive in *.tar.gz *.zip; do
[ -f "$archive" ] || continue
syft "$archive" \
-o "cyclonedx-json=${archive}.cdx.json" \
-o "spdx-json=${archive}.spdx.json"
echo " sbom: $archive → ${archive}.cdx.json + ${archive}.spdx.json"
done
echo "=== Generating checksums ==="
checksum_files=()
for pattern in *.tar.gz *.zip *.cdx.json *.spdx.json; do
for f in $pattern; do
[ -f "$f" ] && checksum_files+=("$f")
done
done
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${checksum_files[@]}" > checksums.txt
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "${checksum_files[@]}" > checksums.txt
else
python3 - "${checksum_files[@]}" > checksums.txt <<'PY'
import hashlib
import sys
for name in sys.argv[1:]:
with open(name, "rb") as fh:
print(f"{hashlib.sha256(fh.read()).hexdigest()} {name}")
PY
fi
echo " checksum: checksums.txt"
echo "=== Signing artifacts (cosign keyless, Sigstore OIDC) ==="
for f in *.tar.gz *.zip *.cdx.json *.spdx.json checksums.txt; do
[ -f "$f" ] || continue
cosign sign-blob --yes \
--output-signature="${f}.sig" \
--output-certificate="${f}.pem" \
"$f"
echo " signed: $f"
done
echo "=== Final dist/ contents ==="
ls -la
# COSIGN_EXPERIMENTAL was required for keyless signing in cosign
# 1.x. cosign 2.x (which we're on) makes it the default and emits
# a deprecation notice when set; drop the env var.
- name: Upload OS artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/*.cdx.json
dist/*.spdx.json
dist/*.sig
dist/*.pem
if-no-files-found: error
retention-days: 7
# Aggregate all per-OS artifacts and create a single GitHub Release.
go-release-publish:
needs: go-release-build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist-merged
- name: Flatten artifacts
run: |
mkdir -p dist
find dist-merged -type f -exec cp -v {} dist/ \;
ls -la dist/
- name: Recompute checksums (single combined file)
working-directory: dist
run: |
# Each per-OS goreleaser run produced its own checksums.txt; merge into one.
rm -f checksums.txt
sha256sum *.tar.gz *.zip *.cdx.json *.spdx.json 2>/dev/null | sort -k 2 > checksums.txt
cat checksums.txt
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign combined checksums
run: |
cosign sign-blob \
--yes \
--output-signature=dist/checksums.txt.sig \
--output-certificate=dist/checksums.txt.pem \
dist/checksums.txt
# COSIGN_EXPERIMENTAL=1 was required by cosign 1.x for keyless;
# cosign 2.x makes keyless the default and emits a deprecation
# notice when the env var is set, so it is intentionally omitted.
# SLSA L2 build-provenance attestation. actions/attest-build-provenance
# signs a SLSA-compliant in-toto statement against every binary archive
# using the workflow's OIDC identity. The attestation is uploaded to
# GitHub's attestations API and downloadable via `gh attestation verify`.
# Independent of the cosign blob signatures (which sign the file bytes
# without provenance metadata) — both are useful, neither replaces the
# other.
- name: Generate SLSA L2 build provenance
uses: actions/attest-build-provenance@v4
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
dist/*
# Post-release smoke test: download the just-published archive for
# each shipped target, extract, and verify `terrain version --json`
# reports the tagged version. Catches "release published but archive
# contains a stale build / wrong version string" bugs that
# previously could only surface after a user installed from the
# release. Runs after the GitHub release is created so artifact
# URLs resolve.
#
# Matrix covers the three primary platforms: linux/amd64 (the
# historical default), darwin/arm64 (the modern Mac default — Apple
# Silicon is the dominant developer hardware), and windows/amd64
# (the most likely Windows shape). linux/arm64 and darwin/amd64
# archives still ship; they just aren't smoke-tested per release —
# they share build infrastructure with the matrixed targets.
release-smoke:
needs: go-release-publish
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- name: linux_amd64
runner: ubuntu-latest
archive_ext: tar.gz
binary: terrain
- name: darwin_arm64
runner: macos-14
archive_ext: tar.gz
binary: terrain
- name: windows_amd64
runner: windows-latest
archive_ext: zip
binary: terrain.exe
runs-on: ${{ matrix.runner }}
steps:
- name: Download and verify ${{ matrix.name }} archive (POSIX)
if: matrix.archive_ext == 'tar.gz'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
ARCHIVE="terrain_${VERSION}_${{ matrix.name }}.${{ matrix.archive_ext }}"
echo "Downloading $ARCHIVE from release $TAG..."
gh release download "$TAG" --pattern "$ARCHIVE" --clobber
echo "Extracting..."
tar -xzf "$ARCHIVE"
chmod +x ./${{ matrix.binary }}
echo "Running ./${{ matrix.binary }} version --json:"
OUTPUT=$(./${{ matrix.binary }} version --json)
echo "$OUTPUT"
REPORTED=$(echo "$OUTPUT" | grep -oE '"version"\s*:\s*"[^"]+"' | head -1 | sed -E 's/.*"version"\s*:\s*"([^"]+)".*/\1/')
if [ "$REPORTED" != "$VERSION" ]; then
echo "❌ Version mismatch: archive reports '$REPORTED', expected '$VERSION'" >&2
exit 1
fi
echo "✅ Smoke test passed (${{ matrix.name }}): published archive reports version $VERSION."
- name: Download and verify ${{ matrix.name }} archive (Windows)
if: matrix.archive_ext == 'zip'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$TAG = $env:GITHUB_REF_NAME
$VERSION = $TAG.TrimStart('v')
$ARCHIVE = "terrain_${VERSION}_${{ matrix.name }}.${{ matrix.archive_ext }}"
Write-Host "Downloading $ARCHIVE from release $TAG..."
gh release download $TAG --pattern $ARCHIVE --clobber
Write-Host "Extracting..."
Expand-Archive -Path $ARCHIVE -DestinationPath . -Force
Write-Host "Running .\${{ matrix.binary }} version --json:"
$OUTPUT = & ".\${{ matrix.binary }}" version --json
Write-Host $OUTPUT
$match = $OUTPUT | Select-String -Pattern '"version"\s*:\s*"([^"]+)"' | Select-Object -First 1
if (-not $match) {
Write-Error "❌ No version field found in output"
exit 1
}
$REPORTED = $match.Matches[0].Groups[1].Value
if ($REPORTED -ne $VERSION) {
Write-Error "❌ Version mismatch: archive reports '$REPORTED', expected '$VERSION'"
exit 1
}
Write-Host "✅ Smoke test passed (${{ matrix.name }}): published archive reports version $VERSION."
# Homebrew tap update is handled by the separate homebrew-update.yml
# workflow, which fires on `release: published` (and on workflow_dispatch
# for recovery). It updates the source-build formula in-place — no
# goreleaser involvement, no cross-compiler needed, no rebuild on the
# macos runner of artifacts that already exist in the GitHub release.
npm-release:
needs: [verify, go-release-publish, release-smoke]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write # required for npm provenance
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
# Go is required because `prepublishOnly` runs `npm test` which
# invokes `scripts/verify-pack.js`, which calls `go build` to
# exercise the binary path through `npm pack`. Without Go, the
# publish fails with `go: not found`. Found in the final
# polish review — the previous shape would have crashed on first
# release attempt.
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install dependencies
run: npm ci
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}