返回 DeepSeek-Reasonix
release-candidate.mjs
根目录 / scripts / release-candidate.mjs
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 // The notes are rendered once, at seal, because rendering reads GitHub: a later
154 // render can differ (a renamed author, a newly linked fix) or fail outright.
155 export const sealedNotesPath = "evidence/release-notes.md";
156
157 function requireSealedNotes(files, renderedSha256) {
158 const notes = files.find(file => file.path === sealedNotesPath);
159 if (!notes) throw new Error(`candidate is missing sealed release notes: ${sealedNotesPath}`);
160 if (notes.sha256 !== renderedSha256) throw new Error("sealed release notes do not match renderedSha256");
161 }
162
163 export function sealCandidate(payloadRoot, metadata) {
164 requireIdentity(metadata);
165 const files = walk(payloadRoot);
166 requirePayloadLayout(payloadRoot, files);
167 requireDesktopIdentities(payloadRoot, metadata);
168 const id = candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256);
169 const created = new Date(metadata.createdAt);
170 const expires = new Date(metadata.expiresAt);
171 if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created) {
172 throw new Error("invalid candidate validity window");
173 }
174 const acceptance = metadata.acceptance.map(item => requireAcceptanceReceipt(payloadRoot, files, metadata, item));
175 requireSealedNotes(files, metadata.renderedNotesSha256);
176 return {
177 schema: 1,
178 purpose: metadata.purpose ?? "release",
179 candidateId: id,
180 policyVersion: 1,
181 version: metadata.version,
182 sourceSHA: metadata.sourceSHA,
183 control: { buildSHA: metadata.buildControlSHA, acceptanceSHA: metadata.acceptanceControlSHA },
184 notes: {
185 sourceSHA: metadata.notesSourceSHA,
186 catalogSha256: metadata.catalogSha256,
187 renderedSha256: metadata.renderedNotesSha256,
188 renderedPath: sealedNotesPath,
189 },
190 source: {
191 repository: metadata.repository,
192 workflow: metadata.workflow,
193 runId: String(metadata.runId),
194 runAttempt: String(metadata.runAttempt),
195 desktopPrefix: metadata.desktopPrefix,
196 payloadArtifactId: String(metadata.payloadArtifactId),
197 payloadArtifactName: metadata.payloadArtifactName,
198 evidenceArtifactId: String(metadata.evidenceArtifactId),
199 evidenceArtifactName: metadata.evidenceArtifactName,
200 },
201 signing: { desktopFingerprint: metadata.desktopFingerprint },
202 acceptance,
203 validity: { createdAt: created.toISOString(), expiresAt: expires.toISOString(), revoked: false },
204 files,
205 };
206 }
207
208 export function verifyCandidate(payloadRoot, record, now = new Date(), purpose = "release") {
209 artifactNamespace(purpose);
210 if ((record.purpose ?? "release") !== purpose) throw new Error("candidate purpose mismatch; rehearsal cannot be published");
211 if (record.schema !== 1 || record.policyVersion !== 1) throw new Error("unsupported candidate schema");
212 requireIdentity({
213 purpose: record.purpose,
214 version: record.version,
215 sourceSHA: record.sourceSHA,
216 buildControlSHA: record.control?.buildSHA,
217 acceptanceControlSHA: record.control?.acceptanceSHA,
218 notesSourceSHA: record.notes?.sourceSHA,
219 catalogSha256: record.notes?.catalogSha256,
220 renderedNotesSha256: record.notes?.renderedSha256,
221 repository: record.source?.repository,
222 workflow: record.source?.workflow,
223 runId: record.source?.runId,
224 runAttempt: record.source?.runAttempt,
225 payloadArtifactId: record.source?.payloadArtifactId,
226 payloadArtifactName: record.source?.payloadArtifactName,
227 evidenceArtifactId: record.source?.evidenceArtifactId,
228 evidenceArtifactName: record.source?.evidenceArtifactName,
229 candidateId: record.candidateId,
230 });
231 if (!CANDIDATE_RE.test(record.candidateId) || record.candidateId !== candidateId(record.version, record.sourceSHA, record.notes.catalogSha256)) {
232 throw new Error("candidate id does not match its immutable inputs");
233 }
234 if (record.validity?.revoked !== false) throw new Error("candidate is revoked");
235 if (new Date(record.validity?.expiresAt).valueOf() <= now.valueOf()) throw new Error("candidate has expired");
236 const actual = walk(payloadRoot);
237 requirePayloadLayout(payloadRoot, actual);
238 if (JSON.stringify(actual) !== JSON.stringify(record.files)) throw new Error("candidate payload digest mismatch");
239 if (record.notes.renderedPath !== undefined) {
240 if (record.notes.renderedPath !== sealedNotesPath) throw new Error("invalid sealed release notes path");
241 requireSealedNotes(actual, record.notes.renderedSha256);
242 }
243 requireDesktopIdentities(payloadRoot, {
244 version: record.version,
245 sourceSHA: record.sourceSHA,
246 buildControlSHA: record.control.buildSHA,
247 desktopFingerprint: record.signing.desktopFingerprint,
248 desktopPrefix: record.source.desktopPrefix,
249 });
250 const passed = new Set((record.acceptance ?? []).filter(item => item.status === "passed").map(item => item.kind));
251 for (const required of ["windows-amd64", "windows-arm64", "macos-universal-intel"]) {
252 if (!passed.has(required)) throw new Error(`candidate is missing acceptance evidence: ${required}`);
253 }
254 for (const item of record.acceptance) {
255 const evidence = actual.find(file => file.path === item.evidencePath);
256 if (!evidence || evidence.sha256 !== item.evidenceSha256) throw new Error(`candidate acceptance receipt mismatch: ${item.kind}`);
257 const validated = requireAcceptanceReceipt(payloadRoot, actual, {
258 version: record.version,
259 }, item);
260 if (validated.artifactSha256 !== item.artifactSha256) {
261 throw new Error(`candidate acceptance artifact mismatch: ${item.kind}`);
262 }
263 }
264 return record;
265 }
266
267 function parseMetadata(env) {
268 const acceptance = JSON.parse(env.RELEASE_ACCEPTANCE_JSON ?? "[]");
269 return {
270 purpose: env.RELEASE_CANDIDATE_PURPOSE ?? "release",
271 version: env.RELEASE_VERSION,
272 sourceSHA: env.RELEASE_SOURCE_SHA,
273 buildControlSHA: env.RELEASE_BUILD_CONTROL_SHA,
274 acceptanceControlSHA: env.RELEASE_ACCEPTANCE_CONTROL_SHA,
275 notesSourceSHA: env.RELEASE_NOTES_SOURCE_SHA,
276 catalogSha256: env.RELEASE_CATALOG_SHA256,
277 renderedNotesSha256: env.RELEASE_RENDERED_NOTES_SHA256,
278 repository: env.GITHUB_REPOSITORY,
279 workflow: env.RELEASE_WORKFLOW,
280 runId: env.GITHUB_RUN_ID,
281 runAttempt: env.GITHUB_RUN_ATTEMPT,
282 payloadArtifactId: env.RELEASE_PAYLOAD_ARTIFACT_ID,
283 payloadArtifactName: env.RELEASE_PAYLOAD_ARTIFACT_NAME,
284 evidenceArtifactId: env.RELEASE_EVIDENCE_ARTIFACT_ID,
285 evidenceArtifactName: env.RELEASE_EVIDENCE_ARTIFACT_NAME,
286 desktopPrefix: env.RELEASE_DESKTOP_PREFIX,
287 desktopFingerprint: env.RELEASE_DESKTOP_FINGERPRINT,
288 createdAt: env.RELEASE_CREATED_AT,
289 expiresAt: env.RELEASE_EXPIRES_AT,
290 acceptance,
291 };
292 }
293
294 if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
295 const [command, payload, recordPath, catalogSha256] = process.argv.slice(2);
296 if (command === "seal") {
297 mkdirSync(path.dirname(recordPath), { recursive: true });
298 writeFileSync(recordPath, `${JSON.stringify(sealCandidate(payload, parseMetadata(process.env)), null, 2)}\n`);
299 } else if (command === "verify" || command === "verify-rehearsal") {
300 verifyCandidate(payload, JSON.parse(readFileSync(recordPath, "utf8")), new Date(), command === "verify-rehearsal" ? "rehearsal" : "release");
301 process.stdout.write(`${JSON.stringify(JSON.parse(readFileSync(recordPath, "utf8")))}\n`);
302 } else if (command === "id") {
303 process.stdout.write(`${candidateId(payload, recordPath, catalogSha256)}\n`);
304 } else {
305 throw new Error("usage: release-candidate.mjs seal|verify|verify-rehearsal PAYLOAD RECORD | id VERSION SOURCE_SHA CATALOG_SHA256");
306 }
307 }
308
308 lines Plain Text