Skip to content

Commit

Permalink
ci: support automated release prs (#623)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Dec 2, 2023
1 parent f47dd9a commit 0dd61e0
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 3 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/changelogensets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: release

on:
push:
branches:
- main

permissions:
pull-requests: write
contents: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: ${{ github.event_name != 'push' }}

jobs:
update-changelog:
if: github.repository_owner == 'nuxt' && !contains(github.event.head_commit.message, 'v3.')
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
- run: corepack enable
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
with:
node-version: 20
cache: "pnpm"

- name: Install dependencies
run: pnpm install

- run: pnpm jiti ./scripts/update-changelog.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@testing-library/vue": "8.0.1",
"@types/estree": "1.0.5",
"@types/jsdom": "21.1.6",
"@types/semver": "^7.5.6",
"@vitest/ui": "1.0.0-beta.6",
"@vue/test-utils": "2.4.3",
"changelogen": "0.5.5",
Expand All @@ -74,9 +75,11 @@
"eslint-plugin-no-only-tests": "3.1.0",
"eslint-plugin-unicorn": "49.0.0",
"h3": "1.9.0",
"jiti": "^1.21.0",
"nuxt": "3.8.2",
"playwright-core": "1.40.1",
"rollup": "4.6.1",
"semver": "^7.5.4",
"unbuild": "latest",
"unimport": "3.6.0",
"vite": "5.0.4",
Expand Down
15 changes: 12 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 106 additions & 0 deletions scripts/_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { promises as fsp } from 'node:fs'
import { resolve } from 'pathe'
import { execaSync } from 'execa'
import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen'

export interface Dep {
name: string,
range: string,
type: string
}

type ThenArg<T> = T extends PromiseLike<infer U> ? U : T
export type Package = ThenArg<ReturnType<typeof loadPackage>>

export async function loadPackage (dir: string) {
const pkgPath = resolve(dir, 'package.json')
const data = JSON.parse(await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}'))
const save = () => fsp.writeFile(pkgPath, JSON.stringify(data, null, 2) + '\n')

const updateDeps = (reviver: (dep: Dep) => Dep | void) => {
for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
if (!data[type]) { continue }
for (const e of Object.entries(data[type])) {
const dep: Dep = { name: e[0], range: e[1] as string, type }
delete data[type][dep.name]
const updated = reviver(dep) || dep
data[updated.type] = data[updated.type] || {}
data[updated.type][updated.name] = updated.range
}
}
}

return {
dir,
data,
save,
updateDeps
}
}

export async function loadWorkspace (dir: string) {
const workspacePkg = await loadPackage(dir)

const packages = [await loadPackage(process.cwd())]

const find = (name: string) => {
const pkg = packages.find(pkg => pkg.data.name === name)
if (!pkg) {
throw new Error('Workspace package not found: ' + name)
}
return pkg
}

const rename = (from: string, to: string) => {
find(from).data._name = find(from).data.name
find(from).data.name = to
for (const pkg of packages) {
pkg.updateDeps((dep) => {
if (dep.name === from && !dep.range.startsWith('npm:')) {
dep.range = 'npm:' + to + '@' + dep.range
}
})
}
}

const setVersion = (name: string, newVersion: string, opts: { updateDeps?: boolean } = {}) => {
find(name).data.version = newVersion
if (!opts.updateDeps) { return }

for (const pkg of packages) {
pkg.updateDeps((dep) => {
if (dep.name === name) {
dep.range = newVersion
}
})
}
}

const save = () => Promise.all(packages.map(pkg => pkg.save()))

return {
dir,
workspacePkg,
packages,
save,
find,
rename,
setVersion
}
}

export async function determineBumpType () {
const config = await loadChangelogConfig(process.cwd())
const commits = await getLatestCommits()

const bumpType = determineSemverChange(commits, config)

return bumpType === 'major' ? 'minor' : bumpType
}

export async function getLatestCommits () {
const config = await loadChangelogConfig(process.cwd())
const latestTag = execaSync('git', ['describe', '--tags', '--abbrev=0']).stdout

return parseCommits(await getGitDiff(latestTag), config)
}
78 changes: 78 additions & 0 deletions scripts/update-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { execSync } from 'node:child_process'
import { $fetch } from 'ofetch'
import { inc } from 'semver'
import { generateMarkDown, getCurrentGitBranch, loadChangelogConfig } from 'changelogen'
import { consola } from 'consola'
import { determineBumpType, getLatestCommits, loadWorkspace } from './_utils'

async function main () {
const releaseBranch = await getCurrentGitBranch()
const workspace = await loadWorkspace(process.cwd())
const config = await loadChangelogConfig(process.cwd(), {})

const commits = await getLatestCommits().then(commits => commits.filter(
c => config.types[c.type] && !(c.type === 'chore' && c.scope === 'deps' && !c.isBreaking)
))
const bumpType = await determineBumpType()

const newVersion = inc(workspace.find('@nuxt/test-utils').data.version, bumpType || 'patch')
const changelog = await generateMarkDown(commits, config)

// Create and push a branch with bumped versions if it has not already been created
const branchExists = execSync(`git ls-remote --heads origin v${newVersion}`).toString().trim().length > 0
if (!branchExists) {
execSync('git config --global user.email "[email protected]"')
execSync('git config --global user.name "Daniel Roe"')
execSync(`git checkout -b v${newVersion}`)

for (const pkg of workspace.packages.filter(p => !p.data.private)) {
workspace.setVersion(pkg.data.name, newVersion!)
}
await workspace.save()

execSync(`git commit -am v${newVersion}`)
execSync(`git push -u origin v${newVersion}`)
}

// Get the current PR for this release, if it exists
const [currentPR] = await $fetch(`https://api.github.com/repos/nuxt/test-utils/pulls?head=nuxt:v${newVersion}`)

const releaseNotes = [
currentPR?.body.replace(/## 👉 Changelog[\s\S]*$/, '') || `> ${newVersion} is the next ${bumpType} release.\n>\n> **Timetable**: to be announced.`,
'## 👉 Changelog',
changelog.replace(/^## v.*?\n/, '').replace(`...${releaseBranch}`, `...v${newVersion}`)
].join('\n')

// Create a PR with release notes if none exists
if (!currentPR) {
return await $fetch('https://api.github.com/repos/nuxt/test-utils/pulls', {
method: 'POST',
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`
},
body: {
title: `v${newVersion}`,
head: `v${newVersion}`,
base: releaseBranch,
body: releaseNotes,
draft: true
}
})
}

// Update release notes if the pull request does exist
await $fetch(`https://api.github.com/repos/nuxt/test-utils/pulls/${currentPR.number}`, {
method: 'PATCH',
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`
},
body: {
body: releaseNotes
}
})
}

main().catch((err) => {
consola.error(err)
process.exit(1)
})

0 comments on commit 0dd61e0

Please sign in to comment.