name: Multi-OS Tauri Releases on: workflow_dispatch: inputs: test_mode: description: "Run in test mode (skip release step)" required: false default: "true" type: choice options: - "true" - "false" platform: description: "Platform to build (windows, macos, linux, or all)" required: true default: "all" type: choice options: - all - windows - macos - linux sign: description: "Code sign the binaries (requires signing secrets)" required: false default: "true" type: choice options: - "true" - "false" release: types: [created] permissions: contents: read jobs: pick: uses: ./.github/workflows/_runner-pick.yml determine-matrix: if: ${{ vars.CI_PROFILE != 'lite' }} needs: pick runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" distribution: "temurin" - name: Cache Gradle dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | gradle-${{ runner.os }}- - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: gradle-version: 9.3.1 - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Get version number id: versionNumber run: | VERSION=$(./gradlew printVersion --quiet | tail -1) echo "Extracted version: $VERSION" echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - name: Determine build matrix id: set-matrix run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then case "${{ github.event.inputs.platform }}" in "windows") echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT ;; "macos") echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT ;; "linux") echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT ;; *) echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT ;; esac else # For push/release events, build all platforms echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT fi build-jars: needs: [pick, determine-matrix] runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} env: DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} strategy: matrix: variant: - name: "default" disable_security: true build_frontend: true file_suffix: "" - name: "with-login" disable_security: false build_frontend: true file_suffix: "-with-login" - name: "server-only" disable_security: true build_frontend: false file_suffix: "-server" steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: gradle-version: 9.3.1 - name: Setup Node.js if: matrix.variant.build_frontend == true uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }} STIRLING_PDF_DESKTOP_UI: false - name: Rename JAR run: | echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}" echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar" ls -la app/core/build/libs/ mkdir -p ./jar-dist cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar - name: Upload JAR artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: jar${{ matrix.variant.file_suffix }} path: ./jar-dist/*.jar retention-days: 1 build: needs: determine-matrix strategy: fail-fast: false matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }} runs-on: ${{ matrix.platform }} env: SM_API_KEY: ${{ secrets.SM_API_KEY }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit allowed-endpoints: > one.digicert.com:443 clientauth.one.digicert.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install dependencies (ubuntu only) if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Setup Rust uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable with: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} # x86_64 JDK is set up first so the aarch64 step below can leave its # JAVA_HOME as the active one. The macOS universal JRE build needs # jmods from both arches; the x64 path is captured into the env # before the second setup-java overwrites JAVA_HOME. - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" distribution: "temurin" architecture: "x64" - name: Capture x86_64 JAVA_HOME if: matrix.platform == 'macos-15' run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "25" distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: gradle-version: 9.3.1 - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 # Build the universal JRE before desktop:prepare so the jlink:runtime # task short-circuits on its `test -d runtime/jre` status check. - name: Build universal macOS JRE if: matrix.platform == 'macos-15' env: AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }} JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:jlink:universal-mac - name: Prepare desktop build env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: true JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:prepare # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG if ($pkcs11Config) { Write-Host "Found PKCS11_CONFIG: $pkcs11Config" echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV } else { Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path" $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg" if (Test-Path $defaultPath) { Write-Host "Found config at default path: $defaultPath" echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV } else { Write-Host "Warning: Could not find PKCS11 config file" } } # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} shell: powershell run: | if ($env:WINDOWS_CERTIFICATE) { Write-Host "Importing Windows Code Signing Certificate..." # Decode base64 certificate and save to file $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" [IO.File]::WriteAllBytes($certPath, $certBytes) # Import certificate to CurrentUser\My store $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) # Extract and set thumbprint as environment variable $thumbprint = $cert.Thumbprint Write-Host "Certificate imported with thumbprint: $thumbprint" echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV # Clean up certificate file Remove-Item $certPath Write-Host "Windows certificate import completed." } else { Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary" } - name: Import Apple Developer Certificate if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | echo "Importing Apple Developer Certificate..." echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Import certificate security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Clean up rm certificate.p12 - name: Verify Certificate if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application") echo "Certificate Info: $CERT_INFO" CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') echo "Certificate ID: $CERT_ID" echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV echo "Certificate imported successfully." # Pre-flight: verify smctl can talk to DigiCert and sync cert before we sign. # Mirrors the setup from working public Tauri+KeyLocker repos (Labric, Meetily). # Without this, signCommand failures are opaque (Tauri captures but drops # smctl's stderr) - running these loudly surfaces auth/env/keypair issues. - name: Preflight smctl if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} run: | & smctl healthcheck if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl healthcheck failed"; exit 1 } & smctl keypair ls if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl keypair ls failed"; exit 1 } & smctl windows certsync --keypair-alias "$env:KEYPAIR_ALIAS" if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" } Write-Host "[SUCCESS] smctl preflight passed" # Write platform-specific Tauri config that adds signCommand for Windows. # Tauri auto-merges tauri.windows.conf.json with tauri.conf.json (RFC 7396). # Tauri calls this command on every binary BEFORE bundling into the MSI, # substituting %1 with the file path. # # Why OBJECT form (cmd + args) instead of string: # Tauri's string-form parser does a naive split(' ') with no shell/quote handling. # Args with spaces or quote characters get mangled. The object form passes each # arg directly to Rust's Command::arg which handles Windows CreateProcess quoting. # # Why --keypair-alias instead of --fingerprint: # --fingerprint requires smctl windows certsync to have synced the cert to the # Windows cert store first. --keypair-alias goes direct through PKCS11 and works # without certsync. All real-world working Tauri+smctl examples use this flag. # # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD # from env (set by prior DigiCert setup step). No --config-file needed. - name: Configure Windows code signing if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} run: | cat > ./frontend/editor/src-tauri/tauri.windows.conf.json < /usr/lib/libjvm.so" else echo "libjvm not found at $JAVA_LIBJVM" exit 1 fi - name: Build Tauri app uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # AppImage signing — three env vars work together: # SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool # APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively # SIGN_KEY appimagetool picks the key matching this fingerprint # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present. # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present. SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }} VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }} VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }} # DigiCert KeyLocker env vars consumed by smctl during signCommand SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} CI: true with: projectPath: ./frontend/editor tauriScript: npx tauri args: ${{ matrix.args }} updaterJsonKeepUniversal: true - name: Clear release GPG key from runner keyring (Linux) if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') env: RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }} run: | if [ -n "$RELEASE_GPG_FINGERPRINT" ]; then gpg --batch --yes --delete-secret-keys "$RELEASE_GPG_FINGERPRINT" || true gpg --batch --yes --delete-keys "$RELEASE_GPG_FINGERPRINT" || true fi # Verify the MSI (outer wrapper users download) AND the inner exe extracted # from it (what actually gets installed and what AV scans). We don't check # target/.../release/stirling-pdf.exe - that's Tauri's intermediate build # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh run: | $allSigned = $true # Check MSI installer (outer wrapper - what users download) $msiFiles = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*.msi" -Recurse -File if ($msiFiles.Count -eq 0) { Write-Host "[ERROR] No MSI found under target/" exit 1 } foreach ($msi in $msiFiles) { $sig = Get-AuthenticodeSignature -FilePath $msi.FullName Write-Host "MSI: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" if ($sig.Status -ne "Valid") { Write-Host "[ERROR] MSI is not signed" $allSigned = $false } } # Extract MSI and verify the inner exe (the file that actually gets installed). # This is the critical check - AV flags the installed exe at runtime. $msi = $msiFiles[0].FullName $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } $proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow if ($proc.ExitCode -eq 0) { $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 if ($innerExe) { $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName Write-Host "Inner EXE (from MSI): Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" if ($sig.Status -ne "Valid") { Write-Host "[ERROR] Inner exe extracted from MSI is NOT signed - AV will flag this at runtime" $allSigned = $false } } else { Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" $allSigned = $false } } else { Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" $allSigned = $false } if (-not $allSigned) { Write-Host "[ERROR] Signature verification failed" exit 1 } Write-Host "[SUCCESS] MSI and installed exe are properly signed" # Dump smctl log files on failure. Tauri's signCommand captures smctl output # but drops stderr when the command exits non-zero, making failures opaque. # The real errors live in smctl's log files - surface them here for debugging. - name: Dump smctl logs on failure if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} shell: pwsh run: | $logDir = "$env:USERPROFILE\.signingmanager\logs" if (Test-Path $logDir) { Get-ChildItem $logDir | ForEach-Object { Write-Host "=== $($_.FullName) ===" Get-Content $_.FullName -Tail 200 Write-Host "" } } else { Write-Host "smctl log directory not found at $logDir" } # Rename + Upload: use always() so artifacts are still collected when verify # fails - we need them to manually inspect what actually came out of the build. - name: Rename artifacts if: always() && steps.digicert-setup.conclusion != 'failure' shell: bash run: | # Absolute dist path so the cd below can't break the copy targets. DIST="$GITHUB_WORKSPACE/dist" mkdir -p "$DIST" cd ./frontend/editor/src-tauri/target echo "=== tauri bundle artifacts ===" find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \ -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.dmg" \ -o -name "*.app.tar.gz" -o -name "*.sig" \) 2>/dev/null | sort || true echo "==============================" # createUpdaterArtifacts:true signs the native installers in place; # each ships with a sibling .sig consumed by latest.json. if [ "${{ matrix.platform }}" = "windows-latest" ]; then find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then # DMG = manual install; .app.tar.gz (+ .sig) = updater payload. # Raw .app is intentionally not shipped (hundreds of MB of uncompressed input). find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; find . -name "*.app.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz" \; find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \; else # The raw .AppImage IS its updater payload (signed -> .AppImage.sig), # not a .tar.gz wrapper - that's only produced under v1Compatible. find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.deb.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb.sig" \; find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \; find . -name "*.rpm.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm.sig" \; find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \; find . -name "*.AppImage.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.sig" \; fi - name: Upload build artifacts if: always() && steps.digicert-setup.conclusion != 'failure' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Stirling-PDF-${{ matrix.name }} path: ./dist/* retention-days: 1 collect-and-release: needs: [pick, determine-matrix, build, build-jars] runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} permissions: contents: write steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit # Sparse-check out the verifier + pubkey before the artifact downloads # so the checkout cannot clobber ./artifacts. - name: Checkout updater verifier uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: | .github/scripts/verify-updater-signatures.py frontend/editor/src-tauri/tauri.conf.json sparse-checkout-cone-mode: false - name: Download all Tauri artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: Stirling-PDF-* path: ./artifacts/tauri - name: Download JAR artifact (default) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: jar path: ./artifacts/jars - name: Download JAR artifact (with login) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: jar-with-login path: ./artifacts/jars - name: Download JAR artifact (server only) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: jar-server path: ./artifacts/jars - name: Display structure of downloaded files run: ls -R ./artifacts # tauri-action only emits latest.json when it also publishes the release # (tagName/releaseId set). We publish separately via action-gh-release, # so build latest.json here from the per-platform .sig files. - name: Generate updater latest.json env: VERSION: ${{ needs.determine-matrix.outputs.version }} TAG: v${{ needs.determine-matrix.outputs.version }} REPO: ${{ github.repository }} run: | python3 - << 'PYEOF' import json, os, sys from pathlib import Path from datetime import datetime, timezone VERSION = os.environ['VERSION'] TAG = os.environ['TAG'] REPO = os.environ['REPO'] ART = Path('./artifacts/tauri') # Tauri updater looks up {os}-{arch}-{installer} (e.g. linux-x86_64-deb) # before bare {os}-{arch}, so per-format Linux keys let deb/rpm/appimage # each self-update from their matching file. macOS universal serves both # arches from the one .app.tar.gz. PLATFORM_MAP = [ { 'bundles': ['Stirling-PDF-linux-x86_64.deb'], 'targets': ['linux-x86_64-deb'], }, { 'bundles': ['Stirling-PDF-linux-x86_64.rpm'], 'targets': ['linux-x86_64-rpm'], }, { 'bundles': ['Stirling-PDF-linux-x86_64.AppImage'], 'targets': ['linux-x86_64-appimage'], }, { 'bundles': ['Stirling-PDF-windows-x86_64.msi'], 'targets': ['windows-x86_64-msi', 'windows-x86_64'], }, { 'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'], 'targets': ['darwin-x86_64', 'darwin-aarch64'], }, ] # rglob() because download-artifact varies layout: one artifact -> flat, # many -> nested under /. def find_signed(name): for bundle_path in sorted(ART.rglob(name)): sig_path = bundle_path.with_name(bundle_path.name + '.sig') if sig_path.exists(): return bundle_path, sig_path return None platforms = {} skipped = [] for entry in PLATFORM_MAP: picked = None for name in entry['bundles']: picked = find_signed(name) if picked: break if not picked: skipped.append( f"{entry['targets']} (no signed bundle among " f"{entry['bundles']} - TAURI_SIGNING_PRIVATE_KEY unset " f"or createUpdaterArtifacts disabled?)" ) continue bundle_path, sig_path = picked signature = sig_path.read_text(encoding='utf-8').strip() url = f"https://github.com/{REPO}/releases/download/{TAG}/{bundle_path.name}" for target in entry['targets']: platforms[target] = {'signature': signature, 'url': url} print(f"Added {entry['targets']} from {bundle_path.name}") if skipped: print("Skipped platforms:") for s in skipped: print(f" - {s}") if not platforms: print( "WARN: no signed updater bundles found - " "skipping latest.json generation" ) sys.exit(0) manifest = { 'version': VERSION, 'notes': f"See https://github.com/{REPO}/releases/tag/{TAG}", 'pub_date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'platforms': platforms, } out = Path('./artifacts/latest.json') out.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') print(f"Generated {out} with platforms: {sorted(platforms.keys())}") PYEOF - name: Upload merged artifacts for review uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: release-artifacts path: ./artifacts/ retention-days: 7 # Gate publish on valid updater sigs. Runs after the review upload (so # artifacts survive for debugging) and before action-gh-release. - name: Verify updater signatures run: | python3 -m pip install --quiet 'cryptography==44.0.0' python3 .github/scripts/verify-updater-signatures.py \ ./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json # workflow_dispatch path requires platform=='all' so a single-platform # dispatch can't overwrite an existing release's full latest.json with a # partial one (action-gh-release defaults overwrite_files:true). # release / V2-master always build the full matrix so no extra guard needed. # fail_on_unmatched_files makes a missing latest.json or installer fail loudly # instead of silently shipping a broken auto-update. - name: Upload binaries to Release if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} generate_release_notes: true fail_on_unmatched_files: true # Installers + updater payloads + manifest. .sig contents are embedded # in latest.json so the .sig files themselves are not uploaded. files: | ./artifacts/**/*.jar ./artifacts/**/*.msi ./artifacts/**/*.dmg ./artifacts/**/*.app.tar.gz ./artifacts/**/*.deb ./artifacts/**/*.rpm ./artifacts/**/*.AppImage ./artifacts/latest.json draft: false prerelease: false