Skip to content

fix round for 0.20.1: resolution on Built-in, batch singulars, initia… #1039

fix round for 0.20.1: resolution on Built-in, batch singulars, initia…

fix round for 0.20.1: resolution on Built-in, batch singulars, initia… #1039

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 ./...
# AGENTS.md documents `gofmt -l .` as a pre-gate; without CI enforcement
# unformatted files reached main twice (GDK-607). Empty output or fail.
- name: gofmt
run: |
out=$(gofmt -l .)
if [ -n "$out" ]; then echo "gofmt -l flagged:"; echo "$out"; exit 1; fi
# 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 ./...
- 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
# The race tier that used to be the tail of the build job, split across
# three runners (GDK-1035).
race:
name: Server race tests
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# 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 build job's Go tests step
# already ran count=1; 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.
#
# GDK-1035: this tier ran as one step inside the build job, where it
# was 507s of a 698s job and the whole CI critical path (11m38s wall
# on run 33018964815). This is a redistribution, not a relaxation —
# same -count=2, same -race, same two packages, same total test set,
# three runners instead of one. tools/race-partition.sh owns the
# split; --check runs in every shard so a test that escaped the
# partition fails the job that would otherwise have skipped it
# silently.
#
# The one thing sharding does narrow, said plainly: a leaked
# goroutine now shares a process with ~110 later tests instead of
# ~330, so a leak that only ever showed up against some unrelated
# later test has fewer chances to. GDK-270's own mechanism is
# untouched — that leak is caught by the test meeting its own
# leftover on the second run, and every test still runs twice in
# its shard.
- name: Race partition check
run: bash tools/race-partition.sh --check 3
- name: Workspace tests under race (GDK-270)
if: matrix.shard == 1
run: go test ./internal/workspace/ -count=2 -race
- name: Server race shard ${{ matrix.shard }}/3
run: go test ./internal/server/ -count=2 -race -run "$(bash tools/race-partition.sh ${{ matrix.shard }} 3)"
mobile:
name: Mobile (TypeScript)
runs-on: ubuntu-latest
# Viewport gate (GDK-868) adds a Go build + Playwright Chromium on
# 5182/7899. 10m was enough for vitest + svelte-check alone.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
# The phone icons are generated from docs/media/logo.png, the same
# source the desktop resizes at build time. Nothing else notices when
# the mark changes and this set is not regenerated. Pure shell — it
# runs before the installs so a drifted icon fails fast.
- name: Brand icons match the brand source
run: bash tools/check-brand-icons.sh
- 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
cache-dependency-path: |
package-lock.json
mobile/package-lock.json
# Root install is Playwright for the viewport gate; mobile/ is the
# app under test. Two lockfiles, two ci runs — Playwright already
# lives at the repo root (mobile/package.json is scripts-only).
- run: npm ci
- run: npm ci
working-directory: mobile
# The offer decoder is half of a contract whose other half is Go
# (internal/pairing). Both suites read internal/pairing/testdata/
# offer-vectors.json, so this job is what makes a one-sided change
# red — without it the Go side alone would stay green.
- run: npm test
working-directory: mobile
- run: npm run check
working-directory: mobile
# GDK-868 layer 1: DESIGN.md §4.1 / §4.2. Cheap, no browser.
- run: npm run lint:ios
working-directory: mobile
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
# GDK-868 layer 2: 402×874 walk on :5182 / demo :7899. Not e2e :7877.
- run: npm run viewport-gate
working-directory: mobile
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
# GDK-1035: shard at the runner level only. Each shard is a separate
# machine with its own webServer, its own GADAK_E2E_PORT and its own
# seeded home, so e2e/playwright.config.ts keeps workers: 1 and
# fullyParallel: false — raising workers inside one runner is the
# unsafe variant (single shared served instance) and stays out.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
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.
#
# A retry of apt after that kill used to be a guaranteed lock fail —
# run 32233517861 (2026-08-19, twice) measured it after GDK-317
# wrapped the apt half anyway to bound a zero-output hang. The retry
# loop now reaps the orphan first (reap_apt_orphan below), which is
# the only reason the wrap and the retry can coexist. 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
# 27, re-derived 2026-08-19: 120s lock wait + two bounded 420s
# apt attempts (with an orphan-reap + lock wait between, ≤130s —
# 10s over the plain wait it replaced) + three 250s
# download attempts ≈ 25.7 minutes worst case. The step budget
# stays above the sum so the half that failed is always named by
# our own error line, never by the runner's generic timeout —
# that inversion is exactly what run 32228508406 measured when
# the apt half was unbounded. The job's own bound is 30.
timeout-minutes: 27
run: |
set -eu
lock=/var/lib/dpkg/lock-frontend
lists=/var/lib/apt/lists/lock
# 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.
#
# Both locks, not just dpkg's: runs 32233517861 (both attempts,
# 2026-08-19) died on /var/lib/apt/lists/lock — an apt-get hung in
# its download phase holds that one, and this wait never saw it.
#
# 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 ("apt lock held, waiting").
wait_dpkg_lock() {
i=0
while [ "$i" -lt 60 ]; do
if ! sudo fuser "$lock" "$lists" >/dev/null 2>&1; then
return 0
fi
i=$((i + 1))
echo "playwright OS deps (apt): apt lock held, waiting ${i}/60" >&2
sleep 2
done
echo "::error::playwright OS deps (apt) failed — apt lock still held after 120s, not the tests"
return 1
}
# After a timed-out attempt only. `timeout` signals npx, but the
# apt-get that playwright started via sudo is root's — the signal
# never crosses that boundary, so the kill orphans apt-get and it
# keeps the lists lock (run 32233517861: attempt 1 timed out,
# attempt 2 died in 3s on that lock — both reruns, same shape;
# the header comment above predicted exactly this). The GDK-308
# boundary still holds: never kill a running dpkg — a mid-
# configure kill corrupts the dpkg db and makes every later apt
# fail. An apt-get with no dpkg child is in its network phase;
# that orphan is ours and safe to reap.
reap_apt_orphan() {
if ! sudo fuser "$lock" "$lists" >/dev/null 2>&1; then
return 0
fi
if pgrep -x dpkg >/dev/null 2>&1; then
echo "playwright OS deps (apt): dpkg mid-configure — waiting, not killing" >&2
wait_dpkg_lock
return $?
fi
echo "playwright OS deps (apt): reaping orphaned apt-get (download phase, no dpkg child)" >&2
sudo pkill -TERM -x apt-get 2>/dev/null || true
sleep 5
sudo pkill -KILL -x apt-get 2>/dev/null || true
wait_dpkg_lock
}
wait_dpkg_lock
# Bounded, twice — re-authored 2026-08-19. The old shape ran
# install-deps once with no timeout wrapper, reasoning that a
# wrapper would cut a slow-but-working apt and replace a named
# error with a generic one. Run 32228508406 (and two PR runs the
# same day) measured the opposite: install-deps hung with zero
# output from 07:35:54 until the step's own 22-minute kill, so
# the unbounded call is what produced the generic timeout. 420s
# is ~1.6x the slowest measured healthy apt (~6 min); two
# attempts because a hung mirror connection is retryable.
deps_ok=""
for deps_attempt in 1 2; do
rc=0
timeout -k 10 420 npx playwright install-deps chromium || rc=$?
if [ "$rc" = 0 ]; then
echo "playwright OS deps (apt): ok (attempt ${deps_attempt})"
deps_ok=1
break
fi
if [ "$rc" = 124 ] || [ "$rc" = 137 ]; then
echo "::warning::playwright OS deps (apt) attempt ${deps_attempt} hung (timeout, exit ${rc}); retrying"
else
echo "::warning::playwright OS deps (apt) attempt ${deps_attempt} failed (exit ${rc}); retrying"
fi
reap_apt_orphan || true
done
if [ -z "$deps_ok" ]; then
if sudo fuser "$lock" >/dev/null 2>&1; then
echo "::error::playwright OS deps (apt) failed twice — dpkg lock still held, not the tests"
else
echo "::error::playwright OS deps (apt) failed twice — installer or mirror hang, not the tests"
fi
exit 1
fi
# 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 --shard=${{ matrix.shard }}/3
# 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.
#
# GDK-292: the missing-tool check still needs appimagetool absent (exit 69).
# After that this job installs a pinned appimagetool, builds --appimage, and
# opens the image (`--appimage-extract`) to assert MimeType survived. The
# AppDir grep is adjacent to the script; the image is what a user runs
# (GDK-167: what the OS reads is not visible to a source test).
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
# GDK-667 / GDK-635: a third-party import added under the root module
# does not update desktop/go.sum (nested go.mod, not in ./...). Local
# `go test ./...` stays green; the three desktop jobs then fail on a
# missing sum. One check, on the cheapest desktop runner (ubuntu),
# before GTK. Not path-scoped: any root import can require a desktop
# tidy. The command lives in tools/check-desktop-tidy.sh so local and
# CI cannot drift.
- name: Desktop go.mod/go.sum match tidy
run: bash tools/check-desktop-tidy.sh
- 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).
#
# timeout wraps only the network phase. Wrapping install's configure
# phase was measured on run 32209035256 (2026-08-19): attempt 1 was
# in "Setting up" / "Processing triggers" when the 150s clock fired;
# dpkg was left interrupted; attempts 2 and 3 died in ten seconds
# each on "E: dpkg was interrupted"; the closing line said "mirror
# trouble". Same class as the playwright-install split in this file
# (cf544ac): a wall-clock kill applied to a phase the clock is not
# guarding. Do not put timeout back around unpack/configure.
opts=(-o Acquire::Retries=3
-o Acquire::http::Timeout=20
-o Acquire::https::Timeout=20)
# Numbered files in /var/lib/dpkg/updates mean dpkg was killed
# mid-transaction. apt-get then refuses every command with
# "E: dpkg was interrupted".
dpkg_interrupted() {
leftover="$(sudo find /var/lib/dpkg/updates -maxdepth 1 -type f ! -name tmp -printf '%f\n' -quit 2>/dev/null || true)"
[ -n "$leftover" ]
}
repair_dpkg() {
echo "dpkg database is interrupted; running dpkg --configure -a (not a mirror retry)" >&2
sudo dpkg --configure -a
}
# Wall clock on the network half only. update is all network.
# install is split below: download-only (timeout + retry) then
# configure from the local cache (no timeout).
apt_try_network() {
local attempt rc
for attempt in 1 2 3; do
if dpkg_interrupted; then
if ! repair_dpkg; then
echo "apt-get $1 failed — dpkg --configure -a could not recover an interrupted database (not mirror trouble)" >&2
return 1
fi
fi
rc=0
# timeout under sudo, so it signals apt-get directly rather
# than relying on sudo to forward.
sudo timeout -k 10 150 apt-get "${opts[@]}" "$@" || rc=$?
if [ "$rc" = 0 ]; then
return 0
fi
if dpkg_interrupted; then
echo "apt-get $1 attempt ${attempt}: dpkg is interrupted after the timed command (not mirror trouble)" >&2
if [ "$attempt" = 3 ]; then
echo "apt-get $1 failed — timeout killed apt mid-configure; dpkg is interrupted (not mirror trouble)" >&2
return 1
fi
echo "apt-get $1: repairing dpkg before retry (attempt ${attempt})" >&2
sleep 10
continue
fi
if [ "$attempt" = 3 ]; then
if [ "$rc" = 124 ] || [ "$rc" = 137 ] || [ "$rc" = 143 ]; then
echo "apt-get $1 failed or stalled three times — mirror trouble, not a build failure" >&2
else
echo "apt-get $1 failed three times (exit ${rc}), not a stalled mirror" >&2
fi
return 1
fi
if [ "$rc" = 124 ] || [ "$rc" = 137 ] || [ "$rc" = 143 ]; then
echo "apt-get $1 stalled (attempt ${attempt}, timeout exit ${rc}), retrying" >&2
else
echo "apt-get $1 failed (attempt ${attempt}, exit ${rc}), retrying" >&2
fi
sleep 10
done
}
apt_try() {
local cmd="$1"
shift
if [ "$cmd" = "install" ]; then
# -d: fetch .debs, no dpkg transaction. A kill here does not
# leave "dpkg was interrupted".
apt_try_network install -d "$@" || return 1
if dpkg_interrupted; then
repair_dpkg || return 1
fi
# Unpack + configure from the cache. No timeout: a kill here
# is the class of bug this helper exists to close. The step's
# timeout-minutes still bounds a real hang.
if sudo apt-get "${opts[@]}" install --no-download "$@"; then
return 0
fi
if dpkg_interrupted; then
echo "apt-get install failed — dpkg was interrupted mid-configure (not mirror trouble)" >&2
else
echo "apt-get install failed during configure (not mirror trouble)" >&2
fi
return 1
fi
apt_try_network "$cmd" "$@"
}
apt_try update
apt_try install -y --no-install-recommends \
libgtk-4-dev libwebkitgtk-6.0-dev pkg-config imagemagick
# Bad args must be 64 and a missing tool 69, the same contract
# desktop/build-app.sh keeps. The missing tool is appimagetool, which
# is not on this image — 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. This step must stay before the install
# of appimagetool below.
- 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"
# ubuntu-latest does not ship appimagetool (the step above asserts
# that). Cost of installing it, pinned 1.9.1 from GitHub Releases:
# ~15 MB download, sha256 checked, no apt package, no FUSE — the
# binary is itself an AppImage so APPIMAGE_EXTRACT_AND_RUN=1 avoids
# the fuse device that GitHub-hosted Ubuntu 24.04 does not give us.
- name: Install appimagetool
run: |
set -eu
arch="$(uname -m)"
case "$arch" in
x86_64)
file=appimagetool-x86_64.AppImage
sum=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
;;
aarch64)
file=appimagetool-aarch64.AppImage
sum=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
;;
*)
echo "no pinned appimagetool for ${arch}" >&2
exit 1
;;
esac
url="https://github.com/AppImage/appimagetool/releases/download/1.9.1/${file}"
curl -fsSL -o /tmp/appimagetool.AppImage "$url"
echo "${sum} /tmp/appimagetool.AppImage" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
install -m 0755 /tmp/appimagetool.AppImage "$HOME/.local/bin/appimagetool"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
echo "APPIMAGE_EXTRACT_AND_RUN=1" >> "$GITHUB_ENV"
echo "installed appimagetool 1.9.1 (${file})"
- name: Build the web UI
run: npm ci && npm run build
- name: Pack the Linux app
env:
APPIMAGE_EXTRACT_AND_RUN: "1"
run: desktop/build-linux.sh --appimage
- 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"
# GDK-167 / GDK-292: what the OS reads is the image, not the AppDir
# tree. --appimage-extract is the runtime a user runs, dumping the
# payload without FUSE (APPIMAGE_EXTRACT_AND_RUN).
- name: The AppImage claims gadak://
env:
APPIMAGE_EXTRACT_AND_RUN: "1"
run: |
set -eu
shopt -s nullglob
imgs=(desktop/build/Gadak-*.AppImage)
[ "${#imgs[@]}" = 1 ] || { echo "want one AppImage, got ${#imgs[@]}" >&2; ls -la desktop/build || true; exit 1; }
img="$(cd "$(dirname "${imgs[0]}")" && pwd)/$(basename "${imgs[0]}")"
test -x "$img" || { echo "AppImage is not executable: $img" >&2; exit 1; }
extract="${RUNNER_TEMP:-/tmp}/appimage-extract"
rm -rf "$extract"
mkdir -p "$extract"
(
cd "$extract"
"$img" --appimage-extract
)
desktop_file="$extract/squashfs-root/gadak.desktop"
test -f "$desktop_file" || { echo "AppImage extract produced no gadak.desktop" >&2; find "$extract" -name '*.desktop' -print || true; exit 1; }
grep -q 'MimeType=x-scheme-handler/gadak' "$desktop_file" || { echo "AppImage .desktop dropped the gadak:// MimeType" >&2; cat "$desktop_file"; exit 1; }
echo "AppImage claims gadak:// — 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
# build-windows.ps1 builds the ROOT module too (cmd/gadak), so the
# cache key must cover both go.sum files — a desktop-only key never
# rotates on a root dependency change, which then re-downloads on
# every run until desktop/go.sum happens to move (GDK-1036).
cache-dependency-path: |
go.sum
desktop/go.sum
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
# Two steps, not one: when install and build share a step they share a
# duration, and the 2026-08-27 npm-ci swing (a git dependency re-cloned
# on every run, 2–6.5 minutes) hid inside "Build the web UI" for weeks
# (GDK-1036).
- name: Install web dependencies
run: npm ci
- name: Build the web UI
run: npm run build
# --msix packs the same directory a second time as the Store package
# (GDK-1380) — one compile, two outputs; the Windows job is CI's
# critical path (GDK-1036) and a second build would double it.
- name: Pack the Windows app
shell: pwsh
run: ./desktop/build-windows.ps1 --msix
- 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" }
# Honest absence (docs/DESKTOP.md): the portable pack does not
# write HKCU\SOFTWARE\Classes\gadak. Fails if a later pack edit
# starts writing that key. First-launch registration is GDK-207
# and is not exercised here — this job does not start the GUI.
if (Test-Path -LiteralPath 'HKCU:\SOFTWARE\Classes\gadak') {
throw "pack wrote HKCU:\SOFTWARE\Classes\gadak; the portable zip must not register the scheme"
}
Write-Host "windows pack tree — ok"
exit 0
- name: Protocol handler tests
working-directory: desktop
run: go test ./... -run 'Protocol' -count=1
# makeappx validates the manifest schema; only an install proves the
# identity, the full-trust entry point and the protocol declaration
# are accepted. The Store re-signs with its own certificate, so here
# a throwaway self-signed certificate with the same Publisher CN
# stands in — trusted by this runner only, for the length of the step.
# Last in the job on purpose: deploying a package that declares
# windows.protocol registers the scheme in this user's hive, and the
# HKCU assertion above must keep measuring the pack script alone. The
# GUI is not launched.
- name: The msix installs under its declared identity
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msix = Get-ChildItem -Path desktop/build -File -Filter 'Gadak-*-windows-x64.msix' | Select-Object -First 1
if (-not $msix) { throw "build-windows.ps1 --msix produced no .msix" }
$manifest = Select-Xml -Path desktop/msix/AppxManifest.xml -XPath '/*[local-name()="Package"]/*[local-name()="Identity"]' | Select-Object -First 1
$publisher = $manifest.Node.Publisher
$name = $manifest.Node.Name
$kit = Get-ChildItem -Path "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Directory -Filter '10.*' |
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
$signtool = Join-Path $kit.FullName 'x64\signtool.exe'
if (-not (Test-Path -LiteralPath $signtool)) { throw "no signtool.exe under $($kit.FullName)" }
$cert = New-SelfSignedCertificate -Type Custom -Subject $publisher -KeyUsage DigitalSignature `
-FriendlyName 'gadak ci throwaway' -CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
$pfx = Join-Path $env:RUNNER_TEMP 'ci-throwaway.pfx'
$pw = ConvertTo-SecureString -String 'ci' -Force -AsPlainText
Export-PfxCertificate -Cert $cert -FilePath $pfx -Password $pw | Out-Null
# Sign a copy: the unsigned original is the Store upload artifact.
$test = Join-Path $env:RUNNER_TEMP $msix.Name
Copy-Item -LiteralPath $msix.FullName -Destination $test -Force
& $signtool sign /fd SHA256 /a /f $pfx /p ci $test
if ($LASTEXITCODE -ne 0) { throw "signtool exited $LASTEXITCODE" }
Import-PfxCertificate -FilePath $pfx -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' -Password $pw | Out-Null
Add-AppxPackage -Path $test
$pkg = Get-AppxPackage -Name $name
if (-not $pkg) { throw "installed, but Get-AppxPackage $name found nothing" }
Write-Host ("installed {0} {1} ({2})" -f $pkg.Name, $pkg.Version, $pkg.Architecture)
if ($pkg.Publisher -ne $publisher) { throw "publisher drifted: $($pkg.Publisher) vs manifest $publisher" }
$installed = Join-Path $pkg.InstallLocation 'gadak-desktop.exe'
if (-not (Test-Path -LiteralPath $installed)) { throw "package has no gadak-desktop.exe at $installed" }
Remove-AppxPackage -Package $pkg.PackageFullName
Write-Host "msix install/uninstall — ok"
exit 0
# The file Partner Center takes. The install check above signed a copy
# in RUNNER_TEMP, so this is the untouched unsigned package.
- name: Upload Store package artifact
uses: actions/upload-artifact@v4
with:
name: gadak-desktop-msix-x64
path: desktop/build/Gadak-*-windows-x64.msix
if-no-files-found: error
# 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