A flashable Raspberry Pi image that turns a Pi plugged into a TV into the room's big screen and its speaker — a kiosk for an existing Syncphony server. Flash the image with Raspberry Pi Imager, plug the Pi into the TV, and it boots straight into the room. An old PC or a mini-PC works too: boot the x86-64 ISO from a USB stick and install it.
The box is client only: it runs no Syncphony server. Point it at yours.
You need a Raspberry Pi 4 or 5, a 16 GB+ SD card, Raspberry Pi Imager 1.9 or newer, and the address of your Syncphony server.
-
Add the Syncphony Box listing to Imager. Open Imager → App Options → Content Repository → Use custom URL, and paste:
https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json(Or start Imager with it from a terminal:
rpi-imager --repo https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json.) -
Choose your Pi model, then Syncphony Box as the operating system, and your SD card.
-
Customize when Imager offers it: set a username and password, your Wi-Fi (skip it if the box will be on Ethernet), and optionally the hostname, your timezone (the nightly update runs at 04:00 local) and SSH. The image has no default login. Without Wi-Fi settings or a cable, the box starts its own setup hotspot instead (see below).
-
Write, put the card in the Pi, plug the Pi into the TV with the HDMI port next to the USB-C power, and power it on.
-
Point it at your server. The TV shows a QR code: scan it with a phone on the same network and type your server's address. (Or, before step 4, open
syncphony.txton the card'sbootfsdrive and setserver_url=— see Configuration.) -
Pair from your phone. The TV shows a pairing code; in your room on Syncphony, open the Screens list and add it. Done: the box plays the room from then on, through reboots, power cuts and updates.
Updates arrive by themselves overnight (Updates).
Every release
has the image (syncphony-box-<version>-arm64.img.xz). In Imager pick
Use custom and choose the downloaded file; customization works the
same way.
Each release has a SHA256SUMS file signed with
cosign
by the release workflow itself (keyless, through GitHub's OIDC, so there
is no key to leak). With the release's files in one directory:
cosign verify-blob SHA256SUMS \
--bundle SHA256SUMS.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/madeofpendletonwool/syncphony-box/\.github/workflows/build\.yml@refs/tags/v'
sha256sum --check --ignore-missing SHA256SUMSVerified OK and OK mean the image is exactly what CI built from the
tagged source. (Imager checks the image against the listing's SHA-256 on
its own while writing.)
Any 64-bit PC or mini-PC with 2 GB of RAM and a disk of 8 GB or more (ADR 0013). Turn Secure Boot off in the firmware settings; UEFI and legacy BIOS both work.
- Download
syncphony-box-<version>-amd64.isofrom the release and write it to a USB stick (Imager's Use custom, balenaEtcher, orsudo dd if=syncphony-box-<version>-amd64.iso of=/dev/sdX bs=4M conv=fsync). It's a hybrid ISO, so a DVD works too. - Boot the PC from the stick, plugged into the TV. The menu has two
entries:
- Syncphony Box runs the box from the stick, with nothing
installed. Good for trying it. Its changes live in RAM, so it
forgets its setup and pairing at power-off, unless the stick has a
syncphony-datapartition (below). - Install Syncphony Box to disk erases a disk you pick and installs the box on it. It offers an optional admin user (sudo, for SSH and maintenance; a PC has no Imager to make one). When it's done, remove the stick; the PC restarts into the box.
- Syncphony Box runs the box from the stick, with nothing
installed. Good for trying it. Its changes live in RAM, so it
forgets its setup and pairing at power-off, unless the stick has a
- Point it at your server and pair, as in steps 5 and 6 above. The setup QR, the hotspot, updates and the read-only root all work as on a Pi. There is no HDMI-CEC: most PC graphics can't send it.
Keeping a live stick's pairing. Add a partition labelled
syncphony-data to the stick, in the free space after the ISO, and the
live box keeps its setup and pairing there:
# /dev/sdX is the stick with the ISO already written to it.
echo ',,L' | sudo sfdisk --append /dev/sdX
sudo mkfs.ext4 -L syncphony-data /dev/sdX4 # the ISO has 3; this is the 4thRemove the stick before booting an installed box: both would offer a
syncphony-data partition.
- Raspberry Pi 4 or Pi 5, 2 GB RAM or more
- 16 GB+ SD card
- On a Pi, arm64 only. Pi 3, Pi Zero 2 W and armhf are not supported.
- Or an x86-64 PC: 2 GB RAM, an 8 GB+ disk, Secure Boot off (On a PC). The ISO carries firmware for Intel and AMD graphics; NVIDIA graphics are untested.
- A case with a heatsink or a fan. A long party with the visualizer on warms a Pi in a closed case past its soft temperature limit, and the firmware silently halves the CPU first (the box logs temperature and throttling — see Self-healing below).
Requires a Linux machine with Docker and git. The build runs pi-gen inside a container; on an arm64 host the rootfs builds natively, on x86 it runs through QEMU emulation inside the container (much slower — CI is the recommended way to build).
git clone --recurse-submodules https://github.com/madeofpendletonwool/syncphony-box
cd syncphony-box
make imageThe result lands in deploy/:
syncphony-box-<version>-arm64.img.xz— the image (<version>fromgit describe)syncphony-box-<version>-arm64.img.xz.sha256— its checksumsyncphony-box_<deb-version>_arm64.deb, the box's software as a package. The image installs it, and boxes update to newer ones from the apt repo.
make iso builds the x86-64 ISO the same way, with Debian's live-build
in a privileged debian:trixie container (native on an x86 host,
emulated and slow on an arm64 one): syncphony-box-<version>-amd64.iso,
its .sha256, and the _amd64.deb. Both images run the same
customisation (rootfs/customise.sh) inside their build chroots, so a
change to the box lands in both (ADR 0013).
make clean removes build output and the leftover pi-gen build container.
make test runs the unit tests — the bats suite (tests/) that pins the
syncphony.txt behavior and the /data persistence script against
binaries built from the repo, plus go test for boxd — no hardware or
image build needed; install bats (apt install bats) and Go first.
make lint checks gofmt and go vet.
CI runs them on every PR and push.
GitHub Actions builds the image natively on arm64 runners
(ubuntu-24.04-arm) on every push to main and on every PR, runs the
bats and Go tests, runs the no-hardware smoke test (scripts/smoke-test.sh
loop-mounts the built image and checks packages, enabled units, the locked
first user, the first-boot seed files, the kiosk session, the boot
config file, the audio stack, boxd, and the read-only-root layout: three
partitions, the overlay wired into the initramfs, and every persistent
path on /data), and uploads the image as a workflow artifact. An x86
runner builds the ISO alongside it and runs the same smoke test against
the ISO's root filesystem, plus its boot menu (UEFI and BIOS), its
initramfs and the installer. Pushing a v* tag releases both; see
docs/releasing.md.
boxd is the box's helper daemon — a small static Go program that owns
everything that isn't Chromium (ADR 0005). It
parses syncphony.txt on every boot, serves the box bridge on
http://127.0.0.1:8099 for the /tv page (Syncphony
ADR 0016),
and serves the box's own screens:
-
Setup. With no
server_urlset, the TV shows a big QR code. Scan it with a phone on the same network (or openhttp://<box>.local), type your Syncphony server's address, and Save — boxd checks the server's/healthz, writes it tosyncphony.txt(still the single source of truth), and restarts the kiosk into your server's pairing screen. That listener is on the LAN only while the box is unconfigured (or for the 10 minutes after a long OK press on the TV remote), and it never offers anything beyond the URL and the box's name. -
Offline. The kiosk never navigates straight at the server: it opens a local start page that checks
/healthzfirst. If the server can't be reached, the TV shows "Can't reach<server>— retrying" with the box's IP and a countdown, and goes back to the room by itself once the server answers — never Chromium's error page. If the server goes away while the room is open, boxd notices (heartbeats keep coming while the page is alive) and moves the TV to the offline screen after a couple of minutes. -
No network at all: the setup hotspot. A box with no Ethernet and no Wi-Fi becomes its own open Wi-Fi network,
Syncphony-Box-XXXX(the last four of its MAC). The TV shows a QR code that joins it, and the phone's captive-portal page opens with the Wi-Fi networks in range, a password field, and the server URL if the box doesn't have one yet. Saving joins that Wi-Fi. If the join fails, the hotspot comes back and the TV says why. A new box starts the hotspot 60 s after boot. A box that has been online before shows the offline screen first and only falls back to the hotspot after 5 minutes without a network, so a router reboot never puts it in setup mode. Until the hotspot starts, the setup screen counts down to it and says why if it can't start. Imager's Wi-Fi settings still work and are the fast path (ADR 0010). -
Wi-Fi from the TV itself. With a mouse, a keyboard or the remote, the setup screen's Start setup Wi-Fi now skips the wait. Choose Wi-Fi on this screen (also on the hotspot screen) lists the networks in range on the TV: pick one, type its password, and the box joins it with no phone needed.
Logs go to journald: journalctl -u boxd -u syncphony-kiosk.
The box talks CEC through the kernel framework (cec-ctl), not libcec —
see ADR 0009. Three behaviors, all under
cec= in syncphony.txt:
- Wake and switch. When music starts, a TV in standby (or on another input) gets Image View On + Active Source — queue a song from the couch and the room turns itself on. Rate-limited to once per playback start and once every 5 minutes, so it never fights whoever switched to the game console.
- The remote's keys. Arrows, OK, Back and Play/Pause on the TV remote
become keyboard events (injected through uinput) —
/tvalready knows them: arrows steer the visuals, right skips, OK clicks (the "Play here instead" button is focused, so OK takes over as speaker), Play/Pause plays and pauses. Holding OK for 3 seconds opens the setup window and shows the box's IP and version on the TV. - Optional standby.
cec_standby=onputs a TV the box switched on (and nobody has taken away since) into standby after the room has been idle forcec_standby_minutes(default 30). Off by default.
CEC support varies wildly by TV brand — what works where is recorded in docs/hardware.md.
On boot the box starts syncphony-kiosk.service: the unprivileged kiosk
user runs cage (a Wayland kiosk
compositor) on tty1, and cage runs Chromium fullscreen. There is no desktop
and no login. If Chromium is killed, the unit restarts it after 2 seconds.
The boot is silent (quiet loglevel=3, no rainbow splash, no console
cursor, no console blanking). See docs/adr/0002-kiosk-session.md.
Every Chromium flag lives in one wrapper, /usr/lib/syncphony-box/kiosk,
on the box — including the GPU flags that let Chromium use the Pi's GPU.
The browser enters through boxd's local pages and opens the server's /tv
from there.
The box's whole identity is its Chromium profile: the server's
syncphony_display pairing cookie and the "I'm the speaker" localStorage
flag live there, so that's what must survive reboots and power cuts. The
profile lives on the /data partition (/data/chromium — see below);
the path is defined in one place (boxd's DefaultProfileDir) and reaches
the kiosk wrapper through config.env. See
docs/adr/0006-pairing-persistence.md.
Two mechanisms keep it durable:
- When the
/tvpage reports apairedorunpairedevent on the box bridge (and, rate-limited, a playback start), boxd runs asyncburst — Chromium writes cookies and localStorage lazily, and the burst pushes those writes to the SD card seconds after they happen instead of up to half a minute later. Pair, pull the power within 10 s, boot: still paired. - Before every start the wrapper clears Chromium's "exited uncleanly" state, so a power cut never turns into a "restore pages?" bubble on the TV.
Resetting a box. When a box moves to another room or another server, wipe the pairing so it shows a new code:
sudo syncphony-box resetIn a dev checkout, make reset does the same to a profile (point it with
SB_PROFILE=...; it defaults to the box's path).
The box is meant to be power-cycled like a TV — pulled at any moment, mid-song included. The image makes that structurally safe (docs/adr/0007):
- Three partitions: boot (FAT), root (ext4), and data (ext4,
label
syncphony-data). On first boot the data partition grows to fill the card; the root never grows. - The root filesystem is read-only behind an overlay — the mechanism raspi-config's "Overlay FS" option uses, enabled at build time: the real root is the lower layer, every runtime write lands in a tmpfs upper and evaporates at power-off. Nothing on the SD card is ever half-written.
- Everything that must persist lives on /data — the pairing (the Chromium profile), the applied config, logs, the SSH host keys, the Imager-created user, Wi-Fi credentials. The maintained list is docs/persistent-paths.md.
- /boot/firmware stays read-only too; boxd remounts it rw for the
moment it writes
syncphony.txt(the setup form) or aresolution=change, then ro again. - A damaged /data never hangs the boot: the box comes up unpaired and the TV says the storage is lost (fix: check or re-flash the card).
The power-pull test procedure (50 pulls: during boot, during playback, seconds after pairing) is docs/testing.md.
Maintaining the box. Runtime writes under / are discarded by
design, so package changes go to the real root through a chroot (the
overlayroot-chroot role in this image). The nightly update does this by
itself (Updates); for anything else:
sudo syncphony-box chroot # shell on the real root, read-write
# (or: sudo syncphony-box chroot apt install <something>)/data and a writable /boot/firmware come along into the chroot, so a
kernel update lands too. Reboot afterwards. Changes to the pairing,
config and anything else on /data need no chroot, because that partition
is writable at runtime.
The box keeps itself current with no reflashing
(ADR 0012). The web app needs nothing: the
Syncphony server serves it. What updates is the OS and the box's own
software, which is the syncphony-box package, served from a signed apt
repository on this repo's GitHub Pages.
- Every night around 04:00 (box-local time)
syncphony-update.timerrunsboxd update: Debian and Raspberry Pi security updates plus the newestsyncphony-box, installed onto the real root filesystem throughsyncphony-box chroot. If the room is playing, it skips the night. - It reboots only if something changed, and only once the room is
idle. The pairing lives on
/dataand is untouched. auto_update=offinsyncphony.txtturns the nightly run off.sudo syncphony-box updateruns it now (even withauto_update=off); add--no-rebootto reboot on your own schedule.syncphony-box versionprints the installed version;/v1/inforeports it with the last update's time and result, which the room's Screens list shows.- The result of the last run is in
/data/syncphony-box/update.json, and the details are injournalctl -u syncphony-update.
If an update fails, the box keeps running what it had, the Screens list says "update failed", and the next night tries again. If a box no longer boots after an update, reflash the card with the latest release and pair it again (step 6 above). There are no A/B partitions to fall back on.
The box notices when it's stuck and fixes itself — nobody walks over to it. Three watchdogs, each owning one failure class (ADR 0008):
- Hardware watchdog (kernel/systemd hangs). systemd arms the Pi's hardware watchdog at 15 s. A kernel or systemd hang stops the petting and the hardware hard-resets the box; with the read-only root it comes back clean, paired, playing.
- Kiosk watchdog in boxd (a wedged page). The
/tvpage heartbeats to boxd every 30 s. If the heartbeat stops for 90 s while the server still answers/healthz, boxd restarts the kiosk — Chromium comes back and the speaker resumes by itself. If that happens 3 times in 15 minutes, the box reboots instead. When the server is unreachable, the watchdog stays quiet: that's the offline screen's job, not a stuck page. - Time.
systemd-timesyncdruns and the kiosk waits fortime-sync.target: the server's playback clock sync trusts the browser's clock, and a Pi has no RTC battery by default.
Two hygiene loops ride along:
- Thermals. boxd logs
vcgencmd measure_tempand the throttling flags to the journal every minute (journalctl -u boxd | grep thermals) and reports them in/v1/infofor the room's Screens list (stage 4). A persistentthrottled=0x50000-style value means the power supply is weak; a hottemp=means the case needs airflow. - Nightly reload. Once a day around 05:00 local, while the page is up and nothing is playing, boxd restarts the kiosk so a long-lived Chromium gives its memory back. A party that runs through the night is never interrupted.
The freeze and kernel-hang procedures that verify all this are in docs/testing.md.
The box is the room's speaker: Chromium plays everything through the
<audio> element on /tv, and the box's audio stack is PipeWire (the
Raspberry Pi OS default) with WirePlumber. It runs as the kiosk user's
own services, started at boot via linger — no login, no system-mode
daemon. See docs/adr/0004-audio-output.md.
audio=fromsyncphony.txtpicks the sink at every boot (hdmi= HDMI0, the port next to USB-C power;hdmi2= HDMI1;analog= the Pi 4's 3.5 mm jack;usb= the first USB audio device).- The chosen sink is the default, at 100% and unmuted. Nothing in the box scales the sound down: the TV's remote (HDMI carries PCM) or the room's own volume control sets the level.
- The kiosk waits for the audio unit like it waits for the network, so Chromium never starts before the audio server is up — and a hotplug monitor re-applies the choice when outputs appear or disappear (a TV that was off at boot and turns on later, HDMI unplugged and back).
- Outputs never auto-suspend, so the first notes of a song aren't clipped by a device wake-up.
To try sound by hand on a box (from a console or SSH): pactl info shows
the default sink, and this plays a test tone through it:
aplay /usr/share/sounds/alsa/Front_Center.wav # or: pw-play /usr/share/sounds/alsa/Front_Center.wavChanging audio= takes effect on the next boot.
The box is configured by a file you can edit from any computer. Turn the
box off, put the SD card in a card reader, and open syncphony.txt on the
card's boot partition (the small drive, labeled bootfs; on the box it is
/boot/firmware/syncphony.txt). Remove the leading # from the options
you want, save, put the card back, and power the box on. CRLF line endings
and a BOM are fine — Notepad works. A missing or invalid file never stops
the box from booting; bad values are logged and fall back to defaults.
| option | values | default | what it does |
|---|---|---|---|
server_url |
https://… |
unset | the Syncphony server this box shows |
name |
any text | unset | name in the room's Screens list; also the hostname (Living room TV → living-room-tv, advertised as living-room-tv.local) |
audio |
hdmi, hdmi2, analog, usb |
hdmi |
where the sound goes (analog is the Pi 4's jack — a Pi 5 has none; use usb for a DAC) |
resolution |
e.g. 1920x1080@60 |
auto | forces the HDMI mode (takes a reboot; the box reboots once on its own) |
rotate |
0, 90, 180, 270 |
0 |
rotates the screen (takes a reboot) |
cec |
on, off |
on |
switch the TV's input when music starts (HDMI-CEC; the remote's keys drive the box too — docs/hardware.md for the TV matrix). Pi only |
cec_standby |
on, off |
off |
put the TV in standby once the room has been idle (only ever a TV the box switched on itself) |
cec_standby_minutes |
1–240 |
30 |
how many idle minutes before that standby |
auto_update |
on, off |
on |
install updates every night around 04:00 (Updates) |
On a PC box, syncphony.txt is on the SB-BOOT partition (the EFI
system partition, also at /boot/firmware), and resolution/rotate
go into /boot/firmware/cmdline.cfg, which GRUB reads. audio=hdmi and
hdmi2 are the first and second HDMI/DisplayPort outputs; analog is
the headphone/line-out jack. In the live session (from the USB stick)
the file is in RAM, or on the stick's syncphony-data partition, and
resolution/rotate don't apply.
On every boot, syncphony-box-config.service runs boxd config apply:
it parses the file into config.env (kept on /data; /etc/syncphony-box/config.env
is a symlink to it) before the kiosk starts; resolution and rotate
also become a video=HDMI-A-1:…D kernel argument (the D forces the
output on, so a TV that's off at boot still gets a picture later).
Without a valid server_url the box shows its setup screen: a QR code you
scan with a phone on the same network to set the server — no SD card
editing needed. (Editing the card still works too.)
CI can't boot a Pi; verify these on hardware when one is at hand. The destructive power-pull gauntlet (the done-when for the read-only root) has its own page: docs/testing.md.
- Boots to the server's
/tvfullscreen in under ~30 s on a Pi 5. sudo systemctl kill --kill-who=all syncphony-kiosk(or killing Chromium) brings the session back within a few seconds.- No visible cursor — including with a mouse plugged in and wiggled.
- No crash bubble, and no "restore pages" prompt after pulling power.
- Pair → reboot → the box is back in the room and playing without a touch.
sudo syncphony-box resetwipes the pairing: the box shows a fresh pairing code afterwards, and re-pairs normally.- Read-only root:
findmnt /shows an overlay,/boot/firmwareisro,/datais mounted,touch /usr/bin/nopesurvives only until a reboot, and the setup form still saves (boxd remounts the boot partition around its write). - First boot grows
/datato fill the card (lsblk), root stays at image size. sudo syncphony-box chroot apt updatewrites the real root; the change is still there after a reboot.- The
/tvvisualizer runs smoothly with GPU rasterization (checkchrome://gpuon the box); if not, tune the GPU flags in the wrapper. - Edit
syncphony.txton a laptop (Notepad is fine), boot, and the server URL, hostname, audio output and resolution all changed. - The box answers at
<hostname>.local; no validserver_urlstill boots to the setup screen (QR code); an empty or garbage file boots to defaults. - A TV that's off at boot gets a picture when turned on later (
Don the video= token), androtate=90really rotates. aplay /usr/share/sounds/alsa/Front_Center.wav(orpw-playon the same file) plays the test tone from the chosen output, on both a Pi 4 and a Pi 5.- HDMI-CEC (docs/testing.md, "The CEC gauntlet"): a
TV in standby wakes and switches when a song is queued; the remote's
keys drive
/tv; a 3-second OK shows the box's IP and version; the box never fights a hand-switch to another input. Record the TV in docs/hardware.md. pactl infoshows the expectedDefault Sinkfor eachaudio=value — set it on the card, boot, check, and confirm the sink is at 100%.- Cold boot with the TV off: turn the TV on, pair with audio — sound plays through the TV, no login, no SSH (the hotplug monitor switched to the HDMI sink when it appeared; if the stream doesn't follow without a reload, that's a finding for ADR 0004).
audio=usbwith a USB DAC: the DAC is the default sink and the test tone plays through it after a reboot.- Unplug HDMI while music plays, plug it back: audio returns by itself.
- Fresh flash, no Imager Wi-Fi, no cable: about a minute after boot the
TV shows the
Syncphony-Box-XXXXQR; a phone joins, the setup page opens by itself, and choosing the house Wi-Fi (plus the server URL) ends in the pairing screen. A wrong password brings the hotspot back with the reason on the TV (docs/testing.md, "The Wi-Fi setup run"). - Fresh flash, no
server_url: the setup screen's QR appears; setting the URL from a phone (the box validates it via/healthz) brings up/tv's pairing code with no SD-card editing. - Pull the server's network cable: the offline screen appears within a couple of minutes (or at the next kiosk start) with the box's IP and a countdown; plugging it back returns to the room with no touch.
- Freeze Chromium's renderer (
pkill -STOP -f 'type=renderer'): the room comes back by itself within about 2 minutes (the boxd kiosk watchdog), and music resumes. - Kernel-hang reboot (destructive; see docs/testing.md):
echo c > /proc/sysrq-trigger— the hardware watchdog resets the box and it comes back paired and playing. journalctl -u boxd | grep thermalsshows a temperature line every minute;curl 127.0.0.1:8099/v1/infoincludestemperatureC. Check on a Pi 4 and a Pi 5.timedatectlshows the clock synchronized andsystemctl status systemd-timesyncdis active, on both a Pi 4 and a Pi 5 (which has an RTC header but no battery by default).- A 12-hour playback soak with the visualizer on, on a 2 GB Pi 4 (the procedure is in docs/testing.md): memory plateaus, and the 05:00 nightly reload happens only while idle.
- Updates (docs/testing.md, "The overnight update"):
flash release N, pair, publish N+1, leave the box on overnight. In the
morning
syncphony-box versionsays N+1, the box is still paired and playing,findmnt /is still the overlay, and/v1/infohas anupdatewithresult: updated. Playing music across 04:00 skips that night. - An hour of real playback as the room's speaker — Navidrome (MP3 and FLAC) and Spotify (Ogg): no gaps between songs, no drift, no clipped first notes.
pi-gen/ pi-gen submodule (pinned, never edited)
rootfs/ the shared customise layer both images run inside
their chroot: package lists, the .deb, the steps
(ADR 0013)
packaging/root/ every file of the syncphony-box .deb, laid out as
it installs (units, kiosk wrapper, scripts, ...)
packaging/debian/ the .deb's control template and postinst
packaging/apt/ the updater's apt source and the repo's public key
packaging/imager/ the Imager listing's icon
stage-syncphony/ custom pi-gen stage: runs rootfs/customise.sh, then
the Pi's boot partition (cmdline, config.txt, ...)
x86/live/ live-build config and hooks for the x86 ISO
x86/root/ the ISO's own files: the installer, its units and
GRUB templates, the live session's /boot/firmware
boxd/ the box's Go helper daemon (bridge, config, screens,
watchdogs, CEC, Wi-Fi setup hotspot)
config pi-gen build configuration
build.sh wraps pi-gen's build-docker.sh (builds boxd and the
.deb first, then appends the /data partition)
build-iso.sh builds the x86 ISO with live-build in a container
Makefile make image / make iso / make test / make lint /
make clean
scripts/add-data-partition.sh the /data partition post-export step
scripts/smoke-test.sh no-hardware image checks (used by CI)
scripts/build-deb.sh builds the syncphony-box .deb
scripts/apt-repo.sh, imager-os-list.py, changelog-section.sh
the release job's pieces (docs/releasing.md)
CHANGELOG.md release notes, one section per version
tests/ bats tests pinning the syncphony.txt and /data
behavior (used by CI)
docs/adr/ architecture decision records
docs/persistent-paths.md the list of everything that survives a power cut
docs/hardware.md tested boards and the TV CEC compatibility matrix
docs/testing.md the hardware test procedures (power pulls & co)
docs/releasing.md cutting a release; the signing keys
AGPL-3.0, matching Syncphony. See LICENSE.