From 2e1ea19cbab5840aafd048f8267303d29605526c Mon Sep 17 00:00:00 2001 From: eps1lon Date: Mon, 26 Aug 2024 19:32:17 +0200 Subject: [PATCH] Create React sync automatically --- .github/workflows/update_react.yml | 15 ++- scripts/sync-react.js | 172 +++++++++++++++++++++++++---- 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/.github/workflows/update_react.yml b/.github/workflows/update_react.yml index 46b645be93668..33118341506cc 100644 --- a/.github/workflows/update_react.yml +++ b/.github/workflows/update_react.yml @@ -15,6 +15,7 @@ on: env: NODE_LTS_VERSION: 20 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 jobs: create-pull-request: @@ -27,6 +28,11 @@ jobs: # See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + - name: Set Git author + run: | + git config user.name "vercel-release-bot" + git config user.email "infra+release@vercel.com" + - name: Setup node uses: actions/setup-node@v4 with: @@ -37,4 +43,11 @@ jobs: - name: Install dependencies shell: bash - run: pnpm i + # Just need scripts/ but those dependencies are listed in the workspace root. + run: pnpm install --filter . + + - name: Create Pull Request + shell: bash + run: pnpm sync-react --actor "${{ github.actor }}" --version "${{ inputs.version }}" --create-pull + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PULL_REQUESTS }} diff --git a/scripts/sync-react.js b/scripts/sync-react.js index f6c203f653712..9e69d113ce16e 100644 --- a/scripts/sync-react.js +++ b/scripts/sync-react.js @@ -4,11 +4,17 @@ const path = require('path') const fsp = require('fs/promises') const process = require('process') const execa = require('execa') +const { Octokit } = require('octokit') const yargs = require('yargs') /** @type {any} */ const fetch = require('node-fetch') +const repoOwner = 'vercel' +const repoName = 'next.js' +const pullRequestLabels = ['type: react-sync'] +const pullRequestReviewers = ['eps1lon'] + const filesReferencingReactPeerDependencyVersion = [ 'run-tests.js', 'packages/create-next-app/templates/index.ts', @@ -155,12 +161,49 @@ async function main() { 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 used for notifications but not commit attribution.', + }) + .options('create-pull', { + default: false, + type: 'boolean', + description: 'Create a Pull Request in vercel/next.js', + }) + .options('commit', { + default: true, + type: 'boolean', + description: 'Will not create any commit', + }) .options('install', { default: true, type: 'boolean' }) .options('version', { default: null, type: 'string' }).argv - const { install, version } = argv + const { actor, createPull, commit, install, version } = argv + + async function commitEverything(message) { + await execa('git', ['add', '-A']) + await execa('git', ['commit', '--message', message, '--no-verify']) + } + + 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 githubToken = process.env.GITHUB_TOKEN + if (createPull && !githubToken) { + throw new Error( + `Environment variable 'GITHUB_TOKEN' not specified but required when --create-pull is specified.` + ) + } let newVersionStr = version - if (newVersionStr === null) { + if ( + newVersionStr === null || + // TODO: Fork arguments in GitHub workflow to ensure `--version ""` is considered a mistake + newVersionStr === '' + ) { const { stdout, stderr } = await execa( 'npm', ['view', 'react@canary', 'version'], @@ -174,6 +217,9 @@ async function main() { throw new Error('Failed to read latest React canary version from npm.') } newVersionStr = stdout.trim() + console.log( + `--version was not provided. Using react@canary: ${newVersionStr}` + ) } const newVersionInfo = extractInfoFromReactVersion(newVersionStr) @@ -188,6 +234,36 @@ Or, run this command with no arguments to use the most recently published versio ) } const { sha: newSha, dateString: newDateString } = newVersionInfo + + const branchName = `update/react/${newSha}-${newDateString}` + 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) { + throw new Error( + `An existing sync already exists in branch '${branchName}'. Delete the branch to start a new sync.` + ) + } 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') ) @@ -203,6 +279,9 @@ Or, run this command with no arguments to use the most recently published versio noInstall: !install, channel: 'experimental', }) + if (commit) { + await commitEverything('Update `react@experimental`') + } await sync({ newDateString, newSha, @@ -210,6 +289,9 @@ Or, run this command with no arguments to use the most recently published versio noInstall: !install, channel: 'rc', }) + if (commit) { + await commitEverything('Update `react@rc`') + } const baseVersionInfo = extractInfoFromReactVersion(baseVersionStr) if (!baseVersionInfo) { @@ -269,13 +351,22 @@ Or, run this command with no arguments to use the most recently published versio ) } + if (commit) { + await commitEverything('Updated peer dependency references') + } + // Install the updated dependencies and build the vendored React files. if (!install) { console.log('Skipping install step because --no-install flag was passed.\n') } else { console.log('Installing dependencies...\n') - const installSubprocess = execa('pnpm', ['install']) + 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) } @@ -286,6 +377,10 @@ Or, run this command with no arguments to use the most recently published versio 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'), @@ -300,34 +395,29 @@ Or, run this command with no arguments to use the most recently published versio throw new Error('Failed to run ncc.') } + if (commit) { + await commitEverything('ncc-compiled') + } + // Print extra newline after ncc output console.log() } - console.log( - `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**` - ) + let prDescription = `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**\n\n` // Fetch the changelog from GitHub and print it to the console. - console.log( - `[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})` - ) + 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) { - console.log( - `GitHub reported no changes between ${baseSha} and ${newSha}.` - ) + prDescription += `GitHub reported no changes between ${baseSha} and ${newSha}.` } else { - console.log( - `
\nReact upstream changes\n\n${changelog}\n\n
` - ) + prDescription += `
\nReact upstream changes\n\n${changelog}\n\n
` } } catch (error) { console.error(error) - console.log( + prDescription += '\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n' - ) } if (!install) { @@ -343,13 +433,57 @@ Or run this command again without the --no-install flag to do both automatically ) } - await fsp.writeFile(path.join(cwd, '.github/.react-version'), newVersionStr) - if (errors.length) { // eslint-disable-next-line no-undef -- Defined in Node.js throw new AggregateError(errors) } + if (createPull) { + const octokit = new Octokit({ auth: githubToken }) + 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) { + commitEverything(prTitle) + } + await execa('git', ['push', 'origin', branchName]) + const pullRequest = await octokit.rest.pulls.create({ + owner: repoOwner, + repo: repoName, + head: branchName, + base: 'canary', + draft: false, + title: prTitle, + body: prDescription, + }) + console.log('Created pull request %s', pullRequest.data.html_url) + + await Promise.all([ + actor + ? octokit.rest.issues.addAssignees({ + owner: repoOwner, + repo: repoName, + issue_number: pullRequest.data.number, + assignees: [actor], + }) + : Promise.resolve(), + 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, + }), + ]) + } + + console.log(prDescription) console.log( `Successfully updated React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\`` )