diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66694e9..d01246f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,113 @@ jobs: run: | go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... + + release-build: + # Build + package all six release targets on ONE ubuntu box, then prove the + # archives are shippable. This is the exact scripts/release.sh that release.yml + # runs at tag time; running it on every PR turns "it builds on six platforms" + # from a hand-checked claim into a machine-checked one. CGO_ENABLED=0 + pure-Go + # SQLite means all six cross-compile here — NO Actions matrix (the script + # already loops the targets; a matrix would be six runners doing one's job). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Build + package all six targets + run: ./scripts/release.sh + + - name: Verify archive contents + # Fails the PR on a corrupt archive, a lost exec bit, an empty embedded + # UI, or a CoinRollHunter.exe that regressed from GUI to console subsystem. + run: ./scripts/verify-archives.sh + + - name: Smoke — linux/amd64 starts and serves /api/health + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/smoke" + tar -xzf dist/coinrollhunter_*_linux_amd64.tar.gz -C "$RUNNER_TEMP/smoke" + bin="$RUNNER_TEMP/smoke/coinrollhunter_linux_amd64/coinrollhunter" + "$bin" serve --spot-provider=none --db "$RUNNER_TEMP/crh.db" --addr 127.0.0.1:8911 & + srv=$! + ok= + for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:8911/api/health >/dev/null; then ok=1; break; fi + sleep 0.5 + done + kill "$srv" 2>/dev/null || true + [ -n "$ok" ] || { echo "linux/amd64 did not answer /api/health"; exit 1; } + echo "linux/amd64 serve -> /api/health 200" + + - name: Upload release archives + uses: actions/upload-artifact@v4 + with: + name: release-dist + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + + # Native launch smoke on the two OSes GitHub can give us for free (public repo). + # AC is honest: build all six, START THREE — linux/amd64 (above), windows/amd64, + # darwin/arm64. The other three targets (linux/arm64, darwin/amd64, windows/arm64) + # stay build-verified only; no hosted runner can start them natively. + smoke-windows: + needs: release-build + runs-on: windows-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - name: Smoke — windows/amd64 starts and serves /api/health + # cli/coinrollhunter.exe, NOT CoinRollHunter.exe: the GUI (-H=windowsgui) + # build has no valid std handles and cannot print. Always pass `serve` — + # a no-arg invocation would try to open a browser. + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $zip = Get-ChildItem dist/coinrollhunter_*_windows_amd64.zip | Select-Object -First 1 + Expand-Archive -Path $zip.FullName -DestinationPath "$env:RUNNER_TEMP/smoke" -Force + $bin = "$env:RUNNER_TEMP/smoke/coinrollhunter_windows_amd64/cli/coinrollhunter.exe" + $db = Join-Path $env:RUNNER_TEMP 'crh.db' + $p = Start-Process -FilePath $bin -ArgumentList 'serve','--spot-provider=none','--db',$db,'--addr','127.0.0.1:8911' -PassThru + $ok = $false + for ($i = 0; $i -lt 60; $i++) { + try { + if ((Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:8911/api/health').StatusCode -eq 200) { $ok = $true; break } + } catch { Start-Sleep -Milliseconds 500 } + } + Stop-Process -Id $p.Id -Force + if (-not $ok) { throw 'windows/amd64 did not answer /api/health' } + Write-Host 'windows/amd64 cli serve -> /api/health 200' + + smoke-macos: + needs: release-build + runs-on: macos-latest # Apple silicon -> darwin/arm64 native + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - name: Smoke — darwin/arm64 starts and serves /api/health + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/smoke" + tar -xzf dist/coinrollhunter_*_darwin_arm64.tar.gz -C "$RUNNER_TEMP/smoke" + bin="$RUNNER_TEMP/smoke/coinrollhunter_darwin_arm64/coinrollhunter" + "$bin" serve --spot-provider=none --db "$RUNNER_TEMP/crh.db" --addr 127.0.0.1:8911 & + srv=$! + ok= + for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:8911/api/health >/dev/null; then ok=1; break; fi + sleep 0.5 + done + kill "$srv" 2>/dev/null || true + [ -n "$ok" ] || { echo "darwin/arm64 did not answer /api/health"; exit 1; } + echo "darwin/arm64 serve -> /api/health 200" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5e561e..4974bd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,11 @@ jobs: env: VERSION: ${{ github.ref_name }} + - name: Verify archive contents + # Same gate CI runs on every PR — so a release can never publish a corrupt + # archive, a console-subsystem CoinRollHunter.exe, or an empty embedded UI. + run: ./scripts/verify-archives.sh + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/scripts/verify-archives.sh b/scripts/verify-archives.sh new file mode 100755 index 0000000..0d7c5cf --- /dev/null +++ b/scripts/verify-archives.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Verify the release archives that scripts/release.sh produced in dist/. +# +# Compiling all six targets proves almost nothing about what a user unzips: the +# archive can still ship a console-subsystem CoinRollHunter.exe (a black flashing +# window on every double-click), a unix binary with no exec bit, or a binary that +# embedded an EMPTY UI (web/dist is a gitignored build artifact resolved against a +# .gitkeep, so a bare `go build` without `make ui` serves nothing and still exits +# 0). None of those fail to compile; all of them fail here. +# +# ./scripts/verify-archives.sh # checks ./dist +# ./scripts/verify-archives.sh some/dir # checks some/dir +# +# Exits nonzero if any archive is missing, corrupt, or wrong. +set -uo pipefail +cd "$(dirname "$0")/.." + +DIST="${1:-dist}" + +# The six targets release.sh ships. Unix → tar.gz with one `coinrollhunter` +# binary; windows → zip with a GUI CoinRollHunter.exe, a console +# cli/coinrollhunter.exe, and a READ ME FIRST.txt. +UNIX_TARGETS=(linux_amd64 linux_arm64 darwin_amd64 darwin_arm64) +WIN_TARGETS=(windows_amd64 windows_arm64) + +# The mount point in web/dist/index.html. Present in the binary only if the built +# UI was actually embedded; absent for a bare `go build` against the .gitkeep. The +# hashed asset filenames change every build, so we match the stable HTML, not them. +UI_MARKER='
' + +fails=0 +pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fails=$((fails + 1)); } + +# Resolve the single archive for a target suffix (version is in the name and +# varies, so glob rather than hardcode). Echoes the path, or empty if not exactly +# one match. +archive_for() { + local suffix="$1" ext="$2" matches + matches=("$DIST"/coinrollhunter_*_"${suffix}.${ext}") + [ -e "${matches[0]}" ] || { echo ""; return; } + [ "${#matches[@]}" -eq 1 ] || { echo ""; return; } + echo "${matches[0]}" +} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "==> Verifying archives in $DIST/" + +# --- checksums: existence + integrity ------------------------------------------ +# sha256sum -c is the load-bearing corruption check: flip one byte in any archive +# and this turns the run red. +if [ ! -f "$DIST/checksums.txt" ]; then + fail "checksums.txt exists" +else + pass "checksums.txt exists" + if ( cd "$DIST" && sha256sum -c checksums.txt >/dev/null 2>&1 ); then + pass "all archives match their checksums (not corrupt)" + else + fail "checksums.txt verification (an archive is missing or corrupt):" + ( cd "$DIST" && sha256sum -c checksums.txt 2>&1 | grep -v ': OK$' | sed 's/^/ /' ) + fi +fi + +# --- every target archive is present and listed in checksums.txt --------------- +for t in "${UNIX_TARGETS[@]}"; do + a="$(archive_for "$t" tar.gz)" + if [ -z "$a" ]; then fail "archive present: *_${t}.tar.gz"; else pass "archive present: $(basename "$a")"; fi +done +for t in "${WIN_TARGETS[@]}"; do + a="$(archive_for "$t" zip)" + if [ -z "$a" ]; then fail "archive present: *_${t}.zip"; else pass "archive present: $(basename "$a")"; fi +done + +if [ -f "$DIST/checksums.txt" ]; then + n=$(grep -c . "$DIST/checksums.txt") + if [ "$n" -eq 6 ]; then pass "checksums.txt lists all six archives"; else fail "checksums.txt lists all six archives (found $n)"; fi +fi + +# --- unix archives: exec bit + embedded UI ------------------------------------- +for t in "${UNIX_TARGETS[@]}"; do + a="$(archive_for "$t" tar.gz)" + [ -z "$a" ] && continue + bin="coinrollhunter_${t}/coinrollhunter" + + # Exec bit as stored IN the archive: tar's own listing, not a post-extract stat + # (extraction is subject to umask; the tar header is the source of truth). + perms="$(tar -tvzf "$a" 2>/dev/null | awk -v b="$bin" '$NF==b {print $1}')" + if [ -z "$perms" ]; then + fail "$t: archive contains $bin" + elif [ "${perms:3:1}" = "x" ]; then + pass "$t: $bin carries the exec bit ($perms)" + else + fail "$t: $bin is missing the exec bit ($perms)" + fi + + # Embedded UI really present in the binary. + d="$WORK/$t"; mkdir -p "$d" + if tar -xzf "$a" -C "$d" 2>/dev/null && [ -f "$d/$bin" ]; then + if grep -a -q -F "$UI_MARKER" "$d/$bin"; then + pass "$t: embedded UI present (index.html mount point found)" + else + fail "$t: embedded UI MISSING (empty web/dist? ran go build without make ui)" + fi + else + fail "$t: could not extract $bin" + fi +done + +# --- windows archives: members + PE subsystem + embedded UI --------------------- +for t in "${WIN_TARGETS[@]}"; do + a="$(archive_for "$t" zip)" + [ -z "$a" ] && continue + root="coinrollhunter_${t}" + + members="$(unzip -Z1 "$a" 2>/dev/null)" + for want in "$root/CoinRollHunter.exe" "$root/cli/coinrollhunter.exe" "$root/READ ME FIRST.txt"; do + if grep -qxF "$want" <<<"$members"; then + pass "$t: archive contains $(basename "$want")" + else + fail "$t: archive MISSING $want" + fi + done + + d="$WORK/$t"; mkdir -p "$d" + unzip -qo "$a" -d "$d" 2>/dev/null + + gui="$d/$root/CoinRollHunter.exe" + cli="$d/$root/cli/coinrollhunter.exe" + + # The whole point of the -H=windowsgui variant: it must actually be the GUI + # subsystem. A silent regression to console ships a flashing black window. + if [ -f "$gui" ] && file "$gui" | grep -q 'PE32+ executable (GUI)'; then + pass "$t: CoinRollHunter.exe is the GUI subsystem" + else + fail "$t: CoinRollHunter.exe is NOT GUI subsystem — $( [ -f "$gui" ] && file -b "$gui" || echo missing )" + fi + + # The CLI copy must stay console, or the subcommands run blind (no std handles). + if [ -f "$cli" ] && file "$cli" | grep -q 'PE32+ executable (console)'; then + pass "$t: cli/coinrollhunter.exe is the console subsystem" + else + fail "$t: cli/coinrollhunter.exe is NOT console subsystem — $( [ -f "$cli" ] && file -b "$cli" || echo missing )" + fi + + if [ -f "$gui" ] && grep -a -q -F "$UI_MARKER" "$gui"; then + pass "$t: embedded UI present in CoinRollHunter.exe" + else + fail "$t: embedded UI MISSING in CoinRollHunter.exe" + fi +done + +echo +if [ "$fails" -eq 0 ]; then + echo "==> OK: all archive checks passed" + exit 0 +else + echo "==> FAILED: $fails archive check(s) failed" + exit 1 +fi