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