1
- const { join } = require ( 'path' )
1
+ const { join, basename } = require ( 'path' )
2
2
const fsp = require ( 'fs/promises' )
3
3
const hgi = require ( 'hosted-git-info' )
4
4
const semver = require ( 'semver' )
@@ -8,11 +8,12 @@ const tar = require('tar')
8
8
const { cp, withTempDir } = require ( '@npmcli/fs' )
9
9
const { CWD , run, spawn, git, fs, gh } = require ( './util.js' )
10
10
11
+ const NODE_FORK = 'npm/node'
11
12
// this script expects node to already be cloned to a directory at the cli root named "node"
12
13
const NODE_DIR = join ( CWD , 'node' )
13
14
const gitNode = spawn . create ( 'git' , { cwd : NODE_DIR } )
14
15
15
- const createNodeTarball = async ( { mani, registryOnly, tag, dir : extractDir } ) => {
16
+ const createNodeTarball = async ( { mani, registryOnly, localTest , tag, dir : extractDir } ) => {
16
17
const tarball = join ( extractDir , 'npm-node.tgz' )
17
18
await pacote . tarball . file ( mani . _from , tarball , { resolved : mani . _resolved } )
18
19
@@ -28,12 +29,26 @@ const createNodeTarball = async ({ mani, registryOnly, tag, dir: extractDir }) =
28
29
await fs . rimraf ( tarball )
29
30
30
31
// 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/' ] ) {
34
47
await cp ( join ( CWD , path ) , join ( extractDir , path ) , { recursive : true } )
35
48
}
36
49
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
37
52
await tar . c ( {
38
53
...pacote . DirFetcher . tarCreateOptions ( mani ) ,
39
54
cwd : extractDir ,
@@ -43,82 +58,227 @@ const createNodeTarball = async ({ mani, registryOnly, tag, dir: extractDir }) =
43
58
return tarball
44
59
}
45
60
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 } `
48
64
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
+ }
50
78
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'
56
81
}
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 ( / \/ n p m \/ c l i \/ c o m p a r e \/ 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 } `
58
161
59
162
const tarball = await createNodeTarball ( {
60
163
mani,
164
+ tag : npmTag ,
61
165
dir : tmpDir ,
62
166
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,
66
168
} )
169
+ log . info ( 'tarball path' , tarball )
67
170
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
71
183
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' )
79
226
}
80
- log . silly ( base )
81
227
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 )
88
235
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 = [ ]
93
238
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 )
97
242
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 )
100
252
101
- const notes = await gh . json ( 'release' , 'view' , head . tag , 'body' )
102
- log . silly ( 'body' , notes )
253
+ const prBody = await getPrBody ( { releases : npmReleases , closePrs } )
103
254
104
255
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 ( )
111
261
112
262
if ( dryRun ) {
113
263
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
+ }
119
277
}
120
278
121
- return gh ( ... prArgs , '-F' , '-' , { cwd : NODE_DIR , input : notes , out : true } )
279
+ return newOrUpdatedPr
122
280
} )
123
281
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