Ctrl K
ComunidadGuías…

scripts/prepare-release.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()