返回 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 if (record.control?.buildSHA !== run.head_sha) throw new Error("candidate control SHA mismatch");
50 if (!/^[1-9][0-9]*$/.test(record.source?.payloadArtifactId ?? "")) throw new Error("candidate payload artifact id is invalid");
51 if (record.source?.payloadArtifactName !== `${namespace}-payload-${candidateId}`) throw new Error("candidate payload artifact name mismatch");
52 if (!/^[1-9][0-9]*$/.test(record.source?.evidenceArtifactId ?? "")) throw new Error("candidate evidence artifact id is invalid");
53 if (record.source?.evidenceArtifactName !== `${namespace}-evidence-${candidateId}`) throw new Error("candidate evidence artifact name mismatch");
54 if (String(recordArtifact.workflow_run.id) !== record.source.runId) throw new Error("record artifact belongs to another run");
55 if (record.validity?.revoked !== false) throw new Error("candidate record is revoked");
56 const created = new Date(record.validity?.createdAt);
57 const expires = new Date(record.validity?.expiresAt);
58 if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created || expires <= now) {
59 throw new Error("candidate record has expired or has an invalid validity window");
60 }
61 return {
62 candidateId,
63 version: record.version,
64 sourceSHA: record.sourceSHA,
65 candidateControlSHA: record.control.buildSHA,
66 signingFingerprint: record.signing.desktopFingerprint,
67 producerRunId: record.source.runId,
68 producerRunAttempt: record.source.runAttempt,
69 desktopPrefix: record.source.desktopPrefix,
70 payloadArtifactId: record.source.payloadArtifactId,
71 payloadArtifactName: record.source.payloadArtifactName,
72 evidenceArtifactId: record.source.evidenceArtifactId,
73 evidenceArtifactName: record.source.evidenceArtifactName,
74 };
75 }
76
77 async function githubJSON(url, token) {
78 const response = await fetch(`https://api.github.com${url}`, {
79 headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
80 });
81 if (!response.ok) throw new Error(`GitHub API ${response.status}: ${url}`);
82 return response.json();
83 }
84
85 function outputs(values) {
86 if (!process.env.GITHUB_OUTPUT) return process.stdout.write(`${JSON.stringify(values)}\n`);
87 appendFileSync(process.env.GITHUB_OUTPUT, `${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
88 }
89
90 async function resolve(candidateId, required = true, purpose = "release") {
91 const repository = process.env.GITHUB_REPOSITORY;
92 const token = process.env.GH_TOKEN;
93 if (!repository || !token) throw new Error("GITHUB_REPOSITORY and GH_TOKEN are required");
94 requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
95 const artifactData = await githubJSON(`/repos/${repository}/actions/artifacts?name=${encodeURIComponent(`${artifactNamespace(purpose)}-record-${candidateId}`)}&per_page=100`, token);
96 const artifact = selectRecordArtifact(artifactData.artifacts ?? [], candidateId, required, purpose);
97 if (!artifact) {
98 outputs({ found: false });
99 return;
100 }
101 const run = await githubJSON(`/repos/${repository}/actions/runs/${artifact.workflow_run.id}`, token);
102 validateCandidateRun(run, artifact, repository);
103 outputs({ found: true, record_artifact_id: artifact.id, producer_run_id: run.id, producer_run_attempt: run.run_attempt });
104 }
105
106 if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
107 const [command, candidateId, recordPath, artifactPath, runPath] = process.argv.slice(2);
108 if (command === "active") requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
109 else if (command === "resolve") await resolve(candidateId);
110 else if (command === "resolve-optional") await resolve(candidateId, false);
111 else if (command === "resolve-rehearsal") await resolve(candidateId, true, "rehearsal");
112 else if (command === "inspect" || command === "inspect-rehearsal") {
113 const result = inspectRecord(
114 JSON.parse(readFileSync(recordPath, "utf8")), candidateId,
115 JSON.parse(readFileSync(artifactPath, "utf8")), JSON.parse(readFileSync(runPath, "utf8")),
116 new Date(), command === "inspect-rehearsal" ? "rehearsal" : "release",
117 );
118 // This is a workflow API, not a case-conversion convention. In particular,
119 // SHA is one field suffix, not three independently underscored letters.
120 outputs({
121 candidate_id: result.candidateId,
122 version: result.version,
123 source_sha: result.sourceSHA,
124 candidate_control_sha: result.candidateControlSHA,
125 signing_fingerprint: result.signingFingerprint,
126 producer_run_id: result.producerRunId,
127 producer_run_attempt: result.producerRunAttempt,
128 desktop_prefix: result.desktopPrefix,
129 payload_artifact_id: result.payloadArtifactId,
130 payload_artifact_name: result.payloadArtifactName,
131 evidence_artifact_id: result.evidenceArtifactId,
132 evidence_artifact_name: result.evidenceArtifactName,
133 });
134 } else throw new Error("usage: resolve-release-candidate.mjs active ID | resolve|resolve-optional|resolve-rehearsal ID | inspect|inspect-rehearsal ID RECORD ARTIFACT RUN");
135 }
136
136 lines Plain Text