# Releasing llmenv
Releases are **tag-triggered**. Pushing a `v*` tag to GitHub fires
[`.github/workflows/release.yml`](.github/workflows/release.yml), which does all
the publishing work:
- builds the cross-platform binaries (Linux x86_64, macOS x86_64, macOS arm64)
with SHA-256 checksums and SLSA provenance,
- publishes the crate to [crates.io](https://crates.io/crates/llmenv)
(`publish-crate` job),
- creates the GitHub Release with the binaries attached,
- bumps the Homebrew formula in `phaedrus1992/homebrew-tap`.
[`cargo-release`](https://github.com/crate-ci/cargo-release) owns only the local
prep: bumping the version and rolling `CHANGELOG.md` into a single commit. It is
configured (in [`release.toml`](release.toml)) to **not** publish, tag, or push
— see [Why cargo-release does so little](#why-cargo-release-does-so-little).
## Branch strategy
Feature development happens on `main`. Each major.minor version gets a long-lived
`release/X.X.x` branch (created from the release tag) for managing bug fixes
without picking up new feature work.
**Backport policy** — fixes are applied (when feasible) to:
| `release/X.X.x` | Current major.minor — always patched |
| `release/X.(X-1).x` | Previous minor of the current major — always patched |
| `release/(X-1).Y.x` | Last minor branch of the previous major — always patched |
Fix in the **oldest applicable branch** first, then merge forward through the
chain to carry the fix (and its CHANGELOG entry) into newer branches
automatically. Only skip a backport when the fix does not apply to an older
branch — document the skip in the PR description.
**Example:** a fix that applies to 1.0, 1.1, and main:
1. Land fix on `release/1.0.x`
2. Merge `release/1.0.x` → `release/1.1.x`
3. Merge `release/1.1.x` → `main`
The CHANGELOG entry written on `release/1.0.x` propagates forward via the merges
— no cherry-picking or duplicate entries needed.
### Creating a release branch
After tagging a new major.minor (e.g. `v1.1.0`), branch immediately from that
tag so the branch starts at exactly what was released:
```bash
git checkout -b release/1.1.x v1.1.0
git push -u origin release/1.1.x
```
### Cutting a patch release from a release branch
```bash
git switch release/1.1.x && git pull
cargo release patch # dry-run preview
cargo release patch --execute # bump Cargo.toml + roll CHANGELOG + commit
git push -u origin HEAD
gh pr create --base release/1.1.x --fill
# After merge, tag the merged commit:
git switch release/1.1.x && git pull
git tag -a "v1.1.1" -m "v1.1.1"
git push origin "v1.1.1"
```
After the patch tag is pushed, merge forward into the next release branch (or
`main`) so the fix and its CHANGELOG entry propagate.
## One-time setup
```bash
cargo install cargo-release@1.1.2
```
Repo prerequisites (already in place, listed so they are not forgotten):
- **`CARGO_REGISTRY_TOKEN`** secret in the repo settings — the `publish-crate`
job reads it. Without it, the tag build fails at publish.
- **`HOMEBREW_TAP_TOKEN`** secret — used by the `update-homebrew` job.
- The crate name **`llmenv`** must be owned by the publishing account on
crates.io. The first publish claims it; confirm it is available beforehand.
## Cutting a release
`main` is protected (PR-only), so the version-bump commit lands through a PR and
the tag is cut on the merged commit.
### 1. Prepare the bump on a branch
```bash
git switch main && git pull
git switch -c release/<next-version>
```
`cargo release` rewrites `Cargo.toml`'s version, turns the `[Unreleased]`
CHANGELOG section into a dated `[<version>]` section, re-seeds a fresh
`[Unreleased]` + compare link, and makes one `chore(release): <version>` commit.
### 2. PR and merge
```bash
git push -u origin HEAD
gh pr create --fill
# ... review, then merge to main
```
### 3. Tag the merged commit
After the PR merges, tag the resulting `main` commit and push the tag. **This is
what triggers the release.**
```bash
git switch main && git pull
git tag -a "v<version>" -m "v<version>"
git push origin "v<version>"
```
### 4. Watch the release
```bash
gh run watch
```
CI publishes to crates.io, creates the GitHub Release, and updates Homebrew. The
crates.io publish runs exactly once per tag — re-pushing an existing tag will
fail at publish because that version already exists on the registry.
## Why cargo-release does so little
`cargo-release` is fully capable of tagging, pushing, and publishing. We disable
all three deliberately:
- **`publish = false`** — crates.io publishing is owned by the `publish-crate`
job in `release.yml`. If `cargo release` also published, every release would
attempt to publish twice.
- **`tag = false` / `push = false`** — `main` is protected, so the bump commit
must go through a PR. Tagging on the prep branch would point the tag at a
commit that is not on `main` after merge. Cutting the tag on the merged commit
(step 3) keeps the `v*` tag pointing at exactly what shipped.
If branch protection is ever lifted for the maintainer, `release.toml` can be
switched to `tag = true` / `push = true` for a single-command release.
## Security of the release trigger
The release is fired by the `v*` tag, and `release.yml` hands `CARGO_REGISTRY_TOKEN`
and `HOMEBREW_TAP_TOKEN` to whatever commit that tag points at. Two protections
keep an attacker from pushing a malicious tag straight to a publish:
- **`main` is branch-protected** so release content lands through review.
- **Add a tag protection rule for `v*`** (repo Settings → Tags) so only
maintainers can create release tags.
If either protection is ever lifted, **rotate both secrets** — a contributor who
can push a `v*` tag or an arbitrary `main` commit can otherwise publish under the
project's crates.io and Homebrew credentials.
## The 1.0.0 release
1.0.0 is already prepared on `main`: `Cargo.toml` is at `1.0.0` and `CHANGELOG.md`
has a dated `[1.0.0]` section. To publish it, run **step 3** above with
`v1.0.0` — no `cargo release` run is needed for this first release. Every release
after 1.0.0 uses the full flow.