fix(linear): a bare sentinel is invisible to the one function that detects a dead credential (GDK-274) #292
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ci-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| build: | |
| name: Build and check | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: npm | |
| - run: npm ci | |
| - name: Go vet | |
| run: go vet ./... | |
| # Store/source firewall (docs/ARCHITECTURE.md:79). Not path-scoped: any | |
| # import-graph edit can break it. | |
| - name: Store dependency firewall | |
| run: bash tools/check-store-deps.sh | |
| # 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 |