fix round for 0.20.1: resolution on Built-in, batch singulars, initia… #1039
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 ./... | |
| # 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 |