Skip to content

Release from a tag, with ffmpeg in the box - #36

Merged
revtex merged 4 commits into
mainfrom
build/tag-driven-release
Aug 15, 2026
Merged

Release from a tag, with ffmpeg in the box#36
revtex merged 4 commits into
mainfrom
build/tag-driven-release

Conversation

@revtex

Copy link
Copy Markdown
Owner

There was no way to cut a release: no version property anywhere, no release workflow, and build/windows/ an empty placeholder. This is the first buildable piece of Phase 8, and the one everything else in the phase sits on.

The version lives in the tag

A <Version> in Directory.Build.props has to be bumped in lockstep with the tag that releases it — a rule that holds until the first time it does not, after which a build claims a number that was never released. So the tag is the only record: the workflow reads v1.2.3 and passes -p:Version=1.2.3.

VersionPrefix stays only to name unreleased builds, which say 0.1.0-dev. A local build that claims to be 0.1.0 when it is a week of work past it is what gets reported as a bug in a version that never existed.

Verified against the real build rather than assumed:

Built withVersionAssemblyVersionInformationalVersion
(nothing)0.1.0-dev0.1.0.00.1.0-dev+<sha>
-p:Version=1.2.3-rc.11.2.3-rc.11.2.3.01.2.3-rc.1+<sha>
-p:Version=1.2.31.2.31.2.3.01.2.3+<sha>

The pipeline

Pushing v1.2.3 → build → test → publish self-contained win-x64 → sign → package → GitHub release.

  • A malformed tag is rejected before anything is built. A tag stops being editable in any useful sense the moment someone fetches it, so the cheap place to be strict is up front.
  • A prerelease suffix marks the release as a prerelease, so v1.2.3-rc.1 does not become what "latest release" resolves to.
  • The zip carries LICENSE, NOTICE, README.md and CHANGELOG.md alongside the executable, and a SHA-256 beside it.
  • Symbols go to a workflow artefact, not into every user's download — a single-file publish drops the .pdb next to the executable, where it would otherwise be swept into the zip.
  • The changelog is the release notes, preferring the version's own section and falling back to [Unreleased].
  • workflow_dispatch builds everything and publishes nothing, which is how to find out whether this works without spending a version number to do it.
  • Tests run again at tag time. A tag can be pushed at any commit, including one that never saw CI. The encode-integration tests are excluded — they gate every PR already, and re-downloading a 30 MB toolchain to re-prove them buys nothing.

Signing is wired and inert, on purpose

There is no certificate (open question 3, deferred). build/windows/sign.ps1 says so and exits 0; the release still builds. Turning it on later is two repository secrets rather than a pipeline change, and the shape of the step gets reviewed now, while nothing depends on it.

Every artefact is unsigned today and SmartScreen will warn on first run. The release notes say that outright and give a SHA-256, which is the only integrity check available without a signature. The notes also say VB-CABLE is not included and where to get it.

Details worth knowing about the script:

  • Timestamping is on by default. Without it, every signature stops verifying the day the certificate expires — including on copies users installed years earlier.
  • Nothing prints the password, the certificate, or a command line containing either. The .pfx is written to a temp file because signtool takes a path, and removed in a finally so a mid-run failure leaves no private key on the runner.
  • -Require turns the skip into a failure, for a pipeline that is supposed to produce signed output.

Injection

The one caller-controlled input — the manual dispatch version — reaches the script through env: rather than being interpolated into its body. Every later use of that value is safe to interpolate because the regex that validates it admits nothing but digits, dots and hyphens.

Verified

  • Version matrix above, built for real with dotnet build -p:Version=… and read out of the generated AssemblyInfo.cs.
  • sign.ps1 with no certificate: prints the reason, lists what it left unsigned, exits 0.
  • sign.ps1 -Require with no certificate: throws.
  • Release workflow YAML parses; all 13 steps present.

Not verified locally: the actual signing path. This machine has no Windows SDK, so signtool is absent — the script reports exactly that and stops. It needs a certificate and a runner to prove, which is what the workflow_dispatch trigger is for once this is merged.

🤖 Generated with Claude Code

@revtexrevtex changed the title Release from a tag, and put the version in exactly one placeRelease from a tag, with ffmpeg in the boxAug 15, 2026
@revtex

Copy link
Copy Markdown
OwnerAuthor

Added since the first push

ffmpeg is bundled

A release that needs the user to install ffmpeg first does not record anything when it is opened. The plan settled on bundling an LGPL build in DR-0001; nothing had bundled one. Releases now stage ffmpeg.exe into the ffmpeg folder beside the executable — where FFmpegLocator already looked, so no code changed. A configured path or a copy on PATH still wins; bundling is a floor, not a preference.

Pinned by SHA-256, not fetched by name. A GitHub release asset can be replaced after the fact, and an encoder that changes between two builds of the same Offstream version turns a reproducible bug into an unreproducible one. A mismatch fails the release rather than quietly shipping something else.

No binaries in the repo. 108 MB of someone else's build does not belong in a git history. build/windows/ffmpeg.json records what to fetch; fetch-ffmpeg.ps1 fetches it, for the pipeline and for build.ps1 -Publish -BundleFfmpeg alike.

Only ffmpeg.exe.ffprobe is used by the integration tests and by nothing in the app — confirmed by grep, ProbePath has no runtime consumer — and shipping it would add another 108 MB.

The licence obligation is met by attaching source, not offering it

A written offer has to outlive whatever was going to host it. Each release attaches the FFmpeg source archive for the exact commit its binary was built from, so it does not have to. The bundle also carries ffmpeg's own LICENSE.txt and a generated SOURCE.txt naming that commit and the build scripts. The vendor binary is never re-signed or otherwise altered — sign.ps1 is pointed at Offstream.exe only.

NOTICE was rewritten around this. It had said "no GPL build is bundled" when nothing was bundled at all.

Verified

  • The pinned build is genuinely LGPL. Ran its -version: configured without--enable-gpl, with--enable-version3 — so LGPL-3.0-or-later, not 2.1, which is what NOTICE and ffmpeg.json now say. Carries libmp3lame, libopus and native flac/aac: every encoder Offstream uses.
  • fetch-ffmpeg.ps1 end to end — downloaded 146 MB, verified 928a2215…7a11, staged ffmpeg.exe, LICENSE.txt and SOURCE.txt. Re-ran: used the cache.
  • build.ps1 -Publish -BundleFfmpeg put ffmpeg.exe at publish/ffmpeg/ffmpeg.exe, exactly where FFmpegLocator looks, and it runs from there.
  • The source archive URL for commit 852b0552f0…36d8 resolves.

The cost

About 45 MB compressed on top of the app. Worth naming: the release zip lands near 115 MB. That is the price of a download that works when it is opened, and the alternative — a first run that fails on a missing tool — is worse.

Earlier review points, fixed in 4d7e548

  • The zip scattered loose files at its root; it now has one folder.
  • Release notes fell back to [Unreleased], which works exactly once and republishes the previous release's entries every time after. The fallback is gone: a tag build requires ## [1.2.3], checked before the build, with an error naming the two steps.
  • That check's first spelling used $array -notmatch, which returns the non-matching elements and is therefore true on any real changelog — it would have failed every tag, including correct ones.

revtexand others added 4 commits August 14, 2026 23:31
There was no way to cut a release: no version property anywhere, no
workflow, and `build/windows/` an empty placeholder.
The version now comes off the git tag and nowhere else. A `<Version>` in
Directory.Build.props would have to be bumped in lockstep with the tag that
released it, which is a rule that holds until the first time it does not —
and then a build claims a number that was never released. VersionPrefix stays
only to name unreleased builds, which say `-dev` so "which build is this?"
has an answer.
Pushing `v1.2.3` builds, tests, publishes self-contained win-x64, signs,
packages a zip with LICENSE/NOTICE/README/CHANGELOG and a SHA-256, and
creates the GitHub release with the changelog as its notes. A tag that is not
vMAJOR.MINOR.PATCH is rejected before anything is built; a prerelease suffix
marks the GitHub release as a prerelease. Symbols go to a workflow artefact
rather than into every user's download. The same workflow runs from the
Actions tab and publishes nothing, so the pipeline can be exercised without
spending a version number.
Signing is deliberately inert. There is no certificate (open question 3), so
sign.ps1 reports that and exits 0, and the release notes tell users SmartScreen
will warn rather than letting them discover it. Adding a certificate later is
two repository secrets. Timestamping defaults on: without it every signature
dies with the certificate, including on copies installed years earlier.
The one input a caller controls, the manual version, goes through the
environment rather than being interpolated into the script body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems in the pipeline as first written.
The zip scattered its contents. `Compress-Archive -Path staging/*` puts loose
files at the archive root, so unzipping into Downloads - which is what people
do - strews Offstream.exe, LICENSE, NOTICE, README and CHANGELOG through it.
Staging into a named folder gives the archive one root.
The release notes fell back to `## [Unreleased]` when the version had no
section of its own. That works exactly once. Every later release republishes
everything above it, including entries that shipped in the previous one, and
the fix would be amending a release people have already read. The fallback is
gone: a tag build now requires `## [1.2.3]`, checked before the build so a
missing section costs seconds rather than the whole pipeline, and the error
says which two steps cutting a release actually is.
The check's first spelling was inverted. `$array -notmatch $pattern` returns
every element that did not match, which is a non-empty and therefore true
array on any real changelog - it would have thrown on every tag, including a
correct one.
`dotnet test --no-build` also gets the same -p:Version as the build it is
consuming, so the two evaluate the same property set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A release that needs the user to install ffmpeg first is a release that does
not record anything when it is opened. The plan settled on bundling an LGPL
build in DR-0001; nothing had bundled one.
Releases now stage ffmpeg.exe into the `ffmpeg` folder beside the executable,
which is where FFmpegLocator already looked. A configured path or a copy on
PATH still wins - bundling is a floor, not a preference.
Pinned by SHA-256 rather than fetched by name. A release asset can be replaced
after the fact, and an encoder that changes between two builds of the same
Offstream version turns a reproducible bug into an unreproducible one. A
mismatch fails the release instead of quietly shipping something else.
The binaries are not committed. 108 MB of someone else's build does not belong
in a git history, so build/windows/ffmpeg.json records what to fetch and
fetch-ffmpeg.ps1 fetches it - for the pipeline and for
`build.ps1 -Publish -BundleFfmpeg` alike. Only ffmpeg.exe ships: ffprobe is
used by the integration tests and by nothing in the app, and would have added
another 108 MB to every download.
The LGPL obligation is met by attaching the source rather than offering it. A
written offer has to outlive whatever was going to host it; the source archive
for the exact commit is a release asset, so it does not have to. The bundle
carries ffmpeg's own licence text and a SOURCE.txt naming that commit, and the
vendor binary is never re-signed or otherwise altered on its way in.
Verified: the pinned build is configured without --enable-gpl and with
--enable-version3, so LGPL-3.0-or-later, and carries libmp3lame, libopus and
native flac and aac. fetch-ffmpeg.ps1 downloads, verifies and stages; a
`build.ps1 -Publish -BundleFfmpeg` puts ffmpeg.exe exactly where the locator
looks, and it runs from there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inno Setup, per-user, into %LOCALAPPDATA%\Programs\Offstream. Offstream needs
no elevation to run - routing, session mute and loopback capture were all
verified unelevated in Phase 0 - so the thing that installs it asks for none
either. That is the whole reason WiX/MSI lost: an elevation prompt is a
decision the user has to make about software they have not run yet, and
machine-wide deployment is not something this app is asked for.
Built from the same staged folder the portable zip is made from, so the two
downloads cannot hold different software under one version number. The .iss is
compiled on every pull request too, against the publish output CI already
produces and a stand-in for the 146 MB ffmpeg fetch: a script only compiled at
tag time is a script that breaks at tag time.
Details that are load-bearing rather than decorative:
- AppId is fixed. Regenerating it turns an upgrade into a second installation
and strands the previous version in Apps & features with no way to remove it.
- AppMutex is the app's own single-instance mutex, so a running copy is a
polite prompt rather than a failure halfway through replacing a locked file.
- MinVersion 10.0.22000 and x64compatible: Windows 11 only, win-x64 only, both
refused during setup rather than crashing on first run.
- Uninstalling asks whether to remove settings and logs, defaulting to no.
Deleting them silently loses a Last.fm key and a Spotify sign-in; keeping
them silently is wrong for someone who is done. Recordings are never in
scope - they live outside the install folder.
The installer is signed through the same inert sign.ps1 as the executable. It
is the file SmartScreen challenges first, so leaving that to the pipeline to
remember was not good enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@revtex
revtexforce-pushed the build/tag-driven-release branch from b334016 to 6862c9eCompareAugust 15, 2026 03:31
@revtex
revtex merged commit 90360c5 into mainAug 15, 2026
6 checks passed
@revtex
revtex deleted the build/tag-driven-release branch August 15, 2026 03:35
Sign up for freeto 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

@revtex