返回 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 "codewhale-hq/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 // Every request is bounded: `idleMs` covers connecting and any stall (it is
66 // the socket timeout, which starts before the socket connects), `totalMs` is a
67 // hard ceiling for one request including a body that keeps trickling, and a
68 // body larger than `maxBodyBytes` is cut off. Redirects are limited to ten, so
69 // a chain is bounded too. Tests shrink these through the exported object.
70 const limits = {
71 idleMs: 30_000,
72 totalMs: 120_000,
73 maxBodyBytes: 8 * 1024 * 1024,
74 };
75
76 // Reject first, then tear the request down: an error passed to `destroy()` can
77 // surface on an emitter nobody listens to once a response has started.
78 function armTotalTimeout(req, url, reject) {
79 const fail = (error) => {
80 reject(error);
81 req.destroy();
82 };
83 const timer = setTimeout(
84 () => fail(new Error(`Request exceeded ${limits.totalMs} ms: ${url}`)),
85 limits.totalMs,
86 );
87 timer.unref();
88 const clear = () => clearTimeout(timer);
89 req.on("close", clear);
90 req.on("error", clear);
91 req.on("timeout", () =>
92 fail(new Error(`Request timed out after ${limits.idleMs} ms without data: ${url}`)),
93 );
94 return { clear, fail };
95 }
96
97 function requestStatus(url, method = "HEAD", redirects = 0) {
98 if (redirects > 10) {
99 throw new Error(`Too many redirects while checking ${url}`);
100 }
101 const client = url.startsWith("https:") ? https : http;
102 return new Promise((resolve, reject) => {
103 const req = client.request(
104 url,
105 {
106 method,
107 timeout: limits.idleMs,
108 headers: {
109 "User-Agent": "codewhale-npm-release-check",
110 },
111 },
112 (res) => {
113 const status = res.statusCode || 0;
114 const location = res.headers.location;
115 res.resume();
116 clear();
117 if (status >= 300 && status < 400 && location) {
118 const next = new URL(location, url).toString();
119 resolve(requestStatus(next, method, redirects + 1));
120 return;
121 }
122 resolve(status);
123 },
124 );
125 const { clear } = armTotalTimeout(req, url, reject);
126 req.on("error", reject);
127 req.end();
128 });
129 }
130
131 async function verifyAsset(url, label) {
132 let status = await requestStatus(url, "HEAD");
133 if (status === 403 || status === 405) {
134 status = await requestStatus(url, "GET");
135 }
136 if (status < 200 || status >= 400) {
137 throw new Error(`${label} returned HTTP ${status} (${url})`);
138 }
139 }
140
141 async function downloadText(url, redirects = 0) {
142 if (redirects > 10) {
143 throw new Error(`Too many redirects while downloading ${url}`);
144 }
145 const client = url.startsWith("https:") ? https : http;
146 return new Promise((resolve, reject) => {
147 const req = client.get(
148 url,
149 {
150 timeout: limits.idleMs,
151 headers: {
152 "User-Agent": "codewhale-npm-release-check",
153 },
154 },
155 (res) => {
156 const status = res.statusCode || 0;
157 if (status >= 300 && status < 400 && res.headers.location) {
158 const next = new URL(res.headers.location, url).toString();
159 res.resume();
160 resolve(downloadText(next, redirects + 1));
161 return;
162 }
163 if (status !== 200) {
164 reject(new Error(`Request failed with status ${status}: ${url}`));
165 res.resume();
166 return;
167 }
168 const chunks = [];
169 let size = 0;
170 res.setEncoding("utf8");
171 res.on("error", reject);
172 res.on("data", (chunk) => {
173 size += Buffer.byteLength(chunk);
174 if (size > limits.maxBodyBytes) {
175 guard.fail(new Error(`Response exceeds ${limits.maxBodyBytes} bytes: ${url}`));
176 return;
177 }
178 chunks.push(chunk);
179 });
180 res.on("end", () => resolve(chunks.join("")));
181 },
182 );
183 const guard = armTotalTimeout(req, url, reject);
184 req.on("error", reject);
185 });
186 }
187
188 async function downloadJson(url, redirects = 0) {
189 if (redirects > 10) {
190 throw new Error(`Too many redirects while downloading ${url}`);
191 }
192 const parsedUrl = new URL(url);
193 if (parsedUrl.protocol !== "https:") {
194 throw new Error("Release metadata requires HTTPS");
195 }
196 return new Promise((resolve, reject) => {
197 const headers = {
198 Accept: "application/vnd.github+json",
199 "User-Agent": "codewhale-npm-release-check",
200 "X-GitHub-Api-Version": "2022-11-28",
201 };
202 const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
203 if (token && parsedUrl.origin === "https://api.github.com") {
204 headers.Authorization = `Bearer ${token}`;
205 }
206 const req = https
207 .get(url, { headers, timeout: limits.idleMs }, (res) => {
208 const status = res.statusCode || 0;
209 if (status >= 300 && status < 400 && res.headers.location) {
210 const next = new URL(res.headers.location, url).toString();
211 res.resume();
212 resolve(downloadJson(next, redirects + 1));
213 return;
214 }
215 const chunks = [];
216 let size = 0;
217 res.setEncoding("utf8");
218 res.on("error", reject);
219 res.on("data", (chunk) => {
220 size += Buffer.byteLength(chunk);
221 if (size > limits.maxBodyBytes) {
222 guard.fail(new Error(`Response exceeds ${limits.maxBodyBytes} bytes: ${url}`));
223 return;
224 }
225 chunks.push(chunk);
226 });
227 res.on("end", () => {
228 const body = chunks.join("");
229 let parsed;
230 try {
231 parsed = body ? JSON.parse(body) : {};
232 } catch (error) {
233 reject(new Error(`Invalid JSON from ${url}: ${error.message}`));
234 return;
235 }
236 if (status < 200 || status >= 300) {
237 const message = parsed.message ? `: ${parsed.message}` : "";
238 reject(new Error(`GitHub API request failed with status ${status}${message} (${url})`));
239 return;
240 }
241 resolve(parsed);
242 });
243 });
244 const guard = armTotalTimeout(req, url, reject);
245 req.on("error", reject);
246 });
247 }
248
249 function githubApiUrl(repo, path) {
250 return `https://api.github.com/repos/${repo}${path}`;
251 }
252
253 async function githubApi(repo, path) {
254 return downloadJson(githubApiUrl(repo, path));
255 }
256
257 async function resolveTagCommitSha(repo, tag) {
258 const ref = await githubApi(repo, `/git/ref/tags/${encodeURIComponent(tag)}`);
259 if (!ref.object || !ref.object.sha || !ref.object.type) {
260 throw new Error(`GitHub tag ref ${tag} did not include an object SHA`);
261 }
262 if (ref.object.type === "commit") {
263 return ref.object.sha;
264 }
265 if (ref.object.type !== "tag") {
266 throw new Error(`GitHub tag ref ${tag} points at ${ref.object.type}, not a commit or annotated tag`);
267 }
268 const tagObject = await githubApi(repo, `/git/tags/${ref.object.sha}`);
269 if (!tagObject.object || tagObject.object.type !== "commit" || !tagObject.object.sha) {
270 throw new Error(`Annotated tag ${tag} did not peel to a commit SHA`);
271 }
272 return tagObject.object.sha;
273 }
274
275 async function findReleaseWorkflowRun(repo, tag, tagSha, api = githubApi) {
276 const runs = await api(repo, "/actions/workflows/release.yml/runs?per_page=100");
277 const candidates = (runs.workflow_runs || [])
278 .filter((run) => run.head_sha === tagSha)
279 .filter((run) => run.event === "push" || run.event === "workflow_dispatch")
280 .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
281
282 const orderedCandidates = [
283 ...candidates.filter((run) => run.head_branch === tag),
284 ...candidates.filter((run) => run.head_branch !== tag),
285 ];
286 for (const candidate of orderedCandidates) {
287 const runId = candidate.database_id || candidate.id;
288 if (!runId) {
289 continue;
290 }
291 const jobs = await api(repo, `/actions/runs/${runId}/jobs?per_page=100`);
292 const releaseJob = (jobs.jobs || []).find(
293 (job) => job.name === "release" && job.conclusion === "success",
294 );
295 if (releaseJob) {
296 // #5429: pin asset freshness to the successful release job's own
297 // started_at, not the run-level run_started_at. A job-level rerun
298 // (`gh run rerun --failed`) bumps run_started_at past the asset upload
299 // timestamps even though this release job produced those assets.
300 return { ...candidate, release_job_started_at: releaseJob.started_at || null };
301 }
302 }
303
304 if (orderedCandidates.length === 0) {
305 throw new Error(
306 `No release.yml workflow run found for ${tag} at ${tagSha}. ` +
307 "Rerun the Release workflow before publishing npm, or increase the verifier's last-100-runs search window.",
308 );
309 }
310 throw new Error(
311 `No successful asset-publishing job found in release.yml workflow runs for ${tag} at ${tagSha}. ` +
312 "Repair the Release workflow before publishing npm.",
313 );
314 }
315
316 function parseGitHubTime(value, label) {
317 const timestamp = Date.parse(value);
318 if (!Number.isFinite(timestamp)) {
319 throw new Error(`GitHub ${label} timestamp is invalid: ${value}`);
320 }
321 return timestamp;
322 }
323
324 function assertReleaseAssetsFresh(release, expectedAssets, run) {
325 const assetsByName = new Map((release.assets || []).map((asset) => [asset.name, asset]));
326 const missing = expectedAssets.filter((asset) => !assetsByName.has(asset));
327 if (missing.length > 0) {
328 throw new Error(`GitHub Release is missing required release asset(s): ${missing.join(", ")}`);
329 }
330
331 // #5429: compare against the successful release job's started_at when the
332 // run record carries it; fall back to the run-level timestamp only when a
333 // job baseline is unavailable.
334 const baseline = run.release_job_started_at || run.run_started_at || run.created_at;
335 const baselineLabel = run.release_job_started_at ? "release job start" : "workflow run start";
336 const freshnessBaseline = parseGitHubTime(baseline, baselineLabel);
337 const stale = [];
338 for (const expected of expectedAssets) {
339 const asset = assetsByName.get(expected);
340 if (asset.state && asset.state !== "uploaded") {
341 stale.push(`${expected} has state ${asset.state}`);
342 continue;
343 }
344 const updatedAt = parseGitHubTime(asset.updated_at || asset.created_at, `${expected} update`);
345 if (updatedAt < freshnessBaseline) {
346 stale.push(`${expected} updated at ${asset.updated_at || asset.created_at}`);
347 }
348 }
349
350 if (stale.length > 0) {
351 throw new Error(
352 `GitHub Release asset set is stale for workflow run ${run.database_id || run.id}: ${stale.join("; ")}`,
353 );
354 }
355 }
356
357 async function verifyGitHubReleaseFreshness(repo, version, expectedAssets) {
358 const tag = `v${version}`;
359 const tagSha = await resolveTagCommitSha(repo, tag);
360 const release = await githubApi(repo, `/releases/tags/${encodeURIComponent(tag)}`);
361 const run = await findReleaseWorkflowRun(repo, tag, tagSha);
362 assertReleaseAssetsFresh(release, expectedAssets, run);
363 console.log(
364 `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)}.`,
365 );
366 }
367
368 function parseChecksumManifest(text) {
369 const checksums = new Map();
370 for (const line of text.split(/\r?\n/)) {
371 const trimmed = line.trim();
372 if (!trimmed) {
373 continue;
374 }
375 const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
376 if (!match) {
377 throw new Error(`Invalid checksum manifest line: ${trimmed}`);
378 }
379 checksums.set(match[2], match[1].toLowerCase());
380 }
381 return checksums;
382 }
383
384 function assertChecksumManifestIncludes(checksums, expectedAssets, label) {
385 const missing = expectedAssets.filter((asset) => !checksums.has(asset));
386 if (missing.length > 0) {
387 throw new Error(`${label} is missing ${missing.join(", ")}`);
388 }
389 }
390
391 const compiledHosts = require("./compiled-hosts");
392
393 async function run() {
394 const version = resolveBinaryVersion();
395 const repo = resolveRepo();
396 const cnbMirror = usesCnbMirror();
397 const checksums = parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo)));
398 let catalog;
399 if (checksums.has(compiledHosts.HOST_CATALOG)) {
400 const text = await downloadText(releaseAssetUrl(compiledHosts.HOST_CATALOG, version, repo));
401 compiledHosts.verifyBytes(Buffer.from(text), checksums.get(compiledHosts.HOST_CATALOG), compiledHosts.HOST_CATALOG);
402 catalog = compiledHosts.parseCatalog(text, version);
403 }
404 if (compiledHosts.requested() && !catalog) throw new Error("compiled host requested but this source has no qualified catalog");
405 const assets = cnbMirror ? [...CNB_RELEASE_ASSET_NAMES, ...compiledHosts.assets(catalog)] : allReleaseAssetNames(catalog);
406
407 assertPackageVersionMatchesBinaryVersion(version);
408
409 console.log(`Verifying ${assets.length} release assets for ${repo}@v${version}...`);
410 if (hasReleaseBaseOverride()) {
411 console.log("Skipping GitHub workflow freshness check because a release asset mirror/base URL override is set.");
412 } else {
413 await verifyGitHubReleaseFreshness(repo, version, assets);
414 }
415 for (const asset of assets) {
416 const url = releaseAssetUrl(asset, version, repo);
417 await verifyAsset(url, asset);
418 console.log(` ok ${asset}`);
419 }
420 assertChecksumManifestIncludes(
421 checksums,
422 cnbMirror ? [...CNB_BINARY_ASSET_NAMES, ...compiledHosts.assets(catalog)] : checksummedReleaseAssetNames(catalog),
423 "Canonical checksum manifest",
424 );
425 if (!cnbMirror) {
426 const bundleChecksums = parseChecksumManifest(
427 await downloadText(releaseAssetUrl(BUNDLE_CHECKSUM_MANIFEST, version, repo)),
428 );
429 assertChecksumManifestIncludes(
430 bundleChecksums,
431 BUNDLE_ASSET_NAMES,
432 "Bundle checksum manifest",
433 );
434 }
435 console.log("Release assets verified.");
436 }
437
438 if (require.main === module) {
439 run().catch((error) => {
440 console.error("Release asset verification failed:", error.message);
441 process.exit(1);
442 });
443 }
444
445 module.exports = {
446 downloadJson,
447 downloadText,
448 limits,
449 requestStatus,
450 assertChecksumManifestIncludes,
451 assertPackageVersionMatchesBinaryVersion,
452 assertReleaseAssetsFresh,
453 findReleaseWorkflowRun,
454 hasReleaseBaseOverride,
455 parseChecksumManifest,
456 };
457
457 lines JAVASCRIPT