chore(release): v0.9.40 #199
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |