Gershwin on NextBSD — build & live-ISO pipeline_

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.

DESIGN PLAN CI / GITHUB ACTIONS vmactions/freebsd-vm LIVE ISO · vfs.pivot launchd PID 1

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.

① ubuntu-latest: fetch .img.zip──▶ ② FreeBSD VM: extract rootfs──▶ ③ chroot: build Gershwin → /System──▶ ④ repackage live ISO (step-7)──▶ ⑤ ubuntu-latest: qemu boot-verify

1The one fact that drives the whole design

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:

ArtifactBoot modelRole here
NextBSD-amd64-<stamp>.img.zipPlain 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.zipThe 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.

2Why it must run inside a FreeBSD VM

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:

3Repo layout for gershwin-on-nextbsd

The 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.

PROPOSED TREE
.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

4The pipeline — build-iso.yml

Five stages. Stages ① and ⑤ run natively on the Linux runner; ②–④ run inside the FreeBSD VM.

.github/workflows/build-iso.yml
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 }

Stage ②③ — extract & chroot-build (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.

tools/make-gershwin-iso.sh (runs as root in the FreeBSD VM)
#!/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

5Stage ④ — the live-ISO repackage

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.

tools/make-live-iso.sh — the four sub-steps (lifted from build.sh step 7)
# 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"

Stage ⑤ — boot-verify (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:

MarkerProves
vfs.pivot: / is now unionfsThe 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 probeGershwin actually landed in the image and its launchd jobs loaded. add

6The three launchd services

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).

The getty → loginwindow swap confirmed pattern

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:

getty drop (in tools/make-gershwin-iso.sh, after cp -aR overlays)
# 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

gdomap verbatim from gershwin-on-freebsd

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.

overlays/System/Library/LaunchDaemons/org.gnustep.gdomap.plist
<?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>

dshelper verbatim from gershwin-on-freebsd

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).

overlays/System/Library/LaunchDaemons/org.gershwin.dshelper.plist
<?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>

loginwindow verbatim from gershwin-on-freebsd

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).

overlays/System/Library/LaunchDaemons/org.gershwin.loginwindow.plist
<?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>

7Open questions & first-run validation

Ordered by how much they can sink the plan. Each should be settled by a throwaway CI run before the pipeline is declared working.

#QuestionHow to settle it
1Does 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.
2Which 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.
3gdomap no-fork flag + SYSTEM-domain path.Check gnustep-base's gdomap options + the install layout from Install-System-Domain.sh.
4dshelper shape — persistent Mach helper, DO daemon, or one-shot?Read gershwin-components/DirectoryServices.
5loginwindow as System daemon — can it own the console + start the display server without session-type support?Spike Option A on the booted ISO.
6GPT partition index of the UFS root in the .img.gpart show md0 on first run (expected p3).
7ISO size vs. runner disk. uzip of base+Apple userland+Gershwin.free-disk-space already added; watch the VM's disk during makefs/mkuzip.

Summary