Skip to content

Commit 1dc1ac4

Browse files
committed
[dist] Automate installer file and version updates
1 parent 8b4efe6 commit 1dc1ac4

6 files changed

Lines changed: 512 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ tests/resources/*
1010
*.m4v
1111
*.csv
1212
packaging/windows/.version_info
13+
packaging/windows/installer/PySceneDetect.back*.aip
1314
benchmarks/BCC/*.mp4
1415
*.txt
1516
benchmarks/RAI/*.mp4

RELEASE-PLAN.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout
55

66
## 0. Branch setup
77

8-
- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`.
9-
- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes).
8+
- [X] Create / fast-forward release branch: `releases/X.Y` off `main`.
9+
- [X] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes).
1010

1111
## 1. Code & version
1212

1313
- [ ] Bump `__version__` in `scenedetect/__init__.py`.
14-
- [ ] Bump `ProductVersion` in `packaging/windows/installer/PySceneDetect.aip` (must match `__version__` - `scripts/pre_release.py --release` asserts this).
14+
- [ ] Bump the installer project: `python scripts/bump_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`.
1515
- [ ] No `-dev` / pre-release suffix on the version string for a final release.
1616

17-
> **Note:** `setup.cfg` reads the package version dynamically via `version = attr: scenedetect.__version__`, and `pyproject.toml` does not declare a `version` field. The single source of truth is `scenedetect/__init__.py`; the `.aip` is the only other place to keep in sync.
17+
> **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync.
1818
1919
## 2. Docs
2020

@@ -42,7 +42,18 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout
4242

4343
- [ ] `python scripts/pre_release.py --release` passes (enforces `.aip``__version__` parity, writes `packaging/windows/.version_info`).
4444
- [ ] `pyinstaller packaging/windows/scenedetect.spec` produces a working `scenedetect.exe` - run it against a sample video.
45+
- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir <dir> --portable-zip` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`).
4546
- [ ] Build the MSI via Advanced Installer (`packaging/windows/installer/PySceneDetect.aip`); install into a clean Windows VM and run the CLI.
47+
- [ ] After both `pyinstaller` and the MSI build are done (and the portable `.zip` is staged at `dist/PySceneDetect-X.Y.Z-portable.zip`), run `python scripts/generate_manifest.py` to produce `dist/PySceneDetect-X.Y.Z.manifest.json` (per-file SHA256 audit of every artifact) and `dist/SHA256SUMS` (flat `sha256sum -c` compatible). Both are attached to the GitHub release in step 7.
48+
49+
> **GUI required for structural changes.** `scripts/bump_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples:
50+
>
51+
> - Moving the .aip or its source tree (the build's `SourcePath` references are stored relative to the .aip and aren't rewritten by `/NewSync` - cf. the `dist/installer/``packaging/windows/installer/` move that broke the relative paths until they were edited in the GUI).
52+
> - Adding/removing build configurations, features, or prerequisites.
53+
> - Editing dialog layouts, branding bitmaps, install sequences, custom actions, file associations, or shortcuts.
54+
> - Changing `UpgradeCode`, install directory layout (`APPDIR` location), or per-component attributes.
55+
>
56+
> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `bump_installer.py` afterwards if the version-identity fields need refreshing.
4657
4758
## 6. Cut the release
4859

@@ -55,7 +66,7 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout
5566

5667
- [ ] `publish-pypi.yml` ran on the tag and uploaded successfully. Verify both projects: https://pypi.org/project/scenedetect/ and https://pypi.org/project/scenedetect-headless/.
5768
- [ ] Smoke-test PyPI: in a fresh venv, `pip install scenedetect==X.Y.Z`; CLI launches and `pip show scenedetect` lists `opencv-python`. Repeat in a second venv with `pip install scenedetect-headless==X.Y.Z`; verify it lists `opencv-python-headless`.
58-
- [ ] Create GitHub Release from the `vX.Y[.Z]` tag, body = changelog section, attach Windows installer MSI + portable `.zip`.
69+
- [ ] Create GitHub Release from the `vX.Y[.Z]` tag, body = changelog section, attach Windows installer MSI + portable `.zip` + `PySceneDetect-X.Y.Z.manifest.json` + `SHA256SUMS` (both produced by `scripts/generate_manifest.py`).
5970
- [ ] Deploy website: `generate-website.yml` picks up the changelog / download page updates.
6071
- [ ] Deploy docs: `generate-docs.yml` publishes the new version.
6172

appveyor.yml

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,13 @@ install:
4949
- echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
5050
- echo * * BUILDING WINDOWS EXE * *
5151
- echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
52-
# Build Windows .EXE and create portable .ZIP
52+
# Build Windows .EXE and create portable .ZIP. The staging script copies
53+
# ffmpeg.exe + LICENSE from --ffmpeg-dir, third-party licenses, the project
54+
# LICENSE/README, and sphinx docs into dist/scenedetect/, then emits the
55+
# portable .zip - keeps CI and local builds in sync (see scripts/stage_windows_dist.py).
5356
- python scripts/pre_release.py --release
5457
- pyinstaller packaging/windows/scenedetect.spec
55-
- sphinx-build -b singlehtml docs dist/scenedetect/docs
56-
- mkdir dist\scenedetect\thirdparty
57-
- move LICENSE dist\scenedetect\
58-
- copy packaging\windows\LICENSE-PYTHON dist\scenedetect\thirdparty\
59-
- copy scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\
60-
- copy dist\ffmpeg\ffmpeg.exe dist\scenedetect\
61-
- move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG
62-
- cd dist/scenedetect
63-
- 7z a ../scenedetect-win64.zip *
64-
- cd ../..
58+
- python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg --portable-zip
6559

6660
- echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
6761
- echo * * BUILDING MSI INSTALLER * *
@@ -109,8 +103,8 @@ test_script:
109103
- scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s
110104

111105
artifacts:
112-
# Portable ZIP
113-
- path: dist/scenedetect-win64.zip
106+
# Portable ZIP (named PySceneDetect-X.Y.Z-portable.zip by stage_windows_dist.py)
107+
- path: dist/PySceneDetect-*-portable.zip
114108
name: PySceneDetect-win64_portable
115109
# MSI Installer + .EXE Bundle for Signing
116110
- path: dist/scenedetect-signed.zip

scripts/bump_installer.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
"""Bump the AdvancedInstaller .aip project for a release.
13+
14+
Usage:
15+
python scripts/bump_installer.py # version bump only
16+
python scripts/bump_installer.py --sync-files # also re-sync APPDIR
17+
python scripts/bump_installer.py --version 0.7.0 # explicit version override
18+
19+
The version-bump path rewrites ProductVersion / ProductCode / PackageFileName.
20+
The --sync-files path additionally walks dist/scenedetect/ (pyinstaller output)
21+
and rewrites the project's directory + component + file tables to match,
22+
which is needed when bundled dependencies change.
23+
24+
Both paths shell out to AdvancedInstaller.com so the .aip's invariants
25+
(line endings, attribute ordering, GUID casing) stay intact. The CLI lives
26+
under "C:\\Program Files (x86)\\Caphyon\\Advanced Installer ..\\bin\\x86\\".
27+
Override discovery with the ADVINST environment variable.
28+
"""
29+
30+
import argparse
31+
import os
32+
import re
33+
import subprocess
34+
import sys
35+
from pathlib import Path
36+
37+
REPO_DIR = Path(__file__).resolve().parent.parent
38+
sys.path.insert(0, str(REPO_DIR))
39+
40+
import scenedetect # noqa: E402
41+
42+
INSTALLER_AIP = REPO_DIR / "packaging" / "windows" / "installer" / "PySceneDetect.aip"
43+
DIST_TREE = REPO_DIR / "dist" / "scenedetect"
44+
45+
46+
def msi_version(raw: str) -> str:
47+
# AdvancedInstaller's ProductVersion only accepts numeric X[.Y[.Z[.B]]].
48+
# Strip Python-style suffixes ("0.7-dev0" -> "0.7"; "1.0.0-rc1" -> "1.0.0")
49+
# and pad to three components so the resulting MSI filename is consistent.
50+
parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
51+
if not all(p.isdigit() for p in parts if p):
52+
sys.exit(f"Cannot derive numeric MSI version from {raw!r}")
53+
while len(parts) < 3:
54+
parts.append("0")
55+
return ".".join(parts[:4])
56+
57+
58+
def find_advinst() -> Path:
59+
if env := os.environ.get("ADVINST"):
60+
path = Path(env)
61+
if not path.exists():
62+
sys.exit(f"ADVINST={env} does not exist.")
63+
return path
64+
candidates = sorted(
65+
Path(r"C:\Program Files (x86)\Caphyon").glob(
66+
"Advanced Installer*/bin/x86/AdvancedInstaller.com"
67+
)
68+
)
69+
if not candidates:
70+
sys.exit(
71+
"AdvancedInstaller.com not found under C:\\Program Files (x86)\\Caphyon. "
72+
"Set the ADVINST environment variable to its full path."
73+
)
74+
return candidates[-1]
75+
76+
77+
def run(advinst: Path, *edit_args: str, check: bool = True) -> int:
78+
cmd = [str(advinst), "/edit", str(INSTALLER_AIP), *edit_args]
79+
print(">", " ".join(cmd))
80+
return subprocess.run(cmd, check=check).returncode
81+
82+
83+
def main() -> None:
84+
parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
85+
parser.add_argument(
86+
"--sync-files",
87+
action="store_true",
88+
help="Re-sync APPDIR from dist/scenedetect/ (run pyinstaller first).",
89+
)
90+
parser.add_argument(
91+
"--version",
92+
dest="version_override",
93+
help="MSI version override (default: derived from scenedetect.__version__).",
94+
)
95+
args = parser.parse_args()
96+
97+
raw_version = args.version_override or scenedetect.__version__
98+
version = msi_version(raw_version)
99+
100+
advinst = find_advinst()
101+
print(f"Using {advinst}")
102+
if version != raw_version:
103+
print(f"Normalized {raw_version!r} -> {version!r} for AdvancedInstaller")
104+
print(f"Bumping {INSTALLER_AIP.name} to {version}")
105+
106+
run(advinst, "/SetVersion", version)
107+
run(advinst, "/SetProductCode", "-langid", "1033")
108+
run(
109+
advinst,
110+
"/SetPackageName",
111+
f"PySceneDetect-{version}-win64.msi",
112+
"-buildname",
113+
"DefaultBuild",
114+
)
115+
116+
if args.sync_files:
117+
if not DIST_TREE.exists():
118+
sys.exit(
119+
f"{DIST_TREE} not found. Run "
120+
"`pyinstaller packaging/windows/scenedetect.spec` first."
121+
)
122+
# /ResetSync errors out if APPDIR isn't already a synced folder
123+
# (true on the first run); /NewSync will fail if it IS synced. So
124+
# try the reset but tolerate failure, then sync.
125+
run(advinst, "/ResetSync", "APPDIR", check=False)
126+
run(advinst, "/NewSync", "APPDIR", str(DIST_TREE))
127+
128+
129+
if __name__ == "__main__":
130+
main()

scripts/generate_manifest.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
"""Generate a SHA256 audit manifest for the Windows release artifacts.
13+
14+
Walks the pyinstaller output tree, the built MSI, and the portable ZIP
15+
(if present), hashes every file, and writes:
16+
17+
dist/PySceneDetect-X.Y.Z.manifest.json - structured per-file manifest
18+
dist/SHA256SUMS - flat sha256sum -c compatible
19+
20+
Run after both `pyinstaller packaging/windows/scenedetect.spec` and the
21+
AdvancedInstaller MSI build have completed. Attach both outputs to the
22+
GitHub release so users can verify what they downloaded.
23+
"""
24+
25+
import argparse
26+
import hashlib
27+
import json
28+
import re
29+
import sys
30+
import zipfile
31+
from datetime import datetime, timezone
32+
from pathlib import Path
33+
34+
REPO_DIR = Path(__file__).resolve().parent.parent
35+
sys.path.insert(0, str(REPO_DIR))
36+
37+
import scenedetect # noqa: E402
38+
39+
40+
def msi_version(raw: str) -> str:
41+
# Mirror scripts/bump_installer.py - the artifact filename uses the
42+
# normalized X.Y.Z form, not the Python __version__ string.
43+
parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
44+
while len(parts) < 3:
45+
parts.append("0")
46+
return ".".join(parts[:4])
47+
48+
49+
VERSION = msi_version(scenedetect.__version__)
50+
DIST_DIR = REPO_DIR / "dist"
51+
PYINSTALLER_TREE = DIST_DIR / "scenedetect"
52+
MSI_PATH = REPO_DIR / "packaging" / "windows" / "installer" / f"PySceneDetect-{VERSION}-win64.msi"
53+
PORTABLE_ZIP = DIST_DIR / f"PySceneDetect-{VERSION}-portable.zip"
54+
55+
CHUNK = 1 << 20 # 1 MiB
56+
57+
58+
def sha256_file(path: Path) -> str:
59+
h = hashlib.sha256()
60+
with path.open("rb") as f:
61+
for block in iter(lambda: f.read(CHUNK), b""):
62+
h.update(block)
63+
return h.hexdigest()
64+
65+
66+
def hash_tree(root: Path) -> list[dict]:
67+
entries = []
68+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
69+
entries.append(
70+
{
71+
"path": path.relative_to(root).as_posix(),
72+
"size": path.stat().st_size,
73+
"sha256": sha256_file(path),
74+
}
75+
)
76+
return entries
77+
78+
79+
def hash_zip_contents(zip_path: Path) -> list[dict]:
80+
entries = []
81+
with zipfile.ZipFile(zip_path) as zf:
82+
for info in sorted(zf.infolist(), key=lambda i: i.filename):
83+
if info.is_dir():
84+
continue
85+
h = hashlib.sha256()
86+
with zf.open(info) as f:
87+
for block in iter(lambda: f.read(CHUNK), b""):
88+
h.update(block)
89+
entries.append(
90+
{
91+
"path": info.filename,
92+
"size": info.file_size,
93+
"sha256": h.hexdigest(),
94+
}
95+
)
96+
return entries
97+
98+
99+
def main() -> None:
100+
parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
101+
parser.add_argument(
102+
"--out",
103+
type=Path,
104+
default=DIST_DIR / f"PySceneDetect-{VERSION}.manifest.json",
105+
help="Path to the JSON manifest output.",
106+
)
107+
parser.add_argument(
108+
"--sums",
109+
type=Path,
110+
default=DIST_DIR / "SHA256SUMS",
111+
help="Path to the flat sha256sum-compatible output.",
112+
)
113+
args = parser.parse_args()
114+
115+
bundles: dict[str, dict] = {}
116+
top_level: list[tuple[str, str]] = [] # (sha256, relpath) for SHA256SUMS
117+
118+
if PYINSTALLER_TREE.is_dir():
119+
print(f"Hashing pyinstaller tree: {PYINSTALLER_TREE}")
120+
bundles["pyinstaller_tree"] = {
121+
"path": PYINSTALLER_TREE.relative_to(REPO_DIR).as_posix(),
122+
"files": hash_tree(PYINSTALLER_TREE),
123+
}
124+
else:
125+
print(f"WARNING: {PYINSTALLER_TREE} missing - skipping.")
126+
127+
if MSI_PATH.is_file():
128+
print(f"Hashing MSI: {MSI_PATH}")
129+
digest = sha256_file(MSI_PATH)
130+
bundles["msi"] = {
131+
"path": MSI_PATH.relative_to(REPO_DIR).as_posix(),
132+
"size": MSI_PATH.stat().st_size,
133+
"sha256": digest,
134+
}
135+
top_level.append((digest, MSI_PATH.name))
136+
else:
137+
print(f"WARNING: {MSI_PATH} missing - skipping.")
138+
139+
if PORTABLE_ZIP.is_file():
140+
print(f"Hashing portable zip: {PORTABLE_ZIP}")
141+
digest = sha256_file(PORTABLE_ZIP)
142+
bundles["portable_zip"] = {
143+
"path": PORTABLE_ZIP.relative_to(REPO_DIR).as_posix(),
144+
"size": PORTABLE_ZIP.stat().st_size,
145+
"sha256": digest,
146+
"contents": hash_zip_contents(PORTABLE_ZIP),
147+
}
148+
top_level.append((digest, PORTABLE_ZIP.name))
149+
else:
150+
print(f"WARNING: {PORTABLE_ZIP} missing - skipping.")
151+
152+
if not bundles:
153+
sys.exit("No artifacts found to hash.")
154+
155+
manifest = {
156+
"version": VERSION,
157+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
158+
"bundles": bundles,
159+
}
160+
161+
args.out.parent.mkdir(parents=True, exist_ok=True)
162+
args.out.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
163+
print(f"Wrote {args.out}")
164+
165+
if top_level:
166+
args.sums.write_text(
167+
"".join(f"{sha} {name}\n" for sha, name in top_level),
168+
encoding="utf-8",
169+
)
170+
print(f"Wrote {args.sums}")
171+
172+
173+
if __name__ == "__main__":
174+
main()

0 commit comments

Comments
 (0)