ci: sign only what ships, stop signing previews, log signing output
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / test (pull_request) Successful in 5m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m11s
Build App (Preview) / build-linux (pull_request) Successful in 14m11s
Build App (Preview) / prune-previews (pull_request) Successful in 5s

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 18:16:32 -07:00
co-authored by Claude Opus 5.5
parent cc274f39a8
commit a9432324a7
4 changed files with 100 additions and 73 deletions
+14 -36
View File
@@ -7,6 +7,8 @@ name: Build App (Preview)
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so # 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 # 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. # `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, # 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 # which meant six OS builds per push and one unreachable set of bundles; it is
# now releases-only. # now releases-only.
@@ -75,7 +77,6 @@ on:
- "app/**" - "app/**"
- "VERSION" - "VERSION"
- ".gitea/workflows/build-app-preview.yml" - ".gitea/workflows/build-app-preview.yml"
- "scripts/windows-*.ps1"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -671,9 +672,8 @@ jobs:
run: | run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" 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 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 the Linux and macOS jobs run, and kept identical to build-app.yml so
rem command, NSIS uninstaller signing) was verified against it, and "^2" rem a preview is built by the same bundler as the release it previews.
rem would change it underneath the pipeline on any Tauri release.
cargo install tauri-cli --version "=2.11.0" --locked cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection - name: Fix npm platform detection
@@ -695,45 +695,23 @@ jobs:
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build 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 - name: Build Tauri app
working-directory: ./app working-directory: ./app
# The sign command comes in through --config, from the file "Prepare # Previews are not code-signed: signing is metered, previews are built
# code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that # on every PR push, and a PR's workflow runs the PR's own code - so the
# variable (the inline one this step used to set was a no-op), and # signing secrets stay out of this workflow entirely. Releases are
# "Verify signatures" is what caught it. # 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: env:
# See the matching comment on the Linux job's "Build Tauri app" step. # See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} 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: | run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
- 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
- name: Collect artifacts - name: Collect artifacts
run: | run: |
+6
View File
@@ -686,6 +686,12 @@ jobs:
app\src-tauri\target\release\bundle\msi\*.msi app\src-tauri\target\release\bundle\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe 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 - name: Collect artifacts
run: | run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
+62 -21
View File
@@ -1,7 +1,18 @@
# windows-sign.ps1 <file> - sign one file with Azure Artifact Signing. # windows-sign.ps1 <file> - sign one file with Azure Artifact Signing.
# #
# Tauri's bundle.windows.signCommand, set up by windows-signing-setup.ps1. # 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 # 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 # makensis, which is 32-bit and resolves `powershell` to the SysWOW64 copy. So
@@ -15,47 +26,77 @@
param([Parameter(Mandatory = $true)][string]$Path) param([Parameter(Mandatory = $true)][string]$Path)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$utf8 = New-Object System.Text.UTF8Encoding $false
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 { }
}
}
try {
foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', 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') { 'TRIPLE_C_SIGN_TMP', 'TRIPLE_C_SIGN_LOG', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($name)) { if (-not [Environment]::GetEnvironmentVariable($name)) {
throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" 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" } if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" }
$full = (Resolve-Path -LiteralPath $Path).ProviderPath
Write-Log "== $full"
# /d names the product in the UAC prompt, which for an MSI would otherwise show $ships = ($full -match '\\target\\release\\[^\\]+\.exe$') -or
# a temporary file name. The timestamp is what keeps the signature valid after ($full -match '\\target\\release\\bundle\\(msi|nsis)\\[^\\]+\.(msi|exe)$') -or
# the short-lived Artifact Signing certificate expires, so it is not optional. $full.StartsWith($env:TRIPLE_C_SIGN_TMP.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)
if (-not $ships) {
Write-Log 'skipped: build-time file, not shipped'
exit 0
}
$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 = @( $arguments = @(
'sign', '/v', 'sign', '/v', '/debug',
'/fd', 'SHA256', '/fd', 'SHA256',
'/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256',
'/d', 'Triple-C', '/d', 'Triple-C',
'/dlib', $env:TRIPLE_C_SIGN_DLIB, '/dlib', $env:TRIPLE_C_SIGN_DLIB,
'/dmdf', $env:TRIPLE_C_SIGN_METADATA, '/dmdf', $env:TRIPLE_C_SIGN_METADATA,
$Path $full
) )
# Timestamp servers and the signing endpoint both fail transiently now and # Stop is relaxed around the call: Tauri captures this script's output,
# then; a retry is cheaper than a failed three-platform release. # and PowerShell 5.1 turns a native command's stderr into error records
# # when its own streams are redirected - under Stop, signtool's first
# Stop is relaxed around the call: Tauri captures this script's output, and # warning would kill the script before its exit code is read. Timestamp
# PowerShell 5.1 turns a native command's stderr into error records when its # servers and the signing endpoint fail transiently now and then, hence
# own streams are redirected - under Stop, signtool's first warning would kill # the retries.
# the script before its exit code is read.
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
for ($attempt = 1; $attempt -le 3; $attempt++) { for ($attempt = 1; $attempt -le 3; $attempt++) {
& $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { Write-Log "$_" }
if ($LASTEXITCODE -eq 0) { $code = $LASTEXITCODE
# The evidence "Verify signatures" needs for files it cannot see if ($code -eq 0) {
# The evidence "Verify signatures" needs for the file it cannot see
# afterwards - the NSIS uninstaller is embedded in the installer. # afterwards - the NSIS uninstaller is embedded in the installer.
if ($env:TRIPLE_C_SIGN_LOG) { [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8)
[IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false)) Write-Log 'signed'
}
exit 0 exit 0
} }
Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" Write-Log "signtool exited $code (attempt $attempt of 3)"
if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) }
} }
exit 1 exit 1
} catch {
Write-Log "windows-sign.ps1 failed: $($_.Exception.Message)"
exit 1
}
+2
View File
@@ -145,6 +145,7 @@ $tauriConfigPath = Join-Path $root 'tauri.signing.conf.json'
$tmpDir = Join-Path $root 'tmp' $tmpDir = Join-Path $root 'tmp'
New-Item -ItemType Directory -Path $tmpDir | Out-Null New-Item -ItemType Directory -Path $tmpDir | Out-Null
$signLog = Join-Path $root 'signed.log' $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 # $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's
# utf8 encoding adds one, which would corrupt the first key. # utf8 encoding adds one, which would corrupt the first key.
@@ -157,6 +158,7 @@ $lines = @(
"DOTNET_ROOT_X64=$dotnetDir" "DOTNET_ROOT_X64=$dotnetDir"
"TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath" "TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath"
"TRIPLE_C_SIGN_LOG=$signLog" "TRIPLE_C_SIGN_LOG=$signLog"
"TRIPLE_C_SIGN_OUTPUT=$signOutput"
"TRIPLE_C_SIGN_TMP=$tmpDir" "TRIPLE_C_SIGN_TMP=$tmpDir"
"TEMP=$tmpDir" "TEMP=$tmpDir"
"TMP=$tmpDir" "TMP=$tmpDir"