Files
shopdb-flask/docs/RELEASING-WINDOWS.md
cproudlock 89e880afc3
Some checks failed
CI / backend (push) Failing after 6s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Release 0.8.0
The Windows installer has never shipped under a version: v0.7.0 was tagged
before any of it existed, so every build handed out so far stamped a server with
0.7.0. Two servers running different builds were indistinguishable, and the
installer logged each upgrade as "same version already installed" rather than
recording what changed. This cuts the release that fixes that.

0.8.0 rather than a patch: the air-gapped installer is a new capability, and
pre-1.0 semantic versioning puts that in the minor slot (ADR-007).

CHANGELOG gains a 0.8.0 section covering the twelve defects a real Windows
Server 2019 install surfaced, the move from inferring "is this a re-run of my
install?" to recording it, and the operator documentation.

deploy/site-profile-universal.json is now in the repository. Released builds
were being produced from a profile in a temporary directory, so the next release
could not have been reproduced once that file was cleaned up.
docs/RELEASING-WINDOWS.md points at the committed profile and says why.

scripts/gen_openapi.py reads __version__ out of shopdb/__init__.py instead of
restating it. Its hardcoded copy had already drifted a release behind, which is
the same mistake that once shipped an installer stamped with the wrong version.
2026-08-05 07:34:05 -04:00

192 lines
7.7 KiB
Markdown

# Building and releasing the Windows installer
For whoever builds releases. If you run a server rather than build releases, see
[UPDATES-WINDOWS.md](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
```bash
# 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.
```bash
# 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:
```powershell
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
```bash
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 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
- [UPDATES-WINDOWS.md](UPDATES-WINDOWS.md) - what operators should expect
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - installing a new site
- [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md) - running a site
- `docs/adr/` - ADR-007 versioning, ADR-008 plugin migrations, ADR-013 and
ADR-014 lean per-site builds