From a9432324a7d9ebe4458edbad7a747a4fdc5db607 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:16:32 -0700 Subject: [PATCH] ci: sign only what ships, stop signing previews, log signing output Artifact Signing is metered at about 1000 signatures a month, and there were 67 Windows builds last month. - windows-sign.ps1 signs only what reaches users: the app binary, the MSI, the NSIS installer and the uninstaller. It skips the WiX extension DLLs and NSIS plugins that Tauri also offers, and any file that is already validly signed. That is 4 signatures per release. - Previews are no longer signed, so the preview workflow no longer references the signing secrets. A PR's workflow runs the PR's own code. The inline TAURI_CONFIG there, which the v2 CLI never read, becomes a real `--config`. - Tauri reports a failed sign command only as "failed to run powershell". The script now keeps a transcript, signtool /debug included, and the release job prints it on failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/build-app-preview.yml | 50 +++-------- .gitea/workflows/build-app.yml | 6 ++ scripts/windows-sign.ps1 | 115 +++++++++++++++++-------- scripts/windows-signing-setup.ps1 | 2 + 4 files changed, 100 insertions(+), 73 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 7426355..63c9701 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -7,6 +7,8 @@ name: Build App (Preview) # This is also the **PR build check**: it compiles Linux, macOS and Windows, so # a push that breaks any of them fails here. Its `test` job runs vitest and # `cargo test` too, so a push that breaks either suite fails here as well. +# Previews are not code-signed (releases are, in build-app.yml): see the +# comment on the Windows job's "Build Tauri app" step. # build-app.yml used to do the build-check job in parallel and publish nothing, # which meant six OS builds per push and one unreachable set of bundles; it is # now releases-only. @@ -75,7 +77,6 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" - - "scripts/windows-*.ps1" workflow_dispatch: jobs: @@ -671,9 +672,8 @@ jobs: run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which - rem the Linux and macOS jobs run: the Windows code-signing path (sign - rem command, NSIS uninstaller signing) was verified against it, and "^2" - rem would change it underneath the pipeline on any Tauri release. + rem the Linux and macOS jobs run, and kept identical to build-app.yml so + rem a preview is built by the same bundler as the release it previews. cargo install tauri-cli --version "=2.11.0" --locked - name: Fix npm platform detection @@ -695,45 +695,23 @@ jobs: set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build - # Previews are signed exactly like releases (build-app.yml): a preview is - # what gets installed for testing, and SmartScreen treats an unsigned one - # no differently from malware. The setup fetches the Artifact Signing - # client and a job-local .NET runtime, and writes the Tauri config holding - # the sign command, which "Build Tauri app" passes with --config. - - name: Prepare code signing - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} - ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} - ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }} - run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1 - - name: Build Tauri app working-directory: ./app - # The sign command comes in through --config, from the file "Prepare - # code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that - # variable (the inline one this step used to set was a no-op), and - # "Verify signatures" is what caught it. + # Previews are not code-signed: signing is metered, previews are built + # on every PR push, and a PR's workflow runs the PR's own code - so the + # signing secrets stay out of this workflow entirely. Releases are + # signed in build-app.yml. + # + # beforeBuildCommand is blanked through --config because the frontend + # was built in the step above. Not TAURI_CONFIG: the v2 CLI never + # reads that variable, and the inline one this step used to set was a + # no-op. env: # See the matching comment on the Linux job's "Build Tauri app" step. TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} - # Read by the signing dlib itself, never passed on a command line. - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" - - - name: Verify signatures - run: >- - powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass - -File scripts\windows-verify-signatures.ps1 - app\src-tauri\target\release\triple-c.exe - app\src-tauri\target\release\bundle\msi\*.msi - app\src-tauri\target\release\bundle\nsis\*.exe + cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}" - name: Collect artifacts run: | diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 40dfad3..3fa5f49 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -686,6 +686,12 @@ jobs: app\src-tauri\target\release\bundle\msi\*.msi app\src-tauri\target\release\bundle\nsis\*.exe + # Tauri reports a failed sign command as just "failed to run powershell"; + # windows-sign.ps1 keeps its own transcript, signtool /debug included. + - name: Show signing output + if: failure() + run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log + - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 index bbd3580..56d4da7 100644 --- a/scripts/windows-sign.ps1 +++ b/scripts/windows-sign.ps1 @@ -1,7 +1,18 @@ # windows-sign.ps1 - sign one file with Azure Artifact Signing. # # Tauri's bundle.windows.signCommand, set up by windows-signing-setup.ps1. -# Tauri calls it once per file it signs and fails the build on a non-zero exit. +# Tauri calls it once per file it wants signed and fails the build on a +# non-zero exit - but shows none of this script's output when it does, so +# everything is also appended to $TRIPLE_C_SIGN_OUTPUT, which the workflow +# prints if the job fails. +# +# Only what ships is signed. Tauri also offers build-time tools - the WiX +# extension DLLs candle/light load, the NSIS plugins makensis embeds - and +# each signature is metered (about 1000 a month), so those are skipped. The +# allowlist below is the whole of what reaches users: the app binary, the MSI, +# the NSIS installer, and the uninstaller makensis writes to the job's temp +# directory. A file that already carries a valid signature is skipped too: +# Tauri presents the app binary once per bundle type. # # This may run as 32-bit PowerShell: the NSIS uninstaller is signed from inside # makensis, which is 32-bit and resolves `powershell` to the SysWOW64 copy. So @@ -15,47 +26,77 @@ param([Parameter(Mandatory = $true)][string]$Path) $ErrorActionPreference = 'Stop' +$utf8 = New-Object System.Text.UTF8Encoding $false -foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', - 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { - if (-not [Environment]::GetEnvironmentVariable($name)) { - throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" +function Write-Log([string]$Text) { + Write-Host $Text + if ($env:TRIPLE_C_SIGN_OUTPUT) { + try { [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_OUTPUT, "$Text`n", $utf8) } catch { } } } -if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } -# /d names the product in the UAC prompt, which for an MSI would otherwise show -# a temporary file name. The timestamp is what keeps the signature valid after -# the short-lived Artifact Signing certificate expires, so it is not optional. -$arguments = @( - 'sign', '/v', - '/fd', 'SHA256', - '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', - '/d', 'Triple-C', - '/dlib', $env:TRIPLE_C_SIGN_DLIB, - '/dmdf', $env:TRIPLE_C_SIGN_METADATA, - $Path -) - -# Timestamp servers and the signing endpoint both fail transiently now and -# then; a retry is cheaper than a failed three-platform release. -# -# Stop is relaxed around the call: Tauri captures this script's output, and -# PowerShell 5.1 turns a native command's stderr into error records when its -# own streams are redirected - under Stop, signtool's first warning would kill -# the script before its exit code is read. -$ErrorActionPreference = 'Continue' -for ($attempt = 1; $attempt -le 3; $attempt++) { - & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } - if ($LASTEXITCODE -eq 0) { - # The evidence "Verify signatures" needs for files it cannot see - # afterwards - the NSIS uninstaller is embedded in the installer. - if ($env:TRIPLE_C_SIGN_LOG) { - [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false)) +try { + foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', + 'TRIPLE_C_SIGN_TMP', 'TRIPLE_C_SIGN_LOG', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" } + } + if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } + $full = (Resolve-Path -LiteralPath $Path).ProviderPath + Write-Log "== $full" + + $ships = ($full -match '\\target\\release\\[^\\]+\.exe$') -or + ($full -match '\\target\\release\\bundle\\(msi|nsis)\\[^\\]+\.(msi|exe)$') -or + $full.StartsWith($env:TRIPLE_C_SIGN_TMP.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) + if (-not $ships) { + Write-Log 'skipped: build-time file, not shipped' exit 0 } - Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" - if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + + $existing = Get-AuthenticodeSignature -LiteralPath $full + if ($existing.Status -eq 'Valid' -and $existing.TimeStamperCertificate) { + Write-Log "skipped: already signed by $($existing.SignerCertificate.Subject)" + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + exit 0 + } + + # /d names the product in the UAC prompt, which for an MSI would otherwise + # show a temporary file name. The timestamp is what keeps the signature + # valid after the short-lived Artifact Signing certificate expires, so it + # is not optional. /debug makes the dlib say why it failed, into the log. + $arguments = @( + 'sign', '/v', '/debug', + '/fd', 'SHA256', + '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', + '/d', 'Triple-C', + '/dlib', $env:TRIPLE_C_SIGN_DLIB, + '/dmdf', $env:TRIPLE_C_SIGN_METADATA, + $full + ) + + # Stop is relaxed around the call: Tauri captures this script's output, + # and PowerShell 5.1 turns a native command's stderr into error records + # when its own streams are redirected - under Stop, signtool's first + # warning would kill the script before its exit code is read. Timestamp + # servers and the signing endpoint fail transiently now and then, hence + # the retries. + $ErrorActionPreference = 'Continue' + for ($attempt = 1; $attempt -le 3; $attempt++) { + & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { Write-Log "$_" } + $code = $LASTEXITCODE + if ($code -eq 0) { + # The evidence "Verify signatures" needs for the file it cannot see + # afterwards - the NSIS uninstaller is embedded in the installer. + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + Write-Log 'signed' + exit 0 + } + Write-Log "signtool exited $code (attempt $attempt of 3)" + if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + } + exit 1 +} catch { + Write-Log "windows-sign.ps1 failed: $($_.Exception.Message)" + exit 1 } -exit 1 diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 index a3bafaf..a123b74 100644 --- a/scripts/windows-signing-setup.ps1 +++ b/scripts/windows-signing-setup.ps1 @@ -145,6 +145,7 @@ $tauriConfigPath = Join-Path $root 'tauri.signing.conf.json' $tmpDir = Join-Path $root 'tmp' New-Item -ItemType Directory -Path $tmpDir | Out-Null $signLog = Join-Path $root 'signed.log' +$signOutput = Join-Path $root 'sign-output.log' # $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's # utf8 encoding adds one, which would corrupt the first key. @@ -157,6 +158,7 @@ $lines = @( "DOTNET_ROOT_X64=$dotnetDir" "TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath" "TRIPLE_C_SIGN_LOG=$signLog" + "TRIPLE_C_SIGN_OUTPUT=$signOutput" "TRIPLE_C_SIGN_TMP=$tmpDir" "TEMP=$tmpDir" "TMP=$tmpDir"