Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 947bb8e020 docs: Windows signing is release-only, allowlisted, and gated by branch protection
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / test (pull_request) Successful in 4m4s
Build App (Preview) / build-macos (pull_request) Successful in 3m5s
Build App (Preview) / build-windows (pull_request) Successful in 5m35s
Build App (Preview) / build-linux (pull_request) Successful in 10m8s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:35:12 -07:00
shadowdaoandClaude Opus 5.5 a9432324a7 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>
2026-09-23 18:16:32 -07:00
shadowdaoandClaude Opus 5.5 cc274f39a8 fix(ci): pass the sign command with --config and prove the uninstaller was signed
Secret Scan / scan (push) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / test (pull_request) Successful in 3m41s
Build App (Preview) / build-linux (pull_request) Successful in 7m8s
Build App (Preview) / build-windows (pull_request) Failing after 8m53s
Build App (Preview) / prune-previews (pull_request) Skipped
The first signing run built nothing signed, and "Verify signatures" failed
it as intended. The Tauri 2 CLI never reads TAURI_CONFIG. It only sets that
variable for tauri-build, so the sign command was dropped silently, just as
the inline beforeBuildCommand override had been for as long as the Windows
jobs have set it. The setup now writes a config file, and the build passes
it with `cargo tauri build --config`.

Review follow-ups:
- The NSIS uninstaller is written to %TEMP% and signed from 32-bit makensis.
  SYSTEM's %TEMP% sits under System32, which WOW64 redirects for makensis but
  not for the x64 signtool, so they would disagree about where the file is.
  %TEMP% and %TMP% now point into the workspace. makensis 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 signing client is pinned by SHA-512, so the pin can be checked against
  nuget.org's published packageHash.
- tauri-cli on Windows is pinned to =2.11.0 --locked, the @tauri-apps/cli
  version the Linux and macOS jobs run from the lockfile, instead of "^2".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:05:03 -07:00
shadowdaoandClaude Opus 5.5 44e9bd2916 ci: sign Windows builds with Azure Artifact Signing
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m19s
Build App (Preview) / test (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 5m18s
Build App (Preview) / build-windows (pull_request) Failing after 5m32s
Build App (Preview) / prune-previews (pull_request) Skipped
Releases and PR previews now sign the app binary, the MSI, the NSIS
installer and its uninstaller. "Verify signatures" fails the job on any
unsigned or untimestamped .exe/.msi, so an unsigned installer can't ship
quietly.

- windows-signing-setup.ps1 fetches Microsoft.ArtifactSigning.Client 1.0.128
  and a job-local .NET 10.0.12 runtime, each pinned by hash. Nothing is
  installed on the build VM. It also writes the dlib metadata and exports
  TAURI_CONFIG with bundle.windows.signCommand.
- windows-sign.ps1 runs the installed signtool with /dlib, SHA-256 and the
  Microsoft timestamp server, with retries. Credentials come only from the
  AZURE_* environment. The metadata excludes every credential type except
  EnvironmentCredential, because InteractiveBrowserCredential would hang a
  job running as SYSTEM.
- The signing files go in the workspace, not %TEMP%, because the uninstaller
  is signed from 32-bit makensis and WOW64 redirects SYSTEM's %TEMP%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:57:32 -07:00
3 changed files with 38 additions and 84 deletions
+1
View File
@@ -682,6 +682,7 @@ jobs:
run: >- run: >-
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File scripts\windows-verify-signatures.ps1 -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\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe app\src-tauri\target\release\bundle\nsis\*.exe
+5 -11
View File
@@ -857,20 +857,14 @@ merge, dispatch `build-app.yml` on the branch. Every publishing step there is ga
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the - `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 Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment. `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice, **It signs only an allowlist of what ships**, about 4 signatures per release. Tauri also
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer; presents build-time tools: the WiX extension DLLs, the NSIS plugins, and the app binary a
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS second time for the second bundle type. Signing those would roughly triple the metered count
plugins, and signing those would more than double the metered count for no user-visible for no user-visible benefit. If the app ever ships resource DLLs or sidecars, extend the
benefit. If the app ever ships resource DLLs or sidecars, extend the
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`, "failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
`signtool /debug` included), and the job prints it on failure. `signtool /debug` included), and the job prints it on failure.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the - `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp.
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
installer, which cannot be unpacked that way, it requires the signing log to show the app
binary and the uninstaller were signed.
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`, Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the `ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
+32 -73
View File
@@ -1,19 +1,11 @@
# windows-verify-signatures.ps1 <path-or-wildcard>... - fail unless everything # windows-verify-signatures.ps1 <path-or-wildcard>... - fail unless every file
# that ships carries a valid, timestamped Authenticode signature. # carries a valid, timestamped Authenticode signature.
# #
# The check that makes signing load-bearing rather than hopeful: Tauri skips # The check that makes signing load-bearing rather than hopeful: Tauri skips
# signing silently in some configurations (no sign command, --no-sign), and an # signing silently in some configurations (no sign command, --no-sign), and an
# unsigned installer looks exactly like a signed one until SmartScreen blocks # unsigned installer looks exactly like a signed one until SmartScreen blocks
# it on a user's machine. Every pattern must match at least one file, so a # it on a user's machine. Every pattern must match at least one file, so a
# bundle that was never produced cannot pass either. # bundle that was never produced cannot pass either.
#
# Pass the installers, not target\release\triple-c.exe. The app binary users
# get is the copy inside each installer: Tauri patches the loose file with
# bundle-type information before each bundle, signs it, packages it, and
# patches it again, so the loose copy ends up unsigned by design. The MSI is
# unpacked with an administrative install and its binaries checked directly;
# the NSIS installer cannot be unpacked that way, so for it the signing log
# must show the app binary and the uninstaller were signed.
param([Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)][string[]]$Patterns) param([Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)][string[]]$Patterns)
@@ -26,82 +18,49 @@ $files = foreach ($pattern in $Patterns) {
$found $found
} }
function Test-Signature([IO.FileInfo]$File, [string]$Label) { $failed = @()
# signtool's own check - chain to a trusted root under the default foreach ($file in $files) {
# Authenticode policy - with Stop relaxed for the native call, as in # signtool's own check: chain to a trusted root under the default
# windows-sign.ps1. # Authenticode policy.
# Stop relaxed for the native call, as in windows-sign.ps1.
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
$output = & $env:TRIPLE_C_SIGNTOOL verify /pa $File.FullName 2>&1 | ForEach-Object { "$_" } $verifyOutput = & $env:TRIPLE_C_SIGNTOOL verify /pa $file.FullName 2>&1 | ForEach-Object { "$_" }
$signtoolOk = ($LASTEXITCODE -eq 0) $signtoolOk = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not $signtoolOk) { $output | Write-Host } if (-not $signtoolOk) { $verifyOutput | Write-Host }
# And the timestamp, which signtool verify does not require. # And the timestamp, which signtool verify does not require.
$sig = Get-AuthenticodeSignature -FilePath $File.FullName $sig = Get-AuthenticodeSignature -FilePath $file.FullName
$timestamped = $null -ne $sig.TimeStamperCertificate $timestamped = $null -ne $sig.TimeStamperCertificate
if ($signtoolOk -and $sig.Status -eq 'Valid' -and $timestamped) { if ($signtoolOk -and $sig.Status -eq 'Valid' -and $timestamped) {
Write-Host "OK $Label - $($sig.SignerCertificate.Subject)" Write-Host "OK $($file.Name) - $($sig.SignerCertificate.Subject)"
return $true
}
Write-Host "FAIL $Label - status $($sig.Status), signtool $(if ($signtoolOk) {'ok'} else {'failed'}), timestamped $timestamped"
return $false
}
function Get-SignedLog {
if ($env:TRIPLE_C_SIGN_LOG -and (Test-Path $env:TRIPLE_C_SIGN_LOG)) { return @(Get-Content $env:TRIPLE_C_SIGN_LOG) }
return @()
}
$failed = @()
$nsisBuilt = $false
foreach ($file in $files) {
if (-not (Test-Signature $file $file.Name)) { $failed += $file.Name }
if ($file.FullName -match '\\bundle\\nsis\\') { $nsisBuilt = $true }
if ($file.Extension -eq '.msi') {
$extract = Join-Path ([IO.Path]::GetTempPath()) ('msi-verify-' + [guid]::NewGuid().ToString('N'))
$proc = Start-Process msiexec.exe -Wait -PassThru `
-ArgumentList '/a', "`"$($file.FullName)`"", '/qn', "TARGETDIR=`"$extract`""
$inner = @()
if ($proc.ExitCode -eq 0) { $inner = @(Get-ChildItem -Path $extract -Recurse -File -Include *.exe, *.dll) }
if ($proc.ExitCode -ne 0) {
Write-Host "FAIL $($file.Name) - administrative extract exited $($proc.ExitCode)"
$failed += "$($file.Name) (extract)"
} elseif ($inner.Count -eq 0) {
Write-Host "FAIL $($file.Name) - contains no executable to check"
$failed += "$($file.Name) (no executable)"
}
foreach ($f in $inner) {
if (-not (Test-Signature $f "$($file.Name) > $($f.Name)")) { $failed += "$($file.Name) > $($f.Name)" }
}
Remove-Item -Recurse -Force $extract -ErrorAction SilentlyContinue
}
}
# What the NSIS installer carries but cannot be unpacked here. windows-sign.ps1
# logs every file it signs. The app binary is signed in place under
# target\release; the uninstaller is the file makensis wrote under the job's
# temp directory (see windows-signing-setup.ps1) - and makensis ignores the
# sign command's exit code for it, so without this a failure there is silent.
if ($nsisBuilt) {
$log = Get-SignedLog
$appSigned = @($log | Where-Object { $_ -match '\\target\\release\\[^\\]+\.exe$' })
if ($appSigned.Count -eq 0) {
Write-Host 'FAIL app binary - no signature was logged for it before packaging'
$failed += 'app binary'
} else { } else {
Write-Host "OK app binary - signed before packaging ($($appSigned.Count)x)" Write-Host "FAIL $($file.Name) - status $($sig.Status), signtool $(if ($signtoolOk) {'ok'} else {'failed'}), timestamped $timestamped"
$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 $tmp = $env:TRIPLE_C_SIGN_TMP
$uninstaller = @() $log = $env:TRIPLE_C_SIGN_LOG
if ($tmp) { $uninstaller = @($log | Where-Object { $_.StartsWith($tmp, [StringComparison]::OrdinalIgnoreCase) }) } $signedInTmp = @()
if ($uninstaller.Count -eq 0) { if ($tmp -and $log -and (Test-Path $log)) {
Write-Host 'FAIL NSIS uninstaller - no signature was logged for it' $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' $failed += 'NSIS uninstaller'
} else { } else {
Write-Host "OK NSIS uninstaller - signed as $($uninstaller[-1])" Write-Host "OK NSIS uninstaller - signed as $($signedInTmp[-1])"
} }
} }
if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" } if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" }
Write-Host 'Everything that ships is signed and timestamped.' Write-Host "All $(@($files).Count) files are signed and timestamped."