| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { readFile, writeFile } from "node:fs/promises"; |
| 4 | import { resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { loadCatalog, releaseForVersion } from "./release-notes.mjs"; |
| 7 | |
| 8 | function invariant(condition, message) { |
| 9 | if (!condition) throw new Error(message); |
| 10 | } |
| 11 | |
| 12 | function parseArgs(argv) { |
| 13 | const [command = "validate", ...rest] = argv; |
| 14 | const values = { command }; |
| 15 | for (let index = 0; index < rest.length; index += 1) { |
| 16 | const arg = rest[index]; |
| 17 | if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`); |
| 18 | values[arg.slice(2)] = rest[++index]; |
| 19 | } |
| 20 | return values; |
| 21 | } |
| 22 | |
| 23 | export function validateReleaseEvent(event, release) { |
| 24 | invariant(event && typeof event === "object" && !Array.isArray(event), "release event must be an object"); |
| 25 | invariant(event.schemaVersion === 1, "release event schemaVersion must be 1"); |
| 26 | invariant(event.releaseId === release.version, "release event releaseId does not match reviewed notes"); |
| 27 | invariant( |
| 28 | event.channel === (release.channel === "prerelease" ? "preview" : "stable"), |
| 29 | "release event channel does not match reviewed notes", |
| 30 | ); |
| 31 | invariant(/^[0-9a-f]{40}$/.test(event.candidateSha), "release event candidateSha must be a full commit SHA"); |
| 32 | if (release.candidateSha) { |
| 33 | invariant(event.candidateSha === release.candidateSha, "release event candidateSha does not match reviewed notes"); |
| 34 | } |
| 35 | invariant(/^\d{4}-\d{2}-\d{2}T/.test(event.publishedAt), "release event publishedAt must be an ISO timestamp"); |
| 36 | invariant(event.releaseNotesUrl === `https://reasonix.io/changelog/v${release.version}/`, "release event URL is invalid"); |
| 37 | invariant(event.builds && typeof event.builds === "object", "release event builds are required"); |
| 38 | for (const surface of ["cli", "desktop", "npm"]) { |
| 39 | invariant(typeof event.builds[surface] === "string" && event.builds[surface], `release event builds.${surface} is required`); |
| 40 | if (release.builds?.[surface]) { |
| 41 | invariant(event.builds[surface] === release.builds[surface], `release event builds.${surface} does not match reviewed notes`); |
| 42 | } |
| 43 | } |
| 44 | return event; |
| 45 | } |
| 46 | |
| 47 | async function main() { |
| 48 | const args = parseArgs(process.argv.slice(2)); |
| 49 | invariant(args.version, "--version is required"); |
| 50 | const catalog = await loadCatalog(args.catalog ? resolve(args.catalog) : undefined); |
| 51 | const release = releaseForVersion(catalog, args.version); |
| 52 | |
| 53 | if (args.command === "generate") { |
| 54 | invariant(args.sha, "generate requires --sha"); |
| 55 | invariant(args.output, "generate requires --output"); |
| 56 | const event = validateReleaseEvent({ |
| 57 | schemaVersion: 1, |
| 58 | releaseId: release.version, |
| 59 | channel: release.channel === "prerelease" ? "preview" : "stable", |
| 60 | candidateSha: args.sha, |
| 61 | publishedAt: args["published-at"] || new Date().toISOString(), |
| 62 | releaseNotesUrl: `https://reasonix.io/changelog/v${release.version}/`, |
| 63 | builds: release.builds, |
| 64 | }, release); |
| 65 | await writeFile(resolve(args.output), `${JSON.stringify(event, null, 2)}\n`); |
| 66 | return; |
| 67 | } |
| 68 | |
| 69 | invariant(args.command === "validate", `unknown command ${args.command}`); |
| 70 | invariant(args.input, "validate requires --input"); |
| 71 | const event = JSON.parse(await readFile(resolve(args.input), "utf8")); |
| 72 | validateReleaseEvent(event, release); |
| 73 | } |
| 74 | |
| 75 | if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { |
| 76 | main().catch((error) => { |
| 77 | console.error(error.message); |
| 78 | process.exitCode = 1; |
| 79 | }); |
| 80 | } |
| 81 |