# Release and roll back

A release is a tag, an image built from it on the host that serves it, the image id that identifies
it, a smoke test against a host, and a rollback path. This page is written against `1.1.0`; every
release edits the tag and the id recorded below.

## What a release is

| Thing | Value |
| --- | --- |
| Version | `1.1.0` in `package.json` and the newest `CHANGELOG.md` heading |
| Git tag | `v1.1.0` on the release commit |
| Image | `suite:v1.1.0`, built on the host that runs it and also tagged `suite:local` |
| Identity | the image id from `docker image inspect --format '{{.Id}}' suite:v1.1.0` |
| Change record | the matching `CHANGELOG.md` section |
| Evidence | the `pnpm verify` run and the security scan output, quoted in the release notes |

`SUITE_VERSION` is baked into the image at build time, so a running container reports what it is:
`version` in `/api/admin/status`, `suite_build_info{version="…"} 1` in `/metrics`, and the
`server ready` line. The policy behind the scheme is ADR-0026 in
the internal decision log.

The lead creates the tag after integration. A workstream does not push one.

## Cut one

1. `package.json` and the newest `CHANGELOG.md` heading both say `1.1.0`.
2. `node scripts/docs-check.mjs` and `node scripts/generate-limits.mjs --check` are clean.
3. `pnpm verify` is green on the commit being tagged, and a full `pnpm e2e` passed on that same commit,
   in that order, with nothing already listening on `:5173` or `:1235`. A tag is the milestone where
   the full suite runs; the commands are in [test-and-verify.md](./test-and-verify.md).
4. `git tag -a v1.1.0 -m "Suite 1.1.0"`.
5. `git push origin v1.1.0`.

If workflows can start, `.github/workflows/release.yml` runs three jobs:

| Job | Does | Fails when |
| --- | --- | --- |
| `preflight` | Installs, compares `GITHUB_REF_NAME` with `package.json`, runs both docs checks | The tag and the version disagree, or a doc is stale |
| `image` | Builds the `runtime` target with `SUITE_VERSION=<tag>` and pushes `:<tag>`, `:sha-<12>` and `:latest` to GHCR | The build fails |
| `release` | Opens a GitHub release whose body is the changelog section plus the image digest and a rollback link | `CHANGELOG.md` has no section for the version |

Nothing on this page depends on either workflow file. Every workflow on this account still ends in
`startup_failure` with zero jobs, which is account state rather than workflow content, so the release
image is built on the host that serves it.

### Build the image on the host

The build argument is the tag, and the two `-t` names are what the host later rolls back to and runs:

```sh
git checkout v1.1.0
docker build --target runtime --build-arg SUITE_VERSION=v1.1.0 \
  -t suite:v1.1.0 -t suite:local .
docker image inspect --format '{{.Id}}' suite:v1.1.0
```

Record the image id in the release notes. A host-built image has no `RepoDigests`, so the id is the
identity every later rollback names, and `docker image ls` shows it next to the tag.
`deploy/deploy.sh` runs the same build with the tag at `HEAD`, plus the backup, the rollback tag and
the restart.

Do not tag unless the local gate is green on the exact commit. The tag is the only quality claim this
project can currently make.

## Smoke test the release

Against the host that will serve it, before announcing anything:

```sh
sed -i -E 's|^#?[[:space:]]*SUITE_IMAGE=.*|SUITE_IMAGE=suite:v1.1.0|' .env
docker compose up -d --no-build app                   # the new tag; Caddy keeps running
docker compose ps                                     # app healthy, caddy up
curl -fsS https://$SUITE_DOMAIN/health                # {"ok":true}
# The operator API answers a session, so this runs on the box: SUITE_METRICS_LOCAL=1 lets a
# loopback request read /metrics with no credential, and the version stamp lives there.
curl -fsS http://127.0.0.1:1235/metrics | grep suite_build_info
```

`/api/admin/status` must report `"version":"v1.1.0"` and `suite_build_info` must carry the same tag,
because `SUITE_VERSION` reaches the process verbatim. Then, by hand: open a workspace, upload an
attachment, reload, open the same workspace in a second tab, and confirm the published limits still
match what the server reports on a refusal ([reference/limits.md](../reference/limits.md)). A
container that reports the wrong version is not a release, however healthy `/health` looks.

## Rollback

Rollback restarts `app` on the tag taken before the upgrade. It needs no code change and no build, and
on a host build it is the only rollback there is: a build overwrites `suite:local`, so the pre-upgrade
tag is what names the previous image.

1. Find the tag. `deploy/deploy.sh --list` prints every `suite:rollback-*` on the host, and the release
   notes record the version each upgrade started from.
2. Point `app` at it and recreate that service only:

   ```sh
   deploy/deploy.sh --rollback suite:rollback-v1.1.0
   # by hand: SUITE_IMAGE=suite:rollback-v1.1.0 docker compose up -d --no-build app
   ```

3. Confirm `/health` answers and `/api/admin/status` reports the expected `version`.
4. If the release changed a stored format (a schema bump, a new store, a backup manifest change), the
   older binary may refuse the newer data. Stop `app`, restore the pre-upgrade backup with
   `deploy/restore.sh --force` ([operate.md](./operate.md)), then start the older image. An older binary
   against newer data is not a rollback.
5. Record the tag restored, its image id, the time, and whether a restore was needed, in the release
   notes.

`caddy` never moves during a rollback. It serves a static bundle from its own image and proxies to
`app`.

## Not in a release

A release omits image signing and verification on pull, SBOM generation and attestation,
multi-architecture images, self-hosted runners, and a staging environment or canary. There is one host
and one tag. Adding any of them changes `release.yml` and should land with an ADR rather than by habit.
