|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Robust update-services.js |
| 4 | + * Fetch all releases from GitHub, diff against services.md, and insert missing builds. |
| 5 | + * Supports pagination, dry-run, verbose, backups, and error handling. |
| 6 | + * Ensures sections and builds are ordered descending, removes duplicate headers, |
| 7 | + * cleans up bullet formatting, and collapses excess blank lines. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * CONSENSUS_TOKEN=ghp_xxx node update-services.js --md-path path/to/services.md [--dry-run] [--verbose] [--include-prereleases] |
| 11 | + */ |
| 12 | + |
| 13 | +const https = require("https"); |
| 14 | +const fs = require("fs"); |
| 15 | +const path = require("path"); |
| 16 | +const os = require("os"); |
| 17 | +const { spawnSync } = require("child_process"); |
| 18 | + |
| 19 | +// Parse CLI arguments |
| 20 | +const args = process.argv.slice(2).reduce((acc, cur, idx, arr) => { |
| 21 | + if (cur.startsWith("--")) { |
| 22 | + const key = cur.slice(2); |
| 23 | + const next = arr[idx + 1]; |
| 24 | + acc[key] = next && !next.startsWith("--") ? next : true; |
| 25 | + } |
| 26 | + return acc; |
| 27 | +}, {}); |
| 28 | + |
| 29 | +const mdPath = args["md-path"]; |
| 30 | +const dryRun = Boolean(args["dry-run"]); |
| 31 | +const verbose = Boolean(args["verbose"]); |
| 32 | +const includePrereleases = Boolean(args["include-prereleases"]); |
| 33 | +const token = process.env.CONSENSUS_TOKEN; |
| 34 | + |
| 35 | +function log(...msgs) { |
| 36 | + if (verbose) console.log("[verbose]", ...msgs); |
| 37 | +} |
| 38 | + |
| 39 | +if (!mdPath) { |
| 40 | + console.error("error: missing --md-path"); |
| 41 | + process.exit(1); |
| 42 | +} |
| 43 | +if (!token) { |
| 44 | + console.error("error: missing CONSENSUS_TOKEN env var"); |
| 45 | + process.exit(1); |
| 46 | +} |
| 47 | + |
| 48 | +// Semver compare: a > b => 1, a < b => -1, equal => 0 |
| 49 | +function compareVersions(a, b) { |
| 50 | + const pa = a.split(".").map(Number); |
| 51 | + const pb = b.split(".").map(Number); |
| 52 | + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { |
| 53 | + const na = pa[i] || 0; |
| 54 | + const nb = pb[i] || 0; |
| 55 | + if (na > nb) return 1; |
| 56 | + if (na < nb) return -1; |
| 57 | + } |
| 58 | + return 0; |
| 59 | +} |
| 60 | + |
| 61 | +// Fetch a single page of releases |
| 62 | +function fetchPage(page, per_page = 100) { |
| 63 | + const options = { |
| 64 | + hostname: "api.github.com", |
| 65 | + path: `/repos/hiero-ledger/hiero-consensus-node/releases?per_page=${per_page}&page=${page}`, |
| 66 | + method: "GET", |
| 67 | + headers: { |
| 68 | + "User-Agent": "node.js", |
| 69 | + Authorization: `token ${token}`, |
| 70 | + Accept: "application/vnd.github.v3+json", |
| 71 | + }, |
| 72 | + }; |
| 73 | + return new Promise((resolve, reject) => { |
| 74 | + const req = https.request(options, (res) => { |
| 75 | + let data = ""; |
| 76 | + res.on("data", (chunk) => (data += chunk)); |
| 77 | + res.on("end", () => { |
| 78 | + if (res.statusCode >= 200 && res.statusCode < 300) { |
| 79 | + try { |
| 80 | + resolve(JSON.parse(data)); |
| 81 | + } catch (e) { |
| 82 | + reject(new Error("Invalid JSON from GitHub")); |
| 83 | + } |
| 84 | + } else { |
| 85 | + reject(new Error(`GitHub API ${res.statusCode}: ${data}`)); |
| 86 | + } |
| 87 | + }); |
| 88 | + }); |
| 89 | + req.on("error", reject); |
| 90 | + req.end(); |
| 91 | + }); |
| 92 | +} |
| 93 | + |
| 94 | +// Fetch all releases with pagination |
| 95 | +async function fetchAllReleases() { |
| 96 | + let page = 1; |
| 97 | + const all = []; |
| 98 | + while (true) { |
| 99 | + log(`fetching page ${page}`); |
| 100 | + const list = await fetchPage(page); |
| 101 | + if (!Array.isArray(list) || list.length === 0) break; |
| 102 | + all.push(...list); |
| 103 | + if (list.length < 100) break; |
| 104 | + page++; |
| 105 | + } |
| 106 | + return all.filter((r) => r.tag_name && (includePrereleases || !r.prerelease)); |
| 107 | +} |
| 108 | + |
| 109 | +// Build markdown snippet for a release, cleaning up headings |
| 110 | +function buildSnippet(release) { |
| 111 | + const version = release.tag_name; |
| 112 | + const url = release.html_url; |
| 113 | + // remove any "What's Changed" headings |
| 114 | + const raw = (release.body || "").split(/\r?\n/); |
| 115 | + const cleaned = raw |
| 116 | + .filter((line) => !/^#+\s*what'?s changed\s*$/i.test(line)) |
| 117 | + .map((line) => line.trim()) |
| 118 | + .filter((line) => line); |
| 119 | + const bulletized = cleaned.map((line) => `* ${line}`).join("\n"); |
| 120 | + const [major, minor] = version.replace(/^v/, "").split("."); |
| 121 | + return `### [Build ${version}](${url}) |
| 122 | +
|
| 123 | +<details> |
| 124 | +<summary><strong>What's Changed</strong></summary> |
| 125 | +
|
| 126 | +${bulletized} |
| 127 | +
|
| 128 | +**Full Changelog**: [v${major}.${minor}.0...${version}](https://github.com/hiero-ledger/hiero-consensus-node/compare/v${major}.${minor}.0...${version}) |
| 129 | +
|
| 130 | +</details> |
| 131 | +`; |
| 132 | +} |
| 133 | + |
| 134 | +// Insert snippet into content under the correct section |
| 135 | +function insertSnippet(content, snippet, version) { |
| 136 | + const [major, minor] = version.replace(/^v/, "").split("."); |
| 137 | + const header = `## Release v${major}.${minor}`; |
| 138 | + const regex = new RegExp( |
| 139 | + `(${header}[\s\S]*?)(?=(## Release v\\d+\\.\\d+|$))` |
| 140 | + ); |
| 141 | + if (regex.test(content)) { |
| 142 | + return content.replace( |
| 143 | + regex, |
| 144 | + `$1 |
| 145 | +
|
| 146 | +${snippet}` |
| 147 | + ); |
| 148 | + } |
| 149 | + return ( |
| 150 | + content.trimEnd() + |
| 151 | + ` |
| 152 | +
|
| 153 | +${header} |
| 154 | +
|
| 155 | +${snippet}` |
| 156 | + ); |
| 157 | +} |
| 158 | + |
| 159 | +// Sort build blocks within a section descending, join with two newlines |
| 160 | +function sortSectionBuilds(sectionText) { |
| 161 | + const marker = "### [Build "; |
| 162 | + const idx = sectionText.indexOf(marker); |
| 163 | + if (idx === -1) return sectionText; |
| 164 | + const prefix = sectionText.slice(0, idx).trimEnd(); |
| 165 | + const buildsText = sectionText.slice(idx); |
| 166 | + const blocks = buildsText |
| 167 | + .split(/(?=### \[Build )/g) |
| 168 | + .map((b) => b.trim()) |
| 169 | + .filter((b) => b); |
| 170 | + blocks.sort((a, b) => { |
| 171 | + const va = a.match(/### \[Build v?(.*?)\]/)[1]; |
| 172 | + const vb = b.match(/### \[Build v?(.*?)\]/)[1]; |
| 173 | + return compareVersions(vb, va); |
| 174 | + }); |
| 175 | + return `${prefix} |
| 176 | +
|
| 177 | +${blocks.join("\n\n")}`; |
| 178 | +} |
| 179 | + |
| 180 | +// Reorder and merge all sections descending by version, then sort builds |
| 181 | +function reorderSections(content) { |
| 182 | + const splitRegex = /(?=^## Release v\d+\.\d+)/m; |
| 183 | + const parts = content.split(splitRegex); |
| 184 | + const header = parts.shift(); |
| 185 | + |
| 186 | + const map = new Map(); |
| 187 | + parts.forEach((sec) => { |
| 188 | + const m = sec.match(/^## Release v(\d+\.\d+)/); |
| 189 | + if (!m) return; |
| 190 | + const ver = m[1]; |
| 191 | + const trimmed = sec.trim(); |
| 192 | + if (!map.has(ver)) map.set(ver, trimmed); |
| 193 | + else { |
| 194 | + const body = trimmed.replace(/^## Release v\d+\.\d+/, "").trim(); |
| 195 | + if (body) map.set(ver, map.get(ver) + "\n\n" + body); |
| 196 | + } |
| 197 | + }); |
| 198 | + |
| 199 | + const sorted = Array.from(map.keys()).sort((a, b) => compareVersions(b, a)); |
| 200 | + |
| 201 | + let out = header.trimEnd() + "\n"; |
| 202 | + sorted.forEach((ver) => { |
| 203 | + let sec = map.get(ver); |
| 204 | + sec = sortSectionBuilds(sec); |
| 205 | + out += sec + "\n\n"; |
| 206 | + }); |
| 207 | + |
| 208 | + return out.trimEnd().replace(/\n{3,}/g, "\n\n"); |
| 209 | +} |
| 210 | + |
| 211 | +(async () => { |
| 212 | + const filePath = path.resolve(mdPath); |
| 213 | + if (!fs.existsSync(filePath)) { |
| 214 | + console.error(`error: file not found at ${filePath}`); |
| 215 | + process.exit(1); |
| 216 | + } |
| 217 | + const backup = `${filePath}.${Date.now()}.bak`; |
| 218 | + fs.copyFileSync(filePath, backup); |
| 219 | + log(`backup created at ${backup}`); |
| 220 | + |
| 221 | + let content = fs.readFileSync(filePath, "utf8"); |
| 222 | + const existing = content |
| 223 | + .split(/\r?\n/) |
| 224 | + .filter((line) => line.startsWith("### [Build ")) |
| 225 | + .map((line) => line.match(/\[Build v?(.*?)\]/)[1]); |
| 226 | + |
| 227 | + let releases; |
| 228 | + try { |
| 229 | + releases = await fetchAllReleases(); |
| 230 | + } catch (err) { |
| 231 | + console.error("error fetching releases:", err.message); |
| 232 | + process.exit(1); |
| 233 | + } |
| 234 | + |
| 235 | + const fetched = releases.map((r) => r.tag_name.replace(/^v/, "")); |
| 236 | + const missing = [...new Set(fetched)] |
| 237 | + .filter((v) => !existing.includes(v)) |
| 238 | + .sort(compareVersions) |
| 239 | + .reverse(); |
| 240 | + |
| 241 | + if (!missing.length) { |
| 242 | + console.log("✅ no missing builds to insert"); |
| 243 | + return; |
| 244 | + } |
| 245 | + |
| 246 | + missing.forEach((ver) => { |
| 247 | + const rel = releases.find((r) => r.tag_name.replace(/^v/, "") === ver); |
| 248 | + if (!rel) return; |
| 249 | + content = insertSnippet(content, buildSnippet(rel), ver); |
| 250 | + console.log(`inserted build v${ver}`); |
| 251 | + }); |
| 252 | + |
| 253 | + content = reorderSections(content); |
| 254 | + log("sections and builds reordered, cleaned"); |
| 255 | + |
| 256 | + if (dryRun) { |
| 257 | + const tmp = path.join(os.tmpdir(), "services.tmp.md"); |
| 258 | + fs.writeFileSync(tmp, content, "utf8"); |
| 259 | + const diff = spawnSync("diff", ["-u", backup, tmp], { encoding: "utf8" }); |
| 260 | + console.log(diff.stdout || "no differences"); |
| 261 | + fs.unlinkSync(tmp); |
| 262 | + return; |
| 263 | + } |
| 264 | + |
| 265 | + try { |
| 266 | + fs.writeFileSync(filePath, content, "utf8"); |
| 267 | + console.log(`✅ updated with builds: ${missing.join(", ")}`); |
| 268 | + } catch (err) { |
| 269 | + console.error("error writing file:", err.message); |
| 270 | + process.exit(1); |
| 271 | + } |
| 272 | +})(); |
0 commit comments