Files
shopdb-flask/docs/RELEASING-WINDOWS.md
cproudlock 92a90fcec6
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Document publishing the installer as a release asset
The exe is attached to a release, never committed: most forges reject files over
100 MB inside a repository while allowing release assets far larger, and a
committed binary would sit in every future clone forever.

Also records that tags must be pushed explicitly. A plain push of the branch
does not carry them, so a release had nothing to hang off.
2026-08-05 07:44:57 -04:00

8.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 deploy/site-profile-universal.json <repo-path>

deploy/site-profile-universal.json is the profile released builds come from: every bundled plugin, so one .exe serves any site and the operator ticks what that site uses. Build from a narrower profile only when a site genuinely needs a lean build (ADR-013). Do not build a release from a profile that is not in the repository - the build stops being reproducible the moment that file is somewhere else.

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:

  • --universal is 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. packaging reached 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.py is the gate for this.

3. A new plugin

Ordinary plugin work, plus three installer-specific steps:

  1. Stage it. Add the plugin to the profile you build from. plugins.iss is generated from what is actually in the bundle, so the wizard offers it with no edit to the installer script.
  2. Decide whether it is ticked by default. PluginDefault() in ShopDBFlask.iss is an exclusion list: a new plugin defaults to ticked unless you name it there. This is the one manual edit.
  3. Register its migrations. Update PLUGIN_TABLE_OWNERS and EXPECTED_HEAD_REVISION, and give the plugin its own migration chain (ADR-008). Existing sites pick up its tables through plugin 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 - from shopdb/__init__.py
  • plugins.iss - from the plugins actually present in the bundle
  • requirements.txt - from requirements.in via uv pip compile
  • bundle-lock.json - via refresh-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 declared
  • tests/test_bundle_lock.py - the lock covers the payload
  • tests/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 .sha256 file, 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 installer is attached to a git-forge release as an ASSET, never committed. Most forges reject files over 100 MB inside a repository while allowing release assets far larger, and a committed binary would sit in every future clone forever. Release creation is scripted next to the other deployment scripts and verifies the SHA-256 before publishing, because a truncated upload is easiest to catch before anyone can download it.

Tags have to reach the forge for a release to hang off one: a plain git push <remote> main does not carry them, which is why the push step uses --follow-tags.

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.

The decision taken is to wait for a certificate from the organisation's own certificate authority rather than buy one from a public CA. Every server this installer runs on is centrally managed, and that CA's root is already trusted on those machines, so an internally issued Authenticode certificate removes the warning exactly where it matters. A public certificate would buy trust on machines this software never reaches.

Until then, publish the SHA-256 through a channel SEPARATE from the installer itself. A hash sitting beside the file is only as trustworthy as write access to that location; a hash the operator gets another way means tampering has to succeed twice.

Wiring it up afterwards is small: Inno has native SignTool support, so a directive in the script and a signtool configuration on the build machine sign the installer and its uninstaller. Include a timestamp server, or signatures stop verifying when the certificate expires.

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