Skip to content

Commit 157bcb5

Browse files
committed
[dist] Improve release validation checks
1 parent 4e451bf commit 157bcb5

4 files changed

Lines changed: 560 additions & 93 deletions

File tree

RELEASE-PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version
4646
- [ ] Approve code signing request on SignPath, download `scenedetect-signed.zip`
4747
- [ ] Finalize Windows artifacts locally (CI can't do this - signing happens after the AppVeyor build, so the signed-exe swap and hashing must run locally):
4848
- Create `dist/signed/` and copy in both `scenedetect-signed.zip` (from SignPath) and `PySceneDetect-X.Y.Z-win64.zip` (from the AppVeyor `PySceneDetect-win64` artifact).
49-
- Run `python scripts/finalize_windows_dist.py`. This swaps the signed `scenedetect.exe` into the portable `.zip`, repacks it with 7-Zip, copies out the signed `.msi`, and writes `PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`.
50-
- [ ] Draft release on Github using the tagged commit: include full changelog & release notes, signed portable .ZIP, signed .MSI installer, Python .whl/.tar.gz packages, and checksum manifests (`PySceneDetect-X.Y.Z.manifest.json` + `SHA256SUMS`)
49+
- Run `python scripts/finalize_windows_dist.py`. This swaps the signed `scenedetect.exe` into the portable `.zip`, repacks it with 7-Zip, copies out the signed `.msi`, writes `PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`, and then runs `scripts/validate_release.py` to verify filenames, hashes, Authenticode signatures, MSI/zip parity, and frozen `.exe` smoke tests.
50+
- [ ] Draft release on Github using the tagged commit: include full changelog & release notes, signed portable .ZIP, signed .MSI installer, Python .whl/.tar.gz packages, and checksum manifests (`PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`)
5151
- [ ] Verify all artifacts uploaded to Github release are valid and named correctly
5252
- [ ] Smoke-test all release artifacts
5353

scripts/_release_common.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#
2+
# PySceneDetect: Python-Based Video Scene Detector
3+
# -------------------------------------------------------------------
4+
# [ Site: https://scenedetect.com ]
5+
# [ Docs: https://scenedetect.com/docs/ ]
6+
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
7+
#
8+
# Copyright (C) 2026 Brandon Castellano <http://www.bcastell.com>.
9+
# PySceneDetect is licensed under the BSD 3-Clause License; see the
10+
# included LICENSE file, or visit one of the above pages for details.
11+
#
12+
"""Shared helpers for Windows release-finalization and validation scripts."""
13+
14+
import hashlib
15+
import re
16+
import shutil
17+
import subprocess
18+
import sys
19+
import zipfile
20+
from pathlib import Path
21+
22+
CHUNK = 1 << 20 # 1 MiB
23+
24+
25+
def msi_version(raw: str) -> str:
26+
# Mirror scripts/update_installer.py - artifact filenames use the
27+
# normalized X.Y.Z form, not the raw Python __version__.
28+
parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
29+
while len(parts) < 3:
30+
parts.append("0")
31+
return ".".join(parts[:4])
32+
33+
34+
def find_7zip() -> Path:
35+
for candidate in (
36+
Path(r"C:\Program Files\7-Zip\7z.exe"),
37+
Path(r"C:\Program Files (x86)\7-Zip\7z.exe"),
38+
):
39+
if candidate.exists():
40+
return candidate
41+
on_path = shutil.which("7z") or shutil.which("7z.exe")
42+
if on_path:
43+
return Path(on_path)
44+
sys.exit("7-Zip not found. Install from https://www.7-zip.org/.")
45+
46+
47+
def sha256_file(path: Path) -> str:
48+
h = hashlib.sha256()
49+
with path.open("rb") as f:
50+
for block in iter(lambda: f.read(CHUNK), b""):
51+
h.update(block)
52+
return h.hexdigest()
53+
54+
55+
def hash_zip_contents(zip_path: Path) -> list[dict]:
56+
entries = []
57+
with zipfile.ZipFile(zip_path) as zf:
58+
for info in sorted(zf.infolist(), key=lambda i: i.filename):
59+
if info.is_dir():
60+
continue
61+
h = hashlib.sha256()
62+
with zf.open(info) as f:
63+
for block in iter(lambda: f.read(CHUNK), b""):
64+
h.update(block)
65+
entries.append(
66+
{
67+
"path": info.filename,
68+
"size": info.file_size,
69+
"sha256": h.hexdigest(),
70+
}
71+
)
72+
return entries
73+
74+
75+
def verify_authenticode(path: Path) -> None:
76+
"""Bail unless `path` carries a Valid Authenticode signature.
77+
78+
Catches the wrong-artifact case: e.g. someone drops the AppVeyor
79+
pre-signing bundle into dist/signed/ instead of the SignPath output.
80+
PowerShell's Get-AuthenticodeSignature works on both .exe and .msi.
81+
"""
82+
if sys.platform != "win32":
83+
print(f" (skipping Authenticode check for {path.name} on non-Windows)")
84+
return
85+
ps_cmd = (
86+
f"$sig = Get-AuthenticodeSignature -FilePath '{path}'; "
87+
"Write-Output $sig.Status; "
88+
"if ($sig.SignerCertificate) { Write-Output $sig.SignerCertificate.Subject }"
89+
)
90+
result = subprocess.run(
91+
["powershell", "-NoProfile", "-Command", ps_cmd],
92+
capture_output=True,
93+
text=True,
94+
check=False,
95+
)
96+
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
97+
if result.returncode != 0 or not lines:
98+
sys.exit(
99+
f"Authenticode check for {path.name} failed to run.\n stderr: {result.stderr.strip()}"
100+
)
101+
status = lines[0]
102+
subject = lines[1] if len(lines) > 1 else "<no certificate>"
103+
print(f" Authenticode: {status} ({subject})")
104+
if status != "Valid":
105+
sys.exit(
106+
f"Authenticode check FAILED for {path.name}: status={status!r}. "
107+
"Verify scenedetect-signed.zip is the SignPath output, not an "
108+
"unsigned AppVeyor artifact."
109+
)

scripts/finalize_windows_dist.py

Lines changed: 13 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@
3131
"""
3232

3333
import argparse
34-
import hashlib
3534
import json
36-
import re
3735
import shutil
3836
import subprocess
3937
import sys
@@ -44,102 +42,22 @@
4442

4543
REPO_DIR = Path(__file__).resolve().parent.parent
4644
sys.path.insert(0, str(REPO_DIR))
45+
sys.path.insert(0, str(Path(__file__).resolve().parent))
4746

4847
import scenedetect # noqa: E402
4948

50-
CHUNK = 1 << 20 # 1 MiB
51-
52-
53-
def msi_version(raw: str) -> str:
54-
# Mirror scripts/update_installer.py / generate_manifest.py - artifact
55-
# filenames use the normalized X.Y.Z form, not the Python __version__.
56-
parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
57-
while len(parts) < 3:
58-
parts.append("0")
59-
return ".".join(parts[:4])
60-
49+
import validate_release # noqa: E402
50+
from _release_common import ( # noqa: E402
51+
find_7zip,
52+
hash_zip_contents,
53+
msi_version,
54+
sha256_file,
55+
verify_authenticode,
56+
)
6157

6258
VERSION = msi_version(scenedetect.__version__)
6359

6460

65-
def find_7zip() -> Path:
66-
for candidate in (
67-
Path(r"C:\Program Files\7-Zip\7z.exe"),
68-
Path(r"C:\Program Files (x86)\7-Zip\7z.exe"),
69-
):
70-
if candidate.exists():
71-
return candidate
72-
on_path = shutil.which("7z") or shutil.which("7z.exe")
73-
if on_path:
74-
return Path(on_path)
75-
sys.exit("7-Zip not found. Install from https://www.7-zip.org/.")
76-
77-
78-
def sha256_file(path: Path) -> str:
79-
h = hashlib.sha256()
80-
with path.open("rb") as f:
81-
for block in iter(lambda: f.read(CHUNK), b""):
82-
h.update(block)
83-
return h.hexdigest()
84-
85-
86-
def hash_zip_contents(zip_path: Path) -> list[dict]:
87-
entries = []
88-
with zipfile.ZipFile(zip_path) as zf:
89-
for info in sorted(zf.infolist(), key=lambda i: i.filename):
90-
if info.is_dir():
91-
continue
92-
h = hashlib.sha256()
93-
with zf.open(info) as f:
94-
for block in iter(lambda: f.read(CHUNK), b""):
95-
h.update(block)
96-
entries.append(
97-
{
98-
"path": info.filename,
99-
"size": info.file_size,
100-
"sha256": h.hexdigest(),
101-
}
102-
)
103-
return entries
104-
105-
106-
def verify_authenticode(path: Path) -> None:
107-
"""Bail unless `path` carries a Valid Authenticode signature.
108-
109-
Catches the wrong-artifact case: e.g. someone drops the AppVeyor
110-
pre-signing bundle into dist/signed/ instead of the SignPath output.
111-
PowerShell's Get-AuthenticodeSignature works on both .exe and .msi.
112-
"""
113-
if sys.platform != "win32":
114-
print(f" (skipping Authenticode check for {path.name} on non-Windows)")
115-
return
116-
ps_cmd = (
117-
f"$sig = Get-AuthenticodeSignature -FilePath '{path}'; "
118-
"Write-Output $sig.Status; "
119-
"if ($sig.SignerCertificate) { Write-Output $sig.SignerCertificate.Subject }"
120-
)
121-
result = subprocess.run(
122-
["powershell", "-NoProfile", "-Command", ps_cmd],
123-
capture_output=True,
124-
text=True,
125-
check=False,
126-
)
127-
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
128-
if result.returncode != 0 or not lines:
129-
sys.exit(
130-
f"Authenticode check for {path.name} failed to run.\n stderr: {result.stderr.strip()}"
131-
)
132-
status = lines[0]
133-
subject = lines[1] if len(lines) > 1 else "<no certificate>"
134-
print(f" Authenticode: {status} ({subject})")
135-
if status != "Valid":
136-
sys.exit(
137-
f"Authenticode check FAILED for {path.name}: status={status!r}. "
138-
"Verify scenedetect-signed.zip is the SignPath output, not an "
139-
"unsigned AppVeyor artifact."
140-
)
141-
142-
14361
def extract_signed_bundle(signed_zip: Path, dest: Path) -> tuple[Path, Path]:
14462
print(f"Extracting {signed_zip.name}...")
14563
with zipfile.ZipFile(signed_zip) as zf:
@@ -274,6 +192,10 @@ def main() -> None:
274192
print(f"Copied signed MSI -> {msi_dest.name}")
275193
write_manifests(staging, portable_zip, msi_dest)
276194

195+
print()
196+
print("Validating finalized artifacts...")
197+
validate_release.run_all_checks(staging)
198+
277199

278200
if __name__ == "__main__":
279201
main()

0 commit comments

Comments
 (0)