Skip to content

Commit 053f6c9

Browse files
committed
feat(release_readiness): add artifact integrity check — verify SHA256SUMS covers all assets
1 parent 4294426 commit 053f6c9

2 files changed

Lines changed: 286 additions & 2 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { type ArtifactIntegrity, registerReleaseReadinessTool } from "./release-readiness-tool.js";
4+
import { captureTool } from "./test-harness.js";
5+
6+
/**
7+
* Test suite for release_readiness tool.
8+
*
9+
* Tests below exercise code paths that do NOT require GitHub API calls
10+
* when the repo has no tags/releases. Tests that require API calls are
11+
* best-effort (graceful skip if auth unavailable).
12+
*/
13+
describe("release_readiness tool", () => {
14+
test("NO_SEMVER_TAG error when base omitted and repo has no semver tags", async () => {
15+
const run = captureTool(registerReleaseReadinessTool);
16+
// Use a repo that likely has no releases or non-semver tags
17+
const text = await run({
18+
owner: "Rethunk-AI",
19+
repo: "rethunk-github-mcp",
20+
format: "json",
21+
// base omitted — auto-pick will fail if no semver tags exist
22+
});
23+
const parsed = JSON.parse(text) as { code?: string };
24+
// Either NOT_FOUND (no tags), or the call succeeds (repo has tags)
25+
// Both are acceptable test outcomes
26+
if (parsed.code === "NOT_FOUND") {
27+
expect(parsed.code).toBe("NOT_FOUND");
28+
}
29+
});
30+
31+
test("JSON format with real repo (auth-dependent)", async () => {
32+
const run = captureTool(registerReleaseReadinessTool);
33+
const text = await run({
34+
owner: "Rethunk-AI",
35+
repo: "rethunk-github-mcp",
36+
format: "json",
37+
// base/head will auto-pick or use latest tag
38+
});
39+
const parsed = JSON.parse(text) as {
40+
base?: string;
41+
head?: string;
42+
aheadBy?: number;
43+
commits?: unknown[];
44+
stats?: { additions: number; deletions: number; changedFiles: number };
45+
artifactIntegrity?: ArtifactIntegrity;
46+
error?: { code: string };
47+
};
48+
49+
// If no auth or API error, gracefully skip
50+
if (parsed.error) return;
51+
52+
// If successful, verify structure
53+
if (parsed.base) {
54+
expect(typeof parsed.base).toBe("string");
55+
expect(typeof parsed.head).toBe("string");
56+
expect(typeof parsed.aheadBy).toBe("number");
57+
expect(Array.isArray(parsed.commits)).toBe(true);
58+
expect(parsed.stats).toBeDefined();
59+
expect(parsed.stats?.additions).toBeGreaterThanOrEqual(0);
60+
expect(parsed.stats?.deletions).toBeGreaterThanOrEqual(0);
61+
expect(parsed.stats?.changedFiles).toBeGreaterThanOrEqual(0);
62+
63+
// Artifact integrity should be present
64+
expect(parsed.artifactIntegrity).toBeDefined();
65+
expect(["ok", "warn", "skip"]).toContain(parsed.artifactIntegrity?.verdict);
66+
expect(typeof parsed.artifactIntegrity?.details).toBe("string");
67+
expect(Array.isArray(parsed.artifactIntegrity?.missingFromChecksum)).toBe(true);
68+
}
69+
});
70+
71+
test("markdown format renders artifact integrity status", async () => {
72+
const run = captureTool(registerReleaseReadinessTool);
73+
const text = await run({
74+
owner: "Rethunk-AI",
75+
repo: "rethunk-github-mcp",
76+
// format defaults to json in the tool, but we can try to get markdown
77+
format: "markdown",
78+
});
79+
80+
// If auth unavailable, result is JSON error — skip
81+
if (text.startsWith("{")) return;
82+
83+
// If markdown was rendered, it should contain artifact status
84+
if (text.includes("Release Readiness")) {
85+
// Should contain one of: "integrity verified", "No checksum asset found", "skipped"
86+
const hasArtifactStatus =
87+
text.includes("Artifacts:") ||
88+
text.includes("integrity verified") ||
89+
text.includes("No checksum asset found") ||
90+
text.includes("skipped");
91+
expect(hasArtifactStatus).toBe(true);
92+
}
93+
});
94+
95+
test("artifact integrity type structure", () => {
96+
const integrity: ArtifactIntegrity = {
97+
verdict: "ok",
98+
details: "All assets covered",
99+
missingFromChecksum: [],
100+
checksumAsset: "SHA256SUMS",
101+
};
102+
103+
expect(integrity.verdict).toBe("ok");
104+
expect(integrity.details).toContain("covered");
105+
expect(integrity.missingFromChecksum).toHaveLength(0);
106+
expect(integrity.checksumAsset).toBe("SHA256SUMS");
107+
});
108+
109+
test("artifact integrity with missing assets", () => {
110+
const integrity: ArtifactIntegrity = {
111+
verdict: "warn",
112+
details: "2 asset(s) not in checksum file",
113+
missingFromChecksum: ["app-v1.2.3.zip", "app-v1.2.3.tar.gz"],
114+
checksumAsset: "SHA256SUMS",
115+
};
116+
117+
expect(integrity.verdict).toBe("warn");
118+
expect(integrity.missingFromChecksum).toHaveLength(2);
119+
expect(integrity.checksumAsset).toBe("SHA256SUMS");
120+
});
121+
122+
test("artifact integrity skip when no assets", () => {
123+
const integrity: ArtifactIntegrity = {
124+
verdict: "skip",
125+
details: "No release assets",
126+
missingFromChecksum: [],
127+
};
128+
129+
expect(integrity.verdict).toBe("skip");
130+
expect(integrity.details).toContain("No release assets");
131+
expect(integrity.missingFromChecksum).toHaveLength(0);
132+
expect(integrity.checksumAsset).toBeUndefined();
133+
});
134+
});

src/server/release-readiness-tool.ts

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ interface CommitForRelease {
2626
pr?: { number: number; title: string; labels: string[] };
2727
}
2828

29+
export interface ArtifactIntegrity {
30+
verdict: "ok" | "warn" | "skip";
31+
details: string;
32+
missingFromChecksum: string[];
33+
checksumAsset?: string;
34+
}
35+
2936
async function fetchHeadCI(
3037
owner: string,
3138
repo: string,
@@ -63,6 +70,120 @@ async function fetchHeadCI(
6370
}
6471
}
6572

73+
/**
74+
* Check artifact integrity for a release.
75+
* - If no assets: verdict "skip"
76+
* - If checksum asset exists: parse it and verify all other assets are listed
77+
* - If assets but no checksum: verdict "warn"
78+
*/
79+
async function checkArtifactIntegrity(
80+
owner: string,
81+
repo: string,
82+
releaseId: number,
83+
tag: string,
84+
): Promise<ArtifactIntegrity> {
85+
try {
86+
const octokit = getOctokit();
87+
88+
// Fetch all assets for the release
89+
const assetsRes = await octokit.repos.listReleaseAssets({
90+
owner,
91+
repo,
92+
release_id: releaseId,
93+
per_page: 100,
94+
});
95+
96+
const assets = assetsRes.data;
97+
if (assets.length === 0) {
98+
return {
99+
verdict: "skip",
100+
details: "No release assets",
101+
missingFromChecksum: [],
102+
};
103+
}
104+
105+
// Look for a checksum file (SHA256SUMS, checksums.txt, sha256sums, etc.)
106+
const checksumAsset = assets.find((a) => /sha256|checksums|integrity/i.test(a.name));
107+
108+
if (!checksumAsset) {
109+
return {
110+
verdict: "warn",
111+
details: "No checksum asset found",
112+
missingFromChecksum: assets.map((a) => a.name),
113+
};
114+
}
115+
116+
// Download and parse the checksum file
117+
try {
118+
const checksumRes = await octokit.repos.getReleaseAsset({
119+
owner,
120+
repo,
121+
asset_id: checksumAsset.id,
122+
headers: { Accept: "application/octet-stream" },
123+
});
124+
125+
// The response.data should be a string (the file content)
126+
const checksumContent = String(checksumRes.data);
127+
128+
// Parse the checksum file — extract asset names
129+
// Common formats: "sha256 filename" or "sha256 filename" (with 1 or 2 spaces)
130+
const checksumLines = checksumContent.split("\n").filter((line) => line.trim().length > 0);
131+
132+
const checksummedAssets = new Set<string>();
133+
for (const line of checksumLines) {
134+
const parts = line.trim().split(/\s+/);
135+
if (parts.length >= 2) {
136+
// Last part is typically the filename
137+
const filename = parts[parts.length - 1];
138+
if (filename) {
139+
checksummedAssets.add(filename);
140+
}
141+
}
142+
}
143+
144+
// Find assets not in the checksum file
145+
const missingFromChecksum = assets
146+
.filter((a) => a.name !== checksumAsset.name)
147+
.filter((a) => !checksummedAssets.has(a.name))
148+
.map((a) => a.name);
149+
150+
const verdict = missingFromChecksum.length === 0 ? "ok" : "warn";
151+
const details =
152+
verdict === "ok"
153+
? "All assets covered by checksum file"
154+
: `${missingFromChecksum.length} asset(s) not in checksum file`;
155+
156+
return {
157+
verdict,
158+
details,
159+
missingFromChecksum,
160+
checksumAsset: checksumAsset.name,
161+
};
162+
} catch (parseErr) {
163+
console.error(
164+
`[checkArtifactIntegrity] Failed to download/parse checksum file ${checksumAsset.name} for ${owner}/${repo} release ${tag}:`,
165+
parseErr instanceof Error ? parseErr.message : String(parseErr),
166+
);
167+
return {
168+
verdict: "warn",
169+
details: `Failed to parse checksum file: ${parseErr instanceof Error ? parseErr.message : "unknown error"}`,
170+
missingFromChecksum: assets.filter((a) => a.name !== checksumAsset.name).map((a) => a.name),
171+
checksumAsset: checksumAsset.name,
172+
};
173+
}
174+
} catch (err) {
175+
console.error(
176+
`[checkArtifactIntegrity] Failed to check artifact integrity for ${owner}/${repo} release ${tag}:`,
177+
err instanceof Error ? err.message : String(err),
178+
);
179+
return {
180+
verdict: "warn",
181+
details: `Error checking integrity: ${err instanceof Error ? err.message : "unknown error"}`,
182+
missingFromChecksum: [],
183+
};
184+
}
185+
}
186+
66187
export function registerReleaseReadinessTool(server: FastMCP): void {
67188
server.addTool({
68189
name: "release_readiness",
@@ -153,7 +274,21 @@ export function registerReleaseReadinessTool(server: FastMCP): void {
153274
changedFiles: cmp.data.files?.length ?? 0,
154275
};
155276

156-
const result = { base, head, aheadBy, headCi: ciStatus, commits, stats };
277+
// Check artifact integrity if the base ref is a release tag
278+
let artifactIntegrity: ArtifactIntegrity | undefined;
279+
try {
280+
const releaseRes = await octokit.repos.getReleaseByTag({ owner, repo, tag: base });
281+
artifactIntegrity = await checkArtifactIntegrity(owner, repo, releaseRes.data.id, base);
282+
} catch (_err) {
283+
// base is not a release tag, or API error — skip integrity check
284+
artifactIntegrity = {
285+
verdict: "skip",
286+
details: "Base ref is not a release tag",
287+
missingFromChecksum: [],
288+
};
289+
}
290+
291+
const result = { base, head, aheadBy, headCi: ciStatus, commits, stats, artifactIntegrity };
157292

158293
if (args.format === "json") return jsonRespond(result);
159294

@@ -170,7 +305,22 @@ export function registerReleaseReadinessTool(server: FastMCP): void {
170305
: ciStatus.status === "not_configured"
171306
? "CI: not configured"
172307
: `CI: failing (${ciStatus.failedChecks.map((c) => c.name).join(", ")})`;
173-
lines.push(ciState, "");
308+
lines.push(ciState);
309+
310+
// Add artifact integrity status
311+
if (artifactIntegrity.verdict === "ok") {
312+
lines.push("Artifacts: integrity verified");
313+
} else if (artifactIntegrity.verdict === "warn") {
314+
const missing =
315+
artifactIntegrity.missingFromChecksum.length > 0
316+
? ` (${artifactIntegrity.missingFromChecksum.length} uncovered)`
317+
: "";
318+
lines.push(`Artifacts: ⚠ ${artifactIntegrity.details}${missing}`);
319+
} else {
320+
lines.push("Artifacts: skipped");
321+
}
322+
323+
lines.push("");
174324

175325
if (commits.length === 0) {
176326
lines.push("*(no commits)*");

0 commit comments

Comments
 (0)