Skip to content

Commit e1a036e

Browse files
committed
fix(showcase): aggregate-build-results fail-loud on missing slot file + GHA heredoc output + tests
1 parent 1883d8c commit e1a036e

2 files changed

Lines changed: 262 additions & 13 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* aggregate-build-results.test.ts — covers the per-slot aggregator that
3+
* runs in the `aggregate-build-results` job of showcase_build.yml.
4+
*
5+
* The script's `run({inputDir, outputDir, githubOutput})` entrypoint is
6+
* exercised directly with temp dirs (so we never touch tracked files or
7+
* spawn subprocesses). We verify:
8+
* 1. empty INPUT_DIR → results.json = `[]`, any_success=false, no throw
9+
* 2. build-result-<x> dir missing result.json → throws naming the slot
10+
* 3. non-`build-result-*` dirs are ignored
11+
* 4. mixed success/failure → correct merged array + any_success=true
12+
* 5. GITHUB_OUTPUT receives heredoc-form `results` block + any_success
13+
*/
14+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
15+
import { tmpdir } from "node:os";
16+
import { join } from "node:path";
17+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
18+
import { run } from "../aggregate-build-results";
19+
20+
function makeSlot(
21+
inputDir: string,
22+
service: string,
23+
status: "success" | "failure" | "skipped",
24+
): void {
25+
const dir = join(inputDir, `build-result-${service}`);
26+
mkdirSync(dir, { recursive: true });
27+
writeFileSync(join(dir, "result.json"), JSON.stringify({ service, status }));
28+
}
29+
30+
describe("aggregate-build-results.run", () => {
31+
let inputDir: string;
32+
let outputDir: string;
33+
let githubOutput: string;
34+
35+
beforeEach(() => {
36+
const base = mkdtempSync(join(tmpdir(), "agg-build-"));
37+
inputDir = join(base, "in");
38+
outputDir = join(base, "out");
39+
githubOutput = join(base, "gh_output");
40+
mkdirSync(inputDir, { recursive: true });
41+
// OUTPUT_DIR intentionally NOT pre-created — run() must mkdir -p.
42+
writeFileSync(githubOutput, "");
43+
});
44+
45+
afterEach(() => {
46+
// Temp dirs are under os.tmpdir(); OS reaps them. No tracked files
47+
// touched, so no cleanup needed.
48+
});
49+
50+
it("empty INPUT_DIR → results.json=[] and any_success=false", () => {
51+
run({ inputDir, outputDir, githubOutput });
52+
53+
const results = JSON.parse(
54+
readFileSync(join(outputDir, "results.json"), "utf-8"),
55+
);
56+
expect(results).toEqual([]);
57+
58+
const gh = readFileSync(githubOutput, "utf-8");
59+
expect(gh).toContain("any_success=false");
60+
// Heredoc form must be used even for [].
61+
expect(gh).toMatch(/results<<(\S+)\n\[\]\n\1\n/);
62+
});
63+
64+
it("throws naming the slot when build-result-<x>/result.json is missing", () => {
65+
const slotDir = join(inputDir, "build-result-orphan");
66+
mkdirSync(slotDir, { recursive: true });
67+
// Note: NO result.json written.
68+
69+
expect(() => run({ inputDir, outputDir, githubOutput })).toThrow(
70+
/aggregate-build-results: build-result-orphan is missing result\.json/,
71+
);
72+
});
73+
74+
it("ignores directories that do not match build-result-*", () => {
75+
mkdirSync(join(inputDir, "some-other-artifact"), { recursive: true });
76+
writeFileSync(
77+
join(inputDir, "some-other-artifact", "result.json"),
78+
JSON.stringify({ service: "noise", status: "success" }),
79+
);
80+
// A file (not a directory) at top level should also be ignored.
81+
writeFileSync(join(inputDir, "build-result-not-a-dir"), "garbage");
82+
83+
makeSlot(inputDir, "real", "success");
84+
85+
run({ inputDir, outputDir, githubOutput });
86+
87+
const results = JSON.parse(
88+
readFileSync(join(outputDir, "results.json"), "utf-8"),
89+
);
90+
expect(results).toEqual([{ service: "real", status: "success" }]);
91+
});
92+
93+
it("merges mixed success/failure correctly and sets any_success=true", () => {
94+
makeSlot(inputDir, "alpha", "success");
95+
makeSlot(inputDir, "beta", "failure");
96+
makeSlot(inputDir, "gamma", "skipped");
97+
98+
run({ inputDir, outputDir, githubOutput });
99+
100+
const results = JSON.parse(
101+
readFileSync(join(outputDir, "results.json"), "utf-8"),
102+
);
103+
expect(results).toHaveLength(3);
104+
const byName = new Map<string, string>(
105+
(results as Array<{ service: string; status: string }>).map((r) => [
106+
r.service,
107+
r.status,
108+
]),
109+
);
110+
expect(byName.get("alpha")).toBe("success");
111+
expect(byName.get("beta")).toBe("failure");
112+
expect(byName.get("gamma")).toBe("skipped");
113+
114+
const gh = readFileSync(githubOutput, "utf-8");
115+
expect(gh).toContain("any_success=true");
116+
});
117+
118+
it("writes results to GITHUB_OUTPUT in multi-line heredoc form", () => {
119+
makeSlot(inputDir, "alpha", "success");
120+
makeSlot(inputDir, "beta", "failure");
121+
122+
run({ inputDir, outputDir, githubOutput });
123+
124+
const gh = readFileSync(githubOutput, "utf-8");
125+
126+
// The heredoc form is:
127+
// results<<EOF
128+
// <json>
129+
// EOF
130+
// The delimiter token is implementation-defined but must match on
131+
// both sides (GitHub Actions convention; commonly "EOF" or a unique
132+
// token to avoid collision with embedded payloads).
133+
const heredocRe = /results<<(\S+)\n([\s\S]*?)\n\1\n/;
134+
const match = gh.match(heredocRe);
135+
expect(match).not.toBeNull();
136+
if (!match) return;
137+
const [, , jsonBody] = match;
138+
const parsed = JSON.parse(jsonBody);
139+
expect(Array.isArray(parsed)).toBe(true);
140+
expect(parsed).toHaveLength(2);
141+
142+
// any_success line is still a plain key=value.
143+
expect(gh).toMatch(/any_success=true\n/);
144+
});
145+
146+
it("results.json has a trailing newline", () => {
147+
makeSlot(inputDir, "alpha", "success");
148+
149+
run({ inputDir, outputDir, githubOutput });
150+
151+
const raw = readFileSync(join(outputDir, "results.json"), "utf-8");
152+
expect(raw.endsWith("\n")).toBe(true);
153+
});
154+
});

showcase/scripts/aggregate-build-results.ts

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
* them as job-level outputs.
1515
*
1616
* No GitHub API calls. No job-name parsing. Pure filesystem aggregation.
17+
*
18+
* Testability: env reading lives in the CLI entrypoint at the bottom;
19+
* the core is exported as `run({inputDir, outputDir, githubOutput})` so
20+
* tests can drive it with temp dirs.
1721
*/
1822
import {
1923
appendFileSync,
@@ -22,7 +26,9 @@ import {
2226
readdirSync,
2327
writeFileSync,
2428
} from "node:fs";
29+
import { randomBytes } from "node:crypto";
2530
import { join } from "node:path";
31+
import { fileURLToPath } from "node:url";
2632
import { mergeBuildResultFiles, successSet } from "./lib/build-outputs";
2733

2834
function requireEnv(name: string): string {
@@ -33,24 +39,113 @@ function requireEnv(name: string): string {
3339
return v;
3440
}
3541

36-
function main(): void {
37-
const inDir = requireEnv("INPUT_DIR");
38-
const outDir = requireEnv("OUTPUT_DIR");
39-
const ghOutput = requireEnv("GITHUB_OUTPUT");
42+
export interface RunOptions {
43+
inputDir: string;
44+
outputDir: string;
45+
githubOutput: string;
46+
}
47+
48+
/**
49+
* Read a single per-slot result.json. On failure (missing file, permission
50+
* error, etc.), wraps the error with the offending slot directory so the
51+
* job log identifies WHICH slot was the culprit instead of dumping a raw
52+
* ENOENT against a long opaque path. We refuse to silently skip the slot —
53+
* a missing per-slot artifact is a real defect (the build job's artifact
54+
* upload step is broken or the matrix collapsed) and silently dropping it
55+
* would let a failed build masquerade as "not present" downstream.
56+
*/
57+
function readSlotPayload(inputDir: string, slotDirName: string): string {
58+
const path = join(inputDir, slotDirName, "result.json");
59+
try {
60+
return readFileSync(path, "utf-8");
61+
} catch (e) {
62+
const code =
63+
e && typeof e === "object" && "code" in e
64+
? String((e as { code?: unknown }).code)
65+
: e instanceof Error
66+
? e.message
67+
: String(e);
68+
throw new Error(
69+
`aggregate-build-results: ${slotDirName} is missing result.json (${code})`,
70+
{ cause: e },
71+
);
72+
}
73+
}
74+
75+
/**
76+
* Emit the per-job outputs to $GITHUB_OUTPUT. `results` uses the
77+
* multi-line heredoc form, which is the GHA-recommended encoding for
78+
* any value that might contain (or grow to contain) a newline — most
79+
* importantly, it survives pretty-printed JSON or other multi-line
80+
* payloads without truncation. A random delimiter token prevents
81+
* collision with embedded payloads. `any_success` stays a plain
82+
* key=value line since the value is a fixed boolean literal.
83+
*
84+
* Written BEFORE results.json so a $GITHUB_OUTPUT write failure
85+
* (e.g. the file is missing / not writable) aborts before we publish
86+
* an artifact the downstream jobs would consume without seeing the
87+
* matching job output.
88+
*/
89+
function writeGithubOutput(
90+
githubOutput: string,
91+
resultsJson: string,
92+
anySuccess: boolean,
93+
): void {
94+
const delimiter = `EOF_${randomBytes(8).toString("hex")}`;
95+
appendFileSync(
96+
githubOutput,
97+
`results<<${delimiter}\n${resultsJson}\n${delimiter}\n`,
98+
);
99+
appendFileSync(githubOutput, `any_success=${anySuccess ? "true" : "false"}\n`);
100+
}
101+
102+
export function run(opts: RunOptions): void {
103+
const { inputDir, outputDir, githubOutput } = opts;
40104

41-
mkdirSync(outDir, { recursive: true });
105+
mkdirSync(outputDir, { recursive: true });
42106

43-
const payloads = readdirSync(inDir, { withFileTypes: true })
107+
const slotDirs = readdirSync(inputDir, { withFileTypes: true })
44108
.filter((d) => d.isDirectory() && d.name.startsWith("build-result-"))
45-
.map((d) => join(inDir, d.name, "result.json"))
46-
.map((p) => readFileSync(p, "utf-8"));
109+
.map((d) => d.name);
110+
111+
const payloads = slotDirs.map((name) => readSlotPayload(inputDir, name));
47112

48113
const merged = mergeBuildResultFiles(payloads);
49-
writeFileSync(join(outDir, "results.json"), JSON.stringify(merged));
114+
const resultsJson = JSON.stringify(merged);
115+
const anySuccess = successSet(merged).length > 0;
116+
117+
// Emit $GITHUB_OUTPUT first so a write failure here doesn't leave a
118+
// published results.json artifact without a matching job output.
119+
writeGithubOutput(githubOutput, resultsJson, anySuccess);
50120

51-
const anySuccess = successSet(merged).length > 0 ? "true" : "false";
52-
appendFileSync(ghOutput, `results=${JSON.stringify(merged)}\n`);
53-
appendFileSync(ghOutput, `any_success=${anySuccess}\n`);
121+
// Trailing newline for consistency with conventional JSON-on-disk
122+
// tooling (POSIX line, diff-friendly).
123+
writeFileSync(join(outputDir, "results.json"), `${resultsJson}\n`);
54124
}
55125

56-
main();
126+
function main(): void {
127+
run({
128+
inputDir: requireEnv("INPUT_DIR"),
129+
outputDir: requireEnv("OUTPUT_DIR"),
130+
githubOutput: requireEnv("GITHUB_OUTPUT"),
131+
});
132+
}
133+
134+
// CLI entrypoint: only run main() when invoked directly (e.g. `tsx
135+
// aggregate-build-results.ts`), NOT when imported by a test. Comparing
136+
// `import.meta.url` against process.argv[1] is the standard ESM idiom.
137+
const invokedDirectly = (() => {
138+
try {
139+
return (
140+
typeof process !== "undefined" &&
141+
Array.isArray(process.argv) &&
142+
process.argv[1] === fileURLToPath(import.meta.url)
143+
);
144+
} catch {
145+
return false;
146+
}
147+
})();
148+
149+
if (invokedDirectly) {
150+
main();
151+
}

0 commit comments

Comments
 (0)