Skip to content

Commit 7c689e2

Browse files
add script to auto generate release notes (#72)
1 parent b562d71 commit 7c689e2

File tree

6 files changed

+6457
-145
lines changed

6 files changed

+6457
-145
lines changed

.github/scripts/update-services.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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+
})();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: 📝 Auto-generate consensus-node release notes
2+
on:
3+
push:
4+
branches:
5+
- main
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
update-release-notes:
12+
runs-on: hashgraph-docs-linux-medium
13+
steps:
14+
- name: Checkout docs repo
15+
uses: actions/checkout@v3
16+
17+
- name: Run update script
18+
env:
19+
CONSENSUS_TOKEN: ${{ secrets.CONSENSUS_TOKEN }}
20+
run: |
21+
node .github/scripts/update-services.js \
22+
--md-path networks/release-notes/services.md
23+
24+
- name: Commit & push changes
25+
run: |
26+
git config user.name "github-actions[bot]"
27+
git config user.email "github-actions[bot]@users.noreply.github.com"
28+
git add networks/release-notes/services.md
29+
git commit -m "chore: update release notes" || echo "nothing to commit"
30+
git push

.github/workflows/update-node-tables.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ jobs:
1313
runs-on: hashgraph-docs-linux-medium
1414
steps:
1515
- name: Checkout repository
16-
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
16+
uses: actions/checkout@v3 # v3.6.0
17+
with:
18+
fetch-depth: 0 # Fetch all history for all branches and tags
19+
persist-credentials: true # Do not use the GITHUB_TOKEN for authentication
1720

1821
- name: Install dependencies
1922
run: sudo apt-get install -y jq
2023

2124
- name: Run update script
22-
run: ./update_node_tables.sh
25+
run: |
26+
set -eux
27+
./update_node_tables.sh
2328
2429
- name: Commit changes
2530
run: |

0 commit comments

Comments
 (0)