Skip to content

Build the release body from a per-release notes file - #50

Merged
chrisuthe merged 1 commit into
mainfrom
chrisuthe/task/build-the-release-notes-from-a-per-release-notes
Aug 30, 2026
Merged

Build the release body from a per-release notes file#50
chrisuthe merged 1 commit into
mainfrom
chrisuthe/task/build-the-release-notes-from-a-per-release-notes

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

.github/workflows/release.yml published the same body for every tag: one quoted heredoc of Install, Gatekeeper and SHA256SUMS prose that said nothing about the release, under ## headings that render as banner text beneath the release page's own title. v0.1.6's notes had to be replaced by hand with gh release edit after publishing.

The body is now assembled from two halves.

The half that changes is docs/release-notes/<version>.md — keyed off the version preflight already resolves from CMakeLists.txt, written in the pull request that bumps it, so what a release says about itself is reviewed alongside the change it describes. As a side effect docs/release-notes/ becomes the changelog this repo has never had.

The half that does not is a short tail in the workflow: what sendspin-cli --version prints, the unsigned-macOS disclosure, and links to the wiki, the tag-pinned README and the roadmap. Install, "macOS is unsigned" and Checksums are gone as sections — 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.

How the notes reach the release job

The release job has no checkout on purpose, and none was added. preflight already checks out and already reads CMakeLists.txt, so it reads the notes file too and hands the content over as a job output beside version.

  • Output, not 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. A seventh file arriving there would have to be excepted from the assertion that makes the release atomic. The other alternative — a checkout in release — is one more action running under contents: write for a file another job already holds.
  • Fenced with a random delimiter (od -An -N16 -tx1 /dev/urandom), 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. openssl is not in every image; coreutils is.
  • Consumed through the step's environment, never interpolated into the script, so nothing in a notes file is parsed as a workflow expression or as shell.

What preflight refuses

All four fire in seconds, before the matrix burns three runners, and each names the file and ends in the fix:

refused why
file missing a release with no notes is the defect being fixed
no non-whitespace content same; [ -s ] would pass a lone newline
unclosed ``` fence the heading scan below it is fence-aware, so an unpaired fence would leave it skipping the rest of the file — a guard that quietly stops guarding. It also swallows the body into a code block on its own
# or ## outside a fence outranks the heading the release page already has

The heading scan is fence-aware so that a column-0 # inside a bash block — which this project's docs write, docs/wiki/Installation.md among them — is not refused as a heading it is not.

release then refuses to publish if the output did not reach it (a renamed step id, a typo in the output name), rather than shipping a body of tail alone and calling it success.

Two deliberate calls

1. The unsigned-macOS disclosure stayed, as two sentences in the tail. The brief said to drop the "macOS is unsigned" section, and the section is gone — the ## heading, the xattr walkthrough, the Privacy & Security override, the notarization account. What remains is the disclosure itself, pointing at the tag-pinned README anchor. docs/ROADMAP.md:1306 states as a shipped property of item 10: "The release notes say the same in short, which is where a reader deciding whether to download actually is." Dropping it would have falsified that claim in the same commit. This is a deviation from the acceptance criterion's list of what remains — flagging it rather than settling it silently. Easy to strike.

2. A missing notes file fails preflight, the brief's recommended option, rather than falling back to a tail-only body. A release with no notes is exactly the defect being fixed, and failing costs seconds.

Owed, deliberately not here

Every refusal fires after the tag is pushed, and recovery means deleting and re-pushing it. A step in ci.yml asserting that docs/release-notes/<CMakeLists VERSION>.md exists would move the failure into the bump PR where the file belongs and reduce preflight's guard to a backstop. Separate change — the brief scopes this one to the notes step.

Verification

Every case below executed the actual run: blocks extracted from the YAML, on ubuntu:24.04 in Docker — not a re-implementation.

case result
0.1.6 assembles the body below
file missing exit 1, error names the path
whitespace-only file exit 1
## Install heading exit 1, prints 5: ## Install
unclosed ``` fence exit 1
fenced bash with # 1. and ## 2. passes — not a false positive
backticks, $(id -u), $HOME, ${VAR}, !! reach the body verbatim
NOTES empty in release exit 1, refuses to publish

shellcheck scripts/*.sh and actionlint are clean. The download, completeness-assertion, checksum, draft-create and publish steps are byte-identical to origin/main, verified by diff rather than by inspection.

Dry run — the exact body v0.1.6 would publish

Assembled from docs/release-notes/0.1.6.md plus the tail, after the sed pass. Every heading is ###.

### 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/Sendspin/sendspin-cpp-cli/compare/v0.1.5...v0.1.6

### What this build is

sendspin-cli 0.1.6. 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/Sendspin/sendspin-cpp-cli/wiki)[Installation](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation),
or [Getting Started on a Raspberry Pi](https://github.com/Sendspin/sendspin-cpp-cli/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/Sendspin/sendspin-cpp-cli/blob/v0.1.6/README.md#macos-and-gatekeeper).

- [README](https://github.com/Sendspin/sendspin-cpp-cli/blob/v0.1.6/README.md) — build, install, run,
  [macOS, and Gatekeeper](https://github.com/Sendspin/sendspin-cpp-cli/blob/v0.1.6/README.md#macos-and-gatekeeper),
  and [the installer `.pkg`](https://github.com/Sendspin/sendspin-cpp-cli/blob/v0.1.6/README.md#the-macos-installer-pkg)
- [Roadmap](https://github.com/Sendspin/sendspin-cpp-cli/blob/v0.1.6/docs/ROADMAP.md) — what shipped and
  what is owed, item by item

Every tag published the same body: one quoted heredoc of Install, Gatekeeper
and SHA256SUMS prose that said nothing about the release, under `##` headings
that render as banner text beneath the release page's own title. v0.1.6's notes
had to be replaced by hand after publishing.

The body is now two halves. The half that changes is
`docs/release-notes/<version>.md`, keyed off the version preflight already
resolves from CMakeLists.txt and written in the pull request that bumps it, so
what a release says about itself is reviewed alongside the change it describes.
The half that does not is a short tail in the workflow: what
`sendspin-cli --version` prints, the unsigned-macOS disclosure, and links to the
wiki, the tag-pinned README and the roadmap.

Read in preflight, which is the job with a checkout -- the release job has none
on purpose, and adding one would run an action under `contents: write` for a
file another job already holds. Carried across as a job output beside `version`,
fenced with a random delimiter, and consumed through the step's environment
rather than interpolated into its script, so nothing written in a notes file is
parsed as a workflow expression or as shell. The single `sed` pass now covers
both halves, which is what lets a notes file write `@REPO@` and `@TAG@` into its
compare link.

Preflight refuses a tag whose notes file is missing, empty, leaves a ``` fence
open, or carries a `#` or `##` heading outside a fence -- seconds, before the
matrix burns three runners. The release job refuses to publish if the output did
not reach it, rather than shipping a body of tail alone and calling it success.

The download, completeness assertion, checksum, draft-create and publish steps
are untouched.
@chrisuthe
chrisuthe marked this pull request as ready for review August 30, 2026 23:55
@chrisuthe
chrisuthe merged commit 91ea477 into main Aug 30, 2026
16 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