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
18 changes: 15 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ name: Release
# repository secrets; without them the build stops at its login rather than
# publishing to one registry and announcing two.
#
# The Windows executables in the archives are signed with the certificate in the
# `SIGNTOOL_PFX_BASE64` (the PFX, in base64) and `SIGNTOOL_PFX_PASSWORD` repository
# secrets. Without them `standalone.yml` stops before it builds an archive, and the
# image's tags and the release page, which wait for it, are never written; the
# image's own build still runs. The certificate is self-signed, so Windows still
# warns: §6 of docs/plans/standalone-download-plan.md says what it gives and what
# not.
#
# The tag is the source of truth for the version. `Cargo.toml` is not read
# here — a tag that disagrees with it would produce an image labelled one way
# and reporting itself another, so the two are checked against each other
Expand Down Expand Up @@ -116,14 +124,18 @@ jobs:
run: |
echo "image=${REGISTRY}/${REPOSITORY,,}" >> "$GITHUB_OUTPUT"

# After `verify`, like the image's build, and beside it. Unsigned for now:
# signing is #401, and the README in each archive says what to click past.
# After `verify`, like the image's build, and beside it. Named one by one rather
# than `secrets: inherit`, so that file holds these two and not the registry
# tokens. The README in each archive says what to click past.
standalone:
name: Standalone
needs: verify
uses: ./.github/workflows/standalone.yml
with:
tag: ${{ inputs.tag || github.ref_name }}
secrets:
SIGNTOOL_PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }}
SIGNTOOL_PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }}

# Built on native runners rather than under QEMU. A Rust release build
# emulated for arm64 takes hours; GitHub's arm64 runners are free for public
Expand Down Expand Up @@ -357,7 +369,7 @@ jobs:
Linux (x86_64)|linux-x86_64.tar.gz
Linux (arm64)|linux-arm64.tar.gz
ARCHIVES
printf '\nThey are not signed yet, so Windows and macOS warn on the first launch: `README.txt` says what to click past. `SHA256SUMS` holds the checksums of them all, and `%s` the source of the FFmpeg they carry.\n\n' "$sources"
printf '\nThe Windows executables carry a self-signed certificate and the macOS ones an ad-hoc signature, which neither system trusts, so both warn on the first launch: `README.txt` says what to click past. `SHA256SUMS` holds the checksums of them all, and `%s` the source of the FFmpeg they carry.\n\n' "$sources"
} >> notes.md
printf '## Container image\n\n```bash\ndocker pull %s:%s\n```\n\nThe same image, digest for digest, is on Docker Hub:\n\n```bash\ndocker pull %s:%s\n```\n\nAlso published as `%s` in both, for `linux/amd64` and `linux/arm64` (a Raspberry Pi 4 or 5 on a 64-bit system).\n' \
"$IMAGE" "$VERSION" "${HUB_IMAGE#docker.io/}" "$VERSION" "$channel" >> notes.md
Expand Down
213 changes: 207 additions & 6 deletions .github/workflows/standalone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ name: Standalone
# - The Windows server links the C runtime statically: a laptop that has never
# installed the Visual C++ redistributable has no VCRUNTIME140.dll to load.
# - macOS deploys to the same oldest versions as its FFmpeg.
# - The executables are signed before they are packed (§6 of the plan), so the
# archive and `SHA256SUMS` hold the signed files and the smoke test runs them:
# on Windows with the certificate in the secrets below, on a tag only, and on
# macOS ad hoc, on every run. Neither system trusts these signatures, so both
# still warn on the first launch. Linux has nothing to sign.
# - The Windows server is built in a job of its own and signed in another, so the
# certificate never reaches a runner where a dependency's build script has run.

on:
workflow_call:
Expand All @@ -26,6 +33,16 @@ on:
description: "The tag being released"
required: true
type: string
# Not `required`, which holds a caller to naming a secret and says nothing of
# one that is empty. `signing-secrets` checks the values, and names what is
# missing.
secrets:
SIGNTOOL_PFX_BASE64:
description: "The Authenticode certificate, a PFX in base64"
required: false
SIGNTOOL_PFX_PASSWORD:
description: "The password of that PFX"
required: false
pull_request:
paths:
- "packaging/standalone/**"
Expand All @@ -41,8 +58,32 @@ concurrency:
cancel-in-progress: ${{ !inputs.tag }}

jobs:
# First, so the archives of a tag without its certificate stop before they cost
# a build minute, and no release can ship unsigned by accident. A run without a
# tag signs nothing on Windows, so it has nothing to check.
signing-secrets:
name: Signing secrets
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Refuse a tag without its certificate
if: inputs.tag
env:
PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }}
PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }}
run: |
if [ -z "$PFX_BASE64" ]; then
echo "::error::SIGNTOOL_PFX_BASE64 secret is required to publish a release"
exit 1
fi
if [ -z "$PFX_PASSWORD" ]; then
echo "::error::SIGNTOOL_PFX_PASSWORD secret is required alongside SIGNTOOL_PFX_BASE64"
exit 1
fi

ffmpeg:
name: FFmpeg
needs: signing-secrets
uses: ./.github/workflows/ffmpeg.yml
with:
tag: ${{ inputs.tag }}
Expand All @@ -52,6 +93,7 @@ jobs:
# catches.
webapp:
name: Web client
needs: signing-secrets
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
Expand All @@ -78,9 +120,99 @@ jobs:
retention-days: 7
if-no-files-found: error

windows-server:
name: Server (windows-x86_64)
needs: webapp
runs-on: windows-2025
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || '' }}
persist-credentials: false

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: standalone-webapp
path: webapp/dist

- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable
with:
toolchain: "1.98"

- name: Build
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo build --locked --release

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: standalone-windows-server
path: target/release/waveflow-server.exe
retention-days: 7
if-no-files-found: error

# Holds the certificate, so it checks out nothing and runs nothing it was
# handed. In the job that builds, the secrets would be out of reach of the steps
# before this one only on paper: `cargo build` runs the build scripts and the
# procedural macros of every dependency, and a process one of them leaves behind
# outlives its step, there to read the PFX, or the password on signtool's command
# line. On a tag only, too: a branch of this repository can read the secrets, and
# the certificate is there to sign what is released, not what a branch built.
windows-sign:
name: Sign (windows-x86_64)
needs: [ffmpeg, windows-server]
runs-on: windows-2025
timeout-minutes: 15
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: standalone-windows-server
path: target/release

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ffmpeg-${{ needs.ffmpeg.outputs.version }}-windows-x86_64
path: ffmpeg-out

# The PFX lives in the runner's temporary folder, which nothing uploads, for
# as long as signtool runs.
- name: Sign
if: inputs.tag
shell: pwsh
env:
PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }}
PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }}
run: |
Write-Host "::add-mask::$env:PFX_PASSWORD"
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
Sort-Object -Property @{ Expression = { try { [Version]$_.Directory.Parent.Name } catch { [Version]"0.0.0.0" } } } -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in the Windows Kits" }
$pfx = Join-Path $env:RUNNER_TEMP "signing.pfx"
try {
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64))
& $signtool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfx /p $env:PFX_PASSWORD `
target\release\waveflow-server.exe ffmpeg-out\ffmpeg.exe ffmpeg-out\ffprobe.exe
if ($LASTEXITCODE -ne 0) { throw "signtool exited with $LASTEXITCODE" }
} finally {
Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue
}

# Two paths, so the artifact keeps them, and `archive` finds both where the
# other targets have theirs.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: standalone-windows-executables
path: |
target/release/waveflow-server.exe
ffmpeg-out/
retention-days: 7
if-no-files-found: error

archive:
name: Archive (${{ matrix.target }})
needs: [ffmpeg, webapp]
needs: [ffmpeg, webapp, windows-sign]
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
strategy:
Expand All @@ -106,15 +238,23 @@ jobs:
persist-credentials: false

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: runner.os != 'Windows'
with:
name: standalone-webapp
path: webapp/dist

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: runner.os != 'Windows'
with:
name: ffmpeg-${{ needs.ffmpeg.outputs.version }}-${{ matrix.target }}
path: ffmpeg-out

# Built and signed already, by the two jobs above.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: runner.os == 'Windows'
with:
name: standalone-windows-executables

# The toolchain is the image's, and its version the Dockerfile's. The three
# targets that exercise FFmpeg ran in `ffmpeg.yml` against a glibc build;
# these are what says the server works on musl too. An artifact keeps no
Expand All @@ -135,7 +275,7 @@ jobs:
'

- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable
if: runner.os != 'Linux'
if: runner.os == 'macOS'
with:
toolchain: "1.98"

Expand All @@ -145,11 +285,19 @@ jobs:
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos-deployment-target }}
run: cargo build --locked --release

- name: Build (Windows)
if: runner.os == 'Windows'
# Needs no secret, so every run does it and a pull request proves it. `-` is
# ad hoc; a Developer ID would be this identity, with its certificate
# imported first and the notarization of §6 after the archive. The linker
# signs an arm64 binary on its own, under a generated identifier, and an
# x86_64 one not at all: `--force` and `--identifier` make the two alike.
- name: Sign (macOS)
if: runner.os == 'macOS'
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo build --locked --release
SIGNING_IDENTITY: "-"
run: |
codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server target/release/waveflow-server
codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server.ffmpeg ffmpeg-out/ffmpeg
codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server.ffprobe ffmpeg-out/ffprobe

# The tag when there is one. Otherwise the commit, which for a pull request
# is its own head rather than the merge commit GitHub tests, which exists
Expand All @@ -174,6 +322,59 @@ jobs:
shell: bash
run: sh packaging/standalone/smoke.sh dist/waveflow-server-*

# Read back from the archive, which is what ships. A self-signed certificate
# answers `UnknownError`, its root being untrusted, but so does any failure
# PowerShell has no name for, so accepting that status would accept those too.
# Trust is taken out of the question instead: the one signer all three share
# goes into this runner's trusted roots, for this job's short life, and each
# file must then answer `Valid`. The thumbprint is public, and goes in the log
# for anyone comparing a download against it.
- name: Check the signatures (Windows)
if: runner.os == 'Windows' && inputs.tag
shell: pwsh
run: |
$extracted = Join-Path $env:RUNNER_TEMP "signatures"
Expand-Archive -Path dist\waveflow-server-*.zip -DestinationPath $extracted
$files = foreach ($tool in "waveflow-server.exe", "ffmpeg.exe", "ffprobe.exe") {
Get-ChildItem -Path $extracted -Recurse -Filter $tool
}
$signers = @($files | ForEach-Object { (Get-AuthenticodeSignature -LiteralPath $_.FullName).SignerCertificate })
if ($signers.Count -ne 3) { throw "only $($signers.Count) of the three executables have a signer" }
$thumbprints = @($signers.Thumbprint | Select-Object -Unique)
if ($thumbprints.Count -ne 1) { throw "the executables have different signers: $thumbprints" }
Write-Host "signed by $($signers[0].Subject), thumbprint $thumbprints"
$roots = [Security.Cryptography.X509Certificates.X509Store]::new("Root", "LocalMachine")
$roots.Open("ReadWrite")
$roots.Add($signers[0])
$roots.Close()
foreach ($file in $files) {
$signature = Get-AuthenticodeSignature -LiteralPath $file.FullName
Write-Host "$($file.Name): $($signature.Status)"
if ($signature.Status.ToString() -ne "Valid") { throw "$($file.Name): $($signature.StatusMessage)" }
if (-not $signature.TimeStamperCertificate) { throw "$($file.Name) has no timestamp" }
}

# `--verify` alone would pass on the linker's signature of an arm64 binary,
# had the signing above done nothing. The identifier is what only it wrote.
- name: Check the signatures (macOS)
if: runner.os == 'macOS'
run: |
extracted="$RUNNER_TEMP/signatures"
unzip -q dist/waveflow-server-*.zip -d "$extracted"
for tool in waveflow-server ffmpeg ffprobe; do
case "$tool" in
waveflow-server) wanted=app.waveflow.server ;;
*) wanted="app.waveflow.server.$tool" ;;
esac
file=$(echo "$extracted"/*/"$tool")
codesign --verify --strict --verbose=2 "$file"
identifier=$(codesign --display --verbose=2 "$file" 2>&1 | sed -n 's/^Identifier=//p')
if [ "$identifier" != "$wanted" ]; then
echo "::error::$tool is signed as '$identifier', not $wanted"
exit 1
fi
done

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: standalone-archive-${{ matrix.target }}
Expand Down
21 changes: 14 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,20 @@ out first, whether or not it breaks a compile.
`-macos-arm64.zip`, `-macos-x86_64.zip`, `-linux-x86_64.tar.gz` and
`-linux-arm64.tar.gz`, each one folder holding the server, its own FFmpeg,
`laptop.env`, the licences and a `README.txt`, with
`ffmpeg-<version>-sources.tar.gz` and a `SHA256SUMS` over all of them. They are
not signed yet, so Windows and macOS warn on the first launch, and the
`README.txt` says what to click past. The Linux server is static against musl
and runs on any distribution; the Windows one needs no Visual C++
redistributable. Each archive is started and made to play a file on its own
platform before the release attaches it, and the image's tags wait for them
too, so a tag whose archives fail publishes neither.
`ffmpeg-<version>-sources.tar.gz` and a `SHA256SUMS` over all of them. Windows
and macOS warn on the first launch, and the `README.txt` says what to click
past. The Linux server is static against musl and runs on any distribution;
the Windows one needs no Visual C++ redistributable. Each archive is started
and made to play a file on its own platform before the release attaches it,
and the image's tags wait for them too, so a tag whose archives fail publishes
neither.
- **The standalone executables are signed** (#401), as the desktop app's builds
are: with a self-signed Authenticode certificate on Windows, ad hoc on macOS.
A file changed after signing no longer matches its signature, but neither
system trusts these signatures, so the warnings of the first launch stay. A
release needs the `SIGNTOOL_PFX_BASE64` and `SIGNTOOL_PFX_PASSWORD` secrets:
without them the archives stop before they build and nothing is published,
though the image's build still runs.
- **The transcoding state in the web client's administration**: whether the
server can transcode (without it, listeners get the original files only),
the transcodes running against the global limit, the per-account limit and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ From 2.1.0, each [release](https://github.com/InstaZDLL/waveflow-server/releases
| Linux (x86_64) | `waveflow-server-<version>-linux-x86_64.tar.gz` |
| Linux (arm64) | `waveflow-server-<version>-linux-arm64.tar.gz` |

Each holds one folder: the server, its own FFmpeg, a `laptop.env` of settings, the licences, and a `README.txt` that walks through the first start. The archives are not signed yet, so Windows and macOS warn on the first launch; the `README.txt` says what to click past. `SHA256SUMS`, on the same release, holds their checksums, and `ffmpeg-<version>-sources.tar.gz` the source of the FFmpeg they carry. The Linux archives are static and run on any distribution.
Each holds one folder: the server, its own FFmpeg, a `laptop.env` of settings, the licences, and a `README.txt` that walks through the first start. The Windows executables carry a self-signed certificate and the macOS ones an ad-hoc signature, which neither system trusts, so both warn on the first launch; the `README.txt` says what to click past. `SHA256SUMS`, on the same release, holds their checksums, and `ffmpeg-<version>-sources.tar.gz` the source of the FFmpeg they carry. The Linux archives are static and run on any distribution.

The rest of this section is for a server that stays on: the data lives in a folder of your choosing, and the commands assume a terminal.

Expand Down
Loading
Loading