# Test Release workflow # # Manual workflow for testing binary builds without creating a real release. # Triggered via workflow_dispatch from the GitHub Actions UI. name: Test Release on: workflow_dispatch: jobs: # ── Build and test platform-specific binaries ───────────────────────── build-binaries: name: Build & Test ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest target: bun-linux-x64 binary: fn-cli-linux-x64 - os: ubuntu-24.04-arm target: bun-linux-arm64 binary: fn-cli-linux-arm64 - os: macos-latest target: bun-darwin-arm64 binary: fn-cli-darwin-arm64 # bun-darwin-x64 (Intel) dropped: macos-13 runner scarcity — CLI is # Apple-Silicon-only for macOS. Keep in sync with release.yml. - os: windows-latest target: bun-windows-x64 binary: fn-cli-windows-x64.exe steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node and install dependencies uses: ./.github/actions/setup-node-pnpm - name: Install Bun uses: oven-sh/setup-bun@v2 - name: Build run: pnpm build - name: Build binary run: pnpm --filter @runfusion/fusion build:exe -- --target ${{ matrix.target }} - name: Verify binary exists shell: bash run: test -f packages/cli/dist/${{ matrix.binary }} - name: Smoke test (Linux/macOS) if: runner.os != 'Windows' run: | chmod +x packages/cli/dist/${{ matrix.binary }} packages/cli/dist/${{ matrix.binary }} --help - name: Smoke test (Windows) if: runner.os == 'Windows' shell: pwsh run: | & packages/cli/dist/${{ matrix.binary }} --help - name: Sign macOS binary if: runner.os == 'macOS' && env.APPLE_CERTIFICATE_BASE64 != '' env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} run: bash scripts/sign-macos.sh packages/cli/dist/${{ matrix.binary }} packages/cli/dist/runtime - name: Sign Windows binary if: runner.os == 'Windows' && env.WINDOWS_CERTIFICATE_BASE64 != '' env: WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: pwsh scripts/sign-windows.ps1 packages/cli/dist/${{ matrix.binary }} - name: Generate checksum (Linux) if: runner.os == 'Linux' run: | cd packages/cli/dist sha256sum ${{ matrix.binary }} > ${{ matrix.binary }}.sha256 - name: Generate checksum (macOS) if: runner.os == 'macOS' run: | cd packages/cli/dist shasum -a 256 ${{ matrix.binary }} > ${{ matrix.binary }}.sha256 - name: Generate checksum (Windows) if: runner.os == 'Windows' shell: pwsh run: | cd packages/cli/dist $hash = (Get-FileHash ${{ matrix.binary }} -Algorithm SHA256).Hash.ToLower() "$hash ${{ matrix.binary }}" | Out-File -Encoding ascii ${{ matrix.binary }}.sha256 - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.binary }} path: | packages/cli/dist/${{ matrix.binary }} packages/cli/dist/${{ matrix.binary }}.sha256 packages/cli/dist/runtime/**/* # ── Build Windows desktop EXE artifacts ────────────────────────────── # Code-signing with WINDOWS_CERTIFICATE_BASE64 / WINDOWS_CERTIFICATE_PASSWORD # is intentionally deferred to FN-5592; ARM64 support is tracked in FN-5594. build-desktop-windows: name: Build Desktop Windows EXE runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node and install dependencies uses: ./.github/actions/setup-node-pnpm - name: Build run: pnpm build - name: Build desktop package run: pnpm --filter @fusion/desktop build - name: Package Windows desktop EXE run: pnpm --filter @fusion/desktop dist:win -- --publish never env: CSC_IDENTITY_AUTO_DISCOVERY: "false" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify desktop EXE artifacts shell: pwsh run: | $exes = Get-ChildItem packages/desktop/dist-electron -Filter "Fusion-*-win-*.exe" if ($exes.Count -eq 0) { Write-Error "No Fusion Windows EXE artifacts produced" exit 1 } - name: Generate desktop EXE checksums shell: pwsh run: | $exes = Get-ChildItem packages/desktop/dist-electron -Filter "Fusion-*-win-*.exe" foreach ($exe in $exes) { $hash = (Get-FileHash $exe.FullName -Algorithm SHA256).Hash.ToLower() "$hash $($exe.Name)" | Out-File -Encoding ascii "$($exe.FullName).sha256" } - name: Upload desktop Windows artifacts uses: actions/upload-artifact@v4 with: name: fusion-desktop-windows path: | packages/desktop/dist-electron/Fusion-*-win-*.exe packages/desktop/dist-electron/Fusion-*-win-*.exe.sha256 packages/desktop/dist-electron/Fusion-*-win-*.exe.blockmap packages/desktop/dist-electron/latest.yml # ── Build macOS desktop artifacts ──────────────────────────────────── build-desktop-macos: name: Build Desktop macOS DMG/ZIP runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node and install dependencies uses: ./.github/actions/setup-node-pnpm - name: Build run: pnpm build - name: Build desktop package run: pnpm --filter @fusion/desktop build - name: Package signed macOS desktop DMG/ZIP if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} run: pnpm --filter @fusion/desktop dist:mac -- --publish never env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CSC_IDENTITY_AUTO_DISCOVERY: "true" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Package unsigned macOS desktop DMG/ZIP if: ${{ env.APPLE_CERTIFICATE_BASE64 == '' }} run: pnpm --filter @fusion/desktop dist:mac -- --publish never -c.mac.notarize=false env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} CSC_IDENTITY_AUTO_DISCOVERY: "false" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify signed and notarized macOS artifacts if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} shell: bash run: | set -euo pipefail shopt -s nullglob dmgs=(packages/desktop/dist-electron/Fusion-*-mac-*.dmg) zips=(packages/desktop/dist-electron/Fusion-*-mac-*.zip) if [ ${#dmgs[@]} -eq 0 ]; then echo "No Fusion macOS DMG artifacts produced" >&2 exit 1 fi if [ ${#zips[@]} -eq 0 ]; then echo "No Fusion macOS ZIP artifacts produced" >&2 exit 1 fi for dmg in "${dmgs[@]}"; do echo "Verifying signed DMG: $dmg" codesign --verify --deep --strict --verbose=2 "$dmg" spctl --assess --type open --context context:primary-signature -v "$dmg" xcrun stapler validate "$dmg" done for zip in "${zips[@]}"; do echo "Verifying ZIP app bundle: $zip" tmpdir="$(mktemp -d)" unzip -q "$zip" -d "$tmpdir" app="$(find "$tmpdir" -type d -name '*.app' -print -quit)" if [ -z "$app" ]; then echo "No .app bundle found in $zip" >&2 rm -rf "$tmpdir" exit 1 fi codesign --verify --deep --strict --verbose=2 "$app" spctl --assess --type exec -v "$app" xcrun stapler validate "$app" rm -rf "$tmpdir" done - name: Generate desktop macOS checksums shell: bash run: | shopt -s nullglob for file in packages/desktop/dist-electron/Fusion-*-mac-*.dmg packages/desktop/dist-electron/Fusion-*-mac-*.zip; do shasum -a 256 "$file" > "$file.sha256" done - name: Upload desktop macOS artifacts uses: actions/upload-artifact@v4 with: name: fusion-desktop-macos path: | packages/desktop/dist-electron/Fusion-*-mac-*.dmg packages/desktop/dist-electron/Fusion-*-mac-*.dmg.sha256 packages/desktop/dist-electron/Fusion-*-mac-*.zip packages/desktop/dist-electron/Fusion-*-mac-*.zip.sha256 packages/desktop/dist-electron/Fusion-*-mac-*.blockmap packages/desktop/dist-electron/latest-mac.yml # ── Build Linux desktop artifacts ──────────────────────────────────── build-desktop-linux: name: Build Desktop Linux Artifacts runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node and install dependencies uses: ./.github/actions/setup-node-pnpm - name: Build run: pnpm build - name: Build desktop package run: pnpm --filter @fusion/desktop build - name: Package Linux desktop artifacts # Linux desktop code-signing is deferred to FN-5605; Linux ARM64 CLI binaries are tracked in FN-5606. run: pnpm --filter @fusion/desktop dist:linux -- --x64 --arm64 --publish never env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify desktop Linux AppImage artifacts shell: bash run: | shopt -s nullglob arm64_appimages=(packages/desktop/dist-electron/Fusion-*-linux-arm64.AppImage) x64_appimages=(packages/desktop/dist-electron/Fusion-*-linux-x86_64.AppImage) if [ ${#arm64_appimages[@]} -eq 0 ]; then echo "No Fusion Linux arm64 AppImage artifacts produced" >&2 exit 1 fi if [ ${#x64_appimages[@]} -eq 0 ]; then echo "No Fusion Linux x64 AppImage artifacts produced" >&2 exit 1 fi - name: Sign Linux desktop artifacts if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }} env: LINUX_GPG_PRIVATE_KEY: ${{ secrets.LINUX_GPG_PRIVATE_KEY }} LINUX_GPG_PASSPHRASE: ${{ secrets.LINUX_GPG_PASSPHRASE }} LINUX_GPG_KEY_ID: ${{ secrets.LINUX_GPG_KEY_ID }} shell: bash run: | shopt -s nullglob artifacts=( packages/desktop/dist-electron/Fusion-*-linux-*.AppImage packages/desktop/dist-electron/Fusion-*-linux-*.deb packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz ) bash scripts/sign-linux.sh "${artifacts[@]}" - name: Generate desktop Linux checksums shell: bash run: | shopt -s nullglob for file in packages/desktop/dist-electron/Fusion-*-linux-*.AppImage packages/desktop/dist-electron/Fusion-*-linux-*.deb packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz; do sha256sum "$file" > "$file.sha256" done - name: Upload desktop Linux artifacts uses: actions/upload-artifact@v4 with: # Single glob set covers both linux-x64 and linux-arm64 artifact filenames. name: fusion-desktop-linux if-no-files-found: ignore path: | packages/desktop/dist-electron/Fusion-*-linux-*.AppImage packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.sha256 packages/desktop/dist-electron/Fusion-*-linux-*.AppImage.asc packages/desktop/dist-electron/Fusion-*-linux-*.deb packages/desktop/dist-electron/Fusion-*-linux-*.deb.sha256 packages/desktop/dist-electron/Fusion-*-linux-*.deb.asc packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.sha256 packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.asc packages/desktop/dist-electron/latest-linux.yml # ── Build Android APK/AAB artifacts ────────────────────────────────── # FNXC:Release 2026-06-25-12:00: # Keep the tag-less rehearsal workflow in parity with release.yml so APK # generation, checksum output, and artifact collection are validated before a # version tag tries to publish the Android asset publicly. # FNXC:Release 2026-06-25-18:10: # Android signing is optional and secret-gated on ANDROID_KEYSTORE_BASE64, # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD. # The Capacitor Android project is gitignored/regenerated, so CI injects # signing with android.injected.signing.* Gradle properties instead of # committing native build.gradle edits. When the keystore is absent, keep the # FN-7014 unsigned debug APK fallback; Play Store upload remains out of scope # and is tracked separately from sideload release artifacts. build-android: name: Build Android APK/AAB runs-on: ubuntu-latest timeout-minutes: 30 # Job-level env mirrors the desktop signing pattern: step `if:` conditions # can inspect env values, but cannot read secrets.* directly. env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node and install dependencies uses: ./.github/actions/setup-node-pnpm # FNXC:MobileAndroidBuild 2026-06-28-00:00: # Capacitor 7 @capacitor/android compiles its Android library with JavaVersion.VERSION_21; rehearsal APK/AAB Gradle builds must provision JDK 21 because JDK 17 fails with `invalid source release: 21`. - name: Setup Java 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - name: Cache Android Gradle caches uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('pnpm-lock.yaml', 'packages/mobile/capacitor.config.ts') }} restore-keys: | ${{ runner.os }}-gradle- - name: Build dashboard client run: pnpm --filter @fusion/dashboard build - name: Create and sync Capacitor Android project run: | # FNXC:Release 2026-06-25-12:00: # The Capacitor Android platform directory is gitignored and absent in # clean release checkouts, so initialize it from pinned package metadata # before syncing web assets instead of silently skipping the APK leg. if [ ! -d packages/mobile/android ]; then pnpm --filter @fusion/mobile cap add android fi pnpm --filter @fusion/mobile cap sync android - name: Decode Android signing keystore if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} run: | printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/fusion-release.keystore" - name: Build signed Android release APK and AAB if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleRelease bundleRelease \ -Pandroid.injected.signing.store.file="$RUNNER_TEMP/fusion-release.keystore" \ -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" - name: Normalize signed Android release assets if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} run: | APK="packages/mobile/android/app/build/outputs/apk/release/app-release.apk" AAB="packages/mobile/android/app/build/outputs/bundle/release/app-release.aab" if [ ! -f "$APK" ]; then echo "::error::Expected signed Android APK missing at $APK" >&2 exit 1 fi if [ ! -f "$AAB" ]; then echo "::error::Expected signed Android AAB missing at $AAB" >&2 exit 1 fi mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android-release.apk cp "$AAB" packages/mobile/dist/fusion-android-release.aab - name: Verify signed Android APK signature if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} run: | APK="packages/mobile/dist/fusion-android-release.apk" APKSIGNER="" if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1 || true) fi if [ -n "$APKSIGNER" ]; then "$APKSIGNER" verify --verbose "$APK" else jarsigner -verify -strict "$APK" fi - name: Build unsigned Android debug APK if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleDebug - name: Normalize unsigned Android APK asset if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | APK="packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk" if [ ! -f "$APK" ]; then echo "::error::Expected Android APK missing at $APK" >&2 exit 1 fi mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android.apk - name: Generate Android artifact checksums run: | cd packages/mobile/dist for file in fusion-android*.apk fusion-android-release.aab; do [ -f "$file" ] || continue sha256sum "$file" > "$file.sha256" done - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: fusion-android-apk path: | packages/mobile/dist/fusion-android*.apk packages/mobile/dist/fusion-android*.apk.sha256 packages/mobile/dist/fusion-android-release.aab packages/mobile/dist/fusion-android-release.aab.sha256 # ── Collect all artifacts ───────────────────────────────────────────── collect: name: Collect Artifacts needs: [build-binaries, build-desktop-windows, build-desktop-macos, build-desktop-linux, build-android] runs-on: ubuntu-latest steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Combine artifacts run: | mkdir combined find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} combined/ \; ls -la combined/ - name: Upload combined archive uses: actions/upload-artifact@v4 with: name: all-binaries path: combined/*