Skip to content

Commit 0cdf6a8

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

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

‎.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+
importtype{ResolvedChangelogConfig}from'changelogen'
2+
3+
import{execSync}from'node:child_process'
4+
import{promisesasfsp}from'node:fs'
5+
import{join,resolve}from'node:path'
6+
importprocessfrom'node:process'
7+
8+
import{determineSemverChange,generateMarkDown,getCurrentGitBranch,getGitDiff,loadChangelogConfig,parseCommits}from'changelogen'
9+
import{inc}from'semver'
10+
11+
constrepo=`nuxt/cli`
12+
constcorePackage='nuxi'
13+
constignoredPackages=['create-nuxt-app']
14+
constuser={
15+
name: 'Daniel Roe',
16+
email: 'daniel@roe.dev',
17+
}
18+
19+
asyncfunctionmain(){
20+
constreleaseBranch=getCurrentGitBranch()
21+
constworkspace=awaitloadWorkspace(process.cwd())
22+
constconfig=awaitloadChangelogConfig(process.cwd(),{})
23+
24+
constcommits=awaitgetLatestCommits(config).then(commits=>commits.filter(c=>config.types[c.type]&&!(c.type==='chore'&&c.scope==='deps'&&!c.isBreaking)))
25+
constbumpType=(awaitdetermineBumpType(config))||'patch'
26+
27+
constnewVersion=inc(workspace.find(corePackage).data.version,bumpType)
28+
constchangelog=awaitgenerateMarkDown(commits,config)
29+
30+
// Create and push a branch with bumped versions if it has not already been created
31+
constbranchExists=execSync(`git ls-remote --heads origin v${newVersion}`).toString().trim().length>0
32+
if(!branchExists){
33+
for(const[key,value]ofObject.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(constpkgofworkspace.packages.filter(p=>!p.data.private)){
40+
workspace.setVersion(pkg.data.name,newVersion!)
41+
}
42+
awaitworkspace.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]=awaitfetch(`https://api.github.com/repos/${repo}/pulls?head=nuxt:v${newVersion}`).then(r=>r.json())
50+
constcontributors=awaitgetContributors()
51+
52+
constreleaseNotes=[
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+
returnawaitfetch(`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+
awaitfetch(`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+
exportinterfaceDep{
100+
name: string
101+
range: string
102+
type: string
103+
}
104+
105+
typeThenArg<T>=TextendsPromiseLike<infer U> ? U : T
106+
exporttypePackage=ThenArg<ReturnType<typeofloadPackage>>
107+
108+
exportasyncfunctionloadPackage(dir: string){
109+
constpkgPath=resolve(dir,'package.json')
110+
constdata=JSON.parse(awaitfsp.readFile(pkgPath,'utf-8').catch(()=>'{}'))
111+
constsave=()=>fsp.writeFile(pkgPath,`${JSON.stringify(data,null,2)}\n`)
112+
113+
constupdateDeps=(reviver: (dep: Dep)=>Dep|void)=>{
114+
for(consttypeof['dependencies','devDependencies','optionalDependencies','peerDependencies']){
115+
if(!data[type]){
116+
continue
117+
}
118+
for(consteofObject.entries(data[type])){
119+
constdep: Dep={name: e[0],range: e[1]asstring, type }
120+
deletedata[type][dep.name]
121+
constupdated=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+
exportasyncfunctionloadWorkspace(dir: string){
137+
constworkspacePkg=awaitloadPackage(dir)
138+
139+
constpackages: Package[]=[]
140+
141+
forawait(constpkgDiroffsp.glob(['packages/*'],{withFileTypes: true})){
142+
if(!pkgDir.isDirectory()){
143+
continue
144+
}
145+
constpkg=awaitloadPackage(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+
constfind=(name: string)=>{
154+
constpkg=packages.find(pkg=>pkg.data.name===name)
155+
if(!pkg){
156+
thrownewError(`Workspace package not found: ${name}`)
157+
}
158+
returnpkg
159+
}
160+
161+
constrename=(from: string,to: string)=>{
162+
find(from).data._name=find(from).data.name
163+
find(from).data.name=to
164+
for(constpkgofpackages){
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+
constsetVersion=(name: string,newVersion: string,opts: {updateDeps?: boolean}={})=>{
174+
find(name).data.version=newVersion
175+
if(!opts.updateDeps){
176+
return
177+
}
178+
179+
for(constpkgofpackages){
180+
pkg.updateDeps((dep)=>{
181+
if(dep.name===name){
182+
dep.range=newVersion
183+
}
184+
})
185+
}
186+
}
187+
188+
constsave=()=>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+
exportasyncfunctiondetermineBumpType(config: ResolvedChangelogConfig){
202+
constcommits=awaitgetLatestCommits(config)
203+
204+
constbumpType=determineSemverChange(commits,config)
205+
206+
returnbumpType==='major' ? 'minor' : bumpType
207+
}
208+
209+
exportasyncfunctiongetLatestCommits(config: ResolvedChangelogConfig){
210+
constlatestTag=execSync('git describe --tags --abbrev=0').toString().trim()
211+
212+
returnparseCommits(awaitgetGitDiff(latestTag),config)
213+
}
214+
215+
exportasyncfunctiongetContributors(){
216+
constcontributors=[]asArray<{name: string,username: string}>
217+
constemails=newSet<string>()
218+
constlatestTag=execSync('git describe --tags --abbrev=0').toString().trim()
219+
constrawCommits=awaitgetGitDiff(latestTag)
220+
for(constcommitofrawCommits){
221+
if(emails.has(commit.author.email)||commit.author.name==='renovate[bot]'){
222+
continue
223+
}
224+
const{ author }=awaitfetch(`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()asPromise<{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+
returncontributors
237+
}

0 commit comments

Comments
 (0)