| 1 | import { readFileSync, writeFileSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import { pathToFileURL } from "node:url"; |
| 4 | |
| 5 | const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; |
| 6 | const SHA_RE = /^[0-9a-f]{40}$/; |
| 7 | |
| 8 | export const npmPackageNames = [ |
| 9 | "reasonix", |
| 10 | "@reasonix/cli-darwin-arm64", |
| 11 | "@reasonix/cli-darwin-x64", |
| 12 | "@reasonix/cli-linux-arm64", |
| 13 | "@reasonix/cli-linux-x64", |
| 14 | "@reasonix/cli-win32-arm64", |
| 15 | "@reasonix/cli-win32-x64", |
| 16 | ]; |
| 17 | |
| 18 | function compareStable(a, b) { |
| 19 | if (!VERSION_RE.test(a) || !VERSION_RE.test(b)) throw new Error("invalid stable version in publication observation"); |
| 20 | const aa = a.split(".").map(Number); |
| 21 | const bb = b.split(".").map(Number); |
| 22 | for (let index = 0; index < aa.length; index += 1) { |
| 23 | if (aa[index] !== bb[index]) return aa[index] > bb[index] ? 1 : -1; |
| 24 | } |
| 25 | return 0; |
| 26 | } |
| 27 | |
| 28 | export function ownsPublicSite(version, operation, manifest) { |
| 29 | if (!["publish", "recover"].includes(operation)) throw new Error("invalid publication operation"); |
| 30 | const current = manifest?.version; |
| 31 | if (typeof current !== "string" || !current.startsWith("v")) throw new Error("missing or invalid Stable manifest version"); |
| 32 | const comparison = compareStable(current.slice(1), version); |
| 33 | if (comparison === 0) return true; |
| 34 | if (comparison > 0 && operation === "recover") return false; |
| 35 | throw new Error(`Stable manifest serves ${current}, want v${version}`); |
| 36 | } |
| 37 | |
| 38 | function requireIdentity(version, sourceSHA, operation) { |
| 39 | if (!VERSION_RE.test(version)) throw new Error("invalid publication ledger version"); |
| 40 | if (!SHA_RE.test(sourceSHA)) throw new Error("invalid publication ledger source SHA"); |
| 41 | if (!["publish", "recover"].includes(operation)) throw new Error("invalid publication operation"); |
| 42 | } |
| 43 | |
| 44 | function releaseAssets(release, surface) { |
| 45 | if (release?.isDraft !== false || release?.isPrerelease !== false || !Array.isArray(release.assets)) { |
| 46 | throw new Error(`${surface} release is not a public final release`); |
| 47 | } |
| 48 | return release.assets.map(asset => ({ |
| 49 | name: asset.name, |
| 50 | size: asset.size, |
| 51 | digest: asset.digest || null, |
| 52 | state: "identity-verified", |
| 53 | })).sort((a, b) => a.name.localeCompare(b.name)); |
| 54 | } |
| 55 | |
| 56 | export function createCoreLedger({ version, sourceSHA, operation, cliRelease, desktopRelease, npmPackages, observedAt = new Date().toISOString() }) { |
| 57 | requireIdentity(version, sourceSHA, operation); |
| 58 | if (!Array.isArray(npmPackages) || npmPackages.length !== npmPackageNames.length) { |
| 59 | throw new Error("publication ledger requires all npm packages"); |
| 60 | } |
| 61 | const packages = npmPackages.map(item => { |
| 62 | if (!npmPackageNames.includes(item.name) || item.version !== version || !item.integrity) { |
| 63 | throw new Error(`invalid npm publication observation: ${item.name ?? "<unknown>"}`); |
| 64 | } |
| 65 | if ((item.reasonixCandidateSha && item.reasonixCandidateSha !== sourceSHA) |
| 66 | || (item.gitHead && item.gitHead !== sourceSHA) |
| 67 | || (!item.reasonixCandidateSha && !item.gitHead)) { |
| 68 | throw new Error(`npm package does not match the candidate: ${item.name}`); |
| 69 | } |
| 70 | const pointerComparison = compareStable(item.latest, version); |
| 71 | if (pointerComparison < 0 || (operation === "publish" && pointerComparison !== 0)) { |
| 72 | throw new Error(`npm latest is inconsistent for ${item.name}: ${item.latest}`); |
| 73 | } |
| 74 | return { |
| 75 | name: item.name, |
| 76 | version: item.version, |
| 77 | integrity: item.integrity, |
| 78 | latest: item.latest, |
| 79 | state: "identity-verified", |
| 80 | pointerState: pointerComparison === 0 ? "public-entry-updated" : "newer-entry-preserved", |
| 81 | }; |
| 82 | }).sort((a, b) => a.name.localeCompare(b.name)); |
| 83 | if (new Set(packages.map(item => item.name)).size !== npmPackageNames.length) { |
| 84 | throw new Error("publication ledger contains duplicate npm packages"); |
| 85 | } |
| 86 | for (const name of npmPackageNames) { |
| 87 | if (!packages.some(item => item.name === name)) throw new Error(`publication ledger is missing npm package: ${name}`); |
| 88 | } |
| 89 | return { |
| 90 | schema: 1, |
| 91 | version, |
| 92 | sourceSHA, |
| 93 | operation, |
| 94 | observedAt, |
| 95 | surfaces: { |
| 96 | tags: { |
| 97 | state: "identity-verified", |
| 98 | items: [`v${version}`, `npm-v${version}`, `desktop-v${version}`].map(name => ({ name, sha: sourceSHA })), |
| 99 | }, |
| 100 | cli: { state: "identity-verified", assets: releaseAssets(cliRelease, "CLI") }, |
| 101 | npm: { state: "identity-verified", packages }, |
| 102 | desktop: { state: "identity-verified", assets: releaseAssets(desktopRelease, "Desktop") }, |
| 103 | }, |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | export function createSiteLedger({ version, sourceSHA, operation, manifest, observedAt = new Date().toISOString() }) { |
| 108 | requireIdentity(version, sourceSHA, operation); |
| 109 | if (manifest?.version !== `v${version}`) throw new Error("Stable manifest does not match the publication ledger"); |
| 110 | return { |
| 111 | schema: 1, |
| 112 | version, |
| 113 | sourceSHA, |
| 114 | operation, |
| 115 | observedAt, |
| 116 | surfaces: { |
| 117 | stableManifest: { state: "public-entry-updated", version: manifest.version }, |
| 118 | homepage: { state: "public-entry-updated", version: `v${version}` }, |
| 119 | changelog: { state: "public-entry-updated", version: `v${version}` }, |
| 120 | homebrew: { state: "public-entry-updated", version }, |
| 121 | }, |
| 122 | }; |
| 123 | } |
| 124 | |
| 125 | export function mergeLedgers(core, site, observedAt = new Date().toISOString()) { |
| 126 | if (core.schema !== 1 || site.schema !== 1 || core.version !== site.version |
| 127 | || core.sourceSHA !== site.sourceSHA || core.operation !== site.operation) { |
| 128 | throw new Error("publication ledger fragments do not describe one release"); |
| 129 | } |
| 130 | return { ...core, observedAt, surfaces: { ...core.surfaces, ...site.surfaces } }; |
| 131 | } |
| 132 | |
| 133 | function read(file) { |
| 134 | return JSON.parse(readFileSync(file, "utf8")); |
| 135 | } |
| 136 | |
| 137 | if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { |
| 138 | const [command, ...args] = process.argv.slice(2); |
| 139 | if (command === "site-owner" && args.length === 3) { |
| 140 | const [version, operation, manifestPath] = args; |
| 141 | console.log(ownsPublicSite(version, operation, read(manifestPath))); |
| 142 | } else if (command === "core" && args.length === 7) { |
| 143 | const [version, sourceSHA, operation, cliPath, desktopPath, npmPath, output] = args; |
| 144 | writeFileSync(output, `${JSON.stringify(createCoreLedger({ version, sourceSHA, operation, cliRelease: read(cliPath), desktopRelease: read(desktopPath), npmPackages: read(npmPath) }), null, 2)}\n`); |
| 145 | } else if (command === "site" && args.length === 5) { |
| 146 | const [version, sourceSHA, operation, manifestPath, output] = args; |
| 147 | writeFileSync(output, `${JSON.stringify(createSiteLedger({ version, sourceSHA, operation, manifest: read(manifestPath) }), null, 2)}\n`); |
| 148 | } else if (command === "merge" && args.length === 3) { |
| 149 | const [corePath, sitePath, output] = args; |
| 150 | writeFileSync(output, `${JSON.stringify(mergeLedgers(read(corePath), read(sitePath)), null, 2)}\n`); |
| 151 | } else { |
| 152 | throw new Error("usage: release-publication-ledger.mjs core VERSION SHA OPERATION CLI DESKTOP NPM OUTPUT | site VERSION SHA OPERATION MANIFEST OUTPUT | merge CORE SITE OUTPUT"); |
| 153 | } |
| 154 | } |
| 155 |