Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 130 additions & 132 deletions .github/workflows/publish-npm.yml

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,22 @@ Validate all three bindings after a workflow or environment rename.
the work lands. The release promotes that block verbatim under the new
version heading. If nothing accrued, the release falls back to a section
derived from the Conventional Commits in range.
- Dispatch the workflow with `dry-run: true` (the default) to see which
version it would ship. It uploads a GitHub artifact and verifies its download.
It creates no npm stages, tags, releases, or release branches.
- Dispatch with `dry-run: false` to release. `scripts/release/bump.mts` picks
the version, writes `package.json` + `CHANGELOG.md`, and commits them via
the release App onto a throwaway `npm-publish-v<X.Y.Z>` branch. `v1.x` is
fast-forwarded to that commit only after all three packages are staged.
- Dispatch with `mode: release-pr` and `dry-run: true` (the default) to see
which version the next release would get. Nothing is written.
- Dispatch with `mode: release-pr` and `dry-run: false` to open the release
PR. `scripts/release/bump.mts` picks the version and writes `package.json` +
`CHANGELOG.md`, and `scripts/release/open-release-pr.mts` commits them via
the release App onto `npm-publish-v<X.Y.Z>` and opens a
`chore(release): X.Y.Z` PR into `v1.x`. Review it and squash-merge it.
- Dispatch with `mode: publish` and `dry-run: false` to release. It builds the
newest commit that changed the `package.json` version, tags it, cuts the
GitHub release, and stages all three packages for `pnpm stage approve`.
`mode: publish` with `dry-run: true` packs and smoke-tests HEAD and creates
nothing.
- The level is patch by default and minor when a `feat:` is in range. A major
is never derived — a breaking commit stops the bump until someone passes
`release-as: major`.
- A tag reserves its version even when staging or landing fails. Stable tags
- A tag reserves its version even when staging fails. Stable tags
from this major version set the reservation floor. Reachable tags anchor
the changelog history. The next release skips all reserved versions.
- Run `pnpm run release:preflight --version <version>` to check all three
Expand Down
93 changes: 10 additions & 83 deletions scripts/release/bump.mts
Original file line number Diff line number Diff line change
@@ -1,48 +1,33 @@
#!/usr/bin/env node
/**
* @file The CI bump stage. Derives the next version from the commits landed
* since the last release, writes package.json + CHANGELOG.md, and commits the
* pair via the release App onto a throwaway `npm-publish-v<version>` branch.
*
* Nothing here is hand-run. The publish-npm workflow calls it between install
* and build, so the tarballs it packs carry the derived version and the commit
* they claim to be built from. `promote.mts` lands or deletes the branch once
* the run is decided.
* since the last release and writes package.json + CHANGELOG.md into the
* working tree. `open-release-pr.mts` commits the pair and opens the release
* PR from a separate job that never installs dependencies.
*
* Usage:
* node scripts/release/bump.mts [--dry-run] [--release-as major|minor|patch]
*/

import { execFile as execFileCallback } from 'node:child_process'
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'

import {
changelogHeading,
generateChangelogSection,
promoteChangelog,
repoBaseUrl,
} from './changelog.mts'
import { commitViaGithubApi } from './github-api.mts'
import { readReleaseCommits, readReleaseHistory } from './history.mts'
import { readPublishedVersion } from './registry.mts'
import {
discardReleaseBranch,
openReleaseBranch,
resolveReleaseEnv,
} from './release-branch.mts'
import { deriveNextVersion, parseConventionalCommits } from './version.mts'
import { isMainModule } from '../lib/is-main-module.mts'
import { runMain } from '../lib/run-main.mts'

import type { ReleaseBranch } from './release-branch.mts'
import type { ScriptMeta } from '../lib/run-main.mts'

const execFile = promisify(execFileCallback)

const rootPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
Expand All @@ -61,14 +46,6 @@ function log(message: string): void {
process.stdout.write(`[bump] ${message}\n`)
}

async function git(args: readonly string[]): Promise<string> {
const { stdout } = await execFile('git', [...args], {
cwd: rootPath,
maxBuffer: 64 * 1024 * 1024,
})
return stdout
}

function readPackageJson(): { parsed: PackageJsonShape; raw: string } {
const raw = readFileSync(path.join(rootPath, 'package.json'), 'utf8')
return { parsed: JSON.parse(raw) as PackageJsonShape, raw }
Expand Down Expand Up @@ -179,77 +156,27 @@ async function main(): Promise<void> {
return
}

const env = resolveReleaseEnv()
writeFileSync(
path.join(rootPath, 'package.json'),
writeManifestVersion(manifest.raw, derived.version),
)
writeFileSync(changelogPath, promoted.changelog)

const parentSha = (await git(['rev-parse', 'HEAD'])).trim()
const baseTreeSha = (await git(['rev-parse', 'HEAD^{tree}'])).trim()
const files = ['CHANGELOG.md', 'package.json'].map(relPath => ({
content: readFileSync(path.join(rootPath, relPath), 'utf8'),
path: relPath,
}))
const releaseBranch: ReleaseBranch = await openReleaseBranch({
env,
parentSha,
version: derived.version,
})
// Past this point any failure must nuke the branch, otherwise a leftover
// npm-publish-v<version> accumulates. The release line is never touched here,
// so the no-version-creep invariant holds either way.
try {
const sha = await commitViaGithubApi({
baseTreeSha,
branch: releaseBranch.branch,
files,
message: `chore(release): ${derived.version}`,
parentSha,
repo: env.repo,
token: env.token,
})
// The checkout runs with persist-credentials off, so the fetch carries the
// App token inline rather than writing it into .git/config.
const auth = Buffer.from(`x-access-token:${env.token}`).toString('base64')
await git([
'-c',
`http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`,
'fetch',
'--no-tags',
'origin',
`refs/heads/${releaseBranch.branch}`,
])
await git(['reset', '--hard', sha])
log(
`${derived.version} committed ${sha.slice(0, 7)} on ${releaseBranch.branch} ` +
'via the release App.',
)
emitOutputs({
'release-branch': releaseBranch.branch,
sha,
version: derived.version,
})
} catch (e) {
await discardReleaseBranch(releaseBranch)
throw e
}
log(`wrote ${derived.version} to package.json and CHANGELOG.md.`)
emitOutputs({ version: derived.version })
}

const SCRIPT_META: ScriptMeta = {
describe:
'derives the next release version from the landed commits and commits package.json + CHANGELOG.md via the release App',
'derives the next release version from the landed commits and writes package.json + CHANGELOG.md',
help: `Usage: node scripts/release/bump.mts [flags]

--dry-run derive and print the version without opening
a release branch or committing anything
--dry-run derive and print the version without writing
package.json or CHANGELOG.md
--release-as major|minor|patch force the bump level instead of deriving it
from the conventional commits

The publish-npm workflow runs this between install and build. It is not a
hand-run script: it needs RELEASE_APP_TOKEN and the GitHub Actions
environment to reach the release App.`,
The publish-npm workflow runs this in its release-pr mode. It only edits
the working tree, so a local run is safe to inspect and discard.`,
}

if (isMainModule(import.meta.url)) {
Expand Down
145 changes: 138 additions & 7 deletions scripts/release/github-api.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @file The GitHub REST calls the release flow needs: branch refs and a signed
* commit built out of git objects (blob → tree → commit → ref).
* @file The GitHub REST calls the release flow needs: branch refs, a signed
* commit built out of git objects (blob → tree → commit → ref), and the
* release pull request.
*
* A commit created through the API is web-flow VERIFIED without a local GPG or
* SSH key, which is the only way CI can land a commit on a branch that
Expand All @@ -27,7 +28,7 @@ export interface GithubRequestConfig {
readonly method: string
// Path below the API origin, e.g. `/repos/owner/name/git/refs`.
readonly path: string
// Token with contents:write — the release App installation token in CI.
// The release App installation token in CI.
readonly token: string
}

Expand Down Expand Up @@ -97,10 +98,8 @@ export async function createBranchRef(
}

/**
* Advance `refs/heads/<branch>` to `sha`. With `force` false — the default —
* GitHub rejects a non-fast-forward advance with 422, which is what keeps the
* post-publish landing honest: if the release line moved to a commit this one
* does not descend from, the run stops loudly instead of rewriting work.
* Advance `refs/heads/<branch>` to `sha`. With `force` false, the default,
* GitHub rejects a non-fast-forward advance with 422.
*/
export async function updateBranchRef(
config: WriteBranchRefConfig,
Expand Down Expand Up @@ -150,6 +149,8 @@ export interface CommitViaGithubApiConfig {
readonly baseTreeSha: string
readonly branch: string
readonly files: readonly CommitFile[]
// Move the branch even when the new commit does not descend from its tip.
readonly force?: boolean | undefined
readonly message: string
// Parent commit SHA, usually `HEAD`.
readonly parentSha: string
Expand Down Expand Up @@ -214,9 +215,139 @@ export async function commitViaGithubApi(
await updateBranchRef({
apiUrl: cfg.apiUrl,
branch: cfg.branch,
force: cfg.force,
repo: cfg.repo,
sha: commit!.sha,
token: cfg.token,
})
return commit!.sha
}

export interface PullRequest {
readonly html_url: string
readonly number: number
}

export interface ReleasePullRequestConfig {
readonly apiUrl?: string | undefined
// Branch the PR merges into.
readonly base: string
readonly body: string
// Branch the PR merges from, in the same repository.
readonly head: string
readonly repo: string
readonly title: string
readonly token: string
}

/**
* Open a PR from `head` into `base`, or return the open one that already
* exists for that pair. A re-run force-moves `head` to the new commit, so the
* existing PR picks it up by itself and only its title and body need
* refreshing.
*/
export async function upsertPullRequest(
config: ReleasePullRequestConfig,
): Promise<PullRequest> {
const cfg = { __proto__: null, ...config } as ReleasePullRequestConfig
const owner = cfg.repo.split('/')[0]
const query = new URLSearchParams({
base: cfg.base,
head: `${owner}:${cfg.head}`,
state: 'open',
})
const existing = await githubRequest<PullRequest[]>({
apiUrl: cfg.apiUrl,
method: 'GET',
path: `/repos/${cfg.repo}/pulls?${query}`,
token: cfg.token,
})
const open = existing?.[0]
if (open) {
const updated = await githubRequest<PullRequest>({
apiUrl: cfg.apiUrl,
body: { body: cfg.body, title: cfg.title },
method: 'PATCH',
path: `/repos/${cfg.repo}/pulls/${open.number}`,
token: cfg.token,
})
return updated!
}
const created = await githubRequest<PullRequest>({
apiUrl: cfg.apiUrl,
body: {
base: cfg.base,
body: cfg.body,
head: cfg.head,
title: cfg.title,
},
method: 'POST',
path: `/repos/${cfg.repo}/pulls`,
token: cfg.token,
})
return created!
}

export interface ClosePullRequestsConfig {
readonly apiUrl?: string | undefined
// Open PRs into this branch are candidates.
readonly base: string
// Head branches starting with this prefix are closed.
readonly headPrefix: string
// Head branch to keep open.
readonly keepHead: string
readonly repo: string
readonly token: string
}

/**
* Close the open same-repository PRs into `base` whose head branch starts with
* `headPrefix`, except `keepHead`, and delete their branches. Returns the
* closed PR numbers.
*/
export async function closeSupersededPullRequests(
config: ClosePullRequestsConfig,
): Promise<number[]> {
const cfg = { __proto__: null, ...config } as ClosePullRequestsConfig
const query = new URLSearchParams({
base: cfg.base,
per_page: '100',
state: 'open',
})
const open = await githubRequest<
Array<{
head: { ref: string; repo: { full_name: string } | null }
number: number
}>
>({
apiUrl: cfg.apiUrl,
method: 'GET',
path: `/repos/${cfg.repo}/pulls?${query}`,
token: cfg.token,
})
const superseded = (open ?? []).filter(
pr =>
pr.head.repo?.full_name === cfg.repo &&
pr.head.ref.startsWith(cfg.headPrefix) &&
pr.head.ref !== cfg.keepHead,
)
for (let i = 0, { length } = superseded; i < length; i += 1) {
const pr = superseded[i]!
// eslint-disable-next-line no-await-in-loop
await githubRequest({
apiUrl: cfg.apiUrl,
body: { state: 'closed' },
method: 'PATCH',
path: `/repos/${cfg.repo}/pulls/${pr.number}`,
token: cfg.token,
})
// eslint-disable-next-line no-await-in-loop
await deleteBranchRef({
apiUrl: cfg.apiUrl,
branch: pr.head.ref,
repo: cfg.repo,
token: cfg.token,
})
}
return superseded.map(pr => pr.number)
}
Loading
Loading