Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,46 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# FIRST, BEFORE ANYTHING IS BUILT OR PUSHED. A release that has already
# uploaded assets and then discovers its notes are wrong is a release
# somebody has to go and delete.
#
# WHAT THIS CATCHES. [Unreleased] sits ABOVE the version sections, so under
# Keep a Changelog its contents are NEWER than the release below them.
# Every PR correctly appends there and nothing folds it forward, so the gap
# reopens after each merge -- twice on 2026-08-14 alone. The second time it
# stranded all seven security fixes from #366 and the VOD editor from #365:
# nine entries a tag would have omitted.
#
# That is not cosmetic. GHSA-7jqx points at these release notes, and the
# fix for its worst defect -- 0.7.0's seal-at-rest migration leaving
# plaintext keys in the WAL -- was in the section a tag would have skipped.
# An operator following the advisory to the notes would not have found it.
#
# WHAT IT DOES NOT CATCH, stated because the gap is real and a guard that
# is trusted for more than it does is worse than none. This fires when
# entries were written and not folded. It cannot fire when they were never
# written at all -- a merge that records nothing leaves [Unreleased] empty
# and passes here. #400 recorded nothing and would have sailed through.
# That second failure needs a different check, on the pull request rather
# than the tag.
- name: "[Unreleased] must be empty before a tag"
run: |
set -euo pipefail
body=$(awk '/^## \[Unreleased\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md \
| sed '/^[[:space:]]*$/d')
# "Nothing yet." is the deliberate empty state, not an entry.
real=$(printf '%s\n' "$body" | sed '/^Nothing yet\.$/d' | sed '/^[[:space:]]*$/d')
if [ -n "$real" ]; then
echo "::error::CHANGELOG.md still has entries under [Unreleased]. Fold them"
echo "::error::into the version being tagged before releasing -- they sit ABOVE"
echo "::error::the version sections, so a tag cut now omits every line below."
printf '%s\n' "$real" | head -40
exit 1
fi
echo "[Unreleased] is empty; nothing would be stranded by this tag."

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
Expand Down
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,75 @@ its first tagged release.
## [Unreleased]

### Added
- **polyemesis.com publishes the documentation.** The site went from 6 pages to
35: the 23 user-facing documents in `docs/` are rendered at `/docs/<slug>`
rather than linked to GitHub, four comparison pages sit under `/vs/`, and
`/free-restream-service` and `/how-to-multistream-from-obs` answer the two
queries with the most measured search demand this project can address.

The documents were invisible to search before this — 63,000 words reachable
only on github.com, so the authority accrued to Microsoft's domain and the
pages had no title, description or internal linking under our control.

Publishing is an **allowlist**, not a glob with exclusions. `docs/` also holds
`RESEARCH-COMPETITIVE.md` and `COPY-CONSTRAINTS.md`, and a glob would make the
next internal note public by default. A build check fails when a file in that
directory appears in neither list, so adding a document is a decision somebody
writes down.

- **`llms.txt`, `/.well-known/security.txt`, an apple-touch-icon, `HowTo`
structured data on the install steps, and `lastmod` in the sitemap.** The
`lastmod` dates come from `git log -1` per page rather than the clock: a
build-time stamp marks every page as modified on every deploy, which is a lie
a crawler discounts, costing the signal it was meant to give.

`security.txt` carries the mandatory RFC 9116 `Expires` field, and the build
now fails when it is past and warns 30 days out. An expired security contact
reads as an abandoned one to the person deciding between reporting privately
and going public.

### Fixed
- **Four of six pages were shipping with no cache revalidation.**
`web/public/_headers` scoped `no-cache` to `/` and `/*.html`, and its comment
claimed that covered the built pages. It did not: with
`build.format: "file"` and `trailingSlash: "never"` a page is BUILT as
`features.html` and SERVED as `/features`, and Cloudflare matches the request
path. The failure that block exists to prevent — "a deploy is invisible until
caches expire" — was live on `/features`, `/comparison`, `/docs` and
`/download`. A build check now derives the expectation from the built output.

- **114 Copy buttons did nothing.** The click handler shipped inside
`CodeBlock.astro`, which the rendered documentation pages never mount. Styled,
focusable, labelled and inert.

- **Eleven copy defects**, found by three independent reviewers with different
lenses. The ones that mattered were claims about other people's products:
a sentence whose "either" retroactively negated the clause before it, turning
a correct statement into the overclaim it was written to avoid; a wrong
restream.io plan limit that contradicted a sibling page; four capability cells
asserted "No" for competitors with nothing behind them; and a card presenting a
DESCRIPTION of a function in the quoted-source treatment that gives its
neighbours their authority.

Also removed: a per-destination CPU figure that appears nowhere but in prose,
and a claim that the second Twitch audio track works, which
`docs/AUDIO-ROUTING.md` marks EXPERIMENTAL because no broadcast has ever been
published through a key Enhanced Broadcasting minted.

- **An engine test asked for three contiguous UDP ports and checked one.**
`testenv.FreeUDPPort` probes a port, releases it, and returns the number, so a
three-port allocator built on it had two numbers nobody had checked and one
that was already nobody's. It failed CI three times in one day on three
different ranges, each time blaming the code under test for a port it never
held. `testenv.FreeUDPWindow` reserves the window and holds it.

### Changed
- **Install-wide state comes off the engine** (first of six changes for #387).
Tools, recording paths, the host sampler and the settings read no longer route
through whichever engine happens to be default. No behaviour change intended,
with one exception worth stating: `GET /system` now reads settings from the
store rather than the engine's snapshot, so it stops lagging a settings save.

- **An onboarding tour, offered once per install rather than once per browser.**
A new operator finishes the signup screen and lands on an empty dashboard, and
everything they need next is either in a terminal they have closed or in
Expand Down
Loading