From 01c593dbda578fe392d9092981e04e15947a616b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 23 Sep 2026 08:53:16 -0700 Subject: [PATCH 1/6] fix(ci): select runtime suites from PR changes only Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 23 +++-- scripts/ci-selection.test.mjs | 184 ++++++++++++++++++++++++++++++---- 2 files changed, 179 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94336011cb3..693d3be9a6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,17 @@ jobs: - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: - token: '' + # PR files come from GitHub's PR diff. A tokenless comparison uses the + # checked-out merge commit and can include newer, unrelated base changes. + token: ${{ github.token }} + # Ordinary Markdown is documentation; explicitly retain Markdown that + # is embedded in runtime binaries as prompts or agent skills. filters: | rust: - - 'crates/**' - - 'migrations/**' - - 'schema/**' + - 'crates/**/!(*.md)' + - 'crates/buzz-acp/src/{base_prompt,session_model_channel,session_model_thread}.md' + - 'migrations/**/!(*.md)' + - 'schema/**/!(*.md)' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' @@ -64,15 +69,17 @@ jobs: desktop: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - - 'desktop/**' + - 'desktop/**/!(*.md)' + - 'desktop/src-tauri/src/managed_agents/{nest_agents,nest_skill,screenshot_skill}.md' - 'pnpm-lock.yaml' desktop-rust: - - 'desktop/src-tauri/**' + - 'desktop/src-tauri/**/!(*.md)' + - 'desktop/src-tauri/src/managed_agents/{nest_agents,nest_skill,screenshot_skill}.md' web: - - 'web/**' + - 'web/**/!(*.md)' - 'pnpm-lock.yaml' mobile: - - 'mobile/**' + - 'mobile/**/!(*.md)' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index 2099bf18999..324d890ce57 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, @@ -8,9 +8,11 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { after, test } from "node:test"; +import { promisify } from "node:util"; const workflow = readFileSync( new URL("../.github/workflows/ci.yml", import.meta.url), @@ -44,7 +46,7 @@ assert.ok( actionPath && existsSync(actionPath), `Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`, ); -function select(paths) { +async function select(paths, pullRequest = false) { const repo = mkdtempSync(join(scratch, "repo-")); const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 }); @@ -71,24 +73,103 @@ function select(paths) { writeFileSync(join(repo, path), "fixture\n"); } git("add", "."); + let server; + let apiUrl; + const eventPath = join(scratch, `event-${repo.split("/").pop()}.json`); + if (pullRequest) { + const commit = (message) => + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + message, + "-s", + ); + const base = git("rev-parse", "HEAD").toString().trim(); + commit("PR documentation or code"); + const head = git("rev-parse", "HEAD").toString().trim(); + git("checkout", "-qb", "upstream", base); + const upstreamPath = "desktop/src/upstream-only.ts"; + mkdirSync(dirname(join(repo, upstreamPath)), { recursive: true }); + writeFileSync(join(repo, upstreamPath), "upstream change\n"); + git("add", upstreamPath); + commit("Unrelated upstream desktop change"); + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "merge", + "--no-ff", + "-m", + "Synthetic merge", + head, + ); + git("checkout", "--detach"); + writeFileSync( + eventPath, + JSON.stringify({ + pull_request: { + number: 7809, + base: { sha: base }, + head: { sha: head }, + }, + repository: { default_branch: "main" }, + }), + ); + // Serve the PR file list, which intentionally excludes the newer base's code. + server = createServer((request, response) => { + assert.equal( + request.url, + "/repos/block/buzz/pulls/7809/files?per_page=100", + ); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify( + paths.map((filename) => ({ filename, status: "added" })), + ), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + apiUrl = `http://127.0.0.1:${server.address().port}`; + } 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); + try { + await promisify(execFile)(process.execPath, [actionPath], { + cwd: repo, + encoding: "utf8", + timeout: 30000, + env: { + ...process.env, + INPUT_BASE: pullRequest ? "" : "HEAD", + INPUT_FILTERS: filters, + // Resolve the workflow's token expression to a fixture token. Removing + // it must reproduce the contaminated git comparison in the PR fixture. + INPUT_TOKEN: + pullRequest && /token: \$\{\{ github\.token \}\}/.test(workflow) + ? "fixture-token" + : "", + INPUT_REF: "", + GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "", + GITHUB_EVENT_PATH: pullRequest ? eventPath : "", + GITHUB_REPOSITORY: "block/buzz", + GITHUB_API_URL: apiUrl || "https://api.github.com", + GITHUB_OUTPUT: output, + "INPUT_PREDICATE-QUANTIFIER": + workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some", + }, + }); + } finally { + if (server) await new Promise((resolve) => server.close(resolve)); + } return Object.fromEntries( [ ...readFileSync(output, "utf8").matchAll( @@ -127,10 +208,73 @@ const scenarios = [ ], ["web", ["web/src/main.tsx"], ["web"]], ["documentation", ["README.md"], []], + [ + "incident documentation", + ["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"], + [], + ], + [ + "nested documentation", + [ + "crates/buzz-cli/README.md", + "desktop/README.md", + "desktop/src-tauri/README.md", + "web/docs/design.md", + "mobile/test/README.md", + "schema/README.md", + "migrations/README.md", + ], + [], + ], + [ + "mixed documentation and desktop", + ["VISION_MOBILE.md", "mobile/README.md", "desktop/src/main.tsx"], + ["desktop"], + ], + [ + "mixed documentation and relay", + ["desktop/README.md", "crates/buzz-relay/src/lib.rs"], + ["rust"], + ], + ["embedded ACP prompt", ["crates/buzz-acp/src/base_prompt.md"], ["rust"]], + [ + "embedded Tauri skill", + ["desktop/src-tauri/src/managed_agents/nest_skill.md"], + ["desktop", "desktop-rust"], + ], ]; for (const [name, paths, expected] of scenarios) { - test(`real paths-filter: ${name}`, () => { - const outputs = select(paths); + test(`real paths-filter: ${name}`, async () => { + const outputs = await select(paths); + assert.equal(Object.keys(outputs).length, 5); + assert.deepEqual( + Object.keys(outputs) + .filter((key) => outputs[key] === "true") + .sort(), + expected.sort(), + ); + }); +} + +for (const [name, paths, expected] of [ + [ + "docs-only", + ["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"], + [], + ], + [ + "mixed mobile and Markdown", + ["VISION_MOBILE.md", "mobile/lib/main.dart"], + ["mobile"], + ], + [ + "mixed desktop and Markdown", + ["CONTEXT.md", "desktop/src/main.tsx"], + ["desktop"], + ], +]) { + test(`PR file list ignores newer base changes: ${name}`, async () => { + const outputs = await select(paths, true); assert.equal(Object.keys(outputs).length, 5); assert.deepEqual( Object.keys(outputs) From d88220e0355d62b42dfe1425622f62c0d76c64f4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 23 Sep 2026 09:01:23 -0700 Subject: [PATCH 2/6] fix(ci): retain Markdown coverage within runtime directories Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 21 +++++++++------------ scripts/ci-selection.test.mjs | 27 +++++++++++++++------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 693d3be9a6d..bbf9c6aaede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,14 +38,13 @@ jobs: # PR files come from GitHub's PR diff. A tokenless comparison uses the # checked-out merge commit and can include newer, unrelated base changes. token: ${{ github.token }} - # Ordinary Markdown is documentation; explicitly retain Markdown that - # is embedded in runtime binaries as prompts or agent skills. + # Root and docs/ Markdown do not select runtime suites. Markdown under + # runtime directories remains covered by the directory filters below. filters: | rust: - - 'crates/**/!(*.md)' - - 'crates/buzz-acp/src/{base_prompt,session_model_channel,session_model_thread}.md' - - 'migrations/**/!(*.md)' - - 'schema/**/!(*.md)' + - 'crates/**' + - 'migrations/**' + - 'schema/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' @@ -69,17 +68,15 @@ jobs: desktop: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - - 'desktop/**/!(*.md)' - - 'desktop/src-tauri/src/managed_agents/{nest_agents,nest_skill,screenshot_skill}.md' + - 'desktop/**' - 'pnpm-lock.yaml' desktop-rust: - - 'desktop/src-tauri/**/!(*.md)' - - 'desktop/src-tauri/src/managed_agents/{nest_agents,nest_skill,screenshot_skill}.md' + - 'desktop/src-tauri/**' web: - - 'web/**/!(*.md)' + - 'web/**' - 'pnpm-lock.yaml' mobile: - - 'mobile/**/!(*.md)' + - 'mobile/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index 324d890ce57..4a12a86f649 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -214,26 +214,29 @@ const scenarios = [ [], ], [ - "nested documentation", - [ - "crates/buzz-cli/README.md", - "desktop/README.md", - "desktop/src-tauri/README.md", - "web/docs/design.md", - "mobile/test/README.md", - "schema/README.md", - "migrations/README.md", - ], + "nested docs directory", + ["docs/mobile/design.md", "docs/nips/NIP-FI.md"], [], ], + ["crate Markdown", ["crates/buzz-cli/README.md"], ["rust"]], + ["desktop Markdown", ["desktop/README.md"], ["desktop"]], + [ + "Tauri Markdown", + ["desktop/src-tauri/README.md"], + ["desktop", "desktop-rust"], + ], + ["web Markdown", ["web/docs/design.md"], ["web"]], + ["mobile Markdown", ["mobile/test/README.md"], ["mobile"]], + ["schema Markdown", ["schema/README.md"], ["rust"]], + ["migration Markdown", ["migrations/README.md"], ["rust"]], [ "mixed documentation and desktop", - ["VISION_MOBILE.md", "mobile/README.md", "desktop/src/main.tsx"], + ["VISION_MOBILE.md", "docs/mobile/design.md", "desktop/src/main.tsx"], ["desktop"], ], [ "mixed documentation and relay", - ["desktop/README.md", "crates/buzz-relay/src/lib.rs"], + ["docs/desktop/design.md", "crates/buzz-relay/src/lib.rs"], ["rust"], ], ["embedded ACP prompt", ["crates/buzz-acp/src/base_prompt.md"], ["rust"]], From 82199258456fd28c8f6bf5bc154dc45972b60f11 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 25 Sep 2026 09:16:23 -0700 Subject: [PATCH 3/6] fix(ci): run all suites at the PR file-list ceiling Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 17 +++++++---- scripts/ci-runtime-selection.mjs | 26 ++++++++++++++++ scripts/ci-selection.test.mjs | 52 ++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 scripts/ci-runtime-selection.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbf9c6aaede..41c4087c631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,11 @@ jobs: contents: read pull-requests: read outputs: - rust: ${{ steps.filter.outputs.rust }} - desktop: ${{ steps.filter.outputs.desktop }} - desktop-rust: ${{ steps.filter.outputs.desktop-rust }} - web: ${{ steps.filter.outputs.web }} - mobile: ${{ steps.filter.outputs.mobile }} + rust: ${{ steps.selection.outputs.rust }} + desktop: ${{ steps.selection.outputs.desktop }} + desktop-rust: ${{ steps.selection.outputs.desktop-rust }} + web: ${{ steps.selection.outputs.web }} + mobile: ${{ steps.selection.outputs.mobile }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -88,8 +88,13 @@ jobs: - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - '.github/workflows/_ci-*.yml' + - name: Select runtime suites conservatively at the PR file-list limit + id: selection + env: + FILTER_OUTPUTS: ${{ toJSON(steps.filter.outputs) }} + run: node scripts/ci-runtime-selection.mjs - name: Validate PostgreSQL test discovery - if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' + if: github.event_name == 'push' || steps.selection.outputs.rust == 'true' run: | scripts/test-postgres-test-discovery.sh scripts/test-postgres-test-wrapper.sh diff --git a/scripts/ci-runtime-selection.mjs b/scripts/ci-runtime-selection.mjs new file mode 100644 index 00000000000..bef2a2b1a49 --- /dev/null +++ b/scripts/ci-runtime-selection.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { appendFileSync, readFileSync } from "node:fs"; + +const filters = JSON.parse(process.env.FILTER_OUTPUTS); +let runAll = false; +if (process.env.GITHUB_EVENT_NAME === "pull_request") { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const count = event.pull_request.changed_files; + assert.ok( + Number.isSafeInteger(count) && count >= 0, + "Invalid PR changed_files", + ); + // GitHub's PR-files endpoint returns at most 3,000 files. Never use a + // potentially incomplete list to skip a runtime suite. + runAll = count >= 3000; +} +for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) { + assert.ok( + ["true", "false"].includes(filters[key]), + `Invalid ${key} selection`, + ); + appendFileSync( + process.env.GITHUB_OUTPUT, + `${key}=${runAll ? "true" : filters[key]}\n`, + ); +} diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index 4a12a86f649..e0ce0cdb93f 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -118,6 +118,7 @@ async function select(paths, pullRequest = false) { JSON.stringify({ pull_request: { number: 7809, + changed_files: paths.length, base: { sha: base }, head: { sha: head }, }, @@ -133,7 +134,9 @@ async function select(paths, pullRequest = false) { response.setHeader("Content-Type", "application/json"); response.end( JSON.stringify( - paths.map((filename) => ({ filename, status: "added" })), + paths + .slice(0, 3000) + .map((filename) => ({ filename, status: "added" })), ), ); }); @@ -170,13 +173,34 @@ async function select(paths, pullRequest = false) { } finally { if (server) await new Promise((resolve) => server.close(resolve)); } - return Object.fromEntries( + const rawOutputs = 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 selectedOutput = join(repo, "selected-output"); + await promisify(execFile)( + process.execPath, + [new URL("./ci-runtime-selection.mjs", import.meta.url).pathname], + { + env: { + ...process.env, + FILTER_OUTPUTS: JSON.stringify(rawOutputs), + GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "push", + GITHUB_EVENT_PATH: eventPath, + GITHUB_OUTPUT: selectedOutput, + }, + timeout: 10000, + }, + ); + return Object.fromEntries( + readFileSync(selectedOutput, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=")), + ); } const scenarios = [ [ @@ -287,3 +311,27 @@ for (const [name, paths, expected] of [ ); }); } + +test("workflow consumes guarded selection outputs", () => { + const outputs = workflow.match(/ {4}outputs:\n([\s\S]*?) {4}steps:/)[1]; + for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) { + assert.ok(outputs.includes(`steps.selection.outputs.${key}`)); + } + assert.match( + workflow, + /id: selection\n {8}env:\n {10}FILTER_OUTPUTS: \$\{\{ toJSON\(steps.filter.outputs\) \}\}\n {8}run: node scripts\/ci-runtime-selection.mjs/, + ); +}); +for (const count of [2999, 3000, 3001]) { + test(`PR file-list ceiling: ${count} changed files`, async () => { + const paths = Array.from( + { length: Math.min(count, 3000) }, + (_, i) => `docs/file-${i}.md`, + ); + if (count > 3000) paths.push("desktop/src/omitted-by-api.ts"); + const outputs = await select(paths, true); + for (const value of Object.values(outputs)) { + assert.equal(value, count >= 3000 ? "true" : "false"); + } + }); +} From e057f1916c8dec4acd908e93e116f55cc183066e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 25 Sep 2026 09:27:40 -0700 Subject: [PATCH 4/6] fix(ci): fail required checks when path selection fails Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 67 +++++++++++++---------- scripts/ci-required-gates.test.mjs | 85 ++++++++++++++++++++++++++++++ scripts/ci-selection.test.mjs | 15 +++++- 3 files changed, 139 insertions(+), 28 deletions(-) create mode 100644 scripts/ci-required-gates.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41c4087c631..e5e60a91be5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: - 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 + run: node --test scripts/ci-selection.test.mjs scripts/ci-required-gates.test.mjs - name: File size policy run: just file-size-check @@ -253,7 +253,7 @@ jobs: rust-lint: name: Rust Lint - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -261,12 +261,13 @@ jobs: steps: - name: Check Rust Lint result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.rust_lint_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success unit-tests: name: Unit Tests - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -274,12 +275,13 @@ jobs: steps: - name: Check Unit Tests result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.unit_tests_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -287,12 +289,13 @@ jobs: steps: - name: Check Windows Rust result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.windows_rust_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop: name: Desktop - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, desktop-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -300,12 +303,13 @@ jobs: steps: - name: Check Desktop result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.desktop-domain.outputs.desktop_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-build-macos: name: Desktop Build (macOS) - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, desktop-macos-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -313,12 +317,13 @@ jobs: steps: - name: Check Desktop Build (macOS) result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-e2e-relay: name: Desktop E2E Relay - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -326,12 +331,13 @@ jobs: steps: - name: Check Desktop E2E Relay result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-e2e-integration: name: Desktop E2E Integration - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -339,12 +345,13 @@ jobs: steps: - name: Check Desktop E2E Integration result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success backend-integration: name: Backend Integration (relay e2e) - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -352,12 +359,13 @@ jobs: steps: - name: Check Backend Integration result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success postgres-tests: name: PostgreSQL Tests - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, postgres-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -365,12 +373,13 @@ jobs: steps: - name: Check PostgreSQL Tests result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success relay-e2e: name: Relay E2E - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -378,12 +387,13 @@ jobs: steps: - name: Check Relay E2E result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success web: name: Web - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.web == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 @@ -391,12 +401,13 @@ jobs: steps: - name: Check Web result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.clients.outputs.web_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success mobile: name: Mobile - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 @@ -404,12 +415,13 @@ jobs: steps: - name: Check Mobile result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.clients.outputs.mobile_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success security: name: Security - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, security-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -417,5 +429,6 @@ jobs: steps: - name: Check Security result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.security-domain.outputs.security_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success diff --git a/scripts/ci-required-gates.test.mjs b/scripts/ci-required-gates.test.mjs new file mode 100644 index 00000000000..7add7c65fa1 --- /dev/null +++ b/scripts/ci-required-gates.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const workflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); +const gates = [ + "rust-lint", + "unit-tests", + "windows-rust", + "desktop", + "desktop-build-macos", + "desktop-e2e-relay", + "desktop-e2e-integration", + "backend-integration", + "postgres-tests", + "relay-e2e", + "web", + "mobile", + "security", +]; +for (const gate of gates) { + const body = workflow.match( + new RegExp(`^ ${gate}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"), + )[1]; + const condition = body.match(/^ {4}if: (.+)$/m)[1]; + const command = body.match(/^ {8}run: (.+)$/m)[1]; + function shouldRun( + selection, + selected = false, + event = "pull_request", + artifacts = "skipped", + ) { + // These workflow conditions use only booleans, string equality and grouping. + // Evaluate the actual expression after substituting its GitHub context values. + const expression = condition + .replace(/always\(\)/g, "true") + .replace( + /github\.event_name|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, + (key) => { + if (key === "github.event_name") return JSON.stringify(event); + if (key === "needs.changes.result") return JSON.stringify(selection); + if (key === "needs.relay-artifacts-domain.result") + return JSON.stringify(artifacts); + assert.match(key, /^needs\.changes\.outputs\./); + return JSON.stringify(selected ? "true" : "false"); + }, + ); + return runInNewContext(expression, {}, { timeout: 100 }); + } + function check(selection, result) { + assert.match(body, /SELECTION_RESULT: \$\{\{ needs.changes.result \}\}/); + assert.match(body, /RESULT: \$\{\{ needs\.[\w-]+\.outputs\.[\w_]+ \}\}/); + return spawnSync("bash", ["-c", command], { + env: { ...process.env, SELECTION_RESULT: selection, RESULT: result }, + timeout: 1000, + }).status; + } + test(`${gate}: selector failures run and fail the required check`, () => { + for (const selection of ["failure", "cancelled", "skipped"]) { + for (const artifacts of ["skipped", "success"]) { + assert.equal( + shouldRun(selection, false, "pull_request", artifacts), + true, + ); + } + for (const result of ["", "skipped", "success"]) { + assert.notEqual(check(selection, result), 0); + } + } + }); + test(`${gate}: successful selection preserves path gating and suite results`, () => { + assert.equal(shouldRun("success"), false); + assert.equal(shouldRun("success", true, "pull_request", "success"), true); + assert.equal(shouldRun("success", false, "push", "success"), true); + assert.equal(check("success", "success"), 0); + for (const result of ["", "failure", "cancelled", "skipped"]) { + assert.notEqual(check("success", result), 0); + } + }); +} diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index e0ce0cdb93f..6639063384a 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -46,7 +46,7 @@ assert.ok( actionPath && existsSync(actionPath), `Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`, ); -async function select(paths, pullRequest = false) { +async function select(paths, pullRequest = false, apiStatus = 200) { const repo = mkdtempSync(join(scratch, "repo-")); const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 }); @@ -132,6 +132,11 @@ async function select(paths, pullRequest = false) { "/repos/block/buzz/pulls/7809/files?per_page=100", ); response.setHeader("Content-Type", "application/json"); + if (apiStatus !== 200) { + response.writeHead(apiStatus); + response.end(JSON.stringify({ message: "Fixture access denied" })); + return; + } response.end( JSON.stringify( paths @@ -335,3 +340,11 @@ for (const count of [2999, 3000, 3001]) { } }); } + +test("PR API denial fails path selection instead of reporting no changes", async () => { + await assert.rejects(select(["README.md"], true, 403), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout + error.stderr, /Fixture access denied/); + return true; + }); +}); From 8909575be9806d9383b55eb9301f8fd14736be4d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 25 Sep 2026 15:26:33 -0700 Subject: [PATCH 5/6] test(ci): require explicit status guards on required checks Signed-off-by: Tom Brow --- scripts/ci-required-gates.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/ci-required-gates.test.mjs b/scripts/ci-required-gates.test.mjs index 7add7c65fa1..e495a452ca9 100644 --- a/scripts/ci-required-gates.test.mjs +++ b/scripts/ci-required-gates.test.mjs @@ -37,6 +37,11 @@ for (const gate of gates) { ) { // These workflow conditions use only booleans, string equality and grouping. // Evaluate the actual expression after substituting its GitHub context values. + assert.match( + condition, + /\balways\(\)/, + "Required wrapper must override GitHub implicit success()", + ); const expression = condition .replace(/always\(\)/g, "true") .replace( From a6d0474042d9cfc1b055fe5af7aed6b64b84c6a1 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 25 Sep 2026 15:31:47 -0700 Subject: [PATCH 6/6] test(desktop): wait for unpin animation and assert manual mention behavior Signed-off-by: Tom Brow --- desktop/tests/e2e/persistent-agent-audience.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index d1174132c74..db7354873f6 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -601,15 +601,23 @@ test("the mention button opens settings and can undo an address", async ({ await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); - await input.fill(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + // Clear through editor transactions before selecting a manual mention. + await input.press("ControlOrMeta+A"); + await input.press("Backspace"); + await expect(input).toHaveText(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) .click(); await expect(input).toHaveText("@Morgarita "); + // Explicitly opting out keeps subsequent mentions manual. The old avatar's + // exit animation must not be mistaken for a newly pinned recipient. await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); await input.type("later"); await input.press("Enter");