From 792de60fea41db23f54b2ea4a3f30e0cd1f55dd8 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 5 Mar 2024 10:53:09 +0000 Subject: [PATCH] ci: add changelogensets --- .github/workflows/changelogensets.yml | 36 +++++++ scripts/_utils.ts | 130 ++++++++++++++++++++++++++ scripts/update-changelog.ts | 84 +++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 .github/workflows/changelogensets.yml create mode 100644 scripts/_utils.ts create mode 100644 scripts/update-changelog.ts diff --git a/.github/workflows/changelogensets.yml b/.github/workflows/changelogensets.yml new file mode 100644 index 0000000..1e49bb3 --- /dev/null +++ b/.github/workflows/changelogensets.yml @@ -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, 'v0.') && !contains(github.event.head_commit.message, 'v1.') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: corepack enable + - uses: actions/setup-node@v4 + 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 }} diff --git a/scripts/_utils.ts b/scripts/_utils.ts new file mode 100644 index 0000000..e96997f --- /dev/null +++ b/scripts/_utils.ts @@ -0,0 +1,130 @@ +import { promises as fsp } from 'node:fs' +import { execSync } from 'node:child_process' +import { $fetch } from 'ofetch' +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 extends PromiseLike ? U : T +export type Package = ThenArg> + +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) +} + +export async function getContributors () { + const contributors = [] as Array<{ name: string, username: string }> + const emails = new Set() + const latestTag = execSync('git describe --tags --abbrev=0').toString().trim() + const rawCommits = await getGitDiff(latestTag) + for (const commit of rawCommits) { + if (emails.has(commit.author.email) || commit.author.name === 'renovate[bot]') { continue } + const { author } = await $fetch<{ author: { login: string, email: string } }>(`https://api.github.com/repos/nuxt/fonts/commits/${commit.shortHash}`, { + headers: { + 'User-Agent': 'nuxt/fonts', + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${process.env.GITHUB_TOKEN}` + } + }) + if (!contributors.some(c => c.username === author.login)) { + contributors.push({ name: commit.author.name, username: author.login }) + } + emails.add(author.email) + } + return contributors +} diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts new file mode 100644 index 0000000..ce80546 --- /dev/null +++ b/scripts/update-changelog.ts @@ -0,0 +1,84 @@ +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, getContributors, 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/fonts').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 "daniel@roe.dev"') + 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/fonts/pulls?head=nuxt:v${newVersion}`) + const contributors = await getContributors() + + 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}`) + .replace(/### ❤️ Contributors[\s\S]*$/, ''), + '### ❤️ Contributors', + contributors.map(c => `- ${c.name} (@${c.username})`).join('\n') + ].join('\n') + + // Create a PR with release notes if none exists + if (!currentPR) { + return await $fetch('https://api.github.com/repos/nuxt/fonts/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/fonts/pulls/${currentPR.number}`, { + method: 'PATCH', + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}` + }, + body: { + body: releaseNotes + } + }) +} + +main().catch((err) => { + consola.error(err) + process.exit(1) +})