Imported from halcyon-linux/atomic (
container-based/AGENTS.md). Install upstream withnpx skills add halcyon-linux/atomic --skill container-based. Copyright stays with the author.
AGENTS.md — halcyon
Guidance for AI coding agents (and humans) working in this repository.
Branch of record: container.
Task-level procedures live in SKILLS.md — read this file first, then
consult SKILLS.md when your task matches one of its skills.
1. What this repo is
halcyon builds a single bootc OCI image: a lean Hyprland gaming desktop on
quay.io/fedora/fedora-bootc:44 (ARG FEDORA_VERSION=44, mirrored by
FEDORA_VERSION=44 in halcyon.env), with the catpieleaf/kernel-p03 kernel,
prebuilt nvidia-open modules, negativo17 NVIDIA userland, the noctalia greeter
on greetd, and ujust/uupd for user-facing system tasks.
It is not a BlueBuild project and not layered on Bazzite. It borrows
Bazzite's repo structure (scratch ctx stage, semantic unnumbered build
scripts, per-RUN bind mount, /ctx/cleanup after every mutating RUN —
finalize is the one exception, it is itself the hygiene sweep — and bootc container lint as the hermetic final gate) and vendors some Bazzite .just
recipes and Steam wrappers (bazzite-steam*), but the base is plain
fedora-bootc.
The GitHub workflow builds with podman build via the Justfile. There is no
BlueBuild action anywhere in .github/ of this branch — verify-github.sh
fails the build if one appears. If you see one, you are on main (the older,
Bazzite-layered branch that this branch replaced), or a scheduled run is
executing the repository's default branch's workflow instead of
container's.
Output: ghcr.io/aahsnr-work/halcyon:<tag>, cosign-signed in CI using the
legacy sigstore format (§4.10). build.yml publishes only from
PUBLISH_BRANCH (container).
2. Repository layout
Containerfile # 18 RUN stages (banners 00–17) + hermetic bootc lint
Justfile # check / lint / lint-python / test-python / check-github /
# build / verify-image / generate-build-tags / …
halcyon.env # dotenv consumed by the Justfile (FEDORA_VERSION, IMAGE_NAME, …)
packages.json # single source of truth for every dnf/flatpak package (+ _docs notes)
cosign.pub # public signing key — COPY'd into ctx, installed by image-info
.containerignore # keeps docs/notes/verify/.github out of the build context
README.md # user-facing readme
TODO.md # task checklist (design doc)
notes/ # ad-hoc helper scripts (gpg-sign.sh); never shipped
build_files/ # the `ctx` stage — NEVER ends up in the image
packages-lib # jq accessors every stage sources (packages_for, packages_excludes, …)
cleanup # end-of-RUN hygiene; called after every mutating RUN except finalize
libdnf5.conf.d/ # dnf5 retry drop-in (installed in Stage 00 — must stay the first RUN)
base/ # setup-repos, remove-packages
kernel/ # install-kernel, kernel-verify
packages/ # install-packages, install-terra, install-devtools, packages-verify
brew/ # install-brew-bundle, brew-verify
runtime/ # install-nix, setup-flatpaks, setup-ujust + *-verify
apps/ # install-built-apps + sub-installers, built-apps-verify
desktop/ # configure-system, image-info, build-plymouth-assets,
# system-verify, branding-verify
finish/ # build-initramfs, finalize, final-verify
python-packages/ # 11 stdlib-only src-layout Python tools (dump-to-markdown, fconf,
# fe, ff, fkill, fp, fssh, rmi, rmtmp, screenshot, se)
system_files/shared/ # static tree COPY'd to / BEFORE any RUN stage
verify/ # run-all.sh + image-side suites (brew/chezmoi/ujust) +
# host-side .github audit (verify-github.sh)
.github/ # build / lint / clean / semantic-pr workflows + dependabot/renovate/helpers
Paths in scripts are /ctx/<folder>/<script> — the ctx stage copies
build_files/ to / and puts packages.json and cosign.pub at the root, so
build_files/apps/install-obsidian is /ctx/apps/install-obsidian and the
catalog is read from /ctx/packages.json.
3. Commands
just check # just --fmt --check, bash -n over build_files + verify + .just recipe bodies,
# packages.json validity + group-consumer consistency
just lint # shellcheck --shell=bash -x over every build_files script
just lint-python # ruff (E9 + F821/F822/F823) over the python helpers
just test-python # pytest suites for the python helpers that have them (dump-to-markdown, rmi)
just check-github # host-side .github audit (verify/verify-github.sh)
just build # podman build with the CI label/ARG scheme
just verify-image # run verify/verify-{brew,chezmoi,ujust}.sh inside the built image
just package-count # run the built image, report RPM count + kernel version
Always run just check and just lint before proposing a change to anything
under build_files/. A missing fi or an unquoted expansion in a build
script costs a full ~40-minute CI build.
4. Build architecture — rules that must not be broken
-
Stage order is load-bearing (stages 00–17, banners
nn/17). The dnf5 retry drop-in lands in Stage 00 — it exists to survive Copr 504s during the very stages that follow.setup-reposruns next (it bootstrapsjq, whichpackages-libneeds to readpackages.json); removals run against the near-pristine base; the initramfs is built last (so the plymouth theme and NVIDIA dracut hooks are baked in);finalizeandfinal-verifyclose the build; the hermeticbootc container lintis the final gate. -
Every mutating
RUNends with/ctx/cleanup. The single exception isfinalize(Stage 15) — it IS the end-of-build hygiene sweep (third-party repo files,keepcache=0,/tmp+ logs +/boot+ caches) and nothing is left to clean after it.final-verifyand the bootc lint mutate nothing and do not call it either. -
--setopt=install_weak_deps=Falseon everydnf5 install. Anything that used to arrive as a weak dependency must be listed explicitly (e.g.flatpak-selinux,hyprland-guiutils, the xdg portals,qt6ct,nwg-look). -
Third-party repo lifecycle: enable → consume → disable, inside one stage.
finalizeis the belt-and-braces sweep that deletes every third-party repo file (Copr, vscode, brave, terra, negativo17, rpmfusion);final-verifyasserts that only Fedora repo files remain. The shipped image carries no third-party repo files — updates arrive via image rebuilds (bootc). -
NVIDIA comes from negativo17 only (RPM Fusion's nvidia chain is excludepkgs'd in
setup-repos). Four negativo17 subpackages (nvidia-driver,nvidia-driver-cuda,nvidia-settings,nvidia-kmod-common) are dependency-entangled with a kmod package and are payload-extracted viarpm2cpio, not installed. Do not "fix" this by adding them to adnf5 installline — it pullsdkms-nvidia, whichConflictswithkernel-p03-nvidia-open. -
Kernel and NVIDIA RPMs install with
--setopt=tsflags=noscripts;depmodanddracutare run explicitly (build-initramfs). -
Per-stage verification. Every install stage runs a
<stage>-verifycompanion in the sameRUN, exceptinstall-terraandinstall-devtools(each verifies every listed package withrpm -qinside the stage) andbuild-initramfs(its output is gated byfinal-verify's kernel group). Cross-cutting checks that only the finished image can answer go infinal-verify. -
This is bootc, not rpm-ostree. Kernel arguments are changed with
grubby; the cmdline is read from/proc/cmdline;bootc statusreplacesrpm-ostree status. (halcyon-rebase.justkeeps acommand -v rpm-ostreefallback for non-bootc hosts — that is deliberate and is not an excuse to write new rpm-ostree code.) -
A package a recipe shells out to must be in
packages.json.ugumfalls back to fzf whengumis absent, sogumis not required — butgrubby,ethtool,wget(the F44 binary iswget2-wget),hostname,fpaste,wl-copy,zenityandjqare, andujust-verifygates them all withcommand -v. -
Image signatures use the legacy sigstore format. Cosign 3 defaults to a referrer-based bundle that
cosign verifyaccepts but containers/image (podman, skopeo, bootc) cannot see.build.ymlsigns with--new-bundle-format=false --use-signing-config=false --registry-referrers-mode=legacyand then verifies with--new-bundle-format=false(plusregistries.d/halcyon.yaml+policy.jsonwithsignedIdentity: matchRepository, written bydesktop/image-info). Never remove either step —verify-github.shgates the flags.
5. bootc / image constraints
/varmust be effectively empty in the image. Content there without a matchingtmpfiles.dentry triggers thevar-tmpfileslint warning, and is only applied on initial provisioning. Create runtime state withtmpfiles.d(zz-halcyon-*.conf,noctalia-greeter-state.conf) or a oneshot unit (var-nix.service).- Never create
/usr/etc. It is bootc's client-side view of the default/etc,bootc container lintchecks it, andbranding-verifyasserts it does not exist. The cosign key therefore lives at/etc/pki/containers/halcyon.pub, written bydesktop/image-info. /var/runmust remain a symlink to/run— that lint is a hard failure./bootmust be empty; the kernel lives in/usr/lib/modules/<kver>/and the initramfs is baked there too (build-initramfs).- No
/usr/localwrites; use/usr/lib/<app>plus a/usr/binsymlink. (Brave installs under/optvia its RPM; do not gate/optas empty.) - The final
bootc container lintruns with--network=noneand a tmpfs/run. Anything needing the network must happen before it. It runs without--fatal-warnings, sovar-tmpfiles/sysuserswarnings do not fail the build today.
6. Conventions by file type
Build scripts (build_files/**)
#!/usr/bin/env bash+set -euo pipefail(useset -uo pipefailonly when the script deliberately accumulates failures and returns its ownrc).- Wrap output in
echo "::group::<script> — <phase>"/echo "::endgroup::"; use theOK/WARN/FAIL/SKIP/INFOprefixes. - One package per line in
dnf5 installlists; package lists live inpackages.json, never inline. # shellcheck source=build_files/packages-lib— ShellCheck resolvessource=relative to its working directory (the repo root, wherejust lintruns), not the script's directory. Do not rewrite these to../packages-lib; that failsjust lint.- If a script tolerates failure (
|| true), the corresponding verify gate must tolerate it too. The only tolerated install today is thecustom-environmentcomps group, which is not hard-gated. (brave-origin used to install with skip-unavailable semantics viavendor-apps-optional; that group is gone and brave-origin is a hardvendor-appsmember gated bypackages-verify.) - The
/ctxbind mount is read-only. Stages that must write into their own sources copy them out first —install-built-appscopies/ctx/python-packagesto/usr/src/python-packagesprecisely because pip'segg_infostep writes into the source tree.
Static tree (system_files/shared/**)
- COPY'd before any package install. An RPM installed later that owns the
same path can replace or shadow your file. For drop-in directories
(
tmpfiles.d,sysusers.d,modprobe.d) prefixzz-halcyon-<topic>.confso it cannot collide and sorts later, and gate the final content. For RPM-owned config, gate the content right after the package install (seepackages-verifyfor greetd). - Executable bits come from git (
git update-index --chmod=+x), plus atest -xgate.
profile.d ordering
00-path-guard.sh → 01-nix-resolve-home-env.sh → 02-custom-environment.sh
→ brew.sh (interactive shells only) → image-path.sh → texlive.sh
(generated by install-texlive). 00-path-guard.sh uses only shell builtins on
purpose. brew.sh fixes up interactive shells; the HOMEBREW_* env vars for
all sessions come from etc/environment.d/10-homebrew.conf via
systemd-environment-d-generator. The default login shell is zsh; confirm
zsh's /etc/zprofile reaches anything you rely on.
ujust recipes (usr/share/ublue-os/just/*.just)
- Start with
# vim: set ft=make :; every recipe gets a doc comment and a[group("…")]. - Register a new module file in the
for f in …loop inruntime/setup-ujust, which writes60-custom.just(imported by/usr/share/ublue-os/justfile). A module not in that loop ships but is never imported. - Interactive recipes
source /usr/lib/ujust/ujust.shand useChoose. just --fmt --checkdoes not parse recipe bodies;just checkrunsbash -nover every body.
Python packages
Stdlib only, zero pip dependencies, one shared venv at
/usr/lib/halcyon-python. Register a new package in EXPECTED in
apps/install-python-packages, in python-packages/README.md, and in the loop
in apps/built-apps-verify. Every tool must answer -h or --version
non-interactively — but that smoke test never reaches the code that does the
work, and neither does a syntax check. A missing datetime import once
shipped in rmi. just lint-python (ruff F821 undefined names) and
just test-python exist for exactly that.
7. Known traps
- Verify gates must match what
systemctl enableactually does. In a container it writes/etc/systemd/system/<target>.wants/….configure-systemtherefore falls back to explicitln -sfwhensystemctl enablefails, and gates usesystemctl is-enabledfor system units andtest -Lfor the/etc/systemd/user/*.wants/symlinks. - Prove every new gate can fail. Invert it once and confirm exit 1.
final-verifyonce carried a "terra repos all disabled" gate whose globfinalizehad already deleted — it could never fail. Also bewaretest "${VAR}" = "$(...)"when both sides can be empty (the NVIDIA version gate needstest -n "${NV_MOD_VER}"in front of it). - Do not gate what you have not checked exists. No
/optgate (Brave), no comment-sensitivegrepover shipped recipes. install-pyprland: upstream shipssystemd-unit/pyprland.service, so anelsebranch (inline unit) never runs. Anything that must apply to both units (theConditionEnvironmentdrop-in) is written unconditionally, outside thatif, andbuilt-apps-verifygates its content.ConditionEnvironment=on a user unit reads the systemd user manager's environment; the Hyprland session must exportXDG_CURRENT_DESKTOPinto it (dbus-update-activation-environment --systemd) or pyprland silently never starts.- The brew timers gate on a symlink (
ConditionPathIsSymbolicLink).brew-verifyasserts the payload keepsbin/brewa symlink. - The Brewfile is deliberately three formulas (
bun,pixi,opencode). Brew dirs are appended to PATH, so a brewed duplicate of an RPM can never run. - Package names change between Fedora releases (
terra-gamescopeandterra-mangohudretired; lazygit ships asgolang-github-jesseduffield-lazygitin Terra).dnf5aborts the whole transaction on one bad name — verify before adding, and do not assume a tool exists in Fedora because it exists upstream (gumhas no Fedora package;ugumships fromublue-os-justand falls back to fzf, so only fzf is a hard requirement). - Build-tool preconditions are invisible dependencies (
zstd,util-linux-core(setpriv),gnupg2(texlive signatures),jq,gcc-c++);packages-verifygates them. - CI runners are pinned to
ubuntu-24.04.ubuntu-latestmigrates to 26.04 between 2026-10-19 and 2026-11-19, andublue-os/remove-unwanted-software@v9is not compatible with 26.04. - Scheduled workflows run only from the default branch, and
build.ymlpublishes only fromPUBLISH_BRANCH(container). Locallyorigin/HEADpoints atmainuntil changed;verify-github.shwarns about the mismatch.
8. CI
lint.yml:just check,just lint,.githubaudit, actionlint (rhysd/actionlint:1.7.12), ruff, pytest.semantic-pr.yml: PR-title Conventional Commits check (title only, viaamannn/action-semantic-pull-request).build.yml: publish gate → COPR metadata wait → both syntax gates →just build→verify/suite → census → tags → (publish branch only) push, sign (legacy format), verify. Monitors the three COPRs the build consumes:catpieleaf/kernel-p03,lionheartp/Hyprland,ublue-os/packages. (Thesneexy/zen-browserCOPR was retired — zen-browser resolves from Terra now.)clean.yml: weekly GHCR pruning (Sundays 00:15 UTC).
If you add a COPR the build consumes, add its repomd.xml URL to the URLS
array in the "Wait for Copr metadata availability" step, and expect
verify-github.sh to require the monitor entry too.
9. Checklist before proposing a change
-
just checkandjust lintpass. -
just lint-python/just test-pythonpass if you touched python helpers. - New install stage has a
<stage>-verifycompanion in the same RUN (exceptions:install-terra,install-devtools— theyrpm -qevery listed package inline;build-initramfs— covered byfinal-verify). - Every new gate has been inverted once and confirmed to fail.
- New
dnf5 installuses--setopt=install_weak_deps=False. - Any third-party repo enabled is disabled in the same stage.
- Every binary a new recipe calls is in
packages.jsonand gated. - Nothing new lands in
/var,/usr/etc,/usr/localor/boot. - New files in
usr/bin/usr/libexecare mode 0755. - Workflows: no branch pins, no
ubuntu-latest, signing flags untouched. - Comments that describe why are preserved — they are the design docs.
