Two audiences, two documents. Both were only in people's heads. UPDATES-WINDOWS.md is for whoever runs a server: updates arrive as one self-contained exe, an update takes two to four minutes, the site is down for that time, .env and data and any hand-edited web.config are kept, unticking a feature never removes it, the database is backed up and verified first, and a downgrade is refused because migrations only go forwards. It covers both kinds of security release, application and third-party, and explains that the CycloneDX inventory staged on every server is what answers a published vulnerability question. It also says plainly that the exe is not signed and the checksum is the integrity check to rely on today. It answers one question the existing docs did not address at all: the effect on other sites sharing the same IIS server. The application pool is isolated and the configuration is scoped to its own path, so other sites keep their own handlers. What IS shared gets named rather than glossed: installing the IIS modules and writing server-level configuration recycles application pools across the server, which can drop requests in flight and clears in-memory session state, though IIS is never stopped and no iisreset is issued. The two IIS modules and the single permitted rewrite server variable are machine-wide and stay behind on uninstall, deliberately, since another site may have come to depend on them. The bundled database option collides on port 3306 with an existing MySQL. RELEASING-WINDOWS.md is for whoever builds releases: the three kinds of change and the commands for each, why bundle-lock.json must be committed, the two dependency traps that have each already cost a release, which generated files must never be hand-edited, and the pre-release checks. It records the two known gaps honestly - no code signing, and compiling still requires Windows and a person. UPGRADE.md and OPERATE-WINDOWS.md link to the operator document.
6.3 KiB
Building and releasing the Windows installer
For whoever builds releases. If you run a server rather than build releases, see UPDATES-WINDOWS.md.
Every release is one self-contained .exe. It carries the Python runtime, a
hash-checked wheelhouse, MySQL, the IIS modules and the application, so nothing
is fetched from the network at install time. That is the point: sites are
air-gapped.
What you need
- A checkout of this repository.
- Inno Setup 6.6.0 or newer, on Windows. Compiling needs Windows; staging the bundle does not.
- PowerShell, for the bundle lock tools.
uv, only when dependencies change.
The three kinds of change
Almost every release is the first kind.
1. Application change: bug fix, feature, no new dependency
# bump __version__ in shopdb/__init__.py first
./deploy/windows/installer/build-installer.sh <site-profile.json> <repo-path>
Then on Windows, in deploy/windows/installer:
iscc ShopDBFlask.iss
build-installer.sh restages the application, rebuilds the web interface for
the chosen feature set, regenerates plugins.iss and version.iss, and
re-verifies the third-party payload against bundle-lock.json. The lock is
untouched, because nothing third-party changed.
2. A dependency is added, removed or moved
The only case that touches the lock.
# edit requirements.in, then
uv pip compile requirements.in --universal --generate-hashes -o requirements.txt
# fetch the wheel for the target runtime, into the wheelhouse
pip download <name>==<version> -d deploy/windows/installer/bundle/wheels \
--only-binary=:all: --platform win_amd64 --python-version 314 --no-deps
Then regenerate and commit the lock:
pwsh ./refresh-bundle-lock.ps1 # review the change
pwsh ./refresh-bundle-lock.ps1 -Yes # write it
Commit bundle-lock.json. That commit is the review. The lock records every
third-party file and its SHA-256; the installer refuses to run if the payload it
carries does not match, so an unreviewed substitution cannot reach a server.
Two traps, both of which have already cost a release:
--universalis not optional. A resolve done only for the host platform drops packages that exist only on Windows, and the install then fails hash checking on a package with no entry.- Declare every runtime import in
requirements.in, including ones that already work locally.packagingreached this project only as a test dependency, so the full suite passed while a production virtual environment, which has no test dependencies, could not import the application at all.tests/test_runtime_dependencies.pyis the gate for this.
3. A new plugin
Ordinary plugin work, plus three installer-specific steps:
- Stage it. Add the plugin to the profile you build from.
plugins.issis generated from what is actually in the bundle, so the wizard offers it with no edit to the installer script. - Decide whether it is ticked by default.
PluginDefault()inShopDBFlask.issis an exclusion list: a new plugin defaults to ticked unless you name it there. This is the one manual edit. - Register its migrations. Update
PLUGIN_TABLE_OWNERSandEXPECTED_HEAD_REVISION, and give the plugin its own migration chain (ADR-008). Existing sites pick up its tables throughplugin upgrade-all; sites that do not tick it have them pruned (ADR-014).
Version numbers
build-installer.sh reads __version__ from shopdb/__init__.py and writes
version.iss from it. Do not edit version.iss: a hardcoded version in the
installer script had already drifted two minor versions from the code.
Bump the version for every release you hand out. The installer compares versions and:
- refuses a build older than what is installed, because migrations only go forwards;
- warns and continues on an equal version, treating it as a repair.
Equal-version rebuilds are useful while testing and are a poor idea in the field, since the server cannot then tell you what it is running.
Follow ADR-007 for what each part means.
Do not hand-edit generated files
Regenerate these; changes are overwritten without warning:
version.iss- fromshopdb/__init__.pyplugins.iss- from the plugins actually present in the bundlerequirements.txt- fromrequirements.inviauv pip compilebundle-lock.json- viarefresh-bundle-lock.ps1
waitress and tzdata were once hand-added to requirements.txt and vanished
on the next compile, taking the Windows runtime with them.
Before you hand a build out
python -m pytest -q # full suite
./scripts/check-naming-and-style.sh # naming and style
The suite includes gates worth knowing about:
tests/test_runtime_dependencies.py- every runtime import is declaredtests/test_bundle_lock.py- the lock covers the payloadtests/test_docs_publishable.py-docs/carries no internal references, because it is published
build-installer.sh refuses to finish if the payload does not match the lock.
That check is not advisory; do not work around it.
Publishing
Alongside the .exe, publish:
- a
.sha256file, so the operator can verify what they received; - release notes covering what changed and anything needing attention;
- the version in the filename, so a server's build is identifiable on sight.
The compiled installer stages a CycloneDX inventory (sbom.cdx.json) onto every
server, which is what answers a security question about a published
vulnerability without anyone guessing.
Known gaps
The installer is not code-signed. Every install shows an unknown-publisher warning, and the SHA-256 is the only integrity check. This is the significant remaining gap before wider distribution: a checksum published next to the file protects against corruption, not against someone who can write to that location.
Compiling requires Windows. build-installer.ps1 exists so the whole
process can run on a Windows workstation. Nothing about it runs in CI, so a
release is a deliberate act by a person.
See also
- UPDATES-WINDOWS.md - what operators should expect
- INSTALL-WINDOWS.md - installing a new site
- OPERATE-WINDOWS.md - running a site
docs/adr/- ADR-007 versioning, ADR-008 plugin migrations, ADR-013 and ADR-014 lean per-site builds