The runner has docker, so the database container started; the job just could not reach it. pytest-databases takes the address from DOCKER_HOST, and unset means 127.0.0.1 -- the job container's loopback, not the namespace the sibling published on. A dind service makes it resolve to a host that answers.
18 KiB
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
originis Gitea 1.27 atgit.jaroszew.ski. The repo is public, withhas_actionsandhas_packagesboth true. There is no.gitea/or.github/directory and no CI of any kind.- There are no tags in the repository.
backend/pyproject.tomlsays0.1.0,frontend/package.jsonsays0.0.1; nothing reads either. backend/Dockerfileandfrontend/Dockerfileare both multi-stage and both already build a runnable image.docker-compose.ymlbuilds them from source withbuild: ./backendandbuild: ./frontend— there is noimage: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:
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:
// 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_URLin the build stage. This works, but it makes the image specific to one deployment's topology — CI would bakehttp://backend:8000and 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.envat request time:import { env } from '$env/dynamic/private'; export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';$lib/server/config.tsis server-only and its one consumer ($lib/server/api.ts) is too, so$env/dynamic/privateis 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_URLat the same time — theVITE_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-exampleand the composeenvironment: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.jsonhas nopackageManagerfield, socorepack enablefollowed bypnpm installresolves to whatever version corepack considers current on the day the build runs.pnpm-lock.yamlislockfileVersion: 9.0, i.e. pnpm 9/10; a future pnpm 11 could refuse it, and--frozen-lockfilewould fail a release for reasons unrelated to the release. Add"packageManager": "pnpm@<version from your nix shell>"and let corepack honour it. pnpm-workspace.yamlis never copied into the image. It carriesonlyBuiltDependencies(esbuild,@tailwindcss/oxide), and pnpm 10 blocks postinstall scripts that are not listed there. Both install stages infrontend/Dockerfilecopy onlypnpm-lock.yamlandpackage.json, so the container install runs under different rules than the local one. Copy it alongsidepackage.jsonin theprod-depsandbuildstages 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.
-
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 labelubuntu-latest. -
The runner needs a Docker daemon, at an address the job can reach.
act_runnerin docker mode runs each job inside a container with no daemon of its own. Either run adocker:dindsidecar next to the runner and setDOCKER_HOST=tcp://docker:2376(with TLS certs shared over a volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the host socket into job containers gives any workflow root on the runner host.Reachability is a separate question from availability, and it bit us. Mounting the host socket into a containerised job gives it a working daemon, and
pytest tests/still fails withService 'pytest_databases_postgres' failed to come online: the container starts fine, but its published port lands on the host's network namespace while the test process looks for it on the job container's loopback.pytest_databases/docker/__init__.pypicks the address fromDOCKER_HOST—127.0.0.1when it is unset orunix://, otherwise the hostname out oftcp://host:port. So a TCPDOCKER_HOSTis what makes the sibling container addressable, and the value must include the port or it raises rather than falling back.Until the runner grows its own sidecar,
ci.yml's backend job carries adocker:dindservice of its own withDOCKER_HOST: tcp://docker:2375. That needs the runner to permit--privileged. The same treatment is still owed torelease.yml— itsqualityjob runs the same tests, and itssmokejob talks to compose, where the published ports would move to the dind host too, socurl http://localhost:3000becomescurl http://docker:3000. Configuring the runner once (option 1) avoids all of that. -
Action resolution. A bare
uses: docker/build-push-action@v6does not mean github.com here. Gitea resolves it against[actions] DEFAULT_ACTIONS_URL, which defaults tohttps://gitea.com. That is fine as it stands —actions/checkout@v4,docker/setup-buildx-action@v3,docker/login-action@v3,docker/metadata-action@v5anddocker/build-push-action@v6are 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 = githubinapp.iniis the fix if so. Writing fullhttps://URLs inuses:also works on Gitea but is invalid syntax on GitHub Actions, so it would cost portability for no gain. -
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 withwrite:packageand store it as the repo secretREGISTRY_TOKEN. The workflow below readsREGISTRY_TOKENwith a fallback to the automatic token. -
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 pullfrom 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, notgitea.*. Both exist on Gitea; the third-party actions read theGITHUB_*environment anyway, and using it keeps the file portable if the repo is ever mirrored.github.repositoryispatrick/chitai, so the images aregit.jaroszew.ski/patrick/chitai-backendand…/chitai-frontend.- Tags produced from
v1.2.3:1.2.3,1.2,1, andlatest.metadata-action's defaultlatest=autoflavour addslatestonly for a non-prerelease semver, sov0.2.0-rc.1publishes0.2.0-rc.1and leaveslatestwhere it was. That is what makes release-candidate tags a safe way to exercise the pipeline. fail-fast: falseso 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=ghacache relies onact_runner's built-in cache server exportingACTIONS_CACHE_URL/ACTIONS_RUNTIME_TOKEN. If your runner has caching disabled, buildx warns and continues, or errors depending on version — just delete the twocache-*lines. The Dockerfiles'--mount=type=cacheblocks 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
E712would have broken the query.services/filters/book.pyhadm.BookProgress.completed == True, and ruff proposesif 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 runruff check --fix --unsafe-fixesover 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-typescriptwrites 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.prettierignorenow.
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 pulls 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:
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
- Runtime config.
frontend/src/lib/server/config.ts→$env/dynamic/private;ORIGINon the compose frontend service;CHITAI_ORIGINin.env.prod-example. - Toolchain pins.
packageManagerinfrontend/package.json;pnpm-workspace.yamlcopied in bothfrontend/Dockerfileinstall stages. - The workflow.
.gitea/workflows/release.yml, both jobs. - Consumable images.
image:keys indocker-compose.yml, frontendHEALTHCHECK,CHITAI_VERSION, README install steps. - Stand up
act_runnerwith a working Docker daemon. Not something the repo can carry. Prove it with a throwaway workflow runningdocker versionbefore trusting the release one. - Tag
v0.1.0-rc.1. Confirm two images land in the registry, thatlatestwas not moved, and that the smoke job goes green. - Confirm an anonymous
docker pullfrom 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/arm64under 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. Anedgetag from every push tomainis a two-line addition to the same workflow (on: push: branches: [main]plustype=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-actioncan 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/.dockerignoreis thin. It excludes.git,__pycache__and the ruff cache, but not.venv/,.postgres/,libraries/,covers/ortests/. 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.