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: release-policy: name: Resolve desktop release signing policy runs-on: ubuntu-latest outputs: platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }} macos_signing: ${{ steps.policy.outputs.macos_signing }} windows_signing: ${{ steps.policy.outputs.windows_signing }} artifact_label: ${{ steps.policy.outputs.artifact_label }} warning_required: ${{ steps.policy.outputs.warning_required }} defaults: run: working-directory: frontend steps: - name: Checkout release source uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22.13" - name: Resolve version-scoped signing policy id: policy shell: bash run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}" macos-release-candidate: name: macOS release candidate needs: release-policy runs-on: macos-latest defaults: run: working-directory: frontend env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} RELEASE_BUILD: "true" DESKTOP_RELEASE_PLATFORM: macos DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} REQUIRE_UPDATER_SIGNING: "true" REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }} ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }} 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 "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 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 Apple-signed and notarized Universal macOS artifacts if: needs.release-policy.outputs.platform_signing_mode == 'signed' run: npm run desktop:build:macos-release -- --ci - name: Build ad-hoc Universal macOS artifacts if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' run: npm run desktop:build:macos-unsigned-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 "macOS app, updater artifact/signature, and DMG are all required." exit 1 fi codesign --verify --deep --strict --verbose=2 "${app}" if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then spctl --assess --type execute --verbose=2 "${app}" xcrun stapler validate "${app}" xcrun stapler validate "${dmg}" else signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)" if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then echo "The macOS unsigned-platform exception must produce an ad-hoc signed app." exit 1 fi fi stage="src-tauri/target/desktop-release-macos" mkdir -p "${stage}" if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz" dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg" cp "${artifact}" "${stage}/${artifact_name}" cp "${artifact}.sig" "${stage}/${artifact_name}.sig" cp "${dmg}" "${stage}/${dmg_name}" cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt" else cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/" fi - name: Upload macOS candidate artifacts uses: actions/upload-artifact@v4 with: name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-macos/* if-no-files-found: error windows-release-candidate: name: Windows release candidate needs: release-policy runs-on: windows-latest defaults: run: working-directory: frontend env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} RELEASE_BUILD: "true" DESKTOP_RELEASE_PLATFORM: windows DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} REQUIRE_UPDATER_SIGNING: "true" REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }} ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }} 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 "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 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 if: needs.release-policy.outputs.platform_signing_mode == 'signed' 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 if: needs.release-policy.outputs.platform_signing_mode == 'signed' 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 if: needs.release-policy.outputs.platform_signing_mode == 'signed' timeout-minutes: 30 run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci - name: Build unsigned Windows NSIS artifacts if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' timeout-minutes: 30 run: npm run desktop:build:windows-release -- --ci - name: Verify platform signing mode 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 ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") { 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)" } } elseif ($signature.Status -ne "NotSigned") { throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)." } } $stage = "src-tauri/target/desktop-release-windows" New-Item -ItemType Directory -Path $stage -Force | Out-Null if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") { $installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe' $updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip' Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName) Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName) Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig") Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt") } else { 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 Windows candidate artifacts uses: actions/upload-artifact@v4 with: name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-windows/* if-no-files-found: error aggregate-release-candidate: name: Verify combined desktop release directory needs: - release-policy - 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 macOS candidate artifacts uses: actions/download-artifact@v4 with: name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-input/macos - name: Download Windows candidate artifacts uses: actions/download-artifact@v4 with: name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-input/windows - name: Create desktop release provenance run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json - 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 include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json) notes_args=() if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f) if [[ ${#warnings[@]} -ne 1 ]]; then echo "The unsigned-platform release warning is required." exit 1 fi include_args+=(--include "${warnings[0]}") notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.") fi npm run desktop:update-feed:create -- \ --artifact "${mac_artifacts[0]}" \ --platform-artifact "windows-x86_64=${windows_artifacts[0]}" \ "${include_args[@]}" \ "${notes_args[@]}" \ --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 --require-provenance - name: Upload verified desktop release directory uses: actions/upload-artifact@v4 with: name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-feed/* if-no-files-found: error