diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 000000000..5ccf4f8e7 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,35 @@ +name: Release checks + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + +jobs: + release-check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: NixOS/nix-installer-action@62c1943b776c509394b550f3f983adc14e9212d6 # main + - uses: Mic92/hestia@dfed9ced335d28978ba74e513939a10db1f71025 # v3.1.0 + - name: Validate release configuration + run: goreleaser check + - name: Test release guards + run: bash tools/release-check-test.sh + - name: Build and verify Headscale release binary + run: goreleaser build --snapshot --clean --single-target diff --git a/.goreleaser.yml b/.goreleaser.yml index ea6ee6275..317240c0d 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,7 +2,6 @@ version: 2 before: hooks: - - go mod tidy - go mod vendor release: @@ -27,6 +26,9 @@ builds: - linux_arm64 flags: - -mod=readonly + hooks: + pre: bash tools/release-check.sh + post: bash tools/release-check.sh "{{ .Path }}" archives: - id: golang-cross diff --git a/tools/release-check-test.sh b/tools/release-check-test.sh new file mode 100644 index 000000000..f7a16b348 --- /dev/null +++ b/tools/release-check-test.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +export GOWORK=off CGO_ENABLED=0 LC_ALL=C +unset GOFLAGS GOOS GOARCH + +mkdir -p "$tmp/headscale/cmd/headscale" "$tmp/headscale/tools" +cp "$root/.goreleaser.yml" "$tmp/headscale/" +cp "$root/tools/release-check.sh" "$tmp/headscale/tools/" +cd "$tmp/headscale" +cat > go.mod <<'MOD' +module example.com/headscale + +go 1.24.0 + +// Tidy removes this block, reproducing release-time go.mod edits. +require ( +) +MOD +printf 'package main\nfunc main() {}\n' > cmd/headscale/main.go +printf 'dist/\nvendor/\n' > .gitignore +git init -q +git config user.name 'Release check test' +git config user.email 'release-check@example.invalid' +git remote add origin https://github.com/juanfont/headscale.git +git add . +git commit -qm 'Create release fixture' +git tag v0.0.1 +check="$PWD/tools/release-check.sh" + +expect_failure() { + local label=$1 message=$2 + shift 2 + if "$@" > "$tmp/failure.log" 2>&1; then + printf 'FAIL: %s unexpectedly succeeded\n' "$label" >&2 + exit 1 + fi + if ! grep -Fq "$message" "$tmp/failure.log"; then + cat "$tmp/failure.log" >&2 + exit 1 + fi + printf 'PASS: %s\n' "$label" +} + +# Use the real release hooks and every configured target, without publishing. +if ! goreleaser build --snapshot --clean > "$tmp/build.log" 2>&1; then + cat "$tmp/build.log" >&2 + exit 1 +fi +binaries=(dist/headscale_*/headscale) +bash "$check" "${binaries[@]}" +printf 'PASS: clean release builds, including cross-compiled binaries\n' + +git config status.showUntrackedFiles no +printf '// dirty\n' >> cmd/headscale/main.go +expect_failure 'unstaged change' 'working tree is dirty' bash "$check" +go build -o dist/dirty ./cmd/headscale +git add cmd/headscale/main.go +expect_failure 'staged change' 'working tree is dirty' bash "$check" +git restore --staged --worktree cmd/headscale/main.go +expect_failure 'dirty binary after source cleanup' 'lacks clean VCS metadata' bash "$check" dist/dirty + +touch untracked +expect_failure 'untracked file despite Git display settings' 'working tree is dirty' bash "$check" +rm untracked + +go build -buildvcs=false -o dist/unstamped ./cmd/headscale +expect_failure 'missing VCS metadata' 'lacks clean VCS metadata' bash "$check" dist/unstamped +expect_failure 'missing binary' 'no such file or directory' bash "$check" dist/missing + +git commit --allow-empty -qm 'Advance fixture HEAD' +expect_failure 'binary from another commit' 'was not built from HEAD' bash "$check" "${binaries[0]}" + +# Reintroducing a mutating preparation hook must abort the actual build pipeline. +sed -i '/ - go mod vendor/a\ - go mod tidy' .goreleaser.yml +git add .goreleaser.yml +git commit -qm 'Reproduce release-time tidy' +expect_failure 'preparation dirties source' 'working tree is dirty' goreleaser build --snapshot --clean --single-target + +cd "$tmp" +expect_failure 'Git failure' 'not a git repository' bash "$check" diff --git a/tools/release-check.sh b/tools/release-check.sh new file mode 100644 index 000000000..38eb07d0d --- /dev/null +++ b/tools/release-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +status=$(git status --porcelain --untracked-files=all) +if [[ -n "$status" ]]; then + printf 'release: working tree is dirty\n%s\n' "$status" >&2 + exit 1 +fi + +for binary in "$@"; do + info=$(go version -m "$binary") + if ! grep -Eq '[[:space:]]vcs.modified=false$' <<< "$info"; then + printf 'release: %s lacks clean VCS metadata\n%s\n' "$binary" "$info" >&2 + exit 1 + fi + revision=$(git rev-parse HEAD) + if ! grep -Eq "[[:space:]]vcs.revision=${revision}$" <<< "$info"; then + printf 'release: %s was not built from HEAD\n%s\n' "$binary" "$info" >&2 + exit 1 + fi +done