Skip to content

Commit 50d2ec2

Browse files
authored
chore(node-pr): various fixes and updates for the node PR script (#5818)
1 parent 432e97e commit 50d2ec2

File tree

8 files changed

+1771
-571
lines changed

8 files changed

+1771
-571
lines changed

.github/workflows/create-node-pr.yml

+7-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ on:
99
description: "The npm spec to create the PR from"
1010
required: true
1111
default: 'latest'
12+
branch:
13+
description: "The major node version to serve as the base of the PR. Should be `main` or a number like `18`, `19`, etc."
14+
required: true
15+
default: 'main'
1216
dryRun:
1317
description: "Setting this to anything will run all the steps except opening the PR"
1418

@@ -23,6 +27,8 @@ jobs:
2327
steps:
2428
- name: Checkout
2529
uses: actions/checkout@v3
30+
with:
31+
fetch-depth: 0
2632
- name: Setup Git User
2733
run: |
2834
git config --global user.email "npm-cli+bot@github.com"
@@ -46,4 +52,4 @@ jobs:
4652
GITHUB_TOKEN: ${{ secrets.NODE_PULL_REQUEST_TOKEN }}
4753
run: |
4854
DRY_RUN=$([ -z "${{ inputs.dryRun }}" ] && echo "" || echo "--dry-run")
49-
node scripts/create-node-pr.js "${{ inputs.spec }}" "$DRY_RUN"
55+
node scripts/create-node-pr.js ${{ inputs.spec }} ${{ inputs.branch }} "$DRY_RUN"

DEPENDENCIES.md

+3
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,9 @@ graph LR;
536536
npm-->read-package-json;
537537
npm-->read;
538538
npm-->readdir-scoped-modules;
539+
npm-->remark-gfm;
540+
npm-->remark-github;
541+
npm-->remark;
539542
npm-->rimraf;
540543
npm-->semver;
541544
npm-->smoke-tests;

package-lock.json

+1,520-502
Large diffs are not rendered by default.

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@
206206
"licensee": "^9.0.0",
207207
"nock": "^13.2.4",
208208
"npm-packlist": "^7.0.1",
209+
"remark": "^14.0.2",
210+
"remark-gfm": "^3.0.1",
211+
"remark-github": "^11.2.4",
209212
"spawk": "^1.7.1",
210213
"tap": "^16.0.1"
211214
},

scripts/create-node-pr.js

+218-58
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { join } = require('path')
1+
const { join, basename } = require('path')
22
const fsp = require('fs/promises')
33
const hgi = require('hosted-git-info')
44
const semver = require('semver')
@@ -8,11 +8,12 @@ const tar = require('tar')
88
const { cp, withTempDir } = require('@npmcli/fs')
99
const { CWD, run, spawn, git, fs, gh } = require('./util.js')
1010

11+
const NODE_FORK = 'npm/node'
1112
// this script expects node to already be cloned to a directory at the cli root named "node"
1213
const NODE_DIR = join(CWD, 'node')
1314
const gitNode = spawn.create('git', { cwd: NODE_DIR })
1415

15-
const createNodeTarball = async ({ mani, registryOnly, tag, dir: extractDir }) => {
16+
const createNodeTarball = async ({ mani, registryOnly, localTest, tag, dir: extractDir }) => {
1617
const tarball = join(extractDir, 'npm-node.tgz')
1718
await pacote.tarball.file(mani._from, tarball, { resolved: mani._resolved })
1819

@@ -28,12 +29,26 @@ const createNodeTarball = async ({ mani, registryOnly, tag, dir: extractDir }) =
2829
await fs.rimraf(tarball)
2930

3031
// checkout the tag since we need to get files from source.
31-
await git.dirty()
32-
tag && await git('checkout', tag)
33-
for (const path of ['.npmrc', 'tap-snapshots/', 'test/']) {
32+
if (!localTest) {
33+
try {
34+
await git('checkout', tag)
35+
} catch (err) {
36+
log.error('Use the `--local-test` flag to avoid checking out the tag')
37+
throw err
38+
}
39+
}
40+
// currently there is an empty .npmrc file in the deps/npm dir in the node repo
41+
// i do not know why and it might not be used but in order to minimize any
42+
// unnecessary churn, let's create that file to match the old process
43+
await fsp.writeFile(join(extractDir, '.npmrc'), '', 'utf-8')
44+
45+
// copy our test dirs so that tests can be run
46+
for (const path of ['tap-snapshots/', 'test/']) {
3447
await cp(join(CWD, path), join(extractDir, path), { recursive: true })
3548
}
3649

50+
// recreate the tarball as closely as possible to how we would before publishing
51+
// to the registry. the only difference here is the extra files we put in the dir
3752
await tar.c({
3853
...pacote.DirFetcher.tarCreateOptions(mani),
3954
cwd: extractDir,
@@ -43,82 +58,227 @@ const createNodeTarball = async ({ mani, registryOnly, tag, dir: extractDir }) =
4358
return tarball
4459
}
4560

46-
const main = async (spec, opts) => withTempDir(CWD, async (tmpDir) => {
47-
const { dryRun, registryOnly, skipCheckout } = opts
61+
const getPrBody = async ({ releases, closePrs }) => {
62+
const useSummary = releases.length > 1
63+
const releasePath = (v) => `/npm/cli/releases/tag/v${v}`
4864

49-
const mani = await pacote.manifest(`npm@${spec}`, { preferOnline: true })
65+
// XXX: add links to relevant CI and CITGM runs once we no longer include our tests
66+
let prBody = ''
67+
68+
if (useSummary) {
69+
const summary = releases.map(r => {
70+
return `[\`npm@${r.version}\`](https://github.com${releasePath(r.version)})`
71+
})
72+
prBody += `This PR contains changes from: ${summary.join(' ')}\n\n`
73+
}
74+
75+
if (closePrs.length) {
76+
prBody += `This PR replaces: ${closePrs.map(pr => pr.url).join(' ')}\n\n`
77+
}
5078

51-
const head = {
52-
tag: `v${mani.version}`,
53-
branch: `npm-v${mani.version}`,
54-
host: hgi.fromUrl('npm/node'),
55-
message: `deps: upgrade npm to ${mani.version}`,
79+
if (prBody) {
80+
prBody += '---\n\n'
5681
}
57-
log.silly(head)
82+
83+
for (const { version, body } of releases) {
84+
prBody += useSummary
85+
? `<details><summary>${version}</summary>\n<p>\n\n${body}\n\n</p>\n</details>`
86+
: body
87+
prBody += '\n'
88+
}
89+
90+
// These comes from the releases so those link to the raw comparison between tags.
91+
// Since we are putting this in a PR we can change those links back to the releases.
92+
prBody = prBody.replace(/\/npm\/cli\/compare\/v[\w.-]+\.\.\.v([\w.-]+)/g, releasePath('$1'))
93+
94+
const { remark } = await import('remark')
95+
const { default: remarkGfm } = await import('remark-gfm')
96+
const { default: remarkGithub } = await import('remark-github')
97+
98+
return remark()
99+
.use(remarkGfm)
100+
.use(remarkGithub, {
101+
repository: 'npm/cli',
102+
// dont link mentions, but anything else make the link an explicit referance to npm/cli
103+
buildUrl: (values, buildUrl) => values.type === 'mention' ? false : buildUrl(values),
104+
})
105+
.process(prBody)
106+
.then(v => String(v))
107+
}
108+
109+
const tokenRemoteUrl = ({ host, token }) => {
110+
// this is a remote url that uses a github token as the username
111+
// in order to authenticate with github
112+
const headRemoteUrl = new URL(host.https())
113+
headRemoteUrl.username = token
114+
// we have to manually change the protocol. the whatwg url spec
115+
// does not allow changing a special protocol to another one
116+
// but the protocol has to be `https:` without the `git+`
117+
return headRemoteUrl.toString().replace('git+https:', 'https:')
118+
}
119+
120+
const main = async (spec, branch = 'main', opts) => withTempDir(CWD, async (tmpDir) => {
121+
const { GITHUB_TOKEN } = process.env
122+
const { dryRun, registryOnly, localTest } = opts
123+
124+
if (!spec) {
125+
throw new Error('`spec` is required as the first argument')
126+
}
127+
128+
if (!branch) {
129+
throw new Error('`branch` is required as the second argument')
130+
}
131+
132+
if (!GITHUB_TOKEN) {
133+
throw new Error(`process.env.GITHUB_TOKEN is required`)
134+
}
135+
136+
await fsp.access(NODE_DIR, fsp.constants.F_OK).catch(() => {
137+
throw new Error(`node repo must be checked out to \`${NODE_DIR}\` to continue`)
138+
})
139+
140+
await gh.json('repo', 'view', NODE_FORK, 'url').catch(() => {
141+
throw new Error(`node repo must be forked to ${NODE_FORK}`)
142+
})
143+
144+
await git.dirty().catch((er) => {
145+
if (localTest) {
146+
return log.info('Skipping git dirty check due to `--local-test` flag')
147+
}
148+
throw er
149+
})
150+
151+
const mani = await pacote.manifest(`npm@${spec}`, { preferOnline: true })
152+
const packument = await pacote.packument('npm', { preferOnline: true })
153+
const npmVersions = Object.keys(packument.versions).sort(semver.rcompare)
154+
155+
const npmVersion = semver.parse(mani.version)
156+
const npmHost = hgi.fromUrl(NODE_FORK)
157+
const npmTag = `v${npmVersion}`
158+
const npmBranch = `npm-${npmTag}`
159+
const npmRemoteUrl = tokenRemoteUrl({ host: npmHost, token: GITHUB_TOKEN })
160+
const npmMessage = (v = npmVersion) => `deps: upgrade npm to ${v}`
58161

59162
const tarball = await createNodeTarball({
60163
mani,
164+
tag: npmTag,
61165
dir: tmpDir,
62166
registryOnly,
63-
// the only reason this is optional is for testing when updating this script.
64-
// if we checkout an older tag, it won't have the updates we are testing.
65-
tag: skipCheckout ? null : head.tag,
167+
localTest,
66168
})
169+
log.info('tarball path', tarball)
67170

68-
await fsp.access(NODE_DIR, fsp.constants.F_OK).catch(() => {
69-
throw new Error(`node repo must be checked out to \`${NODE_DIR}\` to continue`)
70-
})
171+
const nodeRemote = 'origin'
172+
const nodeBranch = /^\d+$/.test(branch) ? `v${branch}.x-staging` : branch
173+
const nodeHost = hgi.fromUrl(await gitNode('remote', 'get-url', nodeRemote, { out: true }))
174+
const nodePrArgs = ['pr', '-R', nodeHost.path()]
175+
176+
await gitNode('fetch', nodeRemote)
177+
await gitNode('checkout', nodeBranch)
178+
await gitNode('reset', '--hard', `${nodeRemote}/${nodeBranch}`)
179+
180+
const nodeNpmPath = join('deps', 'npm')
181+
const nodeNpmDir = join(NODE_DIR, nodeNpmPath)
182+
const nodeNpmVersion = require(join(nodeNpmDir, 'package.json')).version
71183

72-
const base = {
73-
// we used to send PRs sometimes for old versions to the 14.x staging
74-
// branch. this might not be needed anymore, but this is how we
75-
// would do it, if we needed to send a PR for backport fixes
76-
branch: semver.major(mani.version) <= 8 ? '14.x-staging' : 'main',
77-
remote: 'origin',
78-
host: hgi.fromUrl(await gitNode('remote', 'get-url', 'origin', { out: true })),
184+
// this is the range of all versions included in this update based
185+
// on the current version of npm in node currently. we use this
186+
// to build a list of all release notes and to close any existing PRs
187+
const newNpmVersions = npmVersions.slice(
188+
npmVersions.indexOf(npmVersion.toString()),
189+
npmVersions.indexOf(nodeNpmVersion)
190+
)
191+
.reverse()
192+
.map((v) => semver.parse(v))
193+
.filter((version) => version.major === npmVersion.major)
194+
195+
// get a list of all versions changelogs to add to the body of the PR
196+
// do this before we checkout our branch and make any changes
197+
const npmReleases = await Promise.all(newNpmVersions.map(async (v) => {
198+
// dont include prereleases unless we are updating to a prerlease since we
199+
// manually put all prerelease notes into the first stable major version
200+
if (v.prerelease.length && !npmVersion.prerelease.length) {
201+
return null
202+
}
203+
return {
204+
version: v,
205+
body: await gh.json('release', 'view', `v${v}`, 'body', { quiet: true }).then(r => r.trim()),
206+
}
207+
})).then(r => r.filter(Boolean))
208+
209+
log.info('npm versions', newNpmVersions.map(v => v.toString()))
210+
log.info('npm releases', npmReleases.map(u => u.version.toString()))
211+
212+
await gitNode('branch', '-D', npmBranch, { ok: true })
213+
await gitNode('checkout', '-b', npmBranch)
214+
await fs.clean(nodeNpmDir)
215+
await tar.x({ strip: 1, file: tarball, cwd: nodeNpmDir })
216+
await fs.rimraf(join(nodeNpmDir, basename(tarball)))
217+
218+
await gitNode('add', '-A', nodeNpmPath)
219+
await gitNode('commit', '-m', npmMessage())
220+
await gitNode('rebase', '--whitespace', 'fix', nodeBranch)
221+
222+
await gitNode('remote', 'rm', npmHost.user, { ok: true })
223+
await gitNode('remote', 'add', npmHost.user, npmRemoteUrl)
224+
if (!dryRun) {
225+
await gitNode('push', npmHost.user, npmBranch, '--force')
79226
}
80-
log.silly(base)
81227

82-
await gh('repo', 'fork', base.host.path(), '--org', head.host.user, { quiet: true, ok: true })
83-
await gitNode('fetch', base.remote)
84-
await gitNode('checkout', base.branch)
85-
await gitNode('reset', '--hard', `${base.remote}/${base.branch}`)
86-
await gitNode('branch', '-D', head.branch, { ok: true })
87-
await gitNode('checkout', '-b', head.branch)
228+
const npmPrs = await gh.json(
229+
...nodePrArgs, 'list',
230+
'-S', `in:title "${npmMessage('')}"`,
231+
'number,title,url'
232+
)
233+
234+
log.info('Found other npm PRs', npmPrs)
88235

89-
const npmPath = join('deps', 'npm')
90-
const npmDir = join(NODE_DIR, npmPath)
91-
await fs.clean(npmDir)
92-
await tar.x({ strip: 1, file: tarball, cwd: npmDir })
236+
let existingPr = null
237+
const closePrs = []
93238

94-
await gitNode('add', '-A', npmPath)
95-
await gitNode('commit', '-m', head.message)
96-
await gitNode('rebase', '--whitespace', 'fix', base.branch)
239+
for (const pr of npmPrs) {
240+
const prVersion = pr.title.replace(npmMessage(''), '').trim()
241+
log.silly('checking existing PR', prVersion, pr)
97242

98-
await gitNode('remote', 'add', head.host.user, head.host.ssh(), { ok: true })
99-
await gitNode('push', head.host.user, head.branch, '--force')
243+
if (!existingPr && prVersion === npmVersion.toString()) {
244+
existingPr = pr
245+
} else if (newNpmVersions.some(v => v.toString() === prVersion)) {
246+
closePrs.push(pr)
247+
}
248+
}
249+
250+
log.info('Found exisiting PR', existingPr)
251+
log.info('Found PRs to close', closePrs)
100252

101-
const notes = await gh.json('release', 'view', head.tag, 'body')
102-
log.silly('body', notes)
253+
const prBody = await getPrBody({ releases: npmReleases, closePrs })
103254

104255
const prArgs = [
105-
'pr', 'create',
106-
'-R', base.host.path(),
107-
'-B', base.branch,
108-
'-H', `${head.host.user}:${head.branch}`,
109-
'-t', head.message,
110-
]
256+
nodePrArgs,
257+
(existingPr ? ['edit', existingPr.number] : ['create', '-H', `${npmHost.user}:${npmBranch}`]),
258+
'-B', nodeBranch,
259+
'-t', npmMessage(),
260+
].flat()
111261

112262
if (dryRun) {
113263
log.info(`gh ${prArgs.join(' ')}`)
114-
const url = new URL(base.host.browse())
115-
const compare = `${base.branch}...${head.host.user}:${head.host.project}:${head.branch}`
116-
url.pathname += `/compare/${compare}`
117-
url.searchParams.set('expand', '1')
118-
return url.toString()
264+
return prBody
265+
}
266+
267+
const newOrUpdatedPr = await gh(prArgs, '-F', '-', { input: prBody, out: true })
268+
const closeMessage = `Closing in favor of ${newOrUpdatedPr}`
269+
270+
for (const closePr of closePrs) {
271+
log.info('Attempting to close PR', closePr.url)
272+
try {
273+
await gh(nodePrArgs, 'close', closePr.number, '-c', closeMessage)
274+
} catch (err) {
275+
log.error('Could not close PR', err)
276+
}
119277
}
120278

121-
return gh(...prArgs, '-F', '-', { cwd: NODE_DIR, input: notes, out: true })
279+
return newOrUpdatedPr
122280
})
123281

124-
run(({ argv, ...opts }) => main(argv.remain[0], opts))
282+
run(({ argv, ...opts }) => main(argv.remain[0], argv.remain[1], opts), {
283+
redact: new RegExp(process.env.GITHUB_TOKEN, 'g'),
284+
})

0 commit comments

Comments
 (0)