# Release Process — IO Harness
This is the same across every [initorigin](https://github.com/initorigin) repo so
releases look and work the same everywhere.
## Branches
- **`develop`** — integration branch. All feature work merges here first.
- **`feat/<version>`** — work branch for a version (for example `feat/0.1.0`),
cut from `develop`, merged back into `develop` by PR.
- **`main`** — released versions only. Nothing lands here except a
`develop` → `main` release PR.
## Versioning
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`.
Pre-1.0.0, minor versions may include breaking changes; they are always noted in
the CHANGELOG. The public surface governed by this is defined in
[CONTRACT.md](CONTRACT.md).
## Steps to cut a release
1. On `develop`, confirm the work for the version is complete and the
`## [Unreleased]` section of [CHANGELOG.md](../CHANGELOG.md) is accurate.
2. Rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a fresh
`## [Unreleased]` block above it, following
[CHANGELOG_STRUCTURE.md](CHANGELOG_STRUCTURE.md).
3. Open a **`develop` → `main`** pull request titled `release: vX.Y.Z`.
4. After review and merge, tag `main` as `vX.Y.Z`.
5. Create the **GitHub Release** for `vX.Y.Z`. Its notes are taken **verbatim
from the `## [X.Y.Z]` section of the CHANGELOG** — do not rewrite them.
6. Publish to crates.io. This is **last**, after both merges and the Release
exist, because it is the one step that cannot be undone.
7. Record the release in the UltraShip release record for this product.
8. Sync `develop` with `main` (`git merge --ff-only main`), so the two do not
diverge.
## Steps 4–5, by workflow
[`.github/workflows/release.yml`](../.github/workflows/release.yml) does steps 4
and 5. There are two ways to start it, and they differ only in which one creates
the tag:
- **Push a `v*` tag.** The tag names the version and the Release is cut from the
commit it points at. `git tag vX.Y.Z && git push origin vX.Y.Z`.
- **Run it from the Actions tab** (or `gh workflow run release.yml --ref main -f
version=X.Y.Z`), giving the version with no leading `v`. The workflow creates
the tag itself, pinned to the commit that passed the gate.
Either way the same checks run first. It runs the gate CI runs — `fmt`,
`clippy --all-features`, the three test invocations, `cargo package --locked` —
against the exact commit being released, then cuts the Release with the CHANGELOG
section as its notes. It refuses to start when the commit is not on `main`, when
`Cargo.toml` declares a different version, when the Release already exists, or
when the CHANGELOG has no section to quote.
The "must be on `main`" check asks whether the *commit* is an ancestor of
`main`, not whether the ref is `main` — the same question, and the only form of
it that can be asked of a tag.
**Step 6 is not in the workflow and must not be added to it.** `cargo publish`
is run by hand, from a checkout of `main`, after the Release exists — always the
last thing that happens. It is the one step of a release that cannot be
recalled: a version can never be re-uploaded, and the only remedy is a forward
patch plus `cargo yank`. A workflow that *could* publish is a workflow that can
publish by mistake, so it holds no registry token and needs no environment.
The workflow changes what is typed, not what is decided: a human still decides
the version is ready and presses the button, and steps 1–3 and 6–8 stay by hand.
## Why the CHANGELOG drives release notes
One source, no drift: what contributors write during development becomes the
release notes with no re-authoring. That only works if CHANGELOG entries are
user-facing and complete, so treat every entry as release copy.