Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
161 changes: 161 additions & 0 deletions scripts/verify-archives.sh
Original file line number Diff line number Diff line change
@@ -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='<div id="app"></div>'

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
Loading