Files
vercel__next.js/scripts/create-release-branch.js
Sebastian "Sebbie" Silbermann 665d3458b6 [ci] Align build-and-deploy triggers with build-and-test (#98331)
Change detection (`git diff HEAD~1`) only covers the whole change set
when the checkout is the PR merge commit. `build-and-test` runs on
`pull_request` while `build-and-deploy` ran on `push` for upstream PR
branches, so a multi-commit push whose last commit was docs-only made
`build-and-deploy` skip the preview build while deploy tests in
`build-and-test` waited up to 30 minutes for a preview tarball that
never got published.

`build-and-deploy` now triggers on the same events as `build-and-test`
(`push` to the default branch and `pull_request`), while still accepting
release tags and workflow dispatch. The `deploy-target` fork guard is
removed since upstream PRs are no longer covered by a push run, and
`create-release-branch.js` now rewrites the workflow's branch list
alongside the existing `refs/heads/canary` replacement so release
branches keep their staging deploys.

Preview tarball creation now uses the PR head sha instead of falling
back to the merge-commit sha on `pull_request` `opened` events
(`github.event.after` only exists on `synchronize`), matching the sha
that `upload_preview_tarballs.yml` publishes under and that tests poll
for. This fallback was a pre-existing bug that only affected fork PRs;
it would have started affecting every upstream PR's first run once
previews moved to the `pull_request` event.
2026-09-08 10:55:44 +02:00

170 lines
4.7 KiB
JavaScript

// @ts-check
const fs = require('fs')
const path = require('path')
const execa = require('execa')
const {
configureGitHubAuth,
getGitHubToken,
getGitHubTokenMissingMessage,
} = require('./release-github-auth')
const {
githubRequest,
createSignedCommit,
upsertBranchRef,
} = require('./github-utils/signed-commit')
const REPO_OWNER = 'vercel'
const REPO_NAME = 'next.js'
/**
* Fail fast, before any file mutation, if the branch already exists.
*
* This doubles as the API preflight: it exercises the same git-data path the
* script later writes to (blobs/trees/commits/refs), so a token missing those
* grants fails here rather than midway through creating objects. A plain
* repository GET would succeed with any valid installation token and so could
* never catch that.
*/
async function verifyBranchIsAvailable(token, branch) {
let existing
try {
existing = await githubRequest(
token,
'GET',
`/repos/${REPO_OWNER}/${REPO_NAME}/git/ref/heads/${branch}`
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// A 404 is the expected, successful outcome: the branch is available.
if (message.includes('failed (404)')) {
console.log(`Verified GitHub API access; branch ${branch} is available`)
return
}
throw error
}
throw new Error(
`Branch ${branch} already exists (at ${existing.object?.sha}). ` +
`Delete it or choose a different branch name before re-running.`
)
}
async function main() {
const args = process.argv
const branchName = args[args.indexOf('--branch-name') + 1]
const tagName = args[args.indexOf('--tag-name') + 1]
if (!branchName) {
throw new Error('branchName value is missing!')
}
if (!tagName || !tagName.startsWith('v')) {
throw new Error('tagName value is invalid "' + tagName + '"')
}
const githubToken = getGitHubToken()
if (!githubToken) {
console.log(getGitHubTokenMissingMessage())
return
}
await configureGitHubAuth(githubToken)
await verifyBranchIsAvailable(githubToken, branchName)
await execa('git', ['checkout', '-b', branchName], {
stdio: 'inherit',
})
await execa('git', ['fetch', 'origin', tagName, '--tags'], {
stdio: 'inherit',
})
await execa('git', ['reset', '--hard', tagName], {
stdio: 'inherit',
})
const lernaPath = path.join(__dirname, '..', 'lerna.json')
const existingLerna = JSON.parse(
await fs.promises.readFile(lernaPath, 'utf8')
)
existingLerna.command.publish.allowBranch.push(branchName)
await fs.promises.writeFile(lernaPath, JSON.stringify(existingLerna, null, 2))
const buildAndDeployPath = path.join(
__dirname,
'..',
'.github',
'workflows',
'build_and_deploy.yml'
)
const buildAndDeploy = await fs.promises.readFile(buildAndDeployPath, 'utf8')
await fs.promises.writeFile(
buildAndDeployPath,
buildAndDeploy
// The push trigger is limited to the default branch, same as
// build_and_test.yml below, so point it at the release branch as well.
.replace(`branches: ['canary']`, `branches: ['${branchName}']`)
.replace(/refs\/heads\/canary/g, `refs/heads/${branchName}`)
)
const buildAndTestPath = path.join(
__dirname,
'..',
'.github',
'workflows',
'build_and_test.yml'
)
let buildAndTest = await fs.promises.readFile(buildAndTestPath, 'utf8')
buildAndTest = buildAndTest
.replace(`['canary']`, `['${branchName}']`)
.replace(/[\s]{1,}('test-new-tests-.+',)/g, '')
await fs.promises.writeFile(buildAndTestPath, buildAndTest)
const commitMessage = 'setup release branch'
await execa('git', ['add', '.'], {
stdio: 'inherit',
})
await execa('git', ['commit', '-m', commitMessage], {
stdio: 'inherit',
})
// Branch protection requires signed commits, so create the commit on the
// remote as a GitHub-signed commit via the REST API instead of running
// `git push` (which would push the unsigned local commit).
//
// Release tags are annotated tag objects, so dereference to the underlying
// commit -- the tag object's SHA is not valid as a commit parent.
const { stdout: baseSha } = await execa('git', [
'rev-parse',
`${tagName}^{commit}`,
])
const { stdout: localCommitSha } = await execa('git', ['rev-parse', 'HEAD'])
const signedCommit = await createSignedCommit({
token: githubToken,
owner: REPO_OWNER,
repo: REPO_NAME,
baseSha: baseSha.trim(),
localCommitSha: localCommitSha.trim(),
message: commitMessage,
})
await upsertBranchRef({
token: githubToken,
owner: REPO_OWNER,
repo: REPO_NAME,
branch: branchName,
sha: signedCommit.sha,
})
console.log(
`Created branch ${branchName} at signed commit ${signedCommit.sha}`
)
}
main()