Skip to content

Commit 0cf4031

Browse files
committed
feat(showcase): add per-slot build-result artifact name + merge helpers
Add buildResultArtifactName(service) and mergeBuildResultFiles(payloads) to showcase/scripts/lib/build-outputs.ts so each matrix slot in showcase_build.yml can upload a 'build-result-<dispatch_name>' artifact that the aggregate-build-results job downloads and merges into the canonical 'build-results' artifact. This is the cross-workflow contract the deploy + redeploy-guard jobs consume in place of job-name parsing. Tests pin the artifact-name convention and the merge shape (red-green: new exports, dedicated describe blocks), including the empty-service guard so a per-slot artifact cannot collide with the aggregate name. Per plan-E E-4c/E-4d.
1 parent 5061793 commit 0cf4031

2 files changed

Lines changed: 197 additions & 85 deletions

File tree

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,97 @@
11
import { describe, expect, it } from "vitest";
22
import {
3-
parseBuildOutputs,
4-
successSet,
5-
type BuildOutcome,
6-
type ServiceBuildResult,
3+
buildResultArtifactName,
4+
mergeBuildResultFiles,
5+
parseBuildOutputs,
6+
successSet,
77
} from "../build-outputs";
8+
import type { BuildOutcome, ServiceBuildResult } from "../build-outputs";
89

910
const sample: ServiceBuildResult[] = [
10-
{ service: "showcase-mastra", status: "success" },
11-
{ service: "showcase-ag2", status: "failure" },
12-
{ service: "shell-docs", status: "skipped" },
13-
{ service: "showcase-aimock", status: "success" },
11+
{ service: "showcase-mastra", status: "success" },
12+
{ service: "showcase-ag2", status: "failure" },
13+
{ service: "shell-docs", status: "skipped" },
14+
{ service: "showcase-aimock", status: "success" },
1415
];
1516

1617
describe("build-outputs", () => {
17-
it("parses a JSON array of {service,status} entries", () => {
18-
const json = JSON.stringify(sample);
19-
expect(parseBuildOutputs(json)).toEqual(sample);
20-
});
21-
22-
it("throws on malformed JSON", () => {
23-
expect(() => parseBuildOutputs("not json")).toThrow(/parse/i);
24-
});
25-
26-
it("throws on entries missing fields", () => {
27-
expect(() => parseBuildOutputs(JSON.stringify([{ service: "x" }]))).toThrow(
28-
/status/i,
29-
);
30-
});
31-
32-
it("throws on an unknown status value", () => {
33-
const bad = JSON.stringify([{ service: "x", status: "weird" }]);
34-
expect(() => parseBuildOutputs(bad)).toThrow(/status/i);
35-
});
36-
37-
it("successSet returns only services with status === 'success'", () => {
38-
expect(successSet(sample).sort()).toEqual(
39-
["showcase-aimock", "showcase-mastra"].sort(),
40-
);
41-
});
42-
43-
it("successSet returns empty when no services succeeded", () => {
44-
const allFailed: ServiceBuildResult[] = [
45-
{ service: "a", status: "failure" },
46-
{ service: "b", status: "failure" },
47-
];
48-
expect(successSet(allFailed)).toEqual([]);
49-
});
50-
51-
it("type BuildOutcome enumerates success|failure|skipped", () => {
52-
const outcomes: BuildOutcome[] = ["success", "failure", "skipped"];
53-
expect(outcomes).toHaveLength(3);
54-
});
18+
it("parses a JSON array of {service,status} entries", () => {
19+
const json = JSON.stringify(sample);
20+
expect(parseBuildOutputs(json)).toEqual(sample);
21+
});
22+
23+
it("throws on malformed JSON", () => {
24+
expect(() => parseBuildOutputs("not json")).toThrow(/parse/i);
25+
});
26+
27+
it("throws on entries missing fields", () => {
28+
expect(() => parseBuildOutputs(JSON.stringify([{ service: "x" }]))).toThrow(
29+
/status/i,
30+
);
31+
});
32+
33+
it("throws on an unknown status value", () => {
34+
const bad = JSON.stringify([{ service: "x", status: "weird" }]);
35+
expect(() => parseBuildOutputs(bad)).toThrow(/status/i);
36+
});
37+
38+
it("successSet returns only services with status === 'success'", () => {
39+
expect(successSet(sample).sort()).toEqual(
40+
["showcase-aimock", "showcase-mastra"].sort(),
41+
);
42+
});
43+
44+
it("successSet returns empty when no services succeeded", () => {
45+
const allFailed: ServiceBuildResult[] = [
46+
{ service: "a", status: "failure" },
47+
{ service: "b", status: "failure" },
48+
];
49+
expect(successSet(allFailed)).toEqual([]);
50+
});
51+
52+
it("type BuildOutcome enumerates success|failure|skipped", () => {
53+
const outcomes: BuildOutcome[] = ["success", "failure", "skipped"];
54+
expect(outcomes).toHaveLength(3);
55+
});
56+
});
57+
58+
describe("buildResultArtifactName", () => {
59+
it("returns the canonical per-slot artifact name", () => {
60+
expect(buildResultArtifactName("showcase-aimock")).toBe(
61+
"build-result-showcase-aimock",
62+
);
63+
});
64+
65+
it("rejects empty service names (would collide with aggregate artifact)", () => {
66+
expect(() => buildResultArtifactName("")).toThrow(/service/i);
67+
});
68+
});
69+
70+
describe("mergeBuildResultFiles", () => {
71+
it("merges per-slot {service,status} JSON payloads into a single array", () => {
72+
const slotPayloads = [
73+
'{"service":"showcase-mastra","status":"success"}',
74+
'{"service":"showcase-ag2","status":"failure"}',
75+
'{"service":"showcase-aimock","status":"success"}',
76+
];
77+
expect(mergeBuildResultFiles(slotPayloads)).toEqual([
78+
{ service: "showcase-mastra", status: "success" },
79+
{ service: "showcase-ag2", status: "failure" },
80+
{ service: "showcase-aimock", status: "success" },
81+
]);
82+
});
83+
84+
it("throws when a slot payload is missing service or status", () => {
85+
expect(() => mergeBuildResultFiles(['{"service":"x"}'])).toThrow(/status/i);
86+
});
87+
88+
it("throws on an invalid status value", () => {
89+
expect(() =>
90+
mergeBuildResultFiles(['{"service":"x","status":"weird"}']),
91+
).toThrow(/status/i);
92+
});
93+
94+
it("returns an empty array when no slot payloads are provided", () => {
95+
expect(mergeBuildResultFiles([])).toEqual([]);
96+
});
5597
});

showcase/scripts/lib/build-outputs.ts

Lines changed: 109 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,55 +14,125 @@
1414
export type BuildOutcome = "success" | "failure" | "skipped";
1515

1616
export interface ServiceBuildResult {
17-
service: string;
18-
status: BuildOutcome;
17+
service: string;
18+
status: BuildOutcome;
1919
}
2020

2121
const VALID_STATUSES: ReadonlySet<BuildOutcome> = new Set([
22-
"success",
23-
"failure",
24-
"skipped",
22+
"success",
23+
"failure",
24+
"skipped",
2525
]);
2626

2727
export function parseBuildOutputs(raw: string): ServiceBuildResult[] {
28+
let parsed: unknown;
29+
try {
30+
parsed = JSON.parse(raw);
31+
} catch (e) {
32+
throw new Error(
33+
`Failed to parse build outputs JSON: ${
34+
e instanceof Error ? e.message : String(e)
35+
}`,
36+
{ cause: e },
37+
);
38+
}
39+
if (!Array.isArray(parsed)) {
40+
throw new Error("Build outputs must be a JSON array");
41+
}
42+
const results: ServiceBuildResult[] = [];
43+
for (const entry of parsed) {
44+
if (
45+
typeof entry !== "object" ||
46+
entry === null ||
47+
typeof (entry as { service?: unknown }).service !== "string"
48+
) {
49+
throw new Error(
50+
`Build outputs entry missing required string field "service": ${JSON.stringify(entry)}`,
51+
);
52+
}
53+
const status = (entry as { status?: unknown }).status;
54+
if (
55+
typeof status !== "string" ||
56+
!VALID_STATUSES.has(status as BuildOutcome)
57+
) {
58+
throw new Error(
59+
`Build outputs entry has invalid "status" (must be success|failure|skipped): ${JSON.stringify(entry)}`,
60+
);
61+
}
62+
results.push({
63+
service: (entry as { service: string }).service,
64+
status: status as BuildOutcome,
65+
});
66+
}
67+
return results;
68+
}
69+
70+
export function successSet(results: ServiceBuildResult[]): string[] {
71+
return results.filter((r) => r.status === "success").map((r) => r.service);
72+
}
73+
74+
/**
75+
* Canonical artifact-name convention for the per-slot build-result
76+
* handoff. Each matrix slot in showcase_build.yml uploads exactly one
77+
* artifact named `build-result-<dispatch_name>` containing a single
78+
* `result.json` file. The aggregator job downloads every artifact
79+
* matching the `build-result-*` pattern and merges them via
80+
* mergeBuildResultFiles below. We refuse empty service names so the
81+
* per-slot artifact cannot collide with the aggregated `build-results`
82+
* artifact published downstream.
83+
*/
84+
export function buildResultArtifactName(service: string): string {
85+
if (typeof service !== "string" || service.length === 0) {
86+
throw new Error(
87+
"buildResultArtifactName: `service` must be a non-empty string",
88+
);
89+
}
90+
return `build-result-${service}`;
91+
}
92+
93+
/**
94+
* Merge a list of per-slot result.json payloads (raw strings, one per
95+
* matrix slot's uploaded artifact) into a single ServiceBuildResult[].
96+
* Each payload MUST be a JSON object with `service: string` and
97+
* `status: success|failure|skipped`. The merge is order-preserving so
98+
* downstream consumers can rely on stable iteration.
99+
*/
100+
export function mergeBuildResultFiles(
101+
slotPayloads: readonly string[],
102+
): ServiceBuildResult[] {
103+
return slotPayloads.map((raw, idx) => {
28104
let parsed: unknown;
29105
try {
30-
parsed = JSON.parse(raw);
106+
parsed = JSON.parse(raw);
31107
} catch (e) {
32-
throw new Error(
33-
`Failed to parse build outputs JSON: ${
34-
e instanceof Error ? e.message : String(e)
35-
}`,
36-
);
108+
throw new Error(
109+
`mergeBuildResultFiles: slot[${idx}] is not valid JSON: ${
110+
e instanceof Error ? e.message : String(e)
111+
}`,
112+
{ cause: e },
113+
);
37114
}
38-
if (!Array.isArray(parsed)) {
39-
throw new Error("Build outputs must be a JSON array");
115+
if (
116+
typeof parsed !== "object" ||
117+
parsed === null ||
118+
typeof (parsed as { service?: unknown }).service !== "string"
119+
) {
120+
throw new Error(
121+
`mergeBuildResultFiles: slot[${idx}] missing required string field "service": ${raw}`,
122+
);
40123
}
41-
const results: ServiceBuildResult[] = [];
42-
for (const entry of parsed) {
43-
if (
44-
typeof entry !== "object" ||
45-
entry === null ||
46-
typeof (entry as { service?: unknown }).service !== "string"
47-
) {
48-
throw new Error(
49-
`Build outputs entry missing required string field "service": ${JSON.stringify(entry)}`,
50-
);
51-
}
52-
const status = (entry as { status?: unknown }).status;
53-
if (typeof status !== "string" || !VALID_STATUSES.has(status as BuildOutcome)) {
54-
throw new Error(
55-
`Build outputs entry has invalid "status" (must be success|failure|skipped): ${JSON.stringify(entry)}`,
56-
);
57-
}
58-
results.push({
59-
service: (entry as { service: string }).service,
60-
status: status as BuildOutcome,
61-
});
124+
const status = (parsed as { status?: unknown }).status;
125+
if (
126+
typeof status !== "string" ||
127+
!VALID_STATUSES.has(status as BuildOutcome)
128+
) {
129+
throw new Error(
130+
`mergeBuildResultFiles: slot[${idx}] has invalid "status" (must be success|failure|skipped): ${raw}`,
131+
);
62132
}
63-
return results;
64-
}
65-
66-
export function successSet(results: ServiceBuildResult[]): string[] {
67-
return results.filter((r) => r.status === "success").map((r) => r.service);
133+
return {
134+
service: (parsed as { service: string }).service,
135+
status: status as BuildOutcome,
136+
};
137+
});
68138
}

0 commit comments

Comments
 (0)