mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
fca0fea594
### 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**.
172 lines
4.8 KiB
JavaScript
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()
|