From fd819165862f6b8e0b242888a8b90644fc624ca7 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:23:48 +0000 Subject: [PATCH] Add signed Windows Tauri release flow --- .github/workflows/desktop-build.yml | 159 +++++++------- .github/workflows/release.yml | 196 +++++++++++++++++- README.md | 63 ++++++ .../src-tauri/resources/windows/README.md | 12 +- scripts/ci/_common.sh | 179 ++++++++++++++++ scripts/ci/desktop-windows-pr.sh | 55 +---- scripts/ci/desktop-windows-release.sh | 139 +++++++++++++ scripts/ci/latest-json.sh | 36 +++- scripts/ci/release-verification-guide.sh | 8 +- scripts/ci/signed-release-rehearsal.sh | 5 + scripts/ci/verify-release-artifacts.sh | 3 +- 11 files changed, 711 insertions(+), 144 deletions(-) create mode 100755 scripts/ci/desktop-windows-release.sh diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 7fb34f33..2c6eabf6 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -220,7 +220,10 @@ jobs: run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos build-windows: - runs-on: windows-latest + runs-on: windows-2025 + environment: windows-signing + env: + MAPLE_WINDOWS_AUTHENTICODE_SUBJECT: ${{ secrets.AZURE_ARTIFACT_SIGNING_EXPECTED_SUBJECT }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -234,7 +237,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable with: - toolchain: 1.95.0 + toolchain: 1.88.0 - name: Install sccache shell: bash @@ -266,102 +269,106 @@ jobs: ${{ runner.os }}-sccache-windows- ${{ runner.os }}-sccache- - - name: Provide ONNX Runtime (Windows) + - name: Build Windows app executable shell: bash - run: | - ./frontend/src-tauri/scripts/provide-windows-onnxruntime.sh >> "$GITHUB_ENV" + run: ./scripts/ci/desktop-windows-release.sh build - - name: Stage Windows runtime DLLs for bundling - shell: pwsh - run: | - # maple.exe links onnxruntime.dll by ordinal; without these next to the - # exe the loader binds to the OS Windows-ML onnxruntime.dll (v1.17) and - # TTS hangs at Session::builder. See resources/windows/README.md. - $dest = "frontend/src-tauri/resources/windows" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - # ONNX Runtime 1.22.0 (already downloaded + SHA-verified; path in env) - Copy-Item "$env:ORT_DYLIB_PATH" (Join-Path $dest "onnxruntime.dll") -Force - # MSVC C++ runtime DLLs onnxruntime.dll depends on. Find a source dir - # holding all four, independent of the runner's VS year/edition: prefer - # the versioned redist (located via vswhere), fall back to System32. - $crtDlls = 'VCRUNTIME140.dll','VCRUNTIME140_1.dll','MSVCP140.dll','MSVCP140_1.dll' - $candidates = @() - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path $vswhere) { - $vs = & $vswhere -latest -products * -property installationPath - if ($vs) { - $candidates += Get-ChildItem (Join-Path $vs 'VC\Redist\MSVC\*\x64') -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^Microsoft\.VC\d+\.CRT$' } | ForEach-Object FullName - } - } - $candidates += "$env:WINDIR\System32" - $src = $candidates | Where-Object { $d = $_; -not ($crtDlls | Where-Object { -not (Test-Path (Join-Path $d $_)) }) } | Select-Object -First 1 - if (-not $src) { throw "No directory has all CRT DLLs. Searched: $($candidates -join '; ')" } - Write-Host "CRT source: $src" - foreach ($dll in $crtDlls) { Copy-Item (Join-Path $src $dll) (Join-Path $dest $dll) -Force } - Get-ChildItem $dest | Select-Object Name, Length - - - name: Install frontend dependencies - working-directory: ./frontend - run: bun install --frozen-lockfile --ignore-scripts - - - name: Configure sccache + - name: Azure login for Artifact Signing + uses: azure/login@2dd0bbf4064d5a1812889dc200bb8eed2597f82a # was v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows app executable + uses: Azure/artifact-signing-action@c0ae2c1d0c1847ab81ac0ab8521bee597cfedd30 # was v2 + with: + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ github.workspace }}\frontend\src-tauri\target\release\maple.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Bundle Windows installer + shell: bash + run: ./scripts/ci/desktop-windows-release.sh bundle + + - name: Resolve Windows installer paths shell: bash run: | - { - echo "RUSTC_WRAPPER=sccache" - echo "SCCACHE_CACHE_SIZE=2G" - } >> "$GITHUB_ENV" + set -euo pipefail + source scripts/ci/_common.sh + setup_exe="$(windows_release_setup_exe_required)" + echo "MAPLE_WINDOWS_SETUP_EXE_REL=$(repo_relative_path "${setup_exe}")" >> "${GITHUB_ENV}" + echo "MAPLE_WINDOWS_SETUP_EXE_WIN=$(to_windows_path "${setup_exe}")" >> "${GITHUB_ENV}" - - name: Build Tauri App (Windows) - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # was v0 + - name: Sign Windows installer + uses: Azure/artifact-signing-action@c0ae2c1d0c1847ab81ac0ab8521bee597cfedd30 # was v2 + with: + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ env.MAPLE_WINDOWS_SETUP_EXE_WIN }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Finalize Windows updater signatures and checksums + shell: bash + run: ./scripts/ci/desktop-windows-release.sh finalize env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VITE_OPEN_SECRET_API_URL: https://enclave.trymaple.ai - VITE_MAPLE_BILLING_API_URL: https://billing.opensecret.cloud - VITE_CLIENT_ID: ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 - with: - projectPath: './frontend' - name: Show sccache stats + if: always() run: sccache --show-stats - name: Collect Windows release checksums shell: bash run: | - set -euo pipefail - source scripts/ci/_common.sh - - repro_dir="${TAURI_DIR}/target/reproducibility" - mkdir -p "${repro_dir}" - - windows_artifacts=() - while IFS= read -r -d '' file; do - windows_artifacts+=("${file}") - done < <(find "${TAURI_DIR}/target/release/bundle/nsis" -type f \( \ - -name '*.exe' -o \ - -name '*.nsis.zip' -o \ - -name '*.nsis.zip.sig' \ - \) -print0 | LC_ALL=C sort -z) - - if [ "${#windows_artifacts[@]}" -eq 0 ]; then - echo "No Windows desktop artifacts found." >&2 - exit 1 - fi + ./scripts/ci/attestation-manifest.sh \ + desktop-windows-artifacts.sha256 \ + frontend/src-tauri/target/reproducibility/desktop-release-windows-final.sha256 \ + frontend/src-tauri/target/reproducibility/desktop-release-windows-runtime-dlls.sha256 + cat desktop-windows-artifacts.sha256 - write_sha256_manifest "${repro_dir}/desktop-release-windows-final.sha256" "${windows_artifacts[@]}" - print_file_hashes "${windows_artifacts[@]}" + - name: Attest Windows release artifacts + # Keep uploads/verifiers running if GitHub token policy rejects artifact attestation. + continue-on-error: true + uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb # was v4 + with: + subject-checksums: desktop-windows-artifacts.sha256 - name: Upload Windows Build uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 with: name: maple-windows-x64 path: | - frontend/src-tauri/target/release/bundle/nsis/*.exe - frontend/src-tauri/target/release/bundle/nsis/*.nsis.zip - frontend/src-tauri/target/release/bundle/nsis/*.nsis.zip.sig + ${{ env.MAPLE_WINDOWS_SETUP_EXE_REL }} + ${{ env.MAPLE_WINDOWS_SETUP_EXE_REL }}.sig frontend/src-tauri/target/reproducibility/desktop-release-windows-*.sha256 retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2807bf7..54a13283 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,6 +165,171 @@ jobs: gh release upload "${RELEASE_TAG}" "${files[@]}" --clobber + build-windows: + needs: create-release + runs-on: windows-2025 + environment: windows-signing + env: + MAPLE_WINDOWS_AUTHENTICODE_SUBJECT: ${{ secrets.AZURE_ARTIFACT_SIGNING_EXPECTED_SUBJECT }} + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # was v1 + with: + bun-version: 1.3.5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable + with: + toolchain: 1.88.0 + + - name: Install sccache + shell: bash + run: | + SCCACHE_VERSION=0.8.2 + SCCACHE_SHA256="de5e9f66bb8a6bbdf0e28cb8a086a8d12699af796bf70bcd9dc40d80715bf9b8" + SCCACHE_ARCHIVE="sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc.tar.gz" + SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${SCCACHE_ARCHIVE}" + ( + cd "$RUNNER_TEMP" + curl --fail --location --show-error --silent "$SCCACHE_URL" --output "$SCCACHE_ARCHIVE" + echo "${SCCACHE_SHA256} ${SCCACHE_ARCHIVE}" | sha256sum --check - + tar xzf "$SCCACHE_ARCHIVE" + ) + SCCACHE_BIN_DIR="$RUNNER_TEMP/sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc" + echo "$SCCACHE_BIN_DIR" >> "$GITHUB_PATH" + "$SCCACHE_BIN_DIR/sccache.exe" --version + + - name: Cache sccache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 + with: + path: ~\AppData\Local\Mozilla\sccache + key: ${{ runner.os }}-sccache-windows-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-sccache-windows-release- + + - name: Build Windows app executable + shell: bash + run: ./scripts/ci/desktop-windows-release.sh build + + - name: Azure login for Artifact Signing + uses: azure/login@2dd0bbf4064d5a1812889dc200bb8eed2597f82a # was v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows app executable + uses: Azure/artifact-signing-action@c0ae2c1d0c1847ab81ac0ab8521bee597cfedd30 # was v2 + with: + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ github.workspace }}\frontend\src-tauri\target\release\maple.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Bundle Windows installer + shell: bash + run: ./scripts/ci/desktop-windows-release.sh bundle + + - name: Resolve Windows installer paths + shell: bash + run: | + set -euo pipefail + source scripts/ci/_common.sh + setup_exe="$(windows_release_setup_exe_required)" + echo "MAPLE_WINDOWS_SETUP_EXE_REL=$(repo_relative_path "${setup_exe}")" >> "${GITHUB_ENV}" + echo "MAPLE_WINDOWS_SETUP_EXE_WIN=$(to_windows_path "${setup_exe}")" >> "${GITHUB_ENV}" + + - name: Sign Windows installer + uses: Azure/artifact-signing-action@c0ae2c1d0c1847ab81ac0ab8521bee597cfedd30 # was v2 + with: + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ env.MAPLE_WINDOWS_SETUP_EXE_WIN }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Finalize Windows updater signatures and checksums + shell: bash + run: ./scripts/ci/desktop-windows-release.sh finalize + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + + - name: Show sccache stats + if: always() + run: sccache --show-stats + + - name: Collect Windows release checksums + shell: bash + run: | + ./scripts/ci/attestation-manifest.sh \ + windows-release-artifacts.sha256 \ + frontend/src-tauri/target/reproducibility/desktop-release-windows-final.sha256 \ + frontend/src-tauri/target/reproducibility/desktop-release-windows-runtime-dlls.sha256 + cat windows-release-artifacts.sha256 + + - name: Attest Windows release artifacts + # Keep release uploads running if GitHub token policy rejects artifact attestation. + continue-on-error: true + uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb # was v4 + with: + subject-checksums: windows-release-artifacts.sha256 + + - name: Upload Windows release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: bash + run: | + set -euo pipefail + files=("${MAPLE_WINDOWS_SETUP_EXE_REL}" "${MAPLE_WINDOWS_SETUP_EXE_REL}.sig") + while IFS= read -r -d '' file; do + files+=("${file}") + done < <(find frontend/src-tauri -type f \( \ + -path '*/target/reproducibility/desktop-release-windows-*.sha256' \ + \) -print0 | LC_ALL=C sort -z) + + if [ "${#files[@]}" -eq 0 ]; then + echo "No Windows release artifacts found." >&2 + exit 1 + fi + + gh release upload "${RELEASE_TAG}" "${files[@]}" --clobber + build-android: needs: create-release runs-on: ubuntu-latest-8-cores @@ -519,14 +684,41 @@ jobs: - name: Verify macOS desktop release artifact reproducibility proofs run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos + verify-windows-desktop-release-artifacts: + needs: build-windows + runs-on: ubuntu-latest-8-cores + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # was v22 + with: + github-token: "" + + - name: Download release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + mkdir -p artifacts + gh release download "${RELEASE_TAG}" -D artifacts + + - name: Verify Windows desktop release artifact reproducibility proofs + run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts windows + verify-desktop-release-artifacts: needs: - verify-linux-desktop-release-artifacts - verify-macos-desktop-release-artifacts + - verify-windows-desktop-release-artifacts runs-on: ubuntu-latest steps: - name: Verify desktop release artifact checks completed - run: echo "Desktop release artifact verification completed for Linux and macOS." + run: echo "Desktop release artifact verification completed for Linux, macOS, and Windows." update-latest-json: needs: verify-desktop-release-artifacts @@ -632,7 +824,7 @@ jobs: gh release download "${RELEASE_TAG}" -D artifacts - name: Verify release artifact reproducibility proofs - run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos ios web latest-json + run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos windows ios web latest-json env: MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY: "1" diff --git a/README.md b/README.md index ac1c9dd5..d6b95e47 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,69 @@ For proper macOS builds and notarization, you need to set up the following GitHu 5. `APPLE_PASSWORD` - Your Apple Developer account password or app-specific password 6. `APPLE_TEAM_ID` - Your Apple Developer team ID +#### Azure Artifact Signing (for Windows builds) +Windows release builds use Microsoft Azure Artifact Signing through GitHub +Actions OIDC. No Azure client secret is required. + +Add these GitHub Actions secrets: + +| GitHub secret | Source | +|---------------|--------| +| `AZURE_CLIENT_ID` | Microsoft Entra app registration "Application (client) ID". | +| `AZURE_TENANT_ID` | Microsoft Entra "Directory (tenant) ID". | +| `AZURE_SUBSCRIPTION_ID` | Azure subscription ID that contains the Artifact Signing account. | +| `AZURE_ARTIFACT_SIGNING_ENDPOINT` | Artifact Signing account endpoint. | +| `AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME` | Artifact Signing account name. | +| `AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME` | Certificate profile name under the Artifact Signing account. | +| `AZURE_ARTIFACT_SIGNING_EXPECTED_SUBJECT` | Expected signer certificate Subject DN for that certificate profile. | + +The Entra application must also have a federated credential for this GitHub +environment: + +```text +repo:OpenSecretCloud/Maple:environment:windows-signing +``` + +In the Azure portal, add it under Microsoft Entra ID -> App registrations -> +the CI application -> Certificates & secrets -> Federated credentials. Use: + +- Organization: `OpenSecretCloud` +- Repository: `Maple` +- Entity type: `Environment` +- Environment name: `windows-signing` +- Audience: `api://AzureADTokenExchange` + +If the portal asks for raw values instead of GitHub-specific fields, use: + +- Issuer: `https://token.actions.githubusercontent.com` +- Subject: `repo:OpenSecretCloud/Maple:environment:windows-signing` +- Audience: `api://AzureADTokenExchange` + +Assign the same Entra application/service principal the `Artifact Signing +Certificate Profile Signer` role on the Artifact Signing account, resource +group, or subscription. The role assignment is what authorizes the OIDC-auth'd +GitHub runner to sign with the certificate profile. + +The expected signer subject should match the X.509 signer certificate subject +reported by `Get-AuthenticodeSignature`. It is usually visible on the Artifact +Signing certificate profile as the Subject DN derived from the completed identity +validation, for example `CN=Example Corp, O=Example Corp, L=City, S=State, C=US`. +Do not pin the leaf certificate thumbprint for this check; Artifact Signing +manages certificate lifecycle and can issue rotated certificates for the same +profile identity. + +The Windows release job builds `maple.exe` first, Authenticode-signs it, bundles +the NSIS installer from that signed executable, Authenticode-signs the installer, +then creates the Tauri updater `.sig` for the final signed installer bytes. This +ordering is required because Authenticode signing changes the file being signed; +the Tauri updater signature must be generated after the final Windows installer +signature is applied. + +Windows release verification currently checks the final signed installer bytes, +the final Tauri updater signature, and the pinned runtime DLL proofs. It does not +yet try to canonicalize Authenticode-signed Windows binaries back to an unsigned +baseline. + ### To Create a Release #### Version Management diff --git a/frontend/src-tauri/resources/windows/README.md b/frontend/src-tauri/resources/windows/README.md index f7aa8c29..d9b6bd73 100644 --- a/frontend/src-tauri/resources/windows/README.md +++ b/frontend/src-tauri/resources/windows/README.md @@ -45,8 +45,9 @@ must be staged here **before `bun tauri build`** on Windows. ## CI staging -The Windows PR workflow stages these automatically through -`scripts/ci/desktop-windows-pr.sh` before the Tauri build. That script uses: +The Windows PR and signed release workflows stage these automatically through +`scripts/ci/desktop-windows-pr.sh` and `scripts/ci/desktop-windows-release.sh` +before the Tauri build. Those scripts use: - SHA-verified ONNX Runtime from `scripts/provide-windows-onnxruntime.sh`. - A SHA-verified, versioned Microsoft `VC_redist.x64.exe` URL pinned in @@ -54,8 +55,11 @@ The Windows PR workflow stages these automatically through - A SHA-verified WiX CLI NuGet package, used only to extract the VC++ redist bootstrapper payload reproducibly. -The build emits `target/reproducibility/desktop-pr-windows-*.sha256` proof -manifests, and CI verifies those manifests from uploaded artifacts. +The PR build emits `target/reproducibility/desktop-pr-windows-*.sha256` proof +manifests. The signed release build emits +`target/reproducibility/desktop-release-windows-*.sha256` proof manifests after +the installer is Authenticode-signed and the final Tauri updater `.sig` is +generated. CI verifies those manifests from uploaded artifacts. For a **local** Windows build, run `scripts/provide-windows-onnxruntime.sh` first (it exports `ORT_DYLIB_PATH`), then run the same staging helper: diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh index b0a12177..006c1c1e 100755 --- a/scripts/ci/_common.sh +++ b/scripts/ci/_common.sh @@ -554,6 +554,25 @@ host_os() { uname -s | tr '[:upper:]' '[:lower:]' } +require_windows_host() { + case "$(host_os)" in + mingw* | msys* | cygwin*) + ;; + *) + echo "$1 must run on Windows under Git Bash/MSYS." >&2 + return 1 + ;; + esac +} + +to_windows_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s\n' "$1" + fi +} + linux_ort_arch() { case "$(uname -m)" in x86_64 | amd64) @@ -588,6 +607,47 @@ prepare_linux_onnxruntime() { done <<< "${ort_env}" } +prepare_windows_onnxruntime() { + require_windows_host "prepare_windows_onnxruntime" + + local ort_env key value + ort_env="$("${TAURI_DIR}/scripts/provide-windows-onnxruntime.sh")" + printf '%s\n' "${ort_env}" + + while IFS='=' read -r key value; do + case "${key}" in + ORT_LIB_LOCATION | ORT_SKIP_DOWNLOAD | ORT_DYLIB_PATH) + export "${key}=${value}" + ;; + esac + done <<< "${ort_env}" +} + +stage_windows_runtime_dlls() { + require_windows_host "stage_windows_runtime_dlls" + + local vc_redist_version wix_cli_version + + vc_redist_version="$(windows_vc_redist_x64_version)" + export MAPLE_WINDOWS_VC_REDIST_VERSION="${vc_redist_version}" + export MAPLE_WINDOWS_VC_REDIST_URL + export MAPLE_WINDOWS_VC_REDIST_SHA256 + MAPLE_WINDOWS_VC_REDIST_URL="$(windows_vc_redist_x64_url_for_version "${vc_redist_version}")" + MAPLE_WINDOWS_VC_REDIST_SHA256="$(windows_vc_redist_x64_archive_sha256_for_version "${vc_redist_version}")" + + wix_cli_version="$(windows_wix_cli_version)" + export MAPLE_WINDOWS_WIX_CLI_VERSION="${wix_cli_version}" + export MAPLE_WINDOWS_WIX_CLI_URL + export MAPLE_WINDOWS_WIX_CLI_SHA256 + MAPLE_WINDOWS_WIX_CLI_URL="$(windows_wix_cli_url_for_version "${wix_cli_version}")" + MAPLE_WINDOWS_WIX_CLI_SHA256="$(windows_wix_cli_archive_sha256_for_version "${wix_cli_version}")" + + pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass \ + -File "$(to_windows_path "${TAURI_DIR}/scripts/stage-windows-runtime-dlls.ps1")" \ + -OrtDllPath "$(to_windows_path "${ORT_DYLIB_PATH:?ORT_DYLIB_PATH is required}")" \ + -Destination "$(to_windows_path "${TAURI_DIR}/resources/windows")" +} + sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" @@ -3475,6 +3535,125 @@ linux_tauri_release_config() { }' } +windows_tauri_release_build_config() { + jq -cn '{ + build: { + beforeBuildCommand: null + }, + bundle: { + createUpdaterArtifacts: false, + targets: ["nsis"] + } + }' +} + +windows_tauri_release_bundle_config() { + jq -cn '{ + build: { + beforeBuildCommand: null + }, + bundle: { + createUpdaterArtifacts: false, + targets: ["nsis"] + } + }' +} + +windows_release_app_exe() { + printf '%s\n' "${TAURI_DIR}/target/release/maple.exe" +} + +windows_release_product_name() { + jq -er '.productName' "${TAURI_DIR}/tauri.conf.json" +} + +windows_release_version() { + jq -er '.version' "${TAURI_DIR}/tauri.conf.json" +} + +windows_release_setup_exe_basename_for_version() { + local version="$1" + printf '%s_%s_x64-setup.exe\n' "$(windows_release_product_name)" "${version}" +} + +windows_release_setup_exe_basename() { + windows_release_setup_exe_basename_for_version "$(windows_release_version)" +} + +windows_release_setup_exe_path() { + printf '%s\n' "${TAURI_DIR}/target/release/bundle/nsis/$(windows_release_setup_exe_basename)" +} + +windows_release_setup_sig_path() { + printf '%s.sig\n' "$(windows_release_setup_exe_path)" +} + +windows_release_setup_exes_found() { + local nsis_dir="${TAURI_DIR}/target/release/bundle/nsis" + if [ ! -d "${nsis_dir}" ]; then + return 0 + fi + find "${nsis_dir}" -maxdepth 1 -type f -name '*_x64-setup.exe' -print | LC_ALL=C sort +} + +windows_release_setup_exe_required() { + local setup + setup="$(windows_release_setup_exe_path)" + if [ ! -f "${setup}" ]; then + echo "Expected Windows NSIS setup executable was not found: ${setup}" >&2 + echo "Found Windows NSIS setup candidates:" >&2 + windows_release_setup_exes_found | sed 's/^/ /' >&2 + return 1 + fi + printf '%s\n' "${setup}" +} + +verify_windows_authenticode_signatures() { + require_windows_host "verify_windows_authenticode_signatures" + + if [ "$#" -eq 0 ]; then + echo "verify_windows_authenticode_signatures requires at least one file." >&2 + return 1 + fi + + if [ -z "${MAPLE_WINDOWS_AUTHENTICODE_SUBJECT:-}" ]; then + echo "MAPLE_WINDOWS_AUTHENTICODE_SUBJECT is required to verify the Windows signer identity." >&2 + return 1 + fi + + local ps_args=() + local file + for file in "$@"; do + if [ ! -f "${file}" ]; then + echo "Missing Windows signed artifact: ${file}" >&2 + return 1 + fi + ps_args+=("$(to_windows_path "${file}")") + done + + # shellcheck disable=SC2016 + pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -Command ' + $ErrorActionPreference = "Stop" + $expectedSubject = $args[0] + foreach ($file in $args[1..($args.Count - 1)]) { + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne "Valid") { + throw "Authenticode signature is not valid for $file. Status=$($signature.Status) Message=$($signature.StatusMessage)" + } + $subject = "" + $issuer = "" + if ($null -ne $signature.SignerCertificate) { + $subject = $signature.SignerCertificate.Subject + $issuer = $signature.SignerCertificate.Issuer + } + if ($subject -ne $expectedSubject) { + throw "Authenticode signer subject mismatch for $file. Expected=$expectedSubject Actual=$subject Issuer=$issuer" + } + Write-Host ("verified-windows-authenticode {0} subject={1} issuer={2}" -f $file, $subject, $issuer) + } + ' "${MAPLE_WINDOWS_AUTHENTICODE_SUBJECT}" "${ps_args[@]}" +} + import_apple_developer_certificate() { if [ "$(host_os)" != "darwin" ] || [ -z "${APPLE_CERTIFICATE:-}" ]; then return 0 diff --git a/scripts/ci/desktop-windows-pr.sh b/scripts/ci/desktop-windows-pr.sh index fe8cae74..17c28f32 100755 --- a/scripts/ci/desktop-windows-pr.sh +++ b/scripts/ci/desktop-windows-pr.sh @@ -3,60 +3,7 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" -case "$(host_os)" in - mingw* | msys* | cygwin*) - ;; - *) - echo "desktop-windows-pr.sh must run on Windows under Git Bash/MSYS." >&2 - exit 1 - ;; -esac - -to_windows_path() { - if command -v cygpath >/dev/null 2>&1; then - cygpath -w "$1" - else - printf '%s\n' "$1" - fi -} - -prepare_windows_onnxruntime() { - local ort_env key value - - ort_env="$("${TAURI_DIR}/scripts/provide-windows-onnxruntime.sh")" - printf '%s\n' "${ort_env}" - - while IFS='=' read -r key value; do - case "${key}" in - ORT_LIB_LOCATION | ORT_SKIP_DOWNLOAD | ORT_DYLIB_PATH) - export "${key}=${value}" - ;; - esac - done <<< "${ort_env}" -} - -stage_windows_runtime_dlls() { - local vc_redist_version wix_cli_version - - vc_redist_version="$(windows_vc_redist_x64_version)" - export MAPLE_WINDOWS_VC_REDIST_VERSION="${vc_redist_version}" - export MAPLE_WINDOWS_VC_REDIST_URL - export MAPLE_WINDOWS_VC_REDIST_SHA256 - MAPLE_WINDOWS_VC_REDIST_URL="$(windows_vc_redist_x64_url_for_version "${vc_redist_version}")" - MAPLE_WINDOWS_VC_REDIST_SHA256="$(windows_vc_redist_x64_archive_sha256_for_version "${vc_redist_version}")" - - wix_cli_version="$(windows_wix_cli_version)" - export MAPLE_WINDOWS_WIX_CLI_VERSION="${wix_cli_version}" - export MAPLE_WINDOWS_WIX_CLI_URL - export MAPLE_WINDOWS_WIX_CLI_SHA256 - MAPLE_WINDOWS_WIX_CLI_URL="$(windows_wix_cli_url_for_version "${wix_cli_version}")" - MAPLE_WINDOWS_WIX_CLI_SHA256="$(windows_wix_cli_archive_sha256_for_version "${wix_cli_version}")" - - pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass \ - -File "$(to_windows_path "${TAURI_DIR}/scripts/stage-windows-runtime-dlls.ps1")" \ - -OrtDllPath "$(to_windows_path "${ORT_DYLIB_PATH:?ORT_DYLIB_PATH is required}")" \ - -Destination "$(to_windows_path "${TAURI_DIR}/resources/windows")" -} +require_windows_host "desktop-windows-pr.sh" print_source_provenance diff --git a/scripts/ci/desktop-windows-release.sh b/scripts/ci/desktop-windows-release.sh new file mode 100755 index 00000000..daaf962d --- /dev/null +++ b/scripts/ci/desktop-windows-release.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +usage() { + cat >&2 <<'EOF' +usage: desktop-windows-release.sh + +build + Prepare the reproducible Windows release environment, build frontend assets, + stage pinned runtime DLLs, and compile maple.exe without bundling. + +bundle + Verify maple.exe is Authenticode-signed, then generate the NSIS installer + from that signed app executable. The installer itself is not signed here. + +finalize + Verify Authenticode signatures on maple.exe and the NSIS installer, create + the final Tauri updater signature for the signed installer, and emit release + reproducibility manifests. +EOF +} + +mode="${1:-}" +case "${mode}" in + build | bundle | finalize) + ;; + -h | --help | help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; +esac + +require_windows_host "desktop-windows-release.sh" + +run_build_phase() { + local app_exe + + print_source_provenance + install_frontend_deps + configure_sccache + use_release_environment + configure_reproducible_build_metadata + prepare_windows_onnxruntime + stage_windows_runtime_dlls + build_frontend_dist + + cd "${FRONTEND_DIR}" + remove_build_tree "${TAURI_DIR}/target/release/bundle/nsis" + remove_build_tree "${TAURI_DIR}/target/release/bundle/msi" + + bun tauri build --verbose --no-bundle --config "$(windows_tauri_release_build_config)" + + app_exe="$(windows_release_app_exe)" + if [ ! -f "${app_exe}" ]; then + echo "Windows app executable was not built: ${app_exe}" >&2 + exit 1 + fi + print_file_hashes "${app_exe}" + verify_frontend_dist_unchanged +} + +run_bundle_phase() { + local app_exe setup_exe + + print_source_provenance + configure_sccache + use_release_environment + configure_reproducible_build_metadata + + app_exe="$(windows_release_app_exe)" + verify_windows_authenticode_signatures "${app_exe}" + + cd "${FRONTEND_DIR}" + remove_build_tree "${TAURI_DIR}/target/release/bundle/nsis" + remove_build_tree "${TAURI_DIR}/target/release/bundle/msi" + + bun tauri bundle --verbose --bundles nsis --config "$(windows_tauri_release_bundle_config)" + + setup_exe="$(windows_release_setup_exe_required)" + print_file_hashes "${setup_exe}" + verify_frontend_dist_unchanged +} + +run_finalize_phase() { + local app_exe setup_exe repro_dir + local windows_runtime_dlls=() + + print_source_provenance + configure_reproducible_build_metadata + configure_tauri_updater_signing_key + + app_exe="$(windows_release_app_exe)" + setup_exe="$(windows_release_setup_exe_required)" + verify_windows_authenticode_signatures "${app_exe}" "${setup_exe}" + + rm -f "${setup_exe}.sig" + sign_tauri_updater_artifacts "${setup_exe}" + verify_tauri_updater_signature_files "${setup_exe}.sig" + + repro_dir="${TAURI_DIR}/target/reproducibility" + mkdir -p "${repro_dir}" + + while IFS= read -r -d '' file; do + windows_runtime_dlls+=("${file}") + done < <(find "${TAURI_DIR}/resources/windows" -type f -name '*.dll' -print0 | LC_ALL=C sort -z) + + if [ "${#windows_runtime_dlls[@]}" -eq 0 ]; then + echo "windows_runtime_dlls is empty; no staged runtime DLLs found under ${TAURI_DIR}/resources/windows." >&2 + exit 1 + fi + + write_sha256_manifest "${repro_dir}/desktop-release-windows-final.sha256" \ + "${setup_exe}" \ + "${setup_exe}.sig" + write_sha256_manifest "${repro_dir}/desktop-release-windows-runtime-dlls.sha256" \ + "${windows_runtime_dlls[@]}" + + print_file_hashes "${setup_exe}" "${setup_exe}.sig" + print_file_hashes "${windows_runtime_dlls[@]}" + verify_frontend_dist_unchanged +} + +case "${mode}" in + build) + run_build_phase + ;; + bundle) + run_bundle_phase + ;; + finalize) + run_finalize_phase + ;; +esac diff --git a/scripts/ci/latest-json.sh b/scripts/ci/latest-json.sh index c09166ea..4a2c091e 100755 --- a/scripts/ci/latest-json.sh +++ b/scripts/ci/latest-json.sh @@ -25,26 +25,44 @@ pub_date="${MAPLE_LATEST_JSON_PUB_DATE:-$(source_date_rfc3339)}" find_one_artifact() { local pattern="$1" local file - file="$(find "${artifacts_dir}" -type f -name "${pattern}" | LC_ALL=C sort | head -n 1)" - if [ -z "${file}" ]; then + local matches=() + + while IFS= read -r -d '' file; do + matches+=("${file}") + done < <(find "${artifacts_dir}" -type f -name "${pattern}" -print0 | LC_ALL=C sort -z) + + if [ "${#matches[@]}" -eq 0 ]; then echo "Could not find artifact matching ${pattern} in ${artifacts_dir}" >&2 return 1 fi - printf '%s\n' "${file}" + + if [ "${#matches[@]}" -ne 1 ]; then + echo "Expected exactly one artifact matching ${pattern} in ${artifacts_dir}, found ${#matches[@]}:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + return 1 + fi + + printf '%s\n' "${matches[0]}" } macos_bundle="$(find_one_artifact '*.app.tar.gz')" macos_sig="$(find_one_artifact '*.app.tar.gz.sig')" linux_bundle="$(find_one_artifact '*.AppImage')" linux_sig="$(find_one_artifact '*.AppImage.sig')" +windows_bundle_basename="$(windows_release_setup_exe_basename_for_version "${release_tag#v}")" +windows_bundle="$(find_one_artifact "${windows_bundle_basename}")" +windows_sig="$(find_one_artifact "${windows_bundle_basename}.sig")" verify_tauri_updater_signature "${macos_bundle}" "${macos_sig}" "$(basename "${macos_bundle}")" verify_tauri_updater_signature "${linux_bundle}" "${linux_sig}" "$(basename "${linux_bundle}")" +verify_tauri_updater_signature "${windows_bundle}" "${windows_sig}" "$(basename "${windows_bundle}")" macos_sig_content="$(cat "${macos_sig}")" linux_sig_content="$(cat "${linux_sig}")" +windows_sig_content="$(cat "${windows_sig}")" macos_url="https://github.com/OpenSecretCloud/Maple/releases/download/${release_tag}/$(basename "${macos_bundle}")" linux_url="https://github.com/OpenSecretCloud/Maple/releases/download/${release_tag}/$(basename "${linux_bundle}")" +windows_url="https://github.com/OpenSecretCloud/Maple/releases/download/${release_tag}/$(basename "${windows_bundle}")" tmp="$(mktemp)" jq -S -n \ @@ -53,8 +71,10 @@ jq -S -n \ --arg pub_date "${pub_date}" \ --arg macos_sig "${macos_sig_content}" \ --arg linux_sig "${linux_sig_content}" \ + --arg windows_sig "${windows_sig_content}" \ --arg macos_url "${macos_url}" \ --arg linux_url "${linux_url}" \ + --arg windows_url "${windows_url}" \ '{ notes: $notes, platforms: { @@ -69,6 +89,10 @@ jq -S -n \ "linux-x86_64": { signature: $linux_sig, url: $linux_url + }, + "windows-x86_64": { + signature: $windows_sig, + url: $windows_url } }, pub_date: $pub_date, @@ -84,9 +108,11 @@ jq -e ' and (.platforms."darwin-x86_64".signature | type == "string" and length > 0) and (.platforms."linux-x86_64".url | startswith("https://")) and (.platforms."linux-x86_64".signature | type == "string" and length > 0) + and (.platforms."windows-x86_64".url | startswith("https://")) + and (.platforms."windows-x86_64".signature | type == "string" and length > 0) ' "${out}" >/dev/null repro_dir="${TAURI_DIR}/target/reproducibility" mkdir -p "${repro_dir}" -write_sha256_manifest "${repro_dir}/latest-json-final.sha256" "${out}" "${macos_sig}" "${linux_sig}" -print_file_hashes "${out}" "${macos_sig}" "${linux_sig}" +write_sha256_manifest "${repro_dir}/latest-json-final.sha256" "${out}" "${macos_sig}" "${linux_sig}" "${windows_sig}" +print_file_hashes "${out}" "${macos_sig}" "${linux_sig}" "${windows_sig}" diff --git a/scripts/ci/release-verification-guide.sh b/scripts/ci/release-verification-guide.sh index aac56e51..845f8ccb 100755 --- a/scripts/ci/release-verification-guide.sh +++ b/scripts/ci/release-verification-guide.sh @@ -58,7 +58,7 @@ This guide was generated by release CI after Maple's release artifact verifier p ## What The Proofs Cover -The release contains final SHA-256 manifests, canonical payload manifests, Tauri updater signatures, and GitHub artifact attestations. Together they let you check that downloaded bytes match the release, that updater signatures match \`latest.json\`, and that signed Android, macOS, and iOS payloads strip back to the reproducible unsigned build products where that platform supports the check. +The release contains final SHA-256 manifests, canonical payload manifests, Tauri updater signatures, and GitHub artifact attestations. Together they let you check that downloaded bytes match the release, that updater signatures match \`latest.json\`, and that signed Android, macOS, and iOS payloads strip back to the reproducible unsigned build products where that platform supports the check. Windows release proofs cover the final Authenticode-signed NSIS installer, its final Tauri updater signature, and the pinned app-local runtime DLLs used during bundling. Windows verification does not yet canonicalize Authenticode-signed binaries back to an unsigned baseline. GitHub attestations prove that release CI produced the published bytes for this tag. The Maple verifier then recomputes the local hashes and canonical payload proofs from the files you downloaded. @@ -105,6 +105,10 @@ nix develop .#desktop-linux -c ./scripts/ci/verify-release-artifacts.sh artifact # macOS desktop installers: run on macOS with Xcode selected. nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos +# Windows desktop installer: verifies final hashes, pinned runtime DLL proofs, +# and the Tauri updater signature for the signed NSIS installer. +nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts windows + # iOS IPA: run on macOS with Xcode selected. MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY=1 \\ nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts ios @@ -128,7 +132,7 @@ EOF printf 'No release assets were present when this guide was generated.\n' else for name in "${asset_names[@]}"; do - printf -- '- `%s`\n' "${name}" + printf -- "- \`%s\`\n" "${name}" done fi diff --git a/scripts/ci/signed-release-rehearsal.sh b/scripts/ci/signed-release-rehearsal.sh index 07b9084e..20ffa3f3 100755 --- a/scripts/ci/signed-release-rehearsal.sh +++ b/scripts/ci/signed-release-rehearsal.sh @@ -23,6 +23,11 @@ Fake signing: APK/AAB signing without live keys. Apple release signing still requires real Apple credentials. +Windows: + Windows Authenticode release signing is exercised in the protected Windows CI + workflow through Azure Artifact Signing. This local rehearsal helper does not + attempt a Windows release build or cloud signing flow. + No .env files are loaded implicitly. EOF } diff --git a/scripts/ci/verify-release-artifacts.sh b/scripts/ci/verify-release-artifacts.sh index 4ced78d5..fc98a732 100755 --- a/scripts/ci/verify-release-artifacts.sh +++ b/scripts/ci/verify-release-artifacts.sh @@ -626,7 +626,7 @@ verify_latest_json() { and (.platforms | type == "object") ' "${latest_json}" >/dev/null - for platform in darwin-aarch64 darwin-x86_64 linux-x86_64; do + for platform in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do url="$(jq -er --arg platform "${platform}" '.platforms[$platform].url' "${latest_json}")" signature="$(jq -er --arg platform "${platform}" '.platforms[$platform].signature' "${latest_json}")" basename="$(basename "${url}")" @@ -667,6 +667,7 @@ verify_present() { verify_all() { verify_linux verify_macos + verify_windows verify_android verify_ios verify_web