Skip to content

fix(linear): a bare sentinel is invisible to the one function that detects a dead credential (GDK-274) #292

fix(linear): a bare sentinel is invisible to the one function that detects a dead credential (GDK-274)

fix(linear): a bare sentinel is invisible to the one function that detects a dead credential (GDK-274) #292

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
# Example plugin self-tests (make plugins-test). Not path-scoped: a
# plugin contract can be broken by a change to the API those plugins
# call, not only by edits under examples/plugins/.
- name: Example plugin self-tests
run: make plugins-test
# Documentation-factuality contract (tools/doc-checks.sh). Not
# path-scoped: a documentation claim can be falsified by a code change
# anywhere.
- name: Documentation factuality
run: bash tools/doc-checks.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 ./...
# GDK-270: a startSyncJob goroutine that outlives the test only shows
# up when this package is repeated under the race detector. Not
# path-scoped — the writer can be introduced from any import of
# internal/server. internal/workspace is here too: it owns the same
# lifetime one layer up (Registry.Close stops each workspace's sync
# before its mirror). count=2 (not 4): the job already ran count=1
# above; locally count=4 -race is ~180s, and doubling the package
# under race is enough to catch "passes once" while staying modest
# in a 20-minute job.
- name: Server tests under race (GDK-270)
run: go test ./internal/server/ ./internal/workspace/ -count=2 -race
- 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 is two unlike jobs: apt (root, dpkg lock, not safely
# killable) and a browser download (no lock, retryable). Wrapping the
# combined command in `timeout 240` was measured on main run
# 32206671649 (2026-08-19): the step started 02:07:07, Playwright
# printed "Switching to root user to install dependencies...", and
# timeout fired at 02:11:07 while azure.archive.ubuntu.com was still
# fetching the 21.1 MB set (fonts-wqy-zenhei 7472 kB had been in
# flight 101 s). timeout runs as the runner user and cannot signal
# that root apt-get (kill(2) uid check). The desktop-linux step in
# this file puts `timeout` *under* sudo for that reason. Process 2860
# (apt-get) kept /var/lib/dpkg/lock-frontend; attempts 2 and 3 failed
# in ~1 s on the lock; the orphan then continued Get:8–10 and unpacked
# after the step had given up.
#
# Do not wrap install-deps or --with-deps in `timeout`. A retry of apt
# after that kill is a guaranteed lock fail. The download half keeps
# the bounded retry the stalling-installer wrapper was added for
# (2026-08-18: a 29-minute silent mirror looked like a test verdict).
- name: Install Playwright Chromium
# 22, not 14: the worst case this step is allowed to reach is a
# 120s lock wait plus a slow-mirror apt (~6 min measured) plus
# three 250s download attempts — about 20 minutes. At 14 the
# runner's own timeout would cut the third attempt and replace
# the message that names which half failed with a generic one.
# The job's own bound is 30, so a real stall still dies here.
timeout-minutes: 22
run: |
set -eu
lock=/var/lib/dpkg/lock-frontend
# Bounded wait: 60 × 2s = 120s. unattended-upgrades at runner
# start, or a leftover apt-get, must not make install-deps fail
# in 3 seconds — and must not spin forever. After the split, the
# download retry does not call apt, so it cannot race this lock.
#
# This wait is defence, not the fix, and it fails open: if `fuser`
# is absent from the image, the probe errors, the lock reads free,
# and we go straight to install-deps — which is exactly today's
# behaviour, with apt's own message if the lock really was held.
# Whether it fires is visible in the log ("dpkg lock held, waiting").
wait_dpkg_lock() {
i=0
while [ "$i" -lt 60 ]; do
if ! sudo fuser "$lock" >/dev/null 2>&1; then
return 0
fi
i=$((i + 1))
echo "playwright OS deps (apt): dpkg lock held, waiting ${i}/60" >&2
sleep 2
done
echo "::error::playwright OS deps (apt) failed — dpkg lock still held after 120s, not the tests"
return 1
}
wait_dpkg_lock
echo "playwright OS deps (apt): install-deps once, no timeout wrapper"
if ! npx playwright install-deps chromium; then
if sudo fuser "$lock" >/dev/null 2>&1; then
echo "::error::playwright OS deps (apt) failed — dpkg lock still held"
else
echo "::error::playwright OS deps (apt) failed — installer or mirror, not the tests"
fi
exit 1
fi
echo "playwright OS deps (apt): ok"
# Browser download only: no apt, no dpkg lock. timeout signals
# npx (same uid). -k 10 reaps a stuck node after SIGTERM.
for attempt in 1 2 3; do
rc=0
timeout -k 10 240 npx playwright install chromium || rc=$?
if [ "$rc" = 0 ]; then
echo "playwright chromium download: ok (attempt ${attempt})"
exit 0
fi
if [ "$rc" = 124 ] || [ "$rc" = 137 ]; then
echo "::warning::playwright chromium download attempt ${attempt} stalled (timeout, exit ${rc}); retrying"
else
echo "::warning::playwright chromium download attempt ${attempt} failed (exit ${rc}); retrying"
fi
sleep 10
done
echo "::error::playwright chromium download failed three times — browser cache, not apt, 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