返回 CodeWhale
verify-release-assets.js
根目录 / npm / codewhale / scripts / verify-release-assets.js
1 const https = require("https");
2 const http = require("http");
3 const {
4 allReleaseAssetNames,
5 BUNDLE_ASSET_NAMES,
6 BUNDLE_CHECKSUM_MANIFEST,
7 checksummedReleaseAssetNames,
8 checksumManifestUrl,
9 CNB_BINARY_ASSET_NAMES,
10 CNB_RELEASE_ASSET_NAMES,
11 releaseAssetUrl,
12 usesCnbMirror,
13 } = require("./artifacts");
14
15 const pkg = require("../package.json");
16
17 function resolveBinaryVersion() {
18 const configuredVersion =
19 process.env.CODEWHALE_VERSION ||
20 process.env.DEEPSEEK_TUI_VERSION ||
21 process.env.DEEPSEEK_VERSION ||
22 pkg.codewhaleBinaryVersion || pkg.deepseekBinaryVersion ||
23 pkg.version;
24 return String(configuredVersion).trim();
25 }
26
27 function resolveRepo() {
28 return (
29 process.env.CODEWHALE_GITHUB_REPO ||
30 process.env.DEEPSEEK_TUI_GITHUB_REPO ||
31 process.env.DEEPSEEK_GITHUB_REPO ||
32 "Hmbown/CodeWhale"
33 );
34 }
35
36 function hasReleaseBaseOverride() {
37 return Boolean(
38 process.env.CODEWHALE_RELEASE_BASE_URL ||
39 process.env.DEEPSEEK_TUI_RELEASE_BASE_URL ||
40 process.env.DEEPSEEK_RELEASE_BASE_URL ||
41 process.env.CODEWHALE_USE_CNB_MIRROR,
42 );
43 }
44
45 function packageVersionMatchesBinaryVersion(version) {
46 return String(pkg.version).trim() === version;
47 }
48
49 function assertPackageVersionMatchesBinaryVersion(version) {
50 if (packageVersionMatchesBinaryVersion(version)) {
51 return;
52 }
53 if (process.env.CODEWHALE_ALLOW_NPM_BINARY_MISMATCH === "1") {
54 console.log(
55 `npm package version ${pkg.version} points at binary release ${version} (allowed packaging-only mismatch).`,
56 );
57 return;
58 }
59 throw new Error(
60 `npm package version ${pkg.version} does not match codewhaleBinaryVersion ${version}. ` +
61 "Set CODEWHALE_ALLOW_NPM_BINARY_MISMATCH=1 only for an intentional packaging-only npm release.",
62 );
63 }
64
65 function requestStatus(url, method = "HEAD", redirects = 0) {
66 if (redirects > 10) {
67 throw new Error(`Too many redirects while checking ${url}`);
68 }
69 const client = url.startsWith("https:") ? https : http;
70 return new Promise((resolve, reject) => {
71 const req = client.request(
72 url,
73 {
74 method,
75 headers: {
76 "User-Agent": "codewhale-npm-release-check",
77 },
78 },
79 (res) => {
80 const status = res.statusCode || 0;
81 const location = res.headers.location;
82 res.resume();
83 if (status >= 300 && status < 400 && location) {
84 const next = new URL(location, url).toString();
85 resolve(requestStatus(next, method, redirects + 1));
86 return;
87 }
88 resolve(status);
89 },
90 );
91 req.on("error", reject);
92 req.end();
93 });
94 }
95
96 async function verifyAsset(url, label) {
97 let status = await requestStatus(url, "HEAD");
98 if (status === 403 || status === 405) {
99 status = await requestStatus(url, "GET");
100 }
101 if (status < 200 || status >= 400) {
102 throw new Error(`${label} returned HTTP ${status} (${url})`);
103 }
104 }
105
106 async function downloadText(url, redirects = 0) {
107 if (redirects > 10) {
108 throw new Error(`Too many redirects while downloading ${url}`);
109 }
110 const client = url.startsWith("https:") ? https : http;
111 return new Promise((resolve, reject) => {
112 client
113 .get(
114 url,
115 {
116 headers: {
117 "User-Agent": "codewhale-npm-release-check",
118 },
119 },
120 (res) => {
121 const status = res.statusCode || 0;
122 if (status >= 300 && status < 400 && res.headers.location) {
123 const next = new URL(res.headers.location, url).toString();
124 res.resume();
125 resolve(downloadText(next, redirects + 1));
126 return;
127 }
128 if (status !== 200) {
129 reject(new Error(`Request failed with status ${status}: ${url}`));
130 res.resume();
131 return;
132 }
133 const chunks = [];
134 res.setEncoding("utf8");
135 res.on("data", (chunk) => chunks.push(chunk));
136 res.on("end", () => resolve(chunks.join("")));
137 },
138 )
139 .on("error", reject);
140 });
141 }
142
143 async function downloadJson(url, redirects = 0) {
144 if (redirects > 10) {
145 throw new Error(`Too many redirects while downloading ${url}`);
146 }
147 const client = url.startsWith("https:") ? https : http;
148 return new Promise((resolve, reject) => {
149 const headers = {
150 Accept: "application/vnd.github+json",
151 "User-Agent": "codewhale-npm-release-check",
152 "X-GitHub-Api-Version": "2022-11-28",
153 };
154 const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
155 if (token) {
156 headers.Authorization = `Bearer ${token}`;
157 }
158 client
159 .get(url, { headers }, (res) => {
160 const status = res.statusCode || 0;
161 if (status >= 300 && status < 400 && res.headers.location) {
162 const next = new URL(res.headers.location, url).toString();
163 res.resume();
164 resolve(downloadJson(next, redirects + 1));
165 return;
166 }
167 const chunks = [];
168 res.setEncoding("utf8");
169 res.on("data", (chunk) => chunks.push(chunk));
170 res.on("end", () => {
171 const body = chunks.join("");
172 let parsed;
173 try {
174 parsed = body ? JSON.parse(body) : {};
175 } catch (error) {
176 reject(new Error(`Invalid JSON from ${url}: ${error.message}`));
177 return;
178 }
179 if (status < 200 || status >= 300) {
180 const message = parsed.message ? `: ${parsed.message}` : "";
181 reject(new Error(`GitHub API request failed with status ${status}${message} (${url})`));
182 return;
183 }
184 resolve(parsed);
185 });
186 })
187 .on("error", reject);
188 });
189 }
190
191 function githubApiUrl(repo, path) {
192 return `https://api.github.com/repos/${repo}${path}`;
193 }
194
195 async function githubApi(repo, path) {
196 return downloadJson(githubApiUrl(repo, path));
197 }
198
199 async function resolveTagCommitSha(repo, tag) {
200 const ref = await githubApi(repo, `/git/ref/tags/${encodeURIComponent(tag)}`);
201 if (!ref.object || !ref.object.sha || !ref.object.type) {
202 throw new Error(`GitHub tag ref ${tag} did not include an object SHA`);
203 }
204 if (ref.object.type === "commit") {
205 return ref.object.sha;
206 }
207 if (ref.object.type !== "tag") {
208 throw new Error(`GitHub tag ref ${tag} points at ${ref.object.type}, not a commit or annotated tag`);
209 }
210 const tagObject = await githubApi(repo, `/git/tags/${ref.object.sha}`);
211 if (!tagObject.object || tagObject.object.type !== "commit" || !tagObject.object.sha) {
212 throw new Error(`Annotated tag ${tag} did not peel to a commit SHA`);
213 }
214 return tagObject.object.sha;
215 }
216
217 async function findReleaseWorkflowRun(repo, tag, tagSha, api = githubApi) {
218 const runs = await api(repo, "/actions/workflows/release.yml/runs?per_page=100");
219 const candidates = (runs.workflow_runs || [])
220 .filter((run) => run.head_sha === tagSha)
221 .filter((run) => run.event === "push" || run.event === "workflow_dispatch")
222 .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
223
224 const orderedCandidates = [
225 ...candidates.filter((run) => run.head_branch === tag),
226 ...candidates.filter((run) => run.head_branch !== tag),
227 ];
228 for (const candidate of orderedCandidates) {
229 const runId = candidate.database_id || candidate.id;
230 if (!runId) {
231 continue;
232 }
233 const jobs = await api(repo, `/actions/runs/${runId}/jobs?per_page=100`);
234 const releaseJob = (jobs.jobs || []).find(
235 (job) => job.name === "release" && job.conclusion === "success",
236 );
237 if (releaseJob) {
238 // #5429: pin asset freshness to the successful release job's own
239 // started_at, not the run-level run_started_at. A job-level rerun
240 // (`gh run rerun --failed`) bumps run_started_at past the asset upload
241 // timestamps even though this release job produced those assets.
242 return { ...candidate, release_job_started_at: releaseJob.started_at || null };
243 }
244 }
245
246 if (orderedCandidates.length === 0) {
247 throw new Error(
248 `No release.yml workflow run found for ${tag} at ${tagSha}. ` +
249 "Rerun the Release workflow before publishing npm, or increase the verifier's last-100-runs search window.",
250 );
251 }
252 throw new Error(
253 `No successful asset-publishing job found in release.yml workflow runs for ${tag} at ${tagSha}. ` +
254 "Repair the Release workflow before publishing npm.",
255 );
256 }
257
258 function parseGitHubTime(value, label) {
259 const timestamp = Date.parse(value);
260 if (!Number.isFinite(timestamp)) {
261 throw new Error(`GitHub ${label} timestamp is invalid: ${value}`);
262 }
263 return timestamp;
264 }
265
266 function assertReleaseAssetsFresh(release, expectedAssets, run) {
267 const assetsByName = new Map((release.assets || []).map((asset) => [asset.name, asset]));
268 const missing = expectedAssets.filter((asset) => !assetsByName.has(asset));
269 if (missing.length > 0) {
270 throw new Error(`GitHub Release is missing required release asset(s): ${missing.join(", ")}`);
271 }
272
273 // #5429: compare against the successful release job's started_at when the
274 // run record carries it; fall back to the run-level timestamp only when a
275 // job baseline is unavailable.
276 const baseline = run.release_job_started_at || run.run_started_at || run.created_at;
277 const baselineLabel = run.release_job_started_at ? "release job start" : "workflow run start";
278 const freshnessBaseline = parseGitHubTime(baseline, baselineLabel);
279 const stale = [];
280 for (const expected of expectedAssets) {
281 const asset = assetsByName.get(expected);
282 if (asset.state && asset.state !== "uploaded") {
283 stale.push(`${expected} has state ${asset.state}`);
284 continue;
285 }
286 const updatedAt = parseGitHubTime(asset.updated_at || asset.created_at, `${expected} update`);
287 if (updatedAt < freshnessBaseline) {
288 stale.push(`${expected} updated at ${asset.updated_at || asset.created_at}`);
289 }
290 }
291
292 if (stale.length > 0) {
293 throw new Error(
294 `GitHub Release asset set is stale for workflow run ${run.database_id || run.id}: ${stale.join("; ")}`,
295 );
296 }
297 }
298
299 async function verifyGitHubReleaseFreshness(repo, version, expectedAssets) {
300 const tag = `v${version}`;
301 const tagSha = await resolveTagCommitSha(repo, tag);
302 const release = await githubApi(repo, `/releases/tags/${encodeURIComponent(tag)}`);
303 const run = await findReleaseWorkflowRun(repo, tag, tagSha);
304 assertReleaseAssetsFresh(release, expectedAssets, run);
305 console.log(
306 `GitHub release asset freshness OK: ${expectedAssets.length} release assets for ${tag} were produced by run ${run.database_id || run.id} at ${tagSha.slice(0, 12)}.`,
307 );
308 }
309
310 function parseChecksumManifest(text) {
311 const checksums = new Map();
312 for (const line of text.split(/\r?\n/)) {
313 const trimmed = line.trim();
314 if (!trimmed) {
315 continue;
316 }
317 const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
318 if (!match) {
319 throw new Error(`Invalid checksum manifest line: ${trimmed}`);
320 }
321 checksums.set(match[2], match[1].toLowerCase());
322 }
323 return checksums;
324 }
325
326 function assertChecksumManifestIncludes(checksums, expectedAssets, label) {
327 const missing = expectedAssets.filter((asset) => !checksums.has(asset));
328 if (missing.length > 0) {
329 throw new Error(`${label} is missing ${missing.join(", ")}`);
330 }
331 }
332
333 async function run() {
334 const version = resolveBinaryVersion();
335 const repo = resolveRepo();
336 const cnbMirror = usesCnbMirror();
337 const assets = cnbMirror ? CNB_RELEASE_ASSET_NAMES : allReleaseAssetNames();
338
339 assertPackageVersionMatchesBinaryVersion(version);
340
341 console.log(`Verifying ${assets.length} release assets for ${repo}@v${version}...`);
342 if (hasReleaseBaseOverride()) {
343 console.log("Skipping GitHub workflow freshness check because a release asset mirror/base URL override is set.");
344 } else {
345 await verifyGitHubReleaseFreshness(repo, version, assets);
346 }
347 for (const asset of assets) {
348 const url = releaseAssetUrl(asset, version, repo);
349 await verifyAsset(url, asset);
350 console.log(` ok ${asset}`);
351 }
352 const checksums = parseChecksumManifest(
353 await downloadText(checksumManifestUrl(version, repo)),
354 );
355 assertChecksumManifestIncludes(
356 checksums,
357 cnbMirror ? CNB_BINARY_ASSET_NAMES : checksummedReleaseAssetNames(),
358 "Canonical checksum manifest",
359 );
360 if (!cnbMirror) {
361 const bundleChecksums = parseChecksumManifest(
362 await downloadText(releaseAssetUrl(BUNDLE_CHECKSUM_MANIFEST, version, repo)),
363 );
364 assertChecksumManifestIncludes(
365 bundleChecksums,
366 BUNDLE_ASSET_NAMES,
367 "Bundle checksum manifest",
368 );
369 }
370 console.log("Release assets verified.");
371 }
372
373 if (require.main === module) {
374 run().catch((error) => {
375 console.error("Release asset verification failed:", error.message);
376 process.exit(1);
377 });
378 }
379
380 module.exports = {
381 assertChecksumManifestIncludes,
382 assertPackageVersionMatchesBinaryVersion,
383 assertReleaseAssetsFresh,
384 findReleaseWorkflowRun,
385 hasReleaseBaseOverride,
386 parseChecksumManifest,
387 };
388
388 lines JAVASCRIPT