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
|
# 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: |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user