fix: sign Windows release installers

This commit is contained in:
lucaronin
2026-05-27 15:27:34 +02:00
parent f80e571c78
commit ba3f324a52
16 changed files with 357 additions and 21 deletions

View File

@@ -0,0 +1,115 @@
param(
[string]$ConfigPath = "src-tauri/tauri.windows-signing.conf.json"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Read-FirstEnv {
param([string[]]$Names)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
throw "Set one of these environment variables: $($Names -join ', ')"
}
function Read-OptionalEnv {
param(
[string[]]$Names,
[string]$DefaultValue
)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
return $DefaultValue
}
function Normalize-Thumbprint {
param([string]$Thumbprint)
return ($Thumbprint -replace "\s", "").ToUpperInvariant()
}
function Convert-CertificateSecretToBytes {
param([string]$CertificateSecret)
$Base64Lines = $CertificateSecret -split "\r?\n" |
Where-Object { $_ -notmatch "^-+BEGIN " -and $_ -notmatch "^-+END " }
$CertificateBase64 = ($Base64Lines -join "") -replace "\s", ""
try {
return [Convert]::FromBase64String($CertificateBase64)
} catch {
throw "Windows code-signing certificate must be base64-encoded PFX data."
}
}
$CertificateSecret = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE", "WINDOWS_CERTIFICATE")
$CertificatePassword = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_PASSWORD")
$ConfiguredThumbprint = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT", "WINDOWS_CERTIFICATE_THUMBPRINT") ""
$DigestAlgorithm = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_DIGEST_ALGORITHM") "sha256"
$TimestampUrl = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_TIMESTAMP_URL", "WINDOWS_TIMESTAMP_URL") "http://timestamp.digicert.com"
$TempRoot = Join-Path ([IO.Path]::GetTempPath()) "tolaria-windows-signing"
if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
$TempRoot = Join-Path $env:RUNNER_TEMP "tolaria-windows-signing"
}
New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null
$PfxPath = Join-Path $TempRoot "certificate.pfx"
[IO.File]::WriteAllBytes($PfxPath, (Convert-CertificateSecretToBytes $CertificateSecret))
$SecurePassword = ConvertTo-SecureString -String $CertificatePassword -Force -AsPlainText
$ImportedCertificates = @(Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $SecurePassword)
Remove-Item -Force -ErrorAction SilentlyContinue $PfxPath
$ImportedCertificate = $ImportedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
if ($null -eq $ImportedCertificate) {
throw "The imported Windows code-signing certificate does not include a private key."
}
if ([string]::IsNullOrWhiteSpace($ConfiguredThumbprint)) {
$CertificateThumbprint = Normalize-Thumbprint $ImportedCertificate.Thumbprint
} else {
$CertificateThumbprint = Normalize-Thumbprint $ConfiguredThumbprint
}
$StoreCertificate = Get-ChildItem Cert:\CurrentUser\My |
Where-Object { (Normalize-Thumbprint $_.Thumbprint) -eq $CertificateThumbprint } |
Select-Object -First 1
if ($null -eq $StoreCertificate) {
throw "The requested Windows code-signing certificate thumbprint was not found in Cert:\CurrentUser\My."
}
$Config = @{
bundle = @{
windows = @{
certificateThumbprint = $CertificateThumbprint
digestAlgorithm = $DigestAlgorithm
timestampUrl = $TimestampUrl
}
}
}
$ResolvedConfigPath = Resolve-Path -Path (Split-Path -Parent $ConfigPath) -ErrorAction SilentlyContinue
if ($null -eq $ResolvedConfigPath) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ConfigPath) | Out-Null
}
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding utf8NoBOM
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
"WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT=$CertificateThumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
Write-Host "Prepared Windows Authenticode signing config at $ConfigPath."

View File

@@ -457,6 +457,10 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
@@ -464,6 +468,27 @@ jobs:
exit 1
fi
done
if [ -z "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] && [ -z "$WINDOWS_CERTIFICATE" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers."
exit 1
fi
if [ -z "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] && [ -z "$WINDOWS_CERTIFICATE_PASSWORD" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD or WINDOWS_CERTIFICATE_PASSWORD is required to import the Windows signing certificate."
exit 1
fi
- name: Prepare Windows Authenticode signing
shell: pwsh
env:
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }}
WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
run: ./.github/scripts/configure-windows-authenticode.ps1
- name: Build Tauri app (Windows bundles)
env:
@@ -475,7 +500,38 @@ jobs:
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json
- name: Validate Windows Authenticode signatures
shell: pwsh
run: |
$expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT
if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) {
throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step."
}
$expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant()
$paths = @()
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue
$paths = @($paths | Sort-Object FullName -Unique)
if ($paths.Count -eq 0) {
throw "No Windows executable or installer artifacts found to verify."
}
foreach ($path in $paths) {
$signature = Get-AuthenticodeSignature -FilePath $path.FullName
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)"
}
if ($null -eq $signature.SignerCertificate) {
throw "Missing signer certificate for $($path.FullName)."
}
$actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant()
if ($actualThumbprint -ne $expectedThumbprint) {
throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint"
}
Write-Host "Authenticode signature OK: $($path.FullName)"
}
- name: Validate Windows bundles
shell: bash

View File

@@ -513,6 +513,10 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
@@ -520,6 +524,27 @@ jobs:
exit 1
fi
done
if [ -z "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] && [ -z "$WINDOWS_CERTIFICATE" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers."
exit 1
fi
if [ -z "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] && [ -z "$WINDOWS_CERTIFICATE_PASSWORD" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD or WINDOWS_CERTIFICATE_PASSWORD is required to import the Windows signing certificate."
exit 1
fi
- name: Prepare Windows Authenticode signing
shell: pwsh
env:
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }}
WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
run: ./.github/scripts/configure-windows-authenticode.ps1
- name: Build Tauri app (Windows bundles)
env:
@@ -531,7 +556,38 @@ jobs:
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json
- name: Validate Windows Authenticode signatures
shell: pwsh
run: |
$expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT
if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) {
throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step."
}
$expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant()
$paths = @()
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue
$paths = @($paths | Sort-Object FullName -Unique)
if ($paths.Count -eq 0) {
throw "No Windows executable or installer artifacts found to verify."
}
foreach ($path in $paths) {
$signature = Get-AuthenticodeSignature -FilePath $path.FullName
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)"
}
if ($null -eq $signature.SignerCertificate) {
throw "Missing signer certificate for $($path.FullName)."
}
$actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant()
if ($actualThumbprint -ne $expectedThumbprint) {
throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint"
}
Write-Host "Authenticode signature OK: $($path.FullName)"
}
- name: Validate Windows bundles
shell: bash

View File

@@ -43,7 +43,7 @@ brew install --cask tolaria
### Download from releases
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install.
## Getting started

View File

@@ -939,6 +939,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Windows job imports the CI code-signing certificate, builds NSIS with a generated Tauri Authenticode signing config, and verifies the app executable plus installer signatures with `Get-AuthenticodeSignature` before upload. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Authenticode-signed Windows x64 installers plus Tauri-signed updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts selected non-Windows installers without replacing the page with a blank download navigation. Windows visitors see an explicit signed-installer action and managed-device approval guidance instead of an automatic download. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -938,7 +938,9 @@ push to main
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ pnpm install, stamp version, import the Windows code-signing certificate
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis with Authenticode signing config
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ build-linux job:
→ pnpm install, stamp version
@@ -972,7 +974,9 @@ push stable-vYYYY.M.D tag
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ pnpm install, stamp version, import the Windows code-signing certificate
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis with Authenticode signing config
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
@@ -981,7 +985,7 @@ push stable-vYYYY.M.D tag
→ build VitePress public docs into the GitHub Pages root
→ build static HTML release history page at /releases/
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, and expose RPM as a manual Linux option when the stable release includes one
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, require an explicit Windows installer click with managed-device signing guidance, and expose RPM as a manual Linux option when the stable release includes one
→ preserve alpha/latest.json
→ deploy to gh-pages
```

View File

@@ -53,7 +53,7 @@ Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
```
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. Windows release jobs import the CI code-signing certificate, build NSIS with a generated Tauri Authenticode signing config, and verify the app executable plus installer signatures before upload. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
## Quick Start

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0130"
title: "Windows Authenticode signing for release installers"
status: active
date: 2026-05-27
---
## Context
Tolaria's Windows release job already produced Tauri updater signatures, but those signatures are not the Windows trust signal used by SmartScreen, Smart App Control, Defender, or WDAC policies when a user downloads and runs an installer from the browser. A managed Windows 11 user reported that the stable NSIS installer was blocked by Windows Security with no bypass option.
Microsoft's current guidance is that unsigned public installers can be fully blocked by enterprise policy, while signed installers at least carry a publisher identity and can build reputation across releases. Store distribution would provide the strongest SmartScreen outcome, but Tolaria does not currently publish a Microsoft Store package.
## Decision
**Tolaria release CI must Authenticode-sign Windows app executables and installers before publishing them.**
- Alpha and stable Windows release jobs import a CI-provided code-signing certificate from GitHub secrets.
- The workflow generates a temporary Tauri config that sets `bundle.windows.certificateThumbprint`, `digestAlgorithm`, and `timestampUrl`, then passes that config to `pnpm tauri build`.
- The Windows job verifies the produced app executable and installer artifacts with `Get-AuthenticodeSignature` and fails before upload if any signature is missing, invalid, or signed by an unexpected certificate.
- The public stable download page requires an explicit Windows installer click and tells managed-device users that IT may need to approve the Tolaria publisher before first install.
## Options considered
- **CI-enforced Authenticode signing** (chosen): gives Windows users and enterprise admins a real publisher identity, lets certificate reputation transfer across releases, and blocks accidental publication of unsigned installers. Cons: release jobs now depend on code-signing secrets and a valid certificate.
- **Documentation-only SmartScreen warning**: cheaper, but it leaves managed-device users with no supported path when policy removes the bypass option.
- **Microsoft Store distribution only**: strongest SmartScreen behavior, but it requires a separate packaging, submission, and release-management path that Tolaria does not yet own.
- **Portable ZIP fallback**: still downloads executable content from the browser and can remain subject to SmartScreen, Mark-of-the-Web, Smart App Control, or WDAC policy.
## Consequences
- Windows release failures caused by missing or expired code-signing credentials are intentional release blockers.
- Tauri updater signatures remain required for in-app updates, but they are treated as separate from Windows Authenticode trust.
- Enterprise-managed Windows installs can be documented around a stable Tolaria publisher identity instead of asking users to disable security policy.
- A future Microsoft Store/MSIX distribution path can supersede or supplement this policy if Tolaria decides to support Store-managed installs.

View File

@@ -180,3 +180,5 @@ proposed → active → superseded
| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active |
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) |
| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active |
| [0129](0129-tolaria-vault-item-deep-links.md) | Tolaria vault item deep links | active |
| [0130](0130-windows-authenticode-release-signing.md) | Windows Authenticode signing for release installers | active |

View File

@@ -23,11 +23,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -120,11 +120,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -28,11 +28,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -39,6 +39,7 @@
"Download",
"Homebrew",
"Platform Status",
"Managed Windows Devices",
"After Installing"
]
},

View File

@@ -111,11 +111,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -49,6 +49,30 @@ describe('release workflow macOS artifact names', () => {
expect(countOccurrences(alphaWorkflow, releaseEnv)).toBe(3)
expect(countOccurrences(stableWorkflow, releaseEnv)).toBe(3)
})
it('requires Authenticode signing for Windows release installers', () => {
const alphaWorkflow = readFileSync(`${process.cwd()}/.github/workflows/release.yml`, 'utf8')
const stableWorkflow = readFileSync(
`${process.cwd()}/.github/workflows/release-stable.yml`,
'utf8',
)
const signingScript = readFileSync(
`${process.cwd()}/.github/scripts/configure-windows-authenticode.ps1`,
'utf8',
)
for (const workflow of [alphaWorkflow, stableWorkflow]) {
expect(workflow).toContain('WINDOWS_CODE_SIGNING_CERTIFICATE')
expect(workflow).toContain('WINDOWS_CERTIFICATE')
expect(workflow).toContain('./.github/scripts/configure-windows-authenticode.ps1')
expect(workflow).toContain('--config src-tauri/tauri.windows-signing.conf.json')
expect(workflow).toContain('Validate Windows Authenticode signatures')
expect(workflow).toContain('Get-AuthenticodeSignature')
}
expect(signingScript).toContain('certificateThumbprint')
expect(signingScript).toContain('timestampUrl')
expect(signingScript).toContain('WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT')
})
})
describe('extractStableDownloadTargets', () => {
@@ -114,10 +138,12 @@ describe('buildStableDownloadRedirectPage', () => {
expect(html).toContain('Tolaria Stable Download')
expect(html).toContain('DOWNLOAD_TARGETS')
expect(html).toContain('Download Tolaria for Windows')
expect(html).toContain('Windows installers are Authenticode-signed')
expect(html).toContain('Download Tolaria for macOS Apple Silicon')
expect(html).toContain('Download Tolaria for Intel Mac')
expect(html).toContain('hasMultipleMacDownloads')
expect(html).toContain('Choose the Apple Silicon or Intel Mac download below.')
expect(html).toContain('requiresWindowsInstallChoice')
expect(html).toContain('tolaria-download-frame')
expect(html).toContain('color-scheme: light dark')
expect(html).toContain('@media (prefers-color-scheme: dark)')
@@ -137,6 +163,7 @@ describe('buildStableDownloadRedirectPage', () => {
expect(html).toContain('target="tolaria-download-frame"')
expect(html).toContain('sandbox="allow-downloads"')
expect(html).toContain('startDownload(target)')
expect(html).toContain('Company-managed devices may require IT approval')
expect(html).not.toContain('window.location.replace')
})

View File

@@ -1,5 +1,7 @@
const RELEASE_HISTORY_URL = 'https://tolaria.md/releases/'
const DOWNLOAD_FRAME_NAME = 'tolaria-download-frame'
const WINDOWS_MANAGED_INSTALL_NOTE =
'Windows installers are Authenticode-signed. Company-managed devices may still require IT to approve the Tolaria publisher before first install.'
type StablePlatformKey =
| 'darwin-aarch64'
@@ -147,6 +149,13 @@ const REDIRECT_PAGE_STYLES = `
line-height: 1.5;
}
.platform-note {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--download-border-default);
font-size: 0.95rem;
}
.button-list {
display: flex;
flex-wrap: wrap;
@@ -419,6 +428,10 @@ function buildDownloadsMarkup(downloads: StableDownloadTargets): string {
const targets = PLATFORM_ORDER
.map((platform) => Reflect.get(downloads, platform) as StableDownloadTarget | undefined)
.filter((target): target is StableDownloadTarget => Boolean(target))
const windowsTarget = Reflect.get(downloads, 'windows-x86_64') as StableDownloadTarget | undefined
const windowsInstallNote = windowsTarget
? `<p class="platform-note">${escapeHtml(WINDOWS_MANAGED_INSTALL_NOTE)}</p>`
: ''
if (targets.length === 0) {
return `<div class="button-list"><a id="download-link" href="${RELEASE_HISTORY_URL}" data-secondary="true">View release history</a></div>`
@@ -441,7 +454,8 @@ function buildDownloadsMarkup(downloads: StableDownloadTargets): string {
<div class="button-list">${secondaryLinks}</div>
<div class="button-list">
<a href="${RELEASE_HISTORY_URL}" data-secondary="true">View release history</a>
</div>`
</div>
${windowsInstallNote}`
}
function buildDownloadFrameMarkup(downloads: StableDownloadTargets): string {
@@ -493,6 +507,10 @@ function buildRedirectMarkup(downloads: StableDownloadTargets): string {
return hasMultipleMacDownloads && /Mac OS X|Macintosh/i.test(navigator.userAgent);
}
function requiresWindowsInstallChoice() {
return Boolean(DOWNLOAD_TARGETS['windows-x86_64']) && /Windows/i.test(navigator.userAgent);
}
function updatePrimaryDownloadLink(target) {
const link = document.getElementById('download-link');
if (!link) return;
@@ -501,23 +519,27 @@ function buildRedirectMarkup(downloads: StableDownloadTargets): string {
link.textContent = target.buttonLabel;
}
function downloadMessage(target, requiresMacChoice) {
function downloadMessage(target, requiresMacChoice, requiresWindowsChoice) {
if (requiresMacChoice) {
return 'Choose the Apple Silicon or Intel Mac download below.';
}
if (requiresWindowsChoice) {
return 'Use the signed Windows installer link below. Company-managed devices may require IT approval of the Tolaria publisher.';
}
return 'Starting the latest stable Tolaria download for ' + target.label + '.';
}
function updateDownloadMessage(target, requiresMacChoice) {
function updateDownloadMessage(target, requiresMacChoice, requiresWindowsChoice) {
const message = document.getElementById('download-message');
if (!message) return;
message.textContent = downloadMessage(target, requiresMacChoice);
message.textContent = downloadMessage(target, requiresMacChoice, requiresWindowsChoice);
}
function scheduleAutomaticDownload(target, requiresMacChoice) {
if (requiresMacChoice) return;
function scheduleAutomaticDownload(target, requiresMacChoice, requiresWindowsChoice) {
if (requiresMacChoice || requiresWindowsChoice) return;
window.setTimeout(function () {
startDownload(target);
@@ -529,9 +551,10 @@ function buildRedirectMarkup(downloads: StableDownloadTargets): string {
if (!target) return;
const requiresMacChoice = requiresMacDownloadChoice();
const requiresWindowsChoice = requiresWindowsInstallChoice();
updatePrimaryDownloadLink(target);
updateDownloadMessage(target, requiresMacChoice);
scheduleAutomaticDownload(target, requiresMacChoice);
updateDownloadMessage(target, requiresMacChoice, requiresWindowsChoice);
scheduleAutomaticDownload(target, requiresMacChoice, requiresWindowsChoice);
}
window.addEventListener('DOMContentLoaded', setupDownloadPage);