The authoritative agent guide lives at
docs/AGENT_GUIDE.md. Read that file for complete architecture, setup, and troubleshooting.
tunaOS plans larger changes with Spektacular (RFC 011). Use it by the size of the change:
| Change | Spektacular |
|---|---|
| Needs an RFC under RFC-PROCESS.md | Required: spec and plan |
| Touches more than one area, or ports work between branches | Recommended |
| A single script fix or manifest edit | Not needed |
- Start. In Claude Code, use the
spek-new,spek-planandspek-implementskills in.claude/skills/. Other agents drive the same state machines withspektacular spec|plan|implement. Runspektacular version checkfirst; the pinned version isspektacularinimage-versions.yaml. - Knowledge.
.spektacular/knowledge/loads into every plan. When you find a new gotcha or convention, add it withspektacular knowledge writeand cite the file it comes from. A stale entry is worse than none. - Tests. The
testandverifysteps suggest Go tooling (*_test.go,make test). Ignore that: tunaOS tests withjust check,tests/bats/andtests/(pytest), as the rest of this file says. - No person present. Several steps wait for a user to confirm. When you run alone, approve the step yourself, record that in the plan's working context, and say so in the PR.
- Where the code is. This repository.
.spektacular/is its project folder, not a separate project. - Worked example.
.spektacular/specs/and.spektacular/plans/hold the #1893 spec and plan (PR #2682).
just fix && just check # format + validate (mandatory before every commit)
just test # bats + pytest (same as CI)
just ci # the whole PR gate locally: check + CI contract + test
just build yellowfin gnome # build a single flavor
just --list # show all available commandsThe build system is manifest-driven:
manifests/desktops/*.yaml → install-desktop.sh → image
Key scripts:
scripts/resolve-flavor.sh— flavor → build params (tested: 18 bats cases)scripts/resolve-image.sh— consolidated image ref lookupsscripts/build-image-inner.sh— the build engine (env-var driven)build_scripts/desktop/install-desktop.sh— generic DE installerbuild_scripts/lib.sh— shared library (OS detection, pkg abstraction)build_scripts/README.md— naming scheme (dirs = code path, numbers = phase order)
Containerfiles:
Containerfile.el10— RPM-family base + desktop stagesContainerfile.overlay— HWE/nvidia parameterized layerContainerfile.ubuntu— Ubuntu bootcificationContainerfile.debian— Debian bootcificationContainerfile.arch,.gentoo,.opensuse— other base families
Build pipeline: docs/PIPELINE.md
Variants do not all behave like the distro their version strings suggest. The one that has burned the most time:
hummingbird is NOT Fedora 43 and NOT EL10. It is a rolling,
security-hardened fork tracking Fedora Rawhide (Red Hat's Project
Hummingbird, zero-CVE), on the ARK kernel, and it ships no desktop
environment by design. Its .fc43 dist tags are Rawhide's numbering, not
evidence of Fedora 43 — a trap that has produced confidently wrong diagnoses
more than once, including attributing its empty desktop to a package loss in a
repository it does not even read.
Read docs/HUMMINGBIRD.md before filing a packaging
issue, blaming a build failure on a missing package, or assuming a Fedora
package set is available. Since 2026-09-02 hummingbird:gnome takes the GNOME
stack from projectbluefin/utah-packages (digest-pinned OCI repo, see
image-versions.yaml), and only the rest from repo.tunaos.org/hummingbird. Measure the index instead of inferring it —
repodata is public and small:
curl -s https://repo.tunaos.org/hummingbird/20251124-x86_64/repodata/repomd.xml
# then fetch the primary.xml.gz it names and grep for <name>PKG</name>Write manifests/desktops/<name>.yaml. No shell script needed. See existing manifests for the format.
Six rules, each one written because a real agent PR broke it and the break was measured. A PR that violates one of these gets closed, not reviewed.
- Run what you claim to fix, and paste the result. A PR titled "restore green main" must contain the passing test output for the tests that were red. #1828 fixed some occurrences of an escaping bug, missed others, never ran the test suite it claimed to restore — the suite was still red with the patch applied, and the PR was closed as superseded.
- Verify the external claim in the PR body. A one-line transport or pin change still needs its one line of evidence (the curl, the resolved URL, the closing run link). #1852 was a correct https switch that a reviewer had to re-derive from scratch because the body carried no verification.
- Derive status, never transcribe it. Any table of current state
(what's published, what passes, what's listed where) must be emitted by
a script the repo runs on a schedule, or it is stale the day it merges.
This is the repo's core convention:
MATRIX-STATUS.md, the README matrix, andmatrix-provenance.jsonare all generated. #1814 hand-copied a 40-row live-data table into a committed doc and had to end with "re-check the sources when a cell looks stale" — that sentence is the anti-pattern naming itself. - Check main and open PRs before diagnosing or fixing. This repo
merges several PRs a day; the bug you measured yesterday may be fixed,
moved, or being fixed. #1725's recommendation had already landed via
another PR by the time it was filed; #1828 raced a fix that was already
further along.
git log --oneline -20and a PR-list search cost a minute; a stale PR costs a review cycle. - Rebase before opening. A PR based days behind main is reviewed against a repo that no longer exists. If the base moved under you while the PR was open, rebase and re-run rule 1 — the merge queue tests the merge result, and "it passed on my old base" is not evidence.
- Leave the docs better than you found them, in the same PR. Every PR
that diagnoses something writes what it learned down where the next
person will hit it — a row in
docs/ci-troubleshooting/failure-catalog.mdfor anything that cost you a debugging cycle, and a line here when the lesson generalises past one bug. The row states the SYMPTOM someone would search for, the measured CAUSE, and the FIX; a row that only says what changed is a changelog entry, not a troubleshooting entry. Doing this later means not doing it: the detail that makes a row useful is the command output you had in front of you at the time, and it is gone by the next session.
Evidence style, for anything you write into the repo (comments, docs,
commit messages): state the constraint and the measured run/log that proves
it, not the narrative of how you found it. Every load-bearing comment in
build_scripts/ follows this shape — match it.
Two further conventions, adopted from Hive (#2250):
- An incident is fixed when a test proves it cannot silently recur. A
fix for a bug that shipped an unusable or wrongly-promoted image lands
with a regression test in
tests/regressions/test_issue_<N>_*.py(see that directory's README). "Fixed the script" without the test is half a PR. - A gate that is declared is a gate that runs. If you move, rename, or
add a job that asserts a green criterion, update its
gatesblock in.github/green-criteria.yml;tests/test_ci_contract.pyfails when the contract and the workflows disagree, andjust test-contractruns it locally.
Six more, each measured while fixing the live-ISO and install path
(rows 33-40 of docs/ci-troubleshooting/failure-catalog.md carry the evidence):
- A permanently-red check is a dead gate, not a known issue. A check
that always says the same thing cannot report a regression. Three
assertions in
build_scripts/checks/e2e-runtime-checks.shwere red on every run, on every flavor, for reasons unrelated to what they claimed to test:graphical.target is activeasserted from inside that target's own startup transaction, the unit-graph gate failed on upstream units we do not ship plus a man-page check on an image that strips man pages, and the SSH host-key assertion fired on images that deliberately ship sshd disabled. Each had been passed over as background noise for months. When you see a failure that "always fails", that is the bug. - A boot job cannot wait for its own transaction. The runtime check
saw
startingbefore its oneshot exited (#2756). Sample valid manager states without claiming a settled desktop; reject broken and unknown states. - Being invoked is not being reached. A guard placed after an early
exit 0runs on nothing.40-services.shhas three package-manager paths and the first two end inexit 0; a login-banner guard added at the end of the file therefore ran on dnf images ONLY — silently never on Arch, which is the variant the bug was measured on. A test asserted all six Containerfiles invoke the script, which was true and not sufficient. Assert the line number precedes the firstexit, or assert the effect in a built image. - Load the data the desktop uses. The GNOME binary passed the contract without its schema RPM (Yellowfin, #2750). Query the compiled schema with GLib. The XML can exist without a compiled schema.
- Test the last writer. Desktop selection can pass and still be overwritten
by
90-image-info.shor an overlay file copy (PR #3465). Run the final writer sequence too; preserve explicit fork branding after stock selection. - Verify a fix in the built artifact, not in the source tree. The guard
above was present in the script, committed, reviewed and merged, and
absent from every image.
just buildthenpodman run --rm <image>to check the thing happened costs one command and is the only evidence that counts. - A test that fails only on developer machines is not automatically
environment noise. Ten
test_lib.batsOS-detection cases failed locally and passed in CI. The cause was a real defect:lib.shassignedjq's%_dbpath-style lookup straight intoBASE_IMAGE, so animage-info.jsonthat exists without abase-imagekey set it to the empty string and destroyed the caller's value. CI never saw it because the file only exists on ublue-derived hosts. Dismissing those failures three times cost more than reading one of them would have. - Guard a path before you destroy it.
readlink -f ""does not fail — it returns$PWD.rawhide_rpmdb_proberesolved an empty%_dbpaththat way and rancp -a "$PWD" ...thenrm -rf "$PWD", with the delete NOT conditional on the copy succeeding. On a nearly full disk the copy failed, the delete did not, and a fullbatsrun destroyed a checkout of this repository. Refuse empty and non-absolute paths before resolving them, refuse/and$PWDafter, and neverrm -rfon the strength of acpyou did not check. - A detection default is a silent fallback.
customize-live.shused to defaultDESKTOP=gnome(it now matches GNOME positively and exits on an unknown image, #2536), so a Pantheon image matched no branch, was treated as GNOME, and received GDM autologin — into an image whose only display manager is LightDM.liveuserexisted; nothing logged it in; the ISO booted to a black screen. An unrecognised input did not fail loudly, it became the default, and every downstream assumption followed from the wrong answer. When a default exists, test the unrecognised case explicitly. - Change bootstrap versions and their checksums together. Verify the actual versioned release bytes; tests that duplicate old pins do not verify a new release.
Follow commit identity. DCO sign-off is
optional for all commits, including automatic merges. Do not manufacture
attestations, add another contributor's sign-off, or block work on a missing
Signed-off-by trailer. Preserve existing trailers and commit history.
License, attribution, security, review, and merge queue rules still apply.
-
A parser import is a grammar choice. Use the reviewed parser on every host; do not let a newer standard library accept a different config language.
tests/test_config_parser.pychecks setup, backend version and parse limits. -
A missing binary path can be a package layout difference. Inspect the target repository filelists before softening a check; Hyperscale places Asahi diagnostics in
/usr/sbin(#1411).
GitHub Issues for tuna-os/tunaos, operated via gh CLI.
Compare exact titles before you update an issue from search results;
full-text search sent a GNOME failure to the COSMIC issue (#1734, run 37453199979).
needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix.
For retained ISO media, preserve the GitHub receipt/archive anchor.
Use the explicit branch for R2 media; never invent an archive member for R2 bytes.
The negative fixtures in tests/test_iso_retained_media_transport.py distinguish
byte consistency from trust in API freshness and producer authentication.
- Bound package acquisition separately from installation. Runner download timeouts
must never kill dpkg;
scripts/setup-runner-podman.pyand its #3812 regression fixtures enforce that boundary and verify cached packages before installation.
Workflow permission checks live in scripts/check-workflow-permissions.py.
Jobs can inherit an explicit workflow baseline; a job map alone leaves other
jobs dependent on repository defaults. See docs/CI-WORKFLOW-PUBLISHING.md
for the organization audit and caller/callee permission constraints.