| 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 |