CI: sign Windows builds with Azure Artifact Signing #62

Merged
jknapp merged 5 commits from ci/windows-code-signing into main 2026-09-24 02:09:49 +00:00
4 changed files with 100 additions and 73 deletions
Showing only changes of commit a9432324a7 - Show all commits
+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"