The Artificery's Toolbox container for running CI/CD jobs.
  • Dockerfile 64.7%
  • Shell 30.8%
  • Just 4.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Chris Hendrickson 953dbb3388
All checks were successful
Image / Build arm64 (push) Successful in 14m11s
Image / Build amd64 (push) Successful in 14m56s
Image / Publish the multi-arch tag (push) Successful in 15s
Keep FVM's tarball whole and preinstall only stable Flutter
The arm64 FVM release is a launcher script beside a Dart runtime and
snapshot in src/, not a single binary like x64, so moving just `fvm` onto
PATH broke the arm64 image build. The tarball now lives whole under
/opt/fvm-bin with a symlink from /usr/local/bin/fvm, which the launcher
resolves.

Only the current stable Flutter is baked in now; Tempo's three pins are
fetched per job by flutter-install and kept by the actions cache, which
takes the image from 10.3 GB to 6.1 GB. The README shows the cache step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKEvJWsncYMn3zcp3CDc3t
2026-09-11 12:53:14 -04:00
.forgejo/workflows Add the shared toolbox build and CI image 2026-09-11 12:12:12 -04:00
scripts Add the shared toolbox build and CI image 2026-09-11 12:12:12 -04:00
.containerignore Add the shared toolbox build and CI image 2026-09-11 12:12:12 -04:00
Containerfile Keep FVM's tarball whole and preinstall only stable Flutter 2026-09-11 12:53:14 -04:00
justfile Add the shared toolbox build and CI image 2026-09-11 12:12:12 -04:00
README.md Keep FVM's tarball whole and preinstall only stable Flutter 2026-09-11 12:53:14 -04:00
run.sh Add the shared toolbox build and CI image 2026-09-11 12:12:12 -04:00

toolbox

The shared build and CI toolchain image: one Debian container that runs the CI jobs of every project on the Forgejo instance and doubles as the local build toolchain, so a checkout builds on any host with podman and nothing else installed.

Tool Version Where
Debian bookworm (12) base image
Node 24 LTS, npm, corepack with pnpm /opt/node, /usr/local/bin
Rust rustup, stable pinned, rustfmt, clippy /opt/rustup, /opt/cargo
Rust targets aarch64-unknown-linux-gnu (amd64 image only), armv7-unknown-linux-gnueabihf, wasm32-unknown-unknown, wasm-bindgen-cli
Dart stable standalone SDK /opt/dart-sdk (first on PATH)
Flutter FVM, with current stable preinstalled; other pins fetched per job and cached /opt/fvm/versions/<ref>, default on PATH via /opt/fvm/default
Cross compilers gcc/g++ for armhf, and for arm64 on the amd64 image
Native libraries GTK 3, libmpv, epoxy, ALSA (host), armhf multiarch DRM/GBM/EGL/GLES/input/udev/xkbcommon/systemd/ALSA/GStreamer
Kernel and rootfs tools bc bison flex dtc kmod cpio, debootstrap, qemu-user-static, e2fsprogs, parted
Containers podman, buildah, skopeo with fuse-overlayfs, for nested use in privileged jobs
Misc just, git-lfs, jq, rsync, cmake, ninja, clang, python3, zsh with Oh My Zsh

Exact pins are the ARG lines at the top of each section in the Containerfile; bump them there and rebuild.

Using it in CI

Point a job at the registry's copy and run steps as if the tools were on the runner:

jobs:
  check:
    runs-on: linux-amd64-container
    container:
      image: forge.example.net/you/toolbox:<sha12>
    steps:
      - uses: https://data.forgejo.org/actions/checkout@v7
      - run: cargo test --workspace
      - run: fvm flutter test

Pin the <sha12> tag (the first 12 characters of the toolbox commit that built it), not latest; a toolbox change then reaches a project when you choose. Every published image is also tagged latest and YYYYMMDD. Because the image carries Node, the JavaScript actions (checkout, upload-artifact, forgejo-release) run inside it without any provisioning.

Jobs run as root in a privileged container, so apt-get install for a one-off dependency (Playwright's --with-deps, say) works in a step, and so does building or running containers: the image carries podman, buildah and skopeo with fuse-overlayfs, and a plain podman build in a step drives that nested engine (no socket from the host is involved).

Publishing

.forgejo/workflows/image.yml builds the image natively on the amd64 and arm64 container runners inside quay.io/podman/stable, pushes toolbox:<arch>-<sha> from each, and a final job joins them with podman manifest into toolbox:<sha12>, :latest and :<yyyymmdd>. It needs write access to the registry; the header of the workflow explains the one-time Authorized Integration setup (a TOOLBOX_REGISTRY_AUDIENCE repository variable) or the CI_FORGEJO_REGISTRY_* secrets, the same arrangement Tempo and Cadence used for their own images. Each run builds from scratch (a job container keeps no layer cache), which is about forty minutes on amd64 and longer on the arm64 runners. The arm64 build has been exercised as far as resolving its package set under QEMU; if it misbehaves, drop the arm64 entry from the matrix and its line in the publish job, and everything else stands.

The registry refuses blobs over about a gigabyte, so each RUN that lays down something big is split so no layer crosses it. just layers shows them; the largest is under 400 MB compressed.

The same thing by hand: just build, then just push forge.example.net/you/toolbox:<tag> after podman login.

Using it locally

cd ~/Projects/some-project
~/Projects/toolbox/run.sh cargo build
~/Projects/toolbox/run.sh --shell

run.sh mounts the project (the git top level of the current directory) at its real host path and runs the command there as your own user, with HOME and the cargo home in a per-project directory under ~/.local/state/toolbox so caches persist without touching the checkout. The pub cache is your host's own, so package resolutions written inside are valid outside. It builds localhost/toolbox:latest from this directory the first time; set TOOLBOX_IMAGE to the registry's tag to use that instead.

just build, just shell, just versions, just layers and just push <target> wrap the same things; just build linux/arm64 builds the arm64 image under QEMU (slow, but it works with qemu-user-static on the host).

Inside the container TOOLBOX_CONTAINER=1 is set, and run.sh becomes a plain exec, so project tooling can go through it unconditionally.

Flutter and FVM

The SDKs live in FVM's cache layout at /opt/fvm/versions/<ref>, so a project's .fvmrc resolves against the image:

fvm flutter build linux     # the project's pinned SDK
fvm dart run build_runner   # its Dart
flutter --version           # the global default (FLUTTER_STABLE)

Only the current stable is preinstalled (also reachable as stable, which is what Cadence's .fvmrc names). Every other pin is fetched in the job by scripts/flutter-install, which is on PATH in the image, and kept between runs by the actions cache keyed on the pin:

      - name: Restore the pinned Flutter SDK
        uses: https://code.forgejo.org/actions/cache@v4
        with:
          path: /opt/fvm/versions/3.44.9
          key: flutter-sdk-3.44.9-linux-${{ runner.arch }}
      - name: Provision it if the cache missed
        run: flutter-install 3.44.9

flutter-install is idempotent: with the cache restored it prints one line and exits; otherwise it does a blobless partial clone (--filter=blob:none) of the tag or commit, fetches the tool's Dart SDK and snapshot, and precaches the Linux desktop artifacts, about 1.3 GB in around five minutes on amd64. Android, iOS and web artifacts are not fetched; flutter precache --android adds them if a project ever needs them. FVM's own fvm install also works but does a full clone, since its git cache (a complete mirror of flutter/flutter) is switched off in the image's global config and by run.sh for your user.

To change the preinstalled stable, bump FLUTTER_STABLE in the Containerfile. To bake in another version permanently, add a RUN flutter-install --stage ... group for it beside stable's; the image grows by about 1.3 GB per version.

Tempo and Cadence

Both projects currently build their own toolchain image from a Containerfile in the checkout and name it by a content digest. Everything those images carry is in this one, so switching them is a matter of the workflow and the run script:

  • Cadence (toolchain/Containerfile, toolchain/run.sh, .forgejo/workflows/ci.yml): the toolchain and toolchain-build jobs go away and container: image: becomes the toolbox tag. The workspace tool is no longer AOT-compiled into the image, so a job runs it with dart run tool/bin/cadence.dart (or compiles it in a first step and caches the binary). Its Dart pin moves from 3.13.2 to 3.13.3, a patch release; the Containerfile's warning about cross-build launchers deserves a file bundle/bin/cadenced check on each architecture the first time. CADENCE_TOOLCHAIN=1 is set by the image for now, so run.sh's in-container detection keeps working; run.sh itself can become a one-line wrapper of this repository's.
  • Tempo (platform/toolchain/Containerfile, .forgejo/workflows/ci.yml, .forgejo/sdks.sh): same job removal. The DART workflow variable now points at /opt/dart-sdk/bin/dart (3.13.3 rather than 3.12.2 in /opt/toolbox-test). Its three Flutter pins are fetched per job: replace sdks.sh with a cache step per pin plus flutter-install <pin> as shown above, and point the build tool at the result (TEMPO_FLUTTER_SDK=/opt/fvm/versions/3.44.9, or teach FlutterSdk.discover to look in $FVM_CACHE_PATH/versions/<version>). Alternatively keep sdks.sh and its build/sdks cache exactly as they are; they work unchanged in this image. TEMPO_TOOLCHAIN=1, ARCH=arm and CROSS_COMPILE=arm-linux-gnueabihf- are set by the image because the kernel build reads them from the environment; moving them onto Tempo's make line lets the image drop them later.

The kernel and firmware jobs that need rootful podman, loop mounts and binfmt still cannot run on the container runners; the image carries the tools for when a VM-backed runner exists.

Why bookworm

Tempo's Y2 and Cadence's Raspberry Pi targets run Debian bookworm (glibc 2.36). The cross toolchains link against the image's own libc, so building on a newer Debian would produce binaries those devices refuse to run. Move to trixie together with the devices, not before. The Rust and Dart toolchains are downloaded from upstream and are current regardless of the base.

Adding a tool

Add an apt package to the relevant RUN apt-get block, or a downloaded tool as its own section with a version ARG, a per-architecture sha256, and a --version check at the end of the RUN. Anything that writes into its own tree at run time (SDK caches, package managers) must end with chmod -R a+rwX, because locally the container runs as an arbitrary uid. Then just build, just versions, just layers to confirm no layer grew past a gigabyte, and push to main to publish.