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
41 changes: 31 additions & 10 deletions bump.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): <version>`, matching repo history),
* tags `v<version>` and pushes
* 3. bumpp commits (`chore(release): <version>`) on a `release/v<version>`
* branch, pushes that branch and opens a pull request
*
* `changelogen -r` must receive the exact new version: without it the heading
* would read `<oldTag>...<branch>` instead of `v<newVersion>`.
* 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<version>` — which is how v1.1.0 was committed.
*
* `all: true` matters: without it bumpp commits with `git commit <files>`
* (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 `<oldTag>...<branch>` instead of `v<newVersion>`.
*/
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' })
},
})
84 changes: 71 additions & 13 deletions docs/agents/release.md
Original file line number Diff line number Diff line change
@@ -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 <patch|minor> --yes`.
4. That commits `chore(release): <version>` — including the regenerated `CHANGELOG.md` — tags `v<version>`, 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 <patch|minor> --yes`.
bumpp then creates `release/v<version>`, commits
`chore(release): <version>` 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<version>` 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 <files>` (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 <newVersion>` (injected by the config's `execute` hook from `operation.state.newVersion`), or the changelog heading reads `<oldTag>...<branch>` instead of `v<newVersion>`.
- 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 <tag>` fails with `stale info` for tags — use `git push --force origin <tag>`.
- Tags are `v<version>` 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): <version>` 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<version>` 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<version>` — 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 <files>` (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 <newVersion>` (injected by the config's `execute` hook
from `operation.state.newVersion`), or the changelog heading reads
`<oldTag>...<branch>` instead of `v<newVersion>`.
- 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 <tag>`
fails with `stale info` for tags — use `git push --force origin <tag>`.
- Tags are `v<version>` 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions scripts/release-tag.mjs
Original file line number Diff line number Diff line change
@@ -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 <tag>`)
*/
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`')
Loading