@@ -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+
2936async 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 ) => / s h a 2 5 6 | c h e c k s u m s | i n t e g r i t y / 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+
66187export 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