The pipeline bullet's "(push to `main` / tag)" read like a local push hook. State plainly that `compat` has no local tier — it is not in `check`, `verify`, `test:ci` or any hook — and is run manually with `build && test:compat`.
243 lines
10 KiB
Markdown
243 lines
10 KiB
Markdown
# 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.
|
|
- **`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](#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](./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](../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
|
|
`<IMAGE_REPO>:<version>`.
|
|
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 `<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](../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](./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](../README.md#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](./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` / `.not.toBeAny()` are
|
|
load-bearing: a bare compile would also pass if a declaration collapsed to
|
|
`any`; the exact-equality assertions 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](../backlog.tasks) 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](../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.
|