const { join, basename } = require('node:path')
const fsp = require('node:fs/promises')
const hgi = require('hosted-git-info')
const semver = require('semver')
const pacote = require('pacote')
const { log } = require('proc-log')
const tar = require('tar')
const { cp, withTempDir } = require('@npmcli/fs')
const { CWD, run, spawn, git, fs, gh } = require('./util.js')
const NODE_FORK = 'npm/node'
// this script expects node to already be cloned to a directory at the cli root named "node"
const NODE_DIR = join(CWD, 'node')
const gitNode = spawn.create('git', { cwd: NODE_DIR })
const createNodeTarball = async ({ mani, registryOnly, localTest, tag, dir: extractDir }) => {
const tarball = join(extractDir, 'npm-node.tgz')
await pacote.tarball.file(mani._from, tarball, { resolved: mani._resolved })
if (registryOnly) {
// a future goal is to only need files from the published tarball for
// inclusion in node. in that case, we'd be able to remove everything after
// this line since we have already fetched the tarball
return tarball
}
// extract tarball to current dir and delete original tarball
await tar.x({ strip: 1, file: tarball, cwd: extractDir })
await fs.rimraf(tarball)
// checkout the tag since we need to get files from source.
if (!localTest) {
try {
await git('checkout', tag)
} catch (err) {
log.error('Use the `--local-test` flag to avoid checking out the tag')
throw err
}
}
// currently there is an empty .npmrc file in the deps/npm dir in the node repo
// i do not know why and it might not be used but in order to minimize any
// unnecessary churn, let's create that file to match the old process
await fsp.writeFile(join(extractDir, '.npmrc'), '', 'utf-8')
// copy our test dirs so that tests can be run
for (const path of ['tap-snapshots/', 'test/']) {
await cp(join(CWD, path), join(extractDir, path), { recursive: true })
}
// recreate the tarball as closely as possible to how we would before publishing
// to the registry. the only difference here is the extra files we put in the dir
await tar.c({
...pacote.DirFetcher.tarCreateOptions(mani),
cwd: extractDir,
file: tarball,
}, ['.'])
return tarball
}
const getPrBody = async ({ releases, closePrs }) => {
const useSummary = releases.length > 1
const releasePath = (v) => `/npm/cli/releases/tag/v${v}`
// XXX: add links to relevant CI and CITGM runs once we no longer include our tests
let prBody = ''
if (useSummary) {
const summary = releases.map(r => {
return `[\`npm@${r.version}\`](https://github.com${releasePath(r.version)})`
})
prBody += `This PR contains changes from: ${summary.join(' ')}\n\n`
}
if (closePrs.length) {
prBody += `This PR replaces: ${closePrs.map(pr => pr.url).join(' ')}\n\n`
}
if (prBody) {
prBody += '---\n\n'
}
for (const { version, body } of releases) {
prBody += useSummary
? `<details><summary>${version}</summary>\n<p>\n\n${body}\n\n</p>\n</details>`
: body
prBody += '\n'
}
// These comes from the releases so those link to the raw comparison between tags.
// Since we are putting this in a PR we can change those links back to the releases.
prBody = prBody.replace(/\/npm\/cli\/compare\/v[\w.-]+\.\.\.v([\w.-]+)/g, releasePath('$1'))
const { remark } = await import('remark')
const { default: remarkGfm } = await import('remark-gfm')
const { default: remarkGithub, defaultBuildUrl } = await import('remark-github')
return remark()
.use(remarkGfm)
.use(remarkGithub, {
repository: 'npm/cli',
// don't link mentions, but anything else make the link an explicit reference to npm/cli
buildUrl: (values) => values.type === 'mention' ? false : defaultBuildUrl(values),
})
.process(prBody)
.then(v => String(v))
}
const tokenRemoteUrl = ({ host, token }) => {
// this is a remote url that uses a github token as the username
// in order to authenticate with github
const headRemoteUrl = new URL(host.https())
headRemoteUrl.username = token
// we have to manually change the protocol. the whatwg url spec
// does not allow changing a special protocol to another one
// but the protocol has to be `https:` without the `git+`
return headRemoteUrl.toString().replace('git+https:', 'https:')
}
const main = async (spec, branch = 'main', opts) => withTempDir(CWD, async (tmpDir) => {
const { GITHUB_TOKEN } = process.env
const { dryRun, registryOnly, localTest } = opts
if (!spec) {
throw new Error('`spec` is required as the first argument')
}
if (!branch) {
throw new Error('`branch` is required as the second argument')
}
if (!GITHUB_TOKEN) {
throw new Error(`process.env.GITHUB_TOKEN is required`)
}
await fsp.access(NODE_DIR, fsp.constants.F_OK).catch(() => {
throw new Error(`node repo must be checked out to \`${NODE_DIR}\` to continue`)
})
await gh.json('repo', 'view', NODE_FORK, 'url').catch(() => {
throw new Error(`node repo must be forked to ${NODE_FORK}`)
})
await git.dirty().catch((er) => {
if (localTest) {
return log.info('Skipping git dirty check due to `--local-test` flag')
}
throw er
})
const mani = await pacote.manifest(`npm@${spec}`, { preferOnline: true })
const packument = await pacote.packument('npm', { preferOnline: true })
const npmVersions = Object.keys(packument.versions).sort(semver.rcompare)
const npmVersion = semver.parse(mani.version)
const npmHost = hgi.fromUrl(NODE_FORK)
const npmTag = `v${npmVersion}`
// use the target branch as part of the name so we can create multiple PRs for
// the same npm version targeting different Node branches at the same time
const npmBranch = `npm-${npmTag}-${branch}`
const npmRemoteUrl = tokenRemoteUrl({ host: npmHost, token: GITHUB_TOKEN })
const npmMessage = (v = npmVersion) => `deps: upgrade npm to ${v}`
const tarball = await createNodeTarball({
mani,
tag: npmTag,
dir: tmpDir,
registryOnly,
localTest,
})
log.info('tarball path', tarball)
const nodeRemote = 'origin'
const nodeBranch = /^\d+$/.test(branch) ? `v${branch}.x-staging` : branch
const stagingVersion = nodeBranch.match(/^(v\d+\.x)-staging$/)?.[1]
const nodePrTitle = `${stagingVersion ? `[${stagingVersion}] ` : ''}${npmMessage()}`
const nodeHost = hgi.fromUrl(await gitNode('remote', 'get-url', nodeRemote, { out: true }))
const nodePrArgs = ['pr', '-R', nodeHost.path()]
await gitNode('fetch', nodeRemote)
await gitNode('checkout', nodeBranch)
await gitNode('reset', '--hard', `${nodeRemote}/${nodeBranch}`)
const nodeNpmPath = join('deps', 'npm')
const nodeNpmDir = join(NODE_DIR, nodeNpmPath)
const nodeNpmVersion = require(join(nodeNpmDir, 'package.json')).version
// this is the range of all versions included in this update based
// on the current version of npm in node currently. we use this
// to build a list of all release notes and to close any existing PRs
const newNpmVersions = npmVersions.slice(
npmVersions.indexOf(npmVersion.toString()),
npmVersions.indexOf(nodeNpmVersion)
)
.reverse()
.map((v) => semver.parse(v))
.filter((version) => version.major === npmVersion.major)
// get a list of all versions changelogs to add to the body of the PR
// do this before we checkout our branch and make any changes
const npmReleases = await Promise.all(newNpmVersions.map(async (v) => {
// don't include prereleases unless we are updating to a prerelease since we
// manually put all prerelease notes into the first stable major version
if (v.prerelease.length && !npmVersion.prerelease.length) {
return null
}
return {
version: v,
body: await gh.json('release', 'view', `v${v}`, 'body', { quiet: true }).then(r => r.trim()),
}
})).then(r => r.filter(Boolean))
log.info('npm versions', newNpmVersions.map(v => v.toString()))
log.info('npm releases', npmReleases.map(u => u.version.toString()))
await gitNode('branch', '-D', npmBranch, { ok: true })
await gitNode('checkout', '-b', npmBranch)
await fs.clean(nodeNpmDir)
await tar.x({ strip: 1, file: tarball, cwd: nodeNpmDir })
await fs.rimraf(join(nodeNpmDir, basename(tarball)))
await gitNode('add', '-A', nodeNpmPath)
await gitNode('commit', '-m', npmMessage())
await gitNode('rebase', '--whitespace', 'fix', nodeBranch)
await gitNode('remote', 'rm', npmHost.user, { ok: true })
await gitNode('remote', 'add', npmHost.user, npmRemoteUrl)
if (!dryRun) {
await gitNode('push', npmHost.user, npmBranch, '--force')
}
const npmPrs = await gh.json(
...nodePrArgs, 'list',
'-S', `in:title "${npmMessage('')}"`,
'--base', nodeBranch,
'number,title,url'
)
log.info('Found other npm PRs', npmPrs)
let existingPr = null
const closePrs = []
const npmMessagePrefix = npmMessage('')
for (const pr of npmPrs) {
const npmMessageIndex = pr.title.indexOf(npmMessagePrefix)
const prVersion = npmMessageIndex === -1
? null
: pr.title.slice(npmMessageIndex + npmMessagePrefix.length).trim()
log.silly('checking existing PR', prVersion, pr)
if (!existingPr && prVersion === npmVersion.toString()) {
existingPr = pr
} else if (newNpmVersions.some(v => v.toString() === prVersion)) {
closePrs.push(pr)
}
}
log.info('Found existing PR', existingPr)
log.info('Found PRs to close', closePrs)
const prBody = await getPrBody({ releases: npmReleases, closePrs })
const prArgs = [
nodePrArgs,
(existingPr ? ['edit', existingPr.number] : ['create', '-H', `${npmHost.user}:${npmBranch}`]),
'-B', nodeBranch,
'-t', nodePrTitle,
].flat()
if (dryRun) {
log.info(`gh ${prArgs.join(' ')}`)
return prBody
}
const newOrUpdatedPr = await gh(prArgs, '-F', '-', { input: prBody, out: true })
const closeMessage = `Closing in favor of ${newOrUpdatedPr}`
for (const closePr of closePrs) {
log.info('Attempting to close PR', closePr.url)
try {
await gh(nodePrArgs, 'close', closePr.number, '-c', closeMessage)
} catch (err) {
log.error('Could not close PR', err)
}
}
return newOrUpdatedPr
})
run(({ argv, ...opts }) => main(argv.remain[0], argv.remain[1], opts), {
redact: process.env.GITHUB_TOKEN,
})