# CI [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) is the source of truth for the job graph; this file records why it is shaped the way it is. ## Pipeline - **`build`** (push to `main` / tag) — build + correctness + packaging. - **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports, never fails the build. - **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (see [publishing.md](./publishing.md)). - **`release-gate`** — on a `:rocket: Release x.y.z` commit it skips `build`/`maintain`, because `create:release` pushes the tag for the same commit right after and the tag run is authoritative. It uses no Node and stays on the default image. ## Runner image #### Decision (2026-09) `build` / `maintain` / `publish` run in `gitea.e1nsnull.de/tmu/act-ci:` ([docker/Dockerfile](../docker/Dockerfile)): the default act image with Node overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes. #### Why - No job pays the ~50 MB Node fetch, because the probe hits the baked entry. - The tag must equal the exact [.node-version](../.node-version) pin, and the image is rebuilt only as part of a Node bump — there is no other trigger. - Building needs a docker daemon and registry credentials, so it belongs to no feedback tier. That is why it is **not** an `npm run` script: no [prefix](./workflow.md#script-prefix-convention) fits, and that is the signal. #### Rejected - Downloading Node in every job — the ~50 MB fetch was the original problem. - Caching Proxy (Squid or similar) — adds complexity to global setup - Mounting the tool cache - No invalidation will fill the cache with stale versions ## Bumping Node Bumping Node is one coordinated change, committed as a unit: 1. Edit [.node-version](../.node-version) to the exact `x.y.z` — floats like `26` resolve to the latest patch at runtime and bust the baked entry, so [scripts/runner-image.sh](../scripts/runner-image.sh) refuses them. 2. `docker login gitea.e1nsnull.de` (user + package/access token), then `./scripts/runner-image.sh --push`, which reads the version and pushes `:`. 3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to that version. Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download. ## Image invariants For the `setup-node` probe to hit, two things must hold — both easy to break: ### The `x64.complete` marker #### Decision (2026-09) Bake a `/.complete` marker next to the Node directory. #### Why - `actions/tool-cache` accepts a cached tool only when `/.complete` exists beside it (`tc.find()` checks). A bare `node//x64/` is ignored and the download happens anyway. See the comment in [docker/Dockerfile](../docker/Dockerfile). ### Tag freshness, with force-pull deliberately off #### Decision (2026-09) Leave `act_runner`'s `force_pull` disabled. #### Why - The tag encodes only the Node version, and the image is rebuilt only when that changes — so the normal flow always yields a new tag and the runner pulls it. - Forcing a pull re-pulls the image on every job for no benefit. #### Rejected - Enabling `force_pull`: it is acceptable to miss a runner-side image change, and a `Dockerfile`-only change is not worth a per-job pull. #### Known issue - A `Dockerfile`-only change (like the marker above) re-pushed under an unchanged tag is invisible to the runner, which keeps the old image while the registry shows the new digest. Remove the stale tag on the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not reach for force-pull. ## Coverage threshold #### Decision (2026-09) `npm run test:ci` fails below 100% statements / branches / functions / lines across `src/**/*.ts` (`c8 --all --include "src/**/*.ts" --100`). The gate rides the `build` job; `npm run verify` stays coverage-free. #### Why - The types are the feature, so an untested branch is a hole in the contract, not a metric to trade off; 100% is the only threshold that means "no hole". - `--all` counts a `src/` file no test imports. Without it c8 reports only the files the suite happened to load, so a new untested module is invisible and the threshold passes vacuously. - The gate rides `test:ci`, which `build` already runs — no new job or step. - `verify` stays fast and local; the slower coverage run is a CI-only tier (see [workflow.md § Feedback tiers](./workflow.md#feedback-tiers)). #### Rejected - Per-file thresholds: a global 100% already forces every counted file to 100%. - A `check:coverage` script: it would re-run the suite or read c8's temp dir, and no `check:*` script runs tests. - `--all` without `--include`: it would also sweep `scripts/`, which is not the shipped surface. #### Known issue - `src/matcher-shared.ts` is types only, so its runtime image is empty; c8 still lists it under `--all`. It carries a file-level `/* c8 ignore start */` with the reason. Adding runtime code there means removing that directive. ## Coverage serving #### Decision (2026-09) Serve CI coverage from a shared directory on the runner, with no deploy step in CI. #### Why - The webserver exposes the shared directory and the Gitea docker setup reuses the existing reverse proxy — no upload artifact, no external service. - Coverage is written to a shared volume keyed by project and tag (for example `/docs/tiny-pattern-ts//`). #### Rejected - Gitea Pages and Codecov: neither was confirmed available or wanted. #### Known issue - Coverage is served for tag pushes only; non-tag pushes (for example `main/coverage`) are tracked separately. [scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst` sidecars next to text assets. The Gitea pages service (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the sidecar matching `Accept-Encoding` and falls back to the original. #### Decision (2026-09) Precompress into sidecars rather than per request. #### Why - The assets are static and change only on deploy, so the work is paid once. - Images, fonts and archives are already compressed; a sidecar would only grow them, so only text extensions are emitted.