name: Release build artifacts on: workflow_call: inputs: version: required: true type: string macos_bundles: required: false type: string default: "" upload_macos_dmg: required: true type: boolean require_windows_authenticode: required: false type: boolean default: true env: # Bump this when Tauri/Rust target artifacts capture stale absolute paths. RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria # The production Vite bundle can exceed Node's default ~2GB heap on # macOS arm64 runners while Tauri runs beforeBuildCommand. NODE_OPTIONS: --max-old-space-size=4096 jobs: build: name: Build (${{ matrix.arch }}) runs-on: macos-15 strategy: fail-fast: true matrix: include: - arch: aarch64 target: aarch64-apple-darwin - arch: x86_64 target: x86_64-apple-darwin steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: version: 10 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" cache: "pnpm" - name: Setup Bun (required for bundle-qmd.sh) uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: targets: ${{ matrix.target }} - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git src-tauri/target key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: | ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}- - name: Install frontend dependencies run: pnpm install --frozen-lockfile - name: Clear cached bundle artifacts run: | rm -rf src-tauri/target/${{ matrix.target }}/release/bundle - name: Set version run: | VERSION="${{ inputs.version }}" jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml - name: Import Apple Developer certificate into keychain env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | CERT_PATH="$RUNNER_TEMP/apple_cert.p12" KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db" KEYCHAIN_PASSWORD="$(uuidgen)" echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security import "$CERT_PATH" -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: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - name: Validate telemetry env env: VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | python3 <<'PY' import os import re import sys from urllib.parse import urlparse DISALLOWED_PLACEHOLDERS = { "", "-", "_", "false", "true", "null", "undefined", "none", "disabled", } def normalize(name: str) -> str: value = os.getenv(name, "").strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): value = value[1:-1].strip() return value def normalize_http_like(value: str) -> str: if "://" in value: return value return f"https://{value}" def normalize_hostname(hostname: str) -> str: normalized = hostname.strip().rstrip('.').lower() if normalized.startswith('[') and normalized.endswith(']'): normalized = normalized[1:-1] return normalized def is_ip_address(hostname: str) -> bool: if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname): return all(0 <= int(part) <= 255 for part in hostname.split('.')) return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None def is_allowed_hostname(hostname: str) -> bool: normalized = normalize_hostname(hostname) if not normalized or normalized in DISALLOWED_PLACEHOLDERS: return False if normalized == 'localhost': return True return '.' in normalized or is_ip_address(normalized) def is_http_url(value: str) -> bool: parsed = urlparse(normalize_http_like(value)) return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "") values = { name: normalize(name) for name in ( "VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_KEY", "VITE_POSTHOG_HOST", ) } errors = [] for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"): value = values[name] if value.lower() in DISALLOWED_PLACEHOLDERS: errors.append(f"{name} must be set to a real value, not a placeholder") elif not is_http_url(value): errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host") if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS: errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder") if errors: print("Telemetry env validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) raise SystemExit(1) print("Telemetry env validation passed.") PY - name: Build Tauri app (with signing + notarization) env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_RELEASE: ${{ inputs.version }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | MACOS_BUNDLES="${{ inputs.macos_bundles }}" if [ -n "$MACOS_BUNDLES" ]; then pnpm tauri build --target ${{ matrix.target }} --bundles "$MACOS_BUNDLES" else pnpm tauri build --target ${{ matrix.target }} fi - name: Upload .dmg if: ${{ inputs.upload_macos_dmg }} uses: actions/upload-artifact@v4 with: name: dmg-${{ matrix.arch }} path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg retention-days: 1 - name: Upload updater artifacts (.tar.gz + .sig) uses: actions/upload-artifact@v4 with: name: updater-${{ matrix.arch }} path: | src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig retention-days: 1 build-linux: name: Build (linux-x86_64) runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - name: Install Tauri Linux system dependencies run: | sudo apt-get update sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ libsoup-3.0-dev \ libxdo-dev \ libssl-dev \ libayatana-appindicator3-dev \ fcitx5-frontend-gtk3 \ libfuse2 \ librsvg2-dev \ curl \ wget \ patchelf \ build-essential \ file \ rpm - name: Setup pnpm uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: version: 10 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" cache: "pnpm" - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: targets: x86_64-unknown-linux-gnu - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git src-tauri/target key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: | ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}- - name: Install frontend dependencies run: pnpm install --frozen-lockfile - name: Clear cached bundle artifacts run: | rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle - name: Set version run: | VERSION="${{ inputs.version }}" jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml - name: Build Tauri app (Linux bundles) env: APPIMAGE_EXTRACT_AND_RUN: 1 TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_RELEASE: ${{ inputs.version }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage - name: Validate Linux bundles run: | shopt -s nullglob appimages=( src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage ) installers=( "${appimages[@]}" src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm ) signatures=( src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig ) if [ ${#appimages[@]} -eq 0 ]; then echo "::error::Linux build produced no AppImage bundle." exit 1 fi if [ ${#installers[@]} -eq 0 ]; then echo "::error::Linux build produced no AppImage, deb or rpm bundle." exit 1 fi if [ ${#signatures[@]} -eq 0 ]; then echo "::error::Linux build produced no updater signature (.sig) artifact." exit 1 fi - name: Upload Linux bundles uses: actions/upload-artifact@v4 with: name: linux-x86_64-bundles path: | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig if-no-files-found: error retention-days: 1 build-windows: name: Build (windows-x86_64) runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: version: 10 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" cache: "pnpm" - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: targets: x86_64-pc-windows-msvc - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~\.cargo\registry ~\.cargo\git src-tauri\target key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: | ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}- - name: Cache Tauri Windows tools uses: actions/cache@v4 with: path: ~\AppData\Local\tauri key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3 - name: Prefetch Tauri NSIS toolchain shell: pwsh run: ./.github/scripts/prefetch-tauri-nsis.ps1 - name: Install frontend dependencies run: pnpm install --frozen-lockfile - name: Clear cached Windows bundle artifacts shell: pwsh run: | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle" - name: Set version shell: pwsh run: | $version = "${{ inputs.version }}" $tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json $tauri.version = $version $tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json" (Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml" - name: Validate Windows release env id: windows-signing shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }} WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} REQUIRE_WINDOWS_AUTHENTICODE: ${{ inputs.require_windows_authenticode }} run: | for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do if [ -z "${!name}" ]; then echo "::error::$name is required to build signed Windows updater artifacts." exit 1 fi done has_certificate=false has_password=false if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE" ]; then has_certificate=true fi if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then has_password=true fi if [ "$has_certificate" != "$has_password" ]; then echo "::error::Windows Authenticode signing is partially configured. Set both certificate and password secrets, or remove both for unsigned alpha Windows artifacts." exit 1 fi if [ "$has_certificate" = "true" ]; then echo "authenticode_available=true" >> "$GITHUB_OUTPUT" elif [ "$REQUIRE_WINDOWS_AUTHENTICODE" = "true" ]; then echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers." exit 1 else echo "::warning::Windows Authenticode certificate secrets are not configured. Building alpha Windows artifacts without Authenticode signatures; Tauri updater signatures are still required." echo "authenticode_available=false" >> "$GITHUB_OUTPUT" fi - name: Prepare Windows Authenticode signing if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }} shell: pwsh env: WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }} WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }} WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }} WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} run: ./.github/scripts/configure-windows-authenticode.ps1 - name: Build Tauri app (Windows bundles) shell: pwsh env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_RELEASE: ${{ inputs.version }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | if ("${{ steps.windows-signing.outputs.authenticode_available }}" -eq "true") { pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json } else { pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis } - name: Validate Windows Authenticode signatures if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }} shell: pwsh run: | $expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) { throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step." } $expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant() $paths = @() $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue $paths = @($paths | Sort-Object FullName -Unique) if ($paths.Count -eq 0) { throw "No Windows executable or installer artifacts found to verify." } foreach ($path in $paths) { $signature = Get-AuthenticodeSignature -FilePath $path.FullName if ($signature.Status -ne "Valid") { throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)" } if ($null -eq $signature.SignerCertificate) { throw "Missing signer certificate for $($path.FullName)." } $actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant() if ($actualThumbprint -ne $expectedThumbprint) { throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint" } Write-Host "Authenticode signature OK: $($path.FullName)" } - name: Validate Windows bundles shell: bash run: | shopt -s nullglob installers=( src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi ) signatures=( src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig ) if [ ${#installers[@]} -eq 0 ]; then echo "::error::Windows build produced no installable NSIS or MSI bundle." exit 1 fi for installer in "${installers[@]}"; do if [[ "$(basename "$installer")" != *"${{ inputs.version }}"* ]]; then echo "::error::Windows build produced an installer for a different version: $(basename "$installer")" exit 1 fi done if [ ${#signatures[@]} -eq 0 ]; then echo "::error::Windows build produced no updater signature (.sig) artifact." exit 1 fi - name: Upload Windows bundles uses: actions/upload-artifact@v4 with: name: windows-x86_64-bundles path: | src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig if-no-files-found: error retention-days: 1