How the (currently empty) pkgdemon/gershwin-on-nextbsd repo grabs the latest NextBSD .img.zip, builds the Gershwin desktop into its rootfs, and repackages the result into a live ISO that boots identically to NextBSD's own — 2-stage MFS, on-demand uzip, unionfs, and vfs.pivot. Plus the three launchd services (loginwindow, dshelper, gdomap) the desktop overlay must ship.
This plan is the output of reviewing the empty target repo against its two upstreams: nextbsd-redux/nextbsd (image/ISO build & boot model) and pkgdemon/freebsd-launchd-mach (launchd job conventions). It specifies what gershwin-on-nextbsd must contain and how its CI must work.
NextBSD ships two artifacts from the same staged rootfs, with two different boot models. Getting this right is the difference between a bootable ISO and a brick:
| Artifact | Boot model | Role here |
|---|---|---|
NextBSD-amd64-<stamp>.img.zip | Plain GPT disk, read-write UFS root. Kernel mounts freebsd-ufs/ROOTFS directly → /sbin/launchd. No uzip / unionfs / pivot. | Our input. The rw UFS root is the easiest thing to mount, modify, and re-package. |
NextBSD-amd64-<stamp>.iso.zip | The 2-stage MFS → on-demand uzip → unionfs → vfs.pivot live pipeline (nextbsd #70). | Our output shape. We reproduce this from a modified rootfs so the Gershwin ISO boots the same way. |
build.sh says "no uzip/unionfs" — that comment is scoped to the .img only. Step 7 of the same file still builds the full uzip/unionfs/pivot ISO. That step-7 block is what we lift.sysctl vfs.pivot is a NextBSD-kernel-only knob (sys/kern/vfs_pivot.c, in the separate nextbsd-kernel repo). A stock FreeBSD kernel cannot boot this ISO. The downloaded .img already contains the right kernel at /boot/kernel/kernel — we never build a kernel.Every image/ISO tool is FreeBSD-only and absent on a Linux runner: makefs, mkuzip, mkimg, mkisoimages.sh, mdconfig, plus the chroot+pkg bootstrap phase and UFS/cd9660 mounts. NextBSD's own CI solves this exactly the way we will:
vmactions/freebsd-vm purely as the FreeBSD build environment (it runs as root, gives us makefs/mkuzip/mdconfig from base, and rsyncs our workspace in/out). NextBSD's build.yml does identically with release: '15.0'.qemu-system-x86 + ovmf + expect are needed to boot the finished ISO and assert the markers — that runs on the ubuntu-latest host after copyback, mirroring NextBSD's tests/iso-boot-test.sh.gershwin-on-nextbsdThe repo is a thin orchestrator + desktop overlay. It does not fork the Gershwin build — it drives gershwin-developer (which already has a working NextBSD build path) and adds the launchd overlay plus the ISO tooling.
.github/workflows/build-iso.yml # the pipeline (§4) tools/ make-gershwin-iso.sh # orchestrates extract → chroot build → repackage (runs in the VM) make-live-iso.sh # step-7 repackage, adapted from nextbsd build.sh (§5) iso-boot-test.sh # qemu+OVMF+expect boot check (adapted from nextbsd tests/) overlays/ System/Library/LaunchDaemons/ # laid over the rootfs exactly like nextbsd's overlays/ (§6) org.gnustep.gdomap.plist org.gershwin.dshelper.plist org.gershwin.loginwindow.plist README.md
overlays/. over the rootfs with cp -aR before packaging, and overlays/System/Library/LaunchDaemons/*.plist → /System/Library/LaunchDaemons/. We add our three plists into that same tree. (Config that lives under /etc would go under overlays/private/etc/ because /etc is a symlink into /private — not needed for these three services.)Bootstrap.sh / Checkout.sh / Install-System-Domain.sh already key off /usr/lib/system to take the NextBSD branch.build-iso.ymlFive stages. Stages ① and ⑤ run natively on the Linux runner; ②–④ run inside the FreeBSD VM.
name: Build Gershwin-on-NextBSD live ISO on: [push, workflow_dispatch] jobs: build-iso: runs-on: ubuntu-latest timeout-minutes: 240 steps: # --- ① HOST: fetch the latest NextBSD rw disk image --- - uses: actions/checkout@v4 # gershwin-on-nextbsd (overlays + tools) - uses: actions/checkout@v4 # the Gershwin build driver with: repository: gershwin-desktop/gershwin-developer path: gershwin-developer - uses: jlumbroso/free-disk-space@main # rootfs + obj trees are multi-GB - name: Resolve + download latest NextBSD .img.zip env: { GH_TOKEN: "${{ github.token }}" } run: | URL=$(gh api repos/nextbsd-redux/nextbsd/releases/tags/continuous \ --jq '.assets[]|select(.name|test("amd64.*\\.img\\.zip$")).browser_download_url' | head -1) curl -fL -o nextbsd.img.zip "$URL" curl -fL -o nextbsd.img.zip.sha256 "$URL.sha256" sha256sum -c nextbsd.img.zip.sha256 unzip -p nextbsd.img.zip '*.img' > nextbsd.img # --- ②③④ VM: extract rootfs, chroot-build Gershwin, repackage ISO --- - name: Build live ISO inside FreeBSD uses: vmactions/freebsd-vm@v1 with: release: '15.0' # must match the NEXTBSD kernel's FreeBSD base (see §5) mem: 8192 cpu: 4 usesh: true sync: rsync copyback: true # brings out/*.iso back to the host prepare: pkg install -y bash git rsync zip run: | sh ./tools/make-gershwin-iso.sh "$PWD/nextbsd.img" "$PWD/gershwin-developer" # --- ⑤ HOST: boot the ISO under qemu/OVMF and assert the pivot + login markers --- - name: Boot-verify the ISO run: | sudo apt-get update && sudo apt-get install -y qemu-system-x86 ovmf expect ./tools/iso-boot-test.sh out/Gershwin-NextBSD-*.iso - uses: actions/upload-artifact@v4 with: { name: gershwin-nextbsd-iso, path: out/*.iso, if-no-files-found: error }
tools/make-gershwin-iso.sh)Chroot-building userland into the NextBSD rootfs is the exact pattern NextBSD already uses to build its Apple userland suites (chroot $WORK/rootfs …), so it is a proven path, not a gamble.
#!/bin/sh set -eu IMG=$1; DEVELOPER=$2 WORK=$PWD/work; ROOTFS=$WORK/rootfs mkdir -p "$ROOTFS" out # ② extract the NextBSD rootfs out of the .img's 3rd GPT partition (freebsd-ufs/ROOTFS) md=$(mdconfig -a -t vnode -f "$IMG") mount "/dev/${md}p3" /mnt tar -C /mnt -cf - . | tar -C "$ROOTFS" -xpf - # copy to a roomy plain dir (img has only +1.5G headroom) umount /mnt; mdconfig -d -u "${md#md}" # ③ build Gershwin into the rootfs via chroot (net for pkg + git clone) mount -t devfs devfs "$ROOTFS/dev" cp /etc/resolv.conf "$ROOTFS/private/etc/resolv.conf" mkdir -p "$ROOTFS/build" tar -C "$DEVELOPER" -cf - . | tar -C "$ROOTFS/build" -xpf - chroot "$ROOTFS" /bin/sh -eu -c ' cd /build sh Library/Scripts/Bootstrap.sh # pkg install nextbsd.txt prerequisites sh Library/Scripts/Checkout.sh # clone the ~15 component repos make install # installs the SYSTEM domain into /System ' rm -rf "$ROOTFS/build" "$ROOTFS/private/etc/resolv.conf" umount "$ROOTFS/dev" # overlay our launchd plists on top of the built rootfs (same as nextbsd's cp -aR overlays) cp -aR overlays/. "$ROOTFS/" chown -RH 0:0 "$ROOTFS/System/Library/LaunchDaemons" # ④ repackage into a live ISO that boots like NextBSD's sh ./tools/make-live-iso.sh "$ROOTFS" out/Gershwin-NextBSD.iso
configure tests execute compiled probe binaries. If any probe needs a Mach syscall (mach.ko), it will fail under the VM's stock FreeBSD kernel inside the chroot. Mitigation if it bites: boot the .img under qemu-KVM and build inside the running guest instead (slower, more plumbing). The chroot path should be proven with a throwaway run before committing to it.p3 is the freebsd-ufs/ROOTFS from NextBSD's mkimg layout (p1 freebsd-boot, p2 efi, p3 ufs). Confirm with gpart show md0 on first run.tools/make-live-iso.sh is NextBSD build.sh step 7, adapted to take a prebuilt rootfs. It is self-contained given the rootfs (which already carries the NEXTBSD kernel, boot/cdboot, boot/loader.efi, and the mfsroot tool set) plus FreeBSD's mkisoimages.sh.
# 7b. compact UFS of the rootfs (no headroom), then geom_uzip-compress it makefs -t ffs -B little -o version=2,label=NBROOT "$WORK/rootfs.iso.ufs" "$ROOTFS" mkuzip -o "$WORK/rootfs.uzip" "$WORK/rootfs.iso.ufs" # 7c. build the stage-1 mfsroot: minimal tools + their transitive .so closure + /init # tools: sh sleep ls mount umount mount_cd9660 mount_unionfs mdconfig sysctl # /init = mount cd9660 media → mdconfig -t vnode rootfs.uzip → mount md*.uzip /rofs # → tmpfs /cow → mount_unionfs /cow /rofs → sysctl vfs.pivot=/rofs → exec /sbin/launchd makefs -t ffs -B little -o version=2,label=MFSROOT -b 3m "$WORK/mfsroot.img" "$MFS" # 7d. assemble the ISO staging dir + the loader config that drives the 2-stage boot cp -a "$ROOTFS/boot/." "$ISOROOT/boot/" cp "$WORK/mfsroot.img" "$ISOROOT/boot/mfsroot.img" cp "$WORK/rootfs.uzip" "$ISOROOT/rootfs.uzip" cat > "$ISOROOT/boot/loader.conf.d/zz-live.conf" <<'EOF' mfsroot_load="YES" mfsroot_type="md_image" mfsroot_name="/boot/mfsroot.img" init_path="/init" vfs.root.mountfrom="ufs:/dev/md0" EOF # 7e. bake the bootable cd9660 (El-Torito BIOS + UEFI ESP) via FreeBSD's release script MKISO=$(find "$WORK/freebsd-src" -path '*/release/amd64/mkisoimages.sh' | head -1) sh "$MKISO" -b GERSHWIN "$OUT_ISO" "$ISOROOT"
-b GERSHWIN (vs NextBSD's NEXTBSD). The label becomes the GEOM node /dev/iso9660/GERSHWIN that /init mounts — so the /init heredoc's mount loop must look for GERSHWIN. Keep the two in sync or just keep NEXTBSD and change nothing.mkisoimages.sh + release: '15.0' must match the NEXTBSD kernel's base. The script comes from FreeBSD src.txz; fetch the version matching the kernel baked into the downloaded .img. Mismatch risks an El-Torito/loader combo the kernel won't boot. Confirm the base version from the NextBSD release (or uname inside the extracted rootfs)./System (Gershwin) + the three plists, then re-wrap.tools/iso-boot-test.sh)Adapt NextBSD's tests/iso-boot-test.sh: boot the ISO with qemu -cdrom under OVMF, drive the serial console with expect, and gate on these markers in order:
| Marker | Proves |
|---|---|
vfs.pivot: / is now unionfs | The 2-stage MFS → uzip → unionfs → pivot pipeline fired (kernel printed it). |
login: | launchd reached multi-user; the rootfs is sound. |
(new) ls /System/Applications non-empty / a gdomap-up probe | Gershwin actually landed in the image and its launchd jobs loaded. add |
These already exist — we don't author them. pkgdemon/gershwin-on-freebsd already runs launchd (its freebsd-launchd) as PID 1 and ships all three as launchd plists under resources/overlays/System/Library/LaunchDaemons/. Same mechanism as NextBSD, so this is copy + path translation, not design. They install exactly like NextBSD's overlays — cp -R overlays/System/. → /System/, and launchd's boot-time scan of /System/Library/LaunchDaemons/ loads them (no launchctl load, no rc.d; RunAtLoad=true starts them).
gershwin-on-freebsd does precisely the swap you described, and we mirror it on NextBSD: don't ship the getty LaunchDaemon; ship loginwindow instead. On FreeBSD it omits org.freebsd.getty.{console,vty0}.plist at install time (its build.sh build_launchd() skips them with a case in the install loop, because a text getty fights launchd over the tty). On NextBSD the equivalent is one line in make-gershwin-iso.sh after the overlay copy:
# NextBSD ships getty as com.apple.getty.plist — remove it; loginwindow is the login UI rm -f "$ROOTFS/System/Library/LaunchDaemons/com.apple.getty.plist" # our overlay already added org.gershwin.loginwindow.plist in its place
GNUstep Distributed Objects port mapper (UDP/TCP 538) — required by dshelper. Not a Mach service; it binds its own socket, so no MachServices. The -f flag is mandatory and the single most important correctness detail: it keeps gdomap in the foreground so launchd tracks the real PID. Without it gdomap forks, the parent exits 0, KeepAlive respawns — but the forked child still holds UDP 538, so the respawn fails with "Unable to bind … gdomap is already running" and loops forever.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>org.gnustep.gdomap</string>
<key>ProgramArguments</key>
<array>
<string>/System/Library/Tools/gdomap</string> <!-- exact path from gershwin-on-freebsd -->
<string>-f</string> <!-- foreground / no fork — REQUIRED -->
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
</dict>
</plist>-a flag: gdomap auto-detects interfaces (the rc.d version's -a /var/run/gdomap-iface.conf ifconfig/awk pipeline is dropped). It does need networking up — KeepAlive covers an early start.Gershwin's DirectoryServices daemon (user/group/auth lookups). Persistent (RunAtLoad+KeepAlive). It registers via GNUstep Distributed Objects through gdomap — so no MachServices and no Sockets. It depends on gdomap; if it starts first it crashes and launchd respawns it until gdomap's port is up (the repo deliberately split the old composite rc.d/dshelper — which started gdomap inline — into two one-process jobs).
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>org.gershwin.dshelper</string>
<key>ProgramArguments</key>
<array><string>/System/Library/Tools/dshelper</string></array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
</dict>
</plist>gershwin-on-freebsd runs dscli init once during system configuration to initialize Directory Services — the NextBSD image build must do the same (inside the chroot, after make install).Correction applied: this is not authored from scratch — gershwin-on-freebsd already ships it. It's a system LaunchDaemon (root, boot) that runs a wrapper, /System/Library/Scripts/LoginWindow.sh, which sources GNUstep.sh, sets DISPLAY=:0, and execs the GNUstep greeter at /System/Library/CoreServices/Applications/LoginWindow.app/LoginWindow. State lives in /Local/Library/Preferences/LoginWindow.plist (lastLoggedInUser, lastSession=/System/Library/Scripts/Gershwin.sh).
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>org.gershwin.loginwindow</string>
<key>ProgramArguments</key>
<array><string>/System/Library/Scripts/LoginWindow.sh</string></array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>/var/log/LoginWindow.log</string>
<key>StandardErrorPath</key> <string>/var/log/LoginWindow.log</string>
</dict>
</plist>DISPLAY=:0, but on FreeBSD the X/display at :0 is brought up by rc.d (initgfx, slim) — which does not run under launchd PID 1. gershwin-on-freebsd ships no launchd job for the display server, so a NextBSD launchd job to start X at :0 (plus dbus, networking) must be authored. That, not the three plists, is the new work.gdomap → dshelper → loginwindow, enforced only by KeepAlive retry — there are no explicit dependency keys. If NextBSD's (stricter, Apple-derived) launchd dislikes crash-respawn ordering, consider adding real ordering.Ordered by how much they can sink the plan. Each should be settled by a throwaway CI run before the pipeline is declared working.
| # | Question | How to settle it |
|---|---|---|
| 1 | Does the chroot build survive configure-time probe execution? (Mach-dependent probes under the VM's FreeBSD kernel.) | One throwaway run of stage ③ only; if a probe needs mach.ko, fall back to booting the .img under qemu-KVM and building in-guest. |
| 2 | Which FreeBSD release matches the NEXTBSD kernel base? Drives both vmactions and the mkisoimages.sh source. | uname/freebsd-version in the extracted rootfs; pin release: + the src.txz to it. |
| 3 | gdomap no-fork flag + SYSTEM-domain path. | Check gnustep-base's gdomap options + the install layout from Install-System-Domain.sh. |
| 4 | dshelper shape — persistent Mach helper, DO daemon, or one-shot? | Read gershwin-components/DirectoryServices. |
| 5 | loginwindow as System daemon — can it own the console + start the display server without session-type support? | Spike Option A on the booted ISO. |
| 6 | GPT partition index of the UFS root in the .img. | gpart show md0 on first run (expected p3). |
| 7 | ISO size vs. runner disk. uzip of base+Apple userland+Gershwin. | free-disk-space already added; watch the VM's disk during makefs/mkuzip. |
.img.zip → a FreeBSD VM extracts the rootfs, chroot-builds Gershwin into /System, and re-runs NextBSD build.sh step-7 to emit a vfs.pivot live ISO → the host boot-verifies it under qemu/OVMF. Every piece mirrors a proven NextBSD mechanism.gershwin-on-nextbsd is small: one workflow, three short tool scripts (two adapted from NextBSD), and three launchd plists.