Files
chitai/docs/ci-release-pipeline.md
T
patrick 54043a97d2 feat: check formatting, linting and tests in CI
Every push runs ruff, prettier, pytest and svelte-check; a tagged release runs the
blocking half again before it publishes an image. eslint and svelte-check report
without failing, since 87 and 30 findings predate the workflow.
2026-08-17 15:17:20 -04:00

17 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

  • 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:

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_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:

    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 donepackageManager 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 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; ORIGIN on the compose frontend service; CHITAI_ORIGIN in .env.prod-example.
  • Toolchain pins. packageManager in frontend/package.json; pnpm-workspace.yaml copied in both frontend/Dockerfile install stages.
  • The workflow. .gitea/workflows/release.yml, both jobs.
  • 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.