name: Publish Release on: release: types: [published] workflow_dispatch: inputs: tag_name: description: Existing release tag to rebuild and publish required: true type: string release_scope: description: Platforms to publish required: true default: all type: choice options: - all - windows-linux permissions: contents: write actions: write id-token: write attestations: write artifact-metadata: write concurrency: group: release-${{ github.event.release.tag_name || github.event.inputs.tag_name || github.run_id }} cancel-in-progress: false jobs: prepare-release: name: Prepare release runs-on: ubuntu-latest outputs: tag_name: ${{ steps.prepare.outputs.tag_name }} package_version: ${{ steps.prepare.outputs.package_version }} source_sha: ${{ steps.prepare.outputs.source_sha }} prerelease: ${{ steps.prepare.outputs.prerelease }} release_scope: ${{ steps.prepare.outputs.release_scope }} steps: - name: Checkout release source uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.tag_name }} fetch-depth: 0 - name: Validate release tag id: prepare shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BODY: ${{ github.event.release.body }} REQUESTED_SCOPE: ${{ github.event.inputs.release_scope }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} DISPATCH_TAG_NAME: ${{ github.event.inputs.tag_name }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "release" ]; then TAG_NAME="$RELEASE_TAG_NAME" IS_PRERELEASE="${{ github.event.release.prerelease }}" RELEASE_SCOPE="all" if [ "$IS_PRERELEASE" = "true" ] && grep -Fq '' <<< "$RELEASE_BODY"; then RELEASE_SCOPE="windows-linux" fi else TAG_NAME="$DISPATCH_TAG_NAME" IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" RELEASE_SCOPE="${REQUESTED_SCOPE:-all}" fi if [ -z "$TAG_NAME" ]; then echo "Release tag is required." exit 1 fi TAG_VERSION="${TAG_NAME#v}" PACKAGE_VERSION=$(node -p "require('./package.json').version") if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then echo "Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)." exit 1 fi case "$RELEASE_SCOPE" in all|windows-linux) ;; *) echo "Unsupported release scope: $RELEASE_SCOPE" exit 1 ;; esac if [ "$IS_PRERELEASE" != "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then echo "Partial platform scope is allowed only for prereleases." exit 1 fi if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1 || \ gh release create "$TAG_NAME" --repo "${{ github.repository }}" --verify-tag --draft --title "$TAG_NAME" fi echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" echo "release_scope=$RELEASE_SCOPE" >> "$GITHUB_OUTPUT" build-macos-x64: name: Build macOS x64 needs: prepare-release if: needs.prepare-release.outputs.release_scope == 'all' runs-on: macos-15-intel env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Validate macOS signing secrets shell: bash env: APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} run: | set -euo pipefail for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do if [ -z "${!name:-}" ]; then echo "Missing required macOS release secret: $name" exit 1 fi done - name: Prepare macOS signing certificate shell: bash env: APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} run: | set -euo pipefail CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12" echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" { echo "CSC_LINK=$CERT_PATH" echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD" } >> "$GITHUB_ENV" - name: Install dependencies run: npm ci --ignore-scripts - name: Install bundled FFmpeg binary run: node scripts/install-ffmpeg-static.mjs - name: Verify bundled FFmpeg binary run: | node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);" - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build native macOS helpers run: npm run build:platform-native-helpers - name: Build macOS x64 run: | npx tsc npx vite build npx electron-builder --mac dir dmg zip --x64 --publish never - name: Smoke test packaged macOS x64 paths env: PACKAGED_SMOKE_ARCH_TAGS: darwin-x64 run: npm run smoke:packaged-binaries - name: Generate macOS x64 artifact checksums run: npm run checksums:release -- SHA256SUMS-macos-x64.txt - name: Attest macOS x64 artifacts uses: actions/attest@v4 with: subject-checksums: release/SHA256SUMS-macos-x64.txt - name: Upload macOS x64 artifacts uses: actions/upload-artifact@v4 with: name: macos-x64-release path: | release/*.dmg release/*.zip release/*.blockmap release/latest-mac.yml release/SHA256SUMS*.txt if-no-files-found: error build-macos-arm64: name: Build macOS arm64 needs: prepare-release if: needs.prepare-release.outputs.release_scope == 'all' runs-on: macos-14 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Validate macOS signing secrets shell: bash env: APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} run: | set -euo pipefail for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do if [ -z "${!name:-}" ]; then echo "Missing required macOS release secret: $name" exit 1 fi done - name: Prepare macOS signing certificate shell: bash env: APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} run: | set -euo pipefail CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12" echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" { echo "CSC_LINK=$CERT_PATH" echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD" } >> "$GITHUB_ENV" - name: Install dependencies run: npm ci --ignore-scripts - name: Install bundled FFmpeg binary run: node scripts/install-ffmpeg-static.mjs - name: Verify bundled FFmpeg binary run: | node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);" - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build native macOS helpers run: npm run build:platform-native-helpers - name: Build macOS arm64 run: | npx tsc npx vite build npx electron-builder --mac dir dmg zip --arm64 --publish never - name: Smoke test packaged macOS arm64 paths env: PACKAGED_SMOKE_ARCH_TAGS: darwin-arm64 run: npm run smoke:packaged-binaries - name: Generate macOS arm64 artifact checksums run: npm run checksums:release -- SHA256SUMS-macos-arm64.txt - name: Attest macOS arm64 artifacts uses: actions/attest@v4 with: subject-checksums: release/SHA256SUMS-macos-arm64.txt - name: Upload macOS arm64 artifacts uses: actions/upload-artifact@v4 with: name: macos-arm64-release path: | release/*.dmg release/*.zip release/*.blockmap release/latest-mac.yml release/SHA256SUMS*.txt if-no-files-found: error merge-macos-update-metadata: name: Merge macOS update metadata needs: - prepare-release - build-macos-x64 - build-macos-arm64 if: needs.prepare-release.outputs.release_scope == 'all' runs-on: ubuntu-latest steps: - name: Download macOS x64 artifacts uses: actions/download-artifact@v4 with: name: macos-x64-release path: release-assets/macos-x64 - name: Download macOS arm64 artifacts uses: actions/download-artifact@v4 with: name: macos-arm64-release path: release-assets/macos-arm64 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install PyYAML run: python -m pip install pyyaml - name: Merge latest-mac.yml shell: bash run: | set -euo pipefail mkdir -p release-assets/merged python <<'PY' from pathlib import Path import yaml x64_info = yaml.safe_load(Path('release-assets/macos-x64/latest-mac.yml').read_text()) arm64_info = yaml.safe_load(Path('release-assets/macos-arm64/latest-mac.yml').read_text()) merged_files = [] seen_urls = set() for info in (x64_info, arm64_info): for file_info in info.get('files', []): url = file_info.get('url') if url and url not in seen_urls: seen_urls.add(url) merged_files.append(file_info) if not merged_files: raise SystemExit('No macOS update files found to merge.') preferred = next((item for item in merged_files if 'x64' in item.get('url', '')), merged_files[0]) merged = dict(x64_info) merged['files'] = merged_files merged['path'] = preferred.get('url', merged.get('path')) merged['sha512'] = preferred.get('sha512', merged.get('sha512')) if not merged.get('releaseDate') and arm64_info.get('releaseDate'): merged['releaseDate'] = arm64_info['releaseDate'] Path('release-assets/merged/latest-mac.yml').write_text( yaml.safe_dump(merged, sort_keys=False) ) PY - name: Upload merged latest-mac.yml artifact uses: actions/upload-artifact@v4 with: name: macos-merged-metadata path: release-assets/merged/latest-mac.yml if-no-files-found: error build-windows-x64: name: Build Windows x64 needs: prepare-release runs-on: windows-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Configure Windows signing shell: pwsh env: WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }} WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} run: | if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD)) { Write-Warning 'Windows signing secrets are missing. Building an unsigned installer.' Add-Content -Path $env:GITHUB_ENV -Value 'CSC_IDENTITY_AUTO_DISCOVERY=false' exit 0 } $certPath = Join-Path $env:RUNNER_TEMP 'windows-signing-cert.p12' [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64)) Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_LINK=$certPath" Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_KEY_PASSWORD=$env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD" - name: Install dependencies run: npm ci --ignore-scripts - name: Install bundled FFmpeg binary run: node scripts/install-ffmpeg-static.mjs - name: Verify bundled FFmpeg binary run: | node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);" - name: Remove stale uiohook build output shell: pwsh run: | if (Test-Path node_modules/uiohook-napi/build) { Remove-Item node_modules/uiohook-napi/build -Recurse -Force } - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build Windows x64 shell: pwsh run: | npm run build:platform-native-helpers npx tsc npx vite build npx electron-builder --win dir nsis --x64 --publish never - name: Smoke test packaged Windows x64 paths run: npm run smoke:packaged-binaries - name: Generate Windows x64 artifact checksums run: npm run checksums:release -- SHA256SUMS-windows-x64.txt - name: Attest Windows x64 artifacts uses: actions/attest@v4 with: subject-checksums: release/SHA256SUMS-windows-x64.txt - name: Upload Windows x64 artifacts uses: actions/upload-artifact@v4 with: name: windows-x64-release path: | release/*.exe release/*.blockmap release/latest*.yml release/SHA256SUMS*.txt if-no-files-found: error build-linux-x64: name: Build Linux x64 needs: prepare-release runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install Linux native build dependencies run: | sudo apt-get update sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev - name: Install dependencies run: npm ci --ignore-scripts - name: Install bundled FFmpeg binary run: node scripts/install-ffmpeg-static.mjs - name: Verify bundled FFmpeg binary run: | node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);" - name: Remove stale uiohook build output run: rm -rf node_modules/uiohook-napi/build - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build Linux x64 run: | export CSC_IDENTITY_AUTO_DISCOVERY=false npm run build:platform-native-helpers npx tsc npx vite build npx electron-builder --linux dir AppImage --x64 --publish never - name: Smoke test packaged Linux x64 paths run: npm run smoke:packaged-binaries - name: Generate Linux x64 artifact checksums run: npm run checksums:release -- SHA256SUMS-linux-x64.txt - name: Attest Linux x64 artifacts uses: actions/attest@v4 with: subject-checksums: release/SHA256SUMS-linux-x64.txt - name: Upload Linux x64 artifacts uses: actions/upload-artifact@v4 with: name: linux-x64-release path: | release/*.AppImage release/*.blockmap release/latest-linux.yml release/SHA256SUMS*.txt if-no-files-found: error publish-release-assets: name: Publish release assets needs: - prepare-release - build-macos-x64 - build-macos-arm64 - merge-macos-update-metadata - build-windows-x64 - build-linux-x64 if: | always() && needs.prepare-release.result == 'success' && needs.build-windows-x64.result == 'success' && needs.build-linux-x64.result == 'success' && ( needs.prepare-release.outputs.release_scope == 'windows-linux' || ( needs.build-macos-x64.result == 'success' && needs.build-macos-arm64.result == 'success' && needs.merge-macos-update-metadata.result == 'success' ) ) runs-on: ubuntu-latest steps: - name: Download macOS x64 artifacts if: needs.prepare-release.outputs.release_scope == 'all' uses: actions/download-artifact@v4 with: name: macos-x64-release path: release-assets/macos-x64 - name: Download macOS arm64 artifacts if: needs.prepare-release.outputs.release_scope == 'all' uses: actions/download-artifact@v4 with: name: macos-arm64-release path: release-assets/macos-arm64 - name: Download merged macOS metadata if: needs.prepare-release.outputs.release_scope == 'all' uses: actions/download-artifact@v4 with: name: macos-merged-metadata path: release-assets/macos-merged - name: Download Windows x64 artifacts uses: actions/download-artifact@v4 with: name: windows-x64-release path: release-assets/windows-x64 - name: Download Linux x64 artifacts uses: actions/download-artifact@v4 with: name: linux-x64-release path: release-assets/linux-x64 - name: Stage release assets shell: bash env: IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }} RELEASE_SCOPE: ${{ needs.prepare-release.outputs.release_scope }} run: | set -euo pipefail shopt -s nullglob mkdir -p release-assets/upload assets=( release-assets/windows-x64/*.exe release-assets/windows-x64/*.blockmap release-assets/linux-x64/*.AppImage release-assets/linux-x64/*.blockmap ) if [ "$RELEASE_SCOPE" = "all" ]; then assets+=( release-assets/macos-x64/*.dmg release-assets/macos-x64/*.zip release-assets/macos-x64/*.blockmap release-assets/macos-arm64/*.dmg release-assets/macos-arm64/*.zip release-assets/macos-arm64/*.blockmap ) fi if [ "$IS_PRERELEASE" != "true" ]; then assets+=( release-assets/macos-merged/latest-mac.yml release-assets/windows-x64/latest*.yml release-assets/linux-x64/latest-linux.yml ) fi if [ ${#assets[@]} -eq 0 ]; then echo "No release assets found to checksum." exit 1 fi for file in "${assets[@]}"; do target="release-assets/upload/$(basename "$file")" if [ -e "$target" ]; then echo "Duplicate release asset name: $(basename "$file")" exit 1 fi cp "$file" "$target" done - name: Generate release asset checksums shell: bash run: | set -euo pipefail cd release-assets/upload sha256sum * | sort -k2,2 > SHA256SUMS.txt sha256sum -c SHA256SUMS.txt - name: Attest release assets uses: actions/attest@v4 with: subject-checksums: release-assets/upload/SHA256SUMS.txt - name: Upload release assets to GitHub shell: bash run: | set -euo pipefail shopt -s nullglob assets=( release-assets/upload/* ) if [ ${#assets[@]} -eq 0 ]; then echo "No release assets found to upload." exit 1 fi gh release upload "${{ needs.prepare-release.outputs.tag_name }}" \ "${assets[@]}" \ --repo "${{ github.repository }}" \ --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} trigger-homebrew-update: name: Trigger Homebrew tap update needs: - prepare-release - publish-release-assets runs-on: ubuntu-latest if: needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all' steps: - name: Dispatch Homebrew tap workflow shell: bash run: | gh workflow run homebrew-tap.yml \ --repo "${{ github.repository }}" \ -f tag="${{ needs.prepare-release.outputs.tag_name }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}