Skip to content

Split shared CI images into granular tiers matched to org toolchain profiles #22

Description

@ScottMorris

Summary

The shared CI image family is effectively monolithic. ci-base bakes in the full Tauri desktop stack (GTK, webkit2gtk, appindicator, rsvg, patchelf, xdg-utils, libssl-dev), and ci-toolchain piles Node 24 + pnpm + Rust + cargo-nextest + tauri-cli into one stage. Every consumer inherits everything: a pure-Rust codec library pulls roughly 1.5 GB of GUI and Node tooling it never uses, and there is no image a Rust-only repo can adopt without that overhead.

A toolchain survey of all active Liminal HQ repos plus ScottMorris/city-sim-1000 (2026-07-22) shows the org's real toolchain clusters. This issue proposes re-layering the shared Dockerfile into granular tiers where each layer adds exactly one concern, publishing two new CI images (ci-rust for pure-Rust repos and ci-web for JavaScript/TypeScript repos), mirroring the same tiers in the dev image family (dev-rust, dev-web), and adding Bun to the Tauri images — while preserving the existing published image names and contracts.

The dev family is a first-class deliverable here, not a byproduct: the tauri-dev-* images are actively used on desktops and laptops as portable toolboxes for running tools that are not installed on the host, and today they are built from the same Dockerfile and workflow as the CI images. The granular tiers need to exist on both sides.

What the survey found

  • Five repos consume the shared CI images today: threshold and foyer use tauri-ci-mobile; spindle, emoji-nook, and tauri-plugins-workspace use tauri-ci-desktop. Everything else runs on plain runners with setup actions.
  • Tauri + Bun is a real, unserved combination. foyer (Tauri desktop + Android, bun.lock) curl-installs Bun into tauri-ci-mobile on every CI run because the image only ships pnpm. city-sim-1000 (Rust + Bun, Tauri plugin crate) apt-installs webkit2gtk/appindicator/rsvg on plain runners every run. Bun is a house standard beyond that: smdu and coherence-chat-exporter both use bun build --compile for release binaries.
  • JavaScript-only repos run on bare runners today, but only for historical reasons. smdu, coherence-chat-exporter, the-lab, and liminal-hq.github.io all use setup-node/setup-bun/pnpm actions because the shared images were originally Tauri-focused, not because containers do not fit. They are intended adopters of a JS tier. Their runtime usage is mixed (pnpm, npm, and Bun in combination — smdu uses pnpm for deps and Bun to compile release binaries; coherence-chat-exporter uses npm plus Bun compile), which argues for one combined JS image rather than per-runtime forks.
  • Five pure-Rust repos have no suitable image: flow, flicker, libhdmv, libvc1, and mudroom need only a pinned Rust toolchain, clippy/rustfmt, cargo-nextest, and gh — none of the GUI or Node stack.
  • threshold desktop jobs cannot use the CI desktop image. They apt-install the full GTK stack plus GStreamer (base + bad), fonts-noto-color-emoji, and xvfb on host runners each run; the CI desktop image lacks those last three (the dev desktop image already has them).
  • cargo-nextest is reinstalled per run via taiki-e/install-action in threshold, flow, and city-sim-1000 even though the shared images already bundle it.
  • Rust pins are drifting: shared images pin 1.93.0, while threshold pins 1.96.1 and city-sim-1000/flow pin 1.94.
  • ARM matters at the Rust tier: threshold, spindle, emoji-nook, flow, smdu, coherence-chat-exporter, and city-sim-1000 all run ubuntu-*-arm jobs.
  • The dev images are used as host-side toolboxes, not just devcontainers. The tauri-dev-* images see regular interactive use on desktops/laptops to run tools that are not installed on the host. libvc1 demonstrates the granularity gap on this side too: its local docker/Dockerfile.toolchain bases on tauri-dev-desktop (plus ffmpeg) just to get a Rust toolchain, inheriting the entire GUI/Node/Tauri stack it never uses.
Condensed toolchain matrix (survey, 2026-07-22)
Repo JS pkg mgr Rust Tauri CI image today Notable CI deps
threshold pnpm 10.28.0 1.96.1 pinned desktop + Android + Wear tauri-ci-mobile (Android/Wear only) GStreamer base+bad, emoji fonts, xvfb, nextest, arm64
spindle pnpm 10.32.1 1.93.0 pinned desktop tauri-ci-desktop nextest, arm64 releases
emoji-nook pnpm 9.15.0 stable desktop tauri-ci-desktop shared AppImage workflow, arm64
foyer Bun stable desktop + Android tauri-ci-mobile Bun curl-installed every run
tauri-plugins-workspace pnpm 10.28.2 1.93.0 pinned plugins (desktop + Android) tauri-ci-desktop covector, aarch64-android target
city-sim-1000 (ScottMorris) Bun 1.94 pinned plugin crate (shell WIP) none apt webkit stack every run, nextest, wasm-pack, arm64
smdu pnpm + Bun compile none Bun cross-target binaries
coherence-chat-exporter npm + Bun compile none Bun cross-target binaries
flow 1.94 none nextest, arm64 releases
flicker stable (MSRV 1.87) none
libhdmv stable none
libvc1 stable none (dev: tauri-dev-desktop + ffmpeg) ffmpeg wanted locally
mudroom edition 2024 no CI
the-lab, liminal-hq.github.io pnpm none Pages deploys
skills, liminal-hq-report — (Python) none setup-python
prismatic, haptics-lab-app docs only none future Godot / Tauri Android

Proposed image tree

Each layer adds exactly one concern; single inheritance throughout; every layer inherits the pinned Rust toolchain from one ARG RUST_TOOLCHAIN source of truth.

ci-base                 universal CLI only: ca-certificates, curl, wget, git, gh,
│                       file, unzip, build-essential, pkg-config
│                       (GTK/webkit/appindicator/rsvg/patchelf/xdg-utils/libssl-dev
│                        move OUT of base, DOWN into the Tauri desktop tier)
│
├── ci-web              + Node 24 + pnpm + Bun (one combined JS image)
│                       NEW published image: ghcr.io/liminal-hq/ci-web
│                       linux/amd64 + linux/arm64
│                       ← smdu, coherence-chat-exporter, the-lab,
│                         liminal-hq.github.io
│
└── ci-rust             + rustup (pinned) + clippy + rustfmt + cargo-nextest
    │                   NEW published image: ghcr.io/liminal-hq/ci-rust
    │                   linux/amd64 + linux/arm64
    │                   ← flow, flicker, libhdmv, libvc1, mudroom
    │
    └── tauri-ci-desktop  + Node 24 + pnpm + Bun (same runtime block as ci-web)
        │                   + GTK/webkit2gtk/appindicator/rsvg/libssl-dev
        │                   + patchelf/xdg-utils + tauri-cli
        │                   existing name and contract, now with Bun
        │                   ← spindle, emoji-nook, tauri-plugins-workspace,
        │                     threshold desktop (pending open question 1),
        │                     city-sim-1000
        │
        └── tauri-ci-mobile  + Java 17 + Android SDK/NDK + Android Rust targets
                             existing name and contract, inherits Bun
                             ← threshold (Android/Wear), foyer

Single inheritance means the JS runtime block appears twice (in ci-web off base, and again in the Tauri desktop tier off ci-rust). That duplication is deliberate: both sites install from the same ARG NODE_MAJOR / PNPM_VERSION / BUN_VERSION pins, so versions cannot drift, and it avoids COPY --from gymnastics or forcing pure-Rust images to carry Node.

The dev family mirrors the same tiers on the devcontainer base, keeping its existing contract (non-root vscode user, tool paths under $HOME, interactive QoL packages):

dev-base                devcontainers base + universal CLI + interactive QoL
│                       (vim, editors, etc. — GUI/Tauri system libs move DOWN
│                        into the Tauri desktop tier, same eviction as CI)
│
├── dev-web             + Node 24 + pnpm + Bun (user-home paths)
│                       NEW published image: ghcr.io/liminal-hq/dev-web
│
└── dev-rust            + rustup (pinned) + clippy + rustfmt + cargo-nextest
    │                     (user-home paths)
    │                   NEW published image: ghcr.io/liminal-hq/dev-rust
    │                   ← libvc1 local toolchain (adds ffmpeg on top),
    │                     pure-Rust repo devcontainers and host-side toolbox use
    │
    └── tauri-dev-desktop  + JS runtimes + GUI libs + GStreamer/xvfb/emoji fonts
        │                    + tauri-cli — existing name and contract, now with Bun
        │
        └── tauri-dev-mobile  + Java 17 + Android SDK/NDK under $HOME
                              existing name and contract, inherits Bun

Why this shape and not deeper splits

  • No ci-rust-bun / ci-rust-pnpm fork. The survey shows the Rust+JS combos are all Tauri repos, and both runtimes together cost ~200–300 MB — trivial next to the GTK stack (~1.5 GB) and Android SDK (~4 GB). Carrying both runtimes in the Tauri tier removes the fork entirely and fixes foyer's per-run Bun install. It also avoids the strawman's structural bug: with a Tauri+Bun consumer (foyer), a Tauri image anchored to a pnpm-only branch cannot serve it.
  • One ci-web, not ci-bun / ci-node-pnpm. The JS-only repos mix runtimes within a single repo (pnpm or npm for deps, Bun for compiled release binaries), so per-runtime images would force most of them to pick wrong. One combined image carries Node + pnpm + Bun for a few hundred MB total and serves all four intended adopters. Note the containers apply to the Linux legs only — smdu's and coherence's macOS/Windows release jobs stay on native runners, and Pages deploy actions keep working inside container jobs.
  • Existing names stay. tauri-ci-desktop, tauri-ci-mobile, tauri-dev-desktop, and tauri-dev-mobile keep their names, env models, tags, and smoke contracts, so current consumers migrate by doing nothing (foyer can simply delete its Bun install step). Renaming to a uniform scheme can be a separate follow-up if wanted.
  • Dev tiers mirror CI tiers. Both families already build from one Dockerfile with shared ARG pins and one workflow; keeping the tier structure symmetric preserves that alignment and gives host-side toolbox use right-sized images — a Rust toolbox without 1.5 GB of GUI/Node, a JS toolbox without rustup. New dev images follow the existing dev platform contract (linux/amd64 today; ARM remains the documented follow-up).

Consumer migration map

Repo Moves to Wins
flow, flicker, libhdmv, libvc1, mudroom ci-rust (new adoption) pinned toolchain + nextest + gh preinstalled; no GUI/Node dead weight; arm64 variant for flow's ARM releases
smdu, coherence-chat-exporter, the-lab, liminal-hq.github.io ci-web (new adoption, Linux jobs) pinned Node/pnpm/Bun without per-run setup actions; arm64 variant covers smdu's ubuntu-24.04-arm binary job; macOS/Windows release legs stay on native runners
city-sim-1000 tauri-ci-desktop drops per-run apt webkit stack; Bun and nextest preinstalled
foyer tauri-ci-mobile (stays) deletes the per-run Bun curl-install
threshold (desktop jobs) tauri-ci-desktop (pending open question 1) drops the large per-run host apt install
spindle, emoji-nook, tauri-plugins-workspace tauri-ci-desktop (stay) no change required; smaller effective delta on rebuilds
threshold, flow, city-sim-1000 workflows n/a can drop taiki-e/install-action@nextest steps
libvc1 local toolchain container dev-rust (+ repo-local ffmpeg layer) drops the full GUI/Node/Tauri stack it inherits from tauri-dev-desktop today
pure-Rust and JS-only repo devcontainers / host toolbox use dev-rust / dev-web right-sized interactive images with the established non-root, $HOME-path ergonomics

Open questions

  1. GStreamer + xvfb + emoji fonts in tauri-ci-desktop? threshold is the only consumer that needs them, but it is the org's most complex repo and today its desktop jobs cannot use the image at all. The dev desktop image already carries them, so adding them to the CI desktop tier aligns the families. Recommendation: add them; revisit if image size becomes a concern.
  2. Rust version policy. Images pin 1.93.0 while consumers pin 1.94 and 1.96.1. Options: (a) bump the single org pin and have repos align; (b) publish version-suffixed tags (e.g. ci-rust:1.96) alongside latest; (c) rely on rustup honouring each repo's rust-toolchain.toml at runtime (costs a per-run toolchain download, defeating part of the purpose). Recommendation: (a) + keep the existing sha-/date tags for pinning; consider (b) only if repos genuinely need to straddle versions.
  3. wasm tooling (wasm32-unknown-unknown, wasm-pack) — only city-sim-1000 needs it. Recommendation: leave per-repo (rustup target add is cheap on top of ci-rust-based images).
  4. Publish workflow scale. Mirroring the tiers takes the publish matrix from 4 images to 8, each with its own buildcache ref and smoke profile. Options: keep one matrix (simplest, more parallel jobs per publish) or split CI and dev families into separate workflows sharing the Dockerfile. Recommendation: keep one matrix until publish duration or cache behaviour becomes a problem; the bi-weekly cadence keeps total cost modest.
  5. Interactive QoL placement in the dev tiers. Which of dev-base's extras (vim, xvfb, GStreamer, emoji fonts) are universal QoL versus Tauri-desktop concerns that should move down with the GUI libs? Improve the shared Tauri desktop dev image for local validation and Linux desktop debugging #10's wishlist (ripgrep, fd, jq, X11 inspection tools) lands naturally in dev-base if it is truly universal. Recommendation: xvfb/GStreamer/fonts move to the Tauri desktop tier; editor/search/JSON tools stay in dev-base.

Acceptance criteria

  • docker/ci/Dockerfile re-layered for both families: ci-base and dev-base contain universal tooling only; GUI/Tauri system libs live in the desktop tiers; each stage adds one concern.
  • ghcr.io/liminal-hq/ci-rust published (linux/amd64 + linux/arm64) with smoke checks (cargo, rustup, clippy, rustfmt, cargo-nextest, gh) and its own buildcache ref.
  • ghcr.io/liminal-hq/ci-web published (linux/amd64 + linux/arm64) with smoke checks (node, pnpm, bun, gh) and its own buildcache ref, with Node/pnpm/Bun pins shared with the Tauri tier via common ARGs.
  • ghcr.io/liminal-hq/dev-rust and ghcr.io/liminal-hq/dev-web published (linux/amd64, matching the dev family platform contract) with dev-style smoke checks: non-root user, tool availability, $HOME tool paths set and writable.
  • Dev family env model preserved (HOME=/home/vscode, CARGO_HOME=$HOME/.cargo, PNPM_HOME=$HOME/.local/share/pnpm, Android under $HOME/Android/Sdk), and version pins stay aligned between CI and dev tiers via shared ARGs.
  • Bun added to tauri-ci-desktop and tauri-ci-mobile, pinned like Node/pnpm, covered by smoke checks.
  • Existing published image names, env model (CARGO_HOME=/usr/local/cargo etc.), tag policy, and platform coverage preserved for current consumers.
  • Open questions 1–2 and 5 decided and recorded in the reference docs.
  • Docs updated together: README.md, docs/reference/shared-image-layout.md, docs/reference/shared-image-implementation-spec.md, docs/runbooks/image-publish-and-rollback.md.
  • docs/tracking/cross-repo-ci-alignment.md and Track cross-repo CI image and quality-gate normalisation #3 updated with the new adoption targets (pure-Rust repos, JS-only repos onto ci-web, city-sim-1000, foyer cleanup, threshold desktop migration).

Non-goals

  • Per-runtime JS images (ci-bun, ci-node-pnpm) — the combined ci-web covers all current JS consumers; split only if a real conflict appears.
  • Baking ffmpeg into shared images (libvc1's local validation want — keep repo-local).
  • Python/Godot lanes (skills, prismatic) — bare runners serve them fine.
  • Emulator tooling, automatic downstream rollout, per-repo cache mounts (unchanged from the existing spec's out-of-scope list).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    ciContinuous integration workflows and checksdockerContainer build and publish workenhancementNew feature or requestinfrastructureShared infrastructure and automation workrustRust tooling and build and test worktauriTauri platform or plugin-specific work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions