| 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 |