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()