Files
next.js/scripts/release-github-api.js
T
Sebastian "Sebbie" SilbermannandGitHub 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

190 lines
5.6 KiB
JavaScript

// @ts-check
const execa = require('execa')
const fs = require('fs/promises')
const {
replayLocalCommitsAsSigned,
githubRequest,
alignLocalBranchWithSignedCommit,
} = require('./github-utils/signed-commit')
const REPO_API_PATH = '/repos/vercel/next.js'
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
}
/**
* Verify the local Lerna release commit has the version tag implied by
* lerna.json, then return that tag name for GitHub ref creation.
*/
async function getLocalReleaseTagName(commitSha) {
const { version } = JSON.parse(await fs.readFile('lerna.json', 'utf8'))
const expectedTagName = `v${version}`
const tags = String(
await git(['tag', '--points-at', commitSha], { captureOutput: true })
)
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean)
if (!tags.includes(expectedTagName)) {
throw new Error(
`Expected local Lerna release commit ${commitSha} to be tagged with ${expectedTagName}; found ${tags.join(
', '
)}`
)
}
return expectedTagName
}
/**
* Return the local Lerna release commit's single parent so the GitHub-created
* commit can replay the same tree change on top of the same base commit.
*/
async function getSingleParent(commitSha) {
const revList = String(
await git(['rev-list', '--parents', '-n', '1', commitSha], {
captureOutput: true,
})
)
// git rev-list --parents emits "<commit> <parent...>".
const [, ...parents] = revList.split(' ')
if (parents.length !== 1) {
throw new Error(
`Expected release commit ${commitSha} to have exactly one parent; found ${parents.length}`
)
}
return parents[0]
}
/**
* Replace Lerna's local release commit(s) with equivalent GitHub-signed
* commits, then move the release tag and current branch in a single branch
* push.
*
* Signs every local commit between the remote base and local HEAD. The release
* tag is placed on the signed commit that corresponds to the local Lerna
* release commit; the branch is fast-forwarded to the final signed commit.
*
* For a normal release this is a single commit (tag == branch head). For an
* ad-hoc preview release the local history is two commits — the preview
* version bump (tagged) followed by a revert restoring the canary version — so
* the tag points at the preview commit while the branch ends on the revert.
* `options.baseSha` and `options.tagName` let the caller pin both explicitly
* (required for preview, since after the revert `lerna.json` no longer matches
* HEAD).
*
* `options.githubRequest` injects a custom GitHub client (signature matching
* `githubRequest`). A dry run passes a logging mock so the whole sign/tag/push
* flow is exercised without touching the API; when a mock is used the final
* local-branch sync is skipped (the signed commits don't exist on the remote).
*/
async function createGitHubReleaseCommit(token, options = {}) {
const request = options.githubRequest ?? githubRequest
const usingMockClient = options.githubRequest != null
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], {
captureOutput: true,
})
if (branch === 'HEAD') {
throw new Error('Cannot create a GitHub release commit from detached HEAD')
}
const localHead = await git(['rev-parse', 'HEAD'], {
captureOutput: true,
})
const baseSha = options.baseSha ?? (await getSingleParent(localHead))
const tagName = options.tagName ?? (await getLocalReleaseTagName(localHead))
const localTaggedSha = await git(['rev-list', '-n', '1', tagName], {
captureOutput: true,
})
console.log(
`Creating GitHub-signed release commit(s) for ${tagName} from local commits ${baseSha}..${localHead}`
)
const { headSha, signedCommits } = await replayLocalCommitsAsSigned({
token,
owner: 'vercel',
repo: 'next.js',
fromBaseSha: baseSha,
toLocalSha: localHead,
request,
})
const taggedCommit = signedCommits.find(
(entry) => entry.localSha === localTaggedSha
)
if (!taggedCommit) {
throw new Error(
`Could not find a signed commit for the release tag ${tagName} (local ${localTaggedSha})`
)
}
const signedTagSha = taggedCommit.signedSha
let createdTag = false
try {
await request(token, 'POST', `${REPO_API_PATH}/git/refs`, {
ref: `refs/tags/${tagName}`,
sha: signedTagSha,
})
createdTag = true
await request(token, 'PATCH', `${REPO_API_PATH}/git/refs/heads/${branch}`, {
sha: headSha,
force: false,
})
} catch (error) {
if (createdTag) {
await request(
token,
'DELETE',
`${REPO_API_PATH}/git/refs/tags/${tagName}`
).catch((deleteError) => {
console.error(`Failed to delete ${tagName} after release failure`)
console.error(deleteError)
})
}
throw error
}
if (usingMockClient) {
// The signed commits only exist in the mock; there is nothing on the remote
// to sync the local branch against.
console.log(
`Dry run: skipping local branch sync; would set ${branch} to ${headSha} and tag ${tagName} at ${signedTagSha}`
)
} else {
await alignLocalBranchWithSignedCommit(branch, headSha, { tagName })
}
console.log(
`Created GitHub-signed release tag ${tagName} at ${signedTagSha}; branch ${branch} now at ${headSha}`
)
return {
branch,
sha: signedTagSha,
tagName,
headSha,
baseSha,
}
}
module.exports = {
createGitHubReleaseCommit,
}