From cc274f39a886b809bb8e4b1165cc0585d3269c4d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:05:03 -0700 Subject: [PATCH] fix(ci): pass the sign command with --config and prove the uninstaller was signed 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) --- .gitea/workflows/build-app-preview.yml | 19 +++++++----- .gitea/workflows/build-app.yml | 19 +++++++----- CLAUDE.md | 24 +++++++++------- scripts/windows-sign.ps1 | 9 +++++- scripts/windows-signing-setup.ps1 | 40 ++++++++++++++++++++------ scripts/windows-verify-signatures.ps1 | 22 ++++++++++++++ 6 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 4e38e8f..7426355 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -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: >- diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index ca19088..40dfad3 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -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: >- diff --git a/CLAUDE.md b/CLAUDE.md index 85ed900..d9afab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 index f62e285..bbd3580 100644 --- a/scripts/windows-sign.ps1 +++ b/scripts/windows-sign.ps1 @@ -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) } } diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 index 61b0d66..a3bafaf 100644 --- a/scripts/windows-signing-setup.ps1 +++ b/scripts/windows-signing-setup.ps1 @@ -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.' diff --git a/scripts/windows-verify-signatures.ps1 b/scripts/windows-verify-signatures.ps1 index 21fb882..02003fb 100644 --- a/scripts/windows-verify-signatures.ps1 +++ b/scripts/windows-verify-signatures.ps1 @@ -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."