fix(test): a probe test must assert the answer, not the machine's load (GDK-303) #275
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: 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 |