How the whole thing works — every pipe, every gate, and why each one
exists. The User Guide shows people what to run; this
document tells you what happens between a commit and a user's bootc upgrade.
TunaOS is a fork-of-ideas from the Universal Blue
family — Bluefin (GNOME),
Aurora (KDE),
Bazzite (gaming),
Zirconium (Niri). We consume
their artifacts directly where we can (ghcr.io/projectbluefin/common,
ghcr.io/ublue-os/brew, ublue-os/akmods for NVIDIA, the Zirconium source
tree for our Niri stack). Where we can't, we re-derive their patterns. What
defines us is the same desktop on thirteen different bases, so most of
their Fedora-only machinery needs a base-agnostic equivalent here.
Four repositories and two artifact stores make up the machine:
flowchart LR
subgraph sources["Source repositories"]
T["tuna-os/tunaOS<br/>image definitions + CI"]
P["tuna-os/tunaos-packages<br/>package factories"]
I["tuna-os/iso-builder"]
F["fisherman + installer frontends<br/>(bootc-installer, tuna-installer-*)"]
end
subgraph stores["Artifact stores"]
G["ghcr.io/tuna-os/*<br/>OS images (signed, SBOM-attested)"]
R["repo.tunaos.org (R2)<br/>RPM/DEB repositories"]
end
U["upstreams<br/>projectbluefin/common · ublue-os/brew<br/>ublue-os/akmods · zirconium-dev/zirconium"]
P -->|"publishes repos"| R
R -->|"dnf/apt/emerge at build time"| T
U -->|"layers & sources"| T
T -->|"build, sign, promote"| G
G -->|"images"| I
I -->|"ISOs"| F
F -->|"installs"| G
- tunaOS (this repo) turns base OS images + package repos into desktop images and publishes them to GHCR.
- tunaos-packages builds the packages the bases don't have (see §5) and publishes RPM/DEB repos to R2.
- iso-builder wraps published images into live ISOs (via the externally
pinned
tacklebox, contract in TACKLEBOX-CONTRACT.md). - fisherman (Go, shared with Bluefin) + the GUI frontends do disk installs; INSTALLER-FRONTENDS.md tracks their parity matrix.
tunaos.toml drives everything CI does: 14 variants ×
their flavors (5 desktops + base + hardware tiers) × declared platforms
(amd64 / amd64-v2 / arm64). A cell is one (variant, flavor) pair —
yellowfin:gnome, bonito:kde-nvidia — and there are 147 of them with
build_image: true. Every scoreboard, gate, and denominator in the project
derives from this file, on purpose. A flavor that isn't declared here doesn't
exist. Tests enforce that the workflows regenerate from this file, and do not
drift.
Flavors stack in a strict DAG. Hardware layers go on top of desktop images, never the reverse, so CI builds each desktop exactly once per variant:
flowchart TD
base --> gnome & kde & cosmic & niri & xfce
base --> basehwe["base-hwe"] & basenvidia["base-nvidia"]
gnome --> gnomehwe["gnome-hwe"] & gnomenvidia["gnome-nvidia"]
kde --> kdehwe["kde-hwe"] & kdenvidia["kde-nvidia"]
gnomehwe --> gnh["gnome-nvidia-hwe"]
Stage 1 builds base. Stage 2 fans out the desktops. Issue #1729 decoupled
stage 2 from the green status of every architecture in the base. One dead
architecture does not strand every desktop. Stages 3–4 apply the hardware
layers in Containerfile.overlay.
Each per-variant workflow (build-<variant>.yml) calls build-variant.yml,
which fans each cell into the reusable pipeline. This is the heart of CI:
flowchart LR
B["build_push<br/>per-arch image build<br/>+ rechunk (chunkah)"] --> M["Manifest<br/>multi-arch manifest<br/>→ :tag-testing"]
M --> S["Sign<br/>cosign"]
M --> SB["Generate SBOM<br/>syft, memory-bounded<br/>(manifest fallback)"]
M --> GT["Gate<br/>qcow2 via bootc install<br/>boot + verify in QEMU"]
S --> PR["Promote<br/>:tag-testing → :tag"]
GT -.->|"advisory today"| PR
PR -.->|"run completes"| AT["attest-sbom.yml<br/>separate workflow_run<br/>in-toto attestation"]
The important details, each one paid for with a real incident:
- Two tags per cell. Builds land on
:<tag>-testing; Promote copies to the published:<tag>only after the pipeline passes. Users pull only the promoted tags. - Rechunk:
chunkahre-layers the image into ostree-friendly chunks sobootc upgradedownloads deltas, not the world — same technique as Bluefin. - SBOM generation is memory-bounded (
systemd-run --scope -p MemoryMaxwith a runner-derived cap). syft once OOM-killed whole runners on Gentoo-sized package inventories. If the scan still dies,scripts/packages_to_spdx.pysynthesizes an SPDX document from the image's own package manifest. Promote therefore never ships unattested. - The Gate builds a real disk from the image (
just qcow2→bootc install to-disk --via-loopback) and boots it in QEMU (scripts/iso-e2e.sh). It is advisory per cell today. Workstream W3 of GREEN-MASTER-PLAN.md makes itblockingwhere CI can boot: the gnome and base cells. The other cells need a DRM render node, which hosted runners lack. - Retries with judgement: promotion
skopeocopies, GHCR pushes and cosign calls retry with backoff. A Sigstore outage downgrades the attestation; it does not block Promote (#1560). - SBOM attestation is a separate run, not a job. When a build workflow
completes, a
workflow_runtrigger startsattest-sbom.yml. It reads the digest and SBOM artifacts of that run and attests them. Before, the job ran inside the build.continue-on-errorlet the job itself pass, but the caller still reported the result of the whole reusable workflow. So one outage of the transparency log caused a nightly with 27 of 30 images green to reportfailure. The README matrix then showed those images as unbuilt (#2282). A separate workflow has its own result. Recovery stays the same:rerun-infra-failures.ymlfinds theSIGSTORE_OUTAGEmarker and runs the job again once.
Per-family Containerfiles (Containerfile.el10, .debian, .ubuntu,
.arch, .gentoo, .opensuse, .final, .overlay) all execute the same
numbered pipeline from build_scripts/:
lib.shis the shared vocabulary. The functions that matter:install_available/apt_install_available— install what the repo set can resolve, and record every miss viarecord_package_wishlistinto/usr/share/tunaos/missing-on-*.txtinside the image. Misses are tolerated at install time and judged later (see §6) — that split is deliberate.install_rawhide_tolerant— Rawhide-only--skip-brokenretry for the RPM Fusion multimedia skew; strict on every pinned release. Rawhide is detected from os-release (detect_fedora_ver), becauserpm -E %fedoraexpands to a number even on Rawhide.safe_enable/safe_disable— unit toggles that tolerate absent-on-this-variant units.
manifests/desktops/<desktop>.yamldeclares each desktop's package sets per base family (required= contract surface,optional= best-effort), consumed byinstall-desktop.sh. The Niri stack is built from the pinned Zirconium source (install-zirconium.sh,image-versions.yaml).build_scripts/checks/are the in-build gates.verify-desktop-experience.shchecks the desktop contract: sessions, DM and portals present.verify-package-wishlist.shfails the build unlesspackage-miss-allowlist.txtdeclares every recorded miss acceptable. Silent omissions are how marlin:kde once shipped with no wayland-sessions at all (#858).system_files/is the plain-file overlay (brand assets, defaults,00-tunaos.tomlbootc install config).
just build yellowfin gnome # build an image locally
just qcow2 ghcr.io/tuna-os/yellowfin:gnome-testing # disk image from any ref
just run-qcow2 yellowfin gnome # boot it in QEMU
just check # linters
just test # pytest + batsOne sharp edge, now fixed, is still worth a note: recipes live in imported
just/ modules. The directory an imported recipe runs in depends on the
just version (apt's 1.21.0 runs them from just/, 1.25+ from the repo
root). Every module recipe therefore starts with
cd {{ justfile_directory() }}, and a test sweep keeps it that way — don't
remove those lines.
The test suite (tests/, pytest + bats) is unusual on purpose. Most tests
pin workflow behavior: YAML structure, generated files, gate semantics.
This repo's product is largely its pipeline. If you change the
pipeline's shape, expect a test to tell you which document or generator you
also need to update.
| UTC | What |
|---|---|
| 22:20 | check-base-image-pins — every pinned base digest must still resolve (#1788: upstream GC'd three at once) |
| staggered, 00:00-23:00 | All 13 build-<variant>.yml nightlies, each pinned to its own cron hour to spread runner load |
| 08:00 | Desktop contract sweep — pulls every published desktop image; §6 |
| 13:30 | README build-status refresh → automation PR |
| on cell movement | MATRIX-STATUS regeneration → automation PR |
main only accepts merge-queue changes, so every automation pushes a branch
and opens a PR (direct pushes bounce off the rule). Automation PRs use one
long-lived branch each, force-pushed, so there's never more than one open.
The variants only work because someone builds the packages their bases lack. That someone is tunaos-packages:
flowchart TD
subgraph factories["tunaos-packages"]
X["EL10 XFCE factory<br/>(mock, distributed tiers)"]
GN["GNOME 49/50/51 factories<br/>(mock, per-release)"]
H["Hummingbird desktop factory<br/>(runtime-gap, 673 pkgs)"]
TF["tideforge<br/>(DEB builds for grouper/flounder)"]
BH["Gentoo binhost<br/>(guppy binary packages)"]
end
R2["repo.tunaos.org (R2)"]
UP["upstream repos<br/>(Fedora, RPM Fusion,<br/>public-hummingbird, COPRs)"]
X & GN & H & TF & BH --> R2
UP -.->|"buildroot inputs"| factories
R2 --> tunaOS["tunaOS image builds"]
Design rules that hold across all factories:
- Tiered builds: topological orders over the real BuildRequires graph. A tool computes these orders; nobody maintains them by hand. The build orders also generate the workflows that run them, and tests enforce that regeneration.
- A run publishes only when it built something. A run that compiled zero
packages must not re-sign the old repo. It must not re-upload that repo as
if it were fresh.
INCIDENT-repo-wipe-gnome.mdis the origin story; every R2 writer now carries the refuse-to-publish-empty guard. - The tooling regenerates repos in full, not with
createrepo_c --updatepatches. Stale metadata entries once served checksums for RPMs that no longer existed (the gtkgreet drift class). - The Hummingbird factory tracks its upstream automatically. Its build
order is the runtime gap. It is everything the desktops need, minus
everything
public-hummingbirdalready ships.measure-hummingbird-gap.pymeasures this gap, from themembership: runtimeentries in the catalog. Build-only tools come from the buildroot's inherited Rawhide fallback. A daily gap-drift detector re-measures the gap when upstream publishes a new repo revision. The detector opens a PR with the adds and drops. When upstream adopts a package we build, that package drops out of our order. Upstream's build then wins by repo priority. Our.fc43dist tag sorts below their.hum1on purpose, so we can never shadow them.
This is the part most forks of Universal Blue don't have, and the part this
project considers its real product. .github/green-criteria.yml holds
the bar: ten criteria, each with an explicit enforcement
(blocking / advisory / unimplemented). A composite rule then applies:
a cell counts as green only when every blocking criterion has a current
affirmative result; skipped or never-tested never counts.
flowchart LR
subgraph evidence["Evidence producers"]
BLD["nightly builds<br/>(Promote, Gate)"]
SWEEP["contract sweep 08:00<br/>desktop contract +<br/>omissions manifest read"]
LUKS["LUKS E2E<br/>(install axis)"]
ISO["installer smoke<br/>(ISO axis)"]
LC["bootc-lifecycle<br/>(upgrade/rollback axis)"]
PIN["pin check 22:20<br/>(rebuildable axis)"]
end
CRIT[".github/green-criteria.yml<br/>enforcement per criterion"]
GEN["scripts/gen-matrix-status.py<br/>composite scorer"]
MS["docs/MATRIX-STATUS.md<br/>Composite green — the bar"]
RM["README<br/>Built X/147 · composite green Y/147"]
evidence --> GEN
CRIT --> GEN
GEN --> MS
CRIT --> RM
The important properties:
- Enforcement is data, not code. To graduate a criterion from advisory
to
blocking, edit one line ingreen-criteria.yml. The composite table and the README count then tighten automatically. If you make a criterionblockingwithout a wired per-cell assertion, the test suite fails with instructions. A raised bar can never silently render as a blank board. - Absence of evidence is never failure, and never success. Skipped, missing, cancelled, and lost all render ⬜ and count against green. This is the #1730 rule, applied uniformly.
- Nothing trusts a published image; the sweep re-checks it. The 08:00
sweep pulls every published desktop image. It then runs the same scripts
the build ran: the desktop contract, and the omissions gate against
/usr/share/tunaos/missing-on-*.txt. The sweep judges an image even when it shipped before the gate existed. - A generator writes the scoreboard, and tests hold it honest. The tests go down to fine details. One: "a flavor removed from build-config keeps its last verdict visible but leaves the denominator". Another: "the README refresh opens a PR because direct pushes to main bounce".
MATRIX-STATUS.md shows where each axis stands today, per
cell. GREEN-MASTER-PLAN.md holds the plan to drive
all of it to blocking.
Before you debug a red run from scratch, check it against the catalogue:
ci-troubleshooting.md and the Failures that look
like successes section of MATRIX-STATUS.md. Highlights:
- A cancelled run is not a verdict — every reader skips superseded runs; never count them as failures.
continue-on-errormasks step outcomes in the jobs API. Read the raw logs when a step's real exit matters. The logs API gives you a signed blob URL.- Job names matter: the matrix readers key on
<variant> / <flavor> / <Stage>names. A rename of a job breaks the scoreboards. - CI retries the infra flakes by classification (#1731), not by hand.
| Path | What lives there |
|---|---|
tunaos.toml |
The matrix — single source of truth |
.github/green-criteria.yml |
The bar |
.github/workflows/ |
Per-variant entries, build-variant.yml, reusable-build-image.yml, sweeps and refreshers |
Containerfile.* |
Per-family image definitions |
build_scripts/ |
The numbered build pipeline, lib.sh, checks/, desktop/ |
manifests/desktops/*.yaml |
Desktop package contracts per base family |
system_files/ |
Plain-file overlay shipped into every image |
just/ + Justfile |
Local dev entry points (build, qcow2, VMs, tests) |
scripts/ |
Pipeline tooling: matrix generator, parity, iso-e2e, SPDX synthesis |
tests/ |
Pytest + bats — largely pipeline-behavior pins |
docs/ |
You are here; PIPELINE.md is the quick reference |
- Branch;
mainis merge-queue only. - Make the change and the change's paper trail. Did you touch the pipeline's shape, a generator, a gate, or the matrix? Then a test or a generated document must move with the change. CI will tell you which.
just check && just testlocally.- PR; the queue runs the required checks and merges.
The house style comes from hard experience, and review enforces it. Measure, don't assume: comments in this codebase cite run IDs. Loud beats silent: a gate that can't score must fail, not skip. Absence of evidence is not evidence of absence: if your change leaves something unchecked, say so in the scoreboard. Don't let it render green.