Files
vercel__next.js/scripts/create-release-branch.js
T
Luke Sandberg fca0fea594 [ci] Fix create_release_branch for new repo permissions (#96641)
### What

Fixes the **Create Release Branch** workflow, which currently fails on
every run.

1. Drop `permission-environments: write` from the GitHub App token
request.
- The release App installation no longer grants it, and
`actions/create-github-app-token` fails hard on an ungranted permission.
2. Drop the runtime `deployment-branch-policies` API call (and its 5s
sleep) from `scripts/create-release-branch.js`.
- No longer needed: the `release-stable` environment now statically
allows branches conforming to `next-NN` / `next-NN-*`. This is what the
permission above existed for.
3. Create the setup commit as a GitHub-signed commit via
`scripts/github-utils/signed-commit.js` instead of `git push`.
- Branch protection requires signed commits; `git push` pushed an
unsigned one. Uses the same `createSignedCommit` + `upsertBranchRef`
pattern as `scripts/automated-update-workflow.js`.

### Why

The most recent run
([30923742579](https://github.com/vercel/next.js/actions/runs/30923742579/job/92040655448))
dies at the first step, `Create GitHub App token`, with a 422 — so the
job never even clones the repo.

### Note for review

Release tags are **annotated** tag objects, so `git rev-parse v16.0.0`
returns the tag object SHA, not the commit. `createSignedCommit` passes
`baseSha` straight into `parents: [...]`, so the base is dereferenced
with `^{commit}`. Its internal `^{tree}` lookup happens to resolve
either way, so this would otherwise only have failed at commit-creation
time during a real release.

The static patterns cover `next-16-2`-style names but not a single-digit
major like `next-9-5`. Not a concern for current or future releases, but
noting it since the previous code created the policy for any branch
name.

### Verification

The workflow is `workflow_dispatch`-only and writes to the real repo, so
it can't be run end-to-end from a PR. Verified locally against the real
`v16.0.0` tag in a throwaway clone:

- `git reset --hard v16.0.0` leaves HEAD exactly at `v16.0.0^{commit}`,
confirming the base SHA is right.
- Replaying the lerna.json / `build_and_deploy.yml` /
`build_and_test.yml` mutations produces exactly those 3 changed files
and nothing else.
- Driving `createTreeFromLocalCommit` with a mocked `request` (no
network) uploads 3 blobs with correct `100644` modes and a `base_tree`
that matches the tag's tree.
- `node --check`, prettier, and eslint all clean.

Still needs a real `workflow_dispatch` run after merge to confirm the
token step succeeds and the resulting commit shows as **Verified**.
2026-08-04 17:18:15 +00:00

172 lines
4.8 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.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, '')
buildAndTest = buildAndTest.replace(
/(^[ \t]*)# test-new-tests-if\n(^[ \t]*)if:.*\n(^[ \t]*)# test-new-tests-end-if/gm,
(_, indent1, indent2, indent3) =>
`${indent1}# test-new-tests-if\n${indent2}if: false\n${indent3}# test-new-tests-end-if`
)
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()