Files
vercel__next.js/scripts/github-utils/signed-commit.js
Sebastian "Sebbie" Silbermann 681940523a [cd] add ad-hoc preview release cut from canary (#95086)
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
```
2026-06-24 16:33:37 +02:00

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,
}