Skip to content

Commit 0cdf6a8

Browse files
committed
ci: add job to update changelog
1 parent 643adb6 commit 0cdf6a8

File tree

4 files changed

+282
-0
lines changed

4 files changed

+282
-0
lines changed

.github/workflows/changelog.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
pull-requests: write
10+
contents: write
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
14+
cancel-in-progress: ${{ github.event_name != 'push' }}
15+
16+
jobs:
17+
update-changelog:
18+
if: github.repository_owner == 'nuxt'
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
23+
with:
24+
fetch-depth: 0
25+
- run: corepack enable
26+
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
27+
with:
28+
node-version: lts/*
29+
cache: pnpm
30+
31+
- name: 📦 Install dependencies
32+
run: pnpm install
33+
34+
- name: 🚧 Update changelog
35+
run: node --experimental-strip-types ./scripts/update-changelog.ts
36+
env:
37+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
"@antfu/eslint-config": "^4.1.0",
2626
"@nuxt/eslint-config": "^0.7.5",
2727
"@types/node": "^22.12.0",
28+
"@types/semver": "^7.5.8",
2829
"@vitest/coverage-v8": "^3.0.4",
2930
"changelogen": "^0.5.7",
3031
"eslint": "^9.19.0",
3132
"knip": "^5.43.6",
3233
"pkg-pr-new": "^0.0.39",
34+
"semver": "^7.6.3",
3335
"std-env": "^3.8.0",
3436
"tinyexec": "^0.3.2",
3537
"typescript": "^5.7.3",

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/update-changelog.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import type { ResolvedChangelogConfig } from 'changelogen'
2+
3+
import { execSync } from 'node:child_process'
4+
import { promises as fsp } from 'node:fs'
5+
import { join, resolve } from 'node:path'
6+
import process from 'node:process'
7+
8+
import { determineSemverChange, generateMarkDown, getCurrentGitBranch, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen'
9+
import { inc } from 'semver'
10+
11+
const repo = `nuxt/cli`
12+
const corePackage = 'nuxi'
13+
const ignoredPackages = ['create-nuxt-app']
14+
const user = {
15+
name: 'Daniel Roe',
16+
email: 'daniel@roe.dev',
17+
}
18+
19+
async function main() {
20+
const releaseBranch = getCurrentGitBranch()
21+
const workspace = await loadWorkspace(process.cwd())
22+
const config = await loadChangelogConfig(process.cwd(), {})
23+
24+
const commits = await getLatestCommits(config).then(commits => commits.filter(c => config.types[c.type] && !(c.type === 'chore' && c.scope === 'deps' && !c.isBreaking)))
25+
const bumpType = (await determineBumpType(config)) || 'patch'
26+
27+
const newVersion = inc(workspace.find(corePackage).data.version, bumpType)
28+
const changelog = await generateMarkDown(commits, config)
29+
30+
// Create and push a branch with bumped versions if it has not already been created
31+
const branchExists = execSync(`git ls-remote --heads origin v${newVersion}`).toString().trim().length > 0
32+
if (!branchExists) {
33+
for (const [key, value] of Object.entries(user)) {
34+
execSync(`git config --global user.${key} "${value}"`)
35+
execSync(`git config --global user.${key} "${value}"`)
36+
}
37+
execSync(`git checkout -b v${newVersion}`)
38+
39+
for (const pkg of workspace.packages.filter(p => !p.data.private)) {
40+
workspace.setVersion(pkg.data.name, newVersion!)
41+
}
42+
await workspace.save()
43+
44+
execSync(`git commit -am v${newVersion}`)
45+
execSync(`git push -u origin v${newVersion}`)
46+
}
47+
48+
// Get the current PR for this release, if it exists
49+
const [currentPR] = await fetch(`https://api.github.com/repos/${repo}/pulls?head=nuxt:v${newVersion}`).then(r => r.json())
50+
const contributors = await getContributors()
51+
52+
const releaseNotes = [
53+
currentPR?.body.replace(/## 👉 Changelog[\s\S]*$/, '') || `> ${newVersion} is the next ${bumpType} release.\n>\n> **Timetable**: to be announced.`,
54+
'## 👉 Changelog',
55+
changelog
56+
.replace(/^## v.*\n/, '')
57+
.replace(`...${releaseBranch}`, `...v${newVersion}`)
58+
.replace(/### Contributors[\s\S]*$/, ''),
59+
'### ❤️ Contributors',
60+
contributors.map(c => `- ${c.name} (@${c.username})`).join('\n'),
61+
].join('\n')
62+
63+
// Create a PR with release notes if none exists
64+
if (!currentPR) {
65+
return await fetch(`https://api.github.com/repos/${repo}/pulls`, {
66+
method: 'POST',
67+
headers: {
68+
'Authorization': `token ${process.env.GITHUB_TOKEN}`,
69+
'content-type': 'application/json',
70+
},
71+
body: JSON.stringify({
72+
title: `v${newVersion}`,
73+
head: `v${newVersion}`,
74+
base: releaseBranch,
75+
body: releaseNotes,
76+
draft: true,
77+
}),
78+
})
79+
}
80+
81+
// Update release notes if the pull request does exist
82+
await fetch(`https://api.github.com/repos/${repo}/pulls/${currentPR.number}`, {
83+
method: 'PATCH',
84+
headers: {
85+
'Authorization': `token ${process.env.GITHUB_TOKEN}`,
86+
'content-type': 'application/json',
87+
},
88+
body: JSON.stringify({
89+
body: releaseNotes,
90+
}),
91+
})
92+
}
93+
94+
main().catch((err) => {
95+
console.error(err)
96+
process.exit(1)
97+
})
98+
99+
export interface Dep {
100+
name: string
101+
range: string
102+
type: string
103+
}
104+
105+
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T
106+
export type Package = ThenArg<ReturnType<typeof loadPackage>>
107+
108+
export async function loadPackage(dir: string) {
109+
const pkgPath = resolve(dir, 'package.json')
110+
const data = JSON.parse(await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}'))
111+
const save = () => fsp.writeFile(pkgPath, `${JSON.stringify(data, null, 2)}\n`)
112+
113+
const updateDeps = (reviver: (dep: Dep) => Dep | void) => {
114+
for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
115+
if (!data[type]) {
116+
continue
117+
}
118+
for (const e of Object.entries(data[type])) {
119+
const dep: Dep = { name: e[0], range: e[1] as string, type }
120+
delete data[type][dep.name]
121+
const updated = reviver(dep) || dep
122+
data[updated.type] = data[updated.type] || {}
123+
data[updated.type][updated.name] = updated.range
124+
}
125+
}
126+
}
127+
128+
return {
129+
dir,
130+
data,
131+
save,
132+
updateDeps,
133+
}
134+
}
135+
136+
export async function loadWorkspace(dir: string) {
137+
const workspacePkg = await loadPackage(dir)
138+
139+
const packages: Package[] = []
140+
141+
for await (const pkgDir of fsp.glob(['packages/*'], { withFileTypes: true })) {
142+
if (!pkgDir.isDirectory()) {
143+
continue
144+
}
145+
const pkg = await loadPackage(join(pkgDir.parentPath, pkgDir.name))
146+
if (!pkg.data.name || ignoredPackages.includes(pkg.data.name)) {
147+
continue
148+
}
149+
console.log(pkg.data.name)
150+
packages.push(pkg)
151+
}
152+
153+
const find = (name: string) => {
154+
const pkg = packages.find(pkg => pkg.data.name === name)
155+
if (!pkg) {
156+
throw new Error(`Workspace package not found: ${name}`)
157+
}
158+
return pkg
159+
}
160+
161+
const rename = (from: string, to: string) => {
162+
find(from).data._name = find(from).data.name
163+
find(from).data.name = to
164+
for (const pkg of packages) {
165+
pkg.updateDeps((dep) => {
166+
if (dep.name === from && !dep.range.startsWith('npm:')) {
167+
dep.range = `npm:${to}@${dep.range}`
168+
}
169+
})
170+
}
171+
}
172+
173+
const setVersion = (name: string, newVersion: string, opts: { updateDeps?: boolean } = {}) => {
174+
find(name).data.version = newVersion
175+
if (!opts.updateDeps) {
176+
return
177+
}
178+
179+
for (const pkg of packages) {
180+
pkg.updateDeps((dep) => {
181+
if (dep.name === name) {
182+
dep.range = newVersion
183+
}
184+
})
185+
}
186+
}
187+
188+
const save = () => Promise.all(packages.map(pkg => pkg.save()))
189+
190+
return {
191+
dir,
192+
workspacePkg,
193+
packages,
194+
save,
195+
find,
196+
rename,
197+
setVersion,
198+
}
199+
}
200+
201+
export async function determineBumpType(config: ResolvedChangelogConfig) {
202+
const commits = await getLatestCommits(config)
203+
204+
const bumpType = determineSemverChange(commits, config)
205+
206+
return bumpType === 'major' ? 'minor' : bumpType
207+
}
208+
209+
export async function getLatestCommits(config: ResolvedChangelogConfig) {
210+
const latestTag = execSync('git describe --tags --abbrev=0').toString().trim()
211+
212+
return parseCommits(await getGitDiff(latestTag), config)
213+
}
214+
215+
export async function getContributors() {
216+
const contributors = [] as Array<{ name: string, username: string }>
217+
const emails = new Set<string>()
218+
const latestTag = execSync('git describe --tags --abbrev=0').toString().trim()
219+
const rawCommits = await getGitDiff(latestTag)
220+
for (const commit of rawCommits) {
221+
if (emails.has(commit.author.email) || commit.author.name === 'renovate[bot]') {
222+
continue
223+
}
224+
const { author } = await fetch(`https://api.github.com/repos/${repo}/commits/${commit.shortHash}`, {
225+
headers: {
226+
'User-Agent': `${repo} github action automation`,
227+
'Accept': 'application/vnd.github.v3+json',
228+
'Authorization': `token ${process.env.GITHUB_TOKEN}`,
229+
},
230+
}).then(r => r.json() as Promise<{ author: { login: string, email: string } }>)
231+
if (!contributors.some(c => c.username === author.login)) {
232+
contributors.push({ name: commit.author.name, username: author.login })
233+
}
234+
emails.add(author.email)
235+
}
236+
return contributors
237+
}

0 commit comments

Comments
 (0)