Collapse gershwin-on-{debian,devuan,archlinux,freebsd,nextbsd} into gershwin-desktop/gershwin-desktop so every flavor builds the same way with the same shared code — one container path for all Linux, one vmactions path for all BSD — and the only per-flavor difference is the overlay. No more archiso, no more live-build. One X server everywhere — XLibre, no Xorg. A screenshot test gates every publish, and a documented template turns "add a new distro" into an ~8-line caller plus one target directory.
Built from a live read of all five ISO repos, the shared gershwin-developer checkout.sh, the current release inventory, and GitHub's 2026 limits. Thesis: the five repos use five different build engines and only one of them (NextBSD) tests before publishing. Unify the build into two shared templates — container for Linux, vmactions for BSD — push everything identical into shared actions, and reduce each flavor to its overlay. The result is DRY, uniformly gated, and turns new variants into a fill-in-the-blanks exercise.
The five ISO repos are not near-copies. They split into two substrate families, and within the Linux family they use three different engines. Only NextBSD tests before it publishes.
| Repo | Build engine (today) | Runner / arch | Gate? | Publish | ISO size |
|---|---|---|---|---|---|
gershwin-on-debian | live-build in debian:latest | ubuntu-24.04 + -arm (amd64+arm64) | No | uploadtool (fork) | 1.44 GiB + RPi .img |
gershwin-on-devuan | build.sh in GHCR containers | ubuntu-24.04 + -arm (amd64+arm64) | No | uploadtool | 1.26 GiB tag leaking |
gershwin-on-arch | mkarchiso in archlinux:latest | ubuntu-latest (x86_64) | No | uploadtool | 1.91 GiB near 2 GiB cap |
gershwin-on-freebsd | build.sh in vmactions/freebsd-vm | ubuntu-latest → FreeBSD VM (amd64) | No* | gh release | 1.84 GiB |
gershwin-on-nextbsd | build.sh in vmactions/freebsd-vm | ubuntu-latest → FreeBSD VM (amd64) | Yes | gh release | 1.65 GiB + screenshot |
* freebsd only skips the build on PRs; it has no boot/screenshot verification.
uploadtool doesn't clean up; arch is at 1.91 GiB against a hard 2 GiB wall. Five copies of "roughly the same idea" diverge exactly like this.changes → build → test → release, where test boots the ISO in QEMU+OVMF and asserts the desktop rendered (framebuffer unique-colour count > 1500, or OCR of the "System Disk" icon), with the gating step carrying no continue-on-error and release requiring needs.test.result == 'success'. That behaviour is what we spread to every target.Replace the three Linux engines with one container build, keep the two BSDs on one vmactions build, and reduce every flavor to the parts that genuinely can't be shared. Precisely:
| Layer | Shared how far? | Mechanism |
|---|---|---|
| Disk-free · screenshot gate · publish | All 5 flavors, identical | Composite actions (§3) |
| ISO assembly skeleton | All Linux share one; both BSD share one | Two family templates (§4, §5) |
| Live-boot init | One per family (hand-rolled) | Shared init (§4) |
| Distro adapter (bootstrap + pkg cmd) | Per flavor — but tiny (~3 funcs) | targets/<flavor>/adapter.sh |
| Overlay (init wiring, X drivers, boot bits) | Per flavor — irreducible | targets/<flavor>/overlays/ (§6) |
| X server + input/video drivers | All 5, identical — XLibre, no Xorg | Shared requirement; package source in adapter (§7) |
| The Gershwin desktop itself | All 5, already shared | gershwin-developer install (§7) |
archiso/live-build: they secretly own the live-boot initramfs (find + mount the squashfs at boot). Removing them means owning that — see §4. This is where Arch's conversion cost concentrates; Debian/Devuan barely move.The parts that are identical no matter how the ISO was made are expressed as composite actions (bundles of steps dropped into any job with uses:). Each flavor writes its unique build inline and calls these for the rest. Fix a bug once → all flavors get it.
free-disk/action.yml # reclaim ~25 GB on ubuntu-latest for a 2 GiB ISO screenshot-gate/action.yml # boot ISO in QEMU+OVMF → colour/OCR check → FAIL job if no desktop publish-continuous/action.yml # preserve tag: upload new ISO+screenshot → prune old ISO+screenshot (no sha256)
Two maximally-different flavors, Arch and NextBSD, end in the same three lines:
steps: steps: - uses: actions/checkout@v4 - uses: actions/checkout@v4 - uses: ./.github/actions/free-disk - uses: ./.github/actions/free-disk - run: ./targets/archlinux/build.sh - uses: vmactions/freebsd-vm@v1 (build.sh) - uses: ./.github/actions/screenshot-gate - uses: ./.github/actions/screenshot-gate - uses: ./.github/actions/publish-continuous- uses: ./.github/actions/publish-continuous
README.md alone never builds; any other change builds (paths-ignore: [README.md]). There is no "reuse the last ISO" fast path — the build always runs and the gate boots the ISO this run just built, so it can never test an image that doesn't exist. A push to main therefore goes build → gate → publish on its own; workflow_dispatch is only a manual re-trigger. (With several flavors sharing one repo, each caller scopes to targets/<flavor>/** + .github/** so a flavor rebuilds on its own target or shared-infra change — still never on README.)Every Linux flavor builds its ISO inside a container of its own distribution, using that distro's native live-media tooling — not a reimplementation. The container is the per-flavor build environment; the only things shared across Linux flavors are the Gershwin install and the gate + publish tail.
| Flavor | Build container | Native ISO tool | Per-flavor sources under targets/<flavor>/ |
|---|---|---|---|
| devuan | custom — ci/containers/Dockerfile (Devuan repo + keyring + Devuan's debootstrap) | debootstrap + grub + xorriso | build.sh, packages.list, grub.cfg, ci/containers/ |
| debian | stock debian:latest --privileged | live-build (lb config + lb build) | config/ (hooks, package-lists, includes.chroot), scripts/ |
| archlinux | stock archlinux:latest --privileged | mkarchiso | profiledef.sh, packages.x86_64, airootfs/, efiboot/, syslinux/ |
pacman/mkarchiso only exist cleanly on their own distro. The container is the flavor delta.git clone gershwin-developer → bootstrap.sh → checkout.sh → make install — identical everywhere, and the dominant build cost) and the gate + publish tail. Everything distro-specific stays in targets/<flavor>/.debian:latest / archlinux:latest images and install the ISO tool at runtime. Only Devuan needs a custom image, because Devuan's debootstrap (with the excalibur script) + keyring aren't on any stock image. The container never comes from another flavor's repo.busybox+overlayfs init. Rejected — it discards each distro's battle-tested live path for a bespoke one to maintain. Native tooling in a container is simpler and more robust.FreeBSD and NextBSD already build the same way; the template just makes it explicit. Both assemble inside vmactions/freebsd-vm with the identical makefs → mkuzip → mkisoimages.sh toolchain (NextBSD is FreeBSD-derived). FreeBSD gains the screenshot gate for free the moment it calls the template.
NextBSD-everything pkg) and the overlay (rc.d vs launchd). The template applies targets/<target>/overlays/, so freebsd gets rc.d and nextbsd gets launchd — neither carries the other's, no cross-contamination.vmactions/openbsd-vm / netbsd-vm with that OS's ISO tools. Supported in principle, but not "free" like a new Linux flavor. Document it as such."Start LoginWindow / dshelper / gdomap at login" is the same intent everywhere but a different expression per init system. That's why overlays can't be unified — and it's fine, because overlays were never the thing to share.
| Flavor | Init system | Overlay form (session wiring) | Package list |
|---|---|---|---|
| debian, ubuntu | systemd | .service / .target units | packages.list |
| archlinux | systemd | .service units | packages.list |
| devuan | sysvinit / openrc | /etc/init.d/ scripts | packages.list |
| freebsd | rc.d | /etc/rc.d/ + rc.conf | resources/packages/ |
| nextbsd | launchd | LaunchDaemons/*.plist | pkglist.txt |
Each target carries only its own init's overlay under targets/<flavor>/overlays/. Build-method uniformity buys a shared build workflow; it buys nothing on the init wiring, by design.
xorg.conf.d snippets, the server wrapper, input/video driver setup) is no longer per-flavor — it moves into the shared gershwin-developer install (§7). What's left in each overlay is essentially just the init-system service wiring above, plus any boot/live-media plumbing the distro needs.gershwin-developer owns (already shared)gershwin-developer's checkout.sh + make install to build the frameworks/apps/LoginWindow into /System. That's the big common asset and it's already one source of truth — no duplication.defaults, the default wallpaper, the LoginWindow theme, the admin provisioning — install them from gershwin-developer so no flavor re-bakes them. Each flavor's overlay then shrinks to only its init wiring + X drivers + boot plumbing.gershwin-developer/Library/Tests/ and have each template check it out the same way it already checks out gershwin-developer.NextBSD already ships the XLibre server + input/video drivers. Standardizing all five flavors on XLibre — a drop-in X.Org fork — makes the display stack another shared layer: same server, same drivers, same config, on every flavor. XLibre publishes its own binary repos at packages.xlibre.net, so this is a repo-add, not a source build.
gershwin-developer (§7). Getting the packages is a uniform "add the official XLibre repo + key, install, exclude Xorg" step: native port on FreeBSD/NextBSD (done); packages.xlibre.net for Arch and the Debian family.# targets/archlinux/adapter.sh — confirmed layout from packages.xlibre.net/arch/ cat >> "$ROOTFS/etc/pacman.conf" <<'EOF' [xlibre] Server = https://packages.xlibre.net/arch/stable/$arch EOF # targets/debian/adapter.sh — confirm suite/component against packages.xlibre.net/debian/ install -m0644 xlibre-archive-keyring.gpg "$ROOTFS/etc/apt/keyrings/" echo "deb [signed-by=/etc/apt/keyrings/xlibre-archive-keyring.gpg] \ https://packages.xlibre.net/debian stable main" \ > "$ROOTFS/etc/apt/sources.list.d/xlibre.list"
packages.xlibre.net/debian stable apt line + key work unchanged — the Debian-family XLibre repo-add is written once and shared by both debian and devuan.packages.xlibre.net availability and its signing key become build dependencies — pin/verify the key in the adapter, and consider mirroring the packages into the org (or a cache) if you want builds to survive that host being down. This is the real cost of "no Xorg," and it's isolated to the adapter.x86_64 only (Arch is x86_64-only anyway), but debian/devuan build arm64 too — verify packages.xlibre.net publishes arm64 debs before promising XLibre on those ISOs. If it doesn't, the arm64 builds can't get an X server on XLibre, which ties into the "publish arm ungated first" caveat (§14).xlibre-server + XLibre drivers and exclude/hold xorg-server/xserver-xorg-* so no flavor silently pulls Xorg back in as a dependency.Today the five are inconsistent (mixed prefixes, arch tokens x86_64/amd64/aarch64/arm64, and timestamp formats). One template fixes all of it.
Dual-arch ISOs share the flavor's tag as separate assets, keeping the tag count at five and grouping a flavor's arches together.
| Purpose | Tag | Examples |
|---|---|---|
| Continuous (main) | continuous-<flavor> | continuous-debian, continuous-devuan, continuous-archlinux, continuous-freebsd, continuous-nextbsd |
| Custom-branch (future, §9) | branch-<name>-<flavor> | branch-menu-work-nextbsd |
archlinux, not arch, as the flavor token — "arch" collides with the CPU-architecture token (x86_64/aarch64). continuous-archlinux is unambiguous.NextBSD, Devuan, Debian, FreeBSD, Arch Linux — proper-cased, distinct from the lowercase filename/tag token. Kept short deliberately: GitHub ellipsis-truncates release names in its narrow "Releases" sidebar and tag dropdown, and a one-word distro name never clips. "Continuous" is already conveyed by the tag (continuous-<flavor>), so it isn't repeated in the title. Each caller passes the name via publish-continuous's title: input.gershwin-on-<flavor>-<YYYYMMDDhhmmss>-<arch>.<ext> flavor = debian | devuan | archlinux | freebsd | nextbsd stamp = UTC, 14 digits, NO separators (sortable, cache-busting) arch = x86_64 | aarch64 (release token, normalized for EVERY OS — decoupled from the build's internal triple: FreeBSD amd64 → x86_64, dpkg arm64 → aarch64. NEVER amd64/arm64 here) ext = iso | img.zip (img.zip = the RPi / raw disk-image variant) gershwin-on-nextbsd-20260719101500-x86_64.iso gershwin-on-debian-20260719101500-aarch64.iso gershwin-on-debian-20260719101500-aarch64.img.zip
docs/desktop.png to git. The README holds one image and five flavors would churn binaries into history on every build. Instead, publish-continuous attaches the gate's screenshot to the release named to match the ISO — same stem, .png extension: gershwin-on-<flavor>-<stamp>-<arch>.png — and embeds it inline in the release body. It rotates with the ISO (new one uploaded first, old one pruned after) and adds zero git churn.…/releases/tag/continuous-<flavor>), which always shows the current shot inline in the body, rather than hotlinking a fixed asset URL.continuous-<flavor> release contains| Asset | On which flavors |
|---|---|
gershwin-on-<flavor>-<stamp>-x86_64.iso | all |
gershwin-on-<flavor>-<stamp>-aarch64.iso | debian, devuan (dual-arch) |
gershwin-on-<flavor>-<stamp>-x86_64.png (matches the ISO) | all (gated x86_64 boot) |
gershwin-on-debian-<stamp>-aarch64.img.zip | debian only (RPi / disk-image variant) |
.sha256 sidecars. GitHub shows each release asset's SHA-256 digest natively, so the extra files are dropped — one ISO, one screenshot, nothing else.--prerelease — the project is early-stage, so no flavor's rolling build carries GitHub's "Latest" badge. This is a deliberate quality signal; keep it uniform across all flavors.publish-continuous uploads the new (timestamped) ISO and its matching-named screenshot first, then deletes the previous build's ISO and screenshot — the release always holds exactly one current image and is never left empty. The git tag is created once and never re-cut (no --cleanup-tag).…/releases/tag/continuous-debian), which always lists the current asset and shows the screenshot inline.publish-continuous renders each continuous-<flavor> tag as one self-describing release: a proper-cased title, a body that leads with the desktop screenshot inline and nothing else but a build-log link, and exactly two assets whose stems match. Title, body and assets are regenerated on every publish, so the release never shows a stale or half-updated state. For nextbsd it renders as:
# ── title (releases list + tag page) ───────────────────────────── NextBSD Pre-release · tag: continuous-nextbsd # ── body (Markdown) ────────────────────────────────────────────── ┌───────────────────────────────────────────────┐ │ │ │ [ About This Computer + desktop ] │ inline image, embedded from │ │ the matching .png asset below └───────────────────────────────────────────────┘ Build log: …/actions/runs/<run-id> # ── assets — exactly two, matching stems; GitHub shows the digest ─ gershwin-on-nextbsd-20260721000706-x86_64.iso sha256:… 1.66 GiB gershwin-on-nextbsd-20260721000706-x86_64.png sha256:… 248 KiB
.sha256 sidecars to attach or prune — the release is literally one ISO + one screenshot.…-aarch64.iso, published ungated; only the gated x86_64 boot produces a screenshot, so the inline body image always depicts the x86_64 build. See the asset table above.checkout.sh firstgershwin-developer/Library/Scripts/checkout.sh clones the 17 Gershwin/GNUstep source repos, but only on their default branch — its sole ref control is PINNED=1 (hardcoded SHAs) and SKIP_REPOS. No branch support.
else git clone "$REPO" # ← no -b, always default branch fi
REPO_BRANCH_<name> override mirroring the existing checkout_commit idiom, cloning with git clone ${BR:+-b "$BR"} "$REPO". Then a second job / a build-all input sets the branch and publishes to a separate branch-<name>-<flavor> tag so branch builds never clobber continuous-*.Adding a flavor is one per-flavor continuous-<flavor>.yml that builds the ISO in the flavor's own distro container and then calls the shared gate + publish, plus one targets/<flavor>/ dir holding that distro's native build. No forking a repo, no reimplementing live-boot.
on: push: { branches: [main], paths: ['targets/ubuntu/**', '.github/actions/**', '.github/workflows/continuous-ubuntu.yml'] } workflow_dispatch: jobs: build: # in the flavor's OWN distro container runs-on: ubuntu-latest container: { image: ubuntu:latest, options: --privileged } steps: [ install live tooling, checkout, build ISO → out/, upload-artifact ] test: { uses: ./.github/actions/screenshot-gate, with: { flavor: ubuntu } } publish: { uses: ./.github/actions/publish-continuous, with: { tag: continuous-ubuntu, title: Ubuntu } }
Trigger scoped to targets/ubuntu/** + shared .github/actions/** + the flavor's own workflow file — rebuilds on its own target or a shared-infra change, never on another flavor's edit or a README. Filenames use x86_64/aarch64; the distro's build maps them to whatever its bootstrap needs (dpkg arm64, etc.).
<native build> # build.sh (debootstrap) | config/ (live-build) | profiledef.sh (mkarchiso) ci/containers/ # ONLY if the distro needs a custom image (as Devuan does) packages… # the distro package set (incl. XLibre, never Xorg)
| Contract | Requirement |
|---|---|
| Output | write the ISO to out/gershwin-on-<flavor>-<stamp>-<arch>.iso (arch = x86_64/aarch64; no sha256 sidecar) |
| Boot | ISO boots to the Gershwin desktop on x86_64 UEFI so the shared QEMU gate can screenshot it |
| Desktop | install Gershwin via gershwin-developer so the gate's colour/OCR markers match |
| X server | install XLibre (never Xorg); the flavor's own build supplies the package source, the config comes from the shared install |
| Overlay | wire the session services via the variant's own init system under targets/<flavor>/overlays/ |
| Gate tuning | validate the colour threshold + OCR marker for the variant's greeter/wallpaper on first run |
reusable-iso-linux; needs FreeBSD tools → reusable-iso-bsd.targets/_template-<family>/ → targets/<flavor>/; fill adapter.sh, package list, overlay.continuous-<flavor>.yml; set flavor / arches / tag.<flavor> to the build-all matrix and a README gallery cell.continuous-<flavor> with the ISO + inline screenshot (no sha256).All re-verified against current GitHub docs. Only three constrain this — none is caused by consolidating.
| Limit | Value (Free, public repo) | Bites? |
|---|---|---|
| Single release-asset file | 2 GiB | YES — Arch at 1.91 GiB. Keep it compressed. |
| Actions concurrent jobs | 20 — org-wide, shared across repos | Neutral — the 5 repos already share one budget; mono vs split is a wash. A full build-all is ~7 jobs. |
ubuntu-latest disk | 14 GB | YES — needs free-disk to stage a 2 GiB ISO. |
| Actions minutes (public) | Free / unlimited | No |
| Release assets vs storage quota | Separate store, no total cap, unbilled | No — 5 rolling tags cost nothing |
| Actions artifacts storage | Counts against quota (free for public) | No — keep the build→gate ISO artifact short-retention |
paths: filter | Ignored by dispatch / schedule | By design — build-all rebuilds everything; per-flavor callers gate on their paths: |
Storage answer, directly: with delete+recreate each continuous-<flavor> holds ~1 ISO ≈ 1.5–2 GiB → the whole live set is ~10–15 GiB, none of it counting against any quota, no total cap. Five tags, or fifty, is fine.
Moving into one home has two migration pieces. Issues are easy (native support); release history is the awkward one.
gh issue transfer <n> gershwin-desktop/gershwin-desktop preserves body/comments/history and leaves an automatic redirect, so archiving the old repos won't break links. Loop it over each repo's open issues.debian:, freebsd:…), and note PRs can't be transferred — drain/close open ones before archiving.202601 tag alone is 33.5 GiB / 70% of all release storage. Recommended: leave historical tags in the old repos, archive them read-only, link from the new README. Re-uploading assets is hours of transfer for no real gain — continuous builds don't need the history.gershwin-developer/checkout.sh (~15 lines). Unblocks the future custom-branch job; harmless now.gershwin-desktop/gershwin-desktop; add the two composite actions, the two family templates, and targets/ + tests/screenshot/.mkarchiso onto the container template + shared live-init. This is the real engineering; if it works, the template is proven universal.workflow_dispatch matrix fan-out; run every flavor in parallel once, green.gershwin-desktop/gershwin-desktop; compare ISOs + screenshots against the current per-repo outputs.tests/screenshot/ into gershwin-developer/Library/Tests/ as the shared suite.mkarchiso means owning the live initramfs. Mitigated by the shared busybox+overlayfs init; still the riskiest step.paths:, fail-fast: false on matrices, and branch protection.Hub-repo first. If the full monorepo feels big, ship an intermediate: make gershwin-desktop/gershwin-desktop an orchestrator that retrofits the screenshot gate into each existing repo and hosts a release-index / gallery, keeping the five repos in place. It buys uniform gating this week with no migration, and its hub becomes the monorepo's build-all later. The downside is the five build engines still diverge — you don't get "everything builds the same way" until you do the real consolidation. Recommended only as a stepping stone, not the destination.