QuantumQat is a custom Bazzite image for people who identify as gamers first, and as hackers second. It is rebuilt nightly on top of the latest Bazzite.
Huge thanks to the projects this stands on:
- Bazzite
- Universal Blue
- BlueBuild β the tool used to build this image
This image is built with BlueBuild, not a hand-written Containerfile. The whole image is described declaratively in recipes/recipe.yml: a base image plus an ordered list of modules (install packages, copy files, enable services, β¦). BlueBuild's CLI turns that recipe into an OCI image.
Historical note: this repo was originally a raw
Containerfilefork of the ublue-os/image-template pattern. It was migrated to BlueBuild because theblingmodule (which installs 1Password) now relies on BlueBuild's build pipeline β see the git history if you need the full story.
| Path | What it is |
|---|---|
recipes/recipe.yml |
Start here. The image definition: base image + the ordered list of modules. |
files/system/ |
Files copied verbatim into the image root (e.g. /etc, /usr). |
files/scripts/ |
Build-time shell scripts, run by type: script modules in the recipe. |
files/dnf/ |
.repo files consumed by the dnf module (Docker CE, VS Code). |
files/justfiles/ |
ujust recipes baked into the image. |
installer/ |
Lorax templates + config used when building the offline ISO. |
.github/workflows/build.yml |
CI: build β sign β push β build ISO. |
cosign.pub |
Public signing key. The private half is the SIGNING_SECRET repo secret. |
Almost everything is a one-liner in recipes/recipe.yml:
| To⦠| Edit |
|---|---|
| Install an RPM | add it to the dnf module's install.packages |
| Install a Flatpak | add its app id to the default-flatpaks module |
| Enable a systemd unit | add it to the systemd module's system.enabled |
| Ship a config file | drop it under files/system/ at its target path |
| Run a custom build step | add a script to files/scripts/ and list it in a type: script module |
Add a ujust command |
add a recipe to files/justfiles/100-quantumqat.just |
Module options are documented at blue-build.org/reference/modules.
Install the BlueBuild CLI, then:
just build # build the image
just build-iso # build an offline installer ISOBecause the image is cosign-signed, the first rebase is two steps: rebase to the unverified image once (it ships the signing policy), then to the signed image so future updates are verified.
# 1. Rebase to the unverified image, then reboot
rpm-ostree rebase ostree-unverified-registry:ghcr.io/alfredgamulo/quantumqat:latest
systemctl reboot
# 2. Rebase to the signed image, then reboot
rpm-ostree rebase ostree-image-signed:docker://ghcr.io/alfredgamulo/quantumqat:latest
systemctl rebootAfter that, updates arrive automatically through the normal system updater (Bazzite uses bootc/rpm-ostree under the hood) β no further action needed.
Build an offline-installable ISO with just build-iso (see Building locally), or download the ISO artifact from a CI run.
The native 1Password app (installed via the bling module) cannot talk to a Flatpak browser's extension out of the box β the Flatpak sandbox blocks the native-messaging connection 1Password uses.
This image bakes the system-side allowlist (/etc/1password/custom_allowed_browsers). The rest of the bridge β a wrapper script, the browser's native-messaging manifest, and a flatpak override β lives under each user's ~/.var/app/β¦, so it can't be baked into the image and must be run once per user:
ujust setup-1password-browser # brave (default)
ujust setup-1password-browser firefox
ujust setup-1password-browser chrome
ujust setup-1password-browser com.google.Chrome # or any Flatpak app idRun it after the browser Flatpak is installed. Then restart the browser and the 1Password app, and enable "Integrate with 1Password" in the extension settings. Both Chromium- and Firefox-based browsers are supported. The underlying logic lives in files/system/usr/libexec/quantumqat/setup-1password-browser; it is adapted from FlyinPancake/1password-flatpak-browser-integration.
Possible improvement (not implemented): this is a manual, per-user step. It could be automated by shipping a systemd user service (in
/usr/lib/systemd/user/, enabled via thesystemdmodule) that runssetup-1password-browseron each login, removing theujuststep. The tradeoff is more to maintain, and it can break if 1Password's extension IDs or the Flatpak paths change upstream.
Things that aren't obvious from reading the recipe, for whoever returns to this after a long time:
- Modules run top-to-bottom, and the BlueBuild CLI is pinned by
blue-build/github-action@<version>inbuild.yml. Module behavior can change between CLI/module versions β ablingchange is exactly what triggered the migration to BlueBuild. /optapps rely on BlueBuild's automatic optfix. 1Password (viabling) and Winboat (viafiles/scripts/20-install-winboat.sh) install into/opt. BlueBuild symlinks/optβ/usr/lib/optduring the build and recreates the runtime symlinks for you. Do not hand-roll/optmoves or tmpfiles β that is what broke 1Password before.- Don't rename
name:in the recipe. It is the published image ref (ghcr.io/alfredgamulo/quantumqat) that users have rebased onto; changing it silently breaks their updates. - Keep signing with the same key. The image must keep being signed by the cosign key whose public half is
cosign.pub(private half = theSIGNING_SECRETsecret). Rotating it breaks signed updates for existing installs. - Winboat is pinned and checksum-verified.
files/scripts/20-install-winboat.shinstalls an exact version and checks a vetted SHA-256 (the upstream RPM is unsigned, and no upstream checksum is published). To update it, bumpWINBOAT_VERSIONandWINBOAT_SHA256together β a mismatch fails the build by design. It does not auto-update; check the Winboat releases periodically. - GitHub Actions are pinned to commit SHAs with a
# vXcomment; Dependabot (.github/dependabot.yml) opens PRs to bump them. Review those PRs rather than blindly merging. - CI does not push or sign on pull requests β only on merge. The BlueBuild action defaults to
push: trueand always cosign-signs what it pushes. Left unchecked, apull_requestrun tries to push apr-<n>-tagged image and sign it, which needsSIGNING_SECRETβ and that secret is empty on PRs (GitHub withholds Actions secrets from Dependabot PRs entirely, so a malicious bump can't exfiltrate them). The signing step then fails the wholebuild_pushjob early (~10s).build.ymlsetspush: ${{ github.event_name != 'pull_request' }}, so PRs are build-only: the image is still built and validated, just not published or signed. The real sign + push happens on thepushtomainafter merge. This is why a PR could fail on cosign yet the same commit succeed once merged. - Pinned third-party versions are watched by
release-watch. Dependabot can't track a version pinned in a shell script, so a weekly job compares each package listed in.github/release-watch.jsonagainst its upstream's latest release and opens an issue when a newer one exists. To watch another package whose version is pinned shell-style aspin_key="<version>", add one entry (name,repo,pin_file,pin_key) β no workflow change needed. The job only fires an issue when upstream is strictly newer (version-ordered), and a package pinned in some other format fails the job loudly so you know the extraction step needs extending.
These images are signed with cosign. Verify with the cosign.pub from this repo:
cosign verify --key cosign.pub ghcr.io/alfredgamulo/quantumqat