From f29a3acab42a351bb4b107f624b7b602febbe494 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:20:42 +0800 Subject: [PATCH] fix(build): exclude retired sources from reused wheel trees Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/release-readiness.md | 12 + setup.py | 36 ++- tests/presentation/test_distribution_build.py | 213 ++++++++++++++++++ 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 tests/presentation/test_distribution_build.py diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index 9dab348d7e..0cd3d21e1b 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -199,6 +199,18 @@ attestation separately binds those bytes to the repository, workflow, commit, and build event; neither mechanism claims that the package is vulnerability free. +Wheel builds recreate the LoopX package subtrees in `build_lib` and wheel staging +after verifying the frontend bundle. Reused build or retained staging directories +must not carry modules, packages or resources deleted from current source. +Current package data and the bundle's qualified previous asset generation +remain included; unrelated build output +and editable setup remain untouched. A build destination overlapping the +source package is rejected. Explicit `bdist_wheel --skip-build` still packages +the selected existing build; it is not a fresh-source qualification. Before +qualifying retirement, inspect the built wheel and run its installed entry +points outside the source checkout; a source +test or a manually cleaned build directory alone does not prove this rule. + Before a recovery dispatch, inspect the existing `pypi` environment's allowed branch/tag refs as well as the Trusted Publisher and enabled variable: diff --git a/setup.py b/setup.py index cd5ed709bd..8a132522ac 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ import sys from setuptools import setup from setuptools.command.build_py import build_py +from setuptools.command.bdist_wheel import bdist_wheel from setuptools.command.sdist import sdist @@ -23,13 +24,34 @@ def verify_frontend(): module.validate_bundle(root / "loopx/web/chat", source_root=root) +def clear_built_package(directory): + stale = Path(directory) / "loopx" + source = (Path(__file__).parent / "loopx").resolve() + destination = stale.resolve() + if destination.is_relative_to(source) or source.is_relative_to(destination): + raise RuntimeError("build destination must be outside the LoopX source package") + if stale.exists(): + shutil.rmtree(stale) + + class BuildWithFrontend(build_py): def run(self): if not self.editable_mode: verify_frontend() - stale = Path(self.build_lib) / "loopx/web/chat" - if stale.exists(): - shutil.rmtree(stale) + # setuptools copies changed files but does not remove deleted modules or + # package data. Rebuild only our package, including qualified asset history. + clear_built_package(self.build_lib) + super().run() + + +class WheelWithFrontend(bdist_wheel): + def run(self): + if not self.skip_build: + # Validate/build before clearing staging, and let setuptools reuse that + # completed command. --skip-build still uses the explicitly selected build. + self.run_command("build") + # Failed or --keep-temp builds can retain a second copy of deleted files. + clear_built_package(self.bdist_dir) super().run() @@ -39,4 +61,10 @@ def run(self): super().run() -setup(cmdclass={"build_py": BuildWithFrontend, "sdist": SourceWithFrontend}) +setup( + cmdclass={ + "build_py": BuildWithFrontend, + "bdist_wheel": WheelWithFrontend, + "sdist": SourceWithFrontend, + } +) diff --git a/tests/presentation/test_distribution_build.py b/tests/presentation/test_distribution_build.py new file mode 100644 index 0000000000..acd0405c40 --- /dev/null +++ b/tests/presentation/test_distribution_build.py @@ -0,0 +1,213 @@ +"""Real setuptools builds must reflect current source, including deletions. + +Run with the project's build-system dependency installed. The tiny source tree +uses the shipped setup hook and bundle validator without installing LoopX. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def checkout(tmp_path): + pytest.importorskip("setuptools", reason="requires the declared build backend") + source = tmp_path / "source" + source.mkdir() + shutil.copyfile(ROOT / "setup.py", source / "setup.py") + (source / "pyproject.toml").write_text( + '[project]\nname="loopx-build-fixture"\nversion="0.0.0"\n' + '[tool.setuptools.packages.find]\ninclude=["loopx*"]\n' + '[tool.setuptools.package-data]\n"loopx"=["web/chat/**/*", "*.json"]\n' + '"loopx.control_plane"=["*.ts", "*.json"]\n', + encoding="utf-8", + ) + for package in ( + "loopx", + "loopx/presentation", + "loopx/control_plane", + "loopx/retired", + ): + directory = source / package + directory.mkdir(parents=True, exist_ok=True) + (directory / "__init__.py").write_text("", encoding="utf-8") + for name in ( + "loopx/presentation/chat_bundle.py", + "loopx/control_plane/content_digest.py", + ): + shutil.copyfile(ROOT / name, source / name) + for name, content in { + "loopx/current.py": "VALUE = 'first'\n", + "loopx/obsolete.py": "VALUE = 'retired'\n", + "loopx/retired/writer.py": "VALUE = 'retired package'\n", + "loopx/control_plane/current.ts": "export const current = true;\n", + "loopx/control_plane/obsolete.ts": "export const obsolete = true;\n", + "loopx/retained.json": '{"current":true}', + "loopx/obsolete.json": '{"obsolete":true}', + }.items(): + (source / name).write_text(content, encoding="utf-8") + bundle = source / "loopx/web/chat" + (bundle / "assets").mkdir(parents=True) + files = { + "index.html": '', + "manifest.webmanifest": "{}", + "assets/current.js": "current code", + "assets/previous.js": "previous delivery code", + "asset-retention.json": json.dumps( + { + "schema_version": "loopx_chat_asset_retention_v1", + "generations": [["assets/current.js"], ["assets/previous.js"]], + } + ), + } + for name, content in files.items(): + (bundle / name).write_text(content, encoding="utf-8") + (bundle / "bundle-manifest.json").write_text( + json.dumps( + { + "schema_version": "loopx_chat_bundle_v1", + "current_assets": ["assets/current.js"], + "files": { + name: hashlib.sha256((bundle / name).read_bytes()).hexdigest() + for name in files + }, + } + ), + encoding="utf-8", + ) + return source + + +def run_setup(source, *arguments): + environment = { + key: value for key, value in os.environ.items() if key != "PYTHONPATH" + } + return subprocess.run( + [sys.executable, "setup.py", *arguments], + cwd=source, + env=environment, + text=True, + capture_output=True, + timeout=60, + check=False, + ) + + +def wheel(source, output, *, keep_temp=False, skip_build=False): + arguments = ["bdist_wheel", "--dist-dir", str(output)] + if keep_temp: + arguments.append("--keep-temp") + if skip_build: + arguments.append("--skip-build") + result = run_setup(source, *arguments) + assert result.returncode == 0, result.stdout + result.stderr + with zipfile.ZipFile(next(output.glob("*.whl"))) as archive: + return { + name: archive.read(name) + for name in archive.namelist() + if name.startswith("loopx/") + } + + +@pytest.mark.parametrize("keep_temp", [False, True]) +def test_reused_build_removes_deleted_sources_and_keeps_current_resources( + checkout, tmp_path, keep_temp +): + first = wheel(checkout, tmp_path / "first", keep_temp=keep_temp) + obsolete = { + "loopx/obsolete.py", + "loopx/control_plane/obsolete.ts", + "loopx/obsolete.json", + "loopx/retired/__init__.py", + "loopx/retired/writer.py", + } + assert obsolete <= first.keys() + for name in obsolete: + (checkout / name).unlink() + (checkout / "loopx/retired").rmdir() + (checkout / "loopx/current.py").write_text("VALUE = 'second'\n", encoding="utf-8") + # An unrelated build output is outside the LoopX package hook's ownership. + unrelated = checkout / "build/lib/other-package.bin" + unrelated.write_bytes(b"preserve unrelated build output") + second = wheel(checkout, tmp_path / "second", keep_temp=keep_temp) + assert not obsolete & second.keys() + assert second["loopx/current.py"] == b"VALUE = 'second'\n" + assert ( + second["loopx/control_plane/current.ts"] + == first["loopx/control_plane/current.ts"] + ) + assert second["loopx/retained.json"] == first["loopx/retained.json"] + for name in first: + if name.startswith("loopx/web/chat/"): + assert second[name] == first[name] + assert unrelated.read_bytes() == b"preserve unrelated build output" + assert wheel(checkout, tmp_path / "third", keep_temp=keep_temp) == second + + +def test_invalid_frontend_does_not_clear_previous_build(checkout, tmp_path): + first = wheel(checkout, tmp_path / "first") + (checkout / "loopx/web/chat/assets/current.js").write_text( + "corrupt", encoding="utf-8" + ) + result = run_setup(checkout, "build_py") + assert result.returncode != 0 + assert "bundle file changed" in result.stderr + assert (checkout / "build/lib/loopx/current.py").read_bytes() == first[ + "loopx/current.py" + ] + + +def test_explicit_skip_build_packages_the_selected_existing_build(checkout, tmp_path): + first = wheel(checkout, tmp_path / "first", keep_temp=True) + (checkout / "loopx/current.py").write_text( + "VALUE = 'not built'\n", encoding="utf-8" + ) + second = wheel(checkout, tmp_path / "second", skip_build=True) + assert second == first + + +@pytest.mark.parametrize( + "command,option", [("build_py", "--build-lib"), ("bdist_wheel", "--bdist-dir")] +) +def test_build_destination_cannot_overlap_source(checkout, command, option): + original = (checkout / "loopx/current.py").read_bytes() + result = run_setup(checkout, command, option, ".") + assert result.returncode != 0 + assert "outside the LoopX source package" in result.stderr + assert (checkout / "loopx/current.py").read_bytes() == original + + +def test_editable_build_does_not_require_assets_or_clear_outputs(checkout): + shutil.rmtree(checkout / "loopx/web/chat") + marker = checkout / "build/lib/loopx/marker" + marker.parent.mkdir(parents=True) + marker.write_bytes(b"preserve editable output") + # Exercise setuptools' same declared command class in editable mode. + script = ( + "import runpy; from setuptools import Distribution; import setuptools; " + "setuptools.setup=lambda **kwargs: None; " + "hook=runpy.run_path('setup.py')['BuildWithFrontend']; " + "command=hook(Distribution({'packages':['loopx']})); " + "command.ensure_finalized(); command.editable_mode=True; command.run()" + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + text=True, + capture_output=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert marker.read_bytes() == b"preserve editable output"