返回 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 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
292 lines Plain Text