diff --git a/bump.config.ts b/bump.config.ts index d9e9ed1..59129d4 100644 --- a/bump.config.ts +++ b/bump.config.ts @@ -6,28 +6,49 @@ import { defineConfig } from 'bumpp' * 1. bumpp bumps the version in package.json (interactive semver prompt) * 2. this `execute` hook regenerates CHANGELOG.md for the new version and * stages it, so the changelog lands in the release commit - * 3. bumpp commits (`chore(release): `, matching repo history), - * tags `v` and pushes + * 3. bumpp commits (`chore(release): `) on a `release/v` + * branch, pushes that branch and opens a pull request * - * `changelogen -r` must receive the exact new version: without it the heading - * would read `...` instead of `v`. + * The release commit travels as a pull request because the `Main branch` + * ruleset refuses every direct push to `main`, releases included: `422 Changes + * must be made through a pull request`. The owner's bypass (`pull_request` + * mode) covers merging a PR, not pushing one, so there is nothing to route + * around — see `docs/agents/release.md`. + * + * `pr.base` is pinned instead of detected: bumpp otherwise reads the base from + * `origin/HEAD`, which still says `master` in clones that predate the rename. + * + * bumpp creates no tag in this mode — it cannot know the merge commit, and a + * squash or rebase merge rewrites the release commit. The tag is made after the + * merge by `pnpm release:tag` (`scripts/release-tag.mjs`). + * + * The commit message and `--all` are passed on the command line in + * `package.json` rather than set here: bumpp's CLI defaults (`--commit`, + * `--all`) take precedence over the config file's object form, so a + * `commit: { message }` here would be silently replaced by the default + * `chore: release v` — which is how v1.1.0 was committed. * * `all: true` matters: without it bumpp commits with `git commit ` * (only the bumped files), which ignores other staged paths — a staged - * CHANGELOG.md would never land in the release commit. `git commit -a` - * commits the whole index. + * CHANGELOG.md would never land in the release commit. + * + * `changelogen -r` must receive the exact new version: without it the heading + * would read `...` instead of `v`. */ export default defineConfig({ all: true, - commit: { - message: 'chore(release): {version}', + pr: { + base: 'main', + branch: 'release/v{version}', + title: 'chore(release): {version}', + body: '{oldVersion} → {version}', }, execute(operation) { execSync(`changelogen --output CHANGELOG.md -r ${operation.state.newVersion}`, { stdio: 'inherit', }) - // A brand-new CHANGELOG.md is untracked, and `git commit -a` skips - // untracked files, so stage it explicitly. + // A brand-new CHANGELOG.md is untracked, and `git commit -a` skips untracked + // files, so stage it explicitly. execSync('git add CHANGELOG.md', { stdio: 'inherit' }) }, }) diff --git a/docs/agents/release.md b/docs/agents/release.md index dca9993..cb2aa7b 100644 --- a/docs/agents/release.md +++ b/docs/agents/release.md @@ -1,29 +1,87 @@ # Releasing -How to cut a release of tiny-oss. Tooling: `bumpp` drives the version bump / commit / tag / push; `changelogen` maintains `CHANGELOG.md`. Orchestration lives in `package.json` scripts and `bump.config.ts` — read `bump.config.ts` before changing anything here. +How to cut a release of tiny-oss. Tooling: `bumpp` drives the version bump, +the release branch and the pull request; `changelogen` maintains +`CHANGELOG.md`; `scripts/release-tag.mjs` (`pnpm release:tag`) tags the merged +release commit. Orchestration lives in `package.json` scripts and +`bump.config.ts` — read `bump.config.ts` before changing anything here. + +A release is a change to `main` like any other, and `main` only moves through a +pull request: the `Main branch` ruleset refuses a direct push with `422 Changes +must be made through a pull request`, for every credential, the owner's +included. So the release commit travels as a PR too. What the owner's bypass +(`bypass_mode: pull_request`) buys is _merging_ a PR without a second +reviewer — which is what makes a solo release possible — not pushing one. ## Loop preflight (recommended) The loop runs stage=release before you cut a release: all gates green, a version suggestion (feature → minor, fix/docs → patch, breaking → human), and a preview changelog. Read its report comment, then follow the Flow below -yourself — `pnpm release` and `pnpm publish` stay human-only. +yourself — `pnpm release`, `pnpm release:tag` and `pnpm publish` stay +human-only. ## Flow -1. **Working tree must be clean.** `bump.config.ts` sets `all: true`, so the release commit runs `git commit -a` and sweeps up every tracked modification and staged file. Uncommitted work would land in the release commit. +1. **Start from a clean tree on an up-to-date `main`.** `bumpp --pr` refuses + otherwise (clean tree, on the base branch, not behind `origin/main`). + `bump.config.ts` sets `all: true`, so the release commit runs `git commit -a` + and sweeps up every tracked modification and staged file. 2. **Gates** (fast, no browser needed): `pnpm lint && pnpm check:types`. -3. `pnpm release` — interactive semver prompt. Pick per the commits since the last tag: new feature(s) → `minor`; fixes/docs/tests only → `patch`. This repo has had no breaking change yet, so no `major`. Non-interactive (agent/CI): `pnpm exec bumpp --release --yes`. -4. That commits `chore(release): ` — including the regenerated `CHANGELOG.md` — tags `v`, and pushes straight to `main`, carrying any unpushed commits already on the branch. Push is on by default; opt out with `--no-push`. -5. **`pnpm build && pnpm publish`** — manual, needs npm auth. `dist/` is gitignored but ships in the package (`files: ["dist", "UPGRADING.md"]`), so build locally right before publishing. +3. `pnpm release` — interactive semver prompt. Pick per the commits since the + last tag: new feature(s) → `minor`; fixes/docs/tests only → `patch`. This + repo has had no breaking change yet, so no `major`. Non-interactive + (agent/CI): `pnpm exec bumpp --release --yes`. + bumpp then creates `release/v`, commits + `chore(release): ` including the regenerated `CHANGELOG.md`, pushes + the branch, switches you back to `main` and opens the pull request with `gh`. + **No tag is created** — see step 5. +4. **Merge the release PR.** You authored it, so you cannot approve it; the + ruleset bypass is what lets you merge it anyway. Merging is the consent. + Squash and rebase both rewrite the release commit's SHA on `main` — which is + exactly why the tag is not made on the branch. +5. **`pnpm release:tag`** — on the updated `main`, tags `v` at the + merge result and pushes the tag. It refuses a dirty tree, a branch other + than `main`, a `main` out of sync with `origin/main`, and an existing tag. + `--no-push` creates the tag locally only, for a rehearsal. +6. **`pnpm build && pnpm publish`** — manual, needs npm auth. `dist/` is + gitignored but ships in the package (`files: ["dist", "UPGRADING.md"]`), so + build locally right before publishing. -Preview the next changelog without touching anything: `pnpm changelog` (stdout only). +Preview the next changelog without touching anything: `pnpm changelogen` +(stdout only). ## Gotchas (verified against the installed tool versions) -- `bump.config.ts` MUST keep `all: true`. Without it bumpp commits with `git commit ` (only the bumped files), silently dropping the staged `CHANGELOG.md` from the release commit. That shipped once; it was fixed by amending the release commit and force-pushing. -- `changelogen` needs `-r ` (injected by the config's `execute` hook from `operation.state.newVersion`), or the changelog heading reads `...` instead of `v`. -- A brand-new `CHANGELOG.md` is untracked, and `git commit -a` skips untracked files — the `execute` hook stages it explicitly. Do not pre-create `CHANGELOG.md`; the release commit creates it. -- Bare `changelogen` prints to stdout and writes nothing; only `--output` / `--bump` / `--release` touch disk. -- Re-tagging after a mistaken push: `git push --force-with-lease origin ` fails with `stale info` for tags — use `git push --force origin `. -- Tags are `v` since v1.0.0 (pre-1.0.0 `0.x.y` tags have no prefix). `bumpp`/`changelogen` defaults match the `v` prefix; commit messages historically read `chore(release): ` without the `v`. +- **Do not plan around a direct push to `main`.** The ruleset answers `422` + whatever the credential; a PR is the only way in. Tags are _not_ covered — + there is no tag ruleset — so `git push origin v` works. +- `pr.base: 'main'` MUST stay in `bump.config.ts`. Without it bumpp derives the + base from `origin/HEAD`, which still says `master` in a clone made before the + rename, and the precondition check then refuses to run on `main`. +- The commit message lives on the command line in `package.json`, not in + `bump.config.ts`: bumpp's CLI defaults (`--commit`, `--all`) win over the + config file's object form, so a `commit: { message }` there is silently + replaced by the default `chore: release v` — that is how v1.1.0 was + committed, config and all. `-a` is passed for the same reason: `all: true` in + the config is the belt, the flag is the braces. +- `bump.config.ts` MUST keep `all: true`. Without it bumpp commits with + `git commit ` (only the bumped files), silently dropping the staged + `CHANGELOG.md` from the release commit. That shipped once; it was fixed by + amending the release commit and force-pushing. +- The tag is created after the merge, never on the release branch: bumpp's PR + mode creates no tag by design, since it cannot know the merge commit. Tags + are annotated (`git tag -a`) — v1.1.0 was cut by hand and is the one + lightweight tag in the history. +- `changelogen` needs `-r ` (injected by the config's `execute` hook + from `operation.state.newVersion`), or the changelog heading reads + `...` instead of `v`. +- A brand-new `CHANGELOG.md` is untracked, and `git commit -a` skips untracked + files — the `execute` hook stages it explicitly. Do not pre-create + `CHANGELOG.md`; the release commit creates it. +- Bare `changelogen` prints to stdout and writes nothing; only `--output` / + `--bump` / `--release` touch disk. +- Re-tagging after a mistaken push: `git push --force-with-lease origin ` + fails with `stale info` for tags — use `git push --force origin `. +- Tags are `v` since v1.0.0 (pre-1.0.0 `0.x.y` tags have no prefix); + `bumpp`'s default and `scripts/release-tag.mjs` agree on the `v` prefix. diff --git a/package.json b/package.json index 100c4be..c0fb90c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "build:types:protocol": "dts-bundle-generator src/provider.ts -o dist/tiny-oss.protocol.es.d.ts --project tsconfig.json --sort", "check:types": "tsc --noEmit --skipLibCheck test/test-types.ts && tsc --noEmit --skipLibCheck test/test-types-cos.ts && tsc --noEmit --skipLibCheck test/test-types-obs.ts && tsc --noEmit --skipLibCheck test/test-types-tos.ts && tsc --noEmit --skipLibCheck test/test-types-aws.ts && tsc --noEmit --skipLibCheck test/test-types-azure.ts && tsc --noEmit --skipLibCheck test/test-types-protocol.ts", "changelog": "changelogen", - "release": "bumpp", + "release": "bumpp -a --commit \"chore(release): {version}\"", + "release:tag": "node scripts/release-tag.mjs", "dev": "vite", "loop": "node scripts/loop/run.mjs", "serve": "tsx test/server.ts", diff --git a/scripts/release-tag.mjs b/scripts/release-tag.mjs new file mode 100644 index 0000000..3867c6d --- /dev/null +++ b/scripts/release-tag.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Tag a release after its pull request has been merged on `main`. + * + * `pnpm release` (bumpp in `--pr` mode) deliberately creates no tag: it cannot + * know the merge commit, and a squash or rebase merge rewrites the release + * commit — a tag made on the release branch would point at a commit that never + * lands on `main`. The tag is created here instead, on an up-to-date `main`, + * and names the version the merged release commit shipped. + * + * Usage: + * pnpm release:tag create the tag and push it + * pnpm release:tag --no-push create it locally only (rehearse, then + * `git tag -d `) + */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const noPush = process.argv.includes('--no-push') + +/** Run a git command in the repository, returning its trimmed stdout. */ +function git(...args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim() +} + +function fail(message) { + console.error(`release:tag: ${message}`) + process.exit(1) +} + +const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) +const tag = `v${version}` + +if (git('status', '--porcelain') !== '') { + fail('the working tree is not clean — commit or stash first') +} + +const branch = git('rev-parse', '--abbrev-ref', 'HEAD') +if (branch !== 'main') { + fail(`on "${branch}", expected "main" — the tag belongs on the merged release commit`) +} + +git('fetch', 'origin', 'main') +const head = git('rev-parse', 'HEAD') +if (head !== git('rev-parse', 'origin/main')) { + fail('local main is not in sync with origin/main — pull first') +} + +if (git('tag', '--list', tag) !== '') { + fail(`tag ${tag} already exists`) +} + +git('tag', '-a', tag, '-m', tag) +console.log(`release:tag: tagged ${tag} at ${head.slice(0, 7)}`) + +if (noPush) { + console.log( + `release:tag: --no-push — the tag is local only (remove it with \`git tag -d ${tag}\`)`, + ) +} else { + execFileSync('git', ['push', 'origin', tag], { cwd: root, stdio: 'inherit' }) + console.log(`release:tag: pushed ${tag}`) +} + +console.log('release:tag: next, `pnpm build && pnpm publish`')