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
6 changed files with 100 additions and 33 deletions
Showing only changes of commit cc274f39a8 - Show all commits
+12 -7
View File
@@ -670,7 +670,11 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
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.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -694,8 +698,8 @@ jobs:
# 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 exports TAURI_CONFIG with the
# sign command - which is why "Build Tauri app" no longer sets it.
# 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 }}
@@ -708,9 +712,10 @@ jobs:
- name: Build Tauri app
working-directory: ./app
# No TAURI_CONFIG here: "Prepare code signing" exports it with the sign
# command, and a step-level value would override it and silently drop
# signing.
# 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.
env:
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
@@ -720,7 +725,7 @@ jobs:
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%"
- name: Verify signatures
run: >-
+12 -7
View File
@@ -612,7 +612,11 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
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.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -635,8 +639,8 @@ jobs:
# Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1).
# The setup fetches the signing client and a job-local .NET runtime, and
# exports TAURI_CONFIG with the sign command - which is why "Build Tauri
# app" no longer sets it. A missing secret fails here, before the build.
# writes the Tauri config holding the sign command, which "Build Tauri
# app" passes with --config. A missing secret fails here, before the build.
- name: Prepare code signing
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -649,9 +653,10 @@ jobs:
- name: Build Tauri app
working-directory: ./app
# No TAURI_CONFIG here: "Prepare code signing" exports it with the sign
# command, and a step-level value would override it and silently drop
# signing.
# 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.
env:
# Read by the signing dlib itself, never passed on a command line.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -671,7 +676,7 @@ jobs:
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
rem System32 originals, which makes the redirected view resolve. A
rem runner running as a normal user needs no such patch.
cargo tauri build --bundles msi,nsis
cargo tauri build --bundles msi,nsis --config "%TRIPLE_C_TAURI_SIGN_CONFIG%"
- name: Verify signatures
run: >-
+14 -10
View File
@@ -845,8 +845,9 @@ unsigned or untimestamped, so an unsigned installer cannot ship quietly.
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
**each pinned by version and hash**, writes the dlib's `metadata.json`, and exports
`TAURI_CONFIG` with `bundle.windows.signCommand`. Nothing is installed on the build VM. To bump
**each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri
config file with `bundle.windows.signCommand` that the build passes as
`cargo tauri build --config`. Nothing is installed on the build VM. To bump
a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download.
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
@@ -859,14 +860,17 @@ Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as
SYSTEM, where that waits forever for a browser.
- **The "Build Tauri app" steps must not set `TAURI_CONFIG`.** A step-level value overrides the
one the setup exported and drops the sign command without an error — "Verify signatures" is
what would catch it.
- **The signing files live in the workspace, not `%TEMP%`.** The NSIS uninstaller is signed from
inside 32-bit `makensis`, which runs the sign command under 32-bit PowerShell; WOW64 redirects
SYSTEM's `%TEMP%` (under System32) for that process. The workspace is under
`systemprofile\.cache`, which the VM junctions for exactly this (see the comment on
`build-app.yml`'s "Build Tauri app").
- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read
that variable — it only sets it, for tauri-build — so a config put there is dropped without an
error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied;
"Verify signatures" is what exposed it, and it is what would catch a regression.
- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is
written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64
redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64
signtool, so they would disagree about where the file is. The workspace is under
`systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores
the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it
signs and the verify step requires a logged signature under that temp directory.
- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the
`winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's
`signtool` (10.0.26100) but no .NET — hence the job-local runtime.
+8 -1
View File
@@ -47,7 +47,14 @@ $arguments = @(
$ErrorActionPreference = 'Continue'
for ($attempt = 1; $attempt -le 3; $attempt++) {
& $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -eq 0) { exit 0 }
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))
}
exit 0
}
Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)"
if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) }
}
+32 -8
View File
@@ -2,7 +2,10 @@
#
# Run once per job, before `cargo tauri build`. It fetches the two things the
# build VM does not carry, checks each against a pinned hash, and hands the
# rest of the job what `windows-sign.ps1` needs through $GITHUB_ENV:
# rest of the job what `windows-sign.ps1` needs through $GITHUB_ENV, including
# TRIPLE_C_TAURI_SIGN_CONFIG - a config file for `cargo tauri build --config`.
# Not the TAURI_CONFIG variable: the v2 CLI never reads it (it only *sets* it,
# for tauri-build), so a sign command put there is silently ignored.
#
# * Microsoft.ArtifactSigning.Client - the signtool "dlib" that forwards the
# digest to Azure instead of signing with a local certificate.
@@ -19,8 +22,10 @@
# SysWOW64. The workspace sits under systemprofile\.cache, which the VM
# junctions so both views resolve (see "Build Tauri app" in build-app.yml).
#
# Bumping a pin: take the new version's hash from nuget.org / the .NET
# release metadata (releases.json), never from a download you just made.
# Bumping a pin: take the new version's hash from the publisher, never from a
# download you just made - the client's SHA-512 is the base64 `packageHash` in
# its nuget.org catalog entry (hex here), the runtime's is in .NET's
# releases.json.
#
# Required environment (repository secrets): AZURE_TENANT_ID, AZURE_CLIENT_ID,
# AZURE_CLIENT_SECRET, ARTIFACT_SIGNING_ENDPOINT, ARTIFACT_SIGNING_ACCOUNT_NAME,
@@ -32,7 +37,7 @@ $ProgressPreference = 'SilentlyContinue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ClientVersion = '1.0.128'
$ClientSha256 = '74bd7d27e6ce1051409c38d9b46bc8df0400ecd643d51ffbf2ac00869061e40b'
$ClientSha512 = '98f06a691f4fc2fa22f19dcf8556733e98607fbef91a312c453b9b0798cc9088dae0acb36e389b552a11b4d2320324785b8541c2b51091a724c05bc5df5cbf95'
$ClientUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.artifactsigning.client/$ClientVersion/microsoft.artifactsigning.client.$ClientVersion.nupkg"
$DotnetVersion = '10.0.12'
@@ -69,7 +74,7 @@ function Get-Verified([string]$Url, [string]$Name, [string]$Algorithm, [string]$
# The signing client. A .nupkg is a zip; extract it with the framework rather
# than Expand-Archive, which on PowerShell 5.1 refuses any extension but .zip.
$nupkg = Get-Verified $ClientUrl 'client.nupkg' 'SHA256' $ClientSha256
$nupkg = Get-Verified $ClientUrl 'client.nupkg' 'SHA512' $ClientSha512
$clientDir = Join-Path $root 'client'
[IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $clientDir)
$dlib = Join-Path $clientDir 'bin\x64\Azure.CodeSigning.Dlib.dll'
@@ -116,7 +121,8 @@ $utf8 = New-Object System.Text.UTF8Encoding $false
# Tauri runs this for every file it signs - the app binary, the MSI, the NSIS
# installer and (from inside makensis) the uninstaller - with %1 replaced by
# the path. Object form, so paths with spaces survive.
# the path. Object form, so paths with spaces survive. beforeBuildCommand is
# blanked because the job builds the frontend in its own step.
$signScript = Join-Path $workspace 'scripts\windows-sign.ps1'
$tauriConfig = @{
build = @{ beforeBuildCommand = '' }
@@ -124,7 +130,21 @@ $tauriConfig = @{
cmd = 'powershell'
args = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $signScript, '%1')
} } }
} | ConvertTo-Json -Depth 8 -Compress
} | ConvertTo-Json -Depth 8
$tauriConfigPath = Join-Path $root 'tauri.signing.conf.json'
[IO.File]::WriteAllText($tauriConfigPath, $tauriConfig, $utf8)
# The job's temp directory moves into the workspace too. makensis writes the
# uninstaller to %TEMP% before signing it, and SYSTEM's own %TEMP% is under
# System32: the 32-bit makensis and PowerShell would see the SysWOW64 copy of
# that path while the x64 signtool opens the real one, and fail to find the
# file. makensis ignores the sign command's exit code for the uninstaller
# (Tauri emits `!uninstfinalize` without a compare), so that failure would be
# silent - hence the signing log, which "Verify signatures" reads to require
# that a file under this directory, i.e. the uninstaller, really was signed.
$tmpDir = Join-Path $root 'tmp'
New-Item -ItemType Directory -Path $tmpDir | Out-Null
$signLog = Join-Path $root 'signed.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.
@@ -135,7 +155,11 @@ $lines = @(
"TRIPLE_C_SIGN_TIMESTAMP=$TimestampUrl"
"DOTNET_ROOT=$dotnetDir"
"DOTNET_ROOT_X64=$dotnetDir"
"TAURI_CONFIG=$tauriConfig"
"TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath"
"TRIPLE_C_SIGN_LOG=$signLog"
"TRIPLE_C_SIGN_TMP=$tmpDir"
"TEMP=$tmpDir"
"TMP=$tmpDir"
)
[IO.File]::AppendAllText($env:GITHUB_ENV, (($lines -join "`n") + "`n"), $utf8)
Write-Host 'Code signing prepared.'
+22
View File
@@ -40,5 +40,27 @@ foreach ($file in $files) {
$failed += $file.Name
}
}
# The NSIS uninstaller is signed from inside makensis, which ignores the sign
# command's exit code, and it ends up embedded in the installer where the
# checks above cannot reach it. windows-sign.ps1 logs every file it signs; the
# uninstaller is the one makensis wrote under the job's temp directory (see
# windows-signing-setup.ps1), so require at least one logged path there.
$nsisBuilt = @($files | Where-Object { $_.FullName -match '\\bundle\\nsis\\' }).Count -gt 0
if ($nsisBuilt) {
$tmp = $env:TRIPLE_C_SIGN_TMP
$log = $env:TRIPLE_C_SIGN_LOG
$signedInTmp = @()
if ($tmp -and $log -and (Test-Path $log)) {
$signedInTmp = @(Get-Content $log | Where-Object { $_.StartsWith($tmp, [StringComparison]::OrdinalIgnoreCase) })
}
if ($signedInTmp.Count -eq 0) {
Write-Host 'FAIL NSIS uninstaller - no successful signature was logged for it'
$failed += 'NSIS uninstaller'
} else {
Write-Host "OK NSIS uninstaller - signed as $($signedInTmp[-1])"
}
}
if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" }
Write-Host "All $(@($files).Count) files are signed and timestamped."