返回 CodeWhale
verify-release-inventory.js
根目录 / scripts / release / verify-release-inventory.js
1 #!/usr/bin/env node
2 // The GitHub Release asset set, checked as a whole.
3 //
4 // release.yml uploads into a DRAFT release, then runs this with
5 // --draft --asset-dir artifacts --publish
6 // which requires the draft to carry exactly the authoritative inventory
7 // (allReleaseAssetNames), every asset fully uploaded with the local byte
8 // size and SHA-256 digest, and only then flips draft -> published. A rerun
9 // can reuse draft assets only when their bytes match the verified local set.
10 // The whole set becomes public
11 // at once; an upload that dies halfway leaves a draft nobody can install from,
12 // not a public partial release.
13 //
14 // release-republish.yml runs it with --manifest: an older tag may predate the
15 // current inventory, so there the release's own checksum manifest is the
16 // inventory, and the release must carry exactly the manifest's assets plus
17 // the manifest itself before any channel is derived from it.
18 //
19 // Set GH_BIN to choose the GitHub CLI binary (tests use a fake).
20
21 const { execFileSync } = require("node:child_process");
22 const crypto = require("node:crypto");
23 const fs = require("node:fs");
24 const path = require("node:path");
25
26 const { allReleaseAssetNames, CHECKSUM_MANIFEST } = require("../../npm/codewhale/scripts/artifacts");
27 const compiledHosts = require("../../npm/codewhale/scripts/compiled-hosts");
28 const { validateTarget } = require("./ensure-release-assets-absent");
29
30 function usage() {
31 return [
32 "Usage:",
33 " node scripts/release/verify-release-inventory.js [--draft] [--asset-dir DIR] [--publish] OWNER/REPO vX.Y.Z",
34 " node scripts/release/verify-release-inventory.js --manifest OWNER/REPO vX.Y.Z",
35 ].join("\n");
36 }
37
38 function ghRunner(ghBin = process.env.GH_BIN || "gh", exec = execFileSync) {
39 return (args) => {
40 try {
41 return exec(ghBin, args, {
42 encoding: "utf8",
43 maxBuffer: 20 * 1024 * 1024,
44 stdio: ["ignore", "pipe", "pipe"],
45 });
46 } catch (error) {
47 const detail = String(error && error.stderr ? error.stderr : error && error.message).trim();
48 throw new Error(`gh ${args.slice(0, 2).join(" ")} failed${detail ? `: ${detail}` : ""}`);
49 }
50 };
51 }
52
53 /** The draft (listed, since drafts are invisible on the tags endpoint) or the published release. */
54 function findRelease(repo, tag, { draft }, gh) {
55 validateTarget(repo, tag);
56 if (!draft) {
57 const release = JSON.parse(gh(["api", `repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]));
58 if (!release || release.draft) throw new Error(`GitHub Release ${tag} is not published`);
59 return release;
60 }
61 const pages = JSON.parse(gh(["api", `repos/${repo}/releases?per_page=100`, "--paginate", "--slurp"]));
62 if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) {
63 throw new Error("GitHub did not return a paginated release list");
64 }
65 const listed = pages.flat();
66 const drafts = listed.filter((release) => release && release.draft === true && release.tag_name === tag);
67 if (drafts.length !== 1) {
68 throw new Error(
69 `Expected exactly one draft release for ${tag}; found ${drafts.length}. ` +
70 "A stale draft from an earlier failed run must be reviewed and deleted by a maintainer before rerunning.",
71 );
72 }
73 return drafts[0];
74 }
75
76 /** Exact names, each fully uploaded, and matching local size and SHA-256. */
77 function assertInventory(release, tag, expectedNames, assetDir) {
78 if (!release || !Array.isArray(release.assets)) {
79 throw new Error(`GitHub Release ${tag} did not provide an asset inventory`);
80 }
81 const byName = new Map();
82 for (const asset of release.assets) {
83 if (!asset || typeof asset.name !== "string" || byName.has(asset.name)) {
84 throw new Error(`GitHub Release ${tag} has a malformed or duplicate asset entry`);
85 }
86 byName.set(asset.name, asset);
87 }
88 const expected = new Set(expectedNames);
89 const missing = expectedNames.filter((name) => !byName.has(name));
90 const unexpected = [...byName.keys()].filter((name) => !expected.has(name));
91 if (missing.length > 0 || unexpected.length > 0) {
92 throw new Error(
93 `GitHub Release ${tag} does not carry the exact asset inventory` +
94 `${missing.length > 0 ? `; missing: ${missing.join(", ")}` : ""}` +
95 `${unexpected.length > 0 ? `; unexpected: ${unexpected.join(", ")}` : ""}`,
96 );
97 }
98 for (const name of expectedNames) {
99 const asset = byName.get(name);
100 if (asset.state !== "uploaded" || !(asset.size > 0)) {
101 throw new Error(`GitHub Release ${tag} asset ${name} is not fully uploaded (state ${asset.state}, ${asset.size} bytes)`);
102 }
103 if (assetDir) {
104 const local = fs.statSync(path.join(assetDir, name)).size;
105 if (asset.size !== local) {
106 throw new Error(`GitHub Release ${tag} asset ${name} has ${asset.size} bytes; the verified local asset has ${local}`);
107 }
108 const digest = `sha256:${crypto.hash("sha256", fs.readFileSync(path.join(assetDir, name)))}`;
109 if (asset.digest !== digest) {
110 throw new Error(`GitHub Release ${tag} asset ${name} has a missing or mismatched SHA-256 digest; refusing stale draft bytes`);
111 }
112 }
113 }
114 return expectedNames.length;
115 }
116
117 /** Names the release's own checksum manifest lists, plus the manifest. */
118 function manifestInventory(repo, tag, gh) {
119 const text = gh(["release", "download", tag, "--repo", repo, "--pattern", CHECKSUM_MANIFEST, "--output", "-"]);
120 const names = [];
121 for (const line of String(text).split(/\r?\n/)) {
122 const trimmed = line.trim();
123 if (!trimmed) continue;
124 const match = trimmed.match(/^[a-fA-F0-9]{64}\s+\*?(.+)$/);
125 if (!match) throw new Error(`${CHECKSUM_MANIFEST} contains an invalid row: ${trimmed}`);
126 names.push(match[1]);
127 }
128 if (names.length === 0) throw new Error(`${CHECKSUM_MANIFEST} for ${tag} lists no assets`);
129 return [...names, CHECKSUM_MANIFEST];
130 }
131
132 function run(argv, gh = ghRunner(), log = console.log) {
133 const flags = { draft: false, publish: false, manifest: false, assetDir: null };
134 const positional = [];
135 for (let i = 0; i < argv.length; i++) {
136 const arg = argv[i];
137 if (arg === "--draft") flags.draft = true;
138 else if (arg === "--publish") flags.publish = true;
139 else if (arg === "--manifest") flags.manifest = true;
140 else if (arg === "--asset-dir") flags.assetDir = argv[++i];
141 else positional.push(arg);
142 }
143 if (positional.length !== 2 || (flags.publish && (!flags.draft || !flags.assetDir)) || (flags.manifest && (flags.draft || flags.assetDir))) {
144 throw new Error(usage());
145 }
146 const [repo, tag] = positional;
147 const release = findRelease(repo, tag, { draft: flags.draft }, gh);
148 let catalog;
149 if (!flags.manifest && release.assets.some((asset) => asset.name === compiledHosts.HOST_CATALOG)) {
150 if (!flags.assetDir) throw new Error("compiled-host release verification requires --asset-dir; verify exact local qualification/catalog bytes before publication");
151 catalog = compiledHosts.parseCatalog(fs.readFileSync(path.join(flags.assetDir, compiledHosts.HOST_CATALOG), "utf8"), tag.replace(/^v/, ""));
152 compiledHosts.verifyDirectory(flags.assetDir, catalog);
153 }
154 const expected = flags.manifest ? manifestInventory(repo, tag, gh) : allReleaseAssetNames(catalog);
155 const count = assertInventory(release, tag, expected, flags.assetDir);
156 log(`Verified ${count} assets on the ${flags.draft ? "draft" : "published"} release ${tag}`);
157 if (!flags.publish) return;
158 gh(["api", "-X", "PATCH", `repos/${repo}/releases/${release.id}`, "-F", "draft=false"]);
159 const published = findRelease(repo, tag, { draft: false }, gh);
160 if (published.id !== release.id) {
161 throw new Error(`Published release for ${tag} is ${published.id}, not the verified draft ${release.id}`);
162 }
163 assertInventory(published, tag, expected, flags.assetDir);
164 log(`Published ${tag} with its ${count} verified assets`);
165 }
166
167 if (require.main === module) {
168 try {
169 run(process.argv.slice(2));
170 } catch (error) {
171 console.error(error.message);
172 process.exit(1);
173 }
174 }
175
176 module.exports = { assertInventory, findRelease, manifestInventory, run };
177
177 lines JAVASCRIPT