Skip to content

Commit 68ccd33

Browse files
committed
feat: add changelog generation integrated into release process
- Add conventional commit parsing to generate changelog - Create/update CHANGELOG.md during release - Add GitHub workflow to auto-create releases from tags - Use softprops/action-gh-release@v2 instead of deprecated action - Generate categorized changelog with commit links
1 parent 257319c commit 68ccd33

2 files changed

Lines changed: 193 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Create Release
2+
3+
on:
4+
push:
5+
tags:
6+
- '*-v[0-9]*.[0-9]*.[0-9]*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Extract package name and version from tag
18+
id: tag
19+
run: |
20+
TAG="${GITHUB_REF_NAME}"
21+
# Tag format: <package>-v<version> (e.g. markopress-v0.1.0)
22+
if [[ ! "$TAG" =~ ^(.+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then
23+
echo "Invalid tag format: $TAG" >&2
24+
exit 1
25+
fi
26+
PKG_NAME="${BASH_REMATCH[1]}"
27+
VERSION="${BASH_REMATCH[2]}"
28+
IS_PRERELEASE=false
29+
if [[ "$VERSION" == *-* ]]; then
30+
IS_PRERELEASE=true
31+
fi
32+
echo "pkg_name=${PKG_NAME}" >> "$GITHUB_OUTPUT"
33+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
34+
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
35+
36+
- name: Extract changelog entry for this version
37+
id: changelog
38+
run: |
39+
CHANGELOG="packages/${{ steps.tag.outputs.pkg_name }}/CHANGELOG.md"
40+
if [ ! -f "$CHANGELOG" ]; then
41+
echo "body=No changelog found." >> "$GITHUB_OUTPUT"
42+
exit 0
43+
fi
44+
# Extract the section for the released version between ## headings
45+
VERSION="${{ steps.tag.outputs.version }}"
46+
BODY=$(awk "/^## \[${VERSION}\]/{found=1; next} found && /^## /{exit} found{print}" "$CHANGELOG")
47+
if [ -z "$BODY" ]; then
48+
BODY="_No changelog entry found for v${VERSION}._"
49+
fi
50+
# Use a delimiter to preserve multi-line content
51+
{
52+
echo "body<<CHANGELOG_EOF"
53+
echo "$BODY"
54+
echo "CHANGELOG_EOF"
55+
} >> "$GITHUB_OUTPUT"
56+
57+
- name: Create GitHub Release
58+
uses: softprops/action-gh-release@v2
59+
with:
60+
tag_name: ${{ github.ref_name }}
61+
name: ${{ steps.tag.outputs.pkg_name }} v${{ steps.tag.outputs.version }}
62+
body: ${{ steps.changelog.outputs.body }}
63+
draft: false
64+
prerelease: ${{ steps.tag.outputs.is_prerelease == 'true' }}
65+
env:
66+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

scripts/release.js

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
2-
import { resolve, join } from 'node:path'
2+
import { resolve, join, relative } from 'node:path'
33
import { fileURLToPath } from 'node:url'
44
import c from 'picocolors'
55
import 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+
5256
const 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(/^# Changelog\n\n/, '')
154+
}
155+
// Ensure blank line between entries
156+
writeFileSync(changelogPath, header + entry + '\n' + existing)
157+
}
158+
54159
async 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

Comments
 (0)