Skip to content

Commit 38d6a9e

Browse files
committed
[dist] Simplify signed artifact packaging
Use just the MSI as input to build the portable .ZIP.
1 parent acb2638 commit 38d6a9e

3 files changed

Lines changed: 93 additions & 63 deletions

File tree

RELEASE-PLAN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version
4444
- [ ] Final commit on `releases/X.Y`: "Release vX.Y[.Z]".
4545
- [ ] Tag `vX.Y[.Z]-release` on that commit and push. Wait for all tests/builds to pass.
4646
- [ ] Approve code signing request on SignPath, download `scenedetect-signed.zip`
47-
- [ ] 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):
48-
- 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`, 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.
47+
- [ ] Finalize Windows artifacts locally (CI can't do this - signing happens after the AppVeyor build, so the post-signing steps must run locally):
48+
- Create `dist/signed/` and drop `scenedetect-signed.zip` (from SignPath) into it. No other inputs needed - the portable .zip is rebuilt from the signed .msi via `msiexec /a`, eliminating the AppVeyor download.
49+
- Run `python scripts/finalize_windows_dist.py`. This extracts the signed `.msi` from the bundle, runs `msiexec /a` to recover the installed file tree, repacks it as the portable `.zip` with 7-Zip, 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.
5050
- [ ] 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

scripts/finalize_windows_dist.py

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,18 @@
1111
#
1212
"""Finalize signed Windows release artifacts.
1313
14-
Takes the signed bundle returned by SignPath and the portable .zip built by
15-
AppVeyor, swaps the unsigned `scenedetect.exe` inside the portable .zip for
16-
the signed copy, repacks the .zip with LZMA via 7-Zip, copies the signed MSI
17-
alongside, and emits SHA256 manifests over the final release artifacts.
14+
Takes the signed bundle returned by SignPath, extracts the file tree from the
15+
signed MSI via `msiexec /a`, repacks it as the portable .zip with 7-Zip, and
16+
emits SHA256 manifests over the final release artifacts.
1817
1918
Run after the SignPath signing job completes and `scenedetect-signed.zip`
2019
has been downloaded.
2120
22-
Expected inputs (in --staging-dir, default `dist/signed/`):
21+
Expected input (in --staging-dir, default `dist/signed/`):
2322
scenedetect-signed.zip - SignPath bundle (signed .exe + .msi)
24-
PySceneDetect-X.Y.Z-win64.zip - portable .zip from AppVeyor
2523
2624
Outputs (written to the same directory):
27-
PySceneDetect-X.Y.Z-win64.zip - repacked with the signed .exe
25+
PySceneDetect-X.Y.Z-win64.zip - portable .zip rebuilt from the signed MSI
2826
PySceneDetect-X.Y.Z-win64.msi - signed MSI extracted from the bundle
2927
PySceneDetect-X.Y.Z-win64.manifest.json - structured per-file SHA256 manifest
3028
SHA256SUMS - flat sha256sum -c compatible output
@@ -75,52 +73,78 @@ def extract_signed_bundle(signed_zip: Path, dest: Path) -> tuple[Path, Path]:
7573
return exe, msi
7674

7775

78-
def repack_portable(portable_zip: Path, signed_exe: Path, sevenz: Path) -> None:
79-
with tempfile.TemporaryDirectory() as tmp:
80-
tree = Path(tmp)
81-
print(f"Extracting {portable_zip.name}...")
82-
with zipfile.ZipFile(portable_zip) as zf:
83-
zf.extractall(tree)
84-
target = tree / "scenedetect.exe"
85-
if not target.exists():
86-
sys.exit(f"scenedetect.exe not found inside {portable_zip}")
87-
print(" swapping in signed scenedetect.exe")
88-
shutil.copy2(signed_exe, target)
89-
# Preserve the unsigned AppVeyor zip alongside the signed output for
90-
# diffing / recovery. Overwrite any prior .unsigned from a re-run.
91-
backup = portable_zip.with_suffix(".unsigned.zip")
92-
if backup.exists():
93-
backup.unlink()
94-
portable_zip.rename(backup)
95-
print(f" preserved original as {backup.name}")
96-
print(f"Repacking {portable_zip.name} (zip / Deflate / mx=9 / mt=on)...")
97-
# -mm=Deflate (not LZMA): Windows Explorer's built-in "Extract All" only
98-
# supports Deflate-compressed zips; LZMA needs 7-Zip/WinRAR. Portable .zip
99-
# ships to end users on clean Windows, so compat trumps ratio here.
100-
# -mfb=258 -mpass=15: max-out Deflate tuning (slow, but once per release).
101-
# -mmt=on: 7z parallelizes Deflate across files (not within a file), so
102-
# the docs/ + thirdparty/ tree gets a real speedup; the two big binaries
103-
# (scenedetect.exe, ffmpeg.exe) still each compress on a single thread.
104-
# Pass top-level entries (not '*') so we don't depend on shell globbing.
105-
entries = sorted(p.name for p in tree.iterdir())
106-
subprocess.run(
107-
[
108-
str(sevenz),
109-
"a",
110-
"-tzip",
111-
"-mm=Deflate",
112-
"-mx=9",
113-
"-mfb=258",
114-
"-mpass=15",
115-
"-mmt=on",
116-
str(portable_zip),
117-
*entries,
118-
],
119-
cwd=tree,
120-
check=True,
121-
capture_output=True,
76+
def extract_msi_tree(msi_path: Path, dest: Path) -> Path:
77+
"""Run `msiexec /a` to extract the .msi's installed file tree without
78+
actually installing. Returns the directory containing scenedetect.exe
79+
(the app root), which sits under TARGETDIR at the .aip's APPDIR depth."""
80+
if sys.platform != "win32":
81+
sys.exit("msiexec /a is Windows-only")
82+
print(f"Extracting {msi_path.name} via msiexec /a...")
83+
# /a = administrative install: file extraction only, no registry, no admin rights.
84+
# /qn = silent. TARGETDIR must be absolute.
85+
result = subprocess.run(
86+
["msiexec", "/a", str(msi_path), "/qn", f"TARGETDIR={dest}"],
87+
check=False,
88+
capture_output=True,
89+
text=True,
90+
)
91+
if result.returncode != 0:
92+
sys.exit(
93+
f"msiexec /a failed (exit {result.returncode}): "
94+
f"{result.stderr.strip() or result.stdout.strip()}"
12295
)
123-
print(f" {portable_zip.stat().st_size / (1024 * 1024):.1f} MB")
96+
exe = next((p for p in dest.rglob("scenedetect.exe")), None)
97+
if exe is None:
98+
sys.exit(f"scenedetect.exe not found anywhere under {dest} after msiexec /a")
99+
tree = exe.parent
100+
# `msiexec /a` writes an "administrative" copy of the .msi (and sometimes a
101+
# `Cabs/` folder) into TARGETDIR alongside the extracted app files. When
102+
# APPDIR == TARGETDIR (no nested install folder), these land inside the app
103+
# tree and would pollute the portable .zip. Strip them.
104+
for stray in tree.glob("*.msi"):
105+
print(f" stripping admin-install artifact: {stray.name}")
106+
stray.unlink()
107+
cabs_dir = tree / "Cabs"
108+
if cabs_dir.is_dir():
109+
print(" stripping admin-install artifact: Cabs/")
110+
shutil.rmtree(cabs_dir)
111+
print(f" app tree: {tree.relative_to(dest)}/ ({sum(1 for _ in tree.rglob('*')):,} entries)")
112+
return tree
113+
114+
115+
def build_portable_zip(tree: Path, zip_path: Path, sevenz: Path) -> None:
116+
"""Pack `tree`'s top-level contents into a Deflate .zip using the same
117+
flags AppVeyor's stage_windows_dist.py uses for the portable distribution."""
118+
if zip_path.exists():
119+
zip_path.unlink()
120+
print(f"Building {zip_path.name} (zip / Deflate / mx=9 / mt=on)...")
121+
# -mm=Deflate (not LZMA): Windows Explorer's built-in "Extract All" only
122+
# supports Deflate-compressed zips; LZMA needs 7-Zip/WinRAR. Portable .zip
123+
# ships to end users on clean Windows, so compat trumps ratio here.
124+
# -mfb=258 -mpass=15: max-out Deflate tuning (slow, but once per release).
125+
# -mmt=on: 7z parallelizes Deflate across files (not within a file), so
126+
# the docs/ + thirdparty/ tree gets a real speedup; the two big binaries
127+
# (scenedetect.exe, ffmpeg.exe) still each compress on a single thread.
128+
# Pass top-level entries (not '*') so we don't depend on shell globbing.
129+
entries = sorted(p.name for p in tree.iterdir())
130+
subprocess.run(
131+
[
132+
str(sevenz),
133+
"a",
134+
"-tzip",
135+
"-mm=Deflate",
136+
"-mx=9",
137+
"-mfb=258",
138+
"-mpass=15",
139+
"-mmt=on",
140+
str(zip_path),
141+
*entries,
142+
],
143+
cwd=tree,
144+
check=True,
145+
capture_output=True,
146+
)
147+
print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
124148

125149

126150
def write_manifests(staging: Path, portable_zip: Path, msi: Path) -> None:
@@ -164,7 +188,7 @@ def main() -> None:
164188
"--staging-dir",
165189
type=Path,
166190
default=REPO_DIR / "dist" / "signed",
167-
help="Directory holding scenedetect-signed.zip and PySceneDetect-*-win64.zip.",
191+
help="Directory holding scenedetect-signed.zip.",
168192
)
169193
args = parser.parse_args()
170194

@@ -173,23 +197,26 @@ def main() -> None:
173197
sys.exit(f"{staging} not found")
174198

175199
signed_bundle = staging / "scenedetect-signed.zip"
176-
portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip"
177200
if not signed_bundle.is_file():
178201
sys.exit(f"{signed_bundle} not found")
179-
if not portable_zip.is_file():
180-
sys.exit(f"{portable_zip} not found")
181202

182203
sevenz = find_7zip()
183204
print(f"Using 7-Zip: {sevenz}")
184205
print(f"Staging dir: {staging}")
185206
print(f"Version: {VERSION}")
186207

208+
portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip"
187209
with tempfile.TemporaryDirectory() as tmp:
188-
signed_exe, signed_msi = extract_signed_bundle(signed_bundle, Path(tmp))
189-
repack_portable(portable_zip, signed_exe, sevenz)
210+
tmp_path = Path(tmp)
211+
# Bundle holds the SignPath outputs; signed .exe is verified for the
212+
# wrong-bundle check but otherwise unused (the .msi already ships its
213+
# own signed copy of scenedetect.exe).
214+
_signed_exe, signed_msi = extract_signed_bundle(signed_bundle, tmp_path / "bundle")
190215
msi_dest = staging / signed_msi.name
191216
shutil.copy2(signed_msi, msi_dest)
192217
print(f"Copied signed MSI -> {msi_dest.name}")
218+
msi_tree = extract_msi_tree(msi_dest, tmp_path / "msi-extract")
219+
build_portable_zip(msi_tree, portable_zip, sevenz)
193220
write_manifests(staging, portable_zip, msi_dest)
194221

195222
print()

scripts/validate_release.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,13 @@ def check_frozen_exe(portable_zip: Path) -> None:
313313
fail(f"`scenedetect.exe version` exited {result.returncode}\n{result.stderr}")
314314
packages = _parse_packages_section(result.stdout)
315315
scenedetect_reported = packages.get("scenedetect", "")
316-
if scenedetect_reported != VERSION:
316+
# Normalize both sides through msi_version() so a raw __version__ of "0.7"
317+
# matches the artifact-name VERSION of "0.7.0" (mirrors the same
318+
# normalization scripts/update_installer.py applies to filenames).
319+
if msi_version(scenedetect_reported) != VERSION:
317320
fail(
318321
f"`scenedetect.exe version` reports scenedetect=={scenedetect_reported!r}, "
319-
f"expected {VERSION!r}"
322+
f"expected {VERSION!r} (raw __version__ normalized)"
320323
)
321324
print(f" scenedetect=={scenedetect_reported}")
322325

0 commit comments

Comments
 (0)