Skip to content

fix(test): a probe test must assert the answer, not the machine's load (GDK-303) #275

fix(test): a probe test must assert the answer, not the machine's load (GDK-303)

fix(test): a probe test must assert the answer, not the machine's load (GDK-303) #275

Workflow file for this run

name: CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build and check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Go vet
run: go vet ./...
# Store/source firewall (docs/ARCHITECTURE.md:79). Not path-scoped: any
# import-graph edit can break it.
- name: Store dependency firewall
run: bash tools/check-store-deps.sh
# The web build has to precede the Go build: go:embed compiles dist/app
# into the binary, so the serve smoke below exercises the real embedded UI
# rather than the tracked placeholder.
- name: Frontend build
run: npm run build
- name: Go build (static)
run: CGO_ENABLED=0 go build -trimpath -o /tmp/gadak ./cmd/gadak
- name: Go tests
run: go test ./...
- name: Frontend typecheck
run: npm run typecheck
# Pure-logic web specs (vitest, no browser). The Playwright job below
# stays the browser tier; this one is seconds, so it runs in the fast job.
- name: Frontend unit tests
run: npm run test:unit
# Palette contract: contrast floors, ladder shape, ink rank, and the
# anti-slop bounds that keep a theme from drifting into a cool-gray
# ground. Parses app.css, so it catches a hand-edited hex.
- name: Theme check
run: npm run theme-check
- name: Serve smoke
run: |
set -euo pipefail
/tmp/gadak serve --addr 127.0.0.1:7777 &
pid=$!
trap 'kill $pid 2>/dev/null || true' EXIT
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:7777/healthz >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://127.0.0.1:7777/healthz
curl -fsS http://127.0.0.1:7777/config.json
curl -fsS http://127.0.0.1:7777/ | grep -q '<div id="app">'
- name: Refuse non-loopback bind without opt-in
run: |
set -euo pipefail
if /tmp/gadak serve --addr 0.0.0.0:7778; then
echo "expected a non-loopback bind to be refused" >&2
exit 1
fi
scan:
name: Secret and internal-string scan
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Scan tracked files and demo.db
run: bash scripts/scan-internal.sh
env:
# Deployment-specific word list. Absent on forks, where the scanner
# skips that check and still enforces tokens and tenant hostnames.
GADAK_SCAN_WORDS: ${{ secrets.GADAK_SCAN_WORDS }}
- name: Snapshot portability (Datasette Lite contract)
run: |
set -euo pipefail
DB=examples/demo.db
# The snapshot is opened by Datasette Lite in the reader's browser
# (GDK-101), whose SQLite predates contentless_delete (3.43). A
# snapshot regenerated without the scrub script's FTS rebuild makes
# every Lite page fail with `unrecognized option`.
schema=$(sqlite3 "$DB" "SELECT sql FROM sqlite_master WHERE name='items_fts'")
if grep -q contentless_delete <<<"$schema"; then
echo "items_fts carries contentless_delete — Datasette Lite returns Error 500." >&2
echo "Regenerate: python3 scripts/scrub-demo-db.py <demo-profile.db> examples/demo.db" >&2
exit 1
fi
# Read-only open without -shm/-wal siblings (how the raw URL opens).
hits=$(sqlite3 -readonly "$DB" "SELECT count(*) FROM items_fts WHERE items_fts MATCH 'upload'")
[ "$hits" -gt 0 ] || { echo "FTS probe returned 0 rows" >&2; exit 1; }
rows=$(sqlite3 -readonly "$DB" \
"SELECT count(*) FROM (SELECT epic_key, count(*) FROM issues_full WHERE resolved_at IS NULL AND epic_key <> '' GROUP BY epic_key)")
[ "$rows" -gt 0 ] || { echo "epic GROUP BY (the linked demo query) returned 0 rows" >&2; exit 1; }
echo "snapshot portable: fts_hits=$hits epic_rows=$rows"
e2e:
name: Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
# --with-deps runs apt-get, and an Ubuntu mirror that stalls rather than
# fails takes the whole job with it: on 2026-08-18 this step sat on
# archive.ubuntu.com for 29 minutes, hit the job's 30-minute timeout, and
# reported "Playwright E2E fail" on a pull request that changed one
# markdown file. No browser had started. A stalled mirror must not be
# able to look like a verdict on the change, so each attempt is killed at
# four minutes and retried instead of being waited on.
- name: Install Playwright Chromium
timeout-minutes: 14
run: |
for attempt in 1 2 3; do
if timeout 240 npx playwright install --with-deps chromium; then
exit 0
fi
echo "::warning::playwright install attempt ${attempt} failed or stalled; retrying"
sleep 10
done
echo "::error::playwright install failed three times — this is the installer, not the tests"
exit 1
- name: Run browser E2E
run: npx playwright test --config e2e/playwright.config.ts
# Nested module (desktop/go.mod). package main imports wails v3, which does
# not compile on Linux with CGO_ENABLED=0 (undefined pointer in the linux
# files). Handler/deeplink tests are platform-neutral; this runner is the
# link. No real window: Restore/Focus and dock reopen are not exercised.
desktop:
name: Desktop tests
runs-on: macos-14
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: desktop/go.mod
cache-dependency-path: desktop/go.sum
cache: true
- name: Desktop Go build, test, vet
run: cd desktop && go build ./... && go test ./... -count=1 && go vet ./...
# GDK-167: anything the OS reads from Info.plist is invisible to a
# source test — the repo can be right while the artifact is wrong, and
# the symptom is a gadak:// link that does nothing at all. So build the
# actual bundle and assert the claim on the artifact, not the script
# that writes it. (The runbook's install/lsregister half needs a real
# user session; this covers the plist-in-artifact class in CI.)
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Build the app bundle
run: npm ci && npm run build && desktop/build-app.sh
- name: The shipped bundle claims gadak://
run: |
plist=desktop/build/Gadak.app/Contents/Info.plist
test -f "$plist" || { echo "no Info.plist in the built bundle" >&2; exit 1; }
scheme="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleURLSchemes:0' "$plist")"
[ "$scheme" = "gadak" ] || { echo "bundle claims '$scheme', want 'gadak'" >&2; exit 1; }
test -x desktop/build/Gadak.app/Contents/MacOS/gadak-desktop || { echo "bundle has no executable" >&2; exit 1; }
echo "artifact claims gadak:// — ok"
# GDK-208 authored desktop/build-linux.sh on a macOS machine, which cannot
# compile the wails v3 Linux host (no GTK4/WebKitGTK headers) — the round
# reported that honestly and the script went in unexercised. This job is
# where the claim gets tested: a real Linux runner with the real dev
# packages, running the real script. Without it the first person to learn
# the script is broken is whoever tags a release.
#
# No AppImage here (`--appimage` needs appimagetool, and packaging format is
# not what this job doubts) — the doubt is whether the Linux host compiles
# and the tree the script assembles is well-formed.
desktop-linux:
name: Desktop Linux build
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: desktop/go.mod
cache-dependency-path: desktop/go.sum
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
# wails v3's Linux host is GTK4 + WebKitGTK 6.0 and needs CGO. The 4.1
# (gtk3) stack is the legacy tag and is deliberately not installed.
- name: GTK4 / WebKitGTK 6.0 development packages
timeout-minutes: 12
run: |
set -eu
# A stalled Ubuntu mirror, not a build problem, is what this guards.
# Twice on 2026-08-18 azure.archive.ubuntu.com Ign'd every request
# and the archive.ubuntu.com fallback then went silent mid-index for
# 24 minutes, so a PR was reported as a Linux *build* timeout before
# anything had been compiled.
#
# Acquire::*::Timeout does not catch this: the socket is not idle,
# it is trickling, and apt has no minimum-speed option. So the wall
# clock has to come from outside apt — `timeout` per attempt, which
# is also what makes the retry loop reachable at all (with only the
# apt options, attempt 1 never returned and 2 and 3 never ran).
opts=(-o Acquire::Retries=3
-o Acquire::http::Timeout=20
-o Acquire::https::Timeout=20)
apt_try() {
for attempt in 1 2 3; do
# timeout under sudo, so it signals apt-get directly rather
# than relying on sudo to forward.
sudo timeout -k 10 150 apt-get "${opts[@]}" "$@" && return 0
if [ "$attempt" = 3 ]; then
echo "apt-get $1 failed or stalled three times — mirror trouble, not a build failure" >&2
return 1
fi
echo "apt-get $1 failed or stalled (attempt $attempt), retrying" >&2
sleep 10
done
}
apt_try update
apt_try install -y --no-install-recommends \
libgtk-4-dev libwebkitgtk-6.0-dev pkg-config imagemagick
- name: Build the web UI
run: npm ci && npm run build
- name: Pack the Linux app
run: desktop/build-linux.sh
- name: The packed tree carries the binary and the launcher
run: |
set -eu
appdir="$(find desktop/build -maxdepth 1 -type d -name '*.AppDir' -print -quit)"
test -n "$appdir" || { echo "build-linux.sh produced no AppDir" >&2; ls -la desktop/build || true; exit 1; }
test -x "$appdir/usr/bin/gadak-desktop" || { echo "no executable at $appdir/usr/bin/gadak-desktop" >&2; find "$appdir" -type f | head -50; exit 1; }
desktop_file="$(find "$appdir" -maxdepth 1 -name '*.desktop' -print -quit)"
test -n "$desktop_file" || { echo "no .desktop file in $appdir" >&2; exit 1; }
grep -q 'x-scheme-handler/gadak' "$desktop_file" || { echo "the .desktop file does not declare gadak://" >&2; cat "$desktop_file"; exit 1; }
echo "linux pack tree — ok"
# Bad args must be 64 and a missing tool 69, the same contract
# desktop/build-app.sh keeps. The missing tool is appimagetool, which
# this runner deliberately does not install — emptying PATH would not
# test the same thing, because the version stamp is read with grep and
# its absence exits 1 long before any need() runs.
- name: Argument and missing-tool exit codes
run: |
set +e
desktop/build-linux.sh --nope >/dev/null 2>&1
rc=$?
set -e
[ "$rc" = 64 ] || { echo "bad argument exited $rc, want 64" >&2; exit 1; }
command -v appimagetool >/dev/null 2>&1 && { echo "appimagetool is installed; this check needs it absent" >&2; exit 1; }
set +e
desktop/build-linux.sh --appimage >/dev/null 2>&1
rc=$?
set -e
[ "$rc" = 69 ] || { echo "missing appimagetool exited $rc, want 69" >&2; exit 1; }
echo "exit-code contract — ok"
# The Windows pack was authored on macOS, which cannot run it at all — the
# round said so and shipped the script unexercised, same as the Linux one.
# This job is where the claim gets tested: a real Windows runner running the
# real script. install-cli's copy path is also Windows-only behaviour that no
# other job can execute, so it runs here for real rather than through an
# injected GOOS.
desktop-windows:
name: Desktop Windows build
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: desktop/go.mod
cache-dependency-path: desktop/go.sum
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Build the web UI
run: npm ci && npm run build
- name: Pack the Windows app
shell: pwsh
run: ./desktop/build-windows.ps1
- name: The packed tree carries both binaries
shell: pwsh
run: |
$bundle = Get-ChildItem -Path desktop/build -Directory -Filter 'Gadak-*' | Select-Object -First 1
if (-not $bundle) { throw "build-windows.ps1 produced no bundle directory" }
foreach ($exe in 'gadak-desktop.exe', 'gadak.exe') {
$p = Join-Path $bundle.FullName $exe
if (-not (Test-Path -LiteralPath $p)) { throw "missing $exe in $($bundle.Name)" }
}
# The CLI in the pack must be the one that runs, not a cross-built
# stub: ask it for its version.
& (Join-Path $bundle.FullName 'gadak.exe') version
if ($LASTEXITCODE -ne 0) { throw "gadak.exe version exited $LASTEXITCODE" }
Write-Host "windows pack tree — ok"
exit 0
# In a child process, not in this step's own runspace: a non-zero
# $LASTEXITCODE left behind at the end of a pwsh step is what the runner
# reads as the step's result, so checking it in place fails the job even
# when the assertion passed. A child process also runs the script the way
# a person would.
- name: Bad arguments exit 64
shell: pwsh
run: |
$p = Start-Process -FilePath pwsh -NoNewWindow -Wait -PassThru `
-ArgumentList '-NoProfile', '-File', './desktop/build-windows.ps1', '--nope'
if ($p.ExitCode -ne 64) { throw "bad argument exited $($p.ExitCode), want 64" }
Write-Host "exit-code contract — ok"
exit 0
# install-cli copies on Windows because a symlink needs elevation. Only
# a Windows runner can show that the copy actually lands and runs.
- name: install-cli copies a working gadak.exe
shell: pwsh
run: |
$bundle = Get-ChildItem -Path desktop/build -Directory -Filter 'Gadak-*' | Select-Object -First 1
$dest = Join-Path $env:RUNNER_TEMP 'clibin'
New-Item -ItemType Directory -Path $dest -Force | Out-Null
& (Join-Path $bundle.FullName 'gadak.exe') install-cli --dir $dest
if ($LASTEXITCODE -ne 0) { throw "install-cli exited $LASTEXITCODE" }
$installed = Join-Path $dest 'gadak.exe'
if (-not (Test-Path -LiteralPath $installed)) { throw "install-cli left no gadak.exe in $dest" }
& $installed version
if ($LASTEXITCODE -ne 0) { throw "the installed copy did not run" }
# Re-running must be a no-op, not a conflict.
& (Join-Path $bundle.FullName 'gadak.exe') install-cli --dir $dest
if ($LASTEXITCODE -ne 0) { throw "second install-cli exited $LASTEXITCODE" }
Write-Host "install-cli copy path — ok"
exit 0