test:ci now runs c8 with --all --include "src/**/*.ts" --100, so the build job fails when any runtime file under src/ is untested. --all is what makes the gate non-vacuous: without it c8 counts only the files the suite happened to load, and a new untested module stays invisible. Add src/index.test.ts to load the public barrel, which was previously never imported at runtime and so read as 0% under --all. matcher-shared.ts is types-only (an empty runtime image) and carries a file-level c8 ignore with the reason. Why 100% and the rejected alternatives: development/ci.md § Coverage threshold.
6.3 KiB
CI
.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 tomain/ tag) — build + correctness + packaging.maintain(push tomain, non-blocking) —npm run maintain; reports, never fails the build.publish(tag) — packaging checks +publish:publint/publish:attw, then the Gitea release page andnpm publish(see publishing.md).release-gate— on a:rocket: Release x.y.zcommit it skipsbuild/maintain, becausecreate:releasepushes 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:<version>
(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 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 runscript: no prefix 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:
- Edit .node-version to the exact
x.y.z— floats like26resolve to the latest patch at runtime and bust the baked entry, so scripts/runner-image.sh refuses them. docker login gitea.e1nsnull.de(user + package/access token), then./scripts/runner-image.sh --push, which reads the version and pushes<IMAGE_REPO>:<version>.- Repoint the three
container.imagetags in .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 <version>/<arch>.complete marker next to the Node directory.
Why
actions/tool-cacheaccepts a cached tool only when<version>/<arch>.completeexists beside it (tc.find()checks). A barenode/<version>/x64/is ignored and the download happens anyway. See the comment in 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 aDockerfile-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:<version>); 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".
--allcounts asrc/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, whichbuildalready runs — no new job or step. verifystays fast and local; the slower coverage run is a CI-only tier (see workflow.md § Feedback tiers).
Rejected
- Per-file thresholds: a global 100% already forces every counted file to 100%.
- A
check:coveragescript: it would re-run the suite or read c8's temp dir, and nocheck:*script runs tests. --allwithout--include: it would also sweepscripts/, which is not the shipped surface.
Known issue
src/matcher-shared.tsis 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/<tag>/).
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 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.