Skip to content

Commit 1e493fc

Browse files
authored
Create React sync automatically (#69314)
Closes NDX-245
1 parent d66ea6a commit 1e493fc

File tree

4 files changed

+170
-23
lines changed

4 files changed

+170
-23
lines changed

.github/.react-version

Lines changed: 0 additions & 1 deletion
This file was deleted.

.github/labeler.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@
7171
"packages/next-swc/**",
7272
"packages/next/**",
7373
"packages/react-refresh-utils/**"
74-
],
75-
"type: react-sync": [".github/.react-version"]
74+
]
7675
}
7776
}

.github/workflows/update_react.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515

1616
env:
1717
NODE_LTS_VERSION: 20
18+
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
1819

1920
jobs:
2021
create-pull-request:
@@ -27,6 +28,11 @@ jobs:
2728
# See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow
2829
token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }}
2930

31+
- name: Set Git author
32+
run: |
33+
git config user.name "vercel-release-bot"
34+
git config user.email "[email protected]"
35+
3036
- name: Setup node
3137
uses: actions/setup-node@v4
3238
with:
@@ -37,4 +43,11 @@ jobs:
3743

3844
- name: Install dependencies
3945
shell: bash
40-
run: pnpm i
46+
# Just need scripts/ but those dependencies are listed in the workspace root.
47+
run: pnpm install --filter .
48+
49+
- name: Create Pull Request
50+
shell: bash
51+
run: pnpm sync-react --actor "${{ github.actor }}" --commit --create-pull --version "${{ inputs.version }}"
52+
env:
53+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PULL_REQUESTS }}

scripts/sync-react.js

Lines changed: 155 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ const path = require('path')
44
const fsp = require('fs/promises')
55
const process = require('process')
66
const execa = require('execa')
7+
const { Octokit } = require('octokit')
78
const yargs = require('yargs')
89

910
/** @type {any} */
1011
const fetch = require('node-fetch')
1112

13+
const repoOwner = 'vercel'
14+
const repoName = 'next.js'
15+
const pullRequestLabels = ['type: react-sync']
16+
const pullRequestReviewers = ['eps1lon']
17+
1218
const filesReferencingReactPeerDependencyVersion = [
1319
'run-tests.js',
1420
'packages/create-next-app/templates/index.ts',
@@ -155,12 +161,50 @@ async function main() {
155161
const errors = []
156162
const argv = await yargs(process.argv.slice(2))
157163
.version(false)
164+
.options('actor', {
165+
type: 'string',
166+
description:
167+
'Required with `--create-pull`. The actor (GitHub username) that runs this script. Will be used for notifications but not commit attribution.',
168+
})
169+
.options('create-pull', {
170+
default: false,
171+
type: 'boolean',
172+
description: 'Create a Pull Request in vercel/next.js',
173+
})
174+
.options('commit', {
175+
default: false,
176+
type: 'boolean',
177+
description:
178+
'Creates commits for each intermediate step. Useful to create better diffs for GitHub.',
179+
})
158180
.options('install', { default: true, type: 'boolean' })
159181
.options('version', { default: null, type: 'string' }).argv
160-
const { install, version } = argv
182+
const { actor, createPull, commit, install, version } = argv
183+
184+
async function commitEverything(message) {
185+
await execa('git', ['add', '-A'])
186+
await execa('git', ['commit', '--message', message, '--no-verify'])
187+
}
188+
189+
if (createPull && !actor) {
190+
throw new Error(
191+
`Pull Request cannot be created without a GitHub actor (received '${String(actor)}'). ` +
192+
'Pass an actor via `--actor "some-actor"`.'
193+
)
194+
}
195+
const githubToken = process.env.GITHUB_TOKEN
196+
if (createPull && !githubToken) {
197+
throw new Error(
198+
`Environment variable 'GITHUB_TOKEN' not specified but required when --create-pull is specified.`
199+
)
200+
}
161201

162202
let newVersionStr = version
163-
if (newVersionStr === null) {
203+
if (
204+
newVersionStr === null ||
205+
// TODO: Fork arguments in GitHub workflow to ensure `--version ""` is considered a mistake
206+
newVersionStr === ''
207+
) {
164208
const { stdout, stderr } = await execa(
165209
'npm',
166210
['view', 'react@canary', 'version'],
@@ -174,6 +218,9 @@ async function main() {
174218
throw new Error('Failed to read latest React canary version from npm.')
175219
}
176220
newVersionStr = stdout.trim()
221+
console.log(
222+
`--version was not provided. Using react@canary: ${newVersionStr}`
223+
)
177224
}
178225

179226
const newVersionInfo = extractInfoFromReactVersion(newVersionStr)
@@ -188,6 +235,37 @@ Or, run this command with no arguments to use the most recently published versio
188235
)
189236
}
190237
const { sha: newSha, dateString: newDateString } = newVersionInfo
238+
239+
const branchName = `update/react/${newSha}-${newDateString}`
240+
if (createPull) {
241+
const { exitCode, all, command } = await execa(
242+
'git',
243+
[
244+
'ls-remote',
245+
'--exit-code',
246+
'--heads',
247+
'origin',
248+
`refs/heads/${branchName}`,
249+
],
250+
{ reject: false }
251+
)
252+
253+
if (exitCode === 2) {
254+
console.log(
255+
`No sync in progress in branch '${branchName}' according to '${command}'. Starting a new one.`
256+
)
257+
} else if (exitCode === 0) {
258+
console.log(
259+
`An existing sync already exists in branch '${branchName}'. Delete the branch to start a new sync.`
260+
)
261+
return
262+
} else {
263+
throw new Error(
264+
`Failed to check if the branch already existed:\n${command}: ${all}`
265+
)
266+
}
267+
}
268+
191269
const rootManifest = JSON.parse(
192270
await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8')
193271
)
@@ -203,13 +281,19 @@ Or, run this command with no arguments to use the most recently published versio
203281
noInstall: !install,
204282
channel: 'experimental',
205283
})
284+
if (commit) {
285+
await commitEverything('Update `react@experimental`')
286+
}
206287
await sync({
207288
newDateString,
208289
newSha,
209290
newVersionStr,
210291
noInstall: !install,
211292
channel: 'rc',
212293
})
294+
if (commit) {
295+
await commitEverything('Update `react@rc`')
296+
}
213297

214298
const baseVersionInfo = extractInfoFromReactVersion(baseVersionStr)
215299
if (!baseVersionInfo) {
@@ -269,13 +353,22 @@ Or, run this command with no arguments to use the most recently published versio
269353
)
270354
}
271355

356+
if (commit) {
357+
await commitEverything('Updated peer dependency references')
358+
}
359+
272360
// Install the updated dependencies and build the vendored React files.
273361
if (!install) {
274362
console.log('Skipping install step because --no-install flag was passed.\n')
275363
} else {
276364
console.log('Installing dependencies...\n')
277365

278-
const installSubprocess = execa('pnpm', ['install'])
366+
const installSubprocess = execa('pnpm', [
367+
'install',
368+
// Pnpm freezes the lockfile by default in CI.
369+
// However, we just changed versions so the lockfile is expected to be changed.
370+
'--no-frozen-lockfile',
371+
])
279372
if (installSubprocess.stdout) {
280373
installSubprocess.stdout.pipe(process.stdout)
281374
}
@@ -286,6 +379,10 @@ Or, run this command with no arguments to use the most recently published versio
286379
throw new Error('Failed to install updated dependencies.')
287380
}
288381

382+
if (commit) {
383+
await commitEverything('Update lockfile')
384+
}
385+
289386
console.log('Building vendored React files...\n')
290387
const nccSubprocess = execa('pnpm', ['ncc-compiled'], {
291388
cwd: path.join(cwd, 'packages', 'next'),
@@ -300,34 +397,29 @@ Or, run this command with no arguments to use the most recently published versio
300397
throw new Error('Failed to run ncc.')
301398
}
302399

400+
if (commit) {
401+
await commitEverything('ncc-compiled')
402+
}
403+
303404
// Print extra newline after ncc output
304405
console.log()
305406
}
306407

307-
console.log(
308-
`**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**`
309-
)
408+
let prDescription = `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**\n\n`
310409

311410
// Fetch the changelog from GitHub and print it to the console.
312-
console.log(
313-
`[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})`
314-
)
411+
prDescription += `[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})\n\n`
315412
try {
316413
const changelog = await getChangelogFromGitHub(baseSha, newSha)
317414
if (changelog === null) {
318-
console.log(
319-
`GitHub reported no changes between ${baseSha} and ${newSha}.`
320-
)
415+
prDescription += `GitHub reported no changes between ${baseSha} and ${newSha}.`
321416
} else {
322-
console.log(
323-
`<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
324-
)
417+
prDescription += `<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
325418
}
326419
} catch (error) {
327420
console.error(error)
328-
console.log(
421+
prDescription +=
329422
'\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n'
330-
)
331423
}
332424

333425
if (!install) {
@@ -343,13 +435,57 @@ Or run this command again without the --no-install flag to do both automatically
343435
)
344436
}
345437

346-
await fsp.writeFile(path.join(cwd, '.github/.react-version'), newVersionStr)
347-
348438
if (errors.length) {
349439
// eslint-disable-next-line no-undef -- Defined in Node.js
350440
throw new AggregateError(errors)
351441
}
352442

443+
if (createPull) {
444+
const octokit = new Octokit({ auth: githubToken })
445+
const prTitle = `Upgrade React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\``
446+
447+
await execa('git', ['checkout', '-b', branchName])
448+
// We didn't commit intermediate steps yet so now we need to commit to create a PR.
449+
if (!commit) {
450+
commitEverything(prTitle)
451+
}
452+
await execa('git', ['push', 'origin', branchName])
453+
const pullRequest = await octokit.rest.pulls.create({
454+
owner: repoOwner,
455+
repo: repoName,
456+
head: branchName,
457+
base: 'canary',
458+
draft: false,
459+
title: prTitle,
460+
body: prDescription,
461+
})
462+
console.log('Created pull request %s', pullRequest.data.html_url)
463+
464+
await Promise.all([
465+
actor
466+
? octokit.rest.issues.addAssignees({
467+
owner: repoOwner,
468+
repo: repoName,
469+
issue_number: pullRequest.data.number,
470+
assignees: [actor],
471+
})
472+
: Promise.resolve(),
473+
octokit.rest.pulls.requestReviewers({
474+
owner: repoOwner,
475+
repo: repoName,
476+
pull_number: pullRequest.data.number,
477+
reviewers: pullRequestReviewers,
478+
}),
479+
octokit.rest.issues.addLabels({
480+
owner: repoOwner,
481+
repo: repoName,
482+
issue_number: pullRequest.data.number,
483+
labels: pullRequestLabels,
484+
}),
485+
])
486+
}
487+
488+
console.log(prDescription)
353489
console.log(
354490
`Successfully updated React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\``
355491
)

0 commit comments

Comments
 (0)