| 1 | import { createHash } from "node:crypto"; |
| 2 | import { copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 3 | import path from "node:path"; |
| 4 | import { pathToFileURL } from "node:url"; |
| 5 | |
| 6 | export const platforms = ["darwin-arm64", "darwin-amd64", "darwin-universal", "windows-amd64", "windows-arm64", "linux-amd64"]; |
| 7 | const fields = { |
| 8 | sourceSHA: "RELEASE_SOURCE_SHA", controlSHA: "RELEASE_CONTROL_SHA", |
| 9 | tag: "RELEASE_TAG", version: "RELEASE_VERSION", channel: "RELEASE_CHANNEL", |
| 10 | signingFingerprint: "RELEASE_SIGNING_FINGERPRINT", prefix: "RELEASE_ARTIFACT_PREFIX", |
| 11 | }; |
| 12 | |
| 13 | export function releaseIdentity(env) { |
| 14 | const identity = Object.fromEntries(Object.entries(fields).map(([key, variable]) => { |
| 15 | if (!env[variable]) throw new Error(`missing ${variable}`); |
| 16 | return [key, env[variable]]; |
| 17 | })); |
| 18 | const match = /^desktop-([1-9][0-9]*)-([1-9][0-9]*)-(preflight|release)$/.exec(identity.prefix); |
| 19 | const producerRun = env.RELEASE_PRODUCER_RUN_ID ?? env.GITHUB_RUN_ID; |
| 20 | const producerAttempt = env.RELEASE_PRODUCER_RUN_ATTEMPT ?? env.GITHUB_RUN_ATTEMPT; |
| 21 | if (!match || match[1] !== producerRun || !/^[1-9][0-9]*$/.test(producerAttempt ?? "") |
| 22 | || BigInt(match[2]) > BigInt(producerAttempt)) throw new Error("artifact set is not from the verified producer run or a completed attempt"); |
| 23 | if (![identity.sourceSHA, identity.controlSHA].every(sha => /^[a-f0-9]{40}$/.test(sha))) throw new Error("invalid release SHA"); |
| 24 | return identity; |
| 25 | } |
| 26 | |
| 27 | function entries(directory) { |
| 28 | return readdirSync(directory).sort().map(name => { |
| 29 | if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`unsafe artifact name: ${name}`); |
| 30 | const file = path.join(directory, name); |
| 31 | const stat = lstatSync(file); |
| 32 | if (!stat.isFile() || stat.size === 0) throw new Error(`invalid artifact: ${name}`); |
| 33 | return { name, size: stat.size, sha256: createHash("sha256").update(readFileSync(file)).digest("hex") }; |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | function requireSignatures(files) { |
| 38 | const names = new Set(files.map(file => file.name)); |
| 39 | if (!names.size || !files.some(file => !file.name.endsWith(".minisig"))) throw new Error("empty artifact set"); |
| 40 | for (const name of names) { |
| 41 | const companion = name.endsWith(".minisig") ? name.slice(0, -8) : `${name}.minisig`; |
| 42 | if (!names.has(companion)) throw new Error(`missing payload or signature for ${name}`); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | function validAttempt(attempt, identity, currentAttempt) { |
| 47 | return /^[1-9][0-9]*$/.test(attempt ?? "") && /^[1-9][0-9]*$/.test(currentAttempt ?? "") |
| 48 | && BigInt(attempt) >= BigInt(identity.prefix.split("-")[2]) && BigInt(attempt) <= BigInt(currentAttempt); |
| 49 | } |
| 50 | |
| 51 | export function verifyBundle(bundle, platform, identity, currentAttempt) { |
| 52 | if (!platforms.includes(platform)) throw new Error("invalid release platform"); |
| 53 | if (!lstatSync(bundle).isDirectory() || lstatSync(bundle).isSymbolicLink() |
| 54 | || JSON.stringify(readdirSync(bundle).sort()) !== JSON.stringify(["files", "identity.json"])) { |
| 55 | throw new Error("invalid bundle layout"); |
| 56 | } |
| 57 | if (!lstatSync(path.join(bundle, "identity.json")).isFile() |
| 58 | || lstatSync(path.join(bundle, "identity.json")).isSymbolicLink() |
| 59 | || !lstatSync(path.join(bundle, "files")).isDirectory() |
| 60 | || lstatSync(path.join(bundle, "files")).isSymbolicLink()) { |
| 61 | throw new Error("invalid bundle entries"); |
| 62 | } |
| 63 | const manifest = JSON.parse(readFileSync(path.join(bundle, "identity.json"), "utf8")); |
| 64 | if (manifest.schema !== 1 || manifest.platform !== platform) throw new Error("invalid bundle identity"); |
| 65 | if (!validAttempt(manifest.buildAttempt, identity, currentAttempt)) throw new Error("invalid producer attempt"); |
| 66 | for (const [key, value] of Object.entries(identity)) { |
| 67 | if (manifest[key] !== value) throw new Error(`artifact identity mismatch: ${key}`); |
| 68 | } |
| 69 | const files = entries(path.join(bundle, "files")); |
| 70 | if (JSON.stringify(files) !== JSON.stringify(manifest.files)) throw new Error(`artifact digest mismatch: ${platform}`); |
| 71 | requireSignatures(files); |
| 72 | return { manifest, files }; |
| 73 | } |
| 74 | |
| 75 | export function pack(source, target, platform, identity, buildAttempt = identity.prefix.split("-")[2]) { |
| 76 | if (!platforms.includes(platform)) throw new Error("invalid release platform"); |
| 77 | if (!validAttempt(buildAttempt, identity, buildAttempt)) throw new Error("invalid producer attempt"); |
| 78 | const files = entries(source); |
| 79 | requireSignatures(files); |
| 80 | mkdirSync(target); // Never append to a stale bundle. |
| 81 | mkdirSync(path.join(target, "files")); |
| 82 | for (const { name } of files) copyFileSync(path.join(source, name), path.join(target, "files", name)); |
| 83 | writeFileSync(path.join(target, "identity.json"), JSON.stringify({ schema: 1, ...identity, buildAttempt, platform, files }, null, 2)); |
| 84 | } |
| 85 | |
| 86 | export function collect(source, target, identity, currentAttempt = identity.prefix.split("-")[2]) { |
| 87 | const expected = platforms.map(platform => `${identity.prefix}-${platform}`).sort(); |
| 88 | if (JSON.stringify(readdirSync(source).sort()) !== JSON.stringify(expected)) throw new Error("missing or unexpected platform bundle"); |
| 89 | const copies = new Map(); |
| 90 | for (const platform of platforms) { |
| 91 | const bundle = path.join(source, `${identity.prefix}-${platform}`); |
| 92 | const { files } = verifyBundle(bundle, platform, identity, currentAttempt); |
| 93 | for (const { name } of files) { |
| 94 | if (copies.has(name)) throw new Error(`duplicate artifact across platforms: ${name}`); |
| 95 | copies.set(name, path.join(bundle, "files", name)); |
| 96 | } |
| 97 | } |
| 98 | // Complete identity and digest verification precedes any publication input. |
| 99 | mkdirSync(target); |
| 100 | for (const [name, file] of copies) copyFileSync(file, path.join(target, name)); |
| 101 | } |
| 102 | |
| 103 | if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { |
| 104 | const [command, source, target, platform] = process.argv.slice(2); |
| 105 | const identity = releaseIdentity(process.env); |
| 106 | if (command === "pack") pack(source, target, platform, identity, process.env.GITHUB_RUN_ATTEMPT); |
| 107 | else if (command === "collect") collect(source, target, identity, process.env.RELEASE_PRODUCER_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT); |
| 108 | else if (command === "verify") verifyBundle(source, target, identity, process.env.GITHUB_RUN_ATTEMPT); |
| 109 | else throw new Error("usage: desktop-release-artifacts.mjs pack|collect|verify SOURCE TARGET [PLATFORM]"); |
| 110 | } |
| 111 |