ci: reworks changelog and release notes generation (#6164)
This commit is contained in:
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
10
package.json
10
package.json
@@ -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
892
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
16
scripts/utils/getLatestCommits.ts
Normal file
16
scripts/utils/getLatestCommits.ts
Normal 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)
|
||||
}
|
||||
20
scripts/utils/getRecommendedBump.ts
Normal file
20
scripts/utils/getRecommendedBump.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user