mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
## Summary
Two CI-workflow fixes from the 23–30 Oct flake audit. This is one of six
PRs split from the reviewed `flakey-tests` branch (siblings below). Each
PR applies on its own to `main`.
### Hermit tool downloads (5 runs, 1 on main)
- **Runs:** 40635610550 and 40941660945, in Detect Changed Paths
(`node`). The selection step printed nothing for 9–10 min, the job hit
its cap, and every gate failed as cancelled. Three more, 40373404267 on
main (PostgreSQL Tests and Desktop E2E Integration 1/3, `pgschema`),
40373003333 (Mobile Android, `cmake`) and 40373467575 (Desktop Build
macOS, `just`), got 504s during GitHub's release-asset outage on 10-26,
~17:54–18:00 UTC.
- **Cause:** jobs start with an empty Hermit cache, so a tool's first
call downloads it, with no deadline. Hermit v0.54.1 tries three times, a
second apart, then gives up.
- **Change:** a new `scripts/ci-hermit-retry.sh` runs a no-op tool
command with a 90 s deadline per attempt, three attempts and 15 s × n
backoff. The deadline is plain bash: job control gives each attempt its
own process group, which gets TERM and then KILL. macOS runners have no
`timeout(1)` and may run bash 3.2. A "Fetch Hermit tools" step calls it
for:
- `node` and `just` in Detect Changed Paths, where one stall cancels
every gate. One `sh -c` invocation covers both, so the worst case is 345
s, inside the 10-minute cap. Two invocations would take 690 s.
- `pgschema` in PostgreSQL Tests, Desktop E2E Integration and Backend
Integration (via `--help`, since it has no `--version`).
- `just` and `cmake` in the Android lane.
- `just` in Desktop Build (macOS).
The test, `scripts/ci-hermit-retry.test.mjs`, joins Detect Changed
Paths' `node --test` list, and the script joins the rust and mobile path
filters. One stalled attempt costs ~105 s, which fits every job's cap.
- A killed attempt doesn't break the retry. Hermit downloads to a temp
file and unpacks to a temp dir, and renames each into place only once
it's complete. Its lock is a kernel flock.
### sccache storage failures (2 runs)
- **Runs:** 40355289475 (Desktop E2E Relay postgres-tests, 10-26,
"Egress is over the account limit") and 40714034945 (relay-binaries,
10-28, "send http request").
- **Cause:** the sccache server checks its GHA cache storage at startup
and won't start if that fails. Cargo's first `sccache rustc -vV` spawns
it, so the build fails. `SCCACHE_IGNORE_SERVER_IO_ERROR` doesn't help
here: in v0.16.0 it is read only when a running server's compile
response fails.
- **Change:** a "Start sccache" step runs `sccache --start-server`. It
outputs `wrapper=sccache`, or an empty wrapper and a warning if the
server won't start. The three build steps take `RUSTC_WRAPPER` from it,
and cargo treats an empty `RUSTC_WRAPPER` as no wrapper.
- The fallback doesn't fail the job later, in sccache-action's post
step. At the pinned fc920bf, that step runs `sccache --show-stats` and
the JSON form, and fails the job if either exits non-zero. With no
server running, v0.16.0's `--show-stats` doesn't start one. It returns
zero stats from the client side and exits 0. So the step keeps its stats
annotation, and `disable_annotations` stays unset.
### Related issue
None found. **Overlaps #8213** ("ci: fix empty root-workspace Rust
caches and merge test-unit nextest runs"). #8213 already conflicts with
`main`, and this PR adds a conflict in `.github/workflows/ci.yml` (path
filters).
### Testing
Verified locally:
- With a fresh Hermit state dir, `node` was SIGKILLed mid-download, then
mid-unpack while holding the lock, then stopped with TERM by the
script's own 1 s deadline. Each retry produced a working v24.15.0, at
clean-unpack speed. The `sh -c` form fetched both tools, and a 1 s
deadline on `sh -c 'sleep 37 && ...'` stopped `sh` and its child,
leaving no orphan. Tested under macOS bash 3.2 and Debian bash 5.2.
- sccache v0.16.0 (aarch64-apple-darwin) ran against a GHA cache whose
`ACTIONS_RESULTS_URL` refused connections, in READ_ONLY and READ_WRITE
modes. `--start-server` exited 2 ("Server startup failed: cache storage
failed to read"). Both `--show-stats` forms then exited 0, with zero
stats and valid JSON. `--stop-server` exited 2, which shows no server
had started.
Only hosted CI can show:
- the script under the runners' real shells, and that healthy runs only
pay a few seconds;
- whether ~3 min of spaced attempts rides out the next outage. The 10-26
outage lasted ~6 min, longer than this covers;
- that the explicit `--start-server` behaves like cargo's implicit start
against the real GHA cache. The fallback branch needs a storage outage
to run in CI.
Not covered here: first fetches of other tools (rustup, uv, pnpm) still
have no deadline. A per-job Hermit package cache is the follow-up.
Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.
### Readiness
An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.
### Siblings
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
11 KiB
JavaScript
373 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 source helper",
|
|
["scripts/mobile-release-source.sh"],
|
|
["mobile"],
|
|
],
|
|
["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]],
|
|
[
|
|
"mobile gateway recipe tests",
|
|
["scripts/test-mobile-gateway-recipes.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"],
|
|
["desktop", "mobile", "rust"],
|
|
],
|
|
[
|
|
"desktop workflow",
|
|
[".github/workflows/_ci-desktop.yml"],
|
|
["desktop", "mobile", "rust"],
|
|
],
|
|
["Justfile", ["Justfile"], ["desktop", "mobile", "rust"]],
|
|
["CI apt retry", ["scripts/ci-apt-retry.sh"], ["desktop", "rust"]],
|
|
["CI Hermit retry", ["scripts/ci-hermit-retry.sh"], ["mobile", "rust"]],
|
|
["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;
|
|
});
|
|
});
|