| 1 | import { createHash } from "node:crypto"; |
| 2 | import { |
| 3 | lstatSync, |
| 4 | mkdirSync, |
| 5 | readFileSync, |
| 6 | readdirSync, |
| 7 | statSync, |
| 8 | writeFileSync, |
| 9 | } from "node:fs"; |
| 10 | import path from "node:path"; |
| 11 | import { pathToFileURL } from "node:url"; |
| 12 | |
| 13 | const SHA_RE = /^[0-9a-f]{40}$/; |
| 14 | const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; |
| 15 | const CANDIDATE_RE = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-[0-9a-f]{12}-[0-9a-f]{12}$/; |
| 16 | export const desktopPlatforms = [ |
| 17 | "darwin-arm64", "darwin-amd64", "darwin-universal", |
| 18 | "windows-amd64", "windows-arm64", "linux-amd64", |
| 19 | ]; |
| 20 | export const npmPackages = [ |
| 21 | "reasonix", "reasonix-cli-darwin-arm64", "reasonix-cli-darwin-x64", |
| 22 | "reasonix-cli-linux-arm64", "reasonix-cli-linux-x64", |
| 23 | "reasonix-cli-win32-arm64", "reasonix-cli-win32-x64", |
| 24 | ]; |
| 25 | |
| 26 | export function artifactNamespace(purpose = "release") { |
| 27 | if (!["release", "rehearsal"].includes(purpose)) throw new Error("invalid candidate purpose"); |
| 28 | return purpose === "rehearsal" ? "release-candidate-rehearsal" : "release-candidate"; |
| 29 | } |
| 30 | |
| 31 | function sha256(data) { |
| 32 | return createHash("sha256").update(data).digest("hex"); |
| 33 | } |
| 34 | |
| 35 | function fileEntry(root, file) { |
| 36 | const absolute = path.join(root, file); |
| 37 | const stat = lstatSync(absolute); |
| 38 | if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { |
| 39 | throw new Error(`candidate contains an invalid file: ${file}`); |
| 40 | } |
| 41 | return { path: file.split(path.sep).join("/"), size: stat.size, sha256: sha256(readFileSync(absolute)) }; |
| 42 | } |
| 43 | |
| 44 | function walk(root, directory = root) { |
| 45 | const files = []; |
| 46 | for (const name of readdirSync(directory).sort()) { |
| 47 | const absolute = path.join(directory, name); |
| 48 | const stat = lstatSync(absolute); |
| 49 | if (stat.isSymbolicLink()) throw new Error(`candidate contains a symlink: ${path.relative(root, absolute)}`); |
| 50 | if (stat.isDirectory()) files.push(...walk(root, absolute)); |
| 51 | else files.push(fileEntry(root, path.relative(root, absolute))); |
| 52 | } |
| 53 | return files; |
| 54 | } |
| 55 | |
| 56 | function requireIdentity(metadata) { |
| 57 | const namespace = artifactNamespace(metadata.purpose); |
| 58 | if (!VERSION_RE.test(metadata.version ?? "")) throw new Error("invalid candidate version"); |
| 59 | for (const key of ["sourceSHA", "buildControlSHA", "acceptanceControlSHA", "notesSourceSHA"]) { |
| 60 | if (!SHA_RE.test(metadata[key] ?? "")) throw new Error(`invalid ${key}`); |
| 61 | } |
| 62 | if (!/^[0-9a-f]{64}$/.test(metadata.catalogSha256 ?? "")) throw new Error("invalid catalogSha256"); |
| 63 | if (!/^[0-9a-f]{64}$/.test(metadata.renderedNotesSha256 ?? "")) throw new Error("invalid renderedNotesSha256"); |
| 64 | if (!/^[1-9][0-9]*$/.test(String(metadata.runId ?? ""))) throw new Error("invalid runId"); |
| 65 | if (!/^[1-9][0-9]*$/.test(String(metadata.runAttempt ?? ""))) throw new Error("invalid runAttempt"); |
| 66 | if (!/^[1-9][0-9]*$/.test(String(metadata.payloadArtifactId ?? ""))) throw new Error("invalid payloadArtifactId"); |
| 67 | if (!/^[1-9][0-9]*$/.test(String(metadata.evidenceArtifactId ?? ""))) throw new Error("invalid evidenceArtifactId"); |
| 68 | if (metadata.payloadArtifactName !== `${namespace}-payload-${metadata.candidateId ?? candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256)}`) { |
| 69 | throw new Error("invalid payloadArtifactName"); |
| 70 | } |
| 71 | if (metadata.evidenceArtifactName !== `${namespace}-evidence-${metadata.candidateId ?? candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256)}`) { |
| 72 | throw new Error("invalid evidenceArtifactName"); |
| 73 | } |
| 74 | if (metadata.repository !== "esengine/DeepSeek-Reasonix") throw new Error("untrusted candidate repository"); |
| 75 | if (metadata.workflow !== ".github/workflows/release-candidate.yml") throw new Error("untrusted candidate workflow"); |
| 76 | } |
| 77 | |
| 78 | export function candidateId(version, sourceSHA, catalogSha256) { |
| 79 | if (!VERSION_RE.test(version) || !SHA_RE.test(sourceSHA) || !/^[0-9a-f]{64}$/.test(catalogSha256)) { |
| 80 | throw new Error("cannot derive candidate id from invalid identity"); |
| 81 | } |
| 82 | return `v${version}-${sourceSHA.slice(0, 12)}-${catalogSha256.slice(0, 12)}`; |
| 83 | } |
| 84 | |
| 85 | function requirePayloadLayout(root, files) { |
| 86 | const names = new Set(files.map(file => file.path)); |
| 87 | for (const platform of desktopPlatforms) { |
| 88 | const prefix = `desktop/${platform}/`; |
| 89 | if (![...names].some(name => name.startsWith(prefix) && name.endsWith("/identity.json"))) { |
| 90 | throw new Error(`candidate is missing Desktop bundle: ${platform}`); |
| 91 | } |
| 92 | } |
| 93 | const cli = [ |
| 94 | "reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz", |
| 95 | "reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz", |
| 96 | "reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip", "SHA256SUMS", |
| 97 | "reasonix.rb", |
| 98 | ]; |
| 99 | for (const name of cli) if (!names.has(`cli/${name}`)) throw new Error(`candidate is missing CLI asset: ${name}`); |
| 100 | const tarballs = [...names].filter(name => name.startsWith("npm/") && name.endsWith(".tgz")); |
| 101 | if (tarballs.length !== npmPackages.length) throw new Error(`candidate has ${tarballs.length} npm packages; expected ${npmPackages.length}`); |
| 102 | for (const name of npmPackages) { |
| 103 | if (!tarballs.some(file => path.basename(file).startsWith(`${name}-`))) throw new Error(`candidate is missing npm package: ${name}`); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | function desktopIdentity(payloadRoot, platform) { |
| 108 | const identityPath = path.join(payloadRoot, "desktop", platform, "identity.json"); |
| 109 | const identity = JSON.parse(readFileSync(identityPath, "utf8")); |
| 110 | if (identity.schema !== 1 || identity.platform !== platform) { |
| 111 | throw new Error(`candidate has an invalid Desktop identity: ${platform}`); |
| 112 | } |
| 113 | return identity; |
| 114 | } |
| 115 | |
| 116 | function requireDesktopIdentities(payloadRoot, metadata) { |
| 117 | for (const platform of desktopPlatforms) { |
| 118 | const identity = desktopIdentity(payloadRoot, platform); |
| 119 | const expected = { |
| 120 | sourceSHA: metadata.sourceSHA, |
| 121 | controlSHA: metadata.buildControlSHA, |
| 122 | tag: `desktop-v${metadata.version}`, |
| 123 | version: `v${metadata.version}`, |
| 124 | channel: "stable", |
| 125 | signingFingerprint: metadata.desktopFingerprint, |
| 126 | prefix: metadata.desktopPrefix, |
| 127 | }; |
| 128 | for (const [key, value] of Object.entries(expected)) { |
| 129 | if (identity[key] !== value) { |
| 130 | throw new Error(`candidate Desktop identity mismatch for ${platform}: ${key}`); |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | function requireAcceptanceReceipt(payloadRoot, files, metadata, item) { |
| 137 | const evidence = files.find(file => file.path === item.evidencePath); |
| 138 | if (item.status !== "passed" || !evidence) { |
| 139 | throw new Error(`candidate acceptance evidence is missing: ${item.kind}`); |
| 140 | } |
| 141 | const receipt = JSON.parse(readFileSync(path.join(payloadRoot, item.evidencePath), "utf8")); |
| 142 | if (receipt.schema !== 1 || receipt.kind !== item.kind || receipt.status !== "passed" |
| 143 | || receipt.version !== `v${metadata.version}` || !/^[0-9a-f]{64}$/.test(receipt.sha256 ?? "")) { |
| 144 | throw new Error(`candidate acceptance receipt is invalid: ${item.kind}`); |
| 145 | } |
| 146 | const platform = item.kind === "macos-universal-intel" ? "darwin-universal" : item.kind; |
| 147 | const identity = desktopIdentity(payloadRoot, platform); |
| 148 | const artifact = identity.files.find(file => file.sha256 === receipt.sha256); |
| 149 | if (!artifact) throw new Error(`candidate acceptance receipt does not name a sealed file: ${item.kind}`); |
| 150 | return { ...item, evidenceSha256: evidence.sha256, artifactSha256: receipt.sha256 }; |
| 151 | } |
| 152 | |
| 153 | export function sealCandidate(payloadRoot, metadata) { |
| 154 | requireIdentity(metadata); |
| 155 | const files = walk(payloadRoot); |
| 156 | requirePayloadLayout(payloadRoot, files); |
| 157 | requireDesktopIdentities(payloadRoot, metadata); |
| 158 | const id = candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256); |
| 159 | const created = new Date(metadata.createdAt); |
| 160 | const expires = new Date(metadata.expiresAt); |
| 161 | if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created) { |
| 162 | throw new Error("invalid candidate validity window"); |
| 163 | } |
| 164 | const acceptance = metadata.acceptance.map(item => requireAcceptanceReceipt(payloadRoot, files, metadata, item)); |
| 165 | return { |
| 166 | schema: 1, |
| 167 | purpose: metadata.purpose ?? "release", |
| 168 | candidateId: id, |
| 169 | policyVersion: 1, |
| 170 | version: metadata.version, |
| 171 | sourceSHA: metadata.sourceSHA, |
| 172 | control: { buildSHA: metadata.buildControlSHA, acceptanceSHA: metadata.acceptanceControlSHA }, |
| 173 | notes: { |
| 174 | sourceSHA: metadata.notesSourceSHA, |
| 175 | catalogSha256: metadata.catalogSha256, |
| 176 | renderedSha256: metadata.renderedNotesSha256, |
| 177 | }, |
| 178 | source: { |
| 179 | repository: metadata.repository, |
| 180 | workflow: metadata.workflow, |
| 181 | runId: String(metadata.runId), |
| 182 | runAttempt: String(metadata.runAttempt), |
| 183 | desktopPrefix: metadata.desktopPrefix, |
| 184 | payloadArtifactId: String(metadata.payloadArtifactId), |
| 185 | payloadArtifactName: metadata.payloadArtifactName, |
| 186 | evidenceArtifactId: String(metadata.evidenceArtifactId), |
| 187 | evidenceArtifactName: metadata.evidenceArtifactName, |
| 188 | }, |
| 189 | signing: { desktopFingerprint: metadata.desktopFingerprint }, |
| 190 | acceptance, |
| 191 | validity: { createdAt: created.toISOString(), expiresAt: expires.toISOString(), revoked: false }, |
| 192 | files, |
| 193 | }; |
| 194 | } |
| 195 | |
| 196 | export function verifyCandidate(payloadRoot, record, now = new Date(), purpose = "release") { |
| 197 | artifactNamespace(purpose); |
| 198 | if ((record.purpose ?? "release") !== purpose) throw new Error("candidate purpose mismatch; rehearsal cannot be published"); |
| 199 | if (record.schema !== 1 || record.policyVersion !== 1) throw new Error("unsupported candidate schema"); |
| 200 | requireIdentity({ |
| 201 | purpose: record.purpose, |
| 202 | version: record.version, |
| 203 | sourceSHA: record.sourceSHA, |
| 204 | buildControlSHA: record.control?.buildSHA, |
| 205 | acceptanceControlSHA: record.control?.acceptanceSHA, |
| 206 | notesSourceSHA: record.notes?.sourceSHA, |
| 207 | catalogSha256: record.notes?.catalogSha256, |
| 208 | renderedNotesSha256: record.notes?.renderedSha256, |
| 209 | repository: record.source?.repository, |
| 210 | workflow: record.source?.workflow, |
| 211 | runId: record.source?.runId, |
| 212 | runAttempt: record.source?.runAttempt, |
| 213 | payloadArtifactId: record.source?.payloadArtifactId, |
| 214 | payloadArtifactName: record.source?.payloadArtifactName, |
| 215 | evidenceArtifactId: record.source?.evidenceArtifactId, |
| 216 | evidenceArtifactName: record.source?.evidenceArtifactName, |
| 217 | candidateId: record.candidateId, |
| 218 | }); |
| 219 | if (!CANDIDATE_RE.test(record.candidateId) || record.candidateId !== candidateId(record.version, record.sourceSHA, record.notes.catalogSha256)) { |
| 220 | throw new Error("candidate id does not match its immutable inputs"); |
| 221 | } |
| 222 | if (record.validity?.revoked !== false) throw new Error("candidate is revoked"); |
| 223 | if (new Date(record.validity?.expiresAt).valueOf() <= now.valueOf()) throw new Error("candidate has expired"); |
| 224 | const actual = walk(payloadRoot); |
| 225 | requirePayloadLayout(payloadRoot, actual); |
| 226 | if (JSON.stringify(actual) !== JSON.stringify(record.files)) throw new Error("candidate payload digest mismatch"); |
| 227 | requireDesktopIdentities(payloadRoot, { |
| 228 | version: record.version, |
| 229 | sourceSHA: record.sourceSHA, |
| 230 | buildControlSHA: record.control.buildSHA, |
| 231 | desktopFingerprint: record.signing.desktopFingerprint, |
| 232 | desktopPrefix: record.source.desktopPrefix, |
| 233 | }); |
| 234 | const passed = new Set((record.acceptance ?? []).filter(item => item.status === "passed").map(item => item.kind)); |
| 235 | for (const required of ["windows-amd64", "windows-arm64", "macos-universal-intel"]) { |
| 236 | if (!passed.has(required)) throw new Error(`candidate is missing acceptance evidence: ${required}`); |
| 237 | } |
| 238 | for (const item of record.acceptance) { |
| 239 | const evidence = actual.find(file => file.path === item.evidencePath); |
| 240 | if (!evidence || evidence.sha256 !== item.evidenceSha256) throw new Error(`candidate acceptance receipt mismatch: ${item.kind}`); |
| 241 | const validated = requireAcceptanceReceipt(payloadRoot, actual, { |
| 242 | version: record.version, |
| 243 | }, item); |
| 244 | if (validated.artifactSha256 !== item.artifactSha256) { |
| 245 | throw new Error(`candidate acceptance artifact mismatch: ${item.kind}`); |
| 246 | } |
| 247 | } |
| 248 | return record; |
| 249 | } |
| 250 | |
| 251 | function parseMetadata(env) { |
| 252 | const acceptance = JSON.parse(env.RELEASE_ACCEPTANCE_JSON ?? "[]"); |
| 253 | return { |
| 254 | purpose: env.RELEASE_CANDIDATE_PURPOSE ?? "release", |
| 255 | version: env.RELEASE_VERSION, |
| 256 | sourceSHA: env.RELEASE_SOURCE_SHA, |
| 257 | buildControlSHA: env.RELEASE_BUILD_CONTROL_SHA, |
| 258 | acceptanceControlSHA: env.RELEASE_ACCEPTANCE_CONTROL_SHA, |
| 259 | notesSourceSHA: env.RELEASE_NOTES_SOURCE_SHA, |
| 260 | catalogSha256: env.RELEASE_CATALOG_SHA256, |
| 261 | renderedNotesSha256: env.RELEASE_RENDERED_NOTES_SHA256, |
| 262 | repository: env.GITHUB_REPOSITORY, |
| 263 | workflow: env.RELEASE_WORKFLOW, |
| 264 | runId: env.GITHUB_RUN_ID, |
| 265 | runAttempt: env.GITHUB_RUN_ATTEMPT, |
| 266 | payloadArtifactId: env.RELEASE_PAYLOAD_ARTIFACT_ID, |
| 267 | payloadArtifactName: env.RELEASE_PAYLOAD_ARTIFACT_NAME, |
| 268 | evidenceArtifactId: env.RELEASE_EVIDENCE_ARTIFACT_ID, |
| 269 | evidenceArtifactName: env.RELEASE_EVIDENCE_ARTIFACT_NAME, |
| 270 | desktopPrefix: env.RELEASE_DESKTOP_PREFIX, |
| 271 | desktopFingerprint: env.RELEASE_DESKTOP_FINGERPRINT, |
| 272 | createdAt: env.RELEASE_CREATED_AT, |
| 273 | expiresAt: env.RELEASE_EXPIRES_AT, |
| 274 | acceptance, |
| 275 | }; |
| 276 | } |
| 277 | |
| 278 | if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { |
| 279 | const [command, payload, recordPath, catalogSha256] = process.argv.slice(2); |
| 280 | if (command === "seal") { |
| 281 | mkdirSync(path.dirname(recordPath), { recursive: true }); |
| 282 | writeFileSync(recordPath, `${JSON.stringify(sealCandidate(payload, parseMetadata(process.env)), null, 2)}\n`); |
| 283 | } else if (command === "verify" || command === "verify-rehearsal") { |
| 284 | verifyCandidate(payload, JSON.parse(readFileSync(recordPath, "utf8")), new Date(), command === "verify-rehearsal" ? "rehearsal" : "release"); |
| 285 | process.stdout.write(`${JSON.stringify(JSON.parse(readFileSync(recordPath, "utf8")))}\n`); |
| 286 | } else if (command === "id") { |
| 287 | process.stdout.write(`${candidateId(payload, recordPath, catalogSha256)}\n`); |
| 288 | } else { |
| 289 | throw new Error("usage: release-candidate.mjs seal|verify|verify-rehearsal PAYLOAD RECORD | id VERSION SOURCE_SHA CATALOG_SHA256"); |
| 290 | } |
| 291 | } |
| 292 |