diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aa6da5a..0d951516 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,12 @@ permissions: concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # A cancelled run keeps neither its compute nor a verdict, and every main + # commit must carry a green one — so only PR heads cancel (only their + # latest head ever matters). workflow_dispatch evaluates false here + # (event_name != 'pull_request'), on purpose: a dispatched run is an + # explicit request that cancelling would discard. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: @@ -154,8 +159,9 @@ jobs: # GDK-1912: the census measured 31 of 127 commits in one cycle # touching only CHANGELOG/CLAUDE.md/docs — each re-billing this tier - # (~1,150 s of race compute). Same shape as the staticcheck job's - # gofilter and the same fail-open contract, one generalization: + # (~1,150 s of race compute). Same shape and fail-open contract as + # the staticcheck job's original inline gofilter (since GDK-2003 a + # ci-filter.sh subject too), one generalization: # tools/ci-filter.sh owns the input table so every filtered job reads # the same one (subject `go`: **/*.go, module files, internal/ # testdata + embeds, tools/, .github/workflows/). The step's log line @@ -234,7 +240,7 @@ jobs: # GDK-1912: subject `mobile` — mobile/, the two web/src slices it # imports, the root lockfile (Playwright), the brand mark, the # pairing vectors, and the Go module gate-serve.sh builds for - # `gadak demo`. Fail-open like the gofilter; the log line names the + # `gadak demo`. Fail-open by the ci-filter.sh contract; the log line names the # verdict. - name: Did this change touch the mobile inputs? id: cifilter @@ -312,7 +318,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/ms-playwright - key: playwright-chromium-${{ steps.pwver.outputs.version }} + key: playwright-chromium-${{ steps.pwver.outputs.version }}-${{ runner.os }}-${{ runner.arch }} - name: Install Playwright Chromium if: steps.cifilter.outputs.run == 'true' @@ -369,6 +375,11 @@ jobs: name: Staticcheck runs-on: ubuntu-latest timeout-minutes: 15 + env: + # Single owner of the pin: the cache key below and the install + # command both read it, so a bump cannot leave a stale-keyed binary + # behind (GDK-2003). + STATICCHECK_VERSION: v0.7.0 steps: - uses: actions/checkout@v4 @@ -376,71 +387,74 @@ jobs: # GDK-1702: this job billed ~5 min of setup+install per push while # the census measured most pushes touching no Go at all. It is a gate # (the cross-platform list is empty since 90e27bdf), so the skip must - # never hide a Go change — hence the fail-open rules below. GitHub Actions has no - # per-job paths filter (`on:` filters are workflow-level and would - # skip every job), so the first step diffs the change and every step - # below is gated on its output. - # - # It fails OPEN, and that is the contract: a forced push whose before - # is unreachable, a first push (all-zero before), workflow_dispatch, - # an empty event base, or any diff error runs the job. A gate skipped - # by mistake is the failure mode that matters; a gate run for nothing - # is five minutes. - # - # The path list is the Go surface plus the job's own inputs: the - # script it runs, and this workflow (a change here can rewrite the - # gate itself). `git diff A B` compares trees without needing shared - # history, so two depth-1 objects are enough. - - name: Did this change touch Go? - id: gofilter + # never hide a Go change — the fail-open contract (unreachable or + # all-zero before, unknown event, failed diff → run) is owned by + # tools/ci-filter.sh, same as every other filtered tier. The subject + # is its own, not `go`: staticcheck analyses the desktop module too + # (`run_module desktop desktop ./...`), so this table has no + # desktop/ carve-out — and its path list is the script's actual + # inputs, which the old inline regex here was not (`^desktop/` woke + # the 335 s GOOS matrix for a README line; GDK-2003 removed the last + # inline filter copy in this file). + - name: Did this change touch the staticcheck inputs? + id: cifilter env: EVENT_NAME: ${{ github.event_name }} BEFORE: ${{ github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha }} SHA: ${{ github.sha }} - run: | - run_job() { echo "go=true" >> "$GITHUB_OUTPUT"; echo "staticcheck: $1"; exit 0; } - skip_job() { echo "go=false" >> "$GITHUB_OUTPUT"; echo "staticcheck: $1"; exit 0; } - case "$EVENT_NAME" in - pull_request) base="$BASE_SHA" ;; - push) base="$BEFORE" ;; - *) run_job "event '$EVENT_NAME' has no before — running (fail open)" ;; - esac - if [ -z "$base" ] || printf '%s' "$base" | grep -Eq '^0+$'; then - run_job "no usable base sha (first push or forced push) — running (fail open)" - fi - if ! git fetch --no-tags --depth=1 origin "$base" 2>/dev/null; then - run_job "base $base not fetchable — running (fail open)" - fi - if ! changed="$(git diff --name-only "$base" "$SHA")"; then - run_job "git diff failed — running (fail open)" - fi - n=$(printf '%s\n' "$changed" | grep -c . || true) - if printf '%s\n' "$changed" | grep -Eq '\.go$|^go\.mod$|^go\.sum$|^desktop/|^tools/staticcheck\.sh$|^\.github/workflows/ci\.yml$'; then - run_job "Go-touching change in ${n} changed path(s)" - fi - skip_job "no Go-touching change in ${n} changed path(s)" + run: bash tools/ci-filter.sh staticcheck - name: Set up Go - if: steps.gofilter.outputs.go == 'true' + if: steps.cifilter.outputs.run == 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true + # The exact toolchain the analysis will run under — setup-go resolved + # it from go.mod, the single owner of the Go version. GOVERSION (not + # a go.mod hash, which also rotates on every dependency bump) is what + # makes a hit trustworthy: a staticcheck binary built by a different + # Go can analyse differently. + - name: Go toolchain stamp + id: gover + if: steps.cifilter.outputs.run == 'true' + run: echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + + # The pinned binary is a pure function of (staticcheck version, OS, + # arch, Go toolchain) — nothing repo-specific enters it — so it is + # cached whole (GDK-2003; 25–35 s of `go install` per Go-touching + # run otherwise). No restore-keys on purpose: a prefix hit could + # restore a binary built by a different toolchain or version and + # quietly change what the gate analyses. A miss is today's behaviour + # — install — so the safe failure is a miss. + - name: staticcheck binary cache + if: steps.cifilter.outputs.run == 'true' + uses: actions/cache@v4 + with: + path: ~/go/bin/staticcheck + key: staticcheck-bin-${{ env.STATICCHECK_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.gover.outputs.version }} + # What decides whether a finding fails the run or is a build-tag # artefact is the script's classifier, not staticcheck. It runs over # fixtures, needs no toolchain, and takes a second. - name: staticcheck.sh self-test - if: steps.gofilter.outputs.go == 'true' + if: steps.cifilter.outputs.run == 'true' run: bash tools/staticcheck.sh --self-test # Pinned on purpose: staticcheck gains checks between releases, and - # @latest would turn a new check into a CI change nobody made. + # @latest would turn a new check into a CI change nobody made. On a + # cache hit the binary is already in ~/go/bin and the existence check + # is the skip; $GITHUB_PATH is added on both paths (GDK-2003). - name: Install staticcheck - if: steps.gofilter.outputs.go == 'true' + if: steps.cifilter.outputs.run == 'true' run: | - go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 + if [ ! -x "$(go env GOPATH)/bin/staticcheck" ]; then + go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" + else + echo "staticcheck ${STATICCHECK_VERSION} restored from cache — install skipped" + fi echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" # A gate since the cross-platform list reached zero (GDK-1463 triage, @@ -452,7 +466,7 @@ jobs: # second copy of the mirror-flaky apt step in this file for a job that # cannot fail. The root module gets all three either way. - name: staticcheck over the GOOS matrix - if: steps.gofilter.outputs.go == 'true' + if: steps.cifilter.outputs.run == 'true' run: bash tools/staticcheck.sh e2e: @@ -484,7 +498,7 @@ jobs: # GDK-1912: subject `e2e` — the whole web surface plus the Go module, # because e2e/serve.sh (the webServer playwright waits on) builds the # gadak binary before serving the committed fixtures. Fail-open like - # the gofilter; the log line names the verdict. + # ci-filter.sh's fail-open contract; the log line names the verdict. - name: Did this change touch the e2e inputs? id: cifilter env: @@ -528,7 +542,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/ms-playwright - key: playwright-chromium-${{ steps.pwver.outputs.version }} + key: playwright-chromium-${{ steps.pwver.outputs.version }}-${{ runner.os }}-${{ runner.arch }} # --with-deps is two unlike jobs: apt (root, dpkg lock, not safely # killable) and a browser download (no lock, retryable). Wrapping the @@ -699,7 +713,7 @@ jobs: - uses: actions/checkout@v4 # GDK-1912: subject `desktop` — desktop/ plus the root module and web - # build every pack script compiles. Fail-open like the gofilter. + # build every pack script compiles. Fail-open by ci-filter.sh's contract. - name: Did this change touch the desktop inputs? id: cifilter env: @@ -780,7 +794,7 @@ jobs: # GDK-1912: subject `desktop` — the Linux pack compiles the root # module, the desktop module and the web build, so the table is the - # union. Fail-open like the gofilter. + # union. Fail-open by ci-filter.sh's contract. - name: Did this change touch the desktop inputs? id: cifilter env: @@ -1142,8 +1156,8 @@ jobs: - uses: actions/checkout@v4 # GDK-1912: subject `desktop` — this job builds the root module - # (cmd/gadak), the desktop module and the web build. Fail-open like - # the gofilter. + # (cmd/gadak), the desktop module and the web build. Fail-open by + # ci-filter.sh's contract. - name: Did this change touch the desktop inputs? id: cifilter env: diff --git a/tools/ci-filter-test.sh b/tools/ci-filter-test.sh index 19de6db5..300f3b28 100644 --- a/tools/ci-filter-test.sh +++ b/tools/ci-filter-test.sh @@ -145,7 +145,7 @@ docs/project/STATE_OF_PLAY.md specs/000-product/data-model.md artifacts/notes.txt contrib/README.md" -for s in go e2e mobile desktop; do +for s in go e2e mobile desktop staticcheck; do expect_skip "docs-only / $s" "$s" "$DOCS_ONLY" done # docs/media is mobile's ONE docs input (the brand-icon check reads it) — @@ -157,7 +157,7 @@ echo "== case 2: one .go change runs every tier that compiles Go" # ci.yml, not taste: the race tier compiles the two packages from the root # module; e2e/serve.sh builds the binary it serves; gate-serve.sh runs # `gadak demo`; every desktop pack script builds ./cmd/gadak. -for s in go e2e mobile desktop; do +for s in go e2e mobile desktop staticcheck; do expect_run "one .go / $s" "$s" "internal/server/sync.go" done expect_run "cmd .go / go" go "cmd/gadak/main.go" @@ -228,6 +228,42 @@ expect_skip "a stray file under examples/ / go" go "examples/notes.txt" expect_skip "a stray file under examples/ / e2e" e2e "examples/notes.txt" expect_skip "a stray file under examples/ / mobile" mobile "examples/notes.txt" +echo +echo "== case 3d: subject staticcheck — both modules, no desktop carve-out (GDK-2003)" +# The defect this subject closes: the staticcheck job's inline gofilter +# regex carried `^desktop/`, so desktop/README.md alone woke the 335 s GOOS +# matrix. The inverse trap is worse and is the reason this is a NEW subject +# rather than subject `go`: the go table carves desktop/ out (no +# root-module tier compiles it), but tools/staticcheck.sh DOES analyse the +# desktop module (`run_module desktop desktop ./...`) — reusing `go` would +# skip a desktop/main.go-only push and hide a Go change from a gate. The +# run/skip pair below pins both directions; desktop/main.go is the row that +# catches the reuse-go trap. +expect_skip "desktop/README.md does not touch staticcheck" staticcheck "desktop/README.md" +expect_run "desktop module .go → staticcheck (the reuse-go trap)" staticcheck "desktop/main.go" +expect_run "run 35025858519 / staticcheck" staticcheck "$RUN_35025858519" +expect_run "desktop module graph → staticcheck" staticcheck "desktop/go.mod" +expect_run "desktop module sums → staticcheck" staticcheck "desktop/go.sum" +expect_skip "pack scripts are not analysis inputs" staticcheck "desktop/build-linux.sh" +expect_skip "the syso pair is a link input, not an analysis one" staticcheck "desktop/windows-app.manifest" +expect_run "root module graph → staticcheck" staticcheck "go.mod" +expect_run "root .go → staticcheck" staticcheck "internal/statuscat/category.go" +# internal/ stays a whole-directory row for one measured reason: the +# //go:embed files there are load-bearing. `go list -e` on a package whose +# embed target is deleted reports "pattern data.json: no matching files +# found" with Incomplete=true — the load itself fails, which +# staticcheck.sh's classifier fails the run on. Content is never analysed; +# existence is. (testdata/ is inert for staticcheck but shares the prefix.) +expect_run "embed catalogs are load-bearing" staticcheck "internal/config/tokencheck/catalog.json" +expect_run "test-file embeds load too" staticcheck "internal/config/tokencheck/testdata/token-vectors.json" +# The analysis owner and the skip owner, each its own row (not dir:tools/: +# doc-checks.sh edits must not wake this job). +expect_run "the analysis script → staticcheck" staticcheck "tools/staticcheck.sh" +expect_run "the filter owns this skip → staticcheck" staticcheck "tools/ci-filter.sh" +expect_run "the workflow → staticcheck" staticcheck ".github/workflows/ci.yml" +expect_skip "web does not touch staticcheck" staticcheck "web/src/routes/+page.svelte" +expect_skip "docs-only / staticcheck (single file)" staticcheck "CHANGELOG.md" + echo echo "== case 4: fail-open — no diff obtainable means run, always" : > "$WORK/empty.txt" @@ -237,6 +273,7 @@ export FAKE_GIT_DIFF="$WORK/chg.txt" # 4a. workflow_dispatch / schedule: no before at all. CI_ENV=(EVENT_NAME=workflow_dispatch SHA=deadbeef FAKE_GIT_MODE=ok) ci_mode "workflow_dispatch → run" run go +ci_mode "workflow_dispatch → run (staticcheck, same ladder)" run staticcheck # 4b. push with no before (first push) and all-zero before (forced/tag push). CI_ENV=(EVENT_NAME=push BEFORE= SHA=deadbeef FAKE_GIT_MODE=ok) @@ -291,9 +328,11 @@ expect_run "a workflow rewrite re-runs every tier" go ".github/workflows/ci.yml" expect_run " (same, e2e)" e2e ".github/workflows/ci.yml" expect_run " (same, mobile)" mobile ".github/workflows/ci.yml" expect_run " (same, desktop)" desktop ".github/workflows/ci.yml" +expect_run " (same, staticcheck)" staticcheck ".github/workflows/ci.yml" # The filter itself lives under tools/: editing it re-runs every tier, so a # broken filter never ships hidden behind its own verdict. expect_run "filter self-edit → go" go "tools/ci-filter.sh" +expect_run "filter self-edit → staticcheck" staticcheck "tools/ci-filter.sh" echo echo "== stdout contract: verdict line first, reason second, GITHUB_OUTPUT written" @@ -321,7 +360,52 @@ echo "== FAIL-first: the checks go red on bad answers" expect_skip "webmap.ts is not web/" e2e "webmap.ts" expect_skip "a nested go.mod that is not the root one" go "docs/go.mod" expect_skip "a .go suffix inside a filename, not at the end" go "proto.go.txt" +expect_skip " (same, staticcheck)" staticcheck "proto.go.txt" expect_skip "gomodulate is not go.mod" desktop "gomodulate.sh" +expect_skip "desktop/gomod.sh is not desktop/go.mod" staticcheck "desktop/gomod.sh" +expect_skip "docs/go.mod is not the root go.mod" staticcheck "docs/go.mod" + +echo +echo "== the mobile table covers everything the phone imports out of web/ (GDK-2044)" +# Derived, never enumerated. The mobile subject's web rows rotted once: the +# table named lib/i18n/ and lib/terminal/ while mobile/src imported 24 +# modules out of web/src/lib, so a push touching only +# web/src/lib/view-config.ts skipped the Mobile job and CI went green over +# a phone that reads that file. An enumeration is what rotted, so this +# reads the imports off the phone's own source and asks the filter itself. +# Only real import/@import statements — a path a comment merely names is +# not an input (mobile/src cites several web components in prose). +mobile_web_imports() { + grep -rhE "^[[:space:]]*(import|@import|} from|export .* from)[^\n]*web/src/" mobile/src mobile/e2e 2>/dev/null \ + | grep -oE "web/src/[A-Za-z0-9/_.-]+" \ + | sed "s#/*\$##" | sort -u +} +imported="$(mobile_web_imports)" +n_imports=$(printf '%s\n' "$imported" | grep -c . || true) +if [[ "$n_imports" -lt 8 ]]; then + fail "only $n_imports web/ imports found in mobile/src (floor 8) — the scan rotted, fix the scan and not the floor" +fi +uncovered="" +while IFS= read -r f; do + [[ -z "$f" ]] && continue + # Module specifiers drop the extension; resolve to whatever is on disk. + if [[ ! -f "$f" ]]; then + if [[ -f "$f.ts" ]]; then f="$f.ts" + elif [[ -f "$f.svelte" ]]; then f="$f.svelte" + elif [[ -f "$f/index.ts" ]]; then f="$f/index.ts" + else continue + fi + fi + if [[ "$(decide mobile "$f")" != run=true ]]; then + uncovered="$uncovered $f" + fi +done <<<"$imported" +if [[ -n "$uncovered" ]]; then + fail "mobile/src imports these out of web/, and the mobile subject skips them — + a push touching only one would leave the Mobile job unrun:$uncovered" +else + echo "- all $n_imports web/ paths the phone imports run the mobile subject" +fi echo if [[ "$FAILURES" -eq 0 ]]; then diff --git a/tools/ci-filter.sh b/tools/ci-filter.sh index 5cec6e58..74081e74 100644 --- a/tools/ci-filter.sh +++ b/tools/ci-filter.sh @@ -52,8 +52,20 @@ # of examples/ (dashboards/, compose/, plugins/ are read by demo # recordings and Go tests outside this filtered tier). # -# mobile mobile/ (own src, e2e, package-lock.json, tauri) plus the two -# web/src slices it imports (lib/i18n/, lib/terminal/); the root +# mobile mobile/ (own src, e2e, package-lock.json, tauri) plus the web +# slices it imports — web/src/lib/ WHOLE and web/src/app.css +# (GDK-2044). It was two slices (lib/i18n/, lib/terminal/) until a +# count showed the phone importing 24 modules out of that +# directory — adf, api, issue-group, view-config, keyboard, +# person-match and the rest — so a push touching only +# web/src/lib/view-config.ts skipped the Mobile job while the +# phone read that file. That is the one direction this filter +# must never fail in: CI green over a broken phone, the inverse +# of the local-green/CI-red class. The directory row is +# deliberate over a 24-line enumeration, which is what rotted the +# first time; ci-filter-test.sh derives the import set from +# mobile/src and fails if any of it falls outside this table, so +# narrowing the row later is red rather than silent. The root # package.json/package-lock.json (Playwright lives at the root); # docs/media/logo.png (check-brand-icons.sh diffs the phone icons # against the mark); internal/pairing/testdata/ (offer-vectors.json @@ -75,6 +87,37 @@ # .github/workflows/. No desktop/ carve-out here: this subject # IS the one that builds it. # +# staticcheck tools/staticcheck.sh analyses BOTH modules over the GOOS +# matrix (`run_module root . ./cmd/... ./internal/... ./tools/...` +# + `run_module desktop desktop ./...`, GOOS darwin/linux/windows): +# **/*.go with NO desktop/ carve-out — unlike subject `go`, the +# desktop module IS an analysis input here, so desktop/main.go +# must run this subject (the run 35025858519 push that subject +# `go` rightly skips must run this one); go.mod, go.sum, +# desktop/go.mod, desktop/go.sum (the two module graphs staticcheck +# resolves). internal/ stays a whole-directory row for one +# MEASURED reason, not the race tier's: the //go:embed files +# there are load-bearing — `go list -e` on a package whose embed +# target is deleted answers `pattern data.json: no matching +# files found`, Incomplete=true (verified with a scratch module), +# and staticcheck.sh's classifier fails the run on a load error. +# Content is never analysed (staticcheck type-checks, never +# executes); existence is. The measured set: catalog.json, +# dim-catalog.json (internal/config/tokencheck/), and +# testdata/token-vectors.json — tokencheck_test.go embeds it and +# staticcheck loads _test.go files; other internal/ testdata is +# inert but shares the prefix. NOT web/, package.json, .nvmrc +# (no Node anywhere in this job), NOT examples/, contrib/ or the +# root package embed.go (outside the ./cmd/... ./internal/... +# ./tools/... patterns — a stray suffix:.go match on those four +# files is cost, never a wrong skip). tools/staticcheck.sh (the +# analysis owner) and tools/ci-filter.sh (the skip owner — the +# same pairing as subject go's race-partition.sh row: a change +# here can rewrite the gate itself) as exact rows, NOT dir:tools/ +# — doc-checks.sh and the e2e/race partitioners are not this +# job's inputs and must not wake it. .github/workflows/ (the job +# and the pinned staticcheck version live there). +# # Visibility: a filter that never says anything is its own defect, so every # decision — run or skip — prints one line naming the subject, the verdict, # and why (how many paths changed, which pattern matched). Those lines are @@ -99,7 +142,7 @@ SELF="ci-filter" usage() { cat >&2 <<'EOF' usage: - tools/ci-filter.sh subject: go | e2e | mobile | desktop + tools/ci-filter.sh subject: go | e2e | mobile | desktop | staticcheck tools/ci-filter.sh --files decide from a path list ('-' = stdin) EOF exit 2 @@ -155,8 +198,8 @@ EOF cat <<'EOF' not:desktop/ dir:mobile/ -dir:web/src/lib/i18n/ -dir:web/src/lib/terminal/ +dir:web/src/lib/ +exact:web/src/app.css exact:docs/media/logo.png dir:internal/pairing/testdata/ exact:examples/demo.db @@ -182,10 +225,23 @@ exact:go.sum exact:package.json exact:package-lock.json exact:.nvmrc +EOF + ;; + staticcheck) + cat <<'EOF' +suffix:.go +exact:go.mod +exact:go.sum +exact:desktop/go.mod +exact:desktop/go.sum +dir:internal/ +exact:tools/staticcheck.sh +exact:tools/ci-filter.sh +dir:.github/workflows/ EOF ;; *) - echo "$SELF: unknown subject '$1' (go | e2e | mobile | desktop)" >&2 + echo "$SELF: unknown subject '$1' (go | e2e | mobile | desktop | staticcheck)" >&2 exit 2 ;; esac diff --git a/tools/staticcheck.sh b/tools/staticcheck.sh index 41ec96a0..734b2213 100755 --- a/tools/staticcheck.sh +++ b/tools/staticcheck.sh @@ -318,7 +318,10 @@ fi # --- real run -------------------------------------------------------------- if ! command -v staticcheck >/dev/null 2>&1; then echo "staticcheck.sh: staticcheck is not on PATH." >&2 - echo " go install honnef.co/go/tools/cmd/staticcheck@latest" >&2 + # The version CI pins (.github/workflows/ci.yml, STATICCHECK_VERSION). + # @latest would install a build with checks this tree has never been + # measured against, so a local run could disagree with the gate. + echo " go install honnef.co/go/tools/cmd/staticcheck@v0.7.0" >&2 exit 2 fi