返回 DeepSeek-Reasonix
resolve-release-candidate.mjs
根目录 / scripts / resolve-release-candidate.mjs
1 import { readFileSync } from "node:fs";
2 import { appendFileSync } from "node:fs";
3 import path from "node:path";
4 import { pathToFileURL } from "node:url";
5 import { artifactNamespace } from "./release-candidate.mjs";
6
7 const ID_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}$/;
8
9 export function requireNotRevoked(candidateId, value = "") {
10 const revoked = new Set(String(value).split(/[\s,]+/).filter(Boolean));
11 if (revoked.has(candidateId)) throw new Error(`release candidate is revoked: ${candidateId}`);
12 }
13
14 export function selectRecordArtifact(artifacts, candidateId, required = true, purpose = "release") {
15 if (!ID_RE.test(candidateId)) throw new Error("invalid release candidate id");
16 const name = `${artifactNamespace(purpose)}-record-${candidateId}`;
17 const matches = artifacts
18 .filter(item => item.name === name && item.expired === false)
19 .sort((a, b) => Number(b.id) - Number(a.id));
20 if (matches.length === 0) {
21 if (!required) return null;
22 throw new Error(`active candidate record artifact not found: ${candidateId}`);
23 }
24 return matches[0];
25 }
26
27 export function validateCandidateRun(run, recordArtifact, repository) {
28 if (run.id !== recordArtifact.workflow_run?.id) throw new Error("candidate artifact run identity mismatch");
29 if (run.repository?.full_name !== repository || run.path !== ".github/workflows/release-candidate.yml") {
30 throw new Error("candidate artifact was not produced by the protected candidate workflow");
31 }
32 if (run.head_branch !== "main-v2" || !["workflow_dispatch", "push"].includes(run.event) || run.status !== "completed" || run.conclusion !== "success") {
33 throw new Error("candidate producer run is not a successful protected main-v2 dispatch");
34 }
35 }
36
37 export function inspectRecord(record, candidateId, recordArtifact, run, now = new Date(), purpose = "release") {
38 const namespace = artifactNamespace(purpose);
39 if ((record.purpose ?? "release") !== purpose) throw new Error("candidate purpose mismatch; rehearsal cannot be published");
40 if (recordArtifact.name !== `${namespace}-record-${candidateId}` || recordArtifact.expired !== false) {
41 throw new Error("candidate record artifact identity mismatch");
42 }
43 if (record.candidateId !== candidateId) throw new Error("candidate record identity mismatch");
44 if (!/^[0-9a-f]{40}$/.test(record.sourceSHA ?? "")) throw new Error("candidate source SHA is invalid");
45 if (!/^[0-9a-f]{40}$/.test(record.control?.buildSHA ?? "")) throw new Error("candidate control SHA is invalid");
46 if (record.source?.runId !== String(run.id) || record.source?.runAttempt !== String(run.run_attempt)) {
47 throw new Error("candidate record producer mismatch");
48 }
49 const desktopPrefix = /^desktop-([1-9][0-9]*)-([1-9][0-9]*)-preflight$/.exec(record.source?.desktopPrefix ?? "");
50 if (!desktopPrefix || desktopPrefix[1] !== record.source.runId || Number(desktopPrefix[2]) > Number(record.source.runAttempt)) {
51 throw new Error("candidate Desktop prefix does not belong to a completed producer attempt");
52 }
53 if (record.control?.buildSHA !== run.head_sha) throw new Error("candidate control SHA mismatch");
54 if (!/^[1-9][0-9]*$/.test(record.source?.payloadArtifactId ?? "")) throw new Error("candidate payload artifact id is invalid");
55 if (record.source?.payloadArtifactName !== `${namespace}-payload-${candidateId}`) throw new Error("candidate payload artifact name mismatch");
56 if (!/^[1-9][0-9]*$/.test(record.source?.evidenceArtifactId ?? "")) throw new Error("candidate evidence artifact id is invalid");
57 if (record.source?.evidenceArtifactName !== `${namespace}-evidence-${candidateId}`) throw new Error("candidate evidence artifact name mismatch");
58 if (String(recordArtifact.workflow_run.id) !== record.source.runId) throw new Error("record artifact belongs to another run");
59 if (record.validity?.revoked !== false) throw new Error("candidate record is revoked");
60 const created = new Date(record.validity?.createdAt);
61 const expires = new Date(record.validity?.expiresAt);
62 if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created || expires <= now) {
63 throw new Error("candidate record has expired or has an invalid validity window");
64 }
65 return {
66 candidateId,
67 version: record.version,
68 sourceSHA: record.sourceSHA,
69 candidateControlSHA: record.control.buildSHA,
70 signingFingerprint: record.signing.desktopFingerprint,
71 producerRunId: record.source.runId,
72 producerRunAttempt: record.source.runAttempt,
73 desktopPrefix: record.source.desktopPrefix,
74 payloadArtifactId: record.source.payloadArtifactId,
75 payloadArtifactName: record.source.payloadArtifactName,
76 evidenceArtifactId: record.source.evidenceArtifactId,
77 evidenceArtifactName: record.source.evidenceArtifactName,
78 };
79 }
80
81 async function githubJSON(url, token) {
82 const response = await fetch(`https://api.github.com${url}`, {
83 headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
84 });
85 if (!response.ok) throw new Error(`GitHub API ${response.status}: ${url}`);
86 return response.json();
87 }
88
89 function outputs(values) {
90 if (!process.env.GITHUB_OUTPUT) return process.stdout.write(`${JSON.stringify(values)}\n`);
91 appendFileSync(process.env.GITHUB_OUTPUT, `${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
92 }
93
94 async function resolve(candidateId, required = true, purpose = "release") {
95 const repository = process.env.GITHUB_REPOSITORY;
96 const token = process.env.GH_TOKEN;
97 if (!repository || !token) throw new Error("GITHUB_REPOSITORY and GH_TOKEN are required");
98 requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
99 const artifactData = await githubJSON(`/repos/${repository}/actions/artifacts?name=${encodeURIComponent(`${artifactNamespace(purpose)}-record-${candidateId}`)}&per_page=100`, token);
100 const artifact = selectRecordArtifact(artifactData.artifacts ?? [], candidateId, required, purpose);
101 if (!artifact) {
102 outputs({ found: false });
103 return;
104 }
105 const run = await githubJSON(`/repos/${repository}/actions/runs/${artifact.workflow_run.id}`, token);
106 validateCandidateRun(run, artifact, repository);
107 outputs({ found: true, record_artifact_id: artifact.id, producer_run_id: run.id, producer_run_attempt: run.run_attempt });
108 }
109
110 if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
111 const [command, candidateId, recordPath, artifactPath, runPath] = process.argv.slice(2);
112 if (command === "active") requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
113 else if (command === "resolve") await resolve(candidateId);
114 else if (command === "resolve-optional") await resolve(candidateId, false);
115 else if (command === "resolve-rehearsal") await resolve(candidateId, true, "rehearsal");
116 else if (command === "inspect" || command === "inspect-rehearsal") {
117 const result = inspectRecord(
118 JSON.parse(readFileSync(recordPath, "utf8")), candidateId,
119 JSON.parse(readFileSync(artifactPath, "utf8")), JSON.parse(readFileSync(runPath, "utf8")),
120 new Date(), command === "inspect-rehearsal" ? "rehearsal" : "release",
121 );
122 // This is a workflow API, not a case-conversion convention. In particular,
123 // SHA is one field suffix, not three independently underscored letters.
124 outputs({
125 candidate_id: result.candidateId,
126 version: result.version,
127 source_sha: result.sourceSHA,
128 candidate_control_sha: result.candidateControlSHA,
129 signing_fingerprint: result.signingFingerprint,
130 producer_run_id: result.producerRunId,
131 producer_run_attempt: result.producerRunAttempt,
132 desktop_prefix: result.desktopPrefix,
133 payload_artifact_id: result.payloadArtifactId,
134 payload_artifact_name: result.payloadArtifactName,
135 evidence_artifact_id: result.evidenceArtifactId,
136 evidence_artifact_name: result.evidenceArtifactName,
137 });
138 } else throw new Error("usage: resolve-release-candidate.mjs active ID | resolve|resolve-optional|resolve-rehearsal ID | inspect|inspect-rehearsal ID RECORD ARTIFACT RUN");
139 }
140
140 lines Plain Text