diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 661062779..ba4acfa5b 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -87,10 +87,15 @@ jobs: LITHE_VERSION: ${{ steps.version.outputs.version }} LITHE_WINDOWS_TIMESTAMP_SERVER: ${{ secrets.WINDOWS_TIMESTAMP_SERVER }} WINDOWS_RELEASE_SIGNED: ${{ steps.signing.outputs.signed }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + LITHE_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} run: | $packageArgs = @{ Configuration = "Release" Version = $env:LITHE_VERSION + UpdaterEndpoint = "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download/latest.json" + RequireUpdaterArtifacts = $true } if ($env:WINDOWS_RELEASE_SIGNED -eq "true") { $packageArgs.RequireAuthenticodeSignature = $true @@ -99,6 +104,15 @@ jobs: } ./scripts/package-windows.ps1 @packageArgs + - name: Create updater manifest + shell: pwsh + run: | + ./scripts/create-windows-updater-manifest.ps1 ` + -Version "${{ steps.version.outputs.version }}" ` + -Repository "$env:GITHUB_REPOSITORY" ` + -ReleaseTag "${{ steps.version.outputs.tag }}" ` + -ReleaseNotesPath "docs/releases/v${{ steps.version.outputs.version }}.md" + - name: Verify installer checksum shell: pwsh run: | @@ -134,4 +148,7 @@ jobs: gh release upload $env:RELEASE_TAG ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe" ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe.sha256" ` + "dist/Lithe-$env:LITHE_VERSION-windows-x64.nsis.zip" ` + "dist/Lithe-$env:LITHE_VERSION-windows-x64.nsis.zip.sig" ` + "dist/latest.json" ` --repo $env:GITHUB_REPOSITORY --clobber diff --git a/docs/releases/windows-updater.md b/docs/releases/windows-updater.md new file mode 100644 index 000000000..0283a7b96 --- /dev/null +++ b/docs/releases/windows-updater.md @@ -0,0 +1,63 @@ +# Windows in-app updates + +The Windows application uses the Tauri v2 updater with the existing NSIS +bundle. Stable releases publish a signed updater archive and `latest.json` +alongside the normal Windows installer. The application checks the manifest at +the `latest` stable GitHub Release for the repository that built it. + +## One-time repository configuration + +An owner of the release repository must generate the updater signing keypair: + +```powershell +cd windows/tauri +bunx tauri signer generate +``` + +Store the generated values in the release repository settings: + +| Kind | Name | Value | +| --- | --- | --- | +| Actions secret | `TAURI_SIGNING_PRIVATE_KEY` | Complete generated private key | +| Actions secret | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Private-key password; omit this secret when the key has no password | +| Actions variable | `TAURI_UPDATER_PUBLIC_KEY` | Complete generated public key | + +Never commit the private key or its password. The public key is injected into +the packaged application by the release workflow and is safe to store as a +repository variable. + +The existing `WINDOWS_SIGNING_CERTIFICATE_BASE64`, +`WINDOWS_SIGNING_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_SERVER` settings +continue to control Windows Authenticode signing. Authenticode and Tauri updater +signatures serve different purposes and should both be configured for a public +release. + +## Stable release artifacts + +The `Release Windows` workflow publishes: + +- `Lithe--windows-x64.exe` +- `Lithe--windows-x64.exe.sha256` +- `Lithe--windows-x64.nsis.zip` +- `Lithe--windows-x64.nsis.zip.sig` +- `latest.json` + +`latest.json` points at the versioned updater archive in the same GitHub +Release. The workflow fails instead of publishing an unsigned updater when the +updater keypair is not configured. + +## Release verification + +Before announcing a stable release, install the preceding Windows version and +verify this sequence against the new release: + +1. **Help > Check for Updates** reports the new version and release notes. +2. Download progress reaches completion. +3. Unsaved buffers are handled before the application exits. +4. The updater installs the signed NSIS bundle and relaunches Lithe. +5. The relaunched application reports the new version. +6. A second manual check reports that the application is current. + +Also verify that a manifest with a modified signature is rejected. Use a +temporary keypair and a fork Release for development tests; never reuse a test +private key for official releases. diff --git a/scripts/create-windows-updater-manifest.ps1 b/scripts/create-windows-updater-manifest.ps1 new file mode 100644 index 000000000..9348dd2aa --- /dev/null +++ b/scripts/create-windows-updater-manifest.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string]$Version, + [Parameter(Mandatory = $true)] + [ValidatePattern('^[^/\s]+/[^/\s]+$')] + [string]$Repository, + [string]$ReleaseTag = "v$Version", + [string]$OutputDirectory = "dist", + [string]$ReleaseNotesPath +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$output = Join-Path $root $OutputDirectory +$assetName = "Lithe-$Version-windows-x64.nsis.zip" +$asset = Join-Path $output $assetName +$signaturePath = "$asset.sig" + +if (-not (Test-Path -LiteralPath $asset -PathType Leaf)) { + throw "Windows updater bundle does not exist: $asset" +} +if (-not (Test-Path -LiteralPath $signaturePath -PathType Leaf)) { + throw "Windows updater signature does not exist: $signaturePath" +} + +$signature = (Get-Content -LiteralPath $signaturePath -Raw).Trim() +if ([string]::IsNullOrWhiteSpace($signature)) { + throw "Windows updater signature is empty: $signaturePath" +} + +$notes = "Lithe $Version for Windows." +if (-not [string]::IsNullOrWhiteSpace($ReleaseNotesPath)) { + $resolvedNotesPath = if ([System.IO.Path]::IsPathRooted($ReleaseNotesPath)) { + $ReleaseNotesPath + } else { + Join-Path $root $ReleaseNotesPath + } + if (Test-Path -LiteralPath $resolvedNotesPath -PathType Leaf) { + $notes = (Get-Content -LiteralPath $resolvedNotesPath -Raw).Trim() + } +} + +$encodedTag = [System.Uri]::EscapeDataString($ReleaseTag) +$encodedAssetName = [System.Uri]::EscapeDataString($assetName) +$downloadURL = "https://github.com/$Repository/releases/download/$encodedTag/$encodedAssetName" +$manifest = [ordered]@{ + version = $Version + notes = $notes + pub_date = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + platforms = [ordered]@{ + "windows-x86_64" = [ordered]@{ + signature = $signature + url = $downloadURL + } + } +} + +New-Item -ItemType Directory -Force -Path $output | Out-Null +$manifestPath = Join-Path $output "latest.json" +$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8 +Write-Output "Windows updater manifest created: $manifestPath" diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 601bd8a2a..aa115aa64 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -6,16 +6,61 @@ param( [string]$OutputDirectory = "dist", [string]$CertificateThumbprint = $env:LITHE_WINDOWS_CERTIFICATE_THUMBPRINT, [string]$TimestampServer = $env:LITHE_WINDOWS_TIMESTAMP_SERVER, - [switch]$RequireAuthenticodeSignature + [switch]$RequireAuthenticodeSignature, + [string]$UpdaterPublicKey = $env:LITHE_UPDATER_PUBLIC_KEY, + [string]$UpdaterEndpoint = $env:LITHE_UPDATER_ENDPOINT, + [switch]$RequireUpdaterArtifacts ) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $windowsApp = Join-Path $root "windows/tauri" $output = Join-Path $root $OutputDirectory -$versionConfig = Join-Path $env:RUNNER_TEMP "lithe-tauri-version.json" +$taskTempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() +} else { + $env:RUNNER_TEMP +} +$versionConfig = Join-Path $taskTempRoot "lithe-tauri-version.json" + +$versionOverrides = @{ + version = $Version + bundle = @{} +} +if (-not [string]::IsNullOrWhiteSpace($CertificateThumbprint)) { + $windowsSigning = @{ + certificateThumbprint = $CertificateThumbprint + digestAlgorithm = "sha256" + } + if (-not [string]::IsNullOrWhiteSpace($TimestampServer)) { + $windowsSigning.timestampUrl = $TimestampServer + } + $versionOverrides.bundle.windows = $windowsSigning +} elseif ($RequireAuthenticodeSignature) { + throw "Authenticode signing is required but no certificate thumbprint was configured." +} + +if ($RequireUpdaterArtifacts) { + if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { + throw "Tauri updater signing is required but TAURI_SIGNING_PRIVATE_KEY is not configured." + } + if ([string]::IsNullOrWhiteSpace($UpdaterPublicKey)) { + throw "Tauri updater signing is required but LITHE_UPDATER_PUBLIC_KEY is not configured." + } + if ([string]::IsNullOrWhiteSpace($UpdaterEndpoint)) { + throw "Tauri updater signing is required but LITHE_UPDATER_ENDPOINT is not configured." + } -@{ version = $Version } | ConvertTo-Json | Set-Content -Encoding utf8 $versionConfig + $versionOverrides.bundle.createUpdaterArtifacts = true + $versionOverrides.plugins = @{ + updater = @{ + pubkey = $UpdaterPublicKey + endpoints = @($UpdaterEndpoint) + } + } +} + +$versionOverrides | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $versionConfig Set-Location $windowsApp & bun install --frozen-lockfile if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed" } @@ -43,27 +88,32 @@ $installer = Join-Path $output "Lithe-$Version-windows-x64.exe" Copy-Item -LiteralPath $bundle.FullName -Destination $installer -Force if (-not [string]::IsNullOrWhiteSpace($CertificateThumbprint)) { - $certificate = Get-ChildItem -LiteralPath "Cert:\CurrentUser\My\$CertificateThumbprint" ` - -ErrorAction SilentlyContinue - if ($null -eq $certificate) { - throw "The requested Authenticode certificate is not installed: $CertificateThumbprint" - } - $signatureArgs = @{ - FilePath = $installer - Certificate = $certificate - HashAlgorithm = "SHA256" - } - if (-not [string]::IsNullOrWhiteSpace($TimestampServer)) { - $signatureArgs.TimestampServer = $TimestampServer - } - $signature = Set-AuthenticodeSignature @signatureArgs + $signature = Get-AuthenticodeSignature -LiteralPath $installer if ($signature.Status -ne "Valid") { - throw "Authenticode signing failed: $($signature.Status)" + throw "Tauri Authenticode signing failed: $($signature.Status)" } -} elseif ($RequireAuthenticodeSignature) { - throw "Authenticode signing is required but no certificate thumbprint was configured." } $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant() "$hash $(Split-Path -Leaf $installer)" | Set-Content -Encoding ascii "$installer.sha256" Write-Output "Windows installer created: $installer" + +if ($RequireUpdaterArtifacts) { + $updaterBundle = Get-ChildItem -LiteralPath $bundleDirectory -Filter "*.nsis.zip" -File | + Select-Object -First 1 + if ($null -eq $updaterBundle) { + throw "Tauri updater bundle was not found in $bundleDirectory" + } + + $updaterSignature = Get-Item -LiteralPath "$($updaterBundle.FullName).sig" ` + -ErrorAction SilentlyContinue + if ($null -eq $updaterSignature) { + throw "Tauri updater signature was not found for $($updaterBundle.Name)" + } + + $publishedUpdaterBundle = Join-Path $output "Lithe-$Version-windows-x64.nsis.zip" + Copy-Item -LiteralPath $updaterBundle.FullName -Destination $publishedUpdaterBundle -Force + Copy-Item -LiteralPath $updaterSignature.FullName ` + -Destination "$publishedUpdaterBundle.sig" -Force + Write-Output "Windows updater bundle created: $publishedUpdaterBundle" +} diff --git a/scripts/test-windows-updater-manifest.ps1 b/scripts/test-windows-updater-manifest.ps1 new file mode 100644 index 000000000..c73e1e6be --- /dev/null +++ b/scripts/test-windows-updater-manifest.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$testOutput = Join-Path $root "dist/updater-manifest-test" +$version = "9.8.7" +$bundle = Join-Path $testOutput "Lithe-$version-windows-x64.nsis.zip" + +try { + New-Item -ItemType Directory -Force -Path $testOutput | Out-Null + Set-Content -LiteralPath $bundle -Value "test updater bundle" -Encoding ascii + Set-Content -LiteralPath "$bundle.sig" -Value "test-signature" -Encoding ascii + + & "$PSScriptRoot/create-windows-updater-manifest.ps1" ` + -Version $version ` + -Repository "example/Lithe-IDEA" ` + -ReleaseTag "v$version" ` + -OutputDirectory "dist/updater-manifest-test" + + $manifest = Get-Content -LiteralPath (Join-Path $testOutput "latest.json") -Raw | + ConvertFrom-Json + if ($manifest.version -ne $version) { throw "Manifest version is incorrect" } + $platform = $manifest.platforms.'windows-x86_64' + if ($platform.signature -ne "test-signature") { throw "Manifest signature is incorrect" } + $expectedURL = "https://github.com/example/Lithe-IDEA/releases/download/v$version/Lithe-$version-windows-x64.nsis.zip" + if ($platform.url -ne $expectedURL) { throw "Manifest download URL is incorrect" } + + Write-Output "Windows updater manifest test passed." +} finally { + if (Test-Path -LiteralPath $testOutput) { + Remove-Item -LiteralPath $testOutput -Recurse -Force + } +}