From b9fe21175f698ec0503740139f8711e68fa4a885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 14 Sep 2026 12:13:38 +0000 Subject: [PATCH 1/5] :green_heart: Reuse npm cache in publish job The publish job ran npm ci against a cold cache on every release while build/maintain already share a warm node-cache key off the same lockfile. Tag runs can read caches saved on main, so wiring cache: npm here is free. --- .gitea/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index afd8905..cb147a2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -139,6 +139,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: .node-version + # Same lockfile/key as `build`, and tag runs can read caches + # saved on `main` — without this, every release pays a cold + # `npm ci` despite the warm shared npm cache. + cache: "npm" registry-url: "https://registry.npmjs.org/" - run: npm ci # Consume the dist/ that `build` produced and gated, instead of From 245dfaf1987383ed8cdd8abe4eb1d58ebd062498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 14 Sep 2026 12:37:58 +0000 Subject: [PATCH 2/5] :green_heart: Bake Node into the CI job image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-node never consults `node` on PATH; its only fast path is a probe of /opt/hostedtoolcache, which the ephemeral act_runner job containers always miss, so every job paid a ~50 MB Node download. docker/Dockerfile extends the runner's default catthehacker/act image with the Node distribution overlaid at the exact tool-cache layout, so setup-node finds 26.8.2 and skips the fetch while node-version-file, cache: npm and registry-url keep working unchanged. Image layers dedupe against the base the host already pulled and prune via normal docker hygiene — the cleanup story a host bind of /opt/hostedtoolcache lacks. To make the bake deterministic, .node-version is pinned to the exact 26.8.2 the image carries; scripts/runner-image.sh guards that coupling and builds/pushes the tag the three node jobs now reference via container.image. Ops follow-up (outside the repo): build once with `npm run build:runner-image -- --push` on a machine with registry creds. If the package is private, the runner needs container registry credentials in its config. --- .gitea/workflows/ci.yml | 21 +++++++++++++++++---- .node-version | 2 +- cspell.json | 6 ++++++ docker/Dockerfile | 30 ++++++++++++++++++++++++++++++ package.json | 1 + scripts/runner-image.sh | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 docker/Dockerfile create mode 100755 scripts/runner-image.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cb147a2..b6085d6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -49,11 +49,16 @@ jobs: needs: release-gate if: needs.release-gate.outputs.skip != 'true' runs-on: ubuntu-latest - # Bind-mount the shared pages tree so the coverage step below can write - # into it. The runner whitelists this path via `container.valid_volumes` - # (docker-space `setup/gitea.sh`); `image` is omitted on purpose so the - # runner keeps using its default job image. + # `image` extends the runner's default job image (catthehacker/act) + # with Node 26 pre-planted in the tool cache layout, so setup-node's + # version probe hits and never downloads (see docker/Dockerfile). The + # tag MUST equal the exact version pinned in `.node-version`; rebuild + # via `npm run build:runner-image -- --push` and repoint here on every + # Node bump. The volume bind-mounts the shared pages tree so the + # coverage step below can write into it; the runner whitelists this + # path via `container.valid_volumes` (docker-space `setup/gitea.sh`). container: + image: gitea.e1nsnull.de/tmu/act-ci:26.8.2 volumes: - /data/gitea-pages:/data/gitea-pages steps: @@ -108,6 +113,10 @@ jobs: if: needs.release-gate.outputs.skip != 'true' runs-on: ubuntu-latest continue-on-error: true + # Same baked image as `build` — without it this job re-downloads Node + # per run (see docker/Dockerfile). + container: + image: gitea.e1nsnull.de/tmu/act-ci:26.8.2 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -121,6 +130,10 @@ jobs: if: startsWith(gitea.ref, 'refs/tags/') needs: build runs-on: ubuntu-latest + # Same baked image as `build` — setup-node still owns the registry-url + # `.npmrc` rewrite here; only the Node download is skipped. + container: + image: gitea.e1nsnull.de/tmu/act-ci:26.8.2 # The release page is created with the run's automatic Gitea token # (`github.token`), not `NPM_TOKEN`, so it needs `contents: write`. permissions: diff --git a/.node-version b/.node-version index 978b4e8..707210d 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/cspell.json b/cspell.json index 58d50e3..a31d295 100644 --- a/cspell.json +++ b/cspell.json @@ -27,6 +27,12 @@ "knope", "runwisp", "glab", + "hostedtoolcache", + "catthehacker", + "nsnull", + "dedup", + "dedupe", + "repoint", "postversion", "prebuild", "Zilla", diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..2650cb5 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,30 @@ +# CI job image for the Gitea act_runner: the runner's default job image with +# Node pre-planted where actions/setup-node looks first. +# +# Why this layout: setup-node ignores `node` on PATH; its only fast path is a +# probe of /opt/hostedtoolcache/node//. Without an entry there +# it downloads the ~50 MB distribution on EVERY job (the runner's job +# containers are ephemeral, so its tool cache never survives a job). The +# official node images keep exactly the layout setup-node expects under +# /usr/local, so this layer is a pure file overlay — no scripts, no env. +# +# Why not a host bind of /opt/hostedtoolcache: binds never self-prune. Docker +# images are content-addressed: the base layers dedupe against the act image +# the host already has, and `docker image prune` / re-pulls are the cleanup +# story. +# +# NODE_VERSION must match `.node-version` exactly. setup-node resolves a float +# like `26` to the latest known patch at runtime, so a bump silently busts the +# baked entry; `.node-version` is pinned to x.y.z and scripts/runner-image.sh +# guards the coupling. Rebuild + repoint `container.image` in +# .gitea/workflows/ci.yml on every bump. +FROM catthehacker/ubuntu:act-latest + +ARG NODE_VERSION=26.8.2 + +# node image: bin/ + lib/ under /usr/local → tool cache: bin/ + lib/ under /x64. +COPY --from=node:${NODE_VERSION} /usr/local /opt/hostedtoolcache/node/${NODE_VERSION}/x64 + +# Fail the build (not CI) if the overlay or the version arg were wrong. +# Shell form on purpose: exec form (`RUN [...]`) does not expand ARG values. +RUN "/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin/node" --version diff --git a/package.json b/package.json index aca4c28..3a9db00 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", + "build:runner-image": "./scripts/runner-image.sh", "prebuild": "rm -rf dist", "check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell", "check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}", diff --git a/scripts/runner-image.sh b/scripts/runner-image.sh new file mode 100755 index 0000000..6205cbd --- /dev/null +++ b/scripts/runner-image.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build (and optionally push) the CI job image from docker/Dockerfile. +# Run wherever docker + registry credentials live (the runner host, or any +# machine that can reach the registry). The registry/repo below MUST match +# the `container.image` references in .gitea/workflows/ci.yml — the runner +# pulls the image by name. +# +# Usage: scripts/runner-image.sh [--push] + +IMAGE_REPO="gitea.e1nsnull.de/tmu/act-ci" + +NODE_VERSION="$(tr -d '[:space:]' < .node-version)" +if [[ ! "${NODE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: .node-version must be pinned to an exact x.y.z, got '${NODE_VERSION}'." >&2 + echo " setup-node resolves floats like '26' to the latest patch at runtime," >&2 + echo " which silently busts the tool-cache entry baked into the image." >&2 + exit 1 +fi + +IMAGE="${IMAGE_REPO}:${NODE_VERSION}" + +# --pull: refresh the act base layer so the derivative does not float on an +# aging default image forever (layer dedup keeps this cheap). +docker build --pull --build-arg "NODE_VERSION=${NODE_VERSION}" -t "${IMAGE}" -f docker/Dockerfile . + +if [[ "${1:-}" == "--push" ]]; then + docker push "${IMAGE}" +fi + +echo "built ${IMAGE}" +echo "reminder: bump container.image in .gitea/workflows/ci.yml to this tag" From abbdf4410e09bab65b0e1a6821dee44a5122468c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 14 Sep 2026 12:40:13 +0000 Subject: [PATCH 3/5] :memo: Track CI Node-baking work in backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record chore/fix-ci's two done steps and the remaining ops step (build/push the image; first CI run is the acceptance test). Glyph/tag coupling honoured: every ✔ line carries @done, open parent stays ☐. --- backlog.tasks | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index 0a55d8b..920006d 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -45,3 +45,7 @@ Maintenance: ☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy) ☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts//`) ☐ Browse to `…/tiny-pattern-ts/index.html` in the browser +☐ Stop Gitea CI re-downloading Node on every job (branch chore/fix-ci) + ✔ Share the warm npm cache with the publish job @done + ✔ Bake Node into the CI job image (docker/Dockerfile, container.image in ci.yml) @done + ☐ Build/push gitea.e1nsnull.de/tmu/act-ci:26.8.2 and confirm setup-node skips the download (first run on the branch = acceptance test) @high From 3e33b51d1b2c3557c7c837818f0541de70389264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 14 Sep 2026 12:51:34 +0000 Subject: [PATCH 4/5] :memo: Document CI runner-image bump ritual build:runner-image broke the script prefix convention: build is a bare single-tool command, and no tier fits a docker-daemon + registry-cred action, so the script leaves package.json and is invoked directly. CONTRIBUTING gains a 'CI runner image' section recording where the image pushes (gitea.e1nsnull.de/tmu/act-ci:), the .node-version coupling, and the three-step Node-bump ritual. --- .gitea/workflows/ci.yml | 6 +++--- CONTRIBUTING.md | 12 ++++++++++++ package.json | 1 - 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b6085d6..f5bf33f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -52,9 +52,9 @@ jobs: # `image` extends the runner's default job image (catthehacker/act) # with Node 26 pre-planted in the tool cache layout, so setup-node's # version probe hits and never downloads (see docker/Dockerfile). The - # tag MUST equal the exact version pinned in `.node-version`; rebuild - # via `npm run build:runner-image -- --push` and repoint here on every - # Node bump. The volume bind-mounts the shared pages tree so the + # tag MUST equal the exact version pinned in `.node-version`; the bump + # ritual is documented in CONTRIBUTING.md § CI runner image. The volume + # bind-mounts the shared pages tree so the # coverage step below can write into it; the runner whitelists this # path via `container.valid_volumes` (docker-space `setup/gitea.sh`). container: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fa590d..ee23cfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,18 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert - CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)). - **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). +## CI runner image + +The `build` / `maintain` / `publish` jobs run in `gitea.e1nsnull.de/tmu/act-ci:` ([docker/Dockerfile](./docker/Dockerfile)) — the runner's default act image with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in `.node-version`; `release-gate` uses no Node and stays on the default image. The script is deliberately NOT an `npm run` script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per [Script prefix convention](#script-prefix-convention), no existing prefix fits and that is the signal. + +Bumping Node is one coordinated change, committed as a unit: + +1. Edit `.node-version` to the new exact `x.y.z` — floats like `26` resolve to the latest patch at runtime and silently bust the baked entry; `scripts/runner-image.sh` refuses them. +2. `docker login gitea.e1nsnull.de` (user + package/access token), then `./scripts/runner-image.sh --push` — it reads the version from `.node-version` and builds/pushes `:`. +3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) to the same version. + +Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download. + ## Publishing workflow Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`: diff --git a/package.json b/package.json index 3a9db00..aca4c28 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "build:runner-image": "./scripts/runner-image.sh", "prebuild": "rm -rf dist", "check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell", "check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}", From 93cbfcf6b11f76a45d8fa80f77d12ae000b58fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 14 Sep 2026 13:28:29 +0000 Subject: [PATCH 5/5] :bug: Expand the node base image through a named stage COPY --from= resolves its value as a stage name at parse time, before build args exist, so COPY --from=node:${NODE_VERSION} collapsed to the invalid 'node:' and docker failed with 'failed to parse stage name'. ARGs in global scope are expanded in FROM, so route through a named nodebase stage; the per-stage ARG redeclaration keeps the tool-cache paths expanding. --- cspell.json | 2 ++ docker/Dockerfile | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cspell.json b/cspell.json index a31d295..bebc052 100644 --- a/cspell.json +++ b/cspell.json @@ -28,6 +28,8 @@ "runwisp", "glab", "hostedtoolcache", + "nodebase", + "frontends", "catthehacker", "nsnull", "dedup", diff --git a/docker/Dockerfile b/docker/Dockerfile index 2650cb5..babcb5a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,12 +18,26 @@ # baked entry; `.node-version` is pinned to x.y.z and scripts/runner-image.sh # guards the coupling. Rebuild + repoint `container.image` in # .gitea/workflows/ci.yml on every bump. +# +# The extra `nodebase` stage is load-bearing: `COPY --from=` resolves its value +# as a *stage name* at parse time, before build args exist, so +# `COPY --from=node:${NODE_VERSION}` collapses to the invalid `node:` on +# frontends that do not expand args there. ARGs declared before the first FROM +# *are* expanded in FROM, so routing through a named stage works everywhere. + +# Global scope: only visible to FROM lines, but that is exactly where we need it. +ARG NODE_VERSION=26.8.2 +FROM node:${NODE_VERSION} AS nodebase + FROM catthehacker/ubuntu:act-latest +# ARGs do not cross stage boundaries; redeclare (with the same default, so a +# bare `docker build -f docker/Dockerfile .` still works) for the paths below. +# Keep this default in sync with the global one above. ARG NODE_VERSION=26.8.2 # node image: bin/ + lib/ under /usr/local → tool cache: bin/ + lib/ under /x64. -COPY --from=node:${NODE_VERSION} /usr/local /opt/hostedtoolcache/node/${NODE_VERSION}/x64 +COPY --from=nodebase /usr/local /opt/hostedtoolcache/node/${NODE_VERSION}/x64 # Fail the build (not CI) if the overlay or the version arg were wrong. # Shell form on purpose: exec form (`RUN [...]`) does not expand ARG values.