Release from a tag, with ffmpeg in the box - #36
Conversation
revtex
commented
Aug 15, 2026
Added since the first pushffmpeg is bundledA 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 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. Only The licence obligation is met by attaching source, not offering itA 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
Verified
The costAbout 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 |
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>
b334016 to
6862c9eCompareUh oh!
There was an error while loading. Please reload this page.
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>inDirectory.Build.propshas 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 readsv1.2.3and passes-p:Version=1.2.3.VersionPrefixstays only to name unreleased builds, which say0.1.0-dev. A local build that claims to be0.1.0when 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:
VersionAssemblyVersionInformationalVersion0.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-containedwin-x64→ sign → package → GitHub release.v1.2.3-rc.1does not become what "latest release" resolves to.LICENSE,NOTICE,README.mdandCHANGELOG.mdalongside the executable, and a SHA-256 beside it..pdbnext to the executable, where it would otherwise be swept into the zip.[Unreleased].workflow_dispatchbuilds everything and publishes nothing, which is how to find out whether this works without spending a version number to do it.Signing is wired and inert, on purpose
There is no certificate (open question 3, deferred).
build/windows/sign.ps1says so and exits0; 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:
.pfxis written to a temp file becausesigntooltakes a path, and removed in afinallyso a mid-run failure leaves no private key on the runner.-Requireturns 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
dotnet build -p:Version=…and read out of the generatedAssemblyInfo.cs.sign.ps1with no certificate: prints the reason, lists what it left unsigned, exits 0.sign.ps1 -Requirewith no certificate: throws.Not verified locally: the actual signing path. This machine has no Windows SDK, so
signtoolis absent — the script reports exactly that and stops. It needs a certificate and a runner to prove, which is what theworkflow_dispatchtrigger is for once this is merged.🤖 Generated with Claude Code