Files
buzz/scripts/ci-selection.test.mjs
Tom Brow 02c6309f53 fix(ci): select runtime suites from PR changes only (#7843)
A documentation-only update to #7809 selected desktop builds and E2E
tests because path detection compared an old PR base SHA with GitHub's
newer synthetic merge commit. The [failing run's path-detection
log](https://github.com/block/buzz/actions/runs/35789957773/job/106955810630)
includes four unrelated desktop files from `main`; `VISION_MOBILE.md`
did not match a runtime filter.

Use GitHub's PR file list for pull requests so unrelated changes in the
synthetic merge commit cannot select runtime suites. The existing
directory filters are unchanged: root and docs/ Markdown do not select
runtime suites, while Markdown under runtime directories still selects
its affected suites. Mixed code/documentation changes retain normal
coverage. Always-on security, policy, and source-contract checks and
full push-to-main coverage are unchanged.

Added regression coverage runs the pinned paths-filter action against
real fixture repositories, including a synthetic merge containing
unrelated upstream desktop code. All 35 selection scenarios and 26
required-check scenarios pass; the new regressions failed before the
fix. Existing required-context isolation, file-size policy, and
security-review contract checks pass, as do script lint and workflow
syntax validation. Full workflow lint reports the same two pre-existing
shell-quoting findings in the untouched dead-token guard. A desktop E2E
build was run to diagnose the unrelated required smoke failure.

Related: #7809 (incident, unchanged) and #5756 (shared-input path
coverage, separate scope). No matching issue found.

PRs with at least 3,000 changed files now select every runtime suite,
avoiding GitHub's PR-file-list ceiling. Boundary tests cover 2,999,
3,000, and 3,001 files, including a runtime file omitted beyond the API
cap; disabling the safeguard makes the latter two regressions fail.

Required checks now run and fail when path selection fails, is
cancelled, or is skipped. Regression coverage exercises the workflow
conditions and shell checks for all 13 required wrappers, plus an
API-denial case against the pinned action. Mutating either the
scheduling guard or the result check makes all 13 failure regressions
fail.

The required mention-settings smoke test expected a pin after explicitly
disabling automatic mentions. Waiting for its old avatar to exit
reproduced the CI failure 3/3; the test now checks that subsequent
mentions remain manual and retains outgoing recipient-tag assertions.
Corrected browser regression: 20/20 repeated runs; related picker unit
tests: 18/18. No desktop production behavior changed. The always()
requirement is now pinned in required-wrapper regression tests; deleting
it fails all 26 gate cases.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-25 23:19:32 +00:00

351 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile, execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
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),
"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}`,
);
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 });
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", ".");
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,
changed_files: paths.length,
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");
if (apiStatus !== 200) {
response.writeHead(apiStatus);
response.end(JSON.stringify({ message: "Fixture access denied" }));
return;
}
response.end(
JSON.stringify(
paths
.slice(0, 3000)
.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, "");
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));
}
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 = [
[
"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"]],
["integration services", ["docker-compose.yml"], ["rust"]],
["CI integration images", ["docker-compose.ci.yml"], ["rust"]],
[
"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"], []],
[
"incident documentation",
["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.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", "docs/mobile/design.md", "desktop/src/main.tsx"],
["desktop"],
],
[
"mixed documentation and relay",
["docs/desktop/design.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}`, 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)
.filter((key) => outputs[key] === "true")
.sort(),
expected.sort(),
);
});
}
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");
}
});
}
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;
});
});