mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
681940523a
Add a `preview` release type so `@preview` npm releases can be cut **ad-hoc from `canary`** via the `Trigger Release` workflow, retiring the dedicated long-lived preview branch. The GitHub environment has been updated accordingly. A preview cut produces **two commits**, pushed in a single update: 1. **Bump-to-preview** — all packages set to the computed preview version, tagged `v…-preview.N`. This tag's build publishes `@preview` (relies on #95085). 2. **Revert-to-canary** — restores the `-canary.N` versions so `canary` keeps advancing its own line and isn't stranded on a preview version. ``` B canary HEAD before cut (e.g. 16.3.0-canary.61) │ P v16.3.0-preview.N ← tag here; tag build publishes @preview │ R canary HEAD after cut (versions restored to 16.3.0-canary.61) ``` ### Preview version numbering `base = max(canary major.minor.patch, npm @preview major.minor.patch)`: - base still on the published preview line → continue it (`n + 1`) - canary advanced to a higher base (or no `@preview` published) → reset to `.0` So if `canary` jumps to `18.0.0-canary.x` since the last `16.3.0-preview.x`, the next cut is `18.0.0-preview.0`, not a continuation. ## Test plan The dry mode of `start-release.js` was extended to intercept the GH requests and log the relevant data. ```console $ node ./scripts/start-release.js --release-type preview --dry-run Dry run: keeping commits locally, skipping git push and GitHub release creation Running pnpm release-preview... [...] > lerna version 16.3.0-preview.4 --force-publish -y --no-push --allow-branch '**' [...] Creating GitHub-signed release commit(s) for v16.3.0-preview.4 from local commits 0f5c1e865cc34edb257a16823ed6525d9cd58bfc..0062348c411776271fe7eac64e97b8bad2775b77 [dry-run] GitHub API POST /repos/vercel/next.js/git/blobs [...] [dry-run] GitHub API POST /repos/vercel/next.js/git/commits {"message":"v16.3.0-preview.4","tree":"0000000000000000000000000000000000000016","parents":["0f5c1e865cc34edb257a16823ed6525d9cd58bfc"]} [dry-run] GitHub API POST /repos/vercel/next.js/git/commits {"message":"Restore canary version 16.3.0-canary.61 after v16.3.0-preview.4 preview release","tree":"000000000000000000000000000000000000002d","parents":["0000000000000000000000000000000000000017"]} [dry-run] GitHub API POST /repos/vercel/next.js/git/refs {"ref":"refs/tags/v16.3.0-preview.4","sha":"0000000000000000000000000000000000000017"} [dry-run] GitHub API PATCH /repos/vercel/next.js/git/refs/heads/sebbie/preview-release-from-canary {"sha":"000000000000000000000000000000000000002e","force":false} Dry run: skipping local branch sync; would set sebbie/preview-release-from-canary to 000000000000000000000000000000000000002e and tag v16.3.0-preview.4 at 0000000000000000000000000000000000000017 Created GitHub-signed release tag v16.3.0-preview.4 at 0000000000000000000000000000000000000017; branch sebbie/preview-release-from-canary now at 000000000000000000000000000000000000002e Dry run: skipping GitHub release creation Release process is finished ```
400 lines
9.4 KiB
JavaScript
400 lines
9.4 KiB
JavaScript
// @ts-check
|
|
|
|
const execa = require('execa')
|
|
|
|
/**
|
|
* Call the GitHub REST API and include response bodies in thrown errors so
|
|
* workflow failures show actionable details.
|
|
*/
|
|
async function githubRequest(token, method, path, body) {
|
|
const response = await fetch(`https://api.github.com${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const responseText = await response.text()
|
|
|
|
throw new Error(
|
|
`GitHub API ${method} ${path} failed (${response.status}): ${responseText}`
|
|
)
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
async function git(args, options = {}) {
|
|
const { captureOutput = false, ...execaOptions } = options
|
|
const { stdout } = await execa('git', args, {
|
|
stdio: captureOutput ? 'pipe' : 'inherit',
|
|
...execaOptions,
|
|
})
|
|
|
|
return typeof stdout === 'string' ? stdout.trim() : stdout
|
|
}
|
|
|
|
/**
|
|
* List paths changed between two commits, using "\0" delimiters so unusual
|
|
* file names do not affect parsing.
|
|
*/
|
|
async function getChangedFiles(baseSha, headSha) {
|
|
const stdout = await git(
|
|
['diff-tree', '-r', '--name-only', '--no-renames', '-z', baseSha, headSha],
|
|
{ captureOutput: true, encoding: 'utf8' }
|
|
)
|
|
|
|
return String(stdout).split('\0').filter(Boolean)
|
|
}
|
|
|
|
/**
|
|
* Read the Git tree metadata for a path so recreated tree entries preserve
|
|
* file modes, blob types, and submodule commit pointers.
|
|
*/
|
|
async function getTreeEntry(commitSha, filePath) {
|
|
const stdout = await git(['ls-tree', '-z', commitSha, '--', filePath], {
|
|
captureOutput: true,
|
|
encoding: 'utf8',
|
|
})
|
|
|
|
if (!stdout) {
|
|
return null
|
|
}
|
|
|
|
// git ls-tree -z emits "<mode> <type> <object>\t<path>\0".
|
|
const match = /^(\d{6}) (\w+) ([0-9a-f]{40})\t/.exec(String(stdout))
|
|
|
|
if (!match) {
|
|
throw new Error(`Failed to parse git tree entry for ${filePath}`)
|
|
}
|
|
|
|
return {
|
|
mode: match[1],
|
|
type: match[2],
|
|
sha: match[3],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload one file from a local commit as a GitHub blob and return the blob
|
|
* SHA for the recreated tree entry.
|
|
*
|
|
* `request` defaults to the real `githubRequest`; callers can inject a mock
|
|
* (e.g. a dry-run logger) to exercise the flow without hitting the API.
|
|
*/
|
|
async function createBlobForFile(
|
|
token,
|
|
repoApiPath,
|
|
commitSha,
|
|
filePath,
|
|
request = githubRequest
|
|
) {
|
|
const content = await git(['show', `${commitSha}:${filePath}`], {
|
|
captureOutput: true,
|
|
encoding: null,
|
|
stripFinalNewline: false,
|
|
maxBuffer: 1024 * 1024 * 100,
|
|
})
|
|
const blob = await request(token, 'POST', `${repoApiPath}/git/blobs`, {
|
|
content: Buffer.from(content).toString('base64'),
|
|
encoding: 'base64',
|
|
})
|
|
|
|
return blob.sha
|
|
}
|
|
|
|
/**
|
|
* Build a GitHub tree matching a local commit, creating new blob objects for
|
|
* changed files in parallel while preserving deletions and submodules.
|
|
*/
|
|
async function createTreeFromLocalCommit({
|
|
token,
|
|
repoApiPath,
|
|
diffBaseSha,
|
|
baseTreeSha,
|
|
localCommitSha,
|
|
allowEmpty = false,
|
|
request = githubRequest,
|
|
}) {
|
|
const changedFiles = await getChangedFiles(diffBaseSha, localCommitSha)
|
|
|
|
if (changedFiles.length === 0) {
|
|
if (allowEmpty) {
|
|
// Nothing changed — reuse the parent tree so the resulting commit is
|
|
// a no-op.
|
|
return baseTreeSha
|
|
}
|
|
throw new Error(`Commit ${localCommitSha} has no file changes`)
|
|
}
|
|
|
|
const tree = await Promise.all(
|
|
changedFiles.map(async (filePath) => {
|
|
const treeEntry = await getTreeEntry(localCommitSha, filePath)
|
|
|
|
if (!treeEntry) {
|
|
return {
|
|
path: filePath,
|
|
sha: null,
|
|
}
|
|
}
|
|
|
|
if (treeEntry.type === 'commit') {
|
|
return {
|
|
path: filePath,
|
|
mode: treeEntry.mode,
|
|
type: treeEntry.type,
|
|
sha: treeEntry.sha,
|
|
}
|
|
}
|
|
|
|
const blobSha = await createBlobForFile(
|
|
token,
|
|
repoApiPath,
|
|
localCommitSha,
|
|
filePath,
|
|
request
|
|
)
|
|
|
|
return {
|
|
path: filePath,
|
|
mode: treeEntry.mode,
|
|
type: treeEntry.type,
|
|
sha: blobSha,
|
|
}
|
|
})
|
|
)
|
|
|
|
const createdTree = await request(token, 'POST', `${repoApiPath}/git/trees`, {
|
|
base_tree: baseTreeSha,
|
|
tree,
|
|
})
|
|
|
|
return createdTree.sha
|
|
}
|
|
|
|
/**
|
|
* Build a GitHub tree from the local commit's diff against `baseSha`, then
|
|
* create a GitHub-signed commit on top of `baseSha`. Returns the new commit
|
|
* payload (including `sha`). Verifies `verification.verified`.
|
|
*/
|
|
async function createSignedCommit({
|
|
token,
|
|
owner,
|
|
repo,
|
|
baseSha,
|
|
localCommitSha,
|
|
message,
|
|
allowEmpty = false,
|
|
}) {
|
|
const repoApiPath = `/repos/${owner}/${repo}`
|
|
|
|
const baseTreeSha = await git(['rev-parse', `${baseSha}^{tree}`], {
|
|
captureOutput: true,
|
|
})
|
|
|
|
const treeSha = await createTreeFromLocalCommit({
|
|
token,
|
|
repoApiPath,
|
|
diffBaseSha: baseSha,
|
|
baseTreeSha,
|
|
localCommitSha,
|
|
allowEmpty,
|
|
})
|
|
|
|
const commit = await githubRequest(
|
|
token,
|
|
'POST',
|
|
`${repoApiPath}/git/commits`,
|
|
{
|
|
message,
|
|
tree: treeSha,
|
|
parents: [baseSha],
|
|
}
|
|
)
|
|
|
|
if (!commit.verification?.verified) {
|
|
throw new Error(
|
|
`GitHub API created unsigned commit ${commit.sha}: ${commit.verification?.reason}`
|
|
)
|
|
}
|
|
|
|
return commit
|
|
}
|
|
|
|
/**
|
|
* Replay every local commit between `fromBaseSha` (exclusive) and
|
|
* `toLocalSha` (inclusive) as a chain of GitHub-signed commits whose root is
|
|
* `fromBaseSha` on the remote. Each replayed commit's tree is built by
|
|
* applying that local commit's file-tree diff (against its local parent) on
|
|
* top of the previous signed commit's tree. The local commit message is
|
|
* preserved.
|
|
*
|
|
* Returns `{ headSha, signedCommits }` where `headSha` is the final signed
|
|
* commit and `signedCommits` maps each local commit to its signed counterpart
|
|
* (`{ localSha, signedSha }`, in replay order).
|
|
*/
|
|
async function replayLocalCommitsAsSigned({
|
|
token,
|
|
owner,
|
|
repo,
|
|
fromBaseSha,
|
|
toLocalSha,
|
|
allowEmpty = false,
|
|
request = githubRequest,
|
|
}) {
|
|
const repoApiPath = `/repos/${owner}/${repo}`
|
|
|
|
const revListOutput = await git(
|
|
['rev-list', '--reverse', `${fromBaseSha}..${toLocalSha}`],
|
|
{ captureOutput: true }
|
|
)
|
|
const localCommits = String(revListOutput).split('\n').filter(Boolean)
|
|
|
|
if (localCommits.length === 0) {
|
|
throw new Error(
|
|
`No commits to replay between ${fromBaseSha} and ${toLocalSha}`
|
|
)
|
|
}
|
|
|
|
let parentSha = fromBaseSha
|
|
let parentTreeSha = await git(['rev-parse', `${fromBaseSha}^{tree}`], {
|
|
captureOutput: true,
|
|
})
|
|
const signedCommits = []
|
|
|
|
for (const localSha of localCommits) {
|
|
const localParentSha = await git(['rev-parse', `${localSha}^`], {
|
|
captureOutput: true,
|
|
})
|
|
const message = await git(['log', '-1', '--pretty=%B', localSha], {
|
|
captureOutput: true,
|
|
})
|
|
|
|
const treeSha = await createTreeFromLocalCommit({
|
|
token,
|
|
repoApiPath,
|
|
diffBaseSha: localParentSha,
|
|
baseTreeSha: parentTreeSha,
|
|
localCommitSha: localSha,
|
|
allowEmpty,
|
|
request,
|
|
})
|
|
|
|
const commit = await request(token, 'POST', `${repoApiPath}/git/commits`, {
|
|
message,
|
|
tree: treeSha,
|
|
parents: [parentSha],
|
|
})
|
|
|
|
if (!commit.verification?.verified) {
|
|
throw new Error(
|
|
`GitHub API created unsigned commit ${commit.sha}: ${commit.verification?.reason}`
|
|
)
|
|
}
|
|
|
|
parentSha = commit.sha
|
|
parentTreeSha = treeSha
|
|
signedCommits.push({ localSha, signedSha: commit.sha })
|
|
}
|
|
|
|
return { headSha: parentSha, signedCommits }
|
|
}
|
|
|
|
/**
|
|
* Create the branch ref if it does not exist, otherwise fast-forward (or
|
|
* `force`-update) it to the given commit SHA.
|
|
*/
|
|
async function upsertBranchRef({
|
|
token,
|
|
owner,
|
|
repo,
|
|
branch,
|
|
sha,
|
|
force = false,
|
|
}) {
|
|
const repoApiPath = `/repos/${owner}/${repo}`
|
|
|
|
try {
|
|
await githubRequest(token, 'POST', `${repoApiPath}/git/refs`, {
|
|
ref: `refs/heads/${branch}`,
|
|
sha,
|
|
})
|
|
return { created: true }
|
|
} catch (error) {
|
|
const errMessage = error instanceof Error ? error.message : String(error)
|
|
|
|
if (!errMessage.includes('Reference already exists')) {
|
|
throw error
|
|
}
|
|
|
|
await githubRequest(
|
|
token,
|
|
'PATCH',
|
|
`${repoApiPath}/git/refs/heads/${branch}`,
|
|
{
|
|
sha,
|
|
force,
|
|
}
|
|
)
|
|
|
|
return { created: false }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh local refs after API writes so subsequent steps see the
|
|
* GitHub-signed commit instead of the unsigned local commit. Optionally also
|
|
* fetches a freshly created tag.
|
|
*/
|
|
async function alignLocalBranchWithSignedCommit(
|
|
branch,
|
|
commitSha,
|
|
options = {}
|
|
) {
|
|
const { tagName } = options
|
|
|
|
if (tagName) {
|
|
const tagExists = await execa(
|
|
'git',
|
|
['show-ref', '--verify', '--quiet', `refs/tags/${tagName}`],
|
|
{
|
|
stdio: 'ignore',
|
|
reject: false,
|
|
}
|
|
)
|
|
|
|
if (tagExists.exitCode === 0) {
|
|
await git(['tag', '-d', tagName])
|
|
}
|
|
}
|
|
|
|
const fetchRefs = [`refs/heads/${branch}:refs/remotes/origin/${branch}`]
|
|
if (tagName) {
|
|
fetchRefs.push(`refs/tags/${tagName}:refs/tags/${tagName}`)
|
|
}
|
|
|
|
await git(['fetch', 'origin', ...fetchRefs])
|
|
await git(['reset', '--hard', commitSha])
|
|
}
|
|
|
|
module.exports = {
|
|
githubRequest,
|
|
getChangedFiles,
|
|
getTreeEntry,
|
|
createBlobForFile,
|
|
createTreeFromLocalCommit,
|
|
createSignedCommit,
|
|
replayLocalCommitsAsSigned,
|
|
upsertBranchRef,
|
|
alignLocalBranchWithSignedCommit,
|
|
}
|