| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | const { execFileSync } = require("node:child_process"); |
| 4 | |
| 5 | function usage() { |
| 6 | return "Usage: node scripts/release/ensure-release-assets-absent.js OWNER/REPO vX.Y.Z"; |
| 7 | } |
| 8 | |
| 9 | function validateTarget(repo, tag) { |
| 10 | if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { |
| 11 | throw new Error(`Invalid GitHub repository: ${repo}`); |
| 12 | } |
| 13 | if (!/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) { |
| 14 | throw new Error(`Invalid release tag: ${tag}`); |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | function isNotFoundError(error) { |
| 19 | return ( |
| 20 | error && |
| 21 | error.status !== 0 && |
| 22 | /\bHTTP 404\b/.test(String(error.stderr || "")) |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | function fetchRelease(repo, tag, ghBin = process.env.GH_BIN || "gh", exec = execFileSync) { |
| 27 | validateTarget(repo, tag); |
| 28 | const endpoint = `repos/${repo}/releases/tags/${encodeURIComponent(tag)}`; |
| 29 | let output; |
| 30 | try { |
| 31 | output = exec(ghBin, ["api", endpoint], { |
| 32 | encoding: "utf8", |
| 33 | maxBuffer: 10 * 1024 * 1024, |
| 34 | stdio: ["ignore", "pipe", "pipe"], |
| 35 | }); |
| 36 | } catch (error) { |
| 37 | if (isNotFoundError(error)) { |
| 38 | return null; |
| 39 | } |
| 40 | const detail = String(error && error.stderr ? error.stderr : "").trim(); |
| 41 | throw new Error( |
| 42 | `Could not inspect GitHub Release ${tag}${detail ? `: ${detail}` : ""}`, |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | try { |
| 47 | return JSON.parse(output); |
| 48 | } catch (error) { |
| 49 | throw new Error(`GitHub Release ${tag} returned invalid JSON: ${error.message}`); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | function assertReleaseAssetsAbsent(release, tag) { |
| 54 | if (release === null) { |
| 55 | return; |
| 56 | } |
| 57 | if (!release || !Array.isArray(release.assets)) { |
| 58 | throw new Error(`GitHub Release ${tag} did not provide an asset inventory`); |
| 59 | } |
| 60 | if (release.assets.length === 0) { |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | const names = release.assets.map((asset) => asset && asset.name).filter(Boolean); |
| 65 | const inventory = names.length > 0 ? names.join(", ") : `${release.assets.length} unnamed asset(s)`; |
| 66 | throw new Error( |
| 67 | `Refusing to replace existing assets for ${tag}: ${inventory}. ` + |
| 68 | "A normal Release workflow rerun must never delete or overwrite public bytes.", |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | function main() { |
| 73 | if (process.argv.length !== 4) { |
| 74 | throw new Error(usage()); |
| 75 | } |
| 76 | const repo = process.argv[2]; |
| 77 | const tag = process.argv[3]; |
| 78 | const release = fetchRelease(repo, tag); |
| 79 | assertReleaseAssetsAbsent(release, tag); |
| 80 | console.log( |
| 81 | release === null |
| 82 | ? `No existing GitHub Release assets found for ${tag}.` |
| 83 | : `Existing GitHub Release ${tag} has no assets; first upload may proceed.`, |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | if (require.main === module) { |
| 88 | try { |
| 89 | main(); |
| 90 | } catch (error) { |
| 91 | console.error(`Release immutability check failed: ${error.message}`); |
| 92 | process.exit(1); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | module.exports = { |
| 97 | assertReleaseAssetsAbsent, |
| 98 | fetchRelease, |
| 99 | isNotFoundError, |
| 100 | validateTarget, |
| 101 | }; |
| 102 |