diff --git a/scripts/build_release.py b/scripts/build_release.py index 9821bd8..b07006f 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -9,6 +9,7 @@ import os from pathlib import Path import re import shutil +import socket import subprocess import sys import tarfile @@ -76,6 +77,29 @@ def sha256(path: Path) -> str: return digest.hexdigest() +def assert_port_free(host: str, port: int) -> None: + """Fail closed when something is already listening where the acceptance server must bind.""" + with socket.socket() as sock: + sock.settimeout(1.0) + if sock.connect_ex((host, port)) == 0: + raise SystemExit( + f"Port {port} is already served by another process; refusing to gate or record this " + "release against a tree that is not the one under test." + ) + + +def assert_served_tree(tree: Path, host: str, port: int) -> None: + """Prove the responding server is the clone under test, not a stale checkout on the same port.""" + for relative in ("src/domain.js", "app.js"): + served = urllib.request.urlopen(f"http://{host}:{port}/{relative}", timeout=5).read() + expected = (tree / relative).read_bytes() + if hashlib.sha256(served).hexdigest() != hashlib.sha256(expected).hexdigest(): + raise SystemExit( + f"The acceptance server is serving {relative} from a different tree; " + "every gate and the demo would describe a release that is not this commit." + ) + + def tracked_files(tree: Path) -> list[str]: names = run(["git", "ls-files"], tree, capture=True).splitlines() return sorted(name for name in names if name and not name.startswith(EXCLUDED_PREFIXES) @@ -133,6 +157,10 @@ def main() -> int: run(["python3", "scripts/build_vision_corpus.py", "--verify"], tree) server_env = dict(os.environ) server_env.update({"HOST": "127.0.0.1", "PORT": "4173", "TIMMY_VISION_ENABLED": "false"}) + # A leftover server from another checkout answers on this port and the readiness probe cannot + # tell it apart from ours, so every gate and the recorded demo would silently describe a + # different tree. Refuse to build over a live listener, and verify the served bytes afterwards. + assert_port_free("127.0.0.1", 4173) server_log = (release_dir / "acceptance-server.log").open("w", encoding="utf-8") server = subprocess.Popen(["node", "server.mjs"], cwd=tree, env=server_env, stdout=server_log, stderr=subprocess.STDOUT, text=True) @@ -147,6 +175,9 @@ def main() -> int: time.sleep(0.1) else: raise SystemExit("Acceptance server did not become ready") + if server.poll() is not None: + raise SystemExit("Acceptance server exited after reporting ready") + assert_served_tree(tree, "127.0.0.1", 4173) run(["npm", "run", "test:ui"], tree) run(["npm", "run", "test:photo"], tree) run(["npm", "run", "test:mobile-capture"], tree) diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 5dad54f..e584e92 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -373,3 +373,19 @@ test('release demo shows a versioned backup, an upgraded older file, and a refus assert.match(builder, /"portable_backup_restore": "passed"/); assert.match(builder, /"test:portability"/); }); + +test('the builder refuses to gate or record a release against a foreign server', async () => { + const builder = await readFile(builderPath, 'utf8'); + + // A leftover checkout answering on the acceptance port once satisfied every flow and the demo, + // so the release described a tree that was not the commit under test. The builder must refuse a + // live listener before it starts, then prove the served bytes are this tree's before any gate. + assert.match(builder, /def assert_port_free/); + assert.match(builder, /assert_port_free\("127\.0\.0\.1", 4173\)/); + assert.match(builder, /def assert_served_tree/); + assert.match(builder, /assert_served_tree\(tree, "127\.0\.0\.1", 4173\)/); + const identityCheck = builder.indexOf('assert_served_tree(tree, "127.0.0.1", 4173)'); + const firstFlow = builder.indexOf('run(["npm", "run", "test:ui"]'); + assert.ok(identityCheck > 0, 'the served-tree check must be present'); + assert.ok(firstFlow > identityCheck, 'the served-tree check must run before the first acceptance flow'); +});