> ## Documentation Index
> Fetch the complete documentation index at: https://engineering.unkey.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Releases

> How Unkey ships service images from a git tag to production

Releases are tag-driven. Pushing a `<service>/vx.y.z` tag from `main` causes
Depot CI to build the image, push it to GHCR, and cut a GitHub release.
Promotion through canary into production then happens in the
[`unkeyed/infra`](https://github.com/unkeyed/infra) repo. For how the image
itself is built, see [Builds](./builds).

## Design choices worth knowing

* **One image per service, not a monolith.** Each service gets its own
  GHCR repository (`ghcr.io/unkeyed/<service>`) and its own tag namespace
  (`<service>/vx.y.z`). Services release independently; bumping `api` does
  not force a release of `vault`. Helm charts and promotion pins are also
  per-service for the same reason.
* **Stable tags must be on `main`.** Stable releases ship code that has
  already passed CI. The release workflow refuses to publish a stable tag
  whose commit is not reachable from `origin/main`, so a feature branch can
  never become a stable release. Pre-release tags (e.g. `-rc.1`) are exempt
  and may be cut from any branch so they can be canaried before merging.
* **The release workflow does not re-test.** Tests ran when the code merged
  to `main`. The release job's only responsibilities are build, push, and
  cut a GitHub release.
* **Tags are immutable.** Once published, a tag (and its image) sticks
  around as historical record. Bad releases are addressed by forward-fix
  tags, never by deleting or re-pushing the broken one.

## Releasing a service

The unkey side is two steps: tag the service, then wait for CI to publish
the image. The infra side (canary rollout, production promotion, rollback)
lives in the
[infra deploy guide](https://github.com/unkeyed/infra/blob/main/README.md#deploying-unkey-api)
and picks up from there.

### 1. Tag the service with `mise run release`

`mise run release` is the supported way to cut a release. Everything after the
`--` is passed to the tool. It fetches `origin/main` and all tags first, so the
version it picks and the "already exists" check reflect origin rather than your
local state. It then prints the plan and the commits and PRs merged since the
last release, asks for confirmation, and pushes one tag at a time (GitHub drops
tag-push events when more than three land at once).

```bash theme={"theme":"kanagawa-wave"}
# preview without tagging anything
mise run release -- --dry-run api

# patch-bump api and frontline from their latest tags, confirm, push
mise run release -- api frontline
```

Supported services are `api`, `frontline`, `vault`, `heimdall`, `krane`,
`control-api`, `control-worker`, and `cli`. The version is auto-numbered from
the service's existing tags (patch bump by default). For a service's very
first release there is nothing to number from, so pass an explicit version
(e.g. `api@v0.1.0`).

Common variations:

```bash theme={"theme":"kanagawa-wave"}
mise run release -- --bump minor api               # minor bump instead of patch
mise run release -- --rc api                        # next release candidate (-rc.N)
mise run release -- --version v1.2.3 api vault      # pin an exact version for both
mise run release -- api@v1.2.3 vault@v0.4.0-rc.1    # per-service explicit versions
```

`--version` pins an exact version and cannot be combined with
`--bump`/`--rc`/`--pre`. `--yes` skips the confirmation prompt, `--no-log`
hides the changelog, and `--no-fetch` skips the origin fetch for offline use.

#### Pre-release tags

`--rc` (shorthand for `--pre rc`) cuts the next release candidate; `--pre <label>` does the same for any SemVer pre-release label (`beta`, `alpha`, ...).
Pre-releases build and push the image the same way but produce a GitHub release
marked as a pre-release, so it does not show up as "Latest". Use them when you
want a real, immutable artifact to canary in infra without claiming it as a
stable version. Unlike stable tags, a pre-release may be cut from any branch,
so you can canary a feature branch before merging it to `main`. Tags are
immutable, so iterate by bumping the suffix (`-rc.1` -> `-rc.2`); the tool does
this automatically on each run.

```bash theme={"theme":"kanagawa-wave"}
mise run release -- --rc api     # api/v1.2.3-rc.1, then -rc.2 on the next run
```

### 2. Wait for CI

`depot ci run list --repo unkeyed/unkey --trigger push --status running`
shows the in-flight workflow. When it finishes, confirm both artifacts exist
before moving on:

* The image at `ghcr.io/unkeyed/<service>:v<x>.<y>.<z>` (visible in the
  GitHub Packages tab, or via `docker manifest inspect`).
* A GitHub release at `releases/tag/<service>/v<x>.<y>.<z>`.

If the workflow fails, fix the underlying issue on `main` and cut a new
patch tag. Do not delete and re-push the failed one.

## After CI: deploy in `unkeyed/infra`

Follow the
[infra deploy guide](https://github.com/unkeyed/infra/blob/main/README.md#deploying-unkey-api).
Two things to be aware of for per-service releases: helm values live at
`eks-cluster/helm-chart/<service>/values.yaml` with
`image.repository: ghcr.io/unkeyed/<service>`, and each service has its own
production promotion pin under
`eks-cluster/promotions/production001/<service>.yaml`.

## Walkthrough: shipping a new `api` minor

A full run from code on `main` to production, canarying a release candidate
first. Substitute your own service and version.

1. **Preview the plan.** See what the tool would tag without touching origin.

   ```bash theme={"theme":"kanagawa-wave"}
   mise run release -- --dry-run --bump minor --rc api
   ```

   It prints the next version (e.g. `api/v1.3.0-rc.1`) and the commits and PRs
   merged since the last `api` release.

2. **Cut the release candidate.** Drop `--dry-run` and confirm at the prompt.

   ```bash theme={"theme":"kanagawa-wave"}
   mise run release -- --bump minor --rc api
   ```

   The tag is pushed and the tool prints the `depot ci run list` command.

3. **Wait for CI and confirm artifacts.** Watch the run, then check the image
   at `ghcr.io/unkeyed/api:v1.3.0-rc.1` and the GitHub pre-release exist
   (see [step 2 above](#2-wait-for-ci)).

4. **Canary the RC in infra.** Pin `api` to `v1.3.0-rc.1` and roll it out per
   the [infra deploy guide](https://github.com/unkeyed/infra/blob/main/README.md#deploying-unkey-api).
   Watch metrics until you trust it.

5. **Cut the stable tag.** Same commit, now without `--rc`. The bump matches
   the RC so you get `api/v1.3.0`.

   ```bash theme={"theme":"kanagawa-wave"}
   mise run release -- --bump minor api
   ```

6. **Wait for CI, then promote.** Confirm `ghcr.io/unkeyed/api:v1.3.0` and the
   GitHub release exist, then update the production promotion pin in infra.

If anything looks wrong, never delete or re-push a tag. Fix forward on `main`
and cut the next patch or RC.

## Tagging by hand

`mise run release` is preferred, but the tags it pushes are ordinary git tags,
so you can create them directly when the tool is unavailable (for example from
a machine without the Go toolchain). Sync `main` first, and remember that
stable tags must be on `main` and that CI drops tag-push events when more than
three are pushed at once, so push one at a time.

```bash theme={"theme":"kanagawa-wave"}
git fetch origin main --tags
git checkout main
git pull --ff-only
git tag <service>/v<x>.<y>.<z>
git push origin <service>/v<x>.<y>.<z>
```

## CLI releases

The CLI ships through a separate path because it is an npm package, not a
container image. `cli/vx.y.z` tags trigger `.github/workflows/release.yaml`,
which uses GoReleaser to build the CLI binaries and publish to npm. The
GoReleaser config is scoped to the `cli/` tag prefix, so a service tag
never produces CLI artifacts and vice versa.

Tag it the same way as a service; only the downstream CI differs:

```bash theme={"theme":"kanagawa-wave"}
mise run release -- cli              # auto patch-bump from the latest cli tag
mise run release -- cli@v1.2.3       # or pin an explicit version
```
