diff --git a/docker/Dockerfile.runtime b/docker/Dockerfile.runtime index 9087c6f1ed..26538c697b 100644 --- a/docker/Dockerfile.runtime +++ b/docker/Dockerfile.runtime @@ -266,6 +266,10 @@ COPY workspace/sibling-repos/ /workspace/sibling-repos/ # Copy provenance helper (always present — used in workspace mode, no-op otherwise) COPY scripts/runtime_build/compute_workspace_provenance.py /workspace/compute_workspace_provenance.py +# Pin-resolution helper imported by the provenance script (OMN-12989): enforces +# that staged siblings honor the consuming repo's uv.lock pins. Must sit beside +# the provenance script so its sys.path-relative import resolves inside the image. +COPY scripts/runtime_build/resolve_workspace_pins.py /workspace/resolve_workspace_pins.py # Copy the sibling-pin comparison emitted by the preflight (OMN-12977). A # committed placeholder makes this COPY always valid; workspace mode overwrites diff --git a/scripts/deploy-agent/tests/unit/test_executor_workspace_provenance.py b/scripts/deploy-agent/tests/unit/test_executor_workspace_provenance.py index 010bc7698d..f4c695e278 100644 --- a/scripts/deploy-agent/tests/unit/test_executor_workspace_provenance.py +++ b/scripts/deploy-agent/tests/unit/test_executor_workspace_provenance.py @@ -20,6 +20,10 @@ PROVENANCE_SCRIPT = ( REPO_ROOT / "scripts" / "runtime_build" / "compute_workspace_provenance.py" ) +# Pin-resolution helper imported by the provenance script (OMN-12989). Isolated +# test copies of the provenance script must place this alongside the copy so the +# sys.path-relative import resolves — mirrors the Dockerfile's dual COPY. +PIN_SCRIPT = REPO_ROOT / "scripts" / "runtime_build" / "resolve_workspace_pins.py" STAGE_SCRIPT = REPO_ROOT / "scripts" / "runtime_build" / "stage_workspace.sh" @@ -222,7 +226,15 @@ def fake_run(cmd: list[str], timeout: int, **kwargs) -> subprocess.CompletedProc build_cmd = captured_cmds[0] assert "VCS_REF=deadbeef" in build_cmd - assert "RUNTIME_VERSION=0.38.0" in build_cmd + # RUNTIME_VERSION is sourced from the repo's own pyproject — read it the same + # way the executor does so this assertion never drifts on a version bump. + import tomllib + + pyproject = tomllib.loads( + (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + expected_runtime_version = pyproject["project"]["version"] + assert f"RUNTIME_VERSION={expected_runtime_version}" in build_cmd assert any(a.startswith("BUILD_DATE=") for a in build_cmd) @@ -261,6 +273,10 @@ def test_provenance_script_passes_with_valid_local_installs( patched_script = tmp_path / "compute_workspace_provenance_test.py" patched_script.write_text(script_src, encoding="utf-8") + # Co-locate the pin-resolution helper so the script's import resolves. + (tmp_path / "resolve_workspace_pins.py").write_text( + PIN_SCRIPT.read_text(encoding="utf-8"), encoding="utf-8" + ) # Patch importlib.metadata to simulate local installs mock_dist = MagicMock() @@ -322,6 +338,9 @@ def test_provenance_script_missing_sibling_repo_exits_nonzero( patched_script = tmp_path / "compute_prov_missing.py" patched_script.write_text(script_src, encoding="utf-8") + (tmp_path / "resolve_workspace_pins.py").write_text( + PIN_SCRIPT.read_text(encoding="utf-8"), encoding="utf-8" + ) import sys @@ -378,3 +397,147 @@ def test_dockerfile_workspace_copy_and_provenance_present() -> None: assert "build-provenance.json" in dockerfile assert "com.omninode.workspace_provenance_manifest" in dockerfile assert "--if-present" not in dockerfile + + +def test_dockerfile_copies_pin_resolution_helper() -> None: + """OMN-12989: the pin-resolution helper must be copied beside the provenance + script so the in-image import resolves.""" + dockerfile = (REPO_ROOT / "docker" / "Dockerfile.runtime").read_text( + encoding="utf-8" + ) + assert "scripts/runtime_build/resolve_workspace_pins.py" in dockerfile + assert "/workspace/resolve_workspace_pins.py" in dockerfile + + +def _patched_provenance(tmp_path: Path, sibling_dir: Path, manifest_path: Path) -> Path: + """Write an isolated copy of the provenance script + helper, path-patched.""" + script_src = PROVENANCE_SCRIPT.read_text(encoding="utf-8") + script_src = script_src.replace( + 'SIBLING_REPOS_DIR = Path("/workspace/sibling-repos")', + f'SIBLING_REPOS_DIR = Path("{sibling_dir}")', + ).replace( + 'OUTPUT_MANIFEST = Path("/app/build-provenance.json")', + f'OUTPUT_MANIFEST = Path("{manifest_path}")', + ) + patched = tmp_path / "compute_prov_pins.py" + patched.write_text(script_src, encoding="utf-8") + (tmp_path / "resolve_workspace_pins.py").write_text( + PIN_SCRIPT.read_text(encoding="utf-8"), encoding="utf-8" + ) + return patched + + +_OMNIMARKET_LOCK = """ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "omnibase-compat" +version = "0.5.1" +source = { git = "https://github.com/OmniNode-ai/omnibase_compat.git?rev=4d887307aae34d9d40d389ba91070cb411ce3df5#4d887307aae34d9d40d389ba91070cb411ce3df5" } + +[[package]] +name = "omnibase-infra" +version = "0.38.1" +source = { git = "https://github.com/OmniNode-ai/omnibase_infra.git?rev=e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59#e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59" } + +[[package]] +name = "onex-change-control" +version = "0.9.0" +source = { git = "https://github.com/OmniNode-ai/onex_change_control.git?rev=4877d3c223517cb0c7e1eca462ba0f4d38916314" } + +[[package]] +name = "omnimarket" +version = "0.4.3" +source = { editable = "." } +""" + + +def _stage_siblings(sibling_dir: Path) -> None: + """Create the three workspace siblings (compat, occ, omnimarket) at lock pin.""" + for repo, version in ( + ("omnibase_compat", "0.5.1"), + ("onex_change_control", "0.9.0"), + ("omnimarket", "0.4.3"), + ): + d = sibling_dir / repo + d.mkdir(parents=True) + name = repo.replace("_", "-") + (d / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "{version}"\n', encoding="utf-8" + ) + (sibling_dir / "omnimarket" / "uv.lock").write_text( + _OMNIMARKET_LOCK, encoding="utf-8" + ) + + +def test_provenance_emits_pin_comparison_block(tmp_path: Path) -> None: + """OMN-12989: the manifest carries an expected-vs-actual pin_comparison block.""" + import sys + + sibling_dir = tmp_path / "workspace" / "sibling-repos" + sibling_dir.mkdir(parents=True) + _stage_siblings(sibling_dir) + manifest_path = tmp_path / "build-provenance.json" + patched = _patched_provenance(tmp_path, sibling_dir, manifest_path) + + # Host infra version resolved from importlib.metadata is at/above pin in the + # test venv (>=0.38.1), so the infra self-check passes; here we only assert + # the comparison block is present and the staged siblings are recorded. + subprocess.run( + [sys.executable, str(patched)], capture_output=True, text=True, check=False + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert "pin_comparison" in manifest + by_pkg = {e["package"]: e for e in manifest["pin_comparison"]} + # omnibase-compat staged at exactly the lock pin. + assert by_pkg["omnibase-compat"]["status"] == "exact" + assert by_pkg["omnibase-compat"]["match"] is True + # The host infra self-check appears only when omnibase-infra is installed in + # the running venv (always true in the build image; not in the deploy-agent + # venv). When present it must carry the lock-pinned expected version. + if "omnibase-infra" in by_pkg: + assert by_pkg["omnibase-infra"]["expected_version"] == "0.38.1" + + +def test_build_comparisons_flags_host_infra_regression(tmp_path: Path) -> None: + """OMN-12989 (unit): the exact crash — host infra 0.37.0 vs lock pin 0.38.1. + + Exercises build_comparisons directly with an explicit host-infra actual + version so the regression detection does not depend on the test venv. + """ + mod = _load_resolve_module() + omnimarket = tmp_path / "omnimarket" + omnimarket.mkdir() + (omnimarket / "uv.lock").write_text(_OMNIMARKET_LOCK, encoding="utf-8") + + # Synthesize a "host infra" pyproject tree at the stale crash version. + infra = tmp_path / "omnibase_infra" + infra.mkdir() + (infra / "pyproject.toml").write_text( + '[project]\nname = "omnibase-infra"\nversion = "0.37.0"\n', encoding="utf-8" + ) + + comparisons = mod.build_comparisons( + lock_path=omnimarket / "uv.lock", + siblings={"omnibase-infra": infra}, + ) + infra_cmp = next(c for c in comparisons if c.package == "omnibase-infra") + assert infra_cmp.status == "regression" + with pytest.raises(mod.WorkspacePinError): + mod.assert_pins_satisfied(comparisons) + + +def _load_resolve_module(): + import importlib.util + import sys + + spec = importlib.util.spec_from_file_location( + "resolve_workspace_pins_provtest", str(PIN_SCRIPT) + ) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) # type: ignore[attr-defined] + return mod diff --git a/scripts/runtime_build/compute_workspace_provenance.py b/scripts/runtime_build/compute_workspace_provenance.py index 229c7f54cf..bd46a5e1a3 100644 --- a/scripts/runtime_build/compute_workspace_provenance.py +++ b/scripts/runtime_build/compute_workspace_provenance.py @@ -6,12 +6,17 @@ Run during Docker build (workspace mode only) to: - Hash each staged sibling repo tree under /workspace/sibling-repos/ - Verify that the installed package content matches the recorded digest - - Write /app/build-provenance.json for operator inspection + - Enforce that every staged sibling honors the consuming repo's uv.lock pin + (fail-fast on a version regression below the pin — the 2026-06-11 + stability-crash condition, OMN-12989) + - Write /app/build-provenance.json for operator inspection, including the + expected-vs-actual pin comparison block - Print a human-readable provenance summary to stdout Exit codes: 0 all proofs passed - 1 digest mismatch (installed content != recorded workspace digest) + 1 digest mismatch (installed content != recorded workspace digest) OR a + sibling regressed below its uv.lock pin 2 missing sibling repo (expected path not found) 3 missing installed package (venv does not contain the expected dist) """ @@ -25,9 +30,25 @@ import sys from pathlib import Path +# Co-located pin-resolution helpers (OMN-12989). Inside the build image this +# module is copied next to the provenance script (see Dockerfile.runtime); in +# the repo it lives in the same scripts/runtime_build/ directory. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from resolve_workspace_pins import ( + PinComparison, + WorkspacePinError, + assert_pins_satisfied, + build_comparisons, + compare_pin, + parse_lock_pins, +) + SIBLING_REPOS_DIR = Path("/workspace/sibling-repos") VENV_DIR = Path("/app/.venv") OUTPUT_MANIFEST = Path("/app/build-provenance.json") +# The consuming repo whose uv.lock pins the sibling versions. omnimarket dev's +# lock is the authority the runtime image must honor. +CONSUMING_REPO = "omnimarket" # Expected-vs-actual sibling-pin comparison emitted by the preflight # (check_sibling_lock_pins.py via stage_workspace.sh). Folded into the manifest # so deploy verifiers can assert the build honored the consuming repo's lock @@ -75,6 +96,32 @@ def _installed_direct_url(dist_name: str) -> dict[str, object] | None: return parsed +def _host_infra_comparison(lock_path: Path) -> PinComparison | None: + """Compare the runtime image's installed omnibase_infra against the lock pin. + + The host infra package is not staged under sibling-repos (it is the repo + being built), so it bypasses build_comparisons. Resolve the lock's + omnibase-infra pin and compare against the installed version — this is the + direct ratchet for the 2026-06-11 stale-infra crash. Returns a PinComparison + or None if the lock has no infra pin / infra is not installed. + """ + pins = parse_lock_pins(lock_path) + infra_pin = pins.get("omnibase-infra") + if infra_pin is None: + return None + try: + installed_version = importlib.metadata.version("omnibase-infra") + except importlib.metadata.PackageNotFoundError: + return None + return compare_pin( + package="omnibase-infra", + expected_version=infra_pin.version, + expected_rev=infra_pin.rev, + actual_version=installed_version, + actual_rev=None, + ) + + def _load_pin_comparison(errors: list[str]) -> dict[str, object] | None: """Load the preflight's expected-vs-actual sibling-pin comparison. @@ -165,6 +212,43 @@ def main() -> int: f"install={install_url}" ) + # OMN-12989: compare every staged sibling against the consuming repo's + # uv.lock pins. A sibling regressed BELOW its lock pin is the exact + # 2026-06-11 stability-crash condition (vendored infra 0.37.0 vs locked + # 0.38.1) and aborts the build. The comparison is always recorded in the + # manifest so deploy verifiers can check it mechanically. + pin_comparison: list[dict[str, object]] = [] + pin_violation: str | None = None + lock_path = SIBLING_REPOS_DIR / CONSUMING_REPO / "uv.lock" + try: + comparisons = build_comparisons( + lock_path=lock_path, + siblings={ + "omnibase-compat": SIBLING_REPOS_DIR / "omnibase_compat", + "onex-change-control": SIBLING_REPOS_DIR / "onex_change_control", + "omnimarket": SIBLING_REPOS_DIR / "omnimarket", + }, + ) + # Host self-check (OMN-12989): the runtime image's OWN omnibase_infra is + # built from the build context, NOT from sibling-repos — that is exactly + # where the 2026-06-11 stale 0.37.0 entered. Compare the installed infra + # version against omnimarket's lock pin so a stale build context aborts. + host_infra = _host_infra_comparison(lock_path) + if host_infra is not None: + comparisons.append(host_infra) + pin_comparison = [c.to_dict() for c in comparisons] + try: + assert_pins_satisfied(comparisons) + except WorkspacePinError as exc: + pin_violation = str(exc) + except FileNotFoundError as exc: + # A missing consuming lock is itself a hard error — it means the build + # cannot prove sibling freshness. Fail rather than silently skip. + pin_violation = ( + f"Cannot resolve sibling lock pins: {exc}. " + "Workspace mode requires the consuming repo's uv.lock to be staged." + ) + # Fold in the sibling-pin comparison so deploy verifiers can confirm the # build honored the consuming repo's uv.lock (OMN-12977). A drift here would # already have aborted the build in the default (no-override) path; when an @@ -176,6 +260,7 @@ def main() -> int: "build_time": os.environ.get("BUILD_DATE", "unknown"), "vcs_ref": os.environ.get("VCS_REF", "unknown"), "proofs": proofs, + "pin_comparison": pin_comparison, "sibling_pin_comparison": sibling_pin_comparison, } @@ -188,6 +273,21 @@ def main() -> int: print(f" - {err}", file=sys.stderr) return 1 + if pin_violation is not None: + print( + "\nWorkspace provenance FAILED (sibling lock-pin regression):", + file=sys.stderr, + ) + print(pin_violation, file=sys.stderr) + return 1 + + for entry in pin_comparison: + print( + f" pin OK: {entry['package']} " + f"expected>={entry['expected_version'] or '(unpinned)'} " + f"actual={entry['actual_version']} [{entry['status']}]" + ) + print(f"\nAll {len(proofs)} workspace provenance proofs passed.") return 0 diff --git a/scripts/runtime_build/resolve_workspace_pins.py b/scripts/runtime_build/resolve_workspace_pins.py new file mode 100644 index 0000000000..a12a71f689 --- /dev/null +++ b/scripts/runtime_build/resolve_workspace_pins.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Resolve + enforce workspace-mode sibling lock pins (OMN-12989). + +Background — the 2026-06-11 stability bootstrap crash +----------------------------------------------------- +A workspace-mode ``--no-cache`` rebuild staged its sibling repos from a stale +MERGE-SWEEP deploy worktree instead of the canonical clone, vendoring +omnibase_infra 0.37.0-dev (~2c1d672f, 13 days stale) even though omnimarket +dev's ``uv.lock`` pins infra 0.38.1 @ e2dbdc95. The downgraded sibling +predated the OMN-12501 Protocol-quarantine guard, so a latent contract defect +crashed bootstrap fatally and took the stability lane down. + +This module is the fail-fast ratchet for that hole. It: + + * parses the consuming repo's ``uv.lock`` to extract the expected sibling + pins (version + git rev), + * compares each staged/installed sibling against its lock pin, + * classifies the comparison as ``exact`` / ``ahead`` / ``regression`` / + ``unpinned``, + * raises ``WorkspacePinError`` (fail-fast, no silent fallback) when any + sibling has regressed below its lock pin, and + * exposes a serializable comparison block for ``build-provenance.json`` so + deploy verifiers can mechanically check it. + +Policy: the canonical clones track moving dev branches, so a staged sibling may +legitimately be **at or ahead of** the lock pin (dev advanced past the last +omnimarket lock). What must never happen is a sibling **below** the lock pin — +that is the downgrade that caused the crash and is the only violation enforced. +""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# `packaging` is the preferred PEP 440 comparator, but the minimal runtime build +# venv is not guaranteed to carry it. Fall back to a stdlib release-tuple +# comparator so this ratchet never fails-open on a missing dependency. +try: + from packaging.version import InvalidVersion as _InvalidVersion + from packaging.version import Version as _Version + + def _version_key(value: str) -> tuple[int, ...]: + return _Version(value).release + + _VersionError: type[Exception] = _InvalidVersion +except ModuleNotFoundError: # pragma: no cover - exercised only without packaging + + class _VersionError(Exception): # type: ignore[no-redef] + pass + + def _version_key(value: str) -> tuple[int, ...]: + # Parse the leading numeric release segment (e.g. "0.38.1-dev" -> (0,38,1)). + match = re.match(r"^\s*(\d+(?:\.\d+)*)", value) + if not match: + raise _VersionError(value) + return tuple(int(part) for part in match.group(1).split(".")) + + +# Directory-name -> uv.lock distribution name for every sibling that workspace +# mode provisions. Mirrors WORKSPACE_PACKAGES in compute_workspace_provenance.py. +SIBLING_DISTRIBUTIONS: dict[str, str] = { + "omnibase_compat": "omnibase-compat", + "onex_change_control": "onex-change-control", + "omnimarket": "omnimarket", +} + + +class WorkspacePinError(RuntimeError): + """Raised when a staged sibling has regressed below its lock pin.""" + + +@dataclass(frozen=True) +class LockPin: + """Expected version + git rev for one package, parsed from a uv.lock.""" + + package: str + version: str + rev: str | None + + +@dataclass(frozen=True) +class PinComparison: + """Expected-vs-actual comparison for one sibling, for the provenance block.""" + + package: str + expected_version: str + expected_rev: str | None + actual_version: str + actual_rev: str | None + match: bool + status: str # one of: exact | ahead | regression | unpinned + + def to_dict(self) -> dict[str, object]: + return { + "package": self.package, + "expected_version": self.expected_version, + "expected_rev": self.expected_rev, + "actual_version": self.actual_version, + "actual_rev": self.actual_rev, + "match": self.match, + "status": self.status, + } + + +def _rev_from_git_source(git_url: str) -> str | None: + """Extract the resolved commit SHA from a uv.lock git source URL. + + uv encodes git pins as ``...repo.git?rev=#`` (the + fragment is the resolved commit). Prefer the fragment; fall back to the + ``rev=`` query value. + """ + if "#" in git_url: + fragment = git_url.rsplit("#", 1)[1].strip() + if fragment: + return fragment + if "rev=" in git_url: + rev = git_url.split("rev=", 1)[1] + # rev runs until the next URL delimiter. + for delim in ("#", "&", " "): + rev = rev.split(delim, 1)[0] + rev = rev.strip() + if rev: + return rev + return None + + +def parse_lock_pins(lock_path: Path) -> dict[str, LockPin]: + """Parse a uv.lock into ``{distribution_name: LockPin}``. + + Raises FileNotFoundError if the lock file is absent — a missing lock is a + hard error, never a silent skip. + """ + if not lock_path.exists(): + raise FileNotFoundError(f"uv.lock not found: {lock_path}") + + data = tomllib.loads(lock_path.read_text(encoding="utf-8")) + pins: dict[str, LockPin] = {} + for package in data.get("package", []): + name = package.get("name") + if name is None: + continue + version = package.get("version", "") + rev: str | None = None + source = package.get("source", {}) + git_url = source.get("git") + if isinstance(git_url, str): + rev = _rev_from_git_source(git_url) + pins[name] = LockPin(package=name, version=str(version), rev=rev) + return pins + + +def read_repo_version(repo_dir: Path) -> str: + """Read the ``[project].version`` from a repo's pyproject.toml. + + Raises FileNotFoundError if the pyproject is absent. + """ + pyproject = repo_dir / "pyproject.toml" + if not pyproject.exists(): + raise FileNotFoundError(f"pyproject.toml not found: {pyproject}") + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def read_repo_head_sha(repo_dir: Path) -> str | None: + """Return the staged repo's git HEAD SHA, or None if not a git tree. + + Staged sibling trees are rsync'd with ``--exclude=.git`` so HEAD is usually + unavailable inside the build context; this is best-effort provenance only + and never gates the build (version regression is the enforced signal). + """ + head = repo_dir / ".git" / "HEAD" + if not head.exists(): + return None + try: + ref = head.read_text(encoding="utf-8").strip() + except OSError: + return None + if ref.startswith("ref:"): + ref_path = repo_dir / ".git" / ref.split(" ", 1)[1].strip() + if ref_path.exists(): + return ref_path.read_text(encoding="utf-8").strip() + return None + return ref + + +def compare_pin( + *, + package: str, + expected_version: str, + expected_rev: str | None, + actual_version: str, + actual_rev: str | None, +) -> PinComparison: + """Classify one sibling against its lock pin. + + status: + * ``exact`` — actual version == expected version + * ``ahead`` — actual version > expected (dev advanced; allowed) + * ``regression`` — actual version < expected (the downgrade; violation) + * ``unpinned`` — lock pin carried no comparable version (advisory) + + ``match`` is True for exact/ahead/unpinned and False only for regression. + """ + if not expected_version: + return PinComparison( + package=package, + expected_version=expected_version, + expected_rev=expected_rev, + actual_version=actual_version, + actual_rev=actual_rev, + match=True, + status="unpinned", + ) + + try: + expected = _version_key(expected_version) + actual = _version_key(actual_version) + except _VersionError: + # Unorderable versions cannot be ranked; fall back to exact-string match. + status = "exact" if actual_version == expected_version else "regression" + return PinComparison( + package=package, + expected_version=expected_version, + expected_rev=expected_rev, + actual_version=actual_version, + actual_rev=actual_rev, + match=status != "regression", + status=status, + ) + + if actual < expected: + status = "regression" + elif actual > expected: + status = "ahead" + else: + status = "exact" + + return PinComparison( + package=package, + expected_version=expected_version, + expected_rev=expected_rev, + actual_version=actual_version, + actual_rev=actual_rev, + match=status != "regression", + status=status, + ) + + +def build_comparisons( + *, + lock_path: Path, + siblings: dict[str, Path], +) -> list[PinComparison]: + """Build expected-vs-actual comparisons for staged ``siblings``. + + ``siblings`` maps distribution name -> staged repo directory. The expected + pin comes from ``lock_path``; the actual version comes from the staged + tree's pyproject. A sibling absent from the lock is reported ``unpinned``. + """ + pins = parse_lock_pins(lock_path) + comparisons: list[PinComparison] = [] + for dist_name, repo_dir in siblings.items(): + actual_version = read_repo_version(repo_dir) + actual_rev = read_repo_head_sha(repo_dir) + pin = pins.get(dist_name) + if pin is None: + comparisons.append( + PinComparison( + package=dist_name, + expected_version="", + expected_rev=None, + actual_version=actual_version, + actual_rev=actual_rev, + match=True, + status="unpinned", + ) + ) + continue + comparisons.append( + compare_pin( + package=dist_name, + expected_version=pin.version, + expected_rev=pin.rev, + actual_version=actual_version, + actual_rev=actual_rev, + ) + ) + return comparisons + + +def assert_pins_satisfied(comparisons: list[PinComparison]) -> None: + """Fail-fast if any comparison is a regression. No silent fallback. + + Raises WorkspacePinError naming every regressed sibling with its + expected pin and the stale version actually staged. + """ + violations = [c for c in comparisons if c.status == "regression"] + if not violations: + return + lines = [ + "Workspace-mode build aborted: vendored sibling(s) regressed below the " + "consuming repo's uv.lock pin (this is the 2026-06-11 stability-crash " + "condition — a stale sibling was staged instead of the lock-pinned one).", + ] + for c in violations: + lines.append( + f" - {c.package}: lock pins >= {c.expected_version}" + f" (rev {c.expected_rev}), but staged tree is {c.actual_version}" + f" (rev {c.actual_rev}). Re-stage from the canonical clone at or " + "above the lock pin." + ) + raise WorkspacePinError("\n".join(lines)) diff --git a/tests/scripts/test_check_sibling_lock_pins.py b/tests/scripts/test_check_sibling_lock_pins.py index 3eec9732b7..2da77bb77e 100644 --- a/tests/scripts/test_check_sibling_lock_pins.py +++ b/tests/scripts/test_check_sibling_lock_pins.py @@ -20,6 +20,7 @@ _PROVENANCE_SCRIPT = ( _REPO_ROOT / "scripts" / "runtime_build" / "compute_workspace_provenance.py" ) +_PIN_SCRIPT = _REPO_ROOT / "scripts" / "runtime_build" / "resolve_workspace_pins.py" _LOCK_INFRA_REV = "e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59" _LOCK_CORE_REV = "c97c2c9a45c5fb0def5fb7dacfd5f01278bb9f55" @@ -196,6 +197,10 @@ def _write_patched_provenance_script( ) patched = tmp_path / "compute_workspace_provenance_patched.py" patched.write_text(script_src, encoding="utf-8") + (tmp_path / "resolve_workspace_pins.py").write_text( + _PIN_SCRIPT.read_text(encoding="utf-8"), + encoding="utf-8", + ) return patched @@ -225,10 +230,11 @@ def test_provenance_script_folds_sibling_pin_comparison(tmp_path: Path) -> None: tmp_path, sibling_dir, manifest_path, comparison_path ) - subprocess.run( + result = subprocess.run( [sys.executable, str(patched)], capture_output=True, text=True, check=False ) + assert manifest_path.exists(), result.stderr manifest = json.loads(manifest_path.read_text(encoding="utf-8")) assert manifest["sibling_pin_comparison"] == pin_payload @@ -242,9 +248,10 @@ def test_provenance_script_uses_none_when_pin_comparison_absent(tmp_path: Path) tmp_path, sibling_dir, manifest_path, comparison_path ) - subprocess.run( + result = subprocess.run( [sys.executable, str(patched)], capture_output=True, text=True, check=False ) + assert manifest_path.exists(), result.stderr manifest = json.loads(manifest_path.read_text(encoding="utf-8")) assert manifest["sibling_pin_comparison"] is None diff --git a/tests/unit/scripts/test_resolve_workspace_pins.py b/tests/unit/scripts/test_resolve_workspace_pins.py new file mode 100644 index 0000000000..87b0962e2a --- /dev/null +++ b/tests/unit/scripts/test_resolve_workspace_pins.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Tests for workspace-mode sibling lock-pin resolution + preflight (OMN-12989). + +Defect under test: the 2026-06-11 stability bootstrap crash was caused by a +workspace-mode rebuild that vendored omnibase_infra 0.37.0-dev (a 13-day-stale +worktree) even though omnimarket dev's uv.lock pins infra 0.38.1. The build +ignored the lock pin and shipped a downgraded sibling, which removed the +OMN-12501 Protocol-quarantine guard and turned a latent contract defect into a +fatal crash. + +These tests encode the ratchet contract: + (a) expected sibling pins are resolved from the consuming repo's uv.lock, + (b) a fail-fast preflight aborts on version regression / SHA mismatch, + (c) the comparison block is exported for build-provenance.json. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = REPO_ROOT / "scripts" / "runtime_build" / "resolve_workspace_pins.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "resolve_workspace_pins", str(MODULE_PATH) + ) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + # Register before exec so dataclass KW_ONLY detection can resolve the module. + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) # type: ignore[attr-defined] + return mod + + +# Minimal uv.lock fragment mirroring omnimarket dev's pin structure (git-rev source). +_LOCK_TEXT = """ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "omnibase-compat" +version = "0.5.1" +source = { git = "https://github.com/OmniNode-ai/omnibase_compat.git?rev=4d887307aae34d9d40d389ba91070cb411ce3df5#4d887307aae34d9d40d389ba91070cb411ce3df5" } + +[[package]] +name = "omnibase-infra" +version = "0.38.1" +source = { git = "https://github.com/OmniNode-ai/omnibase_infra.git?rev=e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59#e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59" } + +[[package]] +name = "onex-change-control" +version = "0.9.0" +source = { git = "https://github.com/OmniNode-ai/onex_change_control.git?rev=4877d3c223517cb0c7e1eca462ba0f4d38916314" } + +[[package]] +name = "omnimarket" +version = "0.4.3" +source = { editable = "." } +""" + + +def test_module_exists() -> None: + assert MODULE_PATH.exists(), f"resolve_workspace_pins.py not found at {MODULE_PATH}" + + +def test_parse_lock_pins_extracts_version_and_rev(tmp_path: Path) -> None: + mod = _load_module() + lock = tmp_path / "uv.lock" + lock.write_text(_LOCK_TEXT, encoding="utf-8") + + pins = mod.parse_lock_pins(lock) + + assert pins["omnibase-infra"].version == "0.38.1" + assert pins["omnibase-infra"].rev == "e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59" + assert pins["omnibase-compat"].version == "0.5.1" + assert pins["omnibase-compat"].rev == "4d887307aae34d9d40d389ba91070cb411ce3df5" + assert pins["onex-change-control"].rev == "4877d3c223517cb0c7e1eca462ba0f4d38916314" + + +def test_parse_lock_pins_missing_lock_raises(tmp_path: Path) -> None: + mod = _load_module() + with pytest.raises(FileNotFoundError): + mod.parse_lock_pins(tmp_path / "does-not-exist.lock") + + +def test_compare_pin_exact_match_is_ok() -> None: + mod = _load_module() + result = mod.compare_pin( + package="omnibase-infra", + expected_version="0.38.1", + expected_rev="e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59", + actual_version="0.38.1", + actual_rev="e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59", + ) + assert result.match is True + assert result.status == "exact" + + +def test_compare_pin_forward_advance_is_ok() -> None: + """Canonical clone may be AT OR AHEAD of the lock pin (dev advances) — allowed.""" + mod = _load_module() + result = mod.compare_pin( + package="omnibase-infra", + expected_version="0.38.1", + expected_rev="e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59", + actual_version="0.38.3", + actual_rev="20a42cbf0000000000000000000000000000aaaa", + ) + assert result.match is True + assert result.status == "ahead" + + +def test_compare_pin_regression_is_violation() -> None: + """The exact 06-11 crash: vendored 0.37.0 against a 0.38.1 lock pin.""" + mod = _load_module() + result = mod.compare_pin( + package="omnibase-infra", + expected_version="0.38.1", + expected_rev="e2dbdc950540df8bc59ca4370b2d4a0f5b8d6c59", + actual_version="0.37.0", + actual_rev="2c1d672f0000000000000000000000000000bbbb", + ) + assert result.match is False + assert result.status == "regression" + + +def test_assert_pins_satisfied_raises_on_regression() -> None: + mod = _load_module() + comparisons = [ + mod.PinComparison( + package="omnibase-infra", + expected_version="0.38.1", + expected_rev="e2dbdc95", + actual_version="0.37.0", + actual_rev="2c1d672f", + match=False, + status="regression", + ) + ] + with pytest.raises(mod.WorkspacePinError) as exc: + mod.assert_pins_satisfied(comparisons) + msg = str(exc.value) + assert "omnibase-infra" in msg + assert "0.38.1" in msg # expected + assert "0.37.0" in msg # actual + + +def test_assert_pins_satisfied_passes_when_all_match() -> None: + mod = _load_module() + comparisons = [ + mod.PinComparison( + package="omnibase-infra", + expected_version="0.38.1", + expected_rev="e2dbdc95", + actual_version="0.38.3", + actual_rev="20a42cbf", + match=True, + status="ahead", + ) + ] + # Must not raise. + mod.assert_pins_satisfied(comparisons) + + +def test_read_repo_version_from_pyproject(tmp_path: Path) -> None: + mod = _load_module() + repo = tmp_path / "omnibase_infra" + repo.mkdir() + (repo / "pyproject.toml").write_text( + '[project]\nname = "omnibase-infra"\nversion = "0.38.3"\n', + encoding="utf-8", + ) + assert mod.read_repo_version(repo) == "0.38.3" + + +def test_read_repo_version_missing_pyproject_raises(tmp_path: Path) -> None: + mod = _load_module() + with pytest.raises(FileNotFoundError): + mod.read_repo_version(tmp_path / "no-repo") + + +def test_build_comparisons_detects_staged_regression(tmp_path: Path) -> None: + """End-to-end: a staged sibling tree older than the lock pin is flagged.""" + mod = _load_module() + + # Consuming repo (omnimarket) with the dev lock pinning infra 0.38.1. + omnimarket = tmp_path / "omnimarket" + omnimarket.mkdir() + (omnimarket / "uv.lock").write_text(_LOCK_TEXT, encoding="utf-8") + (omnimarket / "pyproject.toml").write_text( + '[project]\nname = "omnimarket"\nversion = "0.4.3"\n', encoding="utf-8" + ) + + # Staged infra sibling at a STALE 0.37.0 (the crash condition). + infra = tmp_path / "omnibase_infra" + infra.mkdir() + (infra / "pyproject.toml").write_text( + '[project]\nname = "omnibase-infra"\nversion = "0.37.0"\n', encoding="utf-8" + ) + + comparisons = mod.build_comparisons( + lock_path=omnimarket / "uv.lock", + siblings={"omnibase-infra": infra}, + ) + infra_cmp = next(c for c in comparisons if c.package == "omnibase-infra") + assert infra_cmp.status == "regression" + assert infra_cmp.match is False + + with pytest.raises(mod.WorkspacePinError): + mod.assert_pins_satisfied(comparisons)