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
1120import fs from 'node:fs' ;
1221import 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 * u p d a t e c h a n g e l o g f o r v \d { 8 } \b / . test ( subject || '' ) ;
60+ }
61+
4562export 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