branding and distribution
Status: SPEC + phase-1 implementation (2026-08-22). Owner intent (maintainer, verbatim requirements): automated GitHub releases; an un-branded wootc build plus branded builds for TunaOS, the Bluefin family (bluefin / dakota / bluefin-lts), Aurora, and Bazzite; branding via a per-brand config + assets override; branded installers do not use the wootc name (wootc is the generic project name only); branded distributions pre-bundle or pre-download the bootc image on Windows, because the deployer stage must never rely on the internet โ laptops on Wi-Fi have no networking in the initramfs, and the E2E VM's virtio ethernet has been masking that.
1. Automated releasesโ
Trigger. Two channels, one pipeline (release.yml):
- Tagged (
v*): unchanged โ the human-blessed channel, E2E-gated. - Auto (
workflow_runafter a green E2E nightly onmain, orworkflow_dispatch): tagsauto-vYYYYMMDD-<sha>and publishes a pre-release. The E2E gate is satisfied by the run that triggered it โ the release is cut from the exact SHA the nightly proved. No green nightly, no auto release.
Assets. Every release ships, for each brand (see ยง2):
| Asset | Source |
|---|---|
<Product>-Setup.exe (e.g. wootc.exe, TunaOS-Installer.exe) | per-brand app build |
deployer-vmlinuz, deployer-initramfs.img | deployer image build (payload/deployer/Containerfile) |
shimx64.efi, grubx64.efi | signed Fedora chain (same extraction the E2E uses) |
wubildr.efi | wubildr build |
SHA256SUMS | sha256sum over every asset above |
This closes the today-gap where deployerBaseURL() points at release assets
that have never existed (documented on #196): after the first automated
release, a plain online install works with zero pre-staging.
Branded downloads. Each branded exe embeds its own
deployerBaseURL-compatible layout expectation; boot artifacts are shared
across brands (the deployer is brand-neutral), so one artifact set + one
SHA256SUMS serves all brand exes on the same release.
2. Brandingโ
One binary per brand, chosen at build time. The existing runtime overlay
(C:\wootc\brand.json, GetBranding/mergeBranding) stays for
enterprise re-skins; brand builds embed their config so the exe is branded
from the first pixel with no sidecar file.
Layout (app/branding/ โ inside the module so go:embed reaches it):
branding/
wootc/brand.json # generic (default; current strings)
tunaos/brand.json
bluefin/brand.json # covers bluefin, dakota, bluefin-lts via catalog
aurora/brand.json
bazzite/brand.json
<brand>/logo.svg # the REAL mark (โ LogoDataURI); emoji only
# where emoji IS the branding (generic TunaOS)
<brand>/font.woff2 # the brand's typeface, embedded (โ FontDataURI);
# never fetched at run time
<brand>/theme.css # deep restyle injected after style.css: tokens,
# button treatment, radii (โ ThemeCSS)
Deep branding, not a palette swap. Assets are taken from each project's
own published branding (see app/branding/README.md for provenance):
Bazzite's gradient "B" mark + DM Sans + the bazzite.gg cobaltโviolet button
gradient; Bluefin's indigo tile + Inter on the projectbluefin.io navy;
Aurora's gradient mark + Geist + getaurora.dev's zinc ground and pill
buttons. The injected theme wins over both light- and dark-mode token blocks,
so a branded build commits to its brand's look; the window pre-paint colour
follows the brand too (#173). Every brand's four-screen journey is captured
as an automated screenshot walkthrough โ see
branded-walkthroughs.md, regenerated by
tests/gui/branded-walkthrough.spec.js on every Playwright run.
brand.json extends the existing Branding struct with distribution
fields:
{
"name": "Bazzite",
"productName": "Bazzite Installer",
"exeName": "Bazzite-Installer",
"tagline": "Play on Linux โ keep your Windows files.",
"logoEmoji": "๐ฎ",
"accent": "#7b5cff", "accentText": "#ffffff",
"installVerb": "Install",
"catalog": ["bazzite", "bazzite-deck"],
"defaultImage": "bazzite",
"hideCustomImage": true,
"preloadImage": true
}
catalog: image ids fromapp/data/images.jsonthis brand offers. Empty = full catalog (wootc generic). The Bluefin brand lists["bluefin", "dakota", "bluefin-lts"].defaultImage: pre-selected card.hideCustomImage: branded builds hide the custom-OCI field regardless of support policy.preloadImage: brand wants the image fetched on Windows (ยง3) โ on for every branded build, off only for generic wootc when the user opts out.productName/exeName: no "wootc" in branded user-facing surfaces โ window title, screens, Add/Remove entry, shutdown message, docs links. Internal paths (C:\wootc, service names) stay: they are part of the on-disk contract shared by the deployer and migration payload, invisible in normal use, and renaming them per brand would fork the E2E surface. (Revisit if a partner requires path-level rebranding.)
Build selection. app/branding/embed.go embeds branding/*/brand.json
via go:embed; -ldflags "-X main.brandID=bazzite" picks one (default
wootc). GetBranding() resolution order: embedded brand โ overlay
C:\wootc\brand.json โ hardcoded defaults. The frontend already consumes
GetBranding + installVerb(); remaining hardcoded "TunaOS"/"wootc" strings
in screens move to state.brand.
Upstream blessings. A branded build wears somebody else's mark, so each
brand carries a recorded decision about whether that is allowed โ
app/branding/<brand>/blessing.json, summarised in the table in
app/branding/README.md and asked for via
upstream-blessings.md (#227). Four separate
questions per project (mark, name, tagline, distributing a branded exe),
plus who owns the winget namespace the identifier would live in. The status
falls out of the answers: any no โ declined, all four yes โ blessed,
anything else โ pending. A mark this project does not own cannot be marked
blessed without a link to the yes.
Release matrix. The publish job asks packaging/brands.sh which brands
may ship, builds each surviving exe with its ldflag, names it exeName.exe,
and adds all to the release + SHA256SUMS. The decision is load-bearing
there: a declined project's exe drops out of the matrix, a brand with no
record at all is a hard error rather than a default yes, and a pending
one still builds (shipping predates the ask) but is named as unblessed in
the release log. WOOTC_REQUIRE_BLESSING=1 drops pending brands too.
3. Offline-first deploysโ
Principle: Windows does all networking; the deployer does none. A laptop's Wi-Fi does not exist in the initramfs and never will (firmware, supplicant, credentials). Every network dependency in the deploy stage is a bug. Today's dependencies and their fates:
| Deployer network use | Fate |
|---|---|
bootc install pulling the OCI image | removed โ image comes from the Windows-staged bundle (below); fisherman already supports --source-imgref oci:<path> |
backend/btrfs probes (podman run $IMAGE) | probe the bundle's image config (skopeo oci: transport, local) |
| ntfs-3g/qemu-ga dnf injection | skipped offline โ the early-cpio closure fallback (now @-protected and exec-verified) is the offline answer |
| EPEL reach, registry mirror | not applicable offline |
dracut rd.neednet=1 + initqueue/online hook | see "starting without a network" below |
The bundle: an OCI layout on NTFS. C:\wootc\bundle\oci\ โ index.json
blobs/sha256/*. Chosen over a containers-storage store because it is plain files (FAT/NTFS-safe: no xattrs, no whiteouts, no overlay driver mismatch โ exactly the #196 items 1-2 trap), digest-addressed (verification is inherent), and already a fisherman-supported source.
Two ways it gets there:
- Pre-download on Windows (default for every brand with
preloadImage). A new pipeline step after "Downloading Linux": "Downloading your Linux system" โ the app pulls the selected image to the bundle dir over the user's working Windows network, with real progress (bytes, not a stalled bar). Implementation:containers/imageGo module (docker://โoci:copy) โ runs fine on Windows, verifies digests as it writes. - Pre-made bundle beside the exe (
payload/bundle/make-release-bundle.shoutput, USB-stick / media distribution): the app stagesbundle/found beside the executable intoC:\wootc\bundleduring "Setting things up" (closing #196 item 3).
vault.json gains bundleRef + bundleDigest so the deployer can verify
the staged bundle matches the user's selection (superseding tag-only
pinning, #196 item on BundleInfo.Digest).
Deployer consumption. Early in deploy.sh, after the NTFS mount: if
/mnt/ntfs/wootc/bundle/oci/index.json exists and its ref matches
bundleRef, set WOOTC_BUNDLE=/mnt/ntfs/wootc/bundle/oci and
- fisherman recipe uses
--source-imgref oci:$WOOTC_BUNDLE; - backend + btrfs probes read the image config from the layout (no podman pull, no network);
- the ntfs-3g injection is skipped with a
[WARN] offline: relying on the image's NTFS support / staged closure; - registry preflight becomes "Verifying your downloaded system..." on the splash.
Starting without a network. The deploy hook currently sits in
initqueue/online, so an offline machine never starts deploying. Change:
install the same guarded hook at initqueue/settled as well; the
settled instance mounts nothing itself โ it starts deploy.sh, and deploy.sh
owns the decision: bundle found โ proceed with zero network; no bundle โ
wait (bounded, with a splash message naming the fix: "Waiting for a wired
network connectionโฆ") for the online path before pulling. The existing
run-once guard file keeps the two hook instances from double-starting.
Observed-mode E2E keeps virtio ethernet and today's timings; a future matrix
axis offline=on runs a cell with -nic none to prove the whole path.
Not doing: Wi-Fi in the initramfs (credential + firmware + supplicant complexity, against the "one calm reboot" principle); per-brand deployer images (the deployer stays brand-neutral).
4. Delivery phasesโ
- Releases โ DELIVERED:
release.ymlcarries both channels (tagv*E2E-gated;workflow_runafter a green nightly GUI E2E โauto-v*pre-release from the proven SHA), builds one exe perapp/branding/*via-X main.brandID, and ships the shared boot artifacts (deployer kernel/initramfs, signed shim+GRUB, wubildr) + SHA256SUMS. - Branding โ DELIVERED: five brands with real assets (logo.svg,
font.woff2, theme.css โ see
app/branding/README.md), embedded selection, de-hardcoded screens (distro name + product name fromstate.brand), brand catalogs withdefaultImage/hideCustomImage, branded ARP entry and boot-menu title (vaultdistro_name). - Offline core โ DELIVERED: Windows-side pre-download
(
app/ocipull.go, "Downloading your Linux system" step, fail-closed sha256 on every blob, resumable), deployer OCI-bundle ingest before anything network-shaped (podman pull oci:+ tag โ probes, injection check, and fisherman's containers-storage export all become local I/O), settled-hook start with a bounded, plain-language network wait, and the dnf injection skipped offline. Contracts:tests/unit/offline-first.bats. (Beside-the-exe bundle staging intoC:\wootc\bundleremains the existingstore/path; the OCI form joins it.) - Offline proof (follow-up):
offline=onmatrix axis with-nic none; promotepreloadImageto default-on for generic wootc once proven.