|
| 1 | +#!/usr/bin/env node |
| 2 | +// Renders the "Downloads" section appended to GitHub release notes: one |
| 3 | +// markdown table per OS with a direct download link, blockmap link, and |
| 4 | +// SHA-256 per installer. Input is a directory of sha256sum/shasum output |
| 5 | +// files collected from the publish jobs in code-release.yml — the asset |
| 6 | +// patterns below must cover every file those jobs checksum. |
| 7 | +import { readdirSync, readFileSync, realpathSync } from "node:fs"; |
| 8 | +import { join } from "node:path"; |
| 9 | +import { fileURLToPath } from "node:url"; |
| 10 | + |
| 11 | +const DOWNLOAD_BASE = "https://github.com/PostHog/code/releases/download"; |
| 12 | + |
| 13 | +const ASSET_KINDS = [ |
| 14 | + { pattern: /-mac\.dmg$/, os: "macos", pkg: "DMG", pkgOrder: 0 }, |
| 15 | + { pattern: /-mac\.zip$/, os: "macos", pkg: "ZIP", pkgOrder: 1 }, |
| 16 | + { |
| 17 | + pattern: /-win\.exe$/, |
| 18 | + os: "windows", |
| 19 | + pkg: "Installer (.exe)", |
| 20 | + pkgOrder: 0, |
| 21 | + }, |
| 22 | + { pattern: /\.AppImage$/, os: "linux", pkg: "AppImage", pkgOrder: 0 }, |
| 23 | + { pattern: /\.deb$/, os: "linux", pkg: "Debian (.deb)", pkgOrder: 1 }, |
| 24 | + { pattern: /\.rpm$/, os: "linux", pkg: "RPM (.rpm)", pkgOrder: 2 }, |
| 25 | +]; |
| 26 | + |
| 27 | +const OS_SECTIONS = [ |
| 28 | + // macOS sorts by arch first: users pick their chip, then a format. |
| 29 | + // Linux sorts by package first: the distro dictates deb/rpm/AppImage. |
| 30 | + { |
| 31 | + os: "macos", |
| 32 | + heading: "macOS", |
| 33 | + archOrder: ["arm64", "x64"], |
| 34 | + archFirst: true, |
| 35 | + }, |
| 36 | + { |
| 37 | + os: "windows", |
| 38 | + heading: "Windows", |
| 39 | + archOrder: ["x64", "arm64"], |
| 40 | + archFirst: false, |
| 41 | + }, |
| 42 | + { |
| 43 | + os: "linux", |
| 44 | + heading: "Linux", |
| 45 | + archOrder: ["x64", "arm64"], |
| 46 | + archFirst: false, |
| 47 | + }, |
| 48 | +]; |
| 49 | + |
| 50 | +const ARCH_LABELS = { |
| 51 | + macos: { arm64: "Apple Silicon (arm64)", x64: "Intel (x64)" }, |
| 52 | + windows: { arm64: "arm64", x64: "x64" }, |
| 53 | + linux: { arm64: "arm64", x64: "x64" }, |
| 54 | +}; |
| 55 | + |
| 56 | +// Parses `sha256sum`/`shasum -a 256` output into a filename -> sha map. |
| 57 | +export function parseChecksums(text) { |
| 58 | + const checksums = new Map(); |
| 59 | + for (const line of text.split("\n")) { |
| 60 | + const match = line.trim().match(/^([0-9a-f]{64})[ *]+(.+)$/); |
| 61 | + if (match) checksums.set(match[2], match[1]); |
| 62 | + } |
| 63 | + return checksums; |
| 64 | +} |
| 65 | + |
| 66 | +function detectArch(name) { |
| 67 | + if (/aarch64|arm64/.test(name)) return "arm64"; |
| 68 | + if (/x86_64|amd64|x64/.test(name)) return "x64"; |
| 69 | + return "unknown"; |
| 70 | +} |
| 71 | + |
| 72 | +export function buildDownloadTables(version, checksums) { |
| 73 | + const base = `${DOWNLOAD_BASE}/v${version.replace(/^v/, "")}`; |
| 74 | + const rows = { macos: [], windows: [], linux: [] }; |
| 75 | + |
| 76 | + for (const [name, sha] of checksums) { |
| 77 | + if (name.endsWith(".blockmap")) continue; |
| 78 | + const kind = ASSET_KINDS.find((k) => k.pattern.test(name)); |
| 79 | + if (!kind) continue; |
| 80 | + const arch = detectArch(name); |
| 81 | + const blockmapName = `${name}.blockmap`; |
| 82 | + const blockmap = checksums.has(blockmapName) |
| 83 | + ? `[blockmap](${base}/${blockmapName})` |
| 84 | + : "—"; |
| 85 | + rows[kind.os].push({ |
| 86 | + name, |
| 87 | + arch, |
| 88 | + pkgOrder: kind.pkgOrder, |
| 89 | + cells: [ |
| 90 | + kind.pkg, |
| 91 | + ARCH_LABELS[kind.os][arch] ?? arch, |
| 92 | + `[${name}](${base}/${name})`, |
| 93 | + blockmap, |
| 94 | + // Abbreviated sha; the link title shows the full hash on hover. |
| 95 | + `[\`${sha.slice(0, 6)}\`](${base}/${name} "${sha}")`, |
| 96 | + ], |
| 97 | + }); |
| 98 | + } |
| 99 | + |
| 100 | + const sections = []; |
| 101 | + for (const { os, heading, archOrder, archFirst } of OS_SECTIONS) { |
| 102 | + if (rows[os].length === 0) continue; |
| 103 | + const archRank = (row) => { |
| 104 | + const rank = archOrder.indexOf(row.arch); |
| 105 | + return rank === -1 ? archOrder.length : rank; |
| 106 | + }; |
| 107 | + rows[os].sort((a, b) => { |
| 108 | + const aKey = archFirst |
| 109 | + ? [archRank(a), a.pkgOrder] |
| 110 | + : [a.pkgOrder, archRank(a)]; |
| 111 | + const bKey = archFirst |
| 112 | + ? [archRank(b), b.pkgOrder] |
| 113 | + : [b.pkgOrder, archRank(b)]; |
| 114 | + return ( |
| 115 | + aKey[0] - bKey[0] || aKey[1] - bKey[1] || a.name.localeCompare(b.name) |
| 116 | + ); |
| 117 | + }); |
| 118 | + sections.push( |
| 119 | + [ |
| 120 | + `### ${heading}`, |
| 121 | + "", |
| 122 | + "| Package | Architecture | Download | Blockmap | SHA-256 |", |
| 123 | + "| --- | --- | --- | --- | --- |", |
| 124 | + ...rows[os].map((row) => `| ${row.cells.join(" | ")} |`), |
| 125 | + ].join("\n"), |
| 126 | + ); |
| 127 | + } |
| 128 | + |
| 129 | + if (sections.length === 0) return ""; |
| 130 | + return `## Downloads\n\n${sections.join("\n\n")}\n`; |
| 131 | +} |
| 132 | + |
| 133 | +function main() { |
| 134 | + const [, , version, checksumsDir] = process.argv; |
| 135 | + |
| 136 | + if (!version || !checksumsDir) { |
| 137 | + console.error( |
| 138 | + "Usage: generate-release-download-tables.mjs <version> <checksums-dir>", |
| 139 | + ); |
| 140 | + process.exit(1); |
| 141 | + } |
| 142 | + |
| 143 | + const files = readdirSync(checksumsDir) |
| 144 | + .filter((name) => name.endsWith(".txt")) |
| 145 | + .sort(); |
| 146 | + const text = files |
| 147 | + .map((name) => readFileSync(join(checksumsDir, name), "utf8")) |
| 148 | + .join("\n"); |
| 149 | + const checksums = parseChecksums(text); |
| 150 | + |
| 151 | + for (const name of checksums.keys()) { |
| 152 | + if ( |
| 153 | + !name.endsWith(".blockmap") && |
| 154 | + !ASSET_KINDS.some((kind) => kind.pattern.test(name)) |
| 155 | + ) { |
| 156 | + console.error(`Skipping unrecognized artifact: ${name}`); |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + const markdown = buildDownloadTables(version, checksums); |
| 161 | + if (!markdown) { |
| 162 | + console.error(`No release artifacts found in ${checksumsDir}`); |
| 163 | + process.exit(1); |
| 164 | + } |
| 165 | + process.stdout.write(markdown); |
| 166 | +} |
| 167 | + |
| 168 | +if ( |
| 169 | + process.argv[1] && |
| 170 | + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) |
| 171 | +) { |
| 172 | + main(); |
| 173 | +} |
0 commit comments