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