From 01b6174a1cbad249e93f31df97d4b2ed1d0e8638 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 17 Sep 2026 11:27:55 -0700 Subject: [PATCH] fix(ci): don't run desktop tests for purely mobile client changes (#7709) Mobile-only PRs currently trigger desktop CI because the desktop test filter's glob inadvertently matches every path outside Tauri. Fix that filter so that pure mobile changes skip desktop builds, tests, and their relay artifact producer. ### Verification - 12 regression tests exercise the exact pinned paths-filter action on mobile, desktop, Tauri, backend, mixed, workflow, and documentation changes. - Restoring the bad glob makes the mobile-only regression test fail. - The filter tests, existing required-context isolation contract, workflow syntax validation, and Biome pass locally. --------- Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 3 +- scripts/ci-selection.test.mjs | 140 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 scripts/ci-selection.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a663ef957c..7b93f32f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'desktop/**' - - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' @@ -114,6 +113,8 @@ jobs: scripts/test-rust-cache-contract-regressions.sh - name: CI required-context isolation contract run: scripts/test-ci-required-context-isolation.sh + - name: CI path selection regression tests + run: node --test scripts/ci-selection.test.mjs - name: File size policy run: just file-size-check diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs new file mode 100644 index 0000000000..5c2ddfa3de --- /dev/null +++ b/scripts/ci-selection.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, test } from "node:test"; + +const workflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); +const scratch = mkdtempSync(join(tmpdir(), "buzz-ci-selection-")); +after(() => rmSync(scratch, { recursive: true, force: true })); +const actionSha = workflow.match(/uses: dorny\/paths-filter@([a-f0-9]{40})/)[1]; +const filters = workflow + .match(/ {10}filters: \|\n([\s\S]*?)(?= {6}- name:)/)[1] + .split("\n") + .map((line) => line.slice(12)) + .join("\n"); +// GitHub-hosted runners prepare pinned actions beside RUNNER_TEMP before any +// steps run. Reuse that bundle, without another download in the selection gate. +// Locally, point PATHS_FILTER_ACTION at dist/index.js from the pinned action. +const actionPath = + process.env.PATHS_FILTER_ACTION || + (process.env.RUNNER_TEMP && + join( + process.env.RUNNER_TEMP, + "..", + "_actions", + "dorny", + "paths-filter", + actionSha, + "dist", + "index.js", + )); +assert.ok( + actionPath && existsSync(actionPath), + `Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`, +); +function select(paths) { + const repo = mkdtempSync(join(scratch, "repo-")); + const git = (...args) => + execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 }); + git("init", "-q"); + // Fixture repositories must not inherit developer machine hooks. + mkdirSync(join(repo, "empty-hooks")); + git("config", "core.hooksPath", join(repo, "empty-hooks")); + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--allow-empty", + "-m", + "fixture", + "-s", + ); + for (const path of paths) { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), "fixture\n"); + } + git("add", "."); + const output = join(repo, "action-output"); + writeFileSync(output, ""); + const child = spawnSync(process.execPath, [actionPath], { + cwd: repo, + encoding: "utf8", + timeout: 30000, + env: { + ...process.env, + INPUT_BASE: "HEAD", + INPUT_FILTERS: filters, + INPUT_TOKEN: "", + INPUT_REF: "", + GITHUB_OUTPUT: output, + "INPUT_PREDICATE-QUANTIFIER": + workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some", + }, + }); + assert.equal(child.status, 0, child.stdout + child.stderr); + return Object.fromEntries( + [ + ...readFileSync(output, "utf8").matchAll( + /^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm, + ), + ].map(([, key, , value]) => [key, value]), + ); +} +const scenarios = [ + [ + "mobile source and tests", + [ + "mobile/lib/features/age_gate/age_signal_provider.dart", + "mobile/test/features/age_gate/age_signal_provider_test.dart", + ], + ["mobile"], + ], + ["mobile lockfile", ["mobile/pubspec.lock"], ["mobile"]], + ["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]], + ["desktop", ["desktop/src/main.tsx"], ["desktop"]], + ["Tauri", ["desktop/src-tauri/src/main.rs"], ["desktop", "desktop-rust"]], + ["relay", ["crates/buzz-relay/src/main.rs"], ["rust"]], + ["migration", ["migrations/123.sql"], ["rust"]], + ["shared workflow", [".github/workflows/ci.yml"], ["rust", "mobile"]], + [ + "mixed mobile and relay", + ["mobile/lib/main.dart", "crates/buzz-core/src/lib.rs"], + ["rust", "mobile"], + ], + [ + "mixed mobile and desktop", + ["mobile/lib/main.dart", "desktop/src/main.tsx"], + ["desktop", "mobile"], + ], + ["web", ["web/src/main.tsx"], ["web"]], + ["documentation", ["README.md"], []], +]; +for (const [name, paths, expected] of scenarios) { + test(`real paths-filter: ${name}`, () => { + const outputs = select(paths); + assert.equal(Object.keys(outputs).length, 5); + assert.deepEqual( + Object.keys(outputs) + .filter((key) => outputs[key] === "true") + .sort(), + expected.sort(), + ); + }); +}