diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11ed578..35cb0e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: timeout-minutes: 5 outputs: version: ${{ steps.version.outputs.version }} + notes: ${{ steps.notes.outputs.notes }} steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -79,6 +80,85 @@ jobs: } echo "version=$version" >>"$GITHUB_OUTPUT" + # Refused here for the reason the version check above is: a tag whose notes nobody wrote + # publishes a release that says nothing about itself, and finding that out costs seconds + # here against half an hour of runners afterwards. Ordered before the API call below, so + # the answer the tree already holds costs no round trip. + # + # What it reads is the half of the release body that changes per release: + # `docs/release-notes/.md`, written in the pull request that bumps the version + # CMakeLists.txt carries, so what a release says about itself is reviewed alongside the + # change it describes. The other half is the fixed tail in the release job below. This is + # the job that reads it because this is the job with a checkout -- the release job + # deliberately has none. + - name: Check the tag carries the release notes it will publish + id: notes + env: + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + notes="docs/release-notes/$VERSION.md" + [ -f "$notes" ] || { + echo "::error::$notes does not exist; add it and re-cut $TAG" + exit 1 + } + # Not `[ -s ]`, which passes a file holding nothing but a newline. + grep -q '[^[:space:]]' "$notes" || { + echo "::error::$notes has no content; write it and re-cut $TAG" + exit 1 + } + + # Counted before the scan below reads them, because that scan is fence-aware and an + # unpaired fence would leave it skipping the rest of the file -- a guard that quietly + # stops guarding. It is also a defect on its own: an unclosed fence swallows everything + # after it into a code block. `|| true` because `grep -c` exits 1 on a count of zero + # and this shell runs under `-e`, the same reason the VERSION count above needs it. + fences="$(grep -c '^```' "$notes" || true)" + [ $((fences % 2)) -eq 0 ] || { + echo "::error::$notes leaves a \`\`\` fence open -- $fences fence lines, an odd count; close it at column 0 and re-cut $TAG" + exit 1 + } + + # A release page renders the release's own title, so a `#` or `##` inside the body + # comes out as banner text beneath it, outranking the heading the page already has. + # Sections start at `###`. + # + # Fence-aware, rather than one grep over the whole file: the docs here write shell + # walkthroughs with a comment at column 0, `docs/wiki/Installation.md` among them, and + # inside a fenced block that is a comment rather than a heading. A guard that refuses + # one is a guard people learn to work around. `##? ` rather than an interval, which not + # every awk on a runner image reads the same way. + banners="$(awk '/^```/ { fence = !fence; next } + !fence && /^##? / { printf "%d: %s\n", NR, $0 }' "$notes")" + [ -z "$banners" ] || { + echo "$banners" + echo "::error::those lines in $notes are ## or shallower; start the sections at ### and re-cut $TAG" + exit 1 + } + + # A job output rather than an artifact. The release job asks download-artifact for + # whatever the build produced with no name and no pattern on purpose, and the step + # after it diffs the whole of dist/ against the exact set the tag publishes, so a + # seventh file arriving there would have to be excepted from the assertion that makes + # the release atomic. The other alternative is a checkout in the release job, and that + # is one more action running under `contents: write` for a file this job already holds. + # An output caps at 1 MB against a notes body of a few KB. + # + # Random delimiter, because a heredoc into $GITHUB_OUTPUT ends at the first line equal + # to it and what is being fenced is exactly the text somebody wrote in the tree. The + # bare `echo` puts the delimiter on a line of its own whether or not the file ends in a + # newline, which is the other way that fence breaks. + delimiter="NOTES_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" + { + echo "notes<<$delimiter" + cat "$notes" + echo + echo "$delimiter" + } >>"$GITHUB_OUTPUT" + + echo "--- $notes ---" + cat "$notes" + # The second likeliest way to reach this workflow with nothing publishable at the end of # it: a tag that something already claims. `gh release create` will not refuse it -- a # draft is not bound to a tag ref, so it is free to add a second one alongside whatever is @@ -200,104 +280,55 @@ jobs: cat SHA256SUMS - name: Write the release notes + env: + # The per-release half of the body, read from the tagged tree by preflight. Step-scoped + # like RELEASE_ID below, one step needing it; and carried in the environment rather + # than interpolated into the script, so nothing somebody wrote in a notes file is ever + # parsed as a workflow expression or as shell. + NOTES: ${{ needs.preflight.outputs.notes }} run: | + # An output that did not arrive -- a renamed step id, a typo in the output name -- + # expands to the empty string and reports nothing, which would publish a release + # carrying only the tail below and call it a success. Preflight proved the file was + # there; this proves what it read got here. + [ -n "$NOTES" ] || { + echo "::error::the notes preflight read did not reach this job; refusing to publish $TAG with a body it did not write" + exit 1 + } + + # The per-release sections first, then the fixed tail below. Quoted argument to + # `printf`, so this half reaches the file exactly as it was written -- it is somebody's + # prose, and none of it is shell. The blank line it ends on is what keeps the tail's + # heading off the last line of the notes. + printf '%s\n\n' "$NOTES" >notes.md + # Heredoc quoted, so the only interpolation is the one done explicitly below: this # text is full of backticks and $ that belong to the reader, not to the shell. - cat >notes.md <<'NOTES' - Binaries for Linux (x86-64, arm64 and 32-bit ARM) and macOS (Apple silicon), plus a - macOS installer `.pkg`, built and published by `.github/workflows/release.yml` from - the tag alone. - - `linux-armv7` and `linux-armv6` are the two 32-bit Raspberry Pi builds, and which one - a board takes is a question about its CPU. `linux-armv7` is for a Pi 2, a Pi 3, a Pi 4 - or a Pi Zero 2 running a 32-bit Raspberry Pi OS; `linux-armv6` is for a Pi Zero, a Pi - Zero W or an original Pi, which are ARM1176 and would meet an illegal instruction in - the other. `scripts/get_started_linux.sh` picks between them for you. Neither is built - on a Pi — the unit suite and smoke test of both are run under emulation on the runner - that built them — and `linux-armv6` is the only archive here that loads on Raspberry - Pi OS bookworm as well as trixie. - - ## Install - - Each archive is a staged `cmake --install` payload, so every path under its `usr/` is - the path the file installs to: - - ```bash - sudo tar -xzf sendspin-cli-@VERSION@-linux-x86_64.tar.gz --strip-components=1 -C / \ - sendspin-cli-@VERSION@-linux-x86_64/usr - sudo systemd-sysusers - sudo systemctl daemon-reload - ``` - - `systemd-sysusers` creates the unprivileged account the unit runs as, from the - declaration the payload installs alongside it; a tarball has no `postinst` to run it - for you, and without it `systemctl start` reports `217/USER`. - - Naming the `usr` member is what leaves `BUILD-INFO.txt` in the archive rather than - unpacking it at `/`. Each archive carries its own `BUILD-INFO.txt` at the root with - that command already filled in for its platform, the runtime packages it needs, and - what the systemd unit does. Or run it where you unpacked it, at - `.//usr/local/bin/sendspin-cli`. - - On macOS the `.pkg` is the shorter path to the same place. It is built from the very - payload the macOS archive holds, so the two put identical files on disk: - - ```bash - sudo installer -pkg sendspin-cli-@VERSION@-macos-arm64.pkg -target / - sendspin-cli --version - ``` - - There is no uninstaller: remove those files and run - `sudo pkgutil --forget io.github.chrisuthe.sendspin-cli`. - - ## macOS is unsigned, archive and `.pkg` alike - - The macOS binary is **ad-hoc signed** — the minimum an arm64 Mach-O needs to execute - at all, applied by the linker. It carries no developer identity, `spctl` rejects it, - and the `.pkg` around it is unsigned in the same way. Unpack the archive from a - terminal rather than in Finder, whose Archive Utility sets the quarantine flag where - `tar` does not; if macOS refuses the binary anyway: - - ```bash - xattr -d com.apple.quarantine \ - ./sendspin-cli-@VERSION@-macos-arm64/usr/local/bin/sendspin-cli - ``` - - A `.pkg` downloaded from this page is quarantined too, and Gatekeeper refuses it on a - double-click — allow it once under **System Settings → Privacy & Security**, or use the - `installer` command above, which makes no Gatekeeper assessment at all whatever the - file is flagged with. - - All of that is a workaround, not a fix, and the `.pkg` is not the fix either. What it - changes is narrower: `xcrun stapler` staples a notarization ticket to a `.pkg` and - refuses a bare executable, so this is the shape that makes notarization possible at - all. A Developer ID signature and notarization need a paid Apple enrolment and are - still owed. Said plainly here because it is worth knowing before you download: these - are unsigned builds from a public CI runner. The full account is under **macOS, and - Gatekeeper** in the README linked below. - - ## Checksums - - `SHA256SUMS` covers the six files above. It does not cover the `Source code` - archives GitHub attaches on its own, which are generated by GitHub rather than built - here. - - ```bash - sha256sum --ignore-missing -c SHA256SUMS # Linux - shasum -a 256 --ignore-missing -c SHA256SUMS # macOS - ``` - - Run that from the directory you downloaded into. `--ignore-missing` is there because - `SHA256SUMS` lists all six and you have almost certainly taken one: without it, the - ones you did not download are reported as failures and the command exits non-zero on a - perfectly good file. - - ## What this build is + # + # Only what is true of every release belongs here. Installing, verifying SHA256SUMS + # and the Gatekeeper procedure are linked rather than restated: a release body is + # frozen at the tag and a wiki page is not, so a copy of one in the other drifts with + # no way for a reader to tell which is current -- and someone reading a release wants + # to know what changed in it, not to scroll past a page of what did not. + cat >>notes.md <<'NOTES' + ### What this build is sendspin-cli @VERSION@. The version of the sendspin-cpp library it is linked against is pinned at build time and printed on the second line of `sendspin-cli --version`, alongside this one on the first. + Installing, verifying `SHA256SUMS` and the macOS Gatekeeper situation are in the + [wiki](https://github.com/@REPO@/wiki) — [Installation](https://github.com/@REPO@/wiki/Installation), + or [Getting Started on a Raspberry Pi](https://github.com/@REPO@/wiki/Getting-Started-on-a-Raspberry-Pi) + for the two 32-bit ARM archives. Each archive also carries its own `BUILD-INFO.txt`, + with the install command already filled in for its platform. + + The macOS binary and the `.pkg` around it are **unsigned** — ad-hoc signed, which is + the minimum an arm64 Mach-O needs to execute at all, and carries no developer identity + — so Gatekeeper refuses them until you say otherwise. Said here rather than only behind + a link because it is worth knowing before you download; what to do about it is under + [macOS, and Gatekeeper](https://github.com/@REPO@/blob/@TAG@/README.md#macos-and-gatekeeper). + - [README](https://github.com/@REPO@/blob/@TAG@/README.md) — build, install, run, [macOS, and Gatekeeper](https://github.com/@REPO@/blob/@TAG@/README.md#macos-and-gatekeeper), and [the installer `.pkg`](https://github.com/@REPO@/blob/@TAG@/README.md#the-macos-installer-pkg) @@ -305,13 +336,17 @@ jobs: what is owed, item by item NOTES - # One quoted heredoc and one substitution pass, rather than a second unquoted heredoc + # One substitution pass over the assembled body, rather than a second unquoted heredoc # for the parts that need a value: nothing in this text is then subject to shell # expansion, so a backtick added to it later cannot become a command substitution in a # published release body. `|` as the delimiter because the repository name holds a `/`, # and every value substituted here is one the preflight already matched against a # pattern with no delimiter in it. # + # It runs over the per-release half too, which is what lets a notes file write its + # compare link as `https://github.com/@REPO@/compare/v0.1.5...@TAG@` rather than + # spelling the repository and tag out by hand. + # # The README and roadmap links are pinned to the tag: a relative link in a release body # resolves against the default branch, where the text it points at is free to move # after the release is cut. diff --git a/README.md b/README.md index 5f48057..6fdb1eb 100644 --- a/README.md +++ b/README.md @@ -1394,6 +1394,22 @@ all — the release is drafted, its assets are checked against the set the tag i supposed to carry, and only then is it published, so a half-finished upload leaves a draft rather than a release missing an architecture. +The body of a release is assembled from two halves. The half that changes is +`docs/release-notes/.md` — `docs/release-notes/0.1.6.md` for `v0.1.6`, keyed +off the same `CMakeLists.txt` version the tag is checked against — and it is written in +the pull request that bumps that version, so what a release says about itself is +reviewed alongside the change it describes. The half that does not is a short tail in +`.github/workflows/release.yml`: what `sendspin-cli --version` prints, and links to the +wiki, the README and the roadmap. Installing and verifying are not restated per release; +they live in the [wiki](https://github.com/Sendspin/sendspin-cpp-cli/wiki) and below. + +Write the notes file before pushing the tag. `preflight` refuses a tag whose file is +missing, empty, or leaves a ``` fence open, and refuses one whose sections start at `#` +or `##` — the release page supplies its own title, so those render as banner text +underneath it. Sections start at `###`; see `docs/release-notes/0.1.6.md` for the shape. `@VERSION@`, `@REPO@` and +`@TAG@` are substituted in both halves, so a compare link is written as +`https://github.com/@REPO@/compare/v0.1.5...@TAG@`. + The archives are the same staged payload described above, so they install the same way, and the `.pkg` wraps the macOS one. Verify whichever you took first: diff --git a/docs/release-notes/0.1.6.md b/docs/release-notes/0.1.6.md new file mode 100644 index 0000000..fd98ae9 --- /dev/null +++ b/docs/release-notes/0.1.6.md @@ -0,0 +1,22 @@ +### New features + +- **`linux-armv7` archive** — 32-bit builds for a Pi 2, Pi 3, Pi 4 or Pi Zero 2 running a 32-bit Raspberry Pi OS. (#47) +- **`linux-armv6` archive** — for a Pi Zero, Pi Zero W or original Pi. These are ARM1176 and hit an illegal instruction in the ARMv7 build. Built natively in an emulated Raspbian container, so it is also the only archive here that loads on Raspberry Pi OS **bookworm** as well as trixie. (#44, #48) +- **Automatic archive selection** — `scripts/get_started_linux.sh` reads the userland and downloads the right archive for the board it is on. It refuses rather than guesses on a pre-ARMv6 CPU or a userland it cannot identify. (#47, #48) + +### Fixed issues + +- **A replugged ALSA device now recovers mid-track.** (#45, #46) `AlsaAudioSink` was the only sink with real hardware behind it not wired into `SinkRecovery`. Any error outside `-EINTR`/`-EAGAIN`, `-EPIPE` and `-ESTRPIPE` — `-ENODEV` included — counted as a failed write, so unplugging a USB DAC left the handle open on hardware that was gone and repeated an unthrottled `ERROR` once per retry until you restarted the process. Device loss now closes the device and reopens at the last negotiated format, behind the delay and retry budget the other sinks already use. Nothing to configure: plug it back in and the stream resumes. +- **`SENDSPIN_SERVER_URL` no longer exports a stale dialled URL** to the stream hooks on an `-s` run. (#34, fixed in 0.1.5, tracked closed here.) + +### Which Raspberry Pi archive do I want? + +| Board | Archive | +| --- | --- | +| Pi 5, Pi 4, Pi 3, Pi Zero 2 on a 64-bit OS | `linux-arm64` | +| Pi 2, Pi 3, Pi 4, Pi Zero 2 on a 32-bit OS | `linux-armv7` | +| Pi Zero, Pi Zero W, original Pi | `linux-armv6` | + +Or run `scripts/get_started_linux.sh` and let it choose. + +**Full changelog:** https://github.com/@REPO@/compare/v0.1.5...@TAG@