ci: reworks changelog and release notes generation (#6164)

This commit is contained in:
Elliot DeNolf
2024-04-30 23:50:49 -04:00
committed by GitHub
parent 5a82f34801
commit 17bee6a145
7 changed files with 551 additions and 778 deletions

View File

@@ -1,39 +0,0 @@
module.exports = {
// gitRawCommitsOpts: {
// from: 'v2.0.9',
// path: 'packages/payload',
// },
// infile: 'CHANGELOG.md',
options: {
preset: {
name: 'conventionalcommits',
types: [
{ section: 'Features', type: 'feat' },
{ section: 'Features', type: 'feature' },
{ section: 'Bug Fixes', type: 'fix' },
{ section: 'Documentation', type: 'docs' },
],
},
},
// outfile: 'NEW.md',
writerOpts: {
commitGroupsSort: (a, b) => {
const groupOrder = ['Features', 'Bug Fixes', 'Documentation']
return groupOrder.indexOf(a.title) - groupOrder.indexOf(b.title)
},
// Scoped commits at the end, alphabetical sort
commitsSort: (a, b) => {
if (a.scope || b.scope) {
if (!a.scope) return -1
if (!b.scope) return 1
return a.scope === b.scope
? a.subject.localeCompare(b.subject)
: a.scope.localeCompare(b.scope)
}
// Alphabetical sort
return a.subject.localeCompare(b.subject)
},
},
}

View File

@@ -90,10 +90,6 @@
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1",
"@types/concat-stream": "^2.0.1",
"@types/conventional-changelog": "^3.1.4",
"@types/conventional-changelog-core": "^4.2.5",
"@types/conventional-changelog-preset-loader": "^2.3.4",
"@types/conventional-changelog-writer": "^4.0.10",
"@types/fs-extra": "^11.0.2",
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
@@ -105,13 +101,9 @@
"@types/shelljs": "0.8.15",
"add-stream": "^1.0.0",
"chalk": "^4.1.2",
"changelogen": "^0.5.5",
"comment-json": "^4.2.3",
"concat-stream": "^2.0.0",
"conventional-changelog": "^5.1.0",
"conventional-changelog-conventionalcommits": "^7.0.2",
"conventional-changelog-core": "^7.0.0",
"conventional-changelog-preset-loader": "^4.1.0",
"conventional-changelog-writer": "^7.0.1",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"dotenv": "8.6.0",

892
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import type { ExecSyncOptions } from 'child_process'
import chalk from 'chalk'
import { loadChangelogConfig } from 'changelogen'
import { execSync } from 'child_process'
import execa from 'execa'
import fse from 'fs-extra'
@@ -15,6 +16,7 @@ import { simpleGit } from 'simple-git'
import type { PackageDetails } from './lib/getPackageDetails.js'
import { getPackageDetails } from './lib/getPackageDetails.js'
import { getRecommendedBump } from './utils/getRecommendedBump.js'
import { updateChangelog } from './utils/updateChangelog.js'
const npmPublishLimit = pLimit(6)
@@ -97,19 +99,43 @@ const cmdRunnerAsync =
}
async function main() {
if (!process.env.GITHUB_TOKEN) {
throw new Error('GITHUB_TOKEN env var is required')
}
if (dryRun) {
console.log(chalk.bold.yellow(chalk.bold.magenta('\n 👀 Dry run mode enabled')))
}
console.log({ args })
const runCmd = cmdRunner(dryRun, gitTag)
const runCmdAsync = cmdRunnerAsync(dryRun)
const fromVersion = execSync('git describe --tags --abbrev=0').toString().trim()
const config = await loadChangelogConfig(process.cwd(), {
repo: 'payloadcms/payload',
})
if (!semver.RELEASE_TYPES.includes(bump)) {
abort(`Invalid bump type: ${bump}.\n\nMust be one of: ${semver.RELEASE_TYPES.join(', ')}`)
}
const recommendedBump = (await getRecommendedBump(fromVersion, 'HEAD', config)) || 'patch'
if (bump !== recommendedBump) {
console.log(
chalk.bold.yellow(
`Recommended bump type is ${recommendedBump} based on commits since last release`,
),
)
const confirmBump = await confirm(`Do you want to continue with ${bump}?`)
if (!confirmBump) {
abort()
}
}
const runCmd = cmdRunner(dryRun, gitTag)
const runCmdAsync = cmdRunnerAsync(dryRun)
if (bump.startsWith('pre') && tag === 'latest') {
abort(`Prerelease bumps must have tag: beta or canary`)
}
@@ -120,8 +146,6 @@ async function main() {
throw new Error('Could not find version in package.json')
}
const lastTag = (await git.tags()).all.reverse().filter((t) => t.startsWith('v'))?.[0]
// TODO: Re-enable this check once we start tagging releases again
// if (monorepoVersion !== lastTag.replace('v', '')) {
// throw new Error(
@@ -136,6 +160,17 @@ async function main() {
return // For TS type checking
}
// Preview/Update changelog
header(`${logPrefix}📝 Updating changelog...`)
await updateChangelog({
bump,
dryRun,
toVersion: 'HEAD',
fromVersion,
openReleaseUrl: true,
writeChangelog: changelog,
})
let packageDetails = await getPackageDetails(packageWhitelist)
console.log(chalk.bold(`\n Version: ${monorepoVersion} => ${chalk.green(nextReleaseVersion)}\n`))
@@ -164,14 +199,6 @@ async function main() {
abort('Build failed')
}
// Update changelog
if (changelog) {
header(`${logPrefix}📝 Updating changelog...`)
await updateChangelog({ dryRun, newVersion: nextReleaseVersion })
} else {
console.log(chalk.bold.yellow('📝 Skipping changelog update'))
}
// Increment all package versions
header(`${logPrefix}📦 Updating package.json versions...`)
await Promise.all(

View File

@@ -0,0 +1,16 @@
import type { ChangelogConfig } from 'changelogen'
import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen'
export async function getLatestCommits(
fromVersion: string,
toVersion: string,
config?: ChangelogConfig,
) {
if (!config) {
config = await loadChangelogConfig(process.cwd(), {
repo: 'payloadcms/payload',
})
}
return parseCommits(await getGitDiff(fromVersion, toVersion), config)
}

View File

@@ -0,0 +1,20 @@
import type { ChangelogConfig } from 'changelogen'
import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen'
import { getLatestCommits } from './getLatestCommits.js'
export async function getRecommendedBump(
fromVersion: string,
toVersion: string,
config?: ChangelogConfig,
) {
if (!config) {
config = await loadChangelogConfig(process.cwd(), {
repo: 'payloadcms/payload',
})
}
const commits = await getLatestCommits(fromVersion, toVersion, config)
const bumpType = determineSemverChange(commits, config)
return bumpType === 'major' ? 'minor' : bumpType
}

View File

@@ -1,117 +1,202 @@
import type {
GitRawCommitsOptions,
ParserOptions,
WriterOptions,
} from 'conventional-changelog-core'
import type { GitCommit, RawGitCommit } from 'changelogen'
import { Octokit } from '@octokit/core'
import addStream from 'add-stream'
import conventionalChangelog from 'conventional-changelog'
import { default as getConventionalPreset } from 'conventional-changelog-conventionalcommits'
import { once } from 'events'
import chalk from 'chalk'
import { execSync } from 'child_process'
import fse from 'fs-extra'
import minimist from 'minimist'
import { simpleGit } from 'simple-git'
import tempfile from 'tempfile'
import open from 'open'
import semver from 'semver'
const { createReadStream, createWriteStream } = fse
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const git = simpleGit()
import { getLatestCommits } from './getLatestCommits.js'
import { getRecommendedBump } from './getRecommendedBump.js'
type Args = {
newVersion: string
fromVersion?: string
toVersion?: string
bump?: 'major' | 'minor' | 'patch' | 'prerelease'
dryRun?: boolean
openReleaseUrl?: boolean
writeChangelog?: boolean
}
export const updateChangelog = async ({ newVersion, dryRun }: Args) => {
const monorepoVersion = fse.readJSONSync('package.json')?.version
export const updateChangelog = async (args: Args = {}) => {
const { toVersion = 'HEAD', dryRun, bump, openReleaseUrl, writeChangelog } = args
if (!monorepoVersion) {
throw new Error('Could not find version in package.json')
const fromVersion =
args.fromVersion || execSync('git describe --tags --abbrev=0').toString().trim()
const tag = fromVersion.match(/-(\w+)\.\d+$/)?.[1] || 'latest'
const recommendedBump =
tag !== 'latest' ? 'prerelease' : await getRecommendedBump(fromVersion, toVersion)
if (bump && bump !== recommendedBump) {
console.log(`WARNING: Recommended bump is ${recommendedBump}, but you specified ${bump}`)
}
const lastTag = (await git.tags()).all.reverse().filter((t) => t.startsWith('v'))?.[0]
const calculatedBump = bump || recommendedBump
if (monorepoVersion !== lastTag.replace('v', '')) {
throw new Error(
`Version in package.json (${monorepoVersion}) does not match last tag (${lastTag})`,
)
}
const proposedReleaseVersion = semver.inc(fromVersion, calculatedBump, undefined, tag)
// Load conventional commits preset and modify it
const conventionalPreset = (await getConventionalPreset()) as {
conventionalChangelog: unknown
gitRawCommitsOpts: GitRawCommitsOptions
parserOpts: ParserOptions
writerOpts: WriterOptions
}
// Unbold scope
conventionalPreset.writerOpts.commitPartial =
conventionalPreset.writerOpts?.commitPartial?.replace('**{{scope}}:**', '{{scope}}:')
// Add footer to end of main template
conventionalPreset.writerOpts.mainTemplate = conventionalPreset.writerOpts?.mainTemplate?.replace(
/\n*$/,
'{{footer}}\n',
)
// Fetch commits from last tag to HEAD
const credits = await createContributorSection(lastTag)
// Add Credits to footer
conventionalPreset.writerOpts.finalizeContext = (context) => {
context.footer = credits
return context
}
const changelogStream = conventionalChangelog(
// Options
{
preset: 'conventionalcommits',
},
// Context
{
version: newVersion, // next release
},
// GitRawCommitsOptions
{
path: 'packages',
},
undefined,
conventionalPreset.writerOpts,
).on('error', (err) => {
console.error(err.stack)
console.error(err.toString())
process.exit(1)
console.log({
tag,
recommendedBump,
fromVersion,
toVersion,
proposedVersion: proposedReleaseVersion,
})
const changelogFile = 'CHANGELOG.md'
const readStream = fse.createReadStream(changelogFile)
const tmp = tempfile()
const conventionalCommits = await getLatestCommits(fromVersion, toVersion)
// Output to stdout if debug is true
const emitter = dryRun
? changelogStream.pipe(createWriteStream(tmp)).on('finish', () => {
createReadStream(tmp).pipe(process.stdout)
})
: changelogStream
.pipe(addStream(readStream))
.pipe(createWriteStream(tmp))
.on('finish', () => {
createReadStream(tmp).pipe(createWriteStream(changelogFile))
})
const sections: Record<'breaking' | 'feat' | 'fix', string[]> = {
feat: [],
fix: [],
breaking: [],
}
// Wait for the stream to finish
await once(emitter, 'finish')
// Group commits by type
conventionalCommits.forEach((c) => {
if (c.isBreaking) {
sections.breaking.push(formatCommitForChangelog(c, true))
}
if (c.type === 'feat' || c.type === 'fix') {
sections[c.type].push(formatCommitForChangelog(c))
}
})
// Fetch commits for fromVersion to toVersion
const contributors = await createContributorSection(conventionalCommits)
const yyyyMMdd = new Date().toISOString().split('T')[0]
// Might need to swap out HEAD for the new proposed version
let output = `## [${proposedReleaseVersion}](https://github.com/payloadcms/payload/compare/${fromVersion}...${proposedReleaseVersion}) (${yyyyMMdd})\n\n\n`
if (sections.feat.length) {
output += `### Features\n\n${sections.feat.join('\n')}\n\n`
}
if (sections.fix.length) {
output += `### Bug Fixes\n\n${sections.fix.join('\n')}\n\n`
}
if (sections.breaking.length) {
output += `### BREAKING CHANGES\n\n${sections.breaking.join('\n')}\n\n`
}
console.log(chalk.green('\nChangelog Preview:\n'))
console.log(chalk.gray(output))
if (writeChangelog) {
const changelogPath = 'CHANGELOG.md'
const changelog = await fse.readFile(changelogPath, 'utf8')
const newChangelog = output + '\n\n' + changelog
await fse.writeFile(changelogPath, newChangelog)
console.log(`Changelog updated at ${changelogPath}`)
}
// Add contributors after writing to file
output += contributors
let url = `https://github.com/payloadcms/payload/releases/new?tag=${proposedReleaseVersion}&title=${proposedReleaseVersion}&body=${encodeURIComponent(output)}`
if (tag !== 'latest') {
url += `&prerelease=1`
}
console.log(`Release URL: ${chalk.dim(url)}`)
if (!openReleaseUrl) {
await open(url)
}
}
// Helper functions
async function createContributorSection(commits: GitCommit[]): Promise<string> {
const contributors = await getContributors(commits)
if (!contributors.length) return ''
let contributorsSection = `### Contributors\n\n`
for (const contributor of contributors) {
contributorsSection += `- ${contributor.name} (@${contributor.username})\n`
}
return contributorsSection
}
async function getContributors(commits: GitCommit[]): Promise<Contributor[]> {
const contributors: Contributor[] = []
const emails = new Set<string>()
for (const commit of commits) {
if (emails.has(commit.author.email) || commit.author.name === 'dependabot[bot]') {
continue
}
const res = await fetch(
`https://api.github.com/repos/payloadcms/payload/commits/${commit.shortHash}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
},
)
if (!res.ok) {
console.error(await res.text())
console.log(`Failed to fetch commit: ${res.status} ${res.statusText}`)
continue
}
const { author } = (await res.json()) as { author: { login: string; email: string } }
// TODO: Handle co-authors
if (!contributors.some((c) => c.username === author.login)) {
contributors.push({ name: commit.author.name, username: author.login })
}
emails.add(author.email)
}
return contributors
}
type Contributor = { name: string; username: string }
function formatCommitForChangelog(commit: GitCommit, includeBreakingNotes = false): string {
const { scope, references, description, isBreaking } = commit
let formatted = `* ${scope ? `${scope}: ` : ''}${description}`
references.forEach((ref) => {
if (ref.type === 'pull-request') {
// /issues will redirect to /pulls if the issue is a PR
formatted += ` ([${ref.value}](https://github.com/payloadcms/payload/issues/${ref.value.replace('#', '')}))`
}
if (ref.type === 'hash') {
const shortHash = ref.value.slice(0, 7)
formatted += ` ([${shortHash}](https://github.com/payloadcms/payload/commit/${shortHash}))`
}
})
if (isBreaking && includeBreakingNotes) {
// Parse breaking change notes from commit body
const [rawNotes, _] = commit.body.split('\n\n')
const notes = rawNotes
.split('\n')
.filter((l) => !l.toUpperCase().startsWith('BREAKING'))
.map((l) => `> ${l}`)
.join('\n')
.trim()
formatted += `\n\n${notes}`
}
return formatted
}
// module import workaround for ejs
if (import.meta.url === `file://${process.argv[1]}`) {
// This module is being run directly
const { newVersion } = minimist(process.argv.slice(2))
updateChangelog({ dryRun: true, newVersion })
const { fromVersion, toVersion, bump, openReleaseUrl, writeChangelog } = minimist(
process.argv.slice(2),
)
updateChangelog({ bump, fromVersion, toVersion, dryRun: false, openReleaseUrl, writeChangelog })
.then(() => {
console.log('Done')
})
@@ -119,37 +204,3 @@ if (import.meta.url === `file://${process.argv[1]}`) {
console.error(err)
})
}
async function createContributorSection(lastTag: string): Promise<string> {
const commits = await git.log({ from: lastTag, to: 'HEAD' })
console.log(`Fetching contributors from ${commits.total} commits`)
const usernames = await Promise.all(
commits.all.map((c) =>
octokit
.request('GET /repos/{owner}/{repo}/commits/{ref}', {
owner: 'payloadcms',
repo: 'payload',
ref: c.hash,
})
.then(({ data }) => data.author?.login),
),
)
if (!usernames.length) return ''
// List of unique contributors
const contributors = Array.from(new Set(usernames)).map((c) => `@${c}`)
const formats = {
1: (contributors: string[]) => contributors[0],
2: (contributors: string[]) => contributors.join(' and '),
// Oxford comma ;)
default: (contributors: string[]) => contributors.join(', ').replace(/,([^,]*)$/, ', and$1'),
}
const formattedContributors =
formats[contributors.length]?.(contributors) || formats['default'](contributors)
const credits = `### Credits\n\nThanks to ${formattedContributors} for their contributions!\n`
return credits
}