Skip to content

Wiki documentation, and a getting-started script for Linux and the Raspberry Pi - #18

Merged
chrisuthe merged 4 commits into
mainfrom
chrisuthe/task/wiki-docs-and-getting-started-scripts-for-linux
Aug 20, 2026
Merged

Wiki documentation, and a getting-started script for Linux and the Raspberry Pi#18
chrisuthe merged 4 commits into
mainfrom
chrisuthe/task/wiki-docs-and-getting-started-scripts-for-linux

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

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.yml
mirrors them to the wiki tab on every push to main. The wiki tab is the rendering; the
tree is the source of truth, and Home says 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_wiki false), and
wikis 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. concurrency queues 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.sh covers every Linux host including the Pi, because a Pi is
an arm64 Linux box here — it takes the same linux-arm64 archive an arm64 server takes.
What is genuinely Pi-specific is advice (the audio group, that the headphone jack and
HDMI are separate cards), printed when /proc/device-tree/model says so. Two scripts would
have been this one with two paragraphs changed, and would have drifted.

It resolves the newest release, downloads that archive plus SHA256SUMS, verifies, unpacks
with the member-selected tar form README documents, and sets the unit up. Fail-closed
throughout: a 32-bit ARM userland is refused with the whole answer rather than
"unsupported"; an archive SHA256SUMS does not list is refused before sha256sum can be
left with nothing to check; a checksum mismatch installs nothing.

It enables the unit but does not start the player until an output is configured. A
system unit has no user session, so ALSA's default PCM has nothing to follow and usually
will not open, and Restart=on-failure/RestartSec=5 would then retry every five seconds
forever 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.conf names an
output, 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 --yes it
refuses rather than assuming.

The release lookup reads the /releases/latest redirect rather than the JSON API: no jq
(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.0 is
not 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 is
how 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_long
table (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) appears
in some page. Every anchor link into README resolves to a real heading (14/14), every
blob/main path exists (5/5), and every cross-page link resolves (8/8). The checker is not
checked in — ci.yml already says that an assertion nothing enforces is one that stops
being true, and adding a CI gate would widen this PR.

The script, end to end in an ubuntu:24.04 container with systemd as PID 1 (aarch64,
systemd 255) — the same shape the packaging work used:

  • fresh install → binary, unit, and the example config seeded to /etc/sendspin-cli.conf;
    unit enabled, player correctly not started, device list printed
  • re-run → idempotent; config not re-seeded, still enabled, still not started
  • with output = null configured → installs, restarts, is-active; status round-trips
    over /run/sendspin-cli/control.sock, and delay 250 lands in the 0600 state file
  • all three architecture paths via a faked uname: armv7l refused with the 64-bit-OS
    answer, s390x refused, x86_64 mapped and proceeding to the release lookup
  • the real no-releases path against live GitHub, and --version v9.9.9 refused
  • as an unprivileged sudo user at a TTY: accepting installs, declining installs nothing
  • sha256sum --ignore-missing -c semantics proven separately — passes on a match, fails on
    a 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; the status block was missing
repeat, shuffle and the note: line that format_status() always emits, and had
note: last where output: is; one sentence claimed a startup log names which source a
static delay came from, and nothing does; the state file sample was not in the alphabetical
order a std::map writes; the device-open failure quoted was the per-stream one rather than
the startup probe's, which is what an operator actually meets.

The sixth was a real bug. resolve_latest_tag() could exit 1 having said nothing at all:
with no Location header, grep exits 1, pipefail carries that out of the pipeline, and
set -e killed 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 a 0600 /etc/sendspin-cli.conf no 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
SHA256SUMS membership check matches the name exactly instead of as a regex whose dots were
wildcards.

In the workflow, has_wiki being anything other than true was reported as "wikis are
turned off" and went green — which would have stopped publishing indefinitely without ever
failing a run. Only false skips 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; actionlint
accepts it and the mirror's empty-and-refill step was simulated locally (.git survives,
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, ctest 349/349, scripts/smoke_test.sh every check passed.
shellcheck scripts/*.sh clean — the new script is covered by the existing CI gate —
and actionlint clean 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. Nothing
    in the systemd section is touched.
  • .github/workflows/release.yml — one comment. It claimed its publishing job was "the
    only job in either workflow that can write to the repository", which stopped being true
    when wiki.yml arrived.

docs/ROADMAP.md is unchanged: it claims no documentation gap this closes.

One thing to know about Running as a Service

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

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
chrisuthe force-pushed the chrisuthe/task/wiki-docs-and-getting-started-scripts-for-linux branch from c07217b to 512a4b9 Compare August 19, 2026 23:24
@chrisuthe
chrisuthe marked this pull request as ready for review August 20, 2026 00:38
@chrisuthe
chrisuthe merged commit a55a67a into main Aug 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant