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/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/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/scripts/_utils.ts b/scripts/_utils.ts new file mode 100644 index 00000000..a418e118 --- /dev/null +++ b/scripts/_utils.ts @@ -0,0 +1,136 @@ +import { promises as fsp } from 'node:fs' +import process from 'node:process' +import { execSync } from 'node:child_process' +import { resolve } from 'node:path' +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 | undefined) => { + 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): undefined => { + 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): undefined => { + 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(`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 }) + } + emails.add(author.email) + } + return contributors +} diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts new file mode 100644 index 00000000..1d8ce5d3 --- /dev/null +++ b/scripts/update-changelog.ts @@ -0,0 +1,82 @@ +import { execSync } from 'node:child_process' +import { inc } from 'semver' +import { generateMarkDown, getCurrentGitBranch, loadChangelogConfig } from 'changelogen' +import { determineBumpType, getContributors, getLatestCommits, loadWorkspace } from './_utils.ts' + +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}`).then(r => r.json()) + 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: 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}`, { + method: 'PATCH', + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}`, + }, + body: JSON.stringify({ + body: releaseNotes, + }), + }) +} + +main().catch((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 + } }