diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682957e7..885e3587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,3 +121,29 @@ jobs: # discovery to this repository: editable dependencies keep their own # test suites and may expose same-named helper modules. run: PYTHONPATH=. uv run --no-sync python -m pytest -q tests + + exact-pin: + name: Exact pinned dependency contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + BINANCE_EXACT_PIN_TESTS: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install frozen dependencies only + run: | + python -m pip install --upgrade pip uv + uv sync --frozen --extra test + + - name: Verify installed pins and real catalog risk gates + # No sibling checkouts, editable overrides, credentials, or broker calls. + run: >- + PYTHONPATH=. uv run --no-sync python -m pytest -q + tests/test_exact_pin_install.py tests/test_strategy_runtime.py diff --git a/docs/exact_pin_ci.md b/docs/exact_pin_ci.md new file mode 100644 index 00000000..c7972de4 --- /dev/null +++ b/docs/exact_pin_ci.md @@ -0,0 +1,35 @@ +# Exact-pin CI contract + +The `exact-pin` job installs only `uv sync --frozen --extra test` in a separate +GitHub-hosted runner. It does not check out sibling repositories or apply editable +overrides. The existing `test` job remains the candidate integration check, +including its same-named sibling branch support. + +The opt-in installed-dependency test checks both QPK and CryptoStrategies: + +- The declared full SHA agrees with the resolved `uv.lock` Git source. +- The real installed `direct_url.json` names that repository and commit, with no + editable/path install. +- The imported module belongs to the distribution's installed file list, so a + sibling source checkout cannot stand in for the pinned package. + +`BINANCE_EXACT_PIN_TESTS=1` disables the runtime test module's local sibling-path +convenience. The job reuses the real catalog/runtime tests, including member-risk +approval and rejection for candidate mismatch, incomplete portfolio snapshots, +expired mandates and research-only mandates in live mode. All inputs are +synthetic; no broker account, cloud secret, execution grant or deployment is used. +These tests establish the current pinned member-risk semantics. They do not +claim that a legacy 20% cost-loss stop is enforced under member-risk approval. +Observation-only reporting added in a separate PR is outside this check's scope. + +Local reproduction in a clean checkout: + +```sh +uv sync --frozen --extra test +BINANCE_EXACT_PIN_TESTS=1 PYTHONPATH=. uv run --no-sync python -m pytest -q \ + tests/test_exact_pin_install.py tests/test_strategy_runtime.py +``` + +The installed-dependency test deliberately skips in ordinary candidate tests, +where editable dependency overrides are permitted. Missing or different Git +provenance fails the exact-pin job instead of being treated as verified. diff --git a/tests/test_exact_pin_install.py b/tests/test_exact_pin_install.py new file mode 100644 index 00000000..672f83bf --- /dev/null +++ b/tests/test_exact_pin_install.py @@ -0,0 +1,53 @@ +"""Opt-in installed-dependency contract for the isolated exact-pin CI job.""" + +import importlib +from importlib import metadata +import json +import os +from pathlib import Path +import re +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PACKAGES = { + "quant-platform-kit": "quant_platform_kit", + "crypto-strategies": "crypto_strategies", +} + + +@unittest.skipUnless(os.environ.get("BINANCE_EXACT_PIN_TESTS") == "1", "isolated frozen-install job only") +class ExactPinInstallTests(unittest.TestCase): + def test_imported_dependencies_are_the_noneditable_declared_locked_commits(self): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + for name, module_name in PACKAGES.items(): + with self.subTest(package=name): + declarations = [ + item for item in project["project"]["dependencies"] + if item.startswith(f"{name} @ git+") + ] + self.assertEqual(len(declarations), 1) + url, pin = declarations[0].split(" @ git+", 1)[1].rsplit("@", 1) + self.assertRegex(pin, re.compile(r"\A[0-9a-f]{40}\Z")) + locked = [item for item in lock["package"] if item["name"] == name] + self.assertEqual(len(locked), 1) + self.assertEqual(locked[0]["source"], {"git": f"{url}?rev={pin}#{pin}"}) + + dist = metadata.distribution(name) + # Read the real installed PEP 610 metadata, never a mocked version. + direct_url = json.loads(dist.read_text("direct_url.json") or "{}") + self.assertEqual(direct_url.get("url"), url) + self.assertNotIn("dir_info", direct_url, "editable/path install is not an exact Git install") + vcs = direct_url.get("vcs_info", {}) + self.assertEqual(vcs.get("vcs"), "git") + self.assertEqual(vcs.get("requested_revision"), pin) + self.assertEqual(vcs.get("commit_id"), pin) + + module = importlib.import_module(module_name) + imported = Path(module.__file__).resolve() + installed = { + Path(dist.locate_file(item)).resolve() for item in (dist.files or ()) + } + self.assertIn(imported, installed, "import is shadowed by a sibling/source checkout") diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index a46179eb..74761009 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -1,3 +1,4 @@ +import os import sys import types import unittest @@ -10,7 +11,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] QPK_SRC = PROJECT_ROOT.parent / "QuantPlatformKit" / "src" CRYPTO_STRATEGIES_SRC = PROJECT_ROOT.parent / "CryptoStrategies" / "src" -for path in (PROJECT_ROOT, QPK_SRC, CRYPTO_STRATEGIES_SRC): +# Exact-pin CI must use installed wheels, never sibling source checkouts. +paths = (PROJECT_ROOT,) if os.environ.get("BINANCE_EXACT_PIN_TESTS") == "1" else (PROJECT_ROOT, QPK_SRC, CRYPTO_STRATEGIES_SRC) +for path in paths: if str(path) not in sys.path: sys.path.insert(0, str(path)) diff --git a/tests/test_uv_dependency_workflow.py b/tests/test_uv_dependency_workflow.py index 32c831f2..5237b4e8 100644 --- a/tests/test_uv_dependency_workflow.py +++ b/tests/test_uv_dependency_workflow.py @@ -72,6 +72,18 @@ def test_ci_runtime_and_watchdog_use_uv_lock(self) -> None: self.assertNotIn("requirements-lock.txt", ci) self.assertNotIn("requirements.txt", ci) + def test_exact_pin_job_is_separate_from_candidate_overrides(self) -> None: + ci = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + candidate, exact = ci.split("\n exact-pin:", 1) + self.assertIn("uv pip install --no-deps -e external/QuantPlatformKit", candidate) + self.assertIn("uv sync --frozen --extra test", exact) + self.assertIn('BINANCE_EXACT_PIN_TESTS: "1"', exact) + self.assertIn("tests/test_exact_pin_install.py tests/test_strategy_runtime.py", exact) + self.assertNotIn("uv pip install", exact) + self.assertNotIn("external/", exact) + self.assertNotIn("GITHUB_HEAD_REF", exact) + self.assertNotIn("secrets.", exact) + def test_uv_outside_project_env_remains_callable_after_sync(self) -> None: """Regression for run 35540307399: uv used for sync must stay callable after it.""" with tempfile.TemporaryDirectory() as tmp: