From e356b18aa8d714cd3fabb5cc27e18224d3d30dcb Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 15:21:04 +0000 Subject: [PATCH 1/3] ci: add changelog workflow --- .github/workflows/changelogensets.yml | 36 +++++++ _utils.ts | 137 ++++++++++++++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 11 +++ update-changelog.ts | 84 ++++++++++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/changelogensets.yml create mode 100644 _utils.ts create mode 100644 update-changelog.ts diff --git a/.github/workflows/changelogensets.yml b/.github/workflows/changelogensets.yml new file mode 100644 index 00000000..84c1620e --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + - run: corepack enable + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - run: node ./scripts/update-changelog.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/_utils.ts b/_utils.ts new file mode 100644 index 00000000..d964a6cb --- /dev/null +++ b/_utils.ts @@ -0,0 +1,137 @@ +import { promises as fsp } from 'node:fs' +import process from 'node:process' +import { execSync } from 'node:child_process' +import { $fetch } from 'ofetch' +import { resolve } from 'pathe' +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 } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + 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 = execSync('git describe --tags --abbrev=0').toString().trim() + + 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/package.json b/package.json index 6db03eee..e35da164 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "lint": "eslint .", "nuxt-telemetry": "node ./bin/nuxt-telemetry.mjs", "prepack": "nuxt-module-build build", - "release": "pnpm test && pnpm build && pnpm changelogen --release --push && pnpm publish", "test": "vitest run", "test:engines": "installed-check -d --no-workspaces", "test:knip": "knip", @@ -56,6 +55,7 @@ "@nuxt/module-builder": "^1.0.2", "@nuxt/schema": "^4.3.0", "@nuxt/test-utils": "^4.0.0", + "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.0.18", "changelogen": "^0.6.2", "eslint": "^10.0.0", @@ -64,6 +64,7 @@ "installed-check": "^9.3.0", "knip": "^5.83.1", "nuxt": "^4.3.0", + "semver": "^7.7.4", "typescript": "^5.9.3", "unbuild": "^3.6.1", "vitest": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c94997b..bb85758f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@nuxt/test-utils': specifier: ^4.0.0 version: 4.0.0(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.2.1)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@25.2.1)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) @@ -66,6 +69,9 @@ importers: nuxt: specifier: ^4.3.0 version: 4.3.0(@parcel/watcher@2.5.6)(@types/node@25.2.1)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@10.0.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2) + semver: + specifier: ^7.7.4 + version: 7.7.4 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1736,6 +1742,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6255,6 +6264,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/semver@7.7.1': {} + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/update-changelog.ts b/update-changelog.ts new file mode 100644 index 00000000..8aee256a --- /dev/null +++ b/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 = 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()) || 'patch' + + const newVersion = inc(workspace.find('@nuxt/telemetry').data.version, bumpType) + 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/telemetry/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/telemetry/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/telemetry/pulls/${currentPR.number}`, { + method: 'PATCH', + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}`, + }, + body: { + body: releaseNotes, + }, + }) +} + +main().catch((err) => { + consola.error(err) + process.exit(1) +}) From 0073d88ed16bd5f92d771cd75f0e52e468a835ab Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 15:22:28 +0000 Subject: [PATCH 2/3] chore: fix --- _utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_utils.ts b/_utils.ts index d964a6cb..84e9f2ba 100644 --- a/_utils.ts +++ b/_utils.ts @@ -19,7 +19,7 @@ export async function loadPackage(dir: string) { 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) => { + const updateDeps = (reviver: (dep: Dep) => Dep | undefined) => { for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { if (!data[type]) { continue From a74637c37b2dd39e51c4eaf1e2cf63dddd2d1a6a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 15:27:11 +0000 Subject: [PATCH 3/3] chore: slim --- knip.json | 2 +- _utils.ts => scripts/_utils.ts | 11 +++++----- .../update-changelog.ts | 20 +++++++++---------- tsconfig.json | 3 +++ 4 files changed, 18 insertions(+), 18 deletions(-) rename _utils.ts => scripts/_utils.ts (92%) rename update-changelog.ts => scripts/update-changelog.ts (85%) diff --git a/knip.json b/knip.json index 2e560d30..adb36516 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,7 @@ "entry": [ "src/cli.ts", "src/module.ts", - "build.config.ts" + "scripts/**" ] } } diff --git a/_utils.ts b/scripts/_utils.ts similarity index 92% rename from _utils.ts rename to scripts/_utils.ts index 84e9f2ba..a418e118 100644 --- a/_utils.ts +++ b/scripts/_utils.ts @@ -1,8 +1,7 @@ import { promises as fsp } from 'node:fs' import process from 'node:process' import { execSync } from 'node:child_process' -import { $fetch } from 'ofetch' -import { resolve } from 'pathe' +import { resolve } from 'node:path' import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen' export interface Dep { @@ -60,7 +59,7 @@ export async function loadWorkspace(dir: string) { find(from).data._name = find(from).data.name find(from).data.name = to for (const pkg of packages) { - pkg.updateDeps((dep) => { + pkg.updateDeps((dep): undefined => { if (dep.name === from && !dep.range.startsWith('npm:')) { dep.range = 'npm:' + to + '@' + dep.range } @@ -75,7 +74,7 @@ export async function loadWorkspace(dir: string) { } for (const pkg of packages) { - pkg.updateDeps((dep) => { + pkg.updateDeps((dep): undefined => { if (dep.name === name) { dep.range = newVersion } @@ -121,13 +120,13 @@ export async function getContributors() { 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}`, { + const { author } = await fetch(`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}`, }, - }) + }).then(r => r.json() as Promise<{ author: { login: string, email: string } }>) if (!contributors.some(c => c.username === author.login)) { contributors.push({ name: commit.author.name, username: author.login }) } diff --git a/update-changelog.ts b/scripts/update-changelog.ts similarity index 85% rename from update-changelog.ts rename to scripts/update-changelog.ts index 8aee256a..1d8ce5d3 100644 --- a/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -1,9 +1,7 @@ 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' +import { determineBumpType, getContributors, getLatestCommits, loadWorkspace } from './_utils.ts' async function main() { const releaseBranch = getCurrentGitBranch() @@ -35,7 +33,7 @@ async function main() { } // Get the current PR for this release, if it exists - const [currentPR] = await $fetch(`https://api.github.com/repos/nuxt/telemetry/pulls?head=nuxt:v${newVersion}`) + const [currentPR] = await fetch(`https://api.github.com/repos/nuxt/telemetry/pulls?head=nuxt:v${newVersion}`).then(r => r.json()) const contributors = await getContributors() const releaseNotes = [ @@ -51,34 +49,34 @@ async function main() { // Create a PR with release notes if none exists if (!currentPR) { - return await $fetch('https://api.github.com/repos/nuxt/telemetry/pulls', { + return await fetch('https://api.github.com/repos/nuxt/telemetry/pulls', { method: 'POST', headers: { Authorization: `token ${process.env.GITHUB_TOKEN}`, }, - body: { + body: JSON.stringify({ 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/telemetry/pulls/${currentPR.number}`, { + await fetch(`https://api.github.com/repos/nuxt/telemetry/pulls/${currentPR.number}`, { method: 'PATCH', headers: { Authorization: `token ${process.env.GITHUB_TOKEN}`, }, - body: { + body: JSON.stringify({ body: releaseNotes, - }, + }), }) } main().catch((err) => { - consola.error(err) + console.error(err) process.exit(1) }) diff --git a/tsconfig.json b/tsconfig.json index dfaf3c6d..99557c19 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,6 @@ { "extends": "./.nuxt/tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true + } }