mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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 ```
190 lines
5.6 KiB
JavaScript
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,
|
|
}
|