Files
WeatherTool/docs/CONTINUOUS_INTEGRATION.md
T
b0txec ea583ed57d
CI / backend (push) Successful in 1m27s
CI / frontend (push) Successful in 52s
Correct two doc sections written before the real CI bugs were found
The changelog claimed ci.yml #1 verified green (it didn't -- that was a
wrong WebFetch summary of the Gitea Actions page, reported as fact without
checking it visually) and CONTINUOUS_INTEGRATION.md described the
HOME/cache permission warning as harmless (it wasn't -- it blocked
resolving actions/checkout entirely). Both corrected with what actually
happened: three real, different bugs, found only by running the workflow
and reading user-provided screenshots of the actual failures.
2026-08-24 22:27:39 +03:00

7.1 KiB
Raw Blame History

Continuous integration

Last verified: 2026-08-24

Current topology

WeatherTool uses Gitea Actions for continuous integration:

bot/WeatherTool repository on gitea.packet.garden
  → repository-scoped weathertool-ci-rocky-01 runner on the coding PC
    → dedicated Docker-in-Docker daemon
      → disposable per-job containers (backend: hseeberger/scala-sbt, frontend: node:20-bookworm)

Gitea does not execute workflow commands itself. The runner polls Gitea for jobs and executes them. The runner is registered only to bot/WeatherTool; it is not a user-, organization-, or instance-wide runner.

The runner host is the Rocky Linux coding PC — the same machine already used for all compile/build/test/staging work. This is a second, independent runner instance on that host: HOP already runs its own Forgejo runner there (hop-forgejo-runner-*, registered to a different Forgejo instance's bot/hop repo). The two don't conflict — each is a separate long-polling client registered to a different server with its own token, own Docker network, and own Docker-in-Docker daemon; they only share host CPU/RAM, which is a non-issue for occasional CI runs.

Isolation boundary

Same pattern as HOP's Forgejo runner: the runner uses a dedicated Docker-in-Docker daemon rather than exposing the coding PC's normal Docker socket to workflow containers. The nested daemon:

  • runs in its own privileged container because a nested Docker daemon requires that capability;
  • exposes no port on the host or LAN;
  • is reachable only through the runner's private Compose network; and
  • stores its images and state in the weathertool-gitea-runner_dind_data volume.

This prevents ordinary WeatherTool workflow jobs from enumerating or mutating the coding PC's normal development containers (including the Rocky staging stack itself).

Host-local runner files

The active runner is operated from:

/home/sandbox/.local/share/weathertool-gitea-runner/
  compose.yml
  runner-token

The directory is mode 0700. runner-token is mode 0600. It is deliberately outside the WeatherTool Git repository and was written directly to disk from an interactive prompt — it was never printed, pasted into chat, or copied into project documentation.

Normal operator commands are run from the directory above:

docker compose up -d
docker compose ps
docker compose logs --tail=100 runner
docker compose pull

The expected steady state is:

  • docker-in-docker: Up (healthy);
  • runner: Up; and
  • weathertool-ci-rocky-01: Idle in Gitea when no job is queued.

Triggering CI

CI only runs on a push (or PR) reaching the gitea remote — pushing to rocky alone does not trigger it, since Gitea has no visibility into that private bare repo. To avoid needing two separate git push commands (and forgetting one), a combined all remote pushes to both rocky and gitea in one command:

git push all codex/staging-baseline

all deliberately does not include origin (the GitHub guntisdev/WeatherTool repo) — see DEVELOPMENT_AND_STAGING.md's commit workflow section for why.

Idle is healthy. It means the runner is authenticated and polling for work. The runner is available only while the coding PC, Docker, and these containers are running.

compose.yml sets HOME: /data on the runner container. Without it, the first real run failed every job, both times it was tried, with Unable to clone https://github.com/actions/checkout ...: mkdir /.cache: permission denied — this was initially misdiagnosed (both in the runner's own startup log, which only calls it "cache server disabled", and in this doc's first draft) as merely disabling the optional actions/cache action type. It actually blocks resolving any remote action at all: with uid 1000 and no writable $HOME, the runner has nowhere to git-clone an action's source into before running it, and actions/checkout is exactly that. HOME=/data (the already-writable bind-mounted data directory) fixed it.

WeatherTool workflow

The version-controlled workflow is:

.gitea/workflows/ci.yml

It runs for:

  • pushes to codex/staging-baseline;
  • pull requests targeting codex/staging-baseline; and
  • manual workflow_dispatch requests.

Two independent jobs, each with its own container image rather than one shared runner-label image with ad-hoc installs — this reuses images already proven for this project rather than bootstrapping a second language runtime into a single shared container:

  • backend (hseeberger/scala-sbt:17.0.2_1.6.2_2.13.8, the same image used all session for local Rocky builds): installs a real Node 20 via NodeSource's setup script (see below for why), checkout, sbt -batch test.
  • frontend (node:20-bookworm): checkout, npm ci, npm run typecheck, npm run build, npm audit, npm audit --omit=dev — the same five checks already documented as the manual Rocky verification routine in DEVELOPMENT_AND_STAGING.md.

Verified before writing the workflow that sbt test compiles and passes with no .env file present at all (via a git archive HEAD dry run into a clean scratch directory) — matching exactly what a checkout-only CI job actually has, since real credentials must never reach CI. The sbt-dotenv plugin logs a graceful warning and continues; nothing in the current test suite (FileNameServiceSpec; ParserSpec has no live assertions) touches sys.env or a live database.

actions/checkout is a JavaScript action — it needs a Node runtime inside the job container to execute at all, which hseeberger/scala-sbt doesn't have. frontend's image already has one, so it passed as soon as the HOME fix landed. backend needed an explicit install step first (apt-get install nodejs alone wasn't enough either: hseeberger/scala-sbt is Debian bullseye-based, whose default apt nodejs package is a stale Node 12, too old to parse actions/checkout@v4's modern JS — SyntaxError: Unexpected token '{' on a class static block. NodeSource's setup_20.x script installs an actual current Node 20 regardless of the distro's packaged version).

None of the three bugs above were caught by writing the workflow carefully or by the pre-write sbt test dry run — all three only surfaced by actually running it and reading the real failure in the Gitea Actions UI, once per bug, in order: the HOME/cache fix, then the missing-Node fix, then the wrong-Node-version fix. The first four runs (ci.yml #1#4) all failed. The first complete green run was ci.yml #5, commit 2c78ae8, 2m24s, verified 2026-08-24.

Current boundary

This workflow is CI only. A green result proves the committed source compiles, passes its (currently minimal) test suite, typechecks, builds, and has no known frontend dependency vulnerabilities. It does not deploy to Rocky staging or the VPS, publish an image, or access any staging/production secret — the runner's job containers never see .env, .env.staging, or any real provider credential. Continuous delivery, and expanding actual test coverage (see the roadmap's Phase 2), remain separate, not yet started work.