<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 repo. For how the image
itself is built, see 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; bumpingapidoes not force a release ofvault. 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 fromorigin/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 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).
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:
--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.
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 viadocker manifest inspect). - A GitHub release at
releases/tag/<service>/v<x>.<y>.<z>.
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.
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.
-
Preview the plan. See what the tool would tag without touching origin.
It prints the next version (e.g.
api/v1.3.0-rc.1) and the commits and PRs merged since the lastapirelease. -
Cut the release candidate. Drop
--dry-runand confirm at the prompt.The tag is pushed and the tool prints thedepot ci run listcommand. -
Wait for CI and confirm artifacts. Watch the run, then check the image
at
ghcr.io/unkeyed/api:v1.3.0-rc.1and the GitHub pre-release exist (see step 2 above). -
Canary the RC in infra. Pin
apitov1.3.0-rc.1and roll it out per the infra deploy guide. Watch metrics until you trust it. -
Cut the stable tag. Same commit, now without
--rc. The bump matches the RC so you getapi/v1.3.0. -
Wait for CI, then promote. Confirm
ghcr.io/unkeyed/api:v1.3.0and the GitHub release exist, then update the production promotion pin in infra.
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.
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:

