Releasing
How a change goes from a commit, through the CHANGELOG, to a release. What counts as a breaking change is in Versioning, and the commit format is in Contributing.
At a glance
commit (Conventional Commits) ──▶ PR: scripts/check.sh checks the commit format and CHANGELOG [Unreleased]
│
▼ merged into main
release PR: node scripts/release.mjs bump <level> ──▶ changes the version everywhere at once and
moves [Unreleased] to ## [X.Y.Z] - date
│
▼ after the merge, the maintainer tags vX.Y.Z (release.mjs check --tag compares the tag with the version)
tag pushed: .github/workflows/release.yml ──▶ builds and smoke-tests the macOS archive and attaches it and
SHA256SUMS to the GitHub Release (created from the CHANGELOG)- Commits are the source of the change history. Every commit uses the Conventional Commits format (checked by
scripts/check.shand.githooks/commit-msg), and its type decides the release level. - CHANGELOG.md is prose for people. In the Keep a Changelog format, each user-visible change (
feat,fix,perf,revert, a breaking change) gets one line under## [Unreleased]in the same PR.node scripts/release.mjs notesdrafts it from the commits (git-cliff, configured incliff.toml). - CHANGELOG entries are written in English, like the documentation, with a Korean copy of each entry in Changelog. The git-cliff draft comes from the commit messages; read it and rewrite it as sentences that make sense to users. Section names (
Added,Changed, ...) and code and identifiers stay as they are in both files. - People write the sentences; machines check the structure. For every PR,
scripts/check.sh(check-commits.mjs --changelog) compares the sections of new entries in[Unreleased]with the sections of the git-cliff draft (see Why it is semi-automatic below). - The public interface is also checked by machine.
api/c-abi.txt(thezeropty_*symbols oflibzeropty_c.dyliband the declarations inzeropty.h) andapi/node.txt(the Node module exports) are the baselines.scripts/check.shcompares them with the build (node scripts/check-api.mjs), and when a baseline line disappears in a PR it requires a breaking mark (!orBREAKING CHANGE:) and a**BREAKING**entry (Versioning). - The tools in this repository never commit, tag, push or publish. They only change files; people check and do the rest.
Why it is semi-automatic
We do not use the git-cliff output as the CHANGELOG verbatim; a person rewrites the draft for users. This is a deliberate choice.
- The readers differ. A commit message is a one-line note for people changing the code, and a CHANGELOG entry is documentation for people using zeropty. A list of commit titles leaves out "what you have to change".
- Several commits become one entry. When one feature is split over several
featcommits, or a feature not released yet is fixed with afixin the same cycle, the draft has several lines but users need one. - Instead,
scripts/check.shcatches entries that are missing or filed in the wrong place.check-commits.mjs --changelogclassifies the PR's commits with the same rules ascliff.toml(sectionsForinscripts/lib/conventional.mjs) and checks that the new or changed entries in[Unreleased]are in those sections.
| Commit | Section in the git-cliff draft | New entry the check requires |
|---|---|---|
feat | Added | under ### Added |
fix | Fixed | under ### Fixed |
fix(security) | Security | under ### Security |
perf, revert | Changed | under ### Changed |
breaking change (!, BREAKING CHANGE:) | Changed | - **BREAKING** ... under ### Changed or ### Removed |
refactor, docs, test, build, ci, chore, style | (left out) | none |
any type + a Changelog: skip footer (except breaking changes) | (left out) | none |
- The only headings allowed in
[Unreleased]areAdded,Changed,Deprecated,Removed,FixedandSecurity, in this order, each at most once. Every entry must be under a###heading. This is always checked, even withZEROPTY_SKIP_CHANGELOG=1. - The wording is not compared. The entries are rewritten rather than copied from the draft, so as long as the section matches, the check passes.
- If the two classification rules (
cliff.tomlandsectionsFor) disagree,scripts/test/cliff.test.mjsfails. It renders commits with the real git-cliff and compares the section of each line withsectionsFor. - A commit that is not news to users, such as a
fixfor a feature not released yet (one still under[Unreleased]), gets aChangelog: skipfooter at the end of its commit message. Only that commit is left out of the check and the git-cliff draft; the other commits in the same PR are still checked. If needed, reword the existing entry. It cannot be used for breaking changes (the commit check rejects it). - If the whole PR is invisible to users but has
feat/fixtypes, say so in the PR description, and the maintainer skips the entry check withZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh. The commit format is still checked.
Where the version lives
release.mjs bump changes all of the following at once, and tests/version.rs, the bindings/c unit tests and release.mjs check verify that they agree.
| File | Contents |
|---|---|
Cargo.toml | [workspace.package] version. The three crates inherit it with version.workspace = true |
Cargo.lock | The zeropty, zeropty-c and zeropty-node entries |
bindings/c/include/zeropty.h | ZEROPTY_VERSION_MAJOR, MINOR, PATCH, PRERELEASE, IS_PRERELEASE, STRING (NUMBER is computed) |
client/zeropty.mjs, client/embedded.mjs | export const VERSION |
CHANGELOG.md | ## [X.Y.Z] - YYYY-MM-DD and the comparison links at the bottom |
zeropty --version, the server's #{version}, zeropty_version() and Node's version() come from the Cargo version at build time, so there is nothing else to change.
bump does not touch the Korean copy Changelog; when you polish the release section (step 4 below), apply the same heading and entries there by hand.
The links at the bottom of the CHANGELOG point only to tags that exist. If the previous version has a tag, they use compare/vA...vB; for a version whose predecessor has no tag, they start from the commit the [Unreleased] link starts from. A link to a tag that does not exist makes node --test scripts/test fail, except the tag of the version being released (the release PR links it; the tag comes after the merge).
Commands
node scripts/release.mjs check # whether the version agrees everywhere (also run by scripts/check.sh)
node scripts/release.mjs check --tag v0.2.0 # and whether the tag matches that version (also run by scripts/check.sh on a tagged HEAD)
node scripts/release.mjs next # the next version, computed from the commits since the last tag
node scripts/release.mjs notes # the [Unreleased] draft (git-cliff, fetched with npx if missing)
node scripts/release.mjs release-notes 0.2.0 # the GitHub Release body, from CHANGELOG.md's ## [0.2.0]
node scripts/release.mjs bump minor --dry-run
node scripts/release.mjs bump <major|minor|patch|auto|X.Y.Z[-pre]> [--date YYYY-MM-DD] [--generate]
node scripts/check-commits.mjs --range origin/main..HEAD --changelog # only the commit and changelog check of scripts/check.sh (including the section comparison)
scripts/smoke-version.sh # whether the CLI, server, C and Node versions in dist/ agree (the version smoke tests step of scripts/check.sh)
scripts/package.sh # the release archive and SHA256SUMS in dist/release/, smoke-tested unpacked (needs rustup target add aarch64-apple-darwin x86_64-apple-darwin)
rustup run 1.88.0 cargo check --workspace --all-targets --locked # MSRV (the MSRV step of scripts/check.sh)
node scripts/check-api.mjs # whether the C ABI and Node exports in dist/ (./build.sh) match api/*.txt
node scripts/check-api.mjs --base origin/main # whether lines removed from the baseline are marked as breaking (PR check)
node scripts/check-api.mjs --update # update the baseline when you changed the interface on purpose (in the same commit)
node --test scripts/test/*.test.mjs # tests for the tools themselves (including git-cliff rendering)bump autouses the levelnextcomputes. Before 1.0, that is MINOR if there is a breaking change and PATCH otherwise.bumpfirst checks thatcheckpasses and that the working tree is clean; if either fails, it changes no files.- It stops if
[Unreleased]is empty. With--generate, it fills it from the git-cliff draft (read and rewrite it anyway). - After changing the version, check that
Cargo.lockis consistent withcargo metadata --locked.
Release procedure
Check that
scripts/check.shpasses on main. Every step must pass without--quick(including MSRV, the examples and rustdoc).Decide the level.
shgit switch main && git pull node scripts/release.mjs nextCompare the result with the rules in Versioning. If you raised the MSRV, or a
buildorrefactorcommit made a user-visible difference, the computation does not see it, so choose the level yourself.Bump the version on a release branch.
shgit switch -c release/0.2.0 node scripts/release.mjs bump minor # or auto, 0.2.0, 0.2.0-rc.1Polish the CHANGELOG. Read the
## [0.2.0]section from a user's point of view.- Merge lines about the same thing into one, and leave out internal details.
- For a breaking change, write what users have to change, along with
**BREAKING**. - If you mention performance numbers, use only values measured in Benchmarks.
- Make the same changes in the Korean copy, Changelog.
Verify.
shcargo build --release --workspace && cargo test --release --workspace && ./build.sh dist/zeropty --version # zeropty 0.2.0 node scripts/release.mjs checkCommit and open a PR. The commit message is
chore(release): 0.2.0(git-cliff leaves this commit out of the CHANGELOG). Use the same PR title.After the merge, the maintainer creates the tag.
shgit switch main && git pull git tag -a v0.2.0 -m "zeropty 0.2.0" git push origin v0.2.0Before pushing, check that the tag matches the version with
node scripts/release.mjs check --tag v0.2.0(on a tagged HEAD,scripts/check.shruns this check too).The tag publishes the release.
.github/workflows/release.yml(a macOS runner) checks the tag against the version, runsscripts/package.sh, and attaches its two files to the release:zeropty-0.2.0-darwin-universal.tar.gz: one folder,zeropty-0.2.0-darwin-universal/, holdingzeropty(arm64 + x86_64,lipo),shim.dylib(arm64, arm64e, x86_64),zeropty-tty,claude-zeropty,LICENSE-APACHEandLICENSE-MIT, with their execute bits.zeroptyandshim.dylibare signed ad hoc (codesign -s -); Developer ID signing and notarization may come later.SHA256SUMS:shasum -a 256of the archive. Whoever pins a version pins this hash.
If the tag has no release yet, the workflow creates it with
node scripts/release.mjs release-notes 0.2.0as the body (the CHANGELOG section, one line per entry, relative links made absolute at the tag); a tag with a pre-release part (v0.2.0-rc.1) becomes a prerelease, never "Latest". A release you created before the workflow got there (about a minute after the push) keeps its body. Check the run under Actions, then download the two files and runshasum -a 256 -c SHA256SUMS. Both binaries load on macOS 11 and later (package.shchecks each slice's minimum).- The workflow never replaces files already on a release: a rebuild is not byte-identical, and a new hash would break whoever pinned the old one. To replace them on purpose, delete them from the release, then re-run.
- A re-run builds the tagged commit again, so it helps only with a failure outside the repository (a runner or network problem). A fix to the packaging needs a new commit and the next PATCH release.
- The embedding libraries and the bash builtin are not in the archive; they come from
./build.sh.
0.1.0 is the first public commit (
c612089), taggedv0.1.0; 0.1.1 was the first release made with these tools.nextandnotescompute from the latest tag.
Formatting
The whole workspace is rustfmt-formatted (configured in rustfmt.toml). Once the branches in progress had all landed on main, it was formatted in one go with a single style: cargo fmt --all commit (e12f84f).
- The rustfmt step of
scripts/check.shchecks the whole tree withcargo fmt --all --check. Runcargo fmt --allbefore committing. - For an old branch that forked before the formatting commit, pick your side in the conflicting files and run
cargo fmt --allagain. Formatting is mechanical, so the result is the same. - To skip the formatting commit in
git blame, usegit blame --ignore-rev e12f84f <file>.
Prereleases and patch releases
- Prerelease:
node scripts/release.mjs bump 0.2.0-rc.1. The tag isv0.2.0-rc.1. The final release isbump patch(0.2.0-rc.1→0.2.0) orbump 0.2.0. The header'sZEROPTY_VERSION_PATCHandZEROPTY_VERSION_NUMBERhold only numbers, so0.2.0-rc.1and0.2.0are the same there. Tell them apart withZEROPTY_VERSION_PRERELEASE("rc.1",""for a final release),ZEROPTY_VERSION_IS_PRERELEASE(1/0, usable in#if) andZEROPTY_VERSION_STRING("0.2.0-rc.1"). - Fixing an earlier version (backport): create a branch from the tag (
git switch -c release/0.1.x v0.1.0). Cherry-pick the fix commits, record them under[Unreleased], and follow the same procedure withbump patch. Copy that version's section into the CHANGELOG on main as well.
Troubleshooting
| Symptom | Fix |
|---|---|
tests/version.rs fails | A version was edited by hand. See where they disagree with node scripts/release.mjs check, and bring them back in line with bump |
The commit messages step of scripts/check.sh: commit format | Fix the message with git rebase -i (reword). Fold fixup commits in with --autosquash |
The commit messages step of scripts/check.sh: changelog | Add a line under ## [Unreleased]. For a change users do not see, skip the entry check with ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh |
needs an entry under ### Fixed for: ... | The entry is in another section. Move it to the section that matches the commit type (the table in Why it is semi-automatic). node scripts/release.mjs notes shows git-cliff's classification |
[Unreleased]: "### Fixes" is not one of ... | Use only the six Keep a Changelog headings, in the order Added → Security |
Fails because a breaking change has no **BREAKING** line | Add a - **BREAKING** ... line under [Unreleased] |
check-api.mjs: api/*.txt does not match the build | If you changed the interface on purpose, run node scripts/check-api.mjs --update and commit api/ with it. If not, revert the changed declarations or exports |
check-api.mjs --base: public API removed or changed | A line disappeared from the baseline (a function removed, a signature changed). If intended, add ! or BREAKING CHANGE: to the commit and - **BREAKING** ... under [Unreleased]; if not, add it under a new name instead |
The rustfmt step of scripts/check.sh fails | Format with cargo fmt --all and commit (Formatting) |
The MSRV step of scripts/check.sh: Rust 1.88.0 is not installed | rustup toolchain install 1.88.0 |
bump stops with nothing under ## [Unreleased] | Write the entries, or pass --generate |
| A tag was created by mistake | Before pushing, git tag -d vX.Y.Z. After pushing, do not delete it; release the next PATCH instead |