name: Desktop Release Candidate on: workflow_dispatch: inputs: artifact_base_url: description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/" required: true type: string push: tags: - "v*" permissions: contents: read concurrency: group: desktop-release-candidate-${{ github.ref }} cancel-in-progress: false jobs: macos-release-candidate: name: Signed macOS release candidate runs-on: macos-latest defaults: run: working-directory: frontend env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} RELEASE_BUILD: "true" REQUIRE_DESKTOP_SIGNING: "true" DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} steps: - name: Checkout release source uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json - name: Enforce release tag context shell: bash run: | if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then echo "Signed desktop release candidates must run from a vX.Y.Z tag." exit 1 fi expected_tag="v$(node -p "require('./package.json').version")" if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}." exit 1 fi - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin,x86_64-apple-darwin - name: Install dependencies run: npm ci - name: Audit dependencies run: npm audit - name: Check release build metadata and signing environment run: npm run release:env:check - name: Check signed desktop release readiness run: npm run desktop:release-readiness:check - name: Check synchronized client version run: npm run version:check - name: Check runtime boundary run: npm run runtime:check - name: Check desktop release and security gate run: npm run desktop:release:check - name: Check UI contract run: npm run ui:contract - name: Type check run: npm run type-check - name: Unit tests run: npm run test:unit - name: Build Web artifact run: npm run build - name: Build signed and notarized Universal macOS artifacts run: npm run desktop:build:macos-release -- --ci - name: Verify and stage macOS artifacts shell: bash run: | app="$(find src-tauri/target -path '*/release/bundle/macos/*.app' -print -quit)" artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)" dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)" if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then echo "Signed macOS app, updater artifact/signature, and DMG are all required." exit 1 fi codesign --verify --deep --strict --verbose=2 "${app}" spctl --assess --type execute --verbose=2 "${app}" xcrun stapler validate "${app}" xcrun stapler validate "${dmg}" stage="src-tauri/target/desktop-release-macos" mkdir -p "${stage}" cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/" - name: Upload signed macOS candidate artifacts uses: actions/upload-artifact@v4 with: name: ctms-desktop-macos-${{ github.ref_name }} path: frontend/src-tauri/target/desktop-release-macos/* if-no-files-found: error windows-release-candidate: name: Signed Windows release candidate runs-on: windows-latest defaults: run: working-directory: frontend env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} RELEASE_BUILD: "true" REQUIRE_WINDOWS_SIGNING: "true" DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} steps: - name: Checkout release source uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json - name: Enforce release tag context shell: pwsh run: | if ($env:GITHUB_REF_TYPE -ne "tag") { throw "Signed desktop release candidates must run from a vX.Y.Z tag." } $expectedTag = "v$(node -p "require('./package.json').version")" if ($env:GITHUB_REF_NAME -ne $expectedTag) { throw "Release tag $env:GITHUB_REF_NAME does not match package version $expectedTag." } - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Install dependencies run: npm ci - name: Audit dependencies run: npm audit - name: Check release build metadata and signing environment run: npm run release:env:check - name: Check signed desktop release readiness run: npm run desktop:release-readiness:check - name: Check synchronized client version run: npm run version:check - name: Check runtime boundary run: npm run runtime:check - name: Check desktop release and security gate run: npm run desktop:release:check - name: Check UI contract run: npm run ui:contract - name: Type check run: npm run type-check - name: Unit tests run: npm run test:unit - name: Build Web artifact run: npm run build - name: Import Windows code-signing certificate shell: pwsh run: | $pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx" $encodedCertificate = $env:WINDOWS_CERTIFICATE ` -replace "-----BEGIN [^-]+-----", "" ` -replace "-----END [^-]+-----", "" ` -replace "\s", "" [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($encodedCertificate)) $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force $importedCertificates = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\CurrentUser\My" -Password $password -Exportable:$false $certificate = $importedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1 if (-not $certificate -or -not $certificate.HasPrivateKey) { throw "The imported Windows code-signing certificate is missing its private key." } $now = Get-Date if ($certificate.NotBefore -gt $now -or $certificate.NotAfter -le $now) { throw "The Windows code-signing certificate is not currently valid." } $codeSigningEku = $certificate.EnhancedKeyUsageList | Where-Object { $_.ObjectId.Value -eq "1.3.6.1.5.5.7.3.3" } if (-not $codeSigningEku) { throw "The Windows certificate does not contain the Code Signing enhanced key usage." } "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append Remove-Item $pfxPath -Force - name: Write signed Windows Tauri config shell: pwsh run: | $config = @{ bundle = @{ windows = @{ certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT digestAlgorithm = "sha256" timestampUrl = $env:WINDOWS_TIMESTAMP_URL tsp = $true } } } | ConvertTo-Json -Depth 8 Set-Content -Path "tauri.windows.release.conf.json" -Value $config -Encoding utf8 Get-Content "tauri.windows.release.conf.json" - name: Build signed Windows NSIS artifacts timeout-minutes: 30 run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci - name: Verify Authenticode and stage Windows artifacts shell: pwsh run: | $bundleDir = "src-tauri/target/release/bundle/nsis" $installers = @(Get-ChildItem -Path $bundleDir -Filter "*.exe" -File) $updaters = @(Get-ChildItem -Path $bundleDir -Filter "*.nsis.zip" -File) $appExecutables = @(Get-ChildItem -Path "src-tauri/target/release" -Filter "*.exe" -File) if ($installers.Count -ne 1 -or $updaters.Count -ne 1 -or $appExecutables.Count -eq 0) { throw "Exactly one NSIS installer/updater and at least one application executable are required." } $signaturePath = "$($updaters[0].FullName).sig" if (-not (Test-Path $signaturePath) -or (Get-Item $signaturePath).Length -eq 0) { throw "The Windows updater signature is missing or empty." } foreach ($executable in @($appExecutables + $installers)) { $signature = Get-AuthenticodeSignature -FilePath $executable.FullName if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)" } } $stage = "src-tauri/target/desktop-release-windows" New-Item -ItemType Directory -Path $stage -Force | Out-Null Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage - name: Remove Windows signing certificate if: always() shell: pwsh run: | if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { Remove-Item "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue } Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue - name: Upload signed Windows candidate artifacts uses: actions/upload-artifact@v4 with: name: ctms-desktop-windows-${{ github.ref_name }} path: frontend/src-tauri/target/desktop-release-windows/* if-no-files-found: error aggregate-release-candidate: name: Verify combined desktop release directory needs: - macos-release-candidate - windows-release-candidate runs-on: ubuntu-latest defaults: run: working-directory: frontend env: DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }} steps: - name: Checkout release source uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22.13" - name: Download signed macOS candidate artifacts uses: actions/download-artifact@v4 with: name: ctms-desktop-macos-${{ github.ref_name }} path: frontend/src-tauri/target/desktop-release-input/macos - name: Download signed Windows candidate artifacts uses: actions/download-artifact@v4 with: name: ctms-desktop-windows-${{ github.ref_name }} path: frontend/src-tauri/target/desktop-release-input/windows - name: Create combined desktop update feed shell: bash run: | mapfile -t mac_artifacts < <(find src-tauri/target/desktop-release-input/macos -name '*.app.tar.gz' -type f) mapfile -t dmgs < <(find src-tauri/target/desktop-release-input/macos -name '*.dmg' -type f) mapfile -t windows_artifacts < <(find src-tauri/target/desktop-release-input/windows -name '*.nsis.zip' -type f) mapfile -t installers < <(find src-tauri/target/desktop-release-input/windows -name '*.exe' -type f) if [[ ${#mac_artifacts[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ${#windows_artifacts[@]} -ne 1 || ${#installers[@]} -ne 1 ]]; then echo "Exactly one macOS updater/DMG and one Windows updater/installer are required." exit 1 fi npm run desktop:update-feed:create -- \ --artifact "${mac_artifacts[0]}" \ --platform-artifact "windows-x86_64=${windows_artifacts[0]}" \ --include "${dmgs[0]}" \ --include "${installers[0]}" \ --output-dir src-tauri/target/desktop-release-feed \ --base-url "${DESKTOP_UPDATE_BASE_URL}" - name: Verify combined desktop update feed run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 - name: Upload verified desktop release directory uses: actions/upload-artifact@v4 with: name: ctms-desktop-release-${{ github.ref_name }} path: frontend/src-tauri/target/desktop-release-feed/* if-no-files-found: error