# Builds the installers for a version tag (`v1.0.0`) and publishes them as a # GitHub release: a per-user NSIS installer for Windows, a .deb, an .rpm and # an AppImage for Linux, a universal disk image for macOS, with SHA-256 # checksums and the notes from docs/releases/.md. Nothing is built # unless the tag matches Cargo.toml and the tests pass. # # It can also be run by hand from `main` for an existing tag (Actions › # Release › Run workflow), with the workflow definition of `main` and the # sources of the tag: that is how a package format added after a release # gets attached to it. `linux_only` skips the Windows and macOS jobs so the # signed builds already published are left untouched; files that are rebuilt # replace their namesake on the release. name: Release on: push: tags: ["v*"] workflow_dispatch: inputs: tag: description: "Existing tag to (re)build, e.g. v1.0.0" required: true type: string linux_only: description: "Only rebuild and upload the Linux packages" required: false type: boolean default: false permissions: contents: read env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} # The packaging tools, pinned: packaging/installer.nsi is a copy of # cargo-packager 0.11.8's template and is refreshed when this moves. CARGO_PACKAGER_VERSION: "0.11.8" CARGO_GENERATE_RPM_VERSION: "0.21.1" # minisign, in Rust: signs the packages the app updates itself from. RSIGN_VERSION: "0.6.7" # One run per tag at a time: a tag push and a manual rebuild of the same tag # would otherwise replace each other's files and checksums on the release. concurrency: group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false jobs: version: name: Tag matches Cargo.toml runs-on: ubuntu-latest timeout-minutes: 5 outputs: sha: ${{ steps.tag.outputs.sha }} steps: # The input must name a tag, not a branch that happens to be called # v; every job then checks out the commit it points to now, # so a tag moved during the run cannot mix two sources. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: refs/tags/${{ env.RELEASE_TAG }} persist-credentials: false - id: tag run: | sha=$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}") test "$sha" = "$(git rev-parse HEAD)" echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "$RELEASE_TAG is $sha" - run: | version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) if [ "v$version" != "$RELEASE_TAG" ]; then echo "::error::tag $RELEASE_TAG does not match version $version" exit 1 fi test -f "docs/releases/$RELEASE_TAG.md" || { echo "::error::docs/releases/$RELEASE_TAG.md is missing"; exit 1; } windows: name: Windows installer needs: version if: ${{ !inputs.linux_only }} runs-on: windows-latest timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.version.outputs.sha }} persist-credentials: false - run: rustup show active-toolchain - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - run: cargo test --locked # pwsh, the Windows runners' shell: environment variables are $env:. - run: cargo install cargo-packager --version "$env:CARGO_PACKAGER_VERSION" --locked # Everything is compiled before the certificate enters the runner, so # no build script of any dependency runs while the key is importable; # cargo-packager has no build hook (Cargo.toml) and packages this. - run: cargo build --release --locked # Authenticode, with WaveFlow's certificate (the same two secrets as # the WaveFlow and Resona repositories). It is self-signed: files are # signed (tampering shows) but Windows does not trust the publisher, # so SmartScreen still warns. A certificate from a CA in Microsoft's # Trusted Root Program in the same two secrets is all it takes to # change that. cargo-packager signs the executable, the uninstaller # and the installer with signtool, by the thumbprint of a certificate # in the store. - name: Import the signing certificate shell: pwsh env: PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} run: | if (-not $env:PFX_BASE64 -or -not $env:PFX_PASSWORD) { Write-Error "SIGNTOOL_PFX_BASE64 and SIGNTOOL_PFX_PASSWORD are required to publish a release" exit 1 } Write-Host "::add-mask::$env:PFX_PASSWORD" $pfx = Join-Path $env:RUNNER_TEMP "signing.pfx" try { [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64)) $password = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $password } finally { Remove-Item $pfx -ErrorAction SilentlyContinue } $thumbprint = $cert.Thumbprint # Recorded before any other check, so the cleanup step removes the # certificate from the store whichever way this step fails. "SIGNING_THUMBPRINT=$thumbprint" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 if (-not $cert.HasPrivateKey) { Write-Error "the PFX has no private key"; exit 1 } Write-Host "Signing with $($cert.Subject) ($thumbprint), valid until $($cert.NotAfter)" # The thumbprint is public; the private key stays in the store. @" [package.metadata.packager.windows] digest-algorithm = "SHA256" certificate-thumbprint = "$thumbprint" timestamp-url = "http://timestamp.digicert.com" tsp = true "@ | Add-Content Cargo.toml - run: cargo packager --release --formats nsis - name: Check the signatures shell: pwsh run: | $files = @(Get-ChildItem target/packager/*.exe) + @(Get-Item target/release/waveflow-quickplay.exe) foreach ($file in $files) { $sig = Get-AuthenticodeSignature $file.FullName # A self-signed certificate reads "UnknownError" (untrusted root) # or "NotTrusted"; any other status, HashMismatch first, means # the signature does not cover the file as it is. if ($sig.Status.ToString() -notin @('Valid', 'UnknownError', 'NotTrusted')) { Write-Error "$($file.Name) has a bad signature ($($sig.Status)): $($sig.StatusMessage)" exit 1 } # UnknownError covers any chain failure, so it passes only for the # one this certificate has: a chain of itself alone whose only # fault is an untrusted root. The chain is read, not the message, # which is in the runner's language. if ($sig.Status.ToString() -eq 'UnknownError') { $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck $null = $chain.Build($sig.SignerCertificate) $faults = @($chain.ChainStatus | ForEach-Object { $_.Status.ToString() }) if ($chain.ChainElements.Count -ne 1 -or $faults.Count -ne 1 -or $faults[0] -ne 'UntrustedRoot') { Write-Error "$($file.Name): signature chain fails for another reason ($($faults -join ', ')): $($sig.StatusMessage)" exit 1 } } if ($sig.SignerCertificate.Thumbprint -ne $env:SIGNING_THUMBPRINT) { Write-Error "$($file.Name) is not signed with the release certificate ($($sig.Status))" exit 1 } if (-not $sig.TimeStamperCertificate) { Write-Error "$($file.Name) has no timestamp" exit 1 } Write-Host "$($file.Name): signed by $($sig.SignerCertificate.Subject), $($sig.Status)" } - name: Remove the signing certificate from the runner if: always() shell: pwsh run: | if ($env:SIGNING_THUMBPRINT) { Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $env:SIGNING_THUMBPRINT } | Remove-Item Write-Host "certificate removed from the store" } - name: Checksums shell: pwsh run: | $lines = Get-ChildItem target/packager/*.exe | ForEach-Object { "{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name } # LF line ends, as sha256sum writes them: Set-Content writes CRLF, # and `sha256sum -c` on Linux or macOS then looks for "….exe\r". $sums = Join-Path $PWD target/packager/SHA256SUMS-windows.txt [IO.File]::WriteAllText($sums, (($lines -join "`n") + "`n")) Get-Content $sums - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: windows path: | target/packager/*.exe target/packager/SHA256SUMS-windows.txt if-no-files-found: error linux: name: Linux packages needs: version runs-on: ubuntu-22.04 timeout-minutes: 90 env: # linuxdeploy and appimagetool are AppImages themselves; no FUSE on runners. APPIMAGE_EXTRACT_AND_RUN: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.version.outputs.sha }} persist-credentials: false # The packaging definitions (desktop entries, rpm.toml) come from the # workflow's own ref: on a tag push that is the tag itself, on a # manual run it is the branch chosen, so a package format added after # a release can be built for it. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} path: workflow-ref sparse-checkout: packaging persist-credentials: false - run: cp -r workflow-ref/packaging/. packaging/ - run: | sudo apt-get update sudo apt-get install -y --no-install-recommends cmake libasound2-dev libxkbcommon-dev libwayland-dev libfuse2 file rpm - run: rustup show active-toolchain - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - run: cargo test --locked - run: cargo install cargo-packager --version "$CARGO_PACKAGER_VERSION" --locked - run: cargo install cargo-generate-rpm --version "$CARGO_GENERATE_RPM_VERSION" --locked # The .deb depends on the glibc the binary was linked against: the # newest GLIBC_ symbol version it references. - name: glibc floor run: | cargo build --release --locked glibc=$(LC_ALL=C objdump -T target/release/waveflow-quickplay | grep -o 'GLIBC_[0-9.]*' | cut -d_ -f2 | sort -uV | tail -1) echo "needs glibc $glibc" sed -i "s/^depends = \[/depends = [\"libc6 (>= $glibc)\", /" Cargo.toml grep -m1 '^depends' Cargo.toml - run: cargo packager --release --formats deb,appimage # The .rpm comes from cargo-generate-rpm (cargo-packager has no rpm # format), from the release binary built above; its contents # and dependencies are the [package.metadata.generate-rpm] section # kept in packaging/rpm.toml, appended to Cargo.toml here. - name: RPM run: | cat packaging/rpm.toml >> Cargo.toml # What the binary links, with its symbol versions, the way rpm's # ELF scan would declare it (automatic detection is off, see # packaging/rpm.toml). LC_ALL=C objdump -p target/release/waveflow-quickplay | awk ' $1 == "NEEDED" { print "\"" $2 "()(64bit)\" = \"*\"" } /required from/ { lib = $NF; sub(/:$/, "", lib) } lib && NF == 4 && $4 ~ /_/ { print "\"" lib "(" $4 ")(64bit)\" = \"*\"" } ' | sort -u >> Cargo.toml cargo generate-rpm --payload-compress zstd rpm=$(ls target/generate-rpm/*.rpm) mv "$rpm" target/packager/ rpm=target/packager/$(basename "$rpm") rpm -qpi "$rpm" echo "--- files"; rpm -qpl "$rpm" echo "--- requires"; rpm -qpR "$rpm" # The package must carry the binary and the desktop entry, and # nothing it does not declare. rpm -qpl "$rpm" | grep -qx /usr/bin/waveflow-quickplay rpm -qpl "$rpm" | grep -qx /usr/share/applications/app.waveflow.quickplay.desktop rpm -qpR "$rpm" | grep -q 'libasound.so.2' rpm -qpR "$rpm" | grep -q 'libc.so.6(GLIBC_' - name: Checksums run: | cd target/packager sha256sum *.deb *.rpm *.AppImage > SHA256SUMS-linux.txt cat SHA256SUMS-linux.txt - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linux path: | target/packager/*.deb target/packager/*.rpm target/packager/*.AppImage target/packager/SHA256SUMS-linux.txt if-no-files-found: error macos: name: macOS disk image needs: version if: ${{ !inputs.linux_only }} runs-on: macos-latest timeout-minutes: 90 env: # The oldest macOS the binary and the bundle's Info.plist accept. MACOSX_DEPLOYMENT_TARGET: "11.0" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.version.outputs.sha }} persist-credentials: false # The bundle definition comes from the workflow's own ref, as the # Linux packaging does, so a manual run can attach it to an older tag. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} path: workflow-ref sparse-checkout: | packaging assets persist-credentials: false - run: | cp -r workflow-ref/packaging/. packaging/ cp workflow-ref/assets/icon.icns assets/icon.icns - run: rustup show active-toolchain - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - run: cargo test --locked # One binary for Apple silicon and Intel Macs. - name: Universal binary run: | cargo build --release --locked --target aarch64-apple-darwin cargo build --release --locked --target x86_64-apple-darwin mkdir -p target/universal lipo -create -output target/universal/waveflow-quickplay \ target/aarch64-apple-darwin/release/waveflow-quickplay \ target/x86_64-apple-darwin/release/waveflow-quickplay lipo -info target/universal/waveflow-quickplay # Signed ad hoc, as WaveFlow Desktop's macOS build is: sealed, with no # Apple Developer ID behind it and an identity that changes with every # build, so Gatekeeper blocks the first launch of each downloaded # version until the user allows it (see packaging/macos/bundle.sh). - name: App bundle and disk image run: | version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) mkdir -p target/packager bash packaging/macos/bundle.sh target/universal/waveflow-quickplay "$version" target/packager app="target/packager/WaveFlow QuickPlay.app" codesign --display --verbose=2 "$app" 2>&1 | grep -q 'Signature=adhoc' lipo -info "$app/Contents/MacOS/waveflow-quickplay" | grep -q 'x86_64 arm64' test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "$version" - name: Checksums run: | cd target/packager shasum -a 256 *.dmg > SHA256SUMS-macos.txt cat SHA256SUMS-macos.txt - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: macos path: | target/packager/*.dmg target/packager/SHA256SUMS-macos.txt if-no-files-found: error publish: name: GitHub release needs: [version, windows, linux, macos] # Runs when every job that ran succeeded; `windows` and `macos` may # have been skipped by `linux_only`. if: ${{ always() && needs.linux.result == 'success' && (needs.windows.result == 'success' || needs.windows.result == 'skipped') && (needs.macos.result == 'success' || needs.macos.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.version.outputs.sha }} persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: dist merge-multiple: true - run: ls -la dist # The tag must still point where the packages were built from, or the # release would pair them with another commit's tag. - name: Tag unchanged env: GH_TOKEN: ${{ github.token }} BUILT_SHA: ${{ needs.version.outputs.sha }} run: | now=$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$RELEASE_TAG" --jq .sha) if [ "$now" != "$BUILT_SHA" ]; then echo "::error::$RELEASE_TAG moved from $BUILT_SHA to $now during the run" exit 1 fi # The update manifest QuickPlay reads at launch (src/update.rs): the # packages it can install by itself, each signed with the release key # (minisign) under a trusted comment naming the version and the file, # so that the unsigned manifest cannot pass one off as another # version. A Linux-only rebuild keeps the Windows and macOS entries # of the manifest already on the release. The signing tool is built # in a step of its own, so no build script sees the key. - run: cargo install rsign2 --version "$RSIGN_VERSION" --locked - name: Update manifest env: GH_TOKEN: ${{ github.token }} UPDATE_KEY: ${{ secrets.QUICKPLAY_UPDATE_KEY }} LINUX_ONLY: ${{ inputs.linux_only }} run: | if [ -z "$UPDATE_KEY" ]; then echo "::error::QUICKPLAY_UPDATE_KEY is required to publish a release" exit 1 fi key="$RUNNER_TEMP/update.key" trap 'rm -f "$key"' EXIT (umask 077 && printf '%s\n' "$UPDATE_KEY" > "$key") version=${RELEASE_TAG#v} base="https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" manifest='{"platforms":{}}' # Only a release that does not exist yet, or has no manifest, # starts from an empty one; any other failure stops here rather # than publish a manifest without Windows and macOS. if [ "$LINUX_ONLY" = "true" ]; then if assets=$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2> "$RUNNER_TEMP/view.err"); then if grep -qx latest.json <<< "$assets"; then gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" -p latest.json -O "$RUNNER_TEMP/previous.json" manifest=$(jq -e 'select(.platforms | type == "object")' "$RUNNER_TEMP/previous.json") # The entries kept are signed for the release's version; # another one, or none, would leave them unusable. previous=$(jq -r '.version // empty' <<< "$manifest") if [ "$previous" != "$version" ]; then echo "::error::latest.json on $RELEASE_TAG is for ${previous:-no version}, not $version" exit 1 fi fi elif ! grep -qi "release not found" "$RUNNER_TEMP/view.err"; then cat "$RUNNER_TEMP/view.err" exit 1 fi fi # sign : exactly one file must match. sign() { set -- "$1" $2 if [ $# -ne 2 ] || [ ! -f "$2" ]; then echo "::error::expected one package for $1" exit 1 fi name=$(basename "$2") rsign sign -W -s "$key" -t "waveflow-quickplay $version $name" -x "$RUNNER_TEMP/$name.minisig" "$2" < /dev/null manifest=$(jq --arg p "$1" --arg url "$base/$name" --rawfile sig "$RUNNER_TEMP/$name.minisig" \ '.platforms[$p] = {url: $url, signature: $sig}' <<< "$manifest") } sign linux-x86_64-appimage 'dist/*.AppImage' if [ "$LINUX_ONLY" != "true" ]; then sign windows-x86_64 'dist/*.exe' sign darwin-universal 'dist/*.dmg' fi jq --arg v "$version" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '.version = $v | .pub_date = $date' <<< "$manifest" > dist/latest.json cat dist/latest.json # On an existing release the files replace their namesakes, the # others are kept, and the body is rewritten from the notes at the tag. - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: ${{ env.RELEASE_TAG }} name: WaveFlow QuickPlay ${{ env.RELEASE_TAG }} body_path: docs/releases/${{ env.RELEASE_TAG }}.md files: dist/* generate_release_notes: false