Files
flutter-node/SPEC.md
T
mrambossekandClaude Opus 4.7 e22d3b9168
Build flutter-node image / build (push) Successful in 2m45s
initial: flutter-node runner image
Custom CI runner image combining cirruslabs/flutter:stable with Node 20
from the official node image, so Gitea Actions runners can execute
JS-based actions (actions/checkout, actions/upload-artifact, etc.) on
Flutter projects.

Registry host is configurable via the vars.REGISTRY_HOST Gitea variable;
weekly cron picks up upstream patch updates without source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:03:47 +02:00

5.9 KiB

flutter-node

Custom CI runner image: Flutter SDK from cirruslabs/flutter with Node 20 binaries overlaid from the official node image. Two-file repo (Dockerfile, .gitea/workflows/build.yaml); the workflow builds and publishes the image on push, weekly, and on demand.

1. Why this exists

Gitea Actions' built-in actions — actions/checkout, actions/setup-*, actions/upload-artifact, basically every official action — are JavaScript-based and need a node binary on the runner's PATH. The vanilla ghcr.io/cirruslabs/flutter:stable image ships Flutter + Android SDK + JDK but no Node, so Flutter workflows that use those actions fail with:

exec: "node": executable file not found in $PATH

Two ways to fix that: install Node into every runner image that uses it (apt-get on each), or bake it in once and use that everywhere. This repo is the latter.

2. Published image

Field Value
Registry path ${REGISTRY_HOST}/ci-images/flutter-node
Tags :stable (moving) + :YYYYMMDD (immutable, dated build)
Visibility Public — anonymous docker pull works
Linked repo https://${REGISTRY_HOST}/ci-images/flutter-node (via OCI image.source label, so Gitea links package → repo and inherits visibility)

REGISTRY_HOST is a Gitea Actions variable (vars.REGISTRY_HOST) configured at the org or repo level — the workflow uses it for the push tags, and passes it into the Docker build as the IMAGE_SOURCE build-arg so the OCI source label matches. Nothing in this repo hardcodes a hostname; pointing the image at a different Gitea instance is just a variable change.

Bumping is deliberate: edit Dockerfile to change a FROM tag, push. The weekly cron picks up upstream patch updates without source changes (cirruslabs republishes :stable and Debian republishes bookworm-slim for security fixes).

3. Build inputs

FROM ${NODE_IMAGE}     AS node      # default: node:20-bookworm-slim
FROM ${FLUTTER_IMAGE}               # default: ghcr.io/cirruslabs/flutter:stable

The runtime stage is ${FLUTTER_IMAGE} with the following bits copied in from the node stage:

  • /usr/local/bin/node
  • /usr/local/lib/node_modules (npm + the npx shim live in here)

Two symlinks materialize /usr/local/bin/npm and /usr/local/bin/npx pointing into the module tree — mirrors what npm install -g does on a normal install.

A RUN node --version && npm --version && npx --version && flutter --version at the end of the Dockerfile fails the build if any of those isn't on PATH, so a broken layer can't sneak into a published image.

4. Workflow triggers

.gitea/workflows/build.yaml runs on:

Trigger When Why
push: branches: [main] Any commit lands on main Pick up source changes immediately
schedule: 0 3 * * 0 Sunday 03:00 UTC Pick up upstream patch updates with no source change
workflow_dispatch Manual button in Gitea UI On-demand rebuild

The job runs on runs-on: ubuntu-docker (a runner with a Docker daemon available), not flutter — that intentionally sidesteps the chicken-and-egg where building the flutter-node image would need the flutter-node image.

Auth to the registry uses vars.REGISTRY_HOST + secrets.REGISTRY_TOKEN — same pattern the other projects on this Gitea instance use. The auto-injected gitea.token is rejected by the registry path; a manually-issued PAT with write:package scope is required.

5. Consumers

Any runner config that points at this image — typically Flutter projects whose workflows declare runs-on: flutter.

Adding a new consumer: nothing changes here. Just point the consuming project's runs-on: at flutter and make sure the runner config has flutter:docker://${REGISTRY_HOST}/ci-images/flutter-node:stable mapped in (substituting the same host you set in vars.REGISTRY_HOST).

6. Pinning + rollback

The :stable tag is moving — runners pulling it without an image_pull_policy: always setting may keep using a cached older layer until Docker prunes. Two ways to deal:

  1. Pin to a dated tag in the runner config (recommended for prod). Change …/flutter-node:stable…/flutter-node:20260524. Bumping is then a deliberate runner-config edit, never a surprise.
  2. Tell the runner to always pull (image_pull_policy: always). Cheap if the runner host is on a fast link to the registry.

Rolling back a bad image: every successful workflow leaves a dated tag in the registry. To roll back, point the runner config at the previous date tag — no need to re-push anything from this repo.

7. Testing changes locally

The image builds with plain Docker — no buildx required for a smoke test:

docker build -t flutter-node:test .
docker run --rm flutter-node:test node --version
docker run --rm flutter-node:test flutter --version

For a fuller smoke test against the actual JS actions, mount it into a local act-runner setup and run a workflow that does actions/checkout

  • actions/upload-artifact.

8. Extending

The Dockerfile is intentionally small. If you need to add tooling:

  • OS packages (apt-get) — add a RUN apt-get update && apt-get install -y --no-install-recommends <packages> && rm -rf /var/lib/apt/lists/* before the sanity-gate step. Keep the apt-get in one RUN to avoid layer bloat.
  • Global npm packages — add RUN npm install -g <pkg>@<version>. Pin versions; latest defeats the point of having a reproducible image.
  • A second runtime (Python, Go, etc.) — for one or two binaries, same COPY --from=<official-image> trick used for Node. For a full language toolchain, consider a separate image (ci-images/flutter-python etc.) so the matrix stays comprehensible.

Anything that adds noticeable image size warrants a comment in this SPEC explaining why; the whole point of a generic runner image is that it doesn't grow into a kitchen sink.