CI: sign Windows builds with Azure Artifact Signing #62
@@ -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: |
|
||||
|
||||
@@ -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%"
|
||||
|
||||
+70
-29
@@ -1,7 +1,18 @@
|
||||
# windows-sign.ps1 <file> - 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') {
|
||||
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',
|
||||
'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" }
|
||||
}
|
||||
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
|
||||
# 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',
|
||||
$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
|
||||
}
|
||||
|
||||
$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,
|
||||
$Path
|
||||
)
|
||||
$full
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
if ($env:TRIPLE_C_SIGN_LOG) {
|
||||
[IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false))
|
||||
}
|
||||
[IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8)
|
||||
Write-Log 'signed'
|
||||
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) }
|
||||
}
|
||||
exit 1
|
||||
} catch {
|
||||
Write-Log "windows-sign.ps1 failed: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
exit 1
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user