scripts/prepare-release.js
Rama main · réplica de lectura del repositorio original.
const { spawnSync } = require('child_process')
const { readFileSync, writeFileSync } = require('fs')
const { default: getReleasePlan } = require('@changesets/get-release-plan')
const { getInfo } = require('@changesets/get-github-info')
const repo = 'keystonejs/keystone'
// TODO: move this to CI linting
const verbs = new Set([
'Adds',
'Changes',
'Fixes',
'Moves',
'Removes',
'Updates',
'Upgrades',
'Replaces',
])
// TODO: derived?
const publicPackages = [
'@keystone-6/auth',
'@keystone-6/cloudinary',
'@keystone-6/core',
'@keystone-6/document-renderer',
'@keystone-6/fields-document',
'create-keystone-app',
]
const cves = [
// {
// id: 'CVE-2023-23936',
// href: 'https://github.com/advisories/GHSA-5r9g-qh6m-jxff',
// upstream: true,
// description: `
// An upstream transitive dependency \`undici\` is vulnerable to a HTTP header CRLF injection vulnerability.
// We have upgraded to a version of \`@prisma/core\` that uses a fixed \`undici\`.
// `,
// },
]
function gitCommitsSince(tag) {
const { stdout } = spawnSync('git', ['rev-list', `^${tag}`, 'HEAD'])
return stdout
.toString('utf-8')
.split('\n')
.filter(x => x)
}
function gitLatestReleaseTag() {
const { stdout } = spawnSync('git', ['tag', '--list', '20??-??-??', '--sort=-version:refname'])
return stdout.toString('utf-8').split('\n')[0]
}
async function githubRequest(path, options = {}) {
if (!process.env.GITHUB_TOKEN) throw new Error('Missing GITHUB_TOKEN')
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'X-GitHub-Api-Version': '2026-03-10',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub request failed (${response.status}): ${await response.text()}`)
}
return response.json()
}
async function getDraftGitHubRelease() {
const releases = await githubRequest(`/repos/${repo}/releases?per_page=100`)
return releases
.filter(release => release.draft)
.sort((a, b) => b.created_at.localeCompare(a.created_at))[0]
}
function formatReleaseTitle(tag) {
let date
try {
date = Temporal.PlainDate.from(tag)
} catch {
throw new Error(`Draft release tag must be a date in YYYY-MM-DD format, received '${tag}'`)
}
if (date.toString() !== tag) {
throw new Error(`Draft release tag must be a date in YYYY-MM-DD format, received '${tag}'`)
}
return date.toLocaleString('en-AU', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
}
async function updateDraftGitHubRelease(release, tag, body) {
await githubRequest(`/repos/${repo}/releases/${release.id}`, {
method: 'PATCH',
body: JSON.stringify({ body, name: formatReleaseTitle(tag), tag_name: tag }),
})
console.error(`Updated draft GitHub release: ${release.html_url}`)
}
function gitCommitsFor(path) {
const { stdout } = spawnSync('git', ['log', '--pretty=format:%H', '--follow', 'HEAD', '--', path])
return stdout
.toString('utf-8')
.split('\n')
.map(x => x.replace(/[^A-Za-z0-9]/g, '').slice(0, 40))
}
function gitCommitInfo(commit) {
const { stdout } = spawnSync('git', ['log', '-1', '--format=%ae%n%s', commit])
const [email, description] = stdout.toString('utf-8').split('\n')
return { email, description }
}
async function fetchData(tag) {
const { changesets, releases } = await getReleasePlan('.')
// find the commits since the tag
const revs = gitCommitsSince(tag)
console.error(`${revs.length} commits since ${tag}`)
if (revs.length === 0) throw new Error('No commits')
// tag changesets with their commits
for (const changeset of changesets) {
const commits = gitCommitsFor(`.changeset/${changeset.id}.md`)
const commit = commits.slice(-1).pop()
console.error(
`changeset ${changeset.id} has ${commits.length} commits, the first commit is ${commit}`
)
if (!revs.includes(commit))
throw new Error(`Unexpected commit ${changeset.commit} for changeset ${changeset.id}`)
changeset.commit = commit
}
// list all the contributors
const previousContributors = JSON.parse(
readFileSync('.changeset/contributors.json').toString('utf-8')
)
const commitInfo = revs.map(commit => ({ commit, ...gitCommitInfo(commit) }))
const usersByEmail = new Map()
for (const { commit, email } of commitInfo) {
if (usersByEmail.has(email)) continue
usersByEmail.set(
email,
getInfo({ repo, commit }).then(info => info.user)
)
}
const githubCommits = {}
for (const { commit, email, description } of commitInfo) {
const user = await usersByEmail.get(email)
const pull = description.match(/\(#([0-9]+)\)\s*$/)?.[1] ?? null
console.error(`commit ${commit}, user ${user}, pull #${pull}`)
const first = !previousContributors.includes(user)
githubCommits[commit] = { commit, user, pull, first }
}
// augment changesets with git information
const changes = []
for (const changeset of changesets) {
const { releases, summary, commit } = changeset
// TODO: move this to CI linting
const describedType = summary.split(' ')[0]
if (!verbs.has(describedType)) {
console.warn(` Verb '${describedType}' is non-standard for a changeset`)
}
// poor semver precedence
let type
for (const release of releases) {
if (['minor', 'patch', undefined].includes(type) && release.type === 'major') type = 'major'
if (['patch', undefined].includes(type) && release.type === 'minor') type = 'minor'
if ([undefined].includes(type) && release.type === 'patch') type = 'patch'
}
if (!type) throw new Error('Unknown type')
// only public packages, then strip the namespace
const packages = releases
.filter(x => publicPackages.includes(x.name))
.map(x => x.name.replace('@keystone-6/', ''))
.sort()
const githubCommit = githubCommits[commit]
changes.push({
...githubCommit,
summary,
type,
packages,
})
}
// if no changeset was associated with a commit, we still want to acknowledge the work
for (const [commit, githubCommit] of Object.entries(githubCommits)) {
if (changes.find(x => x.commit === commit)) continue
changes.push(githubCommit)
}
// find the set of our contributors
const contributors = [...new Set([...previousContributors, ...changes.map(x => x.user)])]
// only public packages
const packages = releases
.filter(x => publicPackages.includes(x.name))
.filter(x => x.type !== 'none')
.map(x => `${x.name}@${x.newVersion}`)
.sort()
return { packages, changes, contributors }
}
function formatPackagesChanged(packages) {
return `The following packages have been updated
%%%
${packages.join('\n')}
%%%`.replace(/%/g, '`')
}
function formatChange({ packages, summary, pull, user }) {
return `- \`[${packages.join(', ')}]\` ${summary}${pull ? ` (#${pull})` : ''} @${user}`
}
function formatCVE({ id, href, upstream, description }) {
description = description.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
return `- [\`${id}\`](${href}) - ${description}`
}
function formatLink(pull) {
return `[#${pull}](https://github.com/${repo}/pull/${pull})`
}
function sortByCommit(a, b) {
return a.commit.localeCompare(b.commit)
}
function groupPullsByUser(list) {
const result = {}
for (const item of list) {
if (!item.pull) continue
result[item.user] ||= []
result[item.user].push(item.pull)
}
return Object.entries(result)
.map(([user, pulls]) => ({ user, pulls }))
.sort((a, b) => a.user.localeCompare(b.user))
}
async function generateGitHubReleaseText(previousTag, releaseTag) {
if (!previousTag) throw new Error('Missing tag')
const { packages, changes, contributors } = await fetchData(previousTag)
// writeFileSync(`./CHANGELOG-${releaseTag}.json`, JSON.stringify({ packages, changes, contributors }, null, 2))
// return process.exit(0)
// const { packages, changes, contributors } = JSON.parse(readFileSync(`./CHANGELOG-${releaseTag}.json`))
const output = []
output.push(formatPackagesChanged(packages))
output.push('')
const breaking = changes.filter(x => x.type === 'major').sort(sortByCommit)
const features = changes.filter(x => x.type === 'minor').sort(sortByCommit)
const fixes = changes.filter(x => x.type === 'patch').sort(sortByCommit)
if (breaking.length) {
output.push(...[`#### Breaking Changes`, ...breaking.map(formatChange), ``])
}
if (features.length) {
output.push(...[`#### New Features`, ...features.map(formatChange), ``])
}
if (fixes.length) {
output.push(...[`#### Bug Fixes`, ...fixes.map(formatChange), ``])
}
if (cves.length) {
output.push(
...[
`#### :rotating_light: Security Updates`,
`We have identified and fixed ${cves.length}${
cves.some(x => x.upstream) ? ' upstream' : ''
} security ${cves.length === 1 ? 'vulnerability' : 'vulnerabilities'}`,
...cves.map(formatCVE),
``,
]
)
}
const first = changes.filter(x => x.first)
const unattributed = changes.filter(x => !x.type && !x.first)
if (first.length) {
const listf = groupPullsByUser(first)
output.push(`#### :seedling: New Contributors`)
output.push(
`Thanks to the following developers for making their first contributions to the project!`
)
output.push(
...listf.map(({ user, pulls }) => `- @${user} (${pulls.map(formatLink).join(',')})`)
)
output.push(``)
}
if (unattributed.length) {
const listu = groupPullsByUser(unattributed)
output.push(`#### :blue_heart: Acknowledgements `)
output.push(
`Lastly, thanks to ${listu
.map(({ user, pulls }) => `@${user} (${pulls.map(formatLink).join(',')})`)
.join(', ')} for changes not shown above, but none-the-less appreciated.`
)
output.push(``)
}
output.push(`#### :eyes: Review`)
output.push(
`See https://github.com/${repo}/compare/${previousTag}...${releaseTag} to compare with our previous release.`
)
writeFileSync(
'./.changeset/contributors.json',
JSON.stringify(contributors.sort(), null, 2) + '\n'
)
return output.join('\n')
}
async function main() {
const updateDraft = process.argv.includes('--update-draft')
const previousTag =
process.argv.slice(2).find(argument => argument !== '--update-draft') ?? gitLatestReleaseTag()
const draftRelease = updateDraft ? await getDraftGitHubRelease() : undefined
const releaseTag =
draftRelease?.tag_name ?? Temporal.Now.plainDateISO('Australia/Sydney').toString()
const output = await generateGitHubReleaseText(previousTag, releaseTag)
console.log(output)
if (draftRelease) {
await updateDraftGitHubRelease(draftRelease, releaseTag, output)
} else if (updateDraft) {
console.error('No draft GitHub release found; skipping release update')
}
}
main()