-
Notifications
You must be signed in to change notification settings - Fork 510
657 lines (617 loc) · 31.7 KB
/
Copy pathtest-installer.yml
File metadata and controls
657 lines (617 loc) · 31.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
# Post-release verification of the signed Windows MSI installer.
#
# Downloads the MSI attached to a published release, then on a clean
# windows-latest runner: verifies checksum + Authenticode signature, performs a
# silent install, checks the Apps & Features registration / PATH / smoke-runs
# the CLI, and uninstalls cleanly. A second job installs the previous release's
# MSI first and upgrades over it to catch duplicate-entry and leftover-file
# regressions.
name: Windows Installer Test
# Dispatched by release.yml (the release orchestrator) as part of post-release
# artifact verification; can also be run manually against any published release.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to test (e.g. v0.7-release or v0.7.1)'
required: true
previous-tag:
description: 'Release tag to upgrade from (default: auto-detect previous release)'
required: false
# NOTE: This workflow never writes to the repository; `contents: write` is required
# only because draft-release assets are invisible to read-only tokens, and the
# release orchestrator runs this verification while the release is still a draft.
permissions:
contents: write
jobs:
resolve:
name: Resolve Release Tags
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
msi-version: ${{ steps.resolve.outputs.msi-version }}
prev-tag: ${{ steps.resolve.outputs.prev-tag }}
prev-msi-version: ${{ steps.resolve.outputs.prev-msi-version }}
steps:
- name: Resolve current and previous release
id: resolve
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_PREV_TAG: ${{ inputs.previous-tag }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
if [[ -z "$TAG" ]]; then
echo "::error::No tag to test: the 'tag' input is required."
exit 1
fi
# Both tag styles are in use (v0.7-release and v0.7.1); mirror the
# normalization release-test.yml applies to pushed tags.
VERSION="${TAG#v}"
VERSION="${VERSION%-release}"
# Pad to the numeric X.Y.Z the installer stamps into the MSI
# ProductVersion / DisplayVersion; mirrors msi_version() in
# scripts/_release_common.py ("0.7" -> "0.7.0").
IFS=. read -r a b c _ <<<"$VERSION"
MSI_VERSION="${a}.${b:-0}.${c:-0}"
if [[ -n "$INPUT_PREV_TAG" ]]; then
PREV_TAG="$INPUT_PREV_TAG"
else
# Newest-first list of published stable releases; the previous
# release is the entry right after the current tag.
mapfile -t tags < <(gh release list --exclude-drafts --exclude-pre-releases \
--limit 30 --json tagName -q '.[].tagName')
PREV_TAG=""
found=0
for i in "${!tags[@]}"; do
if [[ "${tags[$i]}" == "$TAG" ]]; then
found=1
PREV_TAG="${tags[$((i + 1))]:-}"
break
fi
done
if [[ "$found" -eq 0 ]]; then
# Manual dispatch before the release is published: the current
# tag is not listed yet, so upgrade from the newest listed tag
# that differs from it.
for t in "${tags[@]}"; do
if [[ "$t" != "$TAG" ]]; then
PREV_TAG="$t"
break
fi
done
fi
fi
if [[ -z "$PREV_TAG" ]]; then
echo "::error::Could not determine a previous release to upgrade from; pass the 'previous-tag' input."
exit 1
fi
PREV_VERSION="${PREV_TAG#v}"
PREV_VERSION="${PREV_VERSION%-release}"
IFS=. read -r a b c _ <<<"$PREV_VERSION"
PREV_MSI_VERSION="${a}.${b:-0}.${c:-0}"
{
echo "tag=$TAG"
echo "version=$VERSION"
echo "msi-version=$MSI_VERSION"
echo "prev-tag=$PREV_TAG"
echo "prev-msi-version=$PREV_MSI_VERSION"
} >> "$GITHUB_OUTPUT"
echo "Testing $TAG (display $VERSION, MSI $MSI_VERSION); upgrading from $PREV_TAG (MSI $PREV_MSI_VERSION)"
fresh-install:
name: Fresh Install
runs-on: windows-latest
needs: resolve
env:
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
MSI_VERSION: ${{ needs.resolve.outputs.msi-version }}
steps:
# Test videos live on the resources branch; this is the same layout
# build-windows.yml smoke-tests the portable distribution against.
- uses: actions/checkout@v5
with:
ref: resources
- name: Write helper functions
# Each `run:` step is a fresh pwsh process, so shared functions go into
# a file (in RUNNER_TEMP, which checkout cannot clean away) that later
# steps dot-source.
run: |
$helpers = @'
# All hives an uninstall entry could land in. The MSI installs
# per-machine (the .aip sets ALLUSERS=2 and the runner is elevated),
# so 64-bit HKLM is the expected home; the others are scanned so a
# misplaced entry fails the assertions loudly instead of hiding.
$UninstallRoots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
)
function Get-PySceneDetectEntries {
# Every uninstall entry (any hive, visible or hidden) for
# PySceneDetect. A healthy install has exactly two: the MSI
# ProductCode key (hidden from Apps & Features by
# ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible
# custom key "PySceneDetect <msi-version>" that carries
# DisplayVersion / InstallLocation.
foreach ($root in $UninstallRoots) {
if (-not (Test-Path $root)) { continue }
foreach ($key in Get-ChildItem $root) {
$props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue
if ($props.DisplayName -like 'PySceneDetect*') {
[pscustomobject]@{
KeyPath = $key.PSPath
KeyName = $key.PSChildName
DisplayName = $props.DisplayName
DisplayVersion = $props.DisplayVersion
InstallLocation = $props.InstallLocation
SystemComponent = $props.SystemComponent
}
}
}
}
}
function Get-VisiblePySceneDetectEntries {
# The set Apps & Features actually shows: SystemComponent != 1.
Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 }
}
function Get-UninstallKeyByName {
# The visible key's name embeds the MSI version ("PySceneDetect
# 0.7.0"), so a lookup by name across hives is a precise
# per-version existence check.
param([Parameter(Mandatory)][string]$KeyName)
foreach ($root in $UninstallRoots) {
$path = Join-Path $root $KeyName
if (Test-Path $path) { $path }
}
}
function Invoke-Msiexec {
# msiexec detaches from the console immediately, so a bare
# `msiexec ...` would return before the Windows Installer service
# finishes (and without the real exit code); Start-Process
# -Wait -PassThru blocks and surfaces it.
param(
[Parameter(Mandatory)][string[]]$MsiArgs,
[Parameter(Mandatory)][string]$LogPath
)
$log = Join-Path (Get-Location) $LogPath
for ($attempt = 1; $attempt -le 3; $attempt++) {
$p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru
switch ($p.ExitCode) {
0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return }
3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return }
1618 {
# ERROR_INSTALL_ALREADY_RUNNING: runner provisioning
# sometimes still holds the machine-wide MSI mutex.
Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3"
if ($attempt -lt 3) { Start-Sleep -Seconds 30 }
}
default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" }
}
}
throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts'
}
function Get-PathRegistryValues {
# The installer edits PATH in the registry only; neither this
# process nor its children see the change, so assertions must
# read the raw values. Machine PATH is where a per-machine
# install writes (the .aip Environment row uses the '*' system
# prefix); HKCU is read too for completeness.
$values = @()
$machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue
if ($machine) { $values += $machine.Path }
$user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue
if ($user) { $values += $user.Path }
$values
}
function Test-DirOnRegistryPath {
param([Parameter(Mandatory)][string]$Directory)
$needle = $Directory.TrimEnd('\')
foreach ($value in Get-PathRegistryValues) {
# -contains is case-insensitive, matching how Windows treats paths.
if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) {
return $true
}
}
return $false
}
function Assert-CleanRemoval {
param([string]$InstallDir)
$entries = @(Get-PySceneDetectEntries)
if ($entries.Count -ne 0) {
$entries | Format-List | Out-String | Write-Host
throw "Expected zero uninstall entries after uninstall, found $($entries.Count)"
}
# Also match stale keys by name in case a leftover key lost its
# DisplayName value.
foreach ($root in $UninstallRoots) {
if (-not (Test-Path $root)) { continue }
$stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' })
if ($stale.Count -ne 0) {
throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')"
}
}
if ($InstallDir) {
if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) {
throw "scenedetect.exe still present in $InstallDir after uninstall"
}
if (Test-DirOnRegistryPath -Directory $InstallDir) {
throw "$InstallDir still present in a PATH registry value after uninstall"
}
}
Write-Host 'Verified clean removal.'
}
'@
$dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1'
Set-Content -LiteralPath $dest -Value $helpers
Write-Host "Wrote $dest"
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist
if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" }
$expected = "PySceneDetect-$env:VERSION-win64.msi"
$msis = @(Get-ChildItem dist -Filter '*.msi')
if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) {
throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')"
}
if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' }
- name: Verify checksum and Authenticode signature
run: |
$msi = "dist\PySceneDetect-$env:VERSION-win64.msi"
$name = Split-Path $msi -Leaf
# SHA256SUMS lines are `<hash> <name>` (two spaces; the sha256sum -c
# format written by scripts/finalize_windows_dist.py).
$sums = @{}
foreach ($line in Get-Content dist\SHA256SUMS) {
$hash, $entry = $line -split ' ', 2
$sums[$entry.Trim()] = $hash.Trim()
}
if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" }
$actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $sums[$name].ToLowerInvariant()) {
throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual"
}
Write-Host "Checksum OK: $actual"
$sig = Get-AuthenticodeSignature $msi
if ($sig.Status -ne 'Valid') {
throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')"
}
Write-Host "Signature OK: $($sig.SignerCertificate.Subject)"
- name: Install MSI
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
$msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path
Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install.log
- name: Verify installation
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
# Locate the install via the registry - never hard-code the path: the
# .aip uses ALLUSERS=2 with a MixedAllUsersInstallLocation custom
# action, so APPDIR is resolved at install time.
$visible = @(Get-VisiblePySceneDetectEntries)
if ($visible.Count -ne 1) {
Get-PySceneDetectEntries | Format-List | Out-String | Write-Host
throw "Expected exactly 1 visible uninstall entry, found $($visible.Count)"
}
$entry = $visible[0]
if ($entry.DisplayVersion -ne $env:MSI_VERSION) {
throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'"
}
if (-not $entry.InstallLocation) { throw 'InstallLocation is empty' }
$exe = Join-Path $entry.InstallLocation 'scenedetect.exe'
if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" }
# Persist the discovered install dir for the later steps.
Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)"
# Invoke by absolute path: the installer's PATH change is
# registry-only and not visible to this already-running process.
$out = & $exe version 2>&1 | Out-String
Write-Host $out
if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" }
if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" }
if (-not (Test-DirOnRegistryPath -Directory $entry.InstallLocation)) {
throw "$($entry.InstallLocation) was not added to any PATH registry value"
}
Write-Host 'Install verified.'
- name: Functional smoke test
run: |
# Same clip and command build-windows.yml uses to smoke-test the
# portable distribution.
$exe = Join-Path $env:INSTALL_DIR 'scenedetect.exe'
& $exe -i tests/resources/goldeneye.mp4 detect-content time --end 10s
if ($LASTEXITCODE -ne 0) { throw "smoke test exited with $LASTEXITCODE" }
- name: Uninstall MSI
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
# Uninstall via the same local MSI file (not the ProductCode) so a
# broken product registration surfaces as a failure here.
$msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path
Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log
- name: Verify clean removal
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
Assert-CleanRemoval -InstallDir $env:INSTALL_DIR
- name: Upload msiexec logs
if: failure()
uses: actions/upload-artifact@v6
with:
name: fresh-install-logs
path: '*.log'
if-no-files-found: ignore
upgrade:
name: Upgrade From Previous Release
runs-on: windows-latest
needs: resolve
env:
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
MSI_VERSION: ${{ needs.resolve.outputs.msi-version }}
PREV_TAG: ${{ needs.resolve.outputs.prev-tag }}
PREV_MSI_VERSION: ${{ needs.resolve.outputs.prev-msi-version }}
steps:
- name: Write helper functions
# Identical to the fresh-install helpers; jobs cannot share script
# blocks, so the definitions are duplicated per job.
run: |
$helpers = @'
# All hives an uninstall entry could land in. The MSI installs
# per-machine (the .aip sets ALLUSERS=2 and the runner is elevated),
# so 64-bit HKLM is the expected home; the others are scanned so a
# misplaced entry fails the assertions loudly instead of hiding.
$UninstallRoots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
)
function Get-PySceneDetectEntries {
# Every uninstall entry (any hive, visible or hidden) for
# PySceneDetect. A healthy install has exactly two: the MSI
# ProductCode key (hidden from Apps & Features by
# ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible
# custom key "PySceneDetect <msi-version>" that carries
# DisplayVersion / InstallLocation.
foreach ($root in $UninstallRoots) {
if (-not (Test-Path $root)) { continue }
foreach ($key in Get-ChildItem $root) {
$props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue
if ($props.DisplayName -like 'PySceneDetect*') {
[pscustomobject]@{
KeyPath = $key.PSPath
KeyName = $key.PSChildName
DisplayName = $props.DisplayName
DisplayVersion = $props.DisplayVersion
InstallLocation = $props.InstallLocation
SystemComponent = $props.SystemComponent
}
}
}
}
}
function Get-VisiblePySceneDetectEntries {
# The set Apps & Features actually shows: SystemComponent != 1.
Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 }
}
function Get-UninstallKeyByName {
# The visible key's name embeds the MSI version ("PySceneDetect
# 0.7.0"), so a lookup by name across hives is a precise
# per-version existence check.
param([Parameter(Mandatory)][string]$KeyName)
foreach ($root in $UninstallRoots) {
$path = Join-Path $root $KeyName
if (Test-Path $path) { $path }
}
}
function Invoke-Msiexec {
# msiexec detaches from the console immediately, so a bare
# `msiexec ...` would return before the Windows Installer service
# finishes (and without the real exit code); Start-Process
# -Wait -PassThru blocks and surfaces it.
param(
[Parameter(Mandatory)][string[]]$MsiArgs,
[Parameter(Mandatory)][string]$LogPath
)
$log = Join-Path (Get-Location) $LogPath
for ($attempt = 1; $attempt -le 3; $attempt++) {
$p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru
switch ($p.ExitCode) {
0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return }
3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return }
1618 {
# ERROR_INSTALL_ALREADY_RUNNING: runner provisioning
# sometimes still holds the machine-wide MSI mutex.
Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3"
if ($attempt -lt 3) { Start-Sleep -Seconds 30 }
}
default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" }
}
}
throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts'
}
function Get-PathRegistryValues {
# The installer edits PATH in the registry only; neither this
# process nor its children see the change, so assertions must
# read the raw values. Machine PATH is where a per-machine
# install writes (the .aip Environment row uses the '*' system
# prefix); HKCU is read too for completeness.
$values = @()
$machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue
if ($machine) { $values += $machine.Path }
$user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue
if ($user) { $values += $user.Path }
$values
}
function Test-DirOnRegistryPath {
param([Parameter(Mandatory)][string]$Directory)
$needle = $Directory.TrimEnd('\')
foreach ($value in Get-PathRegistryValues) {
# -contains is case-insensitive, matching how Windows treats paths.
if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) {
return $true
}
}
return $false
}
function Assert-CleanRemoval {
param([string]$InstallDir)
$entries = @(Get-PySceneDetectEntries)
if ($entries.Count -ne 0) {
$entries | Format-List | Out-String | Write-Host
throw "Expected zero uninstall entries after uninstall, found $($entries.Count)"
}
# Also match stale keys by name in case a leftover key lost its
# DisplayName value.
foreach ($root in $UninstallRoots) {
if (-not (Test-Path $root)) { continue }
$stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' })
if ($stale.Count -ne 0) {
throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')"
}
}
if ($InstallDir) {
if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) {
throw "scenedetect.exe still present in $InstallDir after uninstall"
}
if (Test-DirOnRegistryPath -Directory $InstallDir) {
throw "$InstallDir still present in a PATH registry value after uninstall"
}
}
Write-Host 'Verified clean removal.'
}
'@
$dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1'
Set-Content -LiteralPath $dest -Value $helpers
Write-Host "Wrote $dest"
- name: Download previous release MSI
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
gh release download $env:PREV_TAG --pattern '*-win64.msi' --dir prev
if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:PREV_TAG" }
$msis = @(Get-ChildItem prev -Filter '*.msi')
if ($msis.Count -ne 1) {
throw "Expected exactly one MSI from $env:PREV_TAG, got: $($msis.Name -join ', ')"
}
# Older releases may not ship SHA256SUMS, so the previous MSI is
# deliberately not checksummed; only the new MSI under test is.
Add-Content $env:GITHUB_ENV "PREV_MSI=$($msis[0].FullName)"
- name: Install previous release
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
Invoke-Msiexec -MsiArgs @('/i', $env:PREV_MSI, '/qn', '/norestart') -LogPath install-prev.log
# Sanity check the baseline before upgrading over it.
$visible = @(Get-VisiblePySceneDetectEntries)
if ($visible.Count -ne 1) {
Get-PySceneDetectEntries | Format-List | Out-String | Write-Host
throw "Expected exactly 1 visible uninstall entry after baseline install, found $($visible.Count)"
}
if ($visible[0].DisplayVersion -ne $env:PREV_MSI_VERSION) {
throw "Baseline DisplayVersion is '$($visible[0].DisplayVersion)', expected '$env:PREV_MSI_VERSION'"
}
$exe = Join-Path $visible[0].InstallLocation 'scenedetect.exe'
if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($visible[0].InstallLocation)" }
& $exe version
if ($LASTEXITCODE -ne 0) { throw "baseline scenedetect version exited with $LASTEXITCODE" }
Add-Content $env:GITHUB_ENV "OLD_INSTALL_DIR=$($visible[0].InstallLocation)"
- name: Download new release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist
if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" }
$expected = "PySceneDetect-$env:VERSION-win64.msi"
$msis = @(Get-ChildItem dist -Filter '*.msi')
if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) {
throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')"
}
if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' }
- name: Verify checksum and Authenticode signature
run: |
$msi = "dist\PySceneDetect-$env:VERSION-win64.msi"
$name = Split-Path $msi -Leaf
# SHA256SUMS lines are `<hash> <name>` (two spaces; the sha256sum -c
# format written by scripts/finalize_windows_dist.py).
$sums = @{}
foreach ($line in Get-Content dist\SHA256SUMS) {
$hash, $entry = $line -split ' ', 2
$sums[$entry.Trim()] = $hash.Trim()
}
if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" }
$actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $sums[$name].ToLowerInvariant()) {
throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual"
}
Write-Host "Checksum OK: $actual"
$sig = Get-AuthenticodeSignature $msi
if ($sig.Status -ne 'Valid') {
throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')"
}
Write-Host "Signature OK: $($sig.SignerCertificate.Subject)"
- name: Install new release over previous
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
$msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path
Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install-upgrade.log
- name: Verify upgrade
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
$visible = @(Get-VisiblePySceneDetectEntries)
if ($visible.Count -ne 1) {
Get-PySceneDetectEntries | Format-List | Out-String | Write-Host
throw "Expected exactly 1 visible uninstall entry after upgrade, found $($visible.Count)"
}
$entry = $visible[0]
if ($entry.DisplayVersion -ne $env:MSI_VERSION) {
throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'"
}
# The visible key name embeds the version ("PySceneDetect
# <msi-version>"), so the old key vanishing from every hive is a
# precise duplicate-Apps-&-Features-entry check.
$stale = @(Get-UninstallKeyByName -KeyName "PySceneDetect $env:PREV_MSI_VERSION")
if ($stale.Count -ne 0) {
throw "Previous version's uninstall key still present: $($stale -join ', ')"
}
# Belt and braces: no entry anywhere (visible or hidden) may still
# report the old version.
$old = @(Get-PySceneDetectEntries | Where-Object { $_.DisplayVersion -eq $env:PREV_MSI_VERSION })
if ($old.Count -ne 0) {
$old | Format-List | Out-String | Write-Host
throw "Found $($old.Count) uninstall entries still at $env:PREV_MSI_VERSION"
}
if (-not $entry.InstallLocation) { throw 'InstallLocation is empty after upgrade' }
$exe = Join-Path $entry.InstallLocation 'scenedetect.exe'
if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" }
$out = & $exe version 2>&1 | Out-String
Write-Host $out
if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" }
if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" }
# If the upgrade relocated the install, the old copy must be gone.
if ($env:OLD_INSTALL_DIR.TrimEnd('\') -ne $entry.InstallLocation.TrimEnd('\')) {
if (Test-Path (Join-Path $env:OLD_INSTALL_DIR 'scenedetect.exe')) {
throw "Old install at $env:OLD_INSTALL_DIR still present after relocating upgrade"
}
}
Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)"
Write-Host 'Upgrade verified.'
- name: Uninstall new MSI
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
$msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path
Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log
- name: Verify clean removal
run: |
. (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1')
Assert-CleanRemoval -InstallDir $env:INSTALL_DIR
- name: Upload msiexec logs
if: failure()
uses: actions/upload-artifact@v6
with:
name: upgrade-logs
path: '*.log'
if-no-files-found: ignore