This guide explains how a Turmeric release is cut, from the first
precondition check to the final GitHub Release. It covers the three
Claude commands that drive the flow -- /cut-major-release,
/cut-minor-release, and /cut-patch-release -- and the automated
release workflow that takes over once the tag is pushed.
For installing Turmeric from a release, see Releases and Installation.
| Command | Version bump | When to use |
|---|---|---|
/cut-patch-release |
MAJOR.MINOR.(PATCH+1) |
Bug fixes only |
/cut-minor-release |
MAJOR.(MINOR+1).0 |
New features, non-breaking changes |
/cut-major-release |
(MAJOR+1).0.0 |
Breaking changes that require user code edits |
All three share the same nine-step flow. The only differences are the version arithmetic (Step 1), the changelog classification emphasis (Step 2), and one extra guard in the major command that refuses to proceed if the commits since the last tag don't actually contain breaking changes.
The commands live in .claude/commands/ and are run from a clean
checkout on main. They are designed to be safe: every destructive
operation is gated behind a precondition check and a user confirmation,
and the ordering ensures a failure at any step is recoverable without
having published a broken release.
Before touching any file, the command runs six checks in parallel and reports the findings. If any check fails, it stops and asks the user to fix the situation before proceeding.
git status --porcelain) -- uncommitted
changes would be swept into the release commit. The user must commit
or stash first.main (git rev-parse --abbrev-ref HEAD) -- releases are
cut from main, not a feature branch.git fetch origin main then
git rev-list --left-right --count origin/main...HEAD) -- a local
main that is behind origin would push a release missing commits
that already landed.cat VERSION) -- the old version, used as the
baseline for the bump and the changelog range.git describe --tags --abbrev=0 --match 'v*')
-- the most recent release tag should be v<VERSION>. A mismatch
means something is out of sync and is surfaced before proceeding.git log v<OLD>..HEAD --oneline) --
there must be at least one commit. A release with zero new commits
is refused.The command also runs ./build/tur experiments (or reads
EXPERIMENTS[] in src/runtime/experiments.c) and lists any
experiment whose expires_at is at or before the version being cut.
This is a notice, not a precondition. It never blocks the release.
The author decides separately whether to graduate the experiment
(delete its row; the feature becomes always-on), shelve it, or bump
expires_at -- in this release or a later one.
Treating this as a gate has stranded two releases. The command is
explicit about this: expires_at is a deadline, not an earliest date,
and there is no registry check in the preconditions. See
experimental-flags-guide.md.
[Unreleased] sectionIf CHANGELOG.md has a ## [Unreleased] section, its bullets are
folded into the new release entry (keeping their ### Added /
### Changed / ### Fixed grouping), and the [Unreleased] heading
and any "next release must be a ..." note are deleted. The section
never survives a release.
If the note says the next release must be a minor or major bump, the
patch command stops and tells the user to run /cut-minor-release or
/cut-major-release instead. A behavior change flagged there must not
ship under a patch version.
Read VERSION, parse MAJOR.MINOR.PATCH, and compute the new version:
MAJOR.MINOR.(PATCH+1) -- e.g. 0.14.0 -> 0.14.1MAJOR.(MINOR+1).0 -- e.g. 0.12.0 -> 0.13.0(MAJOR+1).0.0 -- e.g. 0.16.0 -> 1.0.0For a major bump, the command confirms with the user that the breaking
changes since v<OLD> actually justify a major-version bump (vs. a
minor). If they don't, it refuses and suggests /cut-minor-release.
Run git log v<OLD>..HEAD --pretty=format:'%h %s' to get the commit
list since the last tag, then classify each commit:
When a commit subject is ambiguous, the command runs
git show --stat <sha> to see what files changed. Internal commits
are skipped -- the changelog audience is users, not the git log.
The entry is formatted to match the existing CHANGELOG.md style:
## [NEW] -- YYYY-MM-DD
### Breaking changes
- ...
### Added
- ...
### Changed
- ...
### Fixed
- ...
Empty subsections are omitted. For a major release, each Breaking changes item includes a short before/after snippet or migration note.
README.md line 5 has a one-sentence summary of the current release.
The command replaces it with a new sentence for the new version,
highlighting the most significant change. For a major release, this
names the most significant breaking change.
The command shows the user:
It then asks via AskUserQuestion whether to proceed, edit the
changelog, edit the README line, or cancel. Nothing is committed
without explicit confirmation.
Six files are updated in parallel:
VERSION -- write the new version string.stdlib/VERSION -- write the same version. tur reads this
stamp to tell its own stdlib from another release's; a stale one
makes a mismatched stdlib look like a match and makes the correct
one warn. ctest -R tur_stdlib_version_stamp fails if the two
files disagree.src/web/wasm_glue.h -- update the TURMERIC_VERSION define.web/public/sw.js -- update the CACHE_VERSION literal. Vite
rewrites this token at build time, but the in-tree literal is the
dev/no-build fallback and silently drifts a release behind if
skipped.CHANGELOG.md -- insert the new entry after the header.README.md -- replace the "Latest release" line.After applying, the command runs git diff --stat and shows what
changed. No commit is made yet.
git add VERSION stdlib/VERSION src/web/wasm_glue.h web/public/sw.js CHANGELOG.md README.md
git commit -m "chore: release v<NEW>
<one-paragraph summary>"
Then create an annotated tag (not signed):
git tag -a "v<NEW>" -m "Release v<NEW>"
The tag is annotated (-a), not signed (-s). This is a recorded
decision, not an oversight. What a consumer downloads is a release
asset, and those carry
build provenance
-- signed through Sigstore with a short-lived certificate minted from
the release job's OIDC token, binding each asset to the workflow,
repo, commit, and run that built it. That is the signature that
protects users, and it needs no key anyone has to hold or rotate. A
signed tag protects something narrower (who cut the release, to
someone reading git history) and needs a long-lived key whose loss or
absence turns every future cut into a hard failure.
The tag exists locally only. Nothing is pushed yet. If the web deploy in the next step fails, the local tag can be deleted and the cut retried without having published a broken release.
tur run migrate-ci-db
tur run deploy-web
migrate-ci-db applies any pending file in web/migrations/ to
ci-metrics, the production D1 database behind /ci. It has to come
first: once the Worker writes a column the database does not have yet,
every CI report fails. Migrations already applied are skipped, so with
nothing pending it prints "No migrations to apply!" and returns, which is
why it runs on every cut rather than only when a migration landed. It
needs the same wrangler login as the deploy. The recipe runs wrangler
from web/, because that is where wrangler.jsonc names the database:
the bare npx wrangler d1 migrations apply ci-metrics --remote run from
the repo root fails with "No configuration file found". Any deploy outside
a release follows the same order. See
web/README.md for the store itself.
This runs tur run wasm (which runs tur run docs), then tur run web-deps,
then npm run build, then wrangler deploy to push the web app to
Cloudflare. The user must already be authenticated with wrangler.
This regenerates web/public/turmeric.{js,wasm} and
web/public/doc-names.json. These are gitignored build outputs and
are not committed. git status stays clean through this step.
There is no follow-up "regenerate web artifacts" commit -- that habit
left every release tag carrying the previous release's binary.
If the migration or the deploy fails:
git tag -d v<NEW>.Only after a successful deploy:
git push origin main
git push origin "v<NEW>"
The tag push triggers .github/workflows/release.yml.
gh run list --workflow=release.yml --limit 1
The command reports the run ID and status, the new version, the bump commit SHA, the release workflow URL, and the Cloudflare deploy confirmation. It does not block waiting for the workflow to finish -- it takes 1-2 minutes per matrix leg.
The steps are ordered so that a failure at any point is recoverable:
Preconditions → file changes → commit → tag (local) → deploy web → push tag
The awkward state this ordering exists to avoid is a cut that dies partway through: a tag pushed to the remote with no matching release, or a release whose web app still shows the previous version. Both are recoverable but messy; the ordering makes them rare.
The tag push triggers .github/workflows/release.yml, which has four
jobs:
For linux-x86_64, linux-aarch64, and macos-arm64:
libedit (package manager differs by platform).tur --version as a smoke test.bin/tur + lib/*.a + include/ + share/turmeric/stdlib/
with scripts/stage-release.sh and pack them into a prefix-layout
tar.gz.tur --version proves only that the binary starts; this step proves it
can actually compile. Several releases passed the version check
while being unable to compile anything.tests/release-sdk/ from
the same archive (the two-line program links no -lturi).
tests/check-release-sdk.sh runs the same check on every PR.Windows is a separate job rather than a fourth matrix leg because
every step needs shell: msys2 {0} and the toolchain arrives through
setup-msys2. It does the same steps plus one Windows-specific check:
verifying the binary is self-contained. A default MinGW build can
depend on libwinpthread-1.dll and edit.dll from the MSYS2 tree,
which a user without MSYS2 on PATH would hit as a silent exit 127.
The job both reads the import table (ldd, rejecting anything
resolved inside MSYS2) and runs tur --version with MSYS2 stripped
from PATH.
Platform-independent, so it runs once:
fetch-depth: 0) -- the doc generator
dates each guide from the commit that added it.--require-hashes.genguides.py, genspices.py, gendocs.py, and genpack.py
to build the rendered docs and a docs pack.docs/html and web/public/docs-pack into
turmeric-docs-v<NEW>.tar.gz -- what tur docs --open reads once
unpacked.This job runs only if all three build jobs and the docs job succeed
(not merely avoid cancellation -- checking only for cancelled let
failed legs through in v0.56.1 and v0.56.2, publishing partial
releases with a missing platform and no indication of it):
sha256sums.txt.sha256sums.txt is attested too) and
before the release is created (so a failure here strands the tag
rather than publishing unattested assets). A consumer verifies
with:
gh attestation verify turmeric-<tag>-<target>.tar.gz \
--repo rjungemann/turmeric
expires_at is at
or past the version being cut (advisory only).git push --force for any step./cut-minor-release instead).To test changes to release.yml without burning real version numbers,
use a throwaway tag:
git tag v0.0.0-test1
git push origin v0.0.0-test1
gh run list --workflow=release.yml --limit 1
gh run watch <run-id>
# Clean up:
gh release delete v0.0.0-test1 --cleanup-tag --yes
git tag -d v0.0.0-test1
Increment the suffix (-test2, -test3, ...) per iteration so each
failed attempt's history is preserved. The workflow also supports
workflow_dispatch for a dry run that builds and uploads artifacts
without creating a release.
.claude/commands/cut-{major,minor,patch}-release.md -- the
command definitions this guide describes..github/workflows/release.yml -- the release pipeline.