Final 0.3.0 readiness changes #19
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 | |
| 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 }} |