Wiki documentation, and a getting-started script for Linux and the Raspberry Pi - #18
Merged
chrisuthe merged 4 commits intoAug 20, 2026
Conversation
The pages are authored in docs/wiki/ and mirrored to the repository's GitHub wiki by a new workflow on every push to main. Authoring them in the tree is the point: a wiki push bypasses pull requests, review and CI, which is the wrong default for a document telling people what to run as root. scripts/get_started_linux.sh installs a release on any Linux host, a Raspberry Pi included, verifying against the release's SHA256SUMS and printing every privileged command before running it. It enables the systemd unit but starts the player only once an output is configured -- a system unit has no session for ALSA's default PCM to follow, so starting it first means a unit failing every five seconds. SENDSPIN_CLI_TARBALL installs a locally staged payload instead, which is how this was exercised before any release exists.
No minimum macOS version is declared anywhere in the tree, so Home.md no longer invents one; what the .pkg really checks is the architecture. The Pi model table stops enumerating edge cases and points at uname -m, which is the only test that decides anything. The wiki mirror empties and refills the clone with find and cp rather than rsync, which is one fewer thing to assume is on the runner image.
Review found sample output that was composed rather than captured. The startup log line carries an mDNS field; the status block always emits repeat, shuffle and a note line, and output is last, not note; nothing logs which source a static delay came from; the state file is a std::map and so is written alphabetically; a line whose first non-blank character is # is a comment, indented or not; the device-open failure an operator meets is the startup probe's, not the per-stream one. The SD-wear note stopped claiming a debounce the state store does not have. In the script, resolve_latest_tag() could exit 1 with nothing said at all: with no Location header, grep exits 1, pipefail carries it out of the pipeline, and set -e killed the run before the guard that explains it. The config probe now reads through as_root, so a 0600 /etc/sendspin-cli.conf no longer reads as 'no output configured' and leave a configured player stopped; the journal tail does too, and treats no output as failure rather than printing a blank block. The SHA256SUMS check matches the name exactly instead of as a regex, the unit-file assertion runs before the binary does, and a closed stdin at the prompt says so. The wiki workflow fails on a has_wiki that is neither true nor false rather than reporting it as 'wikis are off' and going green forever, and mirrors docs/wiki whole so a page can carry an image beside it. Three arguments that were stated in full on several pages now have one owner each and links from the rest.
The pages were written against a unit that ran the player as root, with one line saying a change to that was in flight. It has merged, so the note is gone and everything it was hedging is now stated: the unit names User=sendspin-cli, the account is declared in lib/sysusers.d beside it, and `systemd-sysusers` is the one step installing cannot do for itself. That command is now in every install recipe on the wiki -- Running as a Service, Installation's by-hand Linux steps, both getting-started walkthroughs -- and 217/USER is named as what an operator gets for skipping it rather than left to be discovered. Running as a Service carries the rest: how the account comes to exist and why its audio membership ships in the same file, what DynamicUser= would cost, the migration path for a root-run deployment (nothing, since StateDirectory= chowns a directory it finds), and the three operator-visible edges of the hardening block -- the systemd 247 floor, the two directories that stay writable, and the four /dev/snd-gating directives deliberately absent. It summarizes and links to README's section rather than forking its text. Swept the rest for the old model: the control socket belongs to the service account and root reads it by not being subject to the mode (Troubleshooting, Controlling the Player, Getting Started on Linux); the Pi page's audio-group advice now separates what the declaration arranges from what a shell run still owes; the payload listings carry the sysusers fragment; uninstalling removes it and says why the account outlives it. Two failures the hardening block introduces are new Troubleshooting entries, both reproduced rather than reasoned about: 217/USER, and a logfile or pidfile refused as a read-only file system under ProtectSystem=strict. The script runs `systemd-sysusers` between unpacking and daemon-reload, printed in the plan like every other privileged command, and only when the payload it is installing actually carries the fragment -- an announced command that turns out not to be needed is one an operator cannot account for. Two guards, because enabling a unit that cannot start is worse than refusing: no systemd-sysusers on $PATH names the useradd README documents as the equivalent, and a payload with no fragment whose unit still names a User= that does not exist is refused rather than left to report 217/USER after an install that said it worked.
chrisuthe
force-pushed
the
chrisuthe/task/wiki-docs-and-getting-started-scripts-for-linux
branch
from
August 19, 2026 23:24
c07217b to
512a4b9
Compare
chrisuthe
marked this pull request as ready for review
August 20, 2026 00:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns the CLI's user-facing surface into wiki documentation, and adds a getting-started
script for Linux and the Raspberry Pi.
Wiki pages are authored in the tree
A GitHub wiki push bypasses pull requests, review and CI, which is the wrong default for a
document telling people what to run as root. So the eight pages plus a sidebar live in
docs/wiki/and are reviewed like any other change, and.github/workflows/wiki.ymlmirrors them to the wiki tab on every push to
main. The wiki tab is the rendering; thetree is the source of truth, and
Homesays so with the path to send a fix to.Pages: Home, Installation, Getting Started on Linux, Getting Started on a
Raspberry Pi, Configuration, Controlling the Player, Running as a Service,
Troubleshooting.
They link into README rather than forking it. README is the reference, it ships inside
every archive, and it is the only copy an offline tarball holder has — so commands are
repeated freely (a stale command fails visibly) and rationale never is (a stale paragraph
drifts into contradiction and nothing catches it). Each page deep-links the relevant README
anchor for the why.
The workflow's failure model is deliberately narrow. Two things skip with a
::notice::,both repository settings only the owner can flip: wikis turned off (
has_wikifalse), andwikis enabled but never initialized — GitHub creates the wiki's git repository when the
first page is made by hand, and offers no API for it. That second case is recognised by
git's own "not found" stderr rather than by "the clone failed", so a network failure or a
rate limit still fails the job, and the captured stderr is printed either way. One case it
cannot separate is stated in the file rather than glossed: GitHub answers a token that may
not see a repository with the same "not found" it uses for one that does not exist.
concurrencyqueues rather than cancels, since two runs push to one remote.It never force-pushes: a rejected push means somebody edited the wiki tab directly, which
is the bypass this arrangement exists to prevent, so it fails and names that cause.
One script, not two
scripts/get_started_linux.shcovers every Linux host including the Pi, because a Pi isan arm64 Linux box here — it takes the same
linux-arm64archive an arm64 server takes.What is genuinely Pi-specific is advice (the
audiogroup, that the headphone jack andHDMI are separate cards), printed when
/proc/device-tree/modelsays so. Two scripts wouldhave been this one with two paragraphs changed, and would have drifted.
It resolves the newest release, downloads that archive plus
SHA256SUMS, verifies, unpackswith the member-selected
tarform README documents, and sets the unit up. Fail-closedthroughout: a 32-bit ARM userland is refused with the whole answer rather than
"unsupported"; an archive
SHA256SUMSdoes not list is refused beforesha256sumcan beleft with nothing to check; a checksum mismatch installs nothing.
It enables the unit but does not start the player until an
outputis configured. Asystem unit has no user session, so ALSA's
defaultPCM has nothing to follow and usuallywill not open, and
Restart=on-failure/RestartSec=5would then retry every five secondsforever while the script printed congratulations. So it enables, lists the host's devices,
and prints the two commands that finish the job. Once
/etc/sendspin-cli.confnames anoutput, every later run installs and restarts — which is also the upgrade path.No silent sudo. Every privileged command is printed in full and then confirmed at a
terminal, or authorised up front with
--yes. Without a terminal and without--yesitrefuses rather than assuming.
The release lookup reads the
/releases/latestredirect rather than the JSON API: nojq(absent on Raspberry Pi OS Lite), no 60-an-hour unauthenticated rate limit, and it is the
only form that tells "this repository has published no releases" apart from "there is no
such repository" — the API answers 404 to both. That matters today, because
v0.1.0isnot tagged, so the script's honest answer to a first user is currently the no-releases
message.
SENDSPIN_CLI_TARBALL=<path>installs a locally staged payload instead, which ishow this was exercised with no release in existence; it prints a loud unverified warning
rather than letting a skipped integrity check read as a successful one.
Verification
Inventory coverage, scripted against the source. Every long flag in the
getopt_longtable (17/17), every short flag (9/9), every config key in
settable_options()(14/14),every subcommand in
control_subcommands()(14/14) and every state-store key (5/5) appearsin some page. Every anchor link into README resolves to a real heading (14/14), every
blob/mainpath exists (5/5), and every cross-page link resolves (8/8). The checker is notchecked in —
ci.ymlalready says that an assertion nothing enforces is one that stopsbeing true, and adding a CI gate would widen this PR.
The script, end to end in an
ubuntu:24.04container with systemd as PID 1 (aarch64,systemd 255) — the same shape the packaging work used:
/etc/sendspin-cli.conf;unit enabled, player correctly not started, device list printed
output = nullconfigured → installs, restarts,is-active;statusround-tripsover
/run/sendspin-cli/control.sock, anddelay 250lands in the0600state fileuname:armv7lrefused with the 64-bit-OSanswer,
s390xrefused,x86_64mapped and proceeding to the release lookup--version v9.9.9refusedsha256sum --ignore-missing -csemantics proven separately — passes on a match, fails ona tampered archive, and never passes vacuously when the archive is not listed
A review pass against the source found six things worth naming, all fixed and
re-verified. Five were sample output that had been composed rather than captured — the
startup log line omitted its
mDNS:field in three pages; thestatusblock was missingrepeat,shuffleand thenote:line thatformat_status()always emits, and hadnote:last whereoutput:is; one sentence claimed a startup log names which source astatic delay came from, and nothing does; the state file sample was not in the alphabetical
order a
std::mapwrites; the device-open failure quoted was the per-stream one rather thanthe startup probe's, which is what an operator actually meets.
The sixth was a real bug.
resolve_latest_tag()could exit1having said nothing at all:with no
Locationheader,grepexits 1,pipefailcarries that out of the pipeline, andset -ekilled the run before the guard written to explain that case. Reproduced, fixed,and re-tested against a stubbed 302. Three smaller script fixes came with it: the config
probe now reads through
as_root, so a0600/etc/sendspin-cli.confno longer reads as"no output configured" and leaves a properly configured player stopped (verified as an
unprivileged user against a root-only config); the journal tail does too, and treats empty
output as failure rather than printing a blank block at the worst moment; and the
SHA256SUMSmembership check matches the name exactly instead of as a regex whose dots werewildcards.
In the workflow,
has_wikibeing anything other thantruewas reported as "wikis areturned off" and went green — which would have stopped publishing indefinitely without ever
failing a run. Only
falseskips now; anything else fails.Unexercised, and worth stating: the download half of the script has no release to run
against, so only its failure paths are proven. The wiki workflow has never run — the wiki
is not enabled on this repository, which is a setting only the owner can flip;
actionlintaccepts it and the mirror's empty-and-refill step was simulated locally (
.gitsurvives,a stale page is removed, all nine pages land flat), but the clone, commit and push are
untested until the wiki exists. Enabling it and running the workflow by hand
(
workflow_dispatch) is the first thing to do after merge.The suite, on the same container: configure and build clean under
-DSENDSPIN_CLI_WERROR=ON,ctest349/349,scripts/smoke_test.shevery check passed.shellcheck scripts/*.shclean — the new script is covered by the existing CI gate —and
actionlintclean over all four workflows.Footprint outside the new files
Two edits, both one paragraph or less:
README.md— one paragraph near the top pointing at the wiki and the script. Nothingin the systemd section is touched.
.github/workflows/release.yml— one comment. It claimed its publishing job was "theonly job in either workflow that can write to the repository", which stopped being true
when
wiki.ymlarrived.docs/ROADMAP.mdis unchanged: it claims no documentation gap this closes.One thing to know about
Running as a ServiceA concurrent task is changing the unit's user model to a dedicated hardened user. That
change has not merged — there is no open PR for it as this is written — so the page
documents today's root-run behaviour and carries one line noting the change is in flight.
It will want a revisit when that lands.