Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 38 additions & 20 deletions scripts/ci-bun-crash-census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
* counts, closed tokens, versions and numeric run/job ids only, never log bodies.
*
* Bun version per job comes from the `Setup Bun` input in the job log when a log
* is read, and otherwise from the workflow file at the run's head commit. For a
* is read, and otherwise from the workflow file at the run's head commit, which
* is fetched once per commit and resolved per job name. For a
* pull-request run the job actually used the merge commit's workflow, so a stale
* pull-request head can attribute a job to the older pin; `versionSources` shows
* how many jobs each source covers.
Expand Down Expand Up @@ -172,6 +173,8 @@ function workflowJobNamed(opts: {
* `jobs:` to the next one. Its display name is its `name:` template, or its
* key when it has none, so `test shard 2/4` finds the `test-shard` matrix job.
* A job with no `bun-version`, such as the `test` fan-in, returns null.
* Blank lines and column-0 `#` comments inside `jobs:` are skipped; any other
* column-0 line is the next top-level key and ends the jobs.
*/
export function workflowJobBunVersion(
workflow: string,
Expand All @@ -184,6 +187,9 @@ export function workflowJobBunVersion(
}
const blocks: { readonly key: string; readonly lines: string[] }[] = [];
for (const line of lines.slice(jobsAt + 1)) {
if (line.trim() === "" || line.startsWith("#")) {
continue;
}
if (/^\S/.test(line)) {
break;
}
Expand Down Expand Up @@ -449,6 +455,34 @@ export async function runCensus(
}
}

// One contents GET per head sha: every job of every run at that sha resolves
// its Bun version from the same decoded workflow text.
const workflowTexts = new Map<string, Promise<string | null>>();
const workflowText = (sha: string): Promise<string | null> => {
const cached = workflowTexts.get(sha);
if (cached) {
return cached;
}
const pending = gh
.json(
`repos/${repo}/contents/.github/workflows/${options.workflow}?ref=${sha}`
)
.then((value) =>
typeof value === "object" &&
value !== null &&
"content" in value &&
typeof value.content === "string"
? Buffer.from(value.content, "base64").toString("utf8")
: null
)
.catch(() => null);
workflowTexts.set(sha, pending);
return pending;
};

// Keyed by (sha, job display name), never sha alone: jobs at one sha differ
// (the `test` fan-in has no Bun, each shard has the pin), so a sha-only key
// would hand the first job's version to every job a wildcard selector reads.
const workflowVersions = new Map<string, Promise<string | null>>();
const workflowVersion = (
sha: string | null,
Expand All @@ -462,25 +496,9 @@ export async function runCensus(
if (cached) {
return cached;
}
const pending = gh
.json(
`repos/${repo}/contents/.github/workflows/${options.workflow}?ref=${sha}`
)
.then((value) => {
if (
typeof value !== "object" ||
value === null ||
!("content" in value) ||
typeof value.content !== "string"
) {
return null;
}
return workflowJobBunVersion(
Buffer.from(value.content, "base64").toString("utf8"),
job
);
})
.catch(() => null);
const pending = workflowText(sha).then((text) =>
text === null ? null : workflowJobBunVersion(text, job)
);
workflowVersions.set(cacheKey, pending);
return pending;
};
Expand Down
139 changes: 139 additions & 0 deletions tests/ci-bun-crash-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,142 @@ describe("sharded test suite (HACK-1424)", () => {
);
});
});

describe("workflow reads (HACK-1441)", () => {
const WORKFLOW = [
"name: CI",
"jobs:",
" lint:",
" steps:",
" - with:",
' bun-version: "1.0.0"',
"",
"# The suite runs in four shards; `test` is their fan-in.",
" test-shard:",
" name: test shard ${{ matrix.shard }}/4",
" steps:",
" - with:",
' bun-version: "1.4.2"',
"",
"# retired:",
"# bun-version: 0.9.0",
" test:",
" needs: test-shard",
" steps:",
" - run: exit 0",
"env:",
" late:",
" bun-version: 0.1.0",
].join("\n");

test("blank and column-0 comment lines inside jobs: do not end the job scan", () => {
expect(workflowJobBunVersion(WORKFLOW, "test shard 2/4")).toBe("1.4.2");
expect(workflowJobBunVersion(WORKFLOW, "lint")).toBe("1.0.0");
expect(workflowJobBunVersion(WORKFLOW, "test")).toBeNull();
// A commented-out job is not a job.
expect(workflowJobBunVersion(WORKFLOW, "retired")).toBeNull();
// A column-0 key after `jobs:` still ends the jobs.
expect(workflowJobBunVersion(WORKFLOW, "late")).toBeNull();
});

const SHA_SHARED = "d".repeat(40);
const SHA_OTHER = "e".repeat(40);

/** Two runs at one sha and one at another; every job passed, so no log is read. */
function countingGh() {
const contentReads: string[] = [];
const job = (id: number, name: string, attempt = 1): string =>
JSON.stringify({
id,
name,
run_attempt: attempt,
status: "completed",
conclusion: "success",
});
const reader: GhReader = {
json: async (path) => {
if (path.includes("/runs?created=")) {
return { total_count: 3 };
}
if (path.includes("/contents/.github/workflows/ci.yml?ref=")) {
contentReads.push(path);
return { content: Buffer.from(WORKFLOW).toString("base64") };
}
throw new Error("unexpected path");
},
lines: async (path) => {
if (path.includes("/runs?created=")) {
return [
JSON.stringify({ id: 500, head_sha: SHA_SHARED }),
JSON.stringify({ id: 600, head_sha: SHA_SHARED }),
JSON.stringify({ id: 700, head_sha: SHA_OTHER }),
];
}
// The fan-in is listed first, so the first version resolved at a sha is its null.
if (path.includes("/runs/500/jobs?filter=all")) {
return [
job(50, "test"),
job(51, "test shard 1/4"),
job(52, "test shard 2/4"),
job(53, "test shard 3/4"),
job(54, "test shard 4/4"),
];
}
if (path.includes("/runs/600/jobs?filter=all")) {
return [job(61, "test shard 1/4"), job(62, "test shard 1/4", 2)];
}
// Here a shard is first, so a sha-only key would give the fan-in the pin.
if (path.includes("/runs/700/jobs?filter=all")) {
return [job(71, "test shard 4/4"), job(70, "test")];
}
throw new Error("unexpected path");
},
download: () => Promise.reject(new Error("no log should be read")),
};
return { reader, contentReads };
}

test("each job at a sha gets its own workflow version under a wildcard selector", async () => {
const gh = countingGh();
const result = await runCensus(
{ ...options, job: "test*" },
gh.reader,
await workDir()
);
expect(
result.records.map((entry) => [
entry.jobId,
entry.bunVersion,
entry.versionSource,
])
).toEqual([
[50, "unknown", "unknown"],
[51, "1.4.2", "workflow"],
[52, "1.4.2", "workflow"],
[53, "1.4.2", "workflow"],
[54, "1.4.2", "workflow"],
[61, "1.4.2", "workflow"],
[62, "1.4.2", "workflow"],
[71, "1.4.2", "workflow"],
[70, "unknown", "unknown"],
]);
expect(result.warnings).toEqual([
{ kind: "no_bun_setup", jobs: 2, selected: 9 },
]);
});

test("one sha costs one workflow fetch however many jobs and runs resolve from it", async () => {
for (const concurrency of [1, 3]) {
const gh = countingGh();
await runCensus(
{ ...options, job: "test*", concurrency },
gh.reader,
await workDir()
);
expect(gh.contentReads.toSorted()).toEqual([
`repos/hack-dance/hack/contents/.github/workflows/ci.yml?ref=${SHA_SHARED}`,
`repos/hack-dance/hack/contents/.github/workflows/ci.yml?ref=${SHA_OTHER}`,
]);
}
});
});
Loading