Skip to content

Commit 25d19ec

Browse files
committed
ci: add changelogensets
1 parent f62a9eb commit 25d19ec

5 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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' && !contains(github.event.head_commit.message, 'v1.')
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 0
25+
- run: corepack enable
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 20
29+
cache: "pnpm"
30+
31+
- name: Install dependencies
32+
run: pnpm install
33+
34+
- run: pnpm jiti ./scripts/update-changelog.ts
35+
env:
36+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,15 @@
5050
"@vue/test-utils": "^2.4.4",
5151
"changelogen": "^0.5.5",
5252
"eslint": "8.57.0",
53+
"execa": "^8.0.1",
5354
"globby": "^14.0.1",
5455
"happy-dom": "^13.6.2",
5556
"ipx": "^2.1.0",
5657
"jiti": "1.21.0",
5758
"nuxt": "^3.10.3",
59+
"ofetch": "^1.3.3",
5860
"playwright-core": "^1.42.1",
61+
"semver": "^7.6.0",
5962
"typescript": "5.3.3",
6063
"vitest": "^1.3.1",
6164
"vitest-environment-nuxt": "^1.0.0",

pnpm-lock.yaml

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

scripts/_utils.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { promises as fsp } from 'node:fs'
2+
import { execSync } from 'node:child_process'
3+
import { resolve } from 'pathe'
4+
import { execaSync } from 'execa'
5+
import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen'
6+
7+
export interface Dep {
8+
name: string,
9+
range: string,
10+
type: string
11+
}
12+
13+
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T
14+
export type Package = ThenArg<ReturnType<typeof loadPackage>>
15+
16+
export async function loadPackage (dir: string) {
17+
const pkgPath = resolve(dir, 'package.json')
18+
const data = JSON.parse(await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}'))
19+
const save = () => fsp.writeFile(pkgPath, JSON.stringify(data, null, 2) + '\n')
20+
21+
const updateDeps = (reviver: (dep: Dep) => Dep | void) => {
22+
for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
23+
if (!data[type]) { continue }
24+
for (const e of Object.entries(data[type])) {
25+
const dep: Dep = { name: e[0], range: e[1] as string, type }
26+
delete data[type][dep.name]
27+
const updated = reviver(dep) || dep
28+
data[updated.type] = data[updated.type] || {}
29+
data[updated.type][updated.name] = updated.range
30+
}
31+
}
32+
}
33+
34+
return {
35+
dir,
36+
data,
37+
save,
38+
updateDeps
39+
}
40+
}
41+
42+
export async function loadWorkspace (dir: string) {
43+
const workspacePkg = await loadPackage(dir)
44+
45+
const packages = [await loadPackage(process.cwd())]
46+
47+
const find = (name: string) => {
48+
const pkg = packages.find(pkg => pkg.data.name === name)
49+
if (!pkg) {
50+
throw new Error('Workspace package not found: ' + name)
51+
}
52+
return pkg
53+
}
54+
55+
const rename = (from: string, to: string) => {
56+
find(from).data._name = find(from).data.name
57+
find(from).data.name = to
58+
for (const pkg of packages) {
59+
pkg.updateDeps((dep) => {
60+
if (dep.name === from && !dep.range.startsWith('npm:')) {
61+
dep.range = 'npm:' + to + '@' + dep.range
62+
}
63+
})
64+
}
65+
}
66+
67+
const setVersion = (name: string, newVersion: string, opts: { updateDeps?: boolean } = {}) => {
68+
find(name).data.version = newVersion
69+
if (!opts.updateDeps) { return }
70+
71+
for (const pkg of packages) {
72+
pkg.updateDeps((dep) => {
73+
if (dep.name === name) {
74+
dep.range = newVersion
75+
}
76+
})
77+
}
78+
}
79+
80+
const save = () => Promise.all(packages.map(pkg => pkg.save()))
81+
82+
return {
83+
dir,
84+
workspacePkg,
85+
packages,
86+
save,
87+
find,
88+
rename,
89+
setVersion
90+
}
91+
}
92+
93+
export async function determineBumpType () {
94+
const config = await loadChangelogConfig(process.cwd())
95+
const commits = await getLatestCommits()
96+
97+
const bumpType = determineSemverChange(commits, config)
98+
99+
return bumpType === 'major' ? 'minor' : bumpType
100+
}
101+
102+
export async function getLatestCommits () {
103+
const config = await loadChangelogConfig(process.cwd())
104+
const latestTag = execaSync('git', ['describe', '--tags', '--abbrev=0']).stdout
105+
106+
return parseCommits(await getGitDiff(latestTag), config)
107+
}
108+
109+
export async function getContributors () {
110+
const contributors = [] as Array<{ name: string, username: string }>
111+
const emails = new Set<string>()
112+
const latestTag = execSync('git describe --tags --abbrev=0').toString().trim()
113+
const rawCommits = await getGitDiff(latestTag)
114+
for (const commit of rawCommits) {
115+
if (emails.has(commit.author.email) || commit.author.name === 'renovate[bot]') { continue }
116+
const { author } = await $fetch<{ author: { login: string, email: string } }>(`https://api.github.com/repos/nuxt/image/commits/${commit.shortHash}`, {
117+
headers: {
118+
'User-Agent': 'nuxt/image',
119+
Accept: 'application/vnd.github.v3+json',
120+
Authorization: `token ${process.env.GITHUB_TOKEN}`
121+
}
122+
})
123+
if (!contributors.some(c => c.username === author.login)) {
124+
contributors.push({ name: commit.author.name, username: author.login })
125+
}
126+
emails.add(author.email)
127+
}
128+
return contributors
129+
}

scripts/update-changelog.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { execSync } from 'node:child_process'
2+
import { $fetch } from 'ofetch'
3+
import { inc } from 'semver'
4+
import { generateMarkDown, getCurrentGitBranch, loadChangelogConfig } from 'changelogen'
5+
import { consola } from 'consola'
6+
import { determineBumpType, getContributors, getLatestCommits, loadWorkspace } from './_utils'
7+
8+
async function main () {
9+
const releaseBranch = await getCurrentGitBranch()
10+
const workspace = await loadWorkspace(process.cwd())
11+
const config = await loadChangelogConfig(process.cwd(), {})
12+
13+
const commits = await getLatestCommits().then(commits => commits.filter(
14+
c => config.types[c.type] && !(c.type === 'chore' && c.scope === 'deps' && !c.isBreaking)
15+
))
16+
const bumpType = await determineBumpType()
17+
18+
const newVersion = inc(workspace.find('@nuxt/image').data.version, bumpType || 'patch')
19+
const changelog = await generateMarkDown(commits, config)
20+
21+
// Create and push a branch with bumped versions if it has not already been created
22+
const branchExists = execSync(`git ls-remote --heads origin v${newVersion}`).toString().trim().length > 0
23+
if (!branchExists) {
24+
execSync('git config --global user.email "daniel@roe.dev"')
25+
execSync('git config --global user.name "Daniel Roe"')
26+
execSync(`git checkout -b v${newVersion}`)
27+
28+
for (const pkg of workspace.packages.filter(p => !p.data.private)) {
29+
workspace.setVersion(pkg.data.name, newVersion!)
30+
}
31+
await workspace.save()
32+
33+
execSync(`git commit -am v${newVersion}`)
34+
execSync(`git push -u origin v${newVersion}`)
35+
}
36+
37+
// Get the current PR for this release, if it exists
38+
const [currentPR] = await $fetch(`https://api.github.com/repos/nuxt/image/pulls?head=nuxt:v${newVersion}`)
39+
const contributors = await getContributors()
40+
41+
const releaseNotes = [
42+
currentPR?.body.replace(/## 👉 Changelog[\s\S]*$/, '') || `> ${newVersion} is the next ${bumpType} release.\n>\n> **Timetable**: to be announced.`,
43+
'## 👉 Changelog',
44+
changelog
45+
.replace(/^## v.*?\n/, '')
46+
.replace(`...${releaseBranch}`, `...v${newVersion}`)
47+
.replace(/### Contributors[\s\S]*$/, ''),
48+
'### ❤️ Contributors',
49+
contributors.map(c => `- ${c.name} (@${c.username})`).join('\n')
50+
].join('\n')
51+
52+
// Create a PR with release notes if none exists
53+
if (!currentPR) {
54+
return await $fetch('https://api.github.com/repos/nuxt/image/pulls', {
55+
method: 'POST',
56+
headers: {
57+
Authorization: `token ${process.env.GITHUB_TOKEN}`
58+
},
59+
body: {
60+
title: `v${newVersion}`,
61+
head: `v${newVersion}`,
62+
base: releaseBranch,
63+
body: releaseNotes,
64+
draft: true
65+
}
66+
})
67+
}
68+
69+
// Update release notes if the pull request does exist
70+
await $fetch(`https://api.github.com/repos/nuxt/image/pulls/${currentPR.number}`, {
71+
method: 'PATCH',
72+
headers: {
73+
Authorization: `token ${process.env.GITHUB_TOKEN}`
74+
},
75+
body: {
76+
body: releaseNotes
77+
}
78+
})
79+
}
80+
81+
main().catch((err) => {
82+
consola.error(err)
83+
process.exit(1)
84+
})

0 commit comments

Comments
 (0)