The exact sequence for shipping a version, written for the person running it. It assumes
a clean checkout of main, push access to UnicoLab/smlcode, Go, Node 18+ and make.
The short version: you tag; CI does the rest. Everything between the tag landing and
the release appearing is .github/workflows/release.yml. Your job is to make sure the
tree deserves the tag, and to verify the published result afterwards.
The shorter version, when the tree is already in good shape: Actions → Release → Run workflow, leave the tag empty, and the same workflow picks the version off the commits, bumps it and publishes. Steps 0 and 4 below still apply — something has to decide the tree deserves a release, and somebody has to look at what came out. See Starting it from the Actions tab.
Repo slug. The GitHub repository is
UnicoLab/smlcode. The Go module isgithub.com/UnicoLab/slmcode. Those two strings differ by two letters and both are correct.scripts/check-repo-refs.shexists to stop the module path leaking into a download URL; if it fails, believe it.
cd /path/to/smlcode
git switch main && git pull --ff-only
git status --porcelain # must be emptyRegenerate the web lockfile if it is stale. web/package-lock.json currently predates
several devDependencies in web/package.json, so a strict npm ci refuses to run. CI
falls back to npm install and still builds, but the lockfile is the fix:
cd web && npm install && cd ..
git diff --stat web/package-lock.json
# if it changed:
git add web/package-lock.json && git commit -m "chore(web): regenerate package-lock.json"Build the Studio UI and run the full gate. make bootstrap is what puts the real SPA
into cmd/slmcode/ui/; without it every binary you build locally serves the placeholder
page from pkg/server.
make bootstrap # npm deps + vite build + sync into cmd/slmcode/ui/
make check # gofmt, vet, golangci-lint (0 issues), coverage floor, -race, web lint+build
./scripts/check-version.sh # version.go == Makefile == Formula
./scripts/check-repo-refs.sh # no UnicoLab/slmcode download URLsConfirm your local binary really embeds the Studio — this is the one failure mode that silently ships:
make build
ls cmd/slmcode/ui/assets/*.js # must list at least one bundle
./bin/slmcode studio # open the printed URL (it carries ?t=…) — you should see
# the real Studio, not "Studio not built"Confirm the changelog is written. docs/changelog.md needs a real ## vX.Y.Z entry
with a Breaking behaviour changes section, cross-linked to docs/migration.md. The
generated commit-subject dump is a fallback for patch releases, not a substitute.
scripts/prepare-release.sh 0.20.0 --dry-run # look at the diff; nothing is committed
scripts/prepare-release.sh 0.20.0 # for realOr let it pick the number from the commits, the same way the one-button release does:
scripts/next-version.sh --explain # just show the reasoning and the version
scripts/prepare-release.sh auto --dry-run # choose, bump, gate — commit nothing
scripts/prepare-release.sh auto # for real
scripts/prepare-release.sh auto --bump minor # override the level it choseWhat it does, in order:
- Refuses if the tag exists or if any of the four release files are already dirty.
- Sets the version in
cmd/slmcode/version.go,MakefileandFormula/slmcode.rb. - Resets the four
sha256values in the formula to the all-zero placeholder, dropping the# vX.Y.Zlabel each one carried (CI fills both back in after the binaries exist — see step 3). A zeroed value with a stale label is the one statecheck-version.shrejects. - Adds a changelog entry only if
docs/changelog.mdhas no## vX.Y.Zheading yet. - Runs
scripts/check-version.sh --tag vX.Y.Z,scripts/check-repo-refs.sh,make check. - Commits
chore: release vX.Y.Zand creates the tag. It does not push.
For v0.20.0 specifically, the version and the changelog entry are already in the tree, so the script reports "No file changes needed — proceeding as a tag-only release", runs the gate, and creates the tag with no release commit. That is correct.
Review before pushing:
git show --stat HEAD
git tag -v v0.20.0 2>/dev/null || git show v0.20.0 --stat | headgit push origin main
git push origin v0.20.0 # this is what starts the releasePushing the tag is the point of no return for the automation. Everything before it is
reversible with git tag -d and git reset.
Actions → Release → Run workflow runs the same job, and it has two modes.
Leave the tag empty — the whole release, one button. There is nothing to do
beforehand: no local checkout, no bump, no tag. The job reads the version off the
conventional commits since the last tag (scripts/next-version.sh), runs
prepare-release.sh to bump version.go, the Makefile, the Formula, docs/install.md
and the changelog, and commits it.
Two things leave the runner, at two different moments:
- The bump commit goes out immediately, seconds after checkout. If
mainmoved in between, the job rebases onto it and retries (three times) — so the gate and the six builds run on exactly the tree that will be tagged. Holding the bump until the end instead meant losing a 40-minute race with every other push tomain, and one lost race threw away the whole build. - The tag goes out at the very end, after the gate has passed, the Studio UI is verified embedded, all six binaries are built and the linux/amd64 one has been asked what version it reports. The tag is what publishes — the GitHub Release is cut from it — so it is the thing worth withholding.
A release that fails partway therefore leaves a chore: release vX.Y.Z commit on main
with no tag and no release. That is a no-op, not wreckage, and it heals itself: the next
run picks the same version, finds the files already at it, and prepare-release.sh takes
its tag-only path. Nothing to revert.
The bump dropdown forces the level when you disagree with the commits:
| Commits since the last tag | Version chosen |
|---|---|
any feat!: / BREAKING CHANGE: |
major — but held inside 0.x (0.25.0 → 0.25.0) |
any feat: |
minor (0.25.0 → 0.25.0) |
only fix: / perf: |
patch (0.25.0 → 0.24.1) |
only chore:, docs:, ci: … |
patch |
| subjects that are not conventional at all | counted, but they drive nothing |
Cutting 1.0.0 is deliberate, never automatic: on a 0.x line a breaking change moves the
minor. Run scripts/next-version.sh --bump major --allow-zero-major locally, commit the
bump, and release it with an explicit tag.
An automatic release runs from main only, and says so if you point it elsewhere. It
pushes its bump to the branch it ran from, while the Homebrew sync always targets main;
from another branch those two would land in different places. Release another branch with
an explicit tag, where the bump is already committed.
Give a tag — release a version that is already committed. For re-running a release
that published its tag and then died: the job notices the tag exists and builds from that
tag, not from whatever main has become since, so the assets still match the commit the
tag names. check-version.sh --tag still fails the run when that tag disagrees with
version.go, the Makefile or the Formula. A tag that does not exist yet is created on the
branch you selected. (Moving a tag that already published is the rollback in step 6, not
this.)
The changelog an automatic release writes is a dump of commit subjects. That is fine for a patch; for anything bigger, write the
## vX.Y.Zentry indocs/changelog.mdby hand first and commit it —prepare-release.shkeeps an entry that is already there rather than shadowing it.
.github/workflows/release.yml, in order. Roughly 30–45 minutes.
| # | Step | Fails the release if |
|---|---|---|
| 1 | Validate the tag shape | the tag is not vX.Y.Z |
| 2 | scripts/check-version.sh --tag |
the tag disagrees with version.go / Makefile / the formula, or a formula sha256 is labelled for a different release than the formula declares |
| 3 | scripts/check-repo-refs.sh |
a broken repo slug reached a download URL |
| 4 | make web-deps |
npm cannot install by either route |
| 5 | Install golangci-lint v2.13.1 (must be built with Go >= go.mod's toolchain) |
— (without this, scripts/lint.sh silently skips linting) |
| 6 | make ui-react |
the Vite build fails |
| 7 | Strip *.map from cmd/slmcode/ui/ |
— (keeps the TSX source out of the binaries) |
| 8 | make check |
gofmt, vet, lint, coverage floor, race or web build fails |
| 9 | Verify the real Studio is embedded | cmd/slmcode/ui/ has no index.html + assets/*.js, or a sourcemap survived |
| 10 | Cross-compile six binaries | any target fails |
| 11 | sha256sum → SHA256SUMS |
— |
| 12 | Smoke-test linux_amd64 |
version --json reports the wrong version, an unstamped commit/build time, or a leaked SourceRoot |
| 13 | Create the GitHub Release | the upload fails |
| 14 | scripts/update-formula.sh in a fresh clone → push to main |
a placeholder sha256 survives, a checksum lands without its # vX.Y.Z label, or the version line is wrong |
| 15 | Re-download every asset and sha256sum -c |
what GitHub serves differs from what was built |
Steps 6, 9 and 12 are the ones added because the old workflow could publish a binary that served "Studio not built" to every user without failing.
Published artifacts:
slmcode_0.25.0_darwin_arm64 slmcode_0.25.0_windows_amd64.exe
slmcode_0.25.0_darwin_amd64 slmcode_0.25.0_windows_arm64.exe
slmcode_0.25.0_linux_arm64 install.sh install.ps1 install.cmd
slmcode_0.25.0_linux_amd64 SHA256SUMS
CI verifies the bytes. These are the things only a human on a real machine can check.
Checksums and the binary:
cd "$(mktemp -d)"
curl -fsSLO https://github.com/UnicoLab/smlcode/releases/download/v0.25.0/SHA256SUMS
curl -fsSLO https://github.com/UnicoLab/smlcode/releases/download/v0.25.0/slmcode_0.25.0_darwin_arm64
shasum -a 256 -c SHA256SUMS --ignore-missing # must say OK
chmod +x slmcode_0.25.0_darwin_arm64
./slmcode_0.25.0_darwin_arm64 version --json # version 0.20.0, real commit, real builtThe install one-liner, on a machine that has never had slmcode:
curl -fsSL https://raw.githubusercontent.com/UnicoLab/smlcode/main/scripts/install-remote.sh | bash
slmcode version # 0.20.0
slmcode doctorWatch for ✔ Checksum OK (sha256 …) in the output. If you instead see a ⚠ could not verify checksum warning, the release is missing SHA256SUMS — treat that as a failed
release, not a cosmetic issue.
Studio actually works (the whole point of steps 6/9 above):
cd "$(mktemp -d)" && slmcode init && slmcode studio
# open the printed URL — it carries ?t=<token>. You must get the real Studio UI.
# "Studio not built" means CI shipped a placeholder: pull the release (step 6 below).Homebrew — only after the chore: sync Homebrew formula checksums for v0.20.0
commit has landed on main (CI step 14). Before that, the formula carries all-zero
placeholder checksums and brew install will refuse; that is expected, not a break-in.
brew uninstall slmcode 2>/dev/null || true
brew install --formula https://raw.githubusercontent.com/UnicoLab/smlcode/main/Formula/slmcode.rb
slmcode version
brew test slmcode # runs `version`, `version --json`, `init`, `status`, and an unknown-command exit-2 checkWindows, in a fresh PowerShell:
irm https://raw.githubusercontent.com/UnicoLab/smlcode/main/scripts/install.ps1 | iex
slmcode versionConfirm -> Checksum OK (sha256 …) appears. This path had no checksum verification at all
before 0.20.0, so it is worth watching once.
Self-update from the previous release:
# on a machine still running 0.16.0
slmcode update --check # must report v0.20.0 is available
slmcode update --yes
slmcode version # 0.20.0- Confirm
mainhas thechore: sync Homebrew formula checksumscommit and that./scripts/check-version.shon a fresh pull no longer reports placeholder checksums. The foursha256lines should now readsha256 "<hex>" # vX.Y.Zwith X.Y.Z equal to the release you just cut — that label is whatcheck-version.shgates on, and the reason a rebase can no longer leave the previous release's digests under the new version line (which is exactly what happened between v0.18.3 and v0.20.0). - Confirm the docs site rebuilt (
.github/workflows/docs.yml) and that Install, Migration notes and Changelog render. - Announce the breaking behaviour changes, not the feature list. For 0.20.0 those are:
hooks fail closed, project
mcp_serversignored, the tiered shell allowlist,slmcode applyinteractive, HITL gates blocking when attended, the Studio session token, and the new.slmcode/memory+.slmcode/evolvedirectories.
Before the tag is pushed — nothing has happened:
git tag -d v0.20.0
git reset --hard origin/mainAfter the tag is pushed but CI failed — no release exists. If the tree is fine and the run died on something transient (a registry timeout, a runner hiccup), just start it again from Actions → Release → Run workflow with the same tag; it rebuilds from that tag. If the tree itself is at fault, fix and re-tag:
git push --delete origin v0.20.0
git tag -d v0.20.0
# fix, commit, then repeat from step 1After the release published and something is wrong — do not delete and re-upload the same tag. People and caches already have those bytes, and a tag that means two different things is worse than a bad release.
- Mark the GitHub release as a pre-release so
releases/lateststops resolving to it. That immediately stopsslmcode update, both install one-liners and the version notice from offering it, because all four read/releases/latest. - Revert the Homebrew formula on
mainto the previous version and its real checksums:Users ongit revert <sha-of-the-formula-sync-commit> git push origin main
brew install --formula <raw url>followmain, so this is the fastest lever. - Cut v0.17.1 from a fixed tree using this document from step 0. A forward fix is the only rollback that reaches everyone.
- If the release is actively harmful (a broken binary, a leaked secret), delete the assets from the GitHub release — keep the tag and the release page, with a note saying what happened and which version to use instead.
| File | Role |
|---|---|
cmd/slmcode/version.go |
the version compiled in when no -ldflags are given |
Makefile (VERSION ?=) |
what local builds stamp |
Formula/slmcode.rb |
Homebrew version + the four checksums CI syncs, each labelled # vX.Y.Z with the release it was computed for |
docs/changelog.md |
the release entry, with the breaking-changes table |
docs/migration.md |
the per-change detail the changelog links to |
scripts/prepare-release.sh |
bump + gate + commit + tag |
scripts/check-version.sh |
the drift guard (also runs in CI and in the release workflow) |
scripts/check-repo-refs.sh |
the repo-slug guard |
scripts/update-formula.sh |
post-release checksum sync, run by CI |
.github/workflows/release.yml |
everything after the tag is pushed |