11import { readFileSync , writeFileSync , readdirSync , existsSync } from 'node:fs'
2- import { resolve , join } from 'node:path'
2+ import { resolve , join , relative } from 'node:path'
33import { fileURLToPath } from 'node:url'
44import c from 'picocolors'
55import prompts from 'prompts'
@@ -49,8 +49,113 @@ const run = (bin, args, opts = {}) => {
4949 return execa ( bin , args , { stdio : 'inherit' , ...opts } )
5050}
5151
52+ // Always executes regardless of DRY_RUN (read-only git operations)
53+ const runRead = ( bin , args , opts = { } ) =>
54+ execa ( bin , args , { stdio : 'pipe' , ...opts } )
55+
5256const step = ( msg ) => console . log ( c . cyan ( msg ) )
5357
58+ // Conventional commit type → changelog section title
59+ const COMMIT_TYPES = {
60+ feat : '✨ Features' ,
61+ fix : '🐛 Bug Fixes' ,
62+ perf : '⚡ Performance Improvements' ,
63+ refactor : '♻️ Refactoring' ,
64+ docs : '📝 Documentation' ,
65+ build : '🏗️ Build System' ,
66+ ci : '👷 CI/CD' ,
67+ test : '✅ Tests' ,
68+ chore : '🔧 Chores'
69+ }
70+
71+ async function getLastTag ( pkgName ) {
72+ try {
73+ const { stdout } = await runRead ( 'git' , [
74+ 'tag' , '--sort=-creatordate' , '--list' , `${ pkgName } -v*`
75+ ] , { cwd : rootDir } )
76+ const tags = stdout . trim ( ) . split ( '\n' ) . filter ( Boolean )
77+ return tags [ 0 ] || null
78+ } catch {
79+ return null
80+ }
81+ }
82+
83+ async function getCommitsSince ( tag , pkgDir ) {
84+ const range = tag ? `${ tag } ..HEAD` : 'HEAD'
85+ const relPath = relative ( rootDir , pkgDir )
86+ try {
87+ const { stdout } = await runRead ( 'git' , [
88+ 'log' , range , '--format=%H|%s' , '--' , relPath
89+ ] , { cwd : rootDir } )
90+ return stdout . trim ( ) . split ( '\n' ) . filter ( Boolean )
91+ } catch {
92+ return [ ]
93+ }
94+ }
95+
96+ function parseConventionalCommit ( line ) {
97+ const [ hash , ...rest ] = line . split ( '|' )
98+ const subject = rest . join ( '|' )
99+ // Matches: type(scope)!: description or type!: description or type: description
100+ const match = subject . match ( / ^ ( \w + ) ( \( [ ^ ) ] + \) ) ? ( ! ) ? : \s + ( .+ ) $ / )
101+ if ( ! match ) return null
102+ const [ , type , scope , breaking , description ] = match
103+ return {
104+ hash : hash . substring ( 0 , 7 ) ,
105+ type,
106+ scope : scope ? scope . slice ( 1 , - 1 ) : null ,
107+ breaking : ! ! breaking ,
108+ description
109+ }
110+ }
111+
112+ function generateChangelogEntry ( version , commits ) {
113+ const date = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ]
114+ const categories = { breaking : [ ] , ...Object . fromEntries ( Object . keys ( COMMIT_TYPES ) . map ( k => [ k , [ ] ] ) ) }
115+
116+ for ( const line of commits ) {
117+ const commit = parseConventionalCommit ( line )
118+ if ( ! commit ) continue
119+ if ( commit . breaking ) categories . breaking . push ( commit )
120+ if ( categories [ commit . type ] ) categories [ commit . type ] . push ( commit )
121+ }
122+
123+ const sections = [
124+ { key : 'breaking' , title : '⚠️ Breaking Changes' } ,
125+ ...Object . entries ( COMMIT_TYPES ) . map ( ( [ key , title ] ) => ( { key, title } ) )
126+ ]
127+
128+ let content = `## [${ version } ] - ${ date } \n\n`
129+ let hasContent = false
130+
131+ for ( const { key, title } of sections ) {
132+ if ( ! categories [ key ] || categories [ key ] . length === 0 ) continue
133+ hasContent = true
134+ content += `### ${ title } \n\n`
135+ for ( const commit of categories [ key ] ) {
136+ const scope = commit . scope ? `**${ commit . scope } :** ` : ''
137+ content += `- ${ scope } ${ commit . description } ([${ commit . hash } ](https://github.com/Priestch/markopress/commit/${ commit . hash } ))\n`
138+ }
139+ content += '\n'
140+ }
141+
142+ if ( ! hasContent ) {
143+ content += '_No significant changes_\n\n'
144+ }
145+
146+ return content
147+ }
148+
149+ function updateChangelog ( changelogPath , entry ) {
150+ const header = '# Changelog\n\n'
151+ let existing = ''
152+ if ( existsSync ( changelogPath ) ) {
153+ existing = readFileSync ( changelogPath , 'utf-8' ) . replace ( / ^ # C h a n g e l o g \n \n / , '' )
154+ }
155+ // Ensure blank line between entries
156+ writeFileSync ( changelogPath , header + entry + '\n' + existing )
157+ }
158+
54159async function main ( ) {
55160 if ( DRY_RUN ) {
56161 console . log ( c . yellow ( '🧪 DRY RUN MODE - No actual changes will be made\n' ) )
@@ -157,6 +262,27 @@ async function main() {
157262 step ( '\nUpdating the package version...' )
158263 updatePackage ( pkgDir , targetVersion )
159264
265+ const pkgName = pkg . name . replace ( / ^ @ [ ^ / ] + \/ / , '' ) // Remove scope for tag/commit message and changelog lookup
266+
267+ // Generate changelog.
268+ step ( '\nGenerating changelog...' )
269+ const lastTag = await getLastTag ( pkgName )
270+ const commits = await getCommitsSince ( lastTag , pkgDir )
271+ if ( lastTag ) {
272+ console . log ( c . gray ( ` Commits since ${ lastTag } : ${ commits . length } ` ) )
273+ } else {
274+ console . log ( c . gray ( ` No previous tag found, including all commits (${ commits . length } )` ) )
275+ }
276+ const changelogEntry = generateChangelogEntry ( targetVersion , commits )
277+ const changelogPath = join ( pkgDir , 'CHANGELOG.md' )
278+ if ( ! DRY_RUN ) {
279+ updateChangelog ( changelogPath , changelogEntry )
280+ console . log ( c . green ( '✓ CHANGELOG.md updated' ) )
281+ } else {
282+ console . log ( c . gray ( '[dry-run] Would update CHANGELOG.md:' ) )
283+ console . log ( c . gray ( changelogEntry ) )
284+ }
285+
160286 // Build the package.
161287 step ( '\nBuilding the package...' )
162288 await run ( 'pnpm' , [ 'build' ] , { cwd : pkgDir } )
@@ -172,7 +298,6 @@ async function main() {
172298
173299 // Commit changes to the Git and create a tag.
174300 step ( '\nCommitting changes...' )
175- const pkgName = pkg . name . replace ( / ^ @ [ ^ / ] + \/ / , '' ) // Remove scope for cleaner commit message
176301
177302 // Stage all changes (root + package)
178303 await run ( 'git' , [ 'add' , '-A' ] , { cwd : rootDir } )
0 commit comments