Skip to content

Commit f9f6af3

Browse files
author
kigner
committed
fix: harden portable updater workflow
1 parent b0fb03a commit f9f6af3

8 files changed

Lines changed: 207 additions & 38 deletions

File tree

scripts/build_update_release.ps1

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ param(
1111
[string]$PythonDepsSource = "",
1212
[switch]$IncludeUpdater,
1313
[string]$UpdaterMinisignBinary = "",
14-
[ValidatePattern('^\d+\.\d+\.\d+$')][string]$MinimumUpdater = "1.1.0",
14+
[ValidatePattern('^\d+\.\d+\.\d+$')][string]$MinimumUpdater = "1.1.1",
15+
[string]$SupportedFrom = ">=0.2.0",
1516
[switch]$Stable,
1617
[switch]$AllowDirty
1718
)
@@ -254,6 +255,18 @@ if (-not (Test-Path -LiteralPath $MinisignPublicKey -PathType Leaf)) {
254255
if ((Get-Content -LiteralPath $MinisignPublicKey -Raw -Encoding UTF8) -match 'REPLACE_WITH_') {
255256
throw "Refusing to use a placeholder minisign public key."
256257
}
258+
$packagedUpdaterVersion = (Get-Content -LiteralPath (Join-Path $repoRoot "updater\updater.version") -Raw).Trim()
259+
if ([version]$packagedUpdaterVersion -lt [version]$MinimumUpdater) {
260+
throw "Repository updater $packagedUpdaterVersion is older than -MinimumUpdater $MinimumUpdater."
261+
}
262+
if ([string]::IsNullOrWhiteSpace($SupportedFrom)) {
263+
throw "-SupportedFrom cannot be empty."
264+
}
265+
foreach ($clause in ($SupportedFrom -split '\s+' | Where-Object { $_ -ne "" })) {
266+
if ($clause -notmatch '^(>=|<=|>|<|=)?\d+(?:\.\d+){1,3}$') {
267+
throw "Unsupported -SupportedFrom clause '$clause'."
268+
}
269+
}
257270
if ($null -eq (Get-Command $MinisignPath -ErrorAction SilentlyContinue)) {
258271
throw "Minisign executable not found: $MinisignPath"
259272
}
@@ -285,7 +298,7 @@ $manifest = [ordered]@{
285298
product = "audiocpp-portable"
286299
platform = "windows-x64"
287300
version = $Version
288-
supported_from = ">=0.2.0 <0.3.0"
301+
supported_from = $SupportedFrom
289302
minimum_updater = $MinimumUpdater
290303
components = @($components | ForEach-Object { ConvertTo-ManifestComponent $_ })
291304
preserve = $preserveRules

scripts/build_updater_bootstrap.ps1

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[CmdletBinding()]
22
param(
3-
[string]$Version = "0.2.0",
3+
[ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version = "0.2.0",
44
[Parameter(Mandatory = $true)][string]$MinisignBinary,
55
[Parameter(Mandatory = $true)][string]$PublicKeyPath,
66
[string]$OutputPath = ""
@@ -47,6 +47,12 @@ try {
4747
$versionValue.components.app = $Version
4848
$versionValue.components.core_cpu = $Version
4949
$versionValue.components.core_cuda = $Version
50+
$updaterVersion = (Get-Content -LiteralPath (Join-Path $repoRoot "updater\updater.version") -Raw).Trim()
51+
if ($null -eq $versionValue.components.PSObject.Properties["updater"]) {
52+
$versionValue.components | Add-Member -NotePropertyName "updater" -NotePropertyValue $updaterVersion
53+
} else {
54+
$versionValue.components.updater = $updaterVersion
55+
}
5056
$versionValue | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $stage "version.json") -Encoding UTF8
5157

5258
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null

tests/updater/run_tests.ps1

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ $ProgressPreference = "SilentlyContinue"
77

88
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\.."))
99
$testRoot = Join-Path $PSScriptRoot ".tmp-$PID"
10+
$testUpdaterVersion = (Get-Content -LiteralPath (Join-Path $repoRoot "updater\updater.version") -Raw).Trim()
1011
$requiredPreserve = @(
1112
"models/**",
1213
"webui/voice/**",
@@ -33,6 +34,7 @@ function New-TestBundle {
3334
param([string]$Name)
3435
$root = Join-Path $testRoot $Name
3536
New-Item -ItemType Directory -Path (Join-Path $root "updater") -Force | Out-Null
37+
Copy-Item -LiteralPath (Join-Path $repoRoot "update.bat") -Destination (Join-Path $root "update.bat")
3638
Copy-Item -LiteralPath (Join-Path $repoRoot "updater\updater.ps1") -Destination (Join-Path $root "updater\updater.ps1")
3739
Copy-Item -LiteralPath (Join-Path $repoRoot "updater\apply-update.ps1") -Destination (Join-Path $root "updater\apply-update.ps1")
3840
Copy-Item -LiteralPath (Join-Path $repoRoot "updater\updater.version") -Destination (Join-Path $root "updater\updater.version")
@@ -50,7 +52,7 @@ function New-TestBundle {
5052
channel = "stable"
5153
platform = "windows-x64"
5254
python = "3.11"
53-
components = [ordered]@{ app = "0.2.0"; core_cpu = "0.2.0"; core_cuda = "0.2.0"; python_env = "0.2.0"; updater = "1.1.0" }
55+
components = [ordered]@{ app = "0.2.0"; core_cpu = "0.2.0"; core_cuda = "0.2.0"; python_env = "0.2.0"; updater = $testUpdaterVersion }
5456
}
5557
Write-Json $version (Join-Path $root "version.json")
5658
return $root
@@ -132,7 +134,7 @@ function New-CustomManifest {
132134
param(
133135
[string]$Directory,
134136
[object[]]$Components,
135-
[string]$MinimumUpdater = "1.1.0",
137+
[string]$MinimumUpdater = $testUpdaterVersion,
136138
[string[]]$HealthChecks = @()
137139
)
138140
$value = [ordered]@{
@@ -271,6 +273,17 @@ function Invoke-TestUpdater {
271273
return [pscustomobject]@{ ExitCode = $exitCode; Output = ($output -join "`n") }
272274
}
273275

276+
function Invoke-TestBatchUpdater {
277+
param(
278+
[string]$Root,
279+
[string[]]$Arguments
280+
)
281+
$batchPath = Join-Path $Root "update.bat"
282+
$output = @(& $batchPath @Arguments 2>&1)
283+
$exitCode = $LASTEXITCODE
284+
return [pscustomobject]@{ ExitCode = $exitCode; Output = ($output -join "`n") }
285+
}
286+
274287
function Test-CheckMode {
275288
$root = New-TestBundle "check"
276289
$assets = Join-Path $root "assets"
@@ -282,6 +295,30 @@ function Test-CheckMode {
282295
Assert-True ((Read-JsonFile (Join-Path $root "version.json")).version -eq "0.2.0") "check mode changed version.json"
283296
}
284297

298+
function Test-BatchStableChannelCheck {
299+
$root = New-TestBundle "batch entry with spaces"
300+
$assets = Join-Path $root "release assets"
301+
New-Item -ItemType Directory -Path $assets -Force | Out-Null
302+
$archive = New-TestArchive $assets "app.zip" "webui/managed.txt"
303+
$manifest = New-TestManifest $assets $archive
304+
$stable = Join-Path $assets "stable.json"
305+
Write-Json ([ordered]@{
306+
schema = 1
307+
channel = "stable"
308+
version = "0.2.1"
309+
manifest_url = $manifest
310+
signature_url = "$manifest.minisig"
311+
notes_url = "https://example.invalid/audio.cpp/v0.2.1"
312+
}) $stable
313+
$result = Invoke-TestBatchUpdater $root @(
314+
"--check", "-StableUrl", $stable,
315+
"-MinisignPath", (Join-Path $root "updater\test-minisign.cmd"),
316+
"-PublicKeyPath", (Join-Path $root "updater\test-public-key.txt")
317+
)
318+
Assert-True ($result.ExitCode -eq 0) "update.bat stable-channel check failed: $($result.Output)"
319+
Assert-True ($result.Output -match "Target version\s+: 0\.2\.1") "update.bat did not report the stable target version"
320+
}
321+
285322
function Read-JsonFile {
286323
param([string]$Path)
287324
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -344,10 +381,20 @@ function Test-ProtectedPathAndLock {
344381
Assert-True ($result.ExitCode -ne 0) "updater accepted a protected payload path"
345382

346383
New-Item -ItemType Directory -Path (Join-Path $root "_update") -Force | Out-Null
347-
Set-Content -LiteralPath (Join-Path $root "_update\update.lock") -Value "owned by another process" -Encoding ASCII
384+
$lockPath = Join-Path $root "_update\update.lock"
385+
$lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
386+
try {
387+
$result = Invoke-TestUpdater $root "--check" $manifest
388+
Assert-True ($result.ExitCode -ne 0) "updater ignored an active lock"
389+
Assert-True (Test-Path -LiteralPath $lockPath) "updater removed another process's active lock"
390+
} finally {
391+
$lockStream.Dispose()
392+
}
393+
394+
Set-Content -LiteralPath $lockPath -Value "stale interrupted update" -Encoding ASCII
348395
$result = Invoke-TestUpdater $root "--check" $manifest
349-
Assert-True ($result.ExitCode -ne 0) "updater ignored an existing lock"
350-
Assert-True (Test-Path -LiteralPath (Join-Path $root "_update\update.lock")) "updater removed another process's lock"
396+
Assert-True ($result.ExitCode -eq 0) "updater did not recover from a stale lock: $($result.Output)"
397+
Assert-True (-not (Test-Path -LiteralPath $lockPath)) "updater left the recovered stale lock behind"
351398
}
352399

353400
function Test-PythonDependencyUpdateAndRollback {
@@ -466,7 +513,7 @@ function Test-ReleaseBuilder {
466513
"audiocpp-core-cpu-win-x64-v0.2.1.zip",
467514
"audiocpp-core-cuda-win-x64-v0.2.1.zip",
468515
"audiocpp-python-deps-py311-win-x64-v0.2.1.zip",
469-
"audiocpp-updater-v1.1.0.zip",
516+
"audiocpp-updater-v$testUpdaterVersion.zip",
470517
"manifest-v0.2.1.json",
471518
"manifest-v0.2.1.json.minisig",
472519
"stable.json",
@@ -479,6 +526,7 @@ function Test-ReleaseBuilder {
479526
$manifest = Read-JsonFile (Join-Path $output "manifest-v0.2.1.json")
480527
Assert-True (@($manifest.components).Count -eq 5) "release manifest does not contain all five component types"
481528
Assert-True (@($manifest.preserve) -contains "webui/voice/**") "release manifest omitted voice preservation"
529+
Assert-True ([string]$manifest.supported_from -eq ">=0.2.0") "release manifest does not keep bootstrap installs eligible for later versions"
482530
$stable = Read-JsonFile (Join-Path $output "stable.json")
483531
Assert-True ([string]$stable.manifest_url -match '/v0\.2\.1-windows-prebuilt/manifest-v0\.2\.1\.json$') "stable.json points at the wrong manifest"
484532

@@ -494,13 +542,16 @@ function Test-ReleaseBuilder {
494542
foreach ($relative in @("update.bat", "version.json", "updater\updater.ps1", "updater\apply-update.ps1", "updater\updater.version", "updater\public-key.txt", "updater\minisign.exe")) {
495543
Assert-True (Test-Path -LiteralPath (Join-Path $bootstrapExpanded $relative) -PathType Leaf) "bootstrap ZIP omitted $relative"
496544
}
545+
$bootstrapVersion = Read-JsonFile (Join-Path $bootstrapExpanded "version.json")
546+
Assert-True ([string]$bootstrapVersion.components.updater -eq $testUpdaterVersion) "bootstrap version.json does not match updater.version"
497547
}
498548

499549
try {
500550
if (Test-Path -LiteralPath $testRoot) { Remove-Item -LiteralPath $testRoot -Recurse -Force }
501551
New-Item -ItemType Directory -Path $testRoot -Force | Out-Null
502552
$tests = @(
503553
"Test-CheckMode",
554+
"Test-BatchStableChannelCheck",
504555
"Test-DryRunAndHashValidation",
505556
"Test-ApplySuccessAndPreserve",
506557
"Test-RollbackOnHealthFailure",

update.bat

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
@echo off
22
setlocal
33

4-
set "ROOT=%~dp0"
5-
set "UPDATER=%ROOT%updater\updater.ps1"
6-
7-
if not exist "%UPDATER%" (
8-
echo [audio.cpp updater] Missing: "%UPDATER%"
9-
exit /b 2
10-
)
11-
12-
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%UPDATER%" %*
4+
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0updater\updater.ps1" %*
135
set "EXIT_CODE=%ERRORLEVEL%"
146

157
if "%~1"=="" (

updater/README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ secret key outside the repository and outside the portable package.
3030
Release assets are generated by `scripts/build_update_release.ps1`. The
3131
self-contained updater acceptance test is `tests/updater/run_tests.ps1`.
3232

33+
`update.bat` is intentionally only a launcher. Argument parsing, online checks,
34+
downloads, validation, locking, installation, health checks, and rollback all
35+
live in the PowerShell scripts so updater maintenance does not require batch
36+
logic changes.
37+
3338
Generate the one-time overlay patch for existing `0.2.0` bundles with:
3439

3540
```powershell
@@ -51,6 +56,11 @@ CPU/CUDA executables with:
5156
-Stable
5257
```
5358

59+
The default compatibility range is `>=0.2.0`, so bundles that receive the
60+
one-time 0.2.0 bootstrap remain eligible for later 0.3.x and newer releases.
61+
Use `-SupportedFrom` only when a future release intentionally drops an older
62+
portable layout.
63+
5464
When Python dependencies change, prepare a directory with this layout and pass
5565
it as `-PythonDepsSource`:
5666

@@ -112,7 +122,7 @@ Build a release containing dependency and updater components with:
112122
-PythonDepsSource C:\release-work\python-deps-v0.2.1 `
113123
-IncludeUpdater `
114124
-UpdaterMinisignBinary C:\release-tools\minisign.exe `
115-
-MinimumUpdater 1.1.0 `
125+
-MinimumUpdater 1.1.1 `
116126
-MinisignSecretKey C:\secure\audio-cpp.key `
117127
-MinisignPublicKey C:\release-tools\audio-cpp.pub `
118128
-MinisignPath C:\release-tools\minisign.exe `
@@ -123,6 +133,17 @@ Omit `-PythonDepsSource` when dependencies did not change. Omit
123133
`-IncludeUpdater` when updater files did not change. The release builder always
124134
generates app, core-cpu, and core-cuda snapshots.
125135

136+
For every stable online release, publish the generated `stable.json`, signed
137+
manifest, manifest signature, and all component ZIPs in the same non-draft,
138+
non-prerelease GitHub Release, and mark that Release as latest. The updater's
139+
permanent channel URL is GitHub's `releases/latest/download/stable.json`; if the
140+
latest Release omits `stable.json`, online checks will correctly fail instead
141+
of installing an ambiguous payload.
142+
143+
After publishing, extract the bootstrap over a clean 0.2.0 portable copy and
144+
run `update.bat --check`. Do not distribute the bootstrap until that command
145+
reports the newly published target version through the default channel URL.
146+
126147
The bootstrap ZIP is the small net-disk patch described by the release plan.
127148
It contains the updater and local version metadata, but no models, Python
128149
environment, application payload, or runtime binaries.

0 commit comments

Comments
 (0)