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>
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:
- 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. - 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;latestdefeats 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-pythonetc.) 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.