Skip to content

Commit be095e2

Browse files
committed
* mirror current changelog/prune scripts from the shared template (notes-only mode, bot-commit filtering, prune retry + newest-kept guard)
* nightly: same-day re-runs replace release AND tag at the validated SHA * release: the vyyyyMMdd tag is placed ON the changelog commit so bookkeeping never pollutes the next notes * workflows trigger on main only after the rename
1 parent 58f2371 commit be095e2

5 files changed

Lines changed: 89 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ name: CI
44
# release.yml and nightly.yml reuse this via `workflow_call` / `workflow_run`.
55
on:
66
push:
7-
branches: [master]
7+
branches: [main]
88
pull_request:
9-
branches: [master]
9+
branches: [main]
1010
workflow_call: {}
1111

1212
# Force JavaScript-based actions to run on Node 24 instead of the deprecated Node 20 ahead of

.github/workflows/nightly.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Nightly
55
on:
66
workflow_run:
77
workflows: [CI]
8-
branches: [master]
8+
branches: [main]
99
types: [completed]
1010
workflow_dispatch: {}
1111

@@ -48,13 +48,22 @@ jobs:
4848
- uses: actions/setup-node@v4
4949
with:
5050
node-version: '22'
51+
# --notes-only: CHANGELOG.md is committed by release.yml exclusively.
5152
- name: Generate release notes (functional changes, bucketed by + - * # !)
52-
run: node ".github/workflows/scripts/update-changelog.mjs" --nightly --notes RELEASE_NOTES.md
53+
run: node ".github/workflows/scripts/update-changelog.mjs" --nightly --notes-only --notes RELEASE_NOTES.md
54+
55+
# Same-day re-run: drop the existing nightly release AND its tag first,
56+
# so the tag always points at the SHA that was actually built.
57+
- name: Replace same-day nightly (release + tag), if any
58+
env:
59+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60+
run: gh release delete "${{ env.NIGHTLY_TAG }}" --cleanup-tag --yes || true
5361
- name: Publish nightly prerelease
5462
uses: softprops/action-gh-release@v2
5563
with:
5664
tag_name: ${{ env.NIGHTLY_TAG }}
5765
name: ${{ env.NIGHTLY_TAG }}
66+
target_commitish: ${{ github.event.workflow_run.head_sha || github.sha }}
5867
prerelease: true
5968
files: artifacts/*.nupkg
6069
body_path: RELEASE_NOTES.md # commit-message changelog, not GitHub's PR notes

.github/workflows/release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ jobs:
6262
git commit -m "* update changelog for $REL [skip ci]"
6363
git push
6464
fi
65-
- name: Create GitHub Release (dated marker; tags vyyyyMMdd)
65+
echo "REL_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
66+
- name: Create GitHub Release (dated marker; tags vyyyyMMdd at the changelog commit)
6667
uses: softprops/action-gh-release@v2
6768
with:
6869
tag_name: ${{ env.REL }}
6970
name: ${{ env.REL }}
71+
target_commitish: ${{ env.REL_SHA }}
7072
files: dist/*.nupkg
7173
body_path: RELEASE_NOTES.md # commit-message changelog, not GitHub's PR notes

.github/workflows/scripts/prune-nightlies.mjs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// Dry-run locally with `node .github/workflows/scripts/prune-nightlies.mjs --dry-run`.
1111

1212
import { spawnSync } from 'node:child_process';
13+
import { pathToFileURL } from 'node:url';
1314

1415
export const DAILY_KEEP = 7;
1516
export const WEEKLY_KEEP = 4;
@@ -23,8 +24,29 @@ function gh(args, opts = {}) {
2324
return r.stdout;
2425
}
2526

27+
// Synchronous sleep; the script is intentionally spawnSync-based.
28+
function sleep(ms) {
29+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30+
}
31+
32+
// The GitHub API occasionally answers 5xx; retry a few times and otherwise
33+
// skip the prune - missing one night is harmless, the next run catches up.
34+
const LIST_ATTEMPTS = 3;
35+
const RETRY_PAUSE_MS = 10_000;
36+
2637
function listNightlies() {
27-
const out = gh(['release', 'list', '--limit', '200', '--json', 'tagName,createdAt,isPrerelease']);
38+
let out = null;
39+
for (let attempt = 1; attempt <= LIST_ATTEMPTS; attempt++) {
40+
try { out = gh(['release', 'list', '--limit', '200', '--json', 'tagName,createdAt,isPrerelease']); break; }
41+
catch (e) {
42+
console.error(`warning: listing releases failed (attempt ${attempt}/${LIST_ATTEMPTS}): ${e.message}`);
43+
if (attempt < LIST_ATTEMPTS) sleep(RETRY_PAUSE_MS);
44+
}
45+
}
46+
if (out === null) {
47+
console.error('warning: could not list releases; skipping prune.');
48+
return [];
49+
}
2850
let all;
2951
try { all = JSON.parse(out); }
3052
catch (e) {
@@ -95,7 +117,11 @@ export function planRetention(releases, opts = {}) {
95117
const keep = new Set();
96118

97119
// --- Son: N newest releases -----------------------------------------
98-
const sonSlice = releases.slice(0, dailyN);
120+
// INVARIANT: the newest nightly is ALWAYS kept (hence max(1, …)). The
121+
// next nightly's release notes measure their delta from the nearest tag
122+
// (update-changelog.mjs), so deleting the newest tag would silently widen
123+
// the next delta and re-report already-published changes.
124+
const sonSlice = releases.slice(0, Math.max(1, dailyN));
99125
for (const r of sonSlice) keep.add(r.tag);
100126
const sonWeeks = new Set(sonSlice.map(r => isoWeekKey(r.date)));
101127
const sonMonths = new Set(sonSlice.map(r => r.iso.slice(0, 7)));
@@ -135,8 +161,10 @@ export function planRetention(releases, opts = {}) {
135161
}
136162

137163
// --- Entry point -------------------------------------------------------------
138-
// Skipped when imported as a module (for tests).
139-
if (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]?.replace(/\\/g,'/'))) {
164+
// Skipped when imported as a module (for tests). pathToFileURL handles Windows
165+
// separators AND percent-encodes blanks, so the comparison also holds for
166+
// working copies living in paths with spaces.
167+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
140168
main();
141169
}
142170
function main() {

.github/workflows/scripts/update-changelog.mjs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
// Maintains CHANGELOG.md. Invoked from the nightly and release workflows.
22
//
33
// Usage:
4-
// node .github/workflows/scripts/update-changelog.mjs --nightly # "## Nightly YYYY-MM-DD (<version>)"
5-
// node .github/workflows/scripts/update-changelog.mjs --release v1.2.3 # "## v1.2.3 (YYYY-MM-DD)"
4+
// node .github/workflows/scripts/update-changelog.mjs --nightly --notes-only # "## Nightly YYYY-MM-DD (<version>)"
5+
// node .github/workflows/scripts/update-changelog.mjs --release v1.2.3 # "## v1.2.3 (YYYY-MM-DD)"
6+
//
7+
// Flags:
8+
// --notes <file> write the release-notes body (section minus header) there
9+
// --notes-only generate notes but do NOT touch CHANGELOG.md — used by
10+
// nightly.yml, which never commits the changelog (only
11+
// release.yml does), so writing it would be a dead write
12+
// --version X.Y.Z decorates the nightly header
613
//
714
// Commit subject conventions (see bucketize() below):
815
// + Added * Changed # Fixed - Removed ! TODO
9-
// Anything else goes into "Other".
16+
// Anything else goes into "Other". The workflow's own changelog-refresh
17+
// commits ("* update changelog for vyyyyMMdd") are bookkeeping, not change —
18+
// they are filtered out entirely (see isChangelogCommit()).
1019

1120
import fs from 'node:fs';
1221
import path from 'node:path';
@@ -42,6 +51,14 @@ const PREFIX_TO_BUCKET = {
4251
'!': 'TODO',
4352
};
4453

54+
// The release workflow's own bookkeeping commit ("* update changelog for
55+
// vyyyyMMdd [skip ci]"). It must never appear in notes: even though release.yml
56+
// tags the release ON that commit, a manual tag or a resurrected history could
57+
// still leak it into a later range — so the generator filters it defensively.
58+
export function isChangelogCommit(subject) {
59+
return /^\*\s*update changelog for v\d{8}\b/.test(subject || '');
60+
}
61+
4562
export function bucketize(commits) {
4663
const buckets = Object.fromEntries(BUCKET_ORDER.map(b => [b, []]));
4764
for (const c of commits) {
@@ -89,8 +106,10 @@ export function prependSection(existing, section) {
89106
// ---------------------------------------------------------------------------
90107
// Git helpers (used when invoked as a script)
91108
// ---------------------------------------------------------------------------
92-
function gitLastTag() {
93-
const r = spawnSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' });
109+
function gitLastTag(matchPattern) {
110+
const args = ['describe', '--tags', '--abbrev=0'];
111+
if (matchPattern) args.push(`--match=${matchPattern}`);
112+
const r = spawnSync('git', args, { encoding: 'utf8' });
94113
if (r.status !== 0) return null;
95114
return (r.stdout || '').trim() || null;
96115
}
@@ -119,21 +138,27 @@ function main() {
119138
let tag = null;
120139
let version = null;
121140
let notesPath = null; // --notes <file>: write the release-notes body here
141+
let notesOnly = false; // --notes-only: leave CHANGELOG.md untouched
122142

123143
for (let i = 0; i < argv.length; i++) {
124144
const a = argv[i];
125145
if (a === '--nightly') { mode = 'nightly'; }
126146
else if (a === '--release') { mode = 'release'; tag = argv[++i]; }
127147
else if (a === '--version') { version = argv[++i]; }
128148
else if (a === '--notes') { notesPath = argv[++i]; }
149+
else if (a === '--notes-only') { notesOnly = true; }
129150
}
130151
if (!mode) {
131-
console.error('usage: update-changelog.mjs --nightly | --release <tag> [--version X.Y.Z.B] [--notes <file>]');
152+
console.error('usage: update-changelog.mjs --nightly | --release <tag> [--version X.Y.Z.B] [--notes <file>] [--notes-only]');
132153
process.exit(2);
133154
}
134155

135-
const since = gitLastTag();
136-
const commits = gitCommits(since);
156+
// Releases measure from the last STABLE tag (v1.2.3 / vyyyyMMdd) so a
157+
// same-day nightly-* tag never swallows the release's commit range.
158+
// Nightlies keep the nearest tag of any kind: their notes are the delta
159+
// since the previous nightly (or stable, whichever is closer).
160+
const since = gitLastTag(mode === 'release' ? 'v[0-9]*' : null);
161+
const commits = gitCommits(since).filter(c => !isChangelogCommit(c.subject));
137162

138163
const header = mode === 'nightly'
139164
? `Nightly ${isoToday()}${version ? ` (${version})` : ''}`
@@ -151,6 +176,11 @@ function main() {
151176
console.log(`Wrote release notes to ${notesPath}.`);
152177
}
153178

179+
if (notesOnly) {
180+
console.log('--notes-only: CHANGELOG.md left untouched.');
181+
return;
182+
}
183+
154184
if (commits.length === 0) {
155185
console.log('No new commits since last tag -- CHANGELOG unchanged.');
156186
return;
@@ -161,7 +191,8 @@ function main() {
161191
console.log(`CHANGELOG updated with ${commits.length} commit(s) under "${header}".`);
162192
}
163193

164-
const invokedPath = process.argv[1] ? process.argv[1].replace(/\\/g, '/') : '';
165-
if (import.meta.url === `file://${invokedPath}` || import.meta.url.endsWith(invokedPath)) {
194+
// pathToFileURL handles Windows separators AND percent-encodes blanks, so the
195+
// comparison also holds for working copies living in paths with spaces.
196+
if (process.argv[1] && import.meta.url === url.pathToFileURL(process.argv[1]).href) {
166197
main();
167198
}

0 commit comments

Comments
 (0)