Files
chitai/docs/ci-release-pipeline.md
T
patrick b70ed5cb51 feat: build and publish release images from version tags
Tagging v* builds both images on Gitea Actions and pushes them to the instance
registry, then smoke-tests the published stack. The frontend read its backend
URL through import.meta.env, which Vite resolves at build time, so an image
could only point at whatever the build host had; it now reads it at runtime.
2026-08-17 15:05:18 -04:00

250 lines
16 KiB
Markdown

# 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: what the release job should and should not run
Do **not** put `pytest`, `pnpm check` or `pnpm lint` in front of the push. Per `TODO.md` and
`frontend/AGENTS.md` those are all currently red: `ruff check src/` reports 114 errors,
`pnpm check` has a documented baseline of 30 errors, `pnpm lint` 131. Wiring them into the release
path means the first tag fails for reasons that have nothing to do with the release, and the
predictable response is to disable the gate. Cleaning those up is worthwhile and is its own task —
`TODO.md` already tracks it as "Nothing gates formatting, linting or types".
Also skip `pytest` here specifically: it needs Docker for `pytest-databases`, which means
docker-in-docker-in-docker on the runner, for a suite that tests code paths the image build does not
affect.
What *is* 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.