# 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@"` 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.