Stella ships prebuilt binaries, a Homebrew formula, and a curl | sh
installer. Everything is driven by pushing a version tag — the
Release workflow does the rest.
The workflow is a hand-rolled build matrix (it does not require cargo-dist
on the runner). The [workspace.metadata.dist] block in the root Cargo.toml
is retained for a possible future migration to
dist but is not the active
pipeline today; the source of truth is .github/workflows/release.yml.
auto-tag.yml runs after ci goes green on
main and does everything — no manual steps:
- Tags a release commit: one commit on top of the CI-validated merge
commit carrying the version stamp (
scripts/sync-versions.sh), reachable only through the tag — so the tagged tree reports its own version and any from-tag source build is correct with no build-time rewriting (#786).+1 patchby default; start the PR title withrelease:minor/release:majorfor bigger bumps, or include[skip release]to land without releasing. - Dispatches
release.ymlat the tag (binaries, GitHub Release, tap formula — the workflow verifies the tagged manifest matches the tag and builds--locked). - Writes the version back to main so the Cargo manifests stay in sync
with the newest tag: a
bot/version-syncPR bumps[workspace.package].versioninCargo.toml(every crate inherits it), the workspace-member entries inCargo.lock, andpackaging/homebrew/stella.rb, then merges once the required checks pass. Nobody has to touch it. That PR is written bygithub-actions[bot], and this repository makes a first-time contributor's workflow runs wait for a person to approve them, so all thirteen of its runs open parked ataction_required. The workflow approves them itself and then waits for the checks, which is what lets the PR merge under branch protection rather than past it. Its commit carries[skip release], so the sync itself never cuts a release. If a sync PR is ever left open (red check, race with another merge), the next release supersedes it automatically — no cleanup needed. Both the stamp and the merge are checked againstscripts/check-lockfile-sync.sh: the script refuses to author a stamp whoseCargo.lockdoes not resolve, and the workflow refuses to merge a sync branch that would leave main's lock unresolvable even though the branch's own checks were green (#3336). A refusal is the "left open" case above and heals on the next release — but "the next release" is a commit landing onmain, not a guarantee, so a refusal on the last merge before a quiet period opens (or updates) one issue labelledversion-writeback-deferrednaming the branch, the version, and the lockfile reason, and closes it automatically the run the write-back finally merges (#3842). The merge itself is made with the token the workflow run holds, and GitHub starts no workflow for a push made with that token — so the sync commit lands onmainwith nocirun and nomain-canaryrun of its own. The last step asks for them:scripts/dispatch-main-verification.shcounts theciruns for main's tip and starts both workflows when there are none. On a minor or major release the same PR also rollsCHANGELOG.md: whatever sits under## [Unreleased]moves beneath a new version heading, and[Unreleased]is left empty for the next line. A patch release does not touch that file — see below.
Manual version bumps are therefore only needed for the hand-cut flows below.
CHANGELOG.md— the durable curated record, one section per minor line, and CI writes it, not PRs. When a minor or major release is cut,auto-tag.ymldrafts the section from the whole series diff — the previousX.Y.0tag to this one (scripts/changelog-ai.sh, the same AI Gateway key and model as the release notes) — andscripts/changelog-roll.shinjects it at both roll sites (the tagged release commit and the version-sync PR), so the tag and main record identical text. The draft replaces whatever sits under[Unreleased]: contributors and coding agents should leave that section alone rather than hand-write entries in a PR, because a split between hand-written and drafted entries is what used to make this file inconsistent.- GitHub Release notes — generated at publish time by
release.ymlfrom the commit range, for every tag including patches. Release-note prose, not a curated record, and not something to edit by hand.
A patch release records nothing in CHANGELOG.md. This is the important
half of the split, and it is enforced in changelog-roll.sh rather than trusted
to a caller. Every merge to main cuts a patch, so the roll used to fire ~130
times per minor line; changelog-ai.sh degrades open by contract (no API key,
no non-bot commits, or an unparseable response all print nothing and exit 0),
but the roll ran regardless and stamped a bare ## [0.6.x] — <date> heading
with nothing under it. The file reached 180 sections of which 77 were empty. It
was structurally guaranteed to fill with noise no matter how good the drafter
got, so the roll stopped firing on patches — where the per-tag detail already
had a home in the GitHub Release notes above.
The roll is deliberately non-fatal, and can no longer produce an empty or a duplicate section:
- A missing
CHANGELOG.md, or one with no## [Unreleased]heading, logs a warning and continues. A bookkeeping slip must never be the reason a release fails to ship. - If the draft fails on a minor release and
[Unreleased]is empty, the roll writes a pointer to the releases page rather than a heading with an empty body — the failure mode above, which would otherwise recur once per line. - It is idempotent. If the version already has a section, the roll leaves the file alone. That matters because it runs at two call sites per release, and because a maintainer may have written the section by hand in the release PR — a minor release is a considered event, and "CI writes this file" exists to stop per-PR bullets accumulating in inconsistent voices, not to overwrite a section someone sat down and wrote. Whoever got there first wins.
make changelog-roll-test (hermetic, not part of make gate) pins both rules.
The tag-triggered workflow publishes the Homebrew formula to a tap repo. This has to exist and be writable before the first release:
-
Create the tap repo. A public repo named exactly
macanderson/homebrew-tap(Homebrew maps the tapmacanderson/tap→ repohomebrew-tap). It can start empty; the release job commitsFormula/stella.rbinto it. -
Create write access, either way (the release job tries the deploy key first, falling back to the token — see
.github/workflows/release.yml'shomebrewjob):- SSH deploy key (what's actually configured today) — generate a
dedicated keypair, add the public half as a write-enabled deploy key
on
macanderson/homebrew-tap(repo Settings → Deploy keys), and the private half as theHOMEBREW_TAP_DEPLOY_KEYsecret below. Scoped to exactly that one repo, unlike a PAT. - PAT (fallback) — a GitHub token with contents: write on the tap
repo — a fine-grained PAT scoped to
macanderson/homebrew-tap, or a classic PAT withrepo. The defaultGITHUB_TOKENcan't push to another repo, so a dedicated one is required either way.
- SSH deploy key (what's actually configured today) — generate a
dedicated keypair, add the public half as a write-enabled deploy key
on
-
Add it as a secret on this repo (
macanderson/stella): Settings → Secrets and variables → Actions → New repository secret → nameHOMEBREW_TAP_DEPLOY_KEY(deploy key) orHOMEBREW_TAP_TOKEN(PAT).
The prebuilt tarballs, checksums, and the curl | sh installer are published
to this repo's GitHub Releases and need no extra secrets — only the Homebrew
tap push does. If neither secret is set, the release still succeeds and
the homebrew job skips with a warning.
Optional — RELEASE_ADMIN_TOKEN. The bot/version-sync PR merges on its
own with no secret set, so this is not required. auto-tag.yml uses the token
in two places, both as a second try after GITHUB_TOKEN:
- Approving the PR's parked workflow runs. If
GITHUB_TOKENcannot approve them, the runs stay parked, no required check reports, and the PR waits for a person. The run log names which token did the approving. - Merging past branch protection. This no longer works on
main, becauseenforce_adminsis on and GitHub refuses an admin merge on the same grounds it refuses any other (#5589). It is kept for a repository that still grants a bypass.
To set it, add a classic PAT with repo scope, owned by an account with
admin rights on this repository, as the RELEASE_ADMIN_TOKEN secret.
-
Re-cut the demo video if the command deck moved —
make record-demo-video, then watch it and commitdocs/demo/. The film is a picture of the deck (doc:deck-film), so it goes stale exactly when the deck changes: a tab added, a panel moved, a status field renamed. Shipping a demo of a UI the download no longer has is a claim this project does not make. It needs no API key and takes about fifteen minutes. Watch it: the shot list frames rows the deck's own layout decides, so a layout change can move content out of shot without failing any check. -
Bump the version if needed —
versionin[workspace.package]of the rootCargo.toml(all crates inherit it) — and commit. -
Tag and push. The tag must be
v<major>.<minor>.<patch>matching the workspace version:git tag v0.1.0 git push origin v0.1.0
That push starts the Release workflow, which:
- builds
stellafor macOS (aarch64,x86_64) and Linux (aarch64,x86_64), - packs each as a
stella-<version>-<target>.tar.gzwith the licenses + README, - rebuilds
x86_64-unknown-linux-gnuon a second runner and refuses to publish if the two binaries differ byte for byte (verify-reproducible), - creates a GitHub Release with those tarballs, a
SHA256SUMS(tarballs) and aSHA256SUMS.bin(bare binaries), - renders
Formula/stella.rbfrom.github/homebrew/stella.rb.tmpl(real version + per-target SHA-256 sums) and commits it to the Homebrew tap (skipped ifHOMEBREW_TAP_TOKENis not configured).
A failed release used to be silent. release.yml failed on 31 consecutive
tags (v0.6.75 → v0.6.108, about two days) and nothing said so: ci on main was
green, auto-tag succeeded — it tags, dispatches, and exits without looking at
the outcome — the version-sync PR opened and auto-merged, CHANGELOG.md rolled,
and every manifest was stamped. Only the last two jobs of release.yml skipped.
Every surface a maintainer glances at said "released" (#1464).
Three checks now catch it, each on a different failure:
smoke(inrelease.yml) unpacks the artifact and runs it before anything is published, so a release that builds but does not work cannot reach the Homebrew tap (#1626).release-reconcile.yml'sreconcilejob runs hourly and comparesgit tag -l 'v*'againstgh release list, failing when a tag older than 90 minutes has no release. That window is deliberate: full-LTO plus the independent rebuild arm takes the better part of an hour, so "not published yet" is the normal state for a long time after every merge.release-reconcile.yml'stapjob asks the same question one hop later: doesbrew install macanderson/tap/stellaactually serve the newest release? A release can publish perfectly and never reach the tap — thehomebrewjob warns and exits 0 when neither tap credential is set, so an expired deploy key or a revoked token freezes the formula while every other surface still reports success. Before this job, nothing anywhere could answer that question — which is its own hazard: a report that the tap is stale could be neither confirmed nor refuted without reading the tap by hand. It only ever fetches the newest 100 releases, so a tap stale enough to be behind every one of them is reported as "at least N releases behind" rather than a guessed exact count.
Run them yourself at any time:
make releases-published # every tag has a release
make tap-current # the tap serves the newest oneIt compares state rather than watching the dispatched run, which catches
strictly more — a dispatch that never started, a run killed by a runner outage,
and a release deleted after the fact are all invisible to a watcher. It also
deliberately does not read workflow conclusions: the release/homebrew
jobs are gated on a tag ref, so "skipped" is the correct state for a
workflow_dispatch on a branch and reading conclusions would report those as
failures.
scripts/unpublished-tags-baseline.txt grandfathers the tags that shipped
nothing and are not going to be republished (#1463), so the check is an alarm
about new silence rather than a daily recital of a backlog. Adding to it is
a decision — that tag will never have a release — and lands as a reviewable
diff:
make releases-baseline-update # then read the diffWrite the reason above the tag as a comment line. Regeneration keeps those notes, and the reason is what a reviewer needs to judge the entry.
make releases-published names the state of every reported tag, because the
work each one needs is different. Four orphan tags once turned out to be three
different failures:
| State | What happened | What to do |
|---|---|---|
draft |
The build ran and the assets are attached, but a 5xx killed the publish step before the release left draft. |
List the draft's assets. Publish it if the set is complete; delete it and re-run the tag if it is not. |
absent |
No release object exists — a build job died, often in actions/upload-artifact. |
Re-run release.yml on the tag, but read the version guard below first. |
| gone | The build finished, and the run itself was killed after the fact. Build artifacts expire after 7 days, so an old one has nothing left to publish. | Grandfather the tag with a note. Rebuilding a version a hundred releases behind buys nothing. |
Find a draft and see what it holds:
gh api repos/{owner}/{repo}/releases \
--jq '.[] | select(.draft) | {id, tag_name, assets: [.assets[].name]}'A complete set is four tarballs plus SHA256SUMS and SHA256SUMS.bin.
install.sh and the Homebrew formula both read SHA256SUMS, so a draft
missing it is not publishable — delete it (gh api -X DELETE repos/{owner}/{repo}/releases/<id>) rather than shipping a release whose
checksum file is absent.
Re-running an old tag runs the workflow as it stood at that tag. That
matters most for the homebrew job, which renders Formula/stella.rb for
whatever version it is building. Today's job refuses to render a version lower
than the one the tap already serves, so a re-run cannot downgrade brew upgrade. A tag old enough to predate that guard has no such protection: check
what its own release.yml does before dispatching it.
Release builds are reproducible: the same tag, built on the pinned toolchain, produces the same bytes on your machine as on ours. That is what makes the published binary checkable against the published source rather than merely asserted to match it (#910).
git clone --depth 1 --branch v<version> https://github.com/macanderson/stella
cd stella
rustup show # must report the rust-toolchain.toml pin
./scripts/repro-build.sh x86_64-unknown-linux-gnu
curl -fsSLO https://github.com/macanderson/stella/releases/download/v<version>/SHA256SUMS.bin
grep "stella-<version>-x86_64-unknown-linux-gnu" SHA256SUMS.binThe last line of repro-build.sh and the matching line of SHA256SUMS.bin must
be identical. Before you file a mismatch:
- Use
scripts/repro-build.sh, notcargo build --release. The remapping that removes your$CARGO_HOMEand rustup sysroot from the binary lives in that script and nowhere else — a barecargo buildbakes your home directory into the bytes and will never match. - Compare against
SHA256SUMS.bin, notSHA256SUMS. The latter hashes the tarballs; rebuilding gives you a binary, not our archive of it. (The tarballs are reproducible too —scripts/package-tarball.pyzeroes the mtimes, owners and gzip timestamp — but only when packed the same way.) - The toolchain pin is not advisory.
repro-build.shrefuses to build on anything but the version inrust-toolchain.toml, because two rustc releases inline differentstdand no amount of path remapping makes their output agree.
cargo install --git (install.sh's source fallback) is a different build path
with no reproducibility promise — it does not go through this script.
This path ships unattested artifacts. Prefer the tag-driven CI release above whenever Actions can run at all.
release.ymlattests every tarball and theSHA256SUMSfile withactions/attest-build-provenance— a Sigstore bundle bound to that workflow at that commit, which nobody holding the release can reissue. A local release cannot produce one: attestation needs the OIDC token only a GitHub Actions job can mint.The downgrade is invisible to users.
install.shdeliberately separates "no verifier available" from "verifier said no", and an unattested release lands in the first bucket —gh attestation verifyreports no attestation, the installer logs one info line and installs anyway. Anyone runningSTELLA_REQUIRE_PROVENANCE=1will instead be refused outright.The bytes differ too: the Linux tarballs here are
cargo-zigbuildcross compiles against glibc 2.17, whererelease.ymlbuilds them natively. Same tag, different artifacts, depending on who cut it.So the script refuses by default and requires the decision to be explicit.
When GitHub Actions is unavailable (e.g. an org billing hold) and a release
still has to ship, scripts/release.sh does the entire
pipeline from your Mac — build all four targets, publish the GitHub Release, and
push the Homebrew formula — with the version auto-incremented:
git checkout main && git pull # release exactly what's on origin/main
ALLOW_UNATTESTED=1 scripts/release.sh patch # 0.1.15 -> 0.1.16 (also: minor, major)Without ALLOW_UNATTESTED=1 the script stops before touching anything. When you
do use it, say so in the release notes, so users understand why
STELLA_REQUIRE_PROVENANCE=1 rejects that version.
It refuses to run unless your checkout is clean and matches origin/main, and
it never leaves your tree modified. macOS targets build natively; the Linux
targets cross-compile via cargo-zigbuild
(Zig as the C/C++ cross-linker — no Docker, and it compiles the workspace's
bundled C deps (SQLite, tree-sitter grammars) cleanly, unlike the old cross
image). zig and cargo-zigbuild are
auto-installed if missing. All release assets are uploaded in one call, which
matters because this repo has immutable releases enabled — a published
release's assets can't be added or changed afterward, so an incomplete release
means cutting a new version.
Every release tag also publishes packaging/docker/Dockerfile.serve as
ghcr.io/macanderson/stella-serve:<version> and :latest, for linux/amd64
and linux/arm64 (.github/workflows/docker-serve-publish.yml). Each
architecture is built on its own runner, smoked with
scripts/smoke-serve-image.sh before its digest is pushed, and the two
digests are tagged together at the end, so a tag never resolves on one
architecture and not the other. A host that embeds the engine pins the
version tag; latest is for a laptop. The image takes STELLA_SERVE_TOKEN
or STELLA_SERVE_TOKEN_FILE at run time and nothing else.
Homebrew (prebuilt binary, no Rust toolchain):
brew install macanderson/tap/stella
# equivalently: brew tap macanderson/tap && brew install stellaShell installer (macOS/Linux, no Homebrew):
curl -fsSL https://raw.githubusercontent.com/macanderson/stella/main/install.sh | shThe installer detects the platform, downloads the matching tarball from the
GitHub Release, verifies it against SHA256SUMS, and falls back to
cargo install where no prebuilt binary matches. Upgrades are
brew upgrade stella or re-running the installer.
brew reporting an old version is usually a stale local clone, not a stale
tap. A tap is a git clone on the machine, and brew info reads whatever
commit that clone is sitting on — it does not fetch. Run brew update first,
then compare against make tap-current, which reads the tap over the API and
answers whether the published formula is behind. A clone left unfetched for a
few days can be dozens of releases behind a tap that is perfectly current.
Add the tap as macanderson/tap and nothing else. GitHub still redirects
the repo's former name, so brew tap macanderson/stella <url> succeeds and
leaves the machine with two taps for one repo. They fetch independently, so one
goes stale while the other is current, and brew info then answers differently
depending on which name was asked. brew untap macanderson/stella retires the
duplicate; stella self-driving upgrade does it too.
Two formulas live in the repo, for two different purposes:
.github/homebrew/stella.rb.tmpl— the template the release workflow renders and pushes to the tap asFormula/stella.rb. Installs the prebuilt binary per platform. Edit this to change what lands in the tap.packaging/homebrew/stella.rb— a build-from-source formula for local use (brew install --build-from-source ./packaging/homebrew/stella.rb); it compiles with cargo and needs no per-release sha maintenance.