mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
8c7ed006b8
On scheduled runs of the Update React workflow, `github.actor` often resolves to `github-actions[bot]`, which cannot be assigned to pull requests in `vercel/next.js`. Since the sync switched to the GitHub App token, adding a non-assignable assignee fails the request with 403 e.g. https://github.com/vercel/next.js/actions/runs/32392135342/job/96500520936#step:8:781 (user tokens silently ignored it instead), which failed the entire sync run and raced the in-flight reviewer request, leaving the created pull request without a reviewer. The script now checks assignability and skips assignment with a warning when the actor cannot be assigned, and the finalize requests (assign, request reviewers, add labels) run through `Promise.allSettled`, with any failures rethrown together as an `AggregateError` so the run still fails on real errors. Co-authored-by: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
810 lines
27 KiB
JavaScript
810 lines
27 KiB
JavaScript
// @ts-check
|
|
|
|
const path = require('path')
|
|
const fsp = require('fs/promises')
|
|
const process = require('process')
|
|
const { pathToFileURL } = require('url')
|
|
const execa = require('execa')
|
|
const { Octokit } = require('octokit')
|
|
const SemVer = require('semver')
|
|
const yargs = require('yargs')
|
|
const {
|
|
replayLocalCommitsAsSigned,
|
|
upsertBranchRef,
|
|
} = require('./github-utils/signed-commit')
|
|
|
|
// Use this script to update Next's vendored copy of React and related packages:
|
|
//
|
|
// Basic usage (defaults to most recent React canary version):
|
|
// pnpm run sync-react
|
|
//
|
|
// Update package.json but skip installing the dependencies automatically:
|
|
// pnpm run sync-react --no-install
|
|
//
|
|
// Sync from a local checkout of React (requires having React built first):
|
|
// pnpm run sync-react --version /path/to/react/checkout/
|
|
// Sync from a React commit (can be a commit on a PR)
|
|
// pnpm run sync-react --version vp:///commit-sha
|
|
|
|
const repoOwner = 'vercel'
|
|
const repoName = 'next.js'
|
|
const pullRequestLabels = ['type: react-sync']
|
|
const pullRequestReviewers = ['eps1lon']
|
|
/**
|
|
* Set to `null` to automatically sync the React version of Pages Router with App Router React version.
|
|
* Set to a specific version to override the Pages Router React version e.g. `^19.0.0`.
|
|
*
|
|
* "Active" just refers to our current development practice. While we do support
|
|
* React 18 in pages router, we don't focus our development process on it considering
|
|
* it does not receive new features.
|
|
* @type {string | null}
|
|
*/
|
|
const activePagesRouterReact = '^19.0.0'
|
|
|
|
const defaultLatestChannel = 'canary'
|
|
const filesReferencingReactPeerDependencyVersion = [
|
|
'run-tests.js',
|
|
'packages/create-next-app/templates/index.ts',
|
|
'test/lib/next-modes/base.ts',
|
|
]
|
|
const libraryManifestsSupportingNextjsReact = [
|
|
'packages/third-parties/package.json',
|
|
'packages/next/package.json',
|
|
]
|
|
const appManifestsInstallingNextjsPeerDependencies = [
|
|
'examples/reproduction-template/package.json',
|
|
'test/.stats-app/package.json',
|
|
// TODO: These should use the usual test helpers that automatically install the right React version
|
|
'test/e2e/next-test/first-time-setup-js/package.json',
|
|
'test/e2e/next-test/first-time-setup-ts/package.json',
|
|
]
|
|
|
|
async function getSchedulerVersion(reactVersion) {
|
|
if (reactVersion.startsWith('file://')) {
|
|
return reactVersion
|
|
}
|
|
if (reactVersion.startsWith('vp:')) {
|
|
return reactVersion
|
|
}
|
|
|
|
const url = `https://registry.npmjs.org/react-dom/${reactVersion}`
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`${url}: ${response.status} ${response.statusText}\n${await response.text()}`
|
|
)
|
|
}
|
|
|
|
const manifest = await response.json()
|
|
|
|
return manifest.dependencies['scheduler']
|
|
}
|
|
|
|
/**
|
|
* @param {string} packageName
|
|
* @param {string} versionStr An NPM version or a file URL to a React checkout
|
|
* @returns {string}
|
|
*/
|
|
function getPackageVersion(packageName, versionStr) {
|
|
if (versionStr.startsWith('file://')) {
|
|
return new URL(packageName, versionStr).href
|
|
}
|
|
if (versionStr.startsWith('vp:')) {
|
|
const { pathname } = new URL(versionStr)
|
|
const [, commit, releaseChannel] = pathname.split('/')
|
|
return new URL(
|
|
`/react/commits/${commit}/${packageName}@${releaseChannel}`,
|
|
'https://vercel-packages.vercel.app'
|
|
).href
|
|
}
|
|
|
|
return `npm:${packageName}@${versionStr}`
|
|
}
|
|
|
|
async function sync({ channel, newVersionStr, noInstall }) {
|
|
const useExperimental = channel === 'experimental'
|
|
const cwd = process.cwd()
|
|
const pkgJson = JSON.parse(
|
|
await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8')
|
|
)
|
|
const devDependencies = pkgJson.devDependencies
|
|
const pnpmOverrides = pkgJson.pnpm.overrides
|
|
const baseVersionStr = devDependencies[
|
|
useExperimental ? 'react-experimental-builtin' : 'react-builtin'
|
|
].replace(/^npm:react@/, '')
|
|
|
|
console.log(`Updating "react@${channel}" to ${newVersionStr}...`)
|
|
if (newVersionStr === baseVersionStr) {
|
|
console.log('Already up to date.')
|
|
return
|
|
}
|
|
|
|
const newSchedulerVersionStr = await getSchedulerVersion(newVersionStr)
|
|
console.log(`Updating "scheduler@${channel}" to ${newSchedulerVersionStr}...`)
|
|
|
|
for (const packageName of ['react', 'react-dom']) {
|
|
devDependencies[
|
|
`${packageName}${useExperimental ? '-experimental' : ''}-builtin`
|
|
] = getPackageVersion(packageName, newVersionStr)
|
|
|
|
if (!useExperimental) {
|
|
pnpmOverrides[packageName] = getPackageVersion(packageName, newVersionStr)
|
|
}
|
|
}
|
|
|
|
for (const packageName of [
|
|
'react-server-dom-turbopack',
|
|
'react-server-dom-webpack',
|
|
]) {
|
|
devDependencies[`${packageName}${useExperimental ? '-experimental' : ''}`] =
|
|
getPackageVersion(packageName, newVersionStr)
|
|
}
|
|
|
|
devDependencies[
|
|
`scheduler-${useExperimental ? 'experimental-' : ''}builtin`
|
|
] = getPackageVersion('scheduler', newSchedulerVersionStr)
|
|
if (!useExperimental) {
|
|
pnpmOverrides.scheduler = getPackageVersion(
|
|
'scheduler',
|
|
newSchedulerVersionStr
|
|
)
|
|
|
|
// TODO: Should be handled like the other React packages
|
|
devDependencies['react-is-builtin'] = newVersionStr.startsWith('file://')
|
|
? new URL('react-is', newVersionStr).href
|
|
: newVersionStr.startsWith('vp:')
|
|
? getPackageVersion('react-is', newVersionStr)
|
|
: `npm:react-is@${newVersionStr}`
|
|
pnpmOverrides['react-is'] = newVersionStr.startsWith('file://')
|
|
? new URL('react-is', newVersionStr).href
|
|
: newVersionStr.startsWith('vp:')
|
|
? getPackageVersion('react-is', newVersionStr)
|
|
: `npm:react-is@${newVersionStr}`
|
|
}
|
|
|
|
await fsp.writeFile(
|
|
path.join(cwd, 'package.json'),
|
|
JSON.stringify(pkgJson, null, 2) +
|
|
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
|
|
'\n'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} ReactVersionInfo
|
|
* @property {string} semverVersion - The semver version of React.
|
|
* @property {string} releaseLabel - The release label of React (e.g. "canary", "rc").
|
|
* @property {string} sha - The commit SHA of the React version.
|
|
* @property {string} dateString - The date string of the React version.
|
|
* @returns {ReactVersionInfo}
|
|
*/
|
|
function extractInfoFromReactVersion(versionStr) {
|
|
if (versionStr.startsWith('file://')) {
|
|
return {
|
|
dateString: new Date().toISOString().split('T')[0],
|
|
releaseLabel: 'local',
|
|
semverVersion: '0.0.0',
|
|
sha: 'local',
|
|
}
|
|
}
|
|
if (versionStr.startsWith('vp:')) {
|
|
const { pathname } = new URL(versionStr)
|
|
const [, commit] = pathname.split('/')
|
|
return {
|
|
dateString: new Date().toISOString().split('T')[0],
|
|
releaseLabel: 'vercel-packages',
|
|
semverVersion: '0.0.0',
|
|
sha: commit,
|
|
}
|
|
}
|
|
if (versionStr.startsWith('https:')) {
|
|
const url = new URL(versionStr)
|
|
if (url.hostname === 'vercel-packages.vercel.app') {
|
|
// e.g https://vercel-packages.vercel.app/react/commits/bc50ab4bffa17f507386554a8ef3c3ed4f37fe1b/react@canary
|
|
const [, , , commit] = url.pathname.split('/')
|
|
return {
|
|
dateString: new Date().toISOString().split('T')[0],
|
|
releaseLabel: `vercel-packages`,
|
|
semverVersion: '0.0.0',
|
|
sha: commit,
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Unsupported URL '${versionStr}'. Only vercel-packages.vercel.app URLs are supported.`
|
|
)
|
|
}
|
|
|
|
const match = versionStr.match(
|
|
/(?<semverVersion>.*)-(?<releaseLabel>.*)-(?<sha>.*)-(?<dateString>.*)$/
|
|
)
|
|
return match ? match.groups : null
|
|
}
|
|
|
|
async function getChangelogFromGitHub(baseSha, newSha) {
|
|
const pageSize = 50
|
|
let changelog = []
|
|
for (let currentPage = 1; ; currentPage++) {
|
|
const url = `https://api.github.com/repos/facebook/react/compare/${baseSha}...${newSha}?per_page=${pageSize}&page=${currentPage}`
|
|
const headers = new Headers()
|
|
// GITHUB_TOKEN is optional but helps in case of rate limiting during development.
|
|
if (process.env.GITHUB_TOKEN) {
|
|
headers.set('Authorization', `token ${process.env.GITHUB_TOKEN}`)
|
|
}
|
|
const response = await fetch(url, {
|
|
headers,
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`${response.url}: Failed to fetch commit log from GitHub:\n${response.statusText}\n${await response.text()}`
|
|
)
|
|
}
|
|
const data = await response.json()
|
|
|
|
const { commits } = data
|
|
for (const { commit, sha } of commits) {
|
|
const title = commit.message.split('\n')[0] || ''
|
|
const match =
|
|
// The "title" looks like "[Fiber][Float] preinitialized stylesheets should support integrity option (#26881)"
|
|
/\(#([0-9]+)\)$/.exec(title) ??
|
|
// or contains "Pull Request resolved: https://github.com/facebook/react/pull/12345" in the body if merged via ghstack (e.g. https://github.com/facebook/react/commit/0a0a5c02f138b37e93d5d93341b494d0f5d52373)
|
|
/^Pull Request resolved: https:\/\/github.com\/facebook\/react\/pull\/([0-9]+)$/m.exec(
|
|
commit.message
|
|
)
|
|
const prNum = match ? match[1] : ''
|
|
if (prNum) {
|
|
changelog.push(`- https://github.com/facebook/react/pull/${prNum}`)
|
|
} else {
|
|
changelog.push(
|
|
`- [${commit.message.split('\n')[0]} facebook/react@${sha.slice(0, 9)}](https://github.com/facebook/react/commit/${sha}) (${commit.author.name})`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (commits.length < pageSize) {
|
|
// If the number of commits is less than the page size, we've reached
|
|
// the end. Otherwise we'll keep fetching until we run out.
|
|
break
|
|
}
|
|
}
|
|
|
|
changelog.reverse()
|
|
|
|
return changelog.length > 0 ? changelog.join('\n') : null
|
|
}
|
|
|
|
async function findHighestNPMReactVersion(versionLike) {
|
|
const { stdout, stderr } = await execa(
|
|
'npm',
|
|
['--silent', 'view', '--json', `react@${versionLike}`, 'version'],
|
|
{
|
|
// Avoid "Usage Error: This project is configured to use pnpm".
|
|
cwd: '/tmp',
|
|
}
|
|
)
|
|
if (stderr) {
|
|
console.error(stderr)
|
|
throw new Error(
|
|
`Failed to read highest react@${versionLike} version from npm.`
|
|
)
|
|
}
|
|
|
|
const result = JSON.parse(stdout)
|
|
|
|
return typeof result === 'string'
|
|
? result
|
|
: result.sort((a, b) => {
|
|
return SemVer.compare(b, a)
|
|
})[0]
|
|
}
|
|
|
|
/**
|
|
* Assigns `actor` to the given Pull Request if they can be assigned.
|
|
* On scheduled runs `github.actor` often resolves to a bot like
|
|
* `github-actions[bot]`, which cannot be assigned. User tokens silently
|
|
* ignore non-assignable assignees but GitHub App tokens fail the whole
|
|
* request with 403, so check assignability first and skip instead.
|
|
* @param {InstanceType<typeof Octokit>} octokit
|
|
* @param {string | undefined} actor
|
|
* @param {number} pullRequestNumber
|
|
*/
|
|
async function assignActorIfAssignable(octokit, actor, pullRequestNumber) {
|
|
if (actor === undefined) {
|
|
return null
|
|
}
|
|
try {
|
|
await octokit.rest.issues.checkUserCanBeAssigned({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
assignee: actor,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof Error && 'status' in error && error.status === 404) {
|
|
console.warn(
|
|
`'${actor}' cannot be assigned in ${repoOwner}/${repoName}. Skipping assignment.`
|
|
)
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
return octokit.rest.issues.addAssignees({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
issue_number: pullRequestNumber,
|
|
assignees: [actor],
|
|
})
|
|
}
|
|
|
|
async function main() {
|
|
const cwd = process.cwd()
|
|
const errors = []
|
|
const argv = await yargs(process.argv.slice(2))
|
|
.version(false)
|
|
.options('actor', {
|
|
type: 'string',
|
|
description:
|
|
'Required with `--create-pull`. The actor (GitHub username) that runs this script. Will be assigned to the Pull Request for notifications, but neither the commits nor the Pull Request are attributed to them.',
|
|
})
|
|
.options('create-pull', {
|
|
default: false,
|
|
type: 'boolean',
|
|
description: 'Create a Pull Request in vercel/next.js',
|
|
})
|
|
.options('commit', {
|
|
default: false,
|
|
type: 'boolean',
|
|
description:
|
|
'Creates commits for each intermediate step. Useful to create better diffs for GitHub.',
|
|
})
|
|
.options('install', { default: true, type: 'boolean' })
|
|
.options('version', {
|
|
default: null,
|
|
type: 'string',
|
|
description:
|
|
'e.g. 19.3.0-canary-?-? or vp:///commit-sha for a build from a specific React commit (can be a commit on a PR)',
|
|
}).argv
|
|
let { actor, createPull, commit, install, version } = argv
|
|
if (version !== null && version.startsWith('/')) {
|
|
version = pathToFileURL(version).href
|
|
// Ensure trailing slash so that the URL is treated as a directory.
|
|
if (!version.endsWith('/')) {
|
|
version += '/'
|
|
}
|
|
}
|
|
|
|
async function commitEverything(message) {
|
|
await execa('git', ['add', '-A'])
|
|
await execa('git', [
|
|
'commit',
|
|
'--message',
|
|
message,
|
|
'--no-verify',
|
|
// Some steps can be empty, e.g. when we don't sync Pages router
|
|
'--allow-empty',
|
|
])
|
|
}
|
|
|
|
if (createPull && !actor) {
|
|
throw new Error(
|
|
`Pull Request cannot be created without a GitHub actor (received '${String(actor)}'). ` +
|
|
'Pass an actor via `--actor "some-actor"`.'
|
|
)
|
|
}
|
|
const releaseGithubToken = process.env.RELEASE_GITHUB_TOKEN
|
|
const releaseAppSlug = process.env.RELEASE_GITHUB_APP_SLUG
|
|
const releaseAppUserId = process.env.RELEASE_GITHUB_APP_USER_ID
|
|
if (createPull) {
|
|
if (!releaseGithubToken) {
|
|
throw new Error(
|
|
`Environment variable 'RELEASE_GITHUB_TOKEN' not specified but required when --create-pull is specified.`
|
|
)
|
|
}
|
|
if (!releaseAppSlug || !releaseAppUserId) {
|
|
throw new Error(
|
|
`Environment variables 'RELEASE_GITHUB_APP_SLUG' and 'RELEASE_GITHUB_APP_USER_ID' must be set when --create-pull is specified.`
|
|
)
|
|
}
|
|
|
|
// Set the git author up-front so all subsequent local commits made by
|
|
// this script (intermediate `--commit` steps and the final PR commit)
|
|
// succeed even on CI runners that don't have a default git identity.
|
|
// The values themselves are discarded by the GitHub REST API: the
|
|
// GPG-signed commits on the remote are attributed to the app token's
|
|
// identity regardless of local git config. The same app token opens the
|
|
// Pull Request, so the PR author matches the commit author.
|
|
const botUserName = `${releaseAppSlug}[bot]`
|
|
const botUserEmail = `${releaseAppUserId}+${releaseAppSlug}[bot]@users.noreply.github.com`
|
|
await execa('git', ['config', 'user.name', botUserName])
|
|
await execa('git', ['config', 'user.email', botUserEmail])
|
|
}
|
|
|
|
let newVersionStr = version
|
|
if (
|
|
newVersionStr === null ||
|
|
// TODO: Fork arguments in GitHub workflow to ensure `--version ""` is considered a mistake
|
|
newVersionStr === ''
|
|
) {
|
|
newVersionStr = await findHighestNPMReactVersion(defaultLatestChannel)
|
|
console.log(
|
|
`--version was not provided. Using react@${defaultLatestChannel}: ${newVersionStr}`
|
|
)
|
|
}
|
|
|
|
const newVersionInfo = extractInfoFromReactVersion(newVersionStr)
|
|
if (!newVersionInfo) {
|
|
throw new Error(
|
|
`New react version does not match expected format: ${newVersionStr}
|
|
|
|
Choose a React canary version from npm: https://www.npmjs.com/package/react?activeTab=versions
|
|
|
|
Or, run this command with no arguments to use the most recently published version.
|
|
`
|
|
)
|
|
}
|
|
const {
|
|
sha: newSha,
|
|
dateString: newDateString,
|
|
releaseLabel,
|
|
} = newVersionInfo
|
|
|
|
const branchName =
|
|
releaseLabel === 'local'
|
|
? // left to user to name their local sync branch
|
|
`update/react/local`
|
|
: releaseLabel === 'vercel-packages'
|
|
? `update/react/remote/vercel-packages/${newSha}`
|
|
: `update/react/${newVersionStr}`
|
|
if (createPull) {
|
|
const { exitCode, all, command } = await execa(
|
|
'git',
|
|
[
|
|
'ls-remote',
|
|
'--exit-code',
|
|
'--heads',
|
|
'origin',
|
|
`refs/heads/${branchName}`,
|
|
],
|
|
{ reject: false }
|
|
)
|
|
|
|
if (exitCode === 2) {
|
|
console.log(
|
|
`No sync in progress in branch '${branchName}' according to '${command}'. Starting a new one.`
|
|
)
|
|
} else if (exitCode === 0) {
|
|
console.log(
|
|
`An existing sync already exists in branch '${branchName}'. Delete the branch to start a new sync.`
|
|
)
|
|
return
|
|
} else {
|
|
throw new Error(
|
|
`Failed to check if the branch already existed:\n${command}: ${all}`
|
|
)
|
|
}
|
|
}
|
|
|
|
const rootManifest = JSON.parse(
|
|
await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8')
|
|
)
|
|
const baseVersionStr = rootManifest.devDependencies['react-builtin'].replace(
|
|
/^npm:react@/,
|
|
''
|
|
)
|
|
|
|
let experimentalNewVersionStr = `0.0.0-experimental-${newSha}-${newDateString}`
|
|
if (version !== null && version.startsWith('file://')) {
|
|
experimentalNewVersionStr = new URL('build/oss-experimental/', version).href
|
|
newVersionStr = new URL('build/oss-stable/', version).href
|
|
} else if (releaseLabel === 'vercel-packages') {
|
|
experimentalNewVersionStr = `vp:///${newSha}/experimental`
|
|
newVersionStr = `vp:///${newSha}/canary`
|
|
}
|
|
|
|
await sync({
|
|
newVersionStr: experimentalNewVersionStr,
|
|
noInstall: !install,
|
|
channel: 'experimental',
|
|
})
|
|
if (commit) {
|
|
await commitEverything('Update `react@experimental`')
|
|
}
|
|
await sync({
|
|
newVersionStr,
|
|
noInstall: !install,
|
|
channel: '<framework-stable>',
|
|
})
|
|
if (commit) {
|
|
await commitEverything('Update `react`')
|
|
}
|
|
|
|
const baseVersionInfo = extractInfoFromReactVersion(baseVersionStr)
|
|
if (!baseVersionInfo) {
|
|
throw new Error(
|
|
'Base react version does not match expected format: ' + baseVersionStr
|
|
)
|
|
}
|
|
|
|
const syncPagesRouterReact = activePagesRouterReact === null
|
|
const newActivePagesRouterReactVersion = syncPagesRouterReact
|
|
? newVersionStr
|
|
: activePagesRouterReact
|
|
const pagesRouterReactVersion = `^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ${newActivePagesRouterReactVersion}`
|
|
const highestPagesRouterReactVersion = await findHighestNPMReactVersion(
|
|
pagesRouterReactVersion
|
|
)
|
|
const { sha: baseSha, dateString: baseDateString } = baseVersionInfo
|
|
|
|
for (const fileName of filesReferencingReactPeerDependencyVersion) {
|
|
const filePath = path.join(cwd, fileName)
|
|
const previousSource = await fsp.readFile(filePath, 'utf-8')
|
|
const previousHighestVersionMatch = previousSource.match(
|
|
/const nextjsReactPeerVersion = "([^"]+)";/
|
|
)
|
|
if (previousHighestVersionMatch === null) {
|
|
errors.push(
|
|
new Error(
|
|
`${fileName}: Is this file still referencing the React peer dependency version?`
|
|
)
|
|
)
|
|
} else {
|
|
const updatedSource = previousSource.replace(
|
|
previousHighestVersionMatch[0],
|
|
`const nextjsReactPeerVersion = "${highestPagesRouterReactVersion}";`
|
|
)
|
|
if (updatedSource !== previousSource) {
|
|
await fsp.writeFile(filePath, updatedSource)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fileName of appManifestsInstallingNextjsPeerDependencies) {
|
|
const packageJsonPath = path.join(cwd, fileName)
|
|
const packageJson = await fsp.readFile(packageJsonPath, 'utf-8')
|
|
const manifest = JSON.parse(packageJson)
|
|
if (manifest.dependencies['react']) {
|
|
manifest.dependencies['react'] = highestPagesRouterReactVersion
|
|
}
|
|
if (manifest.dependencies['react-dom']) {
|
|
manifest.dependencies['react-dom'] = highestPagesRouterReactVersion
|
|
}
|
|
await fsp.writeFile(
|
|
packageJsonPath,
|
|
JSON.stringify(manifest, null, 2) +
|
|
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
|
|
'\n'
|
|
)
|
|
}
|
|
|
|
if (commit) {
|
|
await commitEverything('Updated peer dependency references in apps')
|
|
}
|
|
|
|
for (const fileName of libraryManifestsSupportingNextjsReact) {
|
|
const packageJsonPath = path.join(cwd, fileName)
|
|
const packageJson = await fsp.readFile(packageJsonPath, 'utf-8')
|
|
const manifest = JSON.parse(packageJson)
|
|
// Need to specify last supported RC version to avoid breaking changes.
|
|
if (manifest.peerDependencies['react']) {
|
|
manifest.peerDependencies['react'] = pagesRouterReactVersion
|
|
}
|
|
if (manifest.peerDependencies['react-dom']) {
|
|
manifest.peerDependencies['react-dom'] = pagesRouterReactVersion
|
|
}
|
|
await fsp.writeFile(
|
|
packageJsonPath,
|
|
JSON.stringify(manifest, null, 2) +
|
|
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
|
|
'\n'
|
|
)
|
|
}
|
|
|
|
if (commit) {
|
|
await commitEverything('Updated peer dependency references in libraries')
|
|
}
|
|
|
|
// Install the updated dependencies and build the vendored React files.
|
|
if (!install) {
|
|
console.log('Skipping install step because --no-install flag was passed.')
|
|
} else {
|
|
console.log('Installing dependencies...')
|
|
|
|
const installSubprocess = execa('pnpm', [
|
|
'install',
|
|
// Pnpm freezes the lockfile by default in CI.
|
|
// However, we just changed versions so the lockfile is expected to be changed.
|
|
'--no-frozen-lockfile',
|
|
])
|
|
if (installSubprocess.stdout) {
|
|
installSubprocess.stdout.pipe(process.stdout)
|
|
}
|
|
try {
|
|
await installSubprocess
|
|
} catch (error) {
|
|
console.error(error)
|
|
throw new Error('Failed to install updated dependencies.')
|
|
}
|
|
|
|
if (commit) {
|
|
await commitEverything('Update lockfile')
|
|
}
|
|
|
|
console.log('Building vendored React files...\n')
|
|
const nccSubprocess = execa('pnpm', ['ncc-compiled'], {
|
|
cwd: path.join(cwd, 'packages', 'next'),
|
|
})
|
|
if (nccSubprocess.stdout) {
|
|
nccSubprocess.stdout.pipe(process.stdout)
|
|
}
|
|
try {
|
|
await nccSubprocess
|
|
} catch (error) {
|
|
console.error(error)
|
|
throw new Error('Failed to run ncc.')
|
|
}
|
|
|
|
if (commit) {
|
|
await commitEverything('ncc-compiled')
|
|
}
|
|
|
|
// Print extra newline after ncc output
|
|
console.log()
|
|
}
|
|
|
|
let prDescription = ''
|
|
if (newVersionInfo.releaseLabel === 'local') {
|
|
prDescription = "Can't generate a changelog for local builds"
|
|
} else {
|
|
if (syncPagesRouterReact) {
|
|
prDescription += `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${pagesRouterReactVersion}\`**\n\n`
|
|
}
|
|
|
|
// Fetch the changelog from GitHub and print it to the console.
|
|
prDescription += `[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})\n\n`
|
|
try {
|
|
const changelog = await getChangelogFromGitHub(baseSha, newSha)
|
|
if (changelog === null) {
|
|
prDescription += `GitHub reported no changes between ${baseSha} and ${newSha}.`
|
|
} else {
|
|
prDescription += `<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
prDescription +=
|
|
'\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n'
|
|
}
|
|
}
|
|
|
|
if (!install) {
|
|
console.log(
|
|
`
|
|
To finish upgrading, complete the following steps:
|
|
|
|
- Install the updated dependencies: pnpm install
|
|
- Build the vendored React files: (inside packages/next dir) pnpm ncc-compiled
|
|
|
|
Or run this command again without the --no-install flag to do both automatically.
|
|
`
|
|
)
|
|
}
|
|
|
|
if (errors.length) {
|
|
// eslint-disable-next-line no-undef -- Defined in Node.js
|
|
throw new AggregateError(errors)
|
|
}
|
|
|
|
if (createPull) {
|
|
const octokit = new Octokit({ auth: releaseGithubToken })
|
|
const prTitle = `Upgrade React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\``
|
|
|
|
await execa('git', ['checkout', '-b', branchName])
|
|
// We didn't commit intermediate steps yet so now we need to commit to create a PR.
|
|
if (!commit) {
|
|
await commitEverything(prTitle)
|
|
}
|
|
|
|
// Branch protection on canary requires signed commits. Push each local
|
|
// commit to the remote as a GitHub-signed commit via the REST API
|
|
// instead of `git push` (which would push unsigned commits).
|
|
const baseRef = process.env.GITHUB_REF_NAME || 'canary'
|
|
const remoteBaseSha = (
|
|
await execa('git', ['rev-parse', `origin/${baseRef}`])
|
|
).stdout.trim()
|
|
const localCommitSha = (
|
|
await execa('git', ['rev-parse', 'HEAD'])
|
|
).stdout.trim()
|
|
|
|
const { headSha: finalSignedSha } = await replayLocalCommitsAsSigned({
|
|
token: releaseGithubToken,
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
fromBaseSha: remoteBaseSha,
|
|
toLocalSha: localCommitSha,
|
|
// commitEverything uses --allow-empty for steps that didn't change
|
|
// any files (e.g. when Pages Router doesn't need a sync); preserve
|
|
// that on the remote signed commits.
|
|
allowEmpty: true,
|
|
})
|
|
await upsertBranchRef({
|
|
token: releaseGithubToken,
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
branch: branchName,
|
|
sha: finalSignedSha,
|
|
})
|
|
const pullRequest = await octokit.rest.pulls.create({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
head: branchName,
|
|
base: process.env.GITHUB_REF || 'canary',
|
|
draft: false,
|
|
title: prTitle,
|
|
body: prDescription,
|
|
})
|
|
console.log('Created pull request %s', pullRequest.data.html_url)
|
|
|
|
// Enable GitHub auto-merge with the squash method so the PR merges
|
|
// automatically once required checks pass. GitHub has no REST field for
|
|
// this, so it must be enabled via the GraphQL mutation on the PR node id.
|
|
// The commit title is left to GitHub's default (the PR title with the PR
|
|
// number), while an explicit empty commitBody keeps the squash commit
|
|
// description empty regardless of the repo's squash message setting.
|
|
await octokit.graphql(
|
|
`mutation ($pullRequestId: ID!) {
|
|
enablePullRequestAutoMerge(
|
|
input: {
|
|
pullRequestId: $pullRequestId
|
|
mergeMethod: SQUASH
|
|
commitBody: ""
|
|
}
|
|
) {
|
|
pullRequest {
|
|
autoMergeRequest {
|
|
enabledAt
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
{ pullRequestId: pullRequest.data.node_id }
|
|
)
|
|
|
|
const finalizeResults = await Promise.allSettled([
|
|
assignActorIfAssignable(octokit, actor, pullRequest.data.number),
|
|
octokit.rest.pulls.requestReviewers({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
pull_number: pullRequest.data.number,
|
|
reviewers: pullRequestReviewers,
|
|
}),
|
|
octokit.rest.issues.addLabels({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
issue_number: pullRequest.data.number,
|
|
labels: pullRequestLabels,
|
|
}),
|
|
])
|
|
const failures = finalizeResults.filter(
|
|
(result) => result.status === 'rejected'
|
|
)
|
|
if (failures.length > 0) {
|
|
// eslint-disable-next-line no-undef -- Defined in Node.js
|
|
throw new AggregateError(
|
|
failures.map((failure) => failure.reason),
|
|
`${failures.length} of ${finalizeResults.length} requests to finalize the Pull Request failed.`
|
|
)
|
|
}
|
|
}
|
|
|
|
console.log(prDescription)
|
|
console.log(
|
|
`Successfully updated React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\``
|
|
)
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|