Files
tmu 24cc007ab0 🔥 Drop redundant not.toBeAny assertions in the fixture
The exact-equality `toEqualTypeOf` assertions already reject `any`, so the
`.not.toBeAny()` guards were redundant. Refresh the fixture header and
development/ci.md to match.
2026-09-29 21:23:15 +00:00

10 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 to main / tag) — build + correctness + packaging.
  • compat (CI only) — type-check the suite and a consumer fixture against the minimum supported TypeScript; consumes build's dist/ and gates publish. It has no local tier: it never runs in a hook or in check / verify / test:ci; run it by hand with npm run build && npm run test:compat. See § TypeScript compatibility.
  • 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).
  • 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:<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 run script: 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:

  1. Edit .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 refuses them.
  2. docker login gitea.e1nsnull.de (user + package/access token), then ./scripts/runner-image.sh --push, which reads the version and pushes <IMAGE_REPO>:<version>.
  3. Repoint the three container.image tags 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-cache accepts a cached tool only when <version>/<arch>.complete exists beside it (tc.find() checks). A bare node/<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 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:<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".
  • --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).

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.

TypeScript compatibility

Decision (2026-09)

A dedicated compat job runs npm run test:compat — the npx-pinned TypeScript 5.9 compiler (typescript@5.9.2) over compat/tsconfig.json — against the dist/ artifact build produced, and publish requires it. The floor is TypeScript 5.9, pinned in the test:compat script itself (the single source of truth) and documented in README § Requirements.

Why

  • The compiler is a different major from the repo's TypeScript 7, so it is resolved by npx at run time. It must never appear in devDependencies: that would install it for every local npm ci and drift the lockfile, which would put a second compiler in the local check / verify loop and every editor.
  • CI-only is the point. npx fetches over the network — like maintain's scans, a network-bound check is never a local feedback tier (see workflow.md § Feedback tiers). It is not in check, verify, test:ci or any hook; locally it is run by hand with npm run build && npm run test:compat.
  • compat consumes build's artifact rather than rebuilding, so it judges the exact bytes check, test:ci and publint saw.
  • compat gates publish because the types are the feature: an artifact that is not consumable at the advertised floor must not ship.
  • The fixture is a consumer, not a unit test. compat/fixture.ts imports the package by name (tiny-pattern-ts), so it resolves through the exports map to dist/index.d.ts and exercises the emitted declarations' relative .ts specifiers — not the source. expectTypeOf's exact-equality assertions are load-bearing: a bare compile would also pass if a declaration collapsed to any; they reject that.

Rejected

  • A devDependencies alias (npm:typescript@5.9): installs the legacy compiler locally, defeating "CI only".
  • A second lockfile / sub-project (compat/ with its own npm ci): a pinned, reproducible matrix, but a whole extra lockfile to maintain for one compiler. npx -p is enough.
  • A paths / moduleSuffixes redirect to typecheck the existing suite against dist/ without touching it: paths cannot remap the relative ./index.ts imports the tests use; moduleSuffixes only lets missing source resolve to suffixed copies, so it would need generated .compat.ts declarations staged into src/ (plus excludes). Both spend more than the fixture buys. See the handover discussion.
  • Writing the fixture against source (relative import): it would prove the source compiles under 5.9, not that the published declarations do, which is the promise consumers rely on.
  • Replacing attw: attw owns the full resolution matrix (node10/node16/nodenext/bundler); compat answers only "does the documented floor compile the artifact".

Known issue

  • @tsconfig/node26 cannot be extended: its lib: ["es2025", ...] and target: es2025 are rejected by 5.9 (TS6046). compat/tsconfig.json extends only @tsconfig/strictest and sets lib / target: es2024, the ceiling 5.9 accepts.
  • skipLibCheck: false is deliberate — it is what makes the floor honest (type-fest pins it at 5.9), rather than hiding a broken dependency d.ts behind true.
  • The version appears in both the test:compat script and the README; a floor bump is a two-file change. The script is authoritative.
  • src/doc-test is excluded from compat/tsconfig.json. The generated examples are checked against the source by their own project; test:compat covers the suite plus the fixture.

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.