返回 DeepSeek-Reasonix
artifact-identity.mjs
根目录 / desktop / frontend / scripts / artifact-identity.mjs
1 #!/usr/bin/env node
2
3 import { createHash } from "node:crypto";
4 import { execFileSync, spawnSync } from "node:child_process";
5 import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6 import path from "node:path";
7 import { fileURLToPath } from "node:url";
8
9 export const FRONTEND_ARTIFACT_SCHEMA = 1;
10
11 function sha256(parts) {
12 const hash = createHash("sha256");
13 for (const part of parts) hash.update(part);
14 return hash.digest("hex");
15 }
16
17 function git(root, args, options = {}) {
18 return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", ...options }).trim();
19 }
20
21 function gitBlobContents(root, names) {
22 const result = spawnSync("git", ["-C", root, "cat-file", "--batch"], {
23 input: names.map(name => `HEAD:${name}\n`).join(""),
24 maxBuffer: 256 * 1024 * 1024,
25 });
26 if (result.error) throw result.error;
27 if (result.status !== 0) throw new Error(result.stderr.toString("utf8").trim() || "git cat-file --batch failed");
28 const contents = [];
29 let offset = 0;
30 for (const name of names) {
31 const headerEnd = result.stdout.indexOf(10, offset);
32 if (headerEnd < 0) throw new Error(`missing git blob header for ${name}`);
33 const header = result.stdout.subarray(offset, headerEnd).toString("utf8");
34 const match = header.match(/^[0-9a-f]+ blob (\d+)$/);
35 if (!match) throw new Error(`invalid git blob header for ${name}: ${header}`);
36 const bodyStart = headerEnd + 1;
37 const bodyEnd = bodyStart + Number(match[1]);
38 if (bodyEnd >= result.stdout.length || result.stdout[bodyEnd] !== 10)
39 throw new Error(`truncated git blob for ${name}`);
40 contents.push(result.stdout.subarray(bodyStart, bodyEnd));
41 offset = bodyEnd + 1;
42 }
43 if (offset !== result.stdout.length) throw new Error("unexpected trailing git cat-file output");
44 return contents;
45 }
46
47 function filesBelow(directory, relative = "") {
48 const out = [];
49 for (const entry of readdirSync(path.join(directory, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
50 const name = path.posix.join(relative.replaceAll("\\", "/"), entry.name);
51 if (entry.isDirectory()) out.push(...filesBelow(directory, name));
52 else out.push(name);
53 }
54 return out;
55 }
56
57 export function distIdentity(dist) {
58 const files = filesBelow(dist).map(name => {
59 const content = readFileSync(path.join(dist, name));
60 return { name, size: content.length, sha256: sha256([content]) };
61 });
62 return { sha256: sha256(files.flatMap(file => [file.name, "\0", file.sha256, "\0"])), files };
63 }
64
65 export function buildInputIdentity(root) {
66 const names = git(root, ["ls-files", "-z", "--", "desktop/package.json", "desktop/pnpm-lock.yaml", "desktop/pnpm-workspace.yaml", "desktop/frontend"])
67 .split("\0").filter(name => name && !name.startsWith("desktop/frontend/dist/")).sort();
68 const contents = gitBlobContents(root, names);
69 return {
70 // Git blobs are the portable source identity. Reading checkout bytes here
71 // would make a Windows CRLF checkout disagree with the Linux producer.
72 sha256: sha256(names.flatMap((name, index) => [name, "\0", contents[index], "\0"])),
73 files: names,
74 };
75 }
76
77 function required(value, name) {
78 if (!String(value ?? "").trim()) throw new Error(`${name} is required`);
79 return String(value).trim();
80 }
81
82 export function createFrontendArtifact({ root, dist, manifest, shell, channel, sourceSHA, runId, attempt, pnpmVersion }) {
83 const actualSHA = git(root, ["rev-parse", "HEAD"]);
84 const expectedSHA = required(sourceSHA, "source SHA");
85 if (expectedSHA !== actualSHA) throw new Error(`source SHA mismatch: expected ${expectedSHA}, checkout is ${actualSHA}`);
86 if (!statSync(dist).isDirectory()) throw new Error(`frontend dist is not a directory: ${dist}`);
87 const body = {
88 schemaVersion: FRONTEND_ARTIFACT_SCHEMA,
89 sourceSHA: actualSHA,
90 workflow: { runId: required(runId, "run ID"), attempt: required(attempt, "run attempt") },
91 variant: { shell: required(shell, "shell"), channel: required(channel, "channel") },
92 toolchain: { node: process.version, pnpm: required(pnpmVersion, "pnpm version"), platform: process.platform, arch: process.arch },
93 inputs: buildInputIdentity(root),
94 dist: distIdentity(dist),
95 };
96 writeFileSync(manifest, JSON.stringify(body, null, 2) + "\n");
97 return body;
98 }
99
100 export function verifyFrontendArtifact({ root, dist, manifest, shell, channel, sourceSHA, runId, attempt, pnpmVersion }) {
101 let body;
102 try {
103 body = JSON.parse(readFileSync(manifest, "utf8"));
104 } catch (error) {
105 throw new Error(`frontend artifact manifest is unavailable or invalid: ${error.message}`);
106 }
107 const expected = {
108 schemaVersion: FRONTEND_ARTIFACT_SCHEMA,
109 sourceSHA: sourceSHA || git(root, ["rev-parse", "HEAD"]),
110 shell,
111 channel,
112 runId,
113 attempt,
114 node: process.version,
115 pnpm: pnpmVersion,
116 };
117 const actual = {
118 schemaVersion: body.schemaVersion,
119 sourceSHA: body.sourceSHA,
120 shell: body.variant?.shell,
121 channel: body.variant?.channel,
122 runId: body.workflow?.runId,
123 attempt: body.workflow?.attempt,
124 node: body.toolchain?.node,
125 pnpm: body.toolchain?.pnpm,
126 };
127 for (const [name, value] of Object.entries(expected)) {
128 if (value !== undefined && String(actual[name]) !== String(value))
129 throw new Error(`frontend artifact ${name} mismatch: expected ${value}, got ${actual[name]}`);
130 }
131 const inputs = buildInputIdentity(root);
132 if (body.inputs?.sha256 !== inputs.sha256) throw new Error("frontend artifact build inputs do not match this checkout");
133 const built = distIdentity(dist);
134 if (body.dist?.sha256 !== built.sha256 || JSON.stringify(body.dist.files) !== JSON.stringify(built.files))
135 throw new Error("frontend artifact contents do not match its manifest");
136 return body;
137 }
138
139 function parseArgs(argv) {
140 const args = { command: argv[0] };
141 for (let i = 1; i < argv.length; i += 2) {
142 if (!argv[i]?.startsWith("--") || argv[i + 1] === undefined) throw new Error(`invalid argument ${argv[i] ?? ""}`);
143 args[argv[i].slice(2).replaceAll("-", "_")] = argv[i + 1];
144 }
145 return args;
146 }
147
148 if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
149 try {
150 const args = parseArgs(process.argv.slice(2));
151 const root = path.resolve(args.root ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "../../.."));
152 const options = {
153 root,
154 dist: path.resolve(args.dist ?? path.join(root, "desktop/frontend/dist")),
155 manifest: path.resolve(args.manifest ?? path.join(root, "desktop/frontend/.reasonix-frontend-artifact.json")),
156 shell: args.shell,
157 channel: args.channel,
158 sourceSHA: args.source_sha,
159 runId: args.run_id,
160 attempt: args.attempt,
161 pnpmVersion: args.pnpm_version ?? execFileSync("pnpm", ["--version"], { encoding: "utf8" }).trim(),
162 };
163 const result = args.command === "create" ? createFrontendArtifact(options)
164 : args.command === "verify" ? verifyFrontendArtifact(options)
165 : (() => { throw new Error("command must be create or verify"); })();
166 console.log(`frontend artifact ${args.command}: ${result.sourceSHA} ${result.variant.shell}/${result.variant.channel} ${result.dist.sha256}`);
167 } catch (error) {
168 console.error(`artifact-identity: ${error.message}`);
169 process.exitCode = 1;
170 }
171 }
172
172 lines Plain Text