Skip to content

chore(release): v0.9.40 #199

chore(release): v0.9.40

chore(release): v0.9.40 #199

Workflow file for this run

name: Desktop Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.1.0)'
required: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Create the GitHub Release up front (as a draft) so each build job can
# upload its binaries straight to it. This deliberately avoids
# upload-artifact/download-artifact: that round-trip through the Actions
# artifact store repeatedly corrupted large binaries (random artifacts each
# run — AppImage squashfs, .deb lzma, .rpm payload — all with hashes matching
# the corrupt content), blocking releases v0.7.26–v0.7.31. The Releases API
# (gh release upload/download) is the same reliable path users download from.
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- name: Get version
id: version
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ -n "$INPUT_VERSION" ]; then VERSION="$INPUT_VERSION"; else VERSION="$REF_NAME"; fi
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" == *"-alpha"* || "$VERSION" == *"-beta"* || "$VERSION" == *"-rc"* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: |
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists (re-run) — ensuring it is a draft and reusing it."
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft
else
args=(--repo "$GITHUB_REPOSITORY" --title "PairUX $VERSION" --draft --generate-notes)
[ "$PRERELEASE" = "true" ] && args+=(--prerelease)
gh release create "$TAG" "${args[@]}"
fi
build:
needs: prepare
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: mac
arch: arm64
- os: macos-latest
platform: mac
arch: x64
- os: windows-latest
platform: win
arch: x64
- os: ubuntu-latest
platform: linux
arch: x64
- os: ubuntu-latest
platform: linux
arch: arm64
runs-on: ${{ matrix.os }}
name: Build ${{ matrix.platform }}-${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Every workspace package the desktop app depends on, not a hardcoded
# list: the main process bundles @profullstack/remote-input, which cannot
# resolve until that package has a dist/. The "^..." filter keeps this
# correct as dependencies are added.
- name: Build shared packages
run: pnpm --filter "@pairux/desktop^..." build
# macOS code signing
- name: Import macOS certificates
if: matrix.platform == 'mac'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
if [ -n "$APPLE_CERTIFICATE" ]; then
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
rm certificate.p12
fi
# Windows code signing
- name: Setup Windows signing
if: matrix.platform == 'win'
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
run: |
if ($env:WINDOWS_CERTIFICATE) {
[IO.File]::WriteAllBytes("certificate.pfx", [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
}
shell: pwsh
# Linux dependencies for electron-builder
- name: Install Linux dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools rpm squashfs-tools
- name: Write .env file
run: printenv ENV_FILE > apps/desktop/.env
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
- name: Build desktop app
run: pnpm --filter @pairux/desktop build
env:
# TURN server configuration
TURN_SERVER_URL: ${{ secrets.TURN_SERVER_URL }}
TURN_SERVER_USERNAME: ${{ secrets.TURN_SERVER_USERNAME }}
TURN_SERVER_CREDENTIAL: ${{ secrets.TURN_SERVER_CREDENTIAL }}
TURNS_SERVER_URL: ${{ secrets.TURNS_SERVER_URL }}
- name: Package desktop app
working-directory: apps/desktop
run: npx electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS notarization (afterSign hook)
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Windows signing
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
# Guard against corrupt AppImage builds reaching users. electron-builder
# has intermittently produced AppImages with a corrupt squashfs (valid
# superblock, but unreadable id/inode tables -> "sqfs_traverse_open error"
# / "Failed to extract AppImage" at runtime). Fully extract the squashfs
# here so a bad artifact fails the build instead of being published.
- name: Verify AppImage integrity
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
imgs=(apps/desktop/out/*.AppImage)
if [ ${#imgs[@]} -eq 0 ]; then
echo "::error::No AppImage was produced by electron-builder"
exit 1
fi
for img in "${imgs[@]}"; do
echo "Verifying squashfs of $img"
# Locate the appended squashfs by scanning for its 'hsqs' magic and
# keeping the first candidate that has a VALID superblock (the magic
# bytes also occur by chance inside the ELF runtime). This is
# arch-independent — the arm64 runtime can't be executed on the x64
# runner, but unsquashfs reads the gzip data regardless of CPU arch.
off=""
while IFS=: read -r cand _; do
if unsquashfs -s -o "$cand" "$img" >/dev/null 2>&1; then
off="$cand"; break
fi
done < <(grep -abo 'hsqs' "$img")
if [ -z "${off:-}" ]; then
echo "::error::no valid squashfs superblock found in $img"
exit 1
fi
echo " squashfs offset=$off"
rm -rf /tmp/appverify
if ! unsquashfs -o "$off" -d /tmp/appverify "$img" >/tmp/unsquashfs.log 2>&1; then
echo "::error::$img has a corrupt squashfs (extraction failed) — not publishing"
tail -30 /tmp/unsquashfs.log
exit 1
fi
# The extracted tree must contain the real app payload.
if [ ! -e /tmp/appverify/AppRun ] && [ ! -d /tmp/appverify/usr ] && [ ! -d /tmp/appverify/resources ]; then
echo "::error::$img extracted but is missing expected contents"
ls -la /tmp/appverify
exit 1
fi
echo " OK: $img extracted cleanly"
done
# Bundle a CURRENT static ffmpeg. The old @ffmpeg-installer npm binary is a
# 2018 (4.x) build that crashes (SIGSEGV) transcoding VP9/WebM from
# MediaRecorder — it broke RTMP streaming for every installer user.
# Linux/Windows use BtbN's maintained static GPL builds; macOS (no BtbN
# builds) keeps the npm fallback for now.
- name: Bundle ffmpeg binary
shell: bash
env:
GH_API_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
EXT=""
if [ "${{ matrix.platform }}" = "win" ]; then EXT=".exe"; fi
OUT="apps/desktop/out/ffmpeg-${{ matrix.platform }}-${{ matrix.arch }}${EXT}"
# Resolve the asset via the API instead of the static
# ".../latest/download/ffmpeg-master-latest-..." URL: BtbN rotates
# autobuilds several times a day and some releases only carry
# versioned asset names (ffmpeg-N-<rev>-g<hash>-...), which 404s the
# static alias. Suffix-match handles both schemes; retry rides out
# the window where a fresh release has no assets yet.
fetch_btbn() {
local suffix="$1" dest="$2" url=""
for attempt in 1 2 3; do
url=$(curl -fsSL -H "Authorization: Bearer $GH_API_TOKEN" \
"https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest" \
| jq -r --arg s "$suffix" \
'[.assets[] | select(.name | endswith($s))][0].browser_download_url // empty')
if [ -n "$url" ] && curl -fsSL -o "$dest" "$url"; then
echo "Downloaded $url"
return 0
fi
echo "BtbN fetch attempt $attempt failed; retrying in 20s"
sleep 20
done
return 1
}
case "${{ matrix.platform }}-${{ matrix.arch }}" in
linux-x64)
fetch_btbn "linux64-gpl.tar.xz" ff.tar.xz
mkdir -p ffx && tar -xJf ff.tar.xz -C ffx && cp ffx/*/bin/ffmpeg "$OUT"
;;
linux-arm64)
fetch_btbn "linuxarm64-gpl.tar.xz" ff.tar.xz
mkdir -p ffx && tar -xJf ff.tar.xz -C ffx && cp ffx/*/bin/ffmpeg "$OUT"
;;
win-x64)
fetch_btbn "win64-gpl.zip" ff.zip
mkdir -p ffx && unzip -q ff.zip -d ffx && cp ffx/*/bin/ffmpeg.exe "$OUT"
;;
*)
FFMPEG_PATH=$(node -e "try { console.log(require('@ffmpeg-installer/ffmpeg').path) } catch(e) { process.exit(1) }")
cp "$FFMPEG_PATH" "$OUT"
;;
esac
chmod +x "$OUT" || true
# Smoke-test when the binary matches the runner arch (x64 builds on
# x64 runners; the cross-built linux-arm64 binary can't execute here).
if [ "${{ matrix.arch }}" = "x64" ]; then
"$OUT" -version | head -1
fi
gzip "$OUT"
echo "Bundled ffmpeg as $(basename "$OUT").gz"
# Upload this platform's binaries straight to the draft release. Runs in
# bash on all three OSes (git-bash on Windows) so the glob works; gh is
# preinstalled on every runner. --clobber makes re-runs idempotent.
- name: Upload binaries to release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
shopt -s nullglob
files=(apps/desktop/out/*.dmg apps/desktop/out/*.zip apps/desktop/out/*.exe \
apps/desktop/out/*.AppImage apps/desktop/out/*.deb apps/desktop/out/*.rpm \
apps/desktop/out/*.gz)
if [ ${#files[@]} -eq 0 ]; then echo "::error::no build outputs to upload"; exit 1; fi
printf 'Uploading to %s:\n' "$TAG"; printf ' %s\n' "${files[@]}"
# Upload one file at a time with retries. The build matrix runs in
# parallel and `--clobber` does a delete-then-reupload, so two jobs
# touching the same asset name race and one gets a transient HTTP 404
# (or 422). Per-file + backoff+jitter rides it out instead of failing
# the whole release.
for f in "${files[@]}"; do
for attempt in 1 2 3 4 5; do
if gh release upload "$TAG" "$f" --clobber --repo "$GITHUB_REPOSITORY"; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "::error::failed to upload $(basename "$f") after 5 attempts"; exit 1
fi
echo "upload of $(basename "$f") failed (attempt $attempt); retrying after backoff"
sleep $(( attempt * 3 + RANDOM % 6 ))
done
done
finalize:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download published release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
mkdir -p dist
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dist --pattern '*'
ls -la dist/
# Re-verify the EXACT bytes published to the Release (defense in depth).
# The build jobs upload straight to the Release, so this catches any
# corruption on the upload/download path before we un-draft. A failure
# here leaves the release as a DRAFT (not public) — re-run to fix.
- name: Install verification tools
run: sudo apt-get update && sudo apt-get install -y squashfs-tools binutils libarchive-tools unzip
- name: Verify artifact integrity
shell: bash
run: |
set -uo pipefail
shopt -s nullglob
fail=0
for img in dist/*.AppImage; do
echo "::group::AppImage $img"
# Locate the appended squashfs by its 'hsqs' magic; keep the first
# offset with a valid superblock (the magic also occurs by chance
# inside the ELF runtime). Arch-independent: unsquashfs reads the
# gzip payload regardless of the runtime's CPU arch.
off=""
while IFS=: read -r cand _; do
if unsquashfs -s -o "$cand" "$img" >/dev/null 2>&1; then off="$cand"; break; fi
done < <(grep -abo 'hsqs' "$img")
if [ -z "$off" ]; then
echo "::error::no valid squashfs superblock in $img"; fail=1; echo "::endgroup::"; continue
fi
rm -rf /tmp/appverify
if unsquashfs -o "$off" -d /tmp/appverify "$img" >/tmp/uns.log 2>&1 \
&& { [ -e /tmp/appverify/AppRun ] || [ -d /tmp/appverify/usr ] || [ -d /tmp/appverify/resources ]; }; then
echo "OK $img"
else
echo "::error::$img has a corrupt squashfs"; tail -20 /tmp/uns.log; fail=1
fi
echo "::endgroup::"
done
for deb in dist/*.deb; do
echo "::group::deb $deb"
if dpkg-deb -x "$deb" /tmp/debverify >/tmp/deb.log 2>&1; then echo "OK $deb"; rm -rf /tmp/debverify
else echo "::error::$deb is corrupt"; tail -20 /tmp/deb.log; fail=1; fi
echo "::endgroup::"
done
# Verify rpms with bsdtar (libarchive), NOT rpm2cpio|cpio: the
# runner's rpm2cpio can't decompress zstd payloads (electron-builder
# emits zstd rpms for some arches, e.g. aarch64), so it falsely
# reported "premature end of file" on perfectly valid rpms. libarchive
# handles every rpm payload compressor (gzip/xz/zstd) uniformly.
for rpm in dist/*.rpm; do
echo "::group::rpm $rpm"
if bsdtar -tf "$rpm" >/dev/null 2>/tmp/rpm.log; then echo "OK $rpm"
else echo "::error::$rpm is corrupt"; tail -20 /tmp/rpm.log; fail=1; fi
echo "::endgroup::"
done
# macOS .zip (Electron app bundle) — cheap to validate on Linux.
# .dmg / Windows .exe aren't generically extractable here.
for z in dist/*.zip; do
echo "::group::zip $z"
if unzip -t "$z" >/tmp/zip.log 2>&1; then echo "OK $z"
else echo "::error::$z is corrupt"; tail -20 /tmp/zip.log; fail=1; fi
echo "::endgroup::"
done
if [ "$fail" -ne 0 ]; then
echo "::error::One or more release artifacts are corrupt — aborting before publish. Re-run this workflow to rebuild."
exit 1
fi
echo "All verifiable artifacts extracted cleanly."
- name: Generate + upload checksums
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
cd dist
rm -f SHA256SUMS.txt
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
gh release upload "$TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
- name: Publish release
env:
# PKG_SUBMIT_TOKEN (PAT) so un-drafting emits a release event that
# triggers the submit-packages workflow; the default GITHUB_TOKEN
# does not trigger other workflows.
GH_TOKEN: ${{ secrets.PKG_SUBMIT_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false