# Building release images in CI

Design for publishing `chitai-backend` and `chitai-frontend` container images from a tagged
release, on Gitea Actions, to the Gitea package registry, for `linux/amd64`.

## What exists today

- `origin` is Gitea 1.27 at `git.jaroszew.ski`. The repo is **public**, with `has_actions` and
  `has_packages` both true. There is no `.gitea/` or `.github/` directory and no CI of any kind.
- There are **no tags** in the repository. `backend/pyproject.toml` says `0.1.0`,
  `frontend/package.json` says `0.0.1`; nothing reads either.
- `backend/Dockerfile` and `frontend/Dockerfile` are both multi-stage and both already build a
  runnable image. `docker-compose.yml` builds them from source with `build: ./backend` and
  `build: ./frontend` — there is no `image:` key, so there is nothing for a user to pull.

So the work is not "make the images build" — they build. It is "make an image built on one machine
correct on another", then automate producing one per tag.

## The blocker: the frontend image hardcodes the backend URL

This has to be fixed before publishing an image is meaningful.

`frontend/src/lib/server/config.ts` reads the backend URL through `import.meta.env`:

```ts
export const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```

Vite replaces `import.meta.env.VITE_*` **at build time**, including in the SSR bundle. The current
build output in `frontend/build/` shows exactly what that produces:

```js
// frontend/build/server/chunks/config-BvKh7uym.js
const BACKEND_API_URL = "http://localhost:8000";
```

`frontend/Dockerfile` sets no `VITE_BACKEND_API_URL` before `pnpm run build`, so the string baked
into any image built from it is `http://localhost:8000`. The `VITE_BACKEND_API_URL: ${CHITAI_API_URL}`
entry under the compose `frontend` service is therefore **dead** — it sets a process environment
variable that nothing reads, in a process whose value was decided at build time. Inside the
container `localhost:8000` is the frontend's own port, not the backend.

Two ways out, and only one of them is right for a published image:

- **Build arg.** `ARG VITE_BACKEND_API_URL` in the build stage. This works, but it makes the image
  specific to one deployment's topology — CI would bake `http://backend:8000` and anyone whose
  service is named differently gets an image that cannot be repointed. Wrong for a release artifact.
- **Runtime env, recommended.** Read it through SvelteKit's dynamic env, which is `process.env` at
  request time:

  ```ts
  import { env } from '$env/dynamic/private';
  export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';
  ```

  `$lib/server/config.ts` is server-only and its one consumer (`$lib/server/api.ts`) is too, so
  `$env/dynamic/private` is available everywhere it is used. The compose entry then starts working
  as written, and the same image serves any deployment.

  Worth renaming the variable to `CHITAI_API_URL` at the same time — the `VITE_` prefix now means
  the opposite of what it does — but that is a follow-up, not a prerequisite. If you do rename it,
  the fallback in `.env.prod-example` and the compose `environment:` block move with it.

Same class of problem, same fix window: `frontend/Dockerfile` sets `ENV ORIGIN=http://localhost:3000`.
That one *is* read at runtime by `adapter-node`, so it can be overridden — but nothing overrode it,
and adapter-node rejects cross-origin form POSTs when `ORIGIN` does not match the browser's, so
every deployment behind a real domain 403s on its first login. Now set as
`ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}` on the compose `frontend` service, with a
documented `CHITAI_ORIGIN` in `.env.prod-example`.

**Both are done.** The built module now reads `private_env.VITE_BACKEND_API_URL`, and the published
image was verified by running it with `VITE_BACKEND_API_URL` pointed at a throwaway listener: the
container's `GET /access/me` arrived there rather than at `localhost:8000`.

## Toolchain pinning

Two reproducibility gaps that a release pipeline turns from cosmetic into real, because CI builds
from a clean container every time and your laptop does not.

- **pnpm has no pin.** `frontend/package.json` has no `packageManager` field, so `corepack enable`
  followed by `pnpm install` resolves to whatever version corepack considers current on the day the
  build runs. `pnpm-lock.yaml` is `lockfileVersion: 9.0`, i.e. pnpm 9/10; a future pnpm 11 could
  refuse it, and `--frozen-lockfile` would fail a release for reasons unrelated to the release. Add
  `"packageManager": "pnpm@<version from your nix shell>"` and let corepack honour it.
- **`pnpm-workspace.yaml` is never copied into the image.** It carries `onlyBuiltDependencies`
  (`esbuild`, `@tailwindcss/oxide`), and pnpm 10 blocks postinstall scripts that are not listed
  there. Both install stages in `frontend/Dockerfile` copy only `pnpm-lock.yaml` and `package.json`,
  so the container install runs under different rules than the local one. Copy it alongside
  `package.json` in the `prod-deps` and `build` stages so the two agree.

Both are one-line changes and both belong before the first tag, not after. **Both are done** —
`packageManager` is pinned to the dev shell's `pnpm@11.20.0`, and `pnpm-workspace.yaml` is copied
into the `prod-deps` and `build` stages. `docker compose build` was re-run against the change.

## Prerequisites on the Gitea instance

Verify these before writing the workflow; each one fails the job in a way that looks like a bug in
the workflow.

1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
   runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building
   workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no
   daemon of its own. Either run a `docker:dind` sidecar next to the runner and set
   `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host
   mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket
   into job containers gives any workflow root on the runner host.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
   Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
   That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
   `docker/login-action@v3`, `docker/metadata-action@v5` and `docker/build-push-action@v6` are all
   mirrored on gitea.com at those tags (verified 2026-08-17). Worth knowing because it is where an
   action reference resolves from if that setting is ever changed; `DEFAULT_ACTIONS_URL = github`
   in `app.ini` is the fix if so. Writing full `https://` URLs in `uses:` also works on Gitea but
   is invalid syntax on GitHub Actions, so it would cost portability for no gain.
4. **A registry credential.** Gitea auto-injects `secrets.GITEA_TOKEN`, but whether it carries
   package-write scope has varied across versions. Try it first; if the push 401s, create a personal
   access token with `write:package` and store it as the repo secret `REGISTRY_TOKEN`. The workflow
   below reads `REGISTRY_TOKEN` with a fallback to the automatic token.
5. **Package visibility.** Gitea ties package visibility to the owner rather than offering a
   per-package toggle. The repo is public, so anonymous pulls should work — confirm with a
   `docker pull` from a logged-out machine after the first release, because the README will tell
   people to do exactly that.

## The workflow

Written as **`.gitea/workflows/release.yml`**, triggered by tags matching `v*`. Notes on the choices
made there:

- **`github.*` context, not `gitea.*`.** Both exist on Gitea; the third-party actions read the
  `GITHUB_*` environment anyway, and using it keeps the file portable if the repo is ever mirrored.
  `github.repository` is `patrick/chitai`, so the images are
  `git.jaroszew.ski/patrick/chitai-backend` and `…/chitai-frontend`.
- **Tags produced from `v1.2.3`:** `1.2.3`, `1.2`, `1`, and `latest`. `metadata-action`'s default
  `latest=auto` flavour adds `latest` only for a non-prerelease semver, so `v0.2.0-rc.1` publishes
  `0.2.0-rc.1` and leaves `latest` where it was. That is what makes release-candidate tags a safe
  way to exercise the pipeline.
- **`fail-fast: false`** so a frontend failure does not cancel a backend build that was going to
  succeed. The two images are independent artifacts; a half-published release is easier to reason
  about than a cancelled one.
- **`type=gha` cache** relies on `act_runner`'s built-in cache server exporting
  `ACTIONS_CACHE_URL` / `ACTIONS_RUNTIME_TOKEN`. If your runner has caching disabled, buildx warns
  and continues, or errors depending on version — just delete the two `cache-*` lines. The
  Dockerfiles' `--mount=type=cache` blocks do nothing across ephemeral runners either way, and a
  cold build of both images is a few minutes.

## Gating: formatting, linting, types and tests

`ci.yml` runs these on every push and pull request, and `release.yml`'s `quality` job runs the
blocking half again on the tagged commit, so a release cannot publish an image whose tests fail.

My first draft of this document argued for keeping all of it out of the release path, on the
grounds that everything was red. Measuring rather than trusting `TODO.md` showed that was mostly
wrong:

| Check | Before | After the sweep | Gate |
| --- | --- | --- | --- |
| `pytest tests/` | **411 passed** in 3 min | unchanged | blocking |
| `ruff format --check src/` | 27 of 66 files | clean | blocking |
| `ruff check src/` | 110 errors | clean | blocking |
| `prettier --check .` | 48 files | clean | blocking |
| `eslint .` | 1804 → **87 real** | 87 | non-blocking |
| `pnpm check` | 30 errors | 30 | non-blocking |

The test suite was green the whole time, so gating on it costs nothing. `ruff check`'s 110 were
104 unused imports, and **all 64 that survived `--fix` were in `__init__.py`** — deliberate
re-exports, so the fix is `per-file-ignores` in `pyproject.toml`, not deleting them. Of eslint's
1804, **1717 were the vendored pdf.js under `static/`**, which the config never ignored the way it
ignores `src/lib/vendor/`; adding it leaves 87 real ones.

Two things the sweep turned up that are worth remembering:

- **ruff's suggested fix for `E712` would have broken the query.** `services/filters/book.py` had
  `m.BookProgress.completed == True`, and ruff proposes `if m.BookProgress.completed:` — Python
  truthiness on a SQLAlchemy Column, which does not generate SQL at all. The correct idiom is
  `.is_(True)`, which the adjacent line already used. Never run `ruff check --fix --unsafe-fixes`
  over query-building code without reading every hunk.
- **Prettier reformatted the generated `schema.d.ts`**, which was 3109 of the sweep's 3946 changed
  lines. `openapi-typescript` writes its own style, so that file would have churned by thousands of
  lines on every regeneration — and then failed the very gate being added. It is in
  `.prettierignore` now.

`eslint` and `pnpm check` run with `continue-on-error: true`. That is an honest weak gate: it
reports without failing, so the counts stay visible and cannot grow silently unnoticed, but nobody
is blocked by 117 pre-existing problems they did not create. Drop the line from each step as its
count reaches zero.

The other thing worth gating on is that the images actually start, which is the failure mode a release
introduces and which nothing else catches. That is the `smoke` job in the same workflow: it copies
`.env.prod-example`, pins `CHITAI_VERSION` to the tag, `docker compose pull`s the images that were
just pushed and brings the stack up with `--wait`, then curls both healthchecks.

This is cheap and it covers the three things that break a release image: migrations failing to apply
from `entrypoint.sh`, the frontend being unable to reach the backend (the baked-URL bug above —
`--wait` fails because the frontend never goes healthy), and a missing runtime env var. It tests the
artifact that was pushed, not a rebuild of it.

`--wait` depends on healthchecks. `db` declares one inline in `docker-compose.yml`, and the backend
image carries a `HEALTHCHECK` in its Dockerfile which compose inherits. The frontend had neither, so
one was added to `frontend/Dockerfile` — in the image rather than in compose, matching the backend
and covering anyone running the image without compose. It probes `/login` with **node's global
`fetch`, not curl**, because `node:24-slim` ships no curl and adding one for a healthcheck is a
package and a CVE surface for nothing. It reads `PORT` so it keeps working if the port is
overridden.

Note the smoke job runs `cp .env.prod-example .env`, which is destructive on a developer machine —
it is safe only because a CI checkout has no `.env`. Don't run those lines locally.

## Making the images consumable

The images are pointless if `docker-compose.yml` still builds from source. Both services now carry
`image:` **and** keep `build:` — compose pulls when the image is absent, and `docker compose build`
still builds from source and tags the result under the same name:

```yaml
  backend:
    image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
    build: ./backend
```

`CHITAI_VERSION` and `CHITAI_ORIGIN` are documented in `.env.prod-example`, and the README's install
steps now copy and edit `.env` *before* pulling — the pull cannot resolve `${CHITAI_VERSION}` until
that file exists, so the old ordering would have silently fetched `latest`.

## Versioning

Adopt `vMAJOR.MINOR.PATCH`. The tag is the source of truth; `metadata-action` derives everything
from it and the OCI labels record it. There is no tag today, so the first release is a decision —
`v0.1.0` matches `backend/pyproject.toml` and is the honest number for an app with an open
"any authenticated user can delete any library" item.

Do not automate bumping `pyproject.toml` and `package.json` from the tag. It requires CI to commit
back to the repo, and neither version is read by anything. Either keep a two-line manual checklist
(bump both, commit, tag) or delete `frontend/package.json`'s version field and treat the backend's
as the product version. Bumping by hand is the smaller cost.

## Order of work

- [x] **Runtime config.** `frontend/src/lib/server/config.ts` → `$env/dynamic/private`; `ORIGIN` on
      the compose frontend service; `CHITAI_ORIGIN` in `.env.prod-example`.
- [x] **Toolchain pins.** `packageManager` in `frontend/package.json`; `pnpm-workspace.yaml` copied
      in both `frontend/Dockerfile` install stages.
- [x] **The workflow.** `.gitea/workflows/release.yml`, both jobs.
- [x] **Consumable images.** `image:` keys in `docker-compose.yml`, frontend `HEALTHCHECK`,
      `CHITAI_VERSION`, README install steps.
- [ ] **Stand up `act_runner` with a working Docker daemon.** Not something the repo can carry.
      Prove it with a throwaway workflow running `docker version` before trusting the release one.
- [ ] **Tag `v0.1.0-rc.1`.** Confirm two images land in the registry, that `latest` was *not* moved,
      and that the smoke job goes green.
- [ ] **Confirm an anonymous `docker pull`** from a logged-out machine, since the README tells people
      to do exactly that.
- [ ] **Tag `v0.1.0`.**

Everything the repository can hold is in place; what remains is instance-side and needs a real tag
to exercise. Verified locally along the way: `docker compose build` succeeds against both changed
Dockerfiles, `docker compose config` resolves the image names, and the built frontend image
honours `VITE_BACKEND_API_URL` at runtime and reports `healthy` to Docker.

## Deferred, deliberately

- **Multi-arch.** amd64 only for now. Adding `linux/arm64` under QEMU roughly triples the job and the
  Python and pnpm installs are exactly the workload emulation is worst at; a native arm runner and a
  fan-out/merge workflow is the answer if it is ever needed.
- **Building on `main`.** An `edge` tag from every push to `main` is a two-line addition to the same
  workflow (`on: push: branches: [main]` plus `type=raw,value=edge,enable={{is_default_branch}}`).
  Left out because it doubles registry churn for a deployment target that does not exist yet.
- **PR CI.** Formatting, lint, type and test gates are a separate workflow on a separate trigger and
  should not be mixed into the release path — see the gating section.
- **Gitea Releases.** A job creating a release object with generated notes is nice-to-have and can
  be added once the tags mean something.
- **Signing / SBOM / provenance.** `build-push-action` can emit provenance and an SBOM, and cosign
  can sign the digests. Worth it if the images are ever consumed by anyone other than you; not worth
  the key management before then.
- **`backend/.dockerignore` is thin.** It excludes `.git`, `__pycache__` and the ruff cache, but not
  `.venv/`, `.postgres/`, `libraries/`, `covers/` or `tests/`. None of those reach the image — the
  Dockerfile copies specific paths — and none exist in a CI checkout since they are gitignored, so
  this does not affect the pipeline. It does make local builds slower than they need to be.
