feat(installer): require the wheelhouse to satisfy requirements.txt, and lock the real payload

The lock records what IS in the wheelhouse, not what the application NEEDS, so
an incomplete wheelhouse was locked, blessed and shipped - and only failed on an
air-gapped server.

That is not hypothetical. Assembling the wheelhouse anywhere other than Windows
silently omits colorama, a win32-only dependency of click, because pip evaluates
environment markers against the machine doing the downloading rather than the
machine being targeted. The bundle built here was short exactly that one wheel.

Both verifiers now cross-check wheels/ against the staged requirements.txt,
ignoring markers, since a requirement guarded by sys_platform == 'win32' is
precisely the one that must be present. Names are normalised to PEP 427 wheel
form, so mysql-connector-python matches mysql_connector_python.

bundle-lock.json is the first real lock: 42 files, cp314/win_amd64 - 39 wheels,
Python 3.14.6, HttpPlatformHandler 1.2 and URL Rewrite. MySQL is absent and
optional; a site choosing the bundled-database option adds it and re-locks.

The naming gate now skips the installer's build output. It contains a staged
copy of the application plus a second SPA build under dist-subpath, which
--exclude-dir=dist does not match, so a staged bundle failed the gate on
vendored minified JS nobody in this repository wrote.
This commit is contained in:
cproudlock
2026-08-03 11:46:39 -04:00
parent 44237b5cbd
commit 13d831eb90
5 changed files with 327 additions and 1 deletions

View File

@@ -185,6 +185,38 @@ def test_lock_missing_files_section_is_not_a_silent_pass(locked):
assert all('wheels/' in p for p in problems)
def test_wheelhouse_must_cover_requirements(locked):
"""The regression that motivated the check.
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
sys_platform markers against the downloading machine. The lock alone cannot
catch it, because the lock records what is there, not what is needed.
"""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text(
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
problems = check_both(bundle, lock)
assert any('colorama==0.4.6' in p for p in problems), problems
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
def test_wheelhouse_coverage_normalizes_names(locked):
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
wheel.write_bytes(b'connector')
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
assert check_both(bundle, lock) == []
def test_payload_list_matches_powershell():
"""Both files enumerate the payload directories. Same names, same required
flags, same order - a directory that is required in one and optional in the