# 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: ```bash 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 && 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 @`. 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=` 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.