diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md index 0a28e71fb..91f777a0f 100644 --- a/docs/platform-compatibility-report.md +++ b/docs/platform-compatibility-report.md @@ -2,7 +2,7 @@ -- Generated: `2026-10-16T18:58:07+00:00` +- Generated: `2026-10-20T16:04:12+00:00` - Selection: `explicit-published` - Compatibility: **dependency-compatible** - Signature: `unsigned (signing infrastructure pending)` diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md index ea554eed6..ebffe9857 100644 --- a/docs/platform-manifest.md +++ b/docs/platform-manifest.md @@ -19,14 +19,21 @@ it must agree with. Other repositories contribute source data only: - PyPI is the authority for published versions, artifact URLs, and digests. - Each exact public release tag is the authority for its release source commit and tree. This reference is not, by itself, a build-provenance attestation. -- The exact published Desktop release tag (`desktop-vX.Y.Z` when that native - tag exists, otherwise `vX.Y.Z`) and its `uv.lock` are the authority for the - package versions frozen into `openadapt-engine`. + A component released from this monorepo is tagged `-vX.Y.Z` + (`openadapt-flow-v2.3.0`) here, and its sources, evidence and lock are read + under `packages//`; the manifest records that directory as the + provenance `source_root`. A release from before the fold is tagged `vX.Y.Z` + in its standalone repository. The generator binds whichever tag exists, the + monorepo first. +- The exact published Desktop release tag (`openadapt-desktop-vX.Y.Z` here, or + in the standalone repository `desktop-vX.Y.Z` when that native tag exists, + otherwise `vX.Y.Z`) and its `uv.lock` are the authority for the package + versions frozen into `openadapt-engine`. - `https://openadapt.ai/status.json` (maintained in `openadapt-web`, `public/status.json`) is the authority for substrate availability, release channel, and the public qualification summary. -- `openadapt-flow` holds the versioned qualification evidence packs the - manifest points at (`public-demo/evidence-packs/*/manifest.json` and the +- The selected Flow release holds the versioned qualification evidence packs + the manifest points at (`public-demo/evidence-packs/*/manifest.json` and the effectbench task pack manifest). ## How it is generated diff --git a/platform-manifest.json b/platform-manifest.json index 47faffe22..476f98b50 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -1,12 +1,12 @@ { "manifest_kind": "openadapt-platform-release-manifest", "schema_version": "2.0.0", - "generated_at": "2026-10-16T18:58:07+00:00", + "generated_at": "2026-10-20T16:04:12+00:00", "generation": { "files": [ { "path": "scripts/generate_platform_manifest.py", - "sha256": "41b85ccddba7d2f610753d321111df6949dfeae1f1a233a78c34dcb24027f832" + "sha256": "52d0c84da7304e42b79e9dfb626e4569b17a49b975714db431f65b497d12a32c" }, { "path": "scripts/render_platform_versions.py", diff --git a/scripts/generate_platform_manifest.py b/scripts/generate_platform_manifest.py index 0f1243109..17bbf72c0 100644 --- a/scripts/generate_platform_manifest.py +++ b/scripts/generate_platform_manifest.py @@ -44,6 +44,7 @@ import urllib.error import urllib.request from pathlib import Path +from typing import NamedTuple from render_platform_versions import render_markdown @@ -93,6 +94,20 @@ } COMPONENT_REPOSITORIES["launcher"] = "OpenAdaptAI/OpenAdapt" +# Components folded into the monorepo publish from it: each package lives +# under packages/, and its release workflow tags it -v +# (openadapt-flow-v2.2.0). Releases from before the fold stay bound to the +# frozen standalone repository and its v tags (COMPONENT_REPOSITORIES). +MONOREPO_REPOSITORY = "OpenAdaptAI/OpenAdapt" +MONOREPO_PACKAGE_DIRS = { + "flow": "packages/flow", + "capture": "packages/capture", + "privacy": "packages/privacy", + "types": "packages/types", + "desktop": "packages/desktop", + "agent": "packages/agent", +} + RUNTIME_UNIT_NAMES = { "launcher_environment", "customer_runner", @@ -171,7 +186,8 @@ SUPPORTED_OS = ["windows", "macos", "linux"] # Qualification evidence pointers. IDs are stable; consumers resolve them to -# the referenced documents. The public status document is the live summary; +# the referenced documents. A component's evidence path is relative to the +# package; the manifest records it in the repository of the selected release. The public status document is the live summary; # the flow evidence packs are versioned, replayable evidence bundles. QUALIFICATION_EVIDENCE = [ { @@ -188,7 +204,6 @@ "id": "flow-public-demo-evidence-packs", "kind": "evidence-pack-collection", "source_component": "flow", - "repository": "OpenAdaptAI/openadapt-flow", "path": "public-demo/evidence-packs", "description": ( "Versioned public evidence packs with per-pack manifest files, " @@ -199,7 +214,6 @@ "id": "flow-effectbench-task-pack", "kind": "benchmark-manifest", "source_component": "flow", - "repository": "OpenAdaptAI/openadapt-flow", "path": "benchmark/effectbench/task_pack/manifest.json", "description": ( "Effect-verification benchmark task pack manifest used by the " @@ -351,7 +365,8 @@ def _schema_compatibility(components: dict[str, dict]) -> dict[str, dict]: versions: set[int] = set() optional = bool(probe.get("optional")) missing_optional = False - for path, patterns in probe["sources"].items(): + for package_path, patterns in probe["sources"].items(): + path = _source_path(component["provenance"], package_path) key = (repository, commit, path) if key not in cache: cache[key] = _github_source_file( @@ -489,10 +504,70 @@ def _release_ref_candidates(role: str, version: str) -> tuple[str, ...]: return (f"v{version}",) +class ReleaseSource(NamedTuple): + """Where one published component version may have been tagged.""" + + repository: str + release_ref: str + # The package's directory in the repository; "" for a standalone repository. + source_root: str + + +def _release_sources(role: str, version: str) -> tuple[ReleaseSource, ...]: + """Return the tags that may identify this component version, in order. + + A folded component is looked up in the monorepo first, then in its frozen + standalone repository for releases from before the fold. The generator binds + the first tag that exists; it does not invent a tag name. + """ + standalone = tuple( + ReleaseSource(COMPONENT_REPOSITORIES[role], release_ref, "") + for release_ref in _release_ref_candidates(role, version) + ) + source_root = MONOREPO_PACKAGE_DIRS.get(role) + if source_root is None: + return standalone + monorepo = ReleaseSource( + MONOREPO_REPOSITORY, f"{COMPONENTS[role]}-v{version}", source_root + ) + return (monorepo, *standalone) + + +def _provenance_source(provenance: dict) -> ReleaseSource: + return ReleaseSource( + str(provenance.get("repository")), + str(provenance.get("release_ref")), + str(provenance.get("source_root", "")), + ) + + +def _source_path(provenance: dict, package_path: str) -> str: + """Return a package-relative path as a path in the release's repository.""" + source_root = str(provenance.get("source_root", "")) + return f"{source_root}/{package_path}" if source_root else package_path + + +def _evidence_location(evidence: dict, provenance: dict) -> tuple[str, str]: + """Return the repository and repository path of one evidence pointer.""" + return str(provenance["repository"]), _source_path(provenance, evidence["path"]) + + +def _desktop_lock_url(provenance: dict) -> str: + """Return the locked environment the native sidecar was built from.""" + source_root = str(provenance.get("source_root", "")) + if not source_root: + return DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format( + release_ref=provenance["release_ref"] + ) + return ( + f"https://raw.githubusercontent.com/{provenance['repository']}/" + f"{provenance['release_ref']}/{source_root}/uv.lock" + ) + + def _source_provenance(role: str, version: str) -> dict: - repository = COMPONENT_REPOSITORIES[role] - candidates = _release_ref_candidates(role, version) - for release_ref in candidates: + candidates = _release_sources(role, version) + for repository, release_ref, source_root in candidates: url = GITHUB_COMMIT_URL_TEMPLATE.format( repository=repository, release_ref=release_ref ) @@ -511,16 +586,21 @@ def _source_provenance(role: str, version: str) -> dict: f"FATAL: {url} did not resolve an exact commit and tree for " f"{repository}@{release_ref}." ) - return { + provenance = { "repository": repository, "release_ref": release_ref, "commit": commit, "tree": tree, "url": f"https://github.com/{repository}/tree/{release_ref}", } + if source_root: + provenance["source_root"] = source_root + provenance["url"] += f"/{source_root}" + return provenance + tried = ", ".join(f"{c.repository}@{c.release_ref}" for c in candidates) raise DriftError( - f"FATAL: {repository} has no published release tag for {version}. " - f"Tried {', '.join(candidates)}." + f"FATAL: {COMPONENTS[role]} has no published release tag for {version}. " + f"Tried {tried}." ) @@ -685,11 +765,12 @@ def _runtime_unit_requirements( def _desktop_sidecar_lock( - source_ref: str, required_packages: set[str] + provenance: dict, required_packages: set[str] ) -> tuple[str, str, str, dict[str, str]]: """Read the immutable package closure used by the native sidecar build.""" - url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(release_ref=source_ref) + source_ref = str(provenance["release_ref"]) + url = _desktop_lock_url(provenance) lock_text = _fetch_text(url) try: lock = tomllib.loads(lock_text) @@ -723,7 +804,7 @@ def _runtime_units(components: dict[str, dict]) -> dict[str, dict]: requirements = _runtime_unit_requirements(components) sidecar_ref, sidecar_lock_url, sidecar_lock_sha256, sidecar_resolved = ( _desktop_sidecar_lock( - components["desktop"]["provenance"]["release_ref"], + components["desktop"]["provenance"], set(requirements["desktop_sidecar"]), ) ) @@ -920,12 +1001,13 @@ def _qualification_evidence(components: dict[str, dict]) -> list[dict]: role = row.get("source_component") if isinstance(role, str): provenance = components[role]["provenance"] - repository = row["repository"] - if repository != provenance["repository"]: - raise DriftError( - f"FATAL: evidence {row['id']} repository does not match " - f"selected {role} provenance." - ) + repository, path = _evidence_location(row, provenance) + located: dict = {} + for key, value in row.items(): + located[key] = path if key == "path" else value + if key == "source_component": + located["repository"] = repository + row = located tree_key = (repository, provenance["tree"]) if tree_key not in trees: trees[tree_key] = _github_tree_entries(*tree_key) diff --git a/scripts/validate_platform_manifest.py b/scripts/validate_platform_manifest.py index 3eaadfc3f..7c60a5a9b 100644 --- a/scripts/validate_platform_manifest.py +++ b/scripts/validate_platform_manifest.py @@ -79,9 +79,7 @@ class of check (for example in an airgapped environment). import tomli as tomllib # type: ignore[no-redef] from generate_platform_manifest import ( - COMPONENT_REPOSITORIES, COMPONENTS, - DESKTOP_NATIVE_LOCK_URL_TEMPLATE, GITHUB_COMMIT_URL_TEMPLATE, PYPI_VERSION_URL_TEMPLATE, QUALIFICATION_EVIDENCE, @@ -89,11 +87,14 @@ class of check (for example in an airgapped environment). DriftError, _compatibility_status, _dependency_edges, + _desktop_lock_url, + _evidence_location, _generation_metadata, _github_tree_entries, _openadapt_dependency_constraints, _openadapt_requirements, - _release_ref_candidates, + _provenance_source, + _release_sources, _runtime_unit_requirements, _schema_compatibility, _substrates_from_status, @@ -219,17 +220,21 @@ def check_structure(manifest: dict, report: Report) -> None: if component.get("source") != "pypi": report.error(f"component {role} source must be 'pypi'") provenance = component.get("provenance", {}) - expected_repository = COMPONENT_REPOSITORIES.get(role) - expected_refs = _release_ref_candidates(role, str(component.get("version"))) - if provenance.get("repository") != expected_repository: - report.error( - f"component {role} provenance repository is " - f"{provenance.get('repository')!r}, expected {expected_repository!r}" - ) - if provenance.get("release_ref") not in expected_refs: + expected_sources = ( + _release_sources(role, str(component.get("version"))) + if role in COMPONENTS + else () + ) + if _provenance_source(provenance) not in expected_sources: + expected = [ + f"{source.repository}@{source.release_ref}" + + (f" ({source.source_root})" if source.source_root else "") + for source in expected_sources + ] report.error( - f"component {role} release_ref is " - f"{provenance.get('release_ref')!r}, expected one of {expected_refs!r}" + f"component {role} provenance is " + f"{provenance.get('repository')!r}@{provenance.get('release_ref')!r}, " + f"expected one of {expected!r}" ) for digest in ("commit", "tree"): if not re.fullmatch(r"[0-9a-f]{40}", str(provenance.get(digest, ""))): @@ -288,6 +293,16 @@ def check_structure(manifest: dict, report: Report) -> None: continue expected = expected_evidence.get(evidence.get("id")) if expected is not None: + expected = dict(expected) + expected_role = expected.get("source_component") + if expected_role is not None: + provenance = components.get(expected_role, {}).get("provenance", {}) + try: + expected["repository"], expected["path"] = _evidence_location( + expected, provenance + ) + except KeyError: + expected["repository"] = None for field in ("kind", "source_component", "repository", "path"): if expected.get(field) != evidence.get(field): report.error( @@ -434,16 +449,15 @@ def check_structure(manifest: dict, report: Report) -> None: ) sidecar = runtime_units.get("desktop_sidecar", {}) - expected_sidecar_ref = ( - components.get("desktop", {}).get("provenance", {}).get("release_ref") - ) + desktop_provenance = components.get("desktop", {}).get("provenance", {}) + expected_sidecar_ref = desktop_provenance.get("release_ref") if sidecar.get("source_ref") != expected_sidecar_ref: report.error( f"desktop_sidecar source_ref is {sidecar.get('source_ref')!r}, " f"expected {expected_sidecar_ref!r}" ) - expected_lock_url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format( - release_ref=expected_sidecar_ref + expected_lock_url = ( + _desktop_lock_url(desktop_provenance) if expected_sidecar_ref else None ) if sidecar.get("lock_url") != expected_lock_url: report.error( @@ -931,17 +945,22 @@ def check_source_provenance( ) -> None: for role, component in manifest.get("components", {}).items(): provenance = component.get("provenance", {}) - release_ref = provenance.get("release_ref") - expected_refs = _release_ref_candidates(role, str(component.get("version"))) - if release_ref not in expected_refs: + source = _provenance_source(provenance) + expected_sources = ( + _release_sources(role, str(component.get("version"))) + if role in COMPONENTS + else () + ) + if source not in expected_sources: report.error( - f"{role} source provenance uses release_ref {release_ref!r}, " - f"expected one of {expected_refs!r}" + f"{role} source provenance uses " + f"{source.repository}@{source.release_ref}, which is not a " + "release tag for its version" ) continue url = GITHUB_COMMIT_URL_TEMPLATE.format( - repository=COMPONENT_REPOSITORIES[role], - release_ref=release_ref, + repository=source.repository, + release_ref=source.release_ref, ) try: doc = _fetch_json(url) @@ -1073,14 +1092,17 @@ def check_desktop_sidecar_lock( unit = manifest.get("runtime_units", {}).get("desktop_sidecar", {}) version = unit.get("version") source_ref = unit.get("source_ref") - expected_refs = _release_ref_candidates("desktop", str(version)) - if source_ref not in expected_refs: + provenance = manifest.get("components", {}).get("desktop", {}).get("provenance", {}) + expected_refs = tuple( + source.release_ref for source in _release_sources("desktop", str(version)) + ) + if source_ref not in expected_refs or source_ref != provenance.get("release_ref"): report.error( f"desktop_sidecar source_ref is {source_ref!r}, " f"expected one of {expected_refs!r}" ) return - url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(release_ref=source_ref) + url = _desktop_lock_url(provenance) try: lock_text = _fetch_text(url) resolved = _locked_openadapt_versions( diff --git a/tests/test_platform_manifest_monorepo.py b/tests/test_platform_manifest_monorepo.py new file mode 100644 index 000000000..40b2741aa --- /dev/null +++ b/tests/test_platform_manifest_monorepo.py @@ -0,0 +1,294 @@ +"""The platform manifest binds components released from the monorepo. + +Since the fold, Flow, Types, Agent and Desktop publish from OpenAdaptAI/OpenAdapt +under ``packages/`` and tag each release ``-v`` +(``openadapt-flow-v2.2.0``). Releases from before the fold stay in the frozen +standalone repositories under ``v``. The generator must bind whichever +tag exists, read schema sources and evidence under the package directory, and +the validator must accept both forms and nothing else. +""" + +from __future__ import annotations + +import json +import sys +from copy import deepcopy +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_platform_manifest as gen # noqa: E402 +from generate_platform_manifest import ( # noqa: E402 + MONOREPO_REPOSITORY, + SCHEMA_PROBES, + DriftError, + ReleaseSource, + _desktop_lock_url, + _evidence_location, + _release_sources, + _schema_compatibility, + _source_path, + _source_provenance, +) +from validate_platform_manifest import Report, check_structure # noqa: E402 + +MANIFEST_PATH = REPO_ROOT / "platform-manifest.json" +COMMIT = "a" * 40 +TREE = "b" * 40 + + +@pytest.fixture() +def manifest() -> dict: + return json.loads(MANIFEST_PATH.read_text()) + + +def _commit_doc() -> dict: + return {"sha": COMMIT, "commit": {"tree": {"sha": TREE}}} + + +def _monorepo_flow(manifest: dict, version: str = "2.3.0") -> dict: + """The committed manifest with Flow rebound to a monorepo release.""" + + moved = deepcopy(manifest) + flow = moved["components"]["flow"] + flow["version"] = version + flow["provenance"] = { + "repository": MONOREPO_REPOSITORY, + "release_ref": f"openadapt-flow-v{version}", + "commit": COMMIT, + "tree": TREE, + "url": ( + f"https://github.com/{MONOREPO_REPOSITORY}/tree/" + f"openadapt-flow-v{version}/packages/flow" + ), + "source_root": "packages/flow", + } + for evidence in moved["qualification_evidence"]: + if evidence.get("source_component") != "flow": + continue + package_path = evidence["path"] + evidence["repository"] = MONOREPO_REPOSITORY + evidence["path"] = f"packages/flow/{package_path}" + evidence["source_commit"] = COMMIT + kind = "blob" if evidence["source_object_type"] == "blob" else "tree" + evidence["url"] = ( + f"https://github.com/{MONOREPO_REPOSITORY}/{kind}/{COMMIT}/" + f"{evidence['path']}" + ) + for schema in moved["schema_compatibility"].values(): + if schema["source_component"] == "flow": + schema["source_commit"] = COMMIT + return moved + + +def _provenance_and_evidence_errors(report: Report) -> list[str]: + return [ + error + for error in report.errors + if "provenance" in error or "qualification evidence" in error + ] + + +def test_folded_components_look_in_the_monorepo_first() -> None: + assert _release_sources("flow", "2.3.0") == ( + ReleaseSource(MONOREPO_REPOSITORY, "openadapt-flow-v2.3.0", "packages/flow"), + ReleaseSource("OpenAdaptAI/openadapt-flow", "v2.3.0", ""), + ) + assert _release_sources("desktop", "0.22.0") == ( + ReleaseSource( + MONOREPO_REPOSITORY, "openadapt-desktop-v0.22.0", "packages/desktop" + ), + ReleaseSource("OpenAdaptAI/openadapt-desktop", "desktop-v0.22.0", ""), + ReleaseSource("OpenAdaptAI/openadapt-desktop", "v0.22.0", ""), + ) + # The launcher was always this repository, tagged v. + assert _release_sources("launcher", "1.18.0") == ( + ReleaseSource(MONOREPO_REPOSITORY, "v1.18.0", ""), + ) + + +def test_a_monorepo_release_binds_its_package_directory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[str] = [] + + def fetch(url: str, **_kwargs): + seen.append(url) + return _commit_doc() if "/OpenAdapt/commits/openadapt-flow-v" in url else None + + monkeypatch.setattr(gen, "_fetch_json_optional", fetch) + provenance = _source_provenance("flow", "2.3.0") + + assert provenance == { + "repository": MONOREPO_REPOSITORY, + "release_ref": "openadapt-flow-v2.3.0", + "commit": COMMIT, + "tree": TREE, + "url": ( + "https://github.com/OpenAdaptAI/OpenAdapt/tree/" + "openadapt-flow-v2.3.0/packages/flow" + ), + "source_root": "packages/flow", + } + assert len(seen) == 1 + + +def test_a_release_from_before_the_fold_stays_in_its_standalone_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fetch(url: str, **_kwargs): + return _commit_doc() if "/openadapt-flow/commits/v2.0.0" in url else None + + monkeypatch.setattr(gen, "_fetch_json_optional", fetch) + provenance = _source_provenance("flow", "2.0.0") + + assert provenance["repository"] == "OpenAdaptAI/openadapt-flow" + assert provenance["release_ref"] == "v2.0.0" + assert ( + provenance["url"] == "https://github.com/OpenAdaptAI/openadapt-flow/tree/v2.0.0" + ) + # Byte-identical to manifests generated before monorepo support. + assert "source_root" not in provenance + + +def test_a_version_tagged_nowhere_names_every_tag_tried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(gen, "_fetch_json_optional", lambda _url, **_kwargs: None) + with pytest.raises(DriftError) as raised: + _source_provenance("types", "0.24.0") + message = str(raised.value) + assert "OpenAdaptAI/OpenAdapt@openadapt-types-v0.24.0" in message + assert "OpenAdaptAI/openadapt-types@v0.24.0" in message + + +def test_paths_are_read_under_the_package_directory() -> None: + monorepo = {"repository": MONOREPO_REPOSITORY, "source_root": "packages/flow"} + standalone = {"repository": "OpenAdaptAI/openadapt-flow"} + + assert _source_path(monorepo, "openadapt_flow/ir.py") == ( + "packages/flow/openadapt_flow/ir.py" + ) + assert _source_path(standalone, "openadapt_flow/ir.py") == "openadapt_flow/ir.py" + evidence = {"path": "public-demo/evidence-packs"} + assert _evidence_location(evidence, monorepo) == ( + MONOREPO_REPOSITORY, + "packages/flow/public-demo/evidence-packs", + ) + assert _evidence_location(evidence, standalone) == ( + "OpenAdaptAI/openadapt-flow", + "public-demo/evidence-packs", + ) + + +def test_the_desktop_sidecar_lock_comes_from_its_release_tag() -> None: + assert _desktop_lock_url( + { + "repository": MONOREPO_REPOSITORY, + "release_ref": "openadapt-desktop-v0.22.0", + "source_root": "packages/desktop", + } + ) == ( + "https://raw.githubusercontent.com/OpenAdaptAI/OpenAdapt/" + "openadapt-desktop-v0.22.0/packages/desktop/uv.lock" + ) + assert _desktop_lock_url( + {"repository": "OpenAdaptAI/openadapt-desktop", "release_ref": "v0.16.0"} + ) == ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/" + "v0.16.0/uv.lock" + ) + + +def test_schema_sources_are_read_from_the_package_in_the_monorepo( + manifest: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + prefixes = { + str(probe["schema_prefix"]) + for probe in SCHEMA_PROBES.values() + if "schema_prefix" in probe + } + source = ( + "SCHEMA_VERSION = 3\n" + + 'data.get("schema_version", 3)\n' + + "".join( + f'X{index} = "{prefix}1"\n' for index, prefix in enumerate(sorted(prefixes)) + ) + ) + requested: list[tuple[str, str]] = [] + + def fake_source(repository: str, commit: str, path: str, *, optional=False): + requested.append((repository, path)) + return source, {"path": path, "git_blob": "c" * 40, "sha256": "d" * 64} + + monkeypatch.setattr(gen, "_github_source_file", fake_source) + components = _monorepo_flow(manifest)["components"] + schemas = _schema_compatibility(components) + + flow_reads = [path for repository, path in requested if "openadapt_flow/" in path] + assert flow_reads + assert all(path.startswith("packages/flow/openadapt_flow/") for path in flow_reads) + assert {repository for repository, path in requested if path in flow_reads} == { + MONOREPO_REPOSITORY + } + assert schemas["workflow_bundle"]["source_commit"] == COMMIT + # Components still on standalone releases keep their package-relative paths. + assert ("OpenAdaptAI/openadapt-types", "openadapt_types/human_decision.py") in ( + requested + ) + + +def test_validator_accepts_a_monorepo_release(manifest: dict) -> None: + report = Report() + check_structure(_monorepo_flow(manifest), report) + assert _provenance_and_evidence_errors(report) == [] + + +def test_validator_accepts_the_standalone_releases_already_pinned( + manifest: dict, +) -> None: + report = Report() + check_structure(manifest, report) + assert _provenance_and_evidence_errors(report) == [] + + +@pytest.mark.parametrize( + "mutate", + [ + # The monorepo tag with the standalone repository. + lambda flow: flow["provenance"].update(repository="OpenAdaptAI/openadapt-flow"), + # Another package's directory. + lambda flow: flow["provenance"].update(source_root="packages/types"), + # A monorepo release read as if it were the repository root. + lambda flow: flow["provenance"].pop("source_root"), + # A tag for another version. + lambda flow: flow["provenance"].update(release_ref="openadapt-flow-v2.2.0"), + ], +) +def test_validator_refuses_a_mismatched_monorepo_provenance( + manifest: dict, mutate +) -> None: + moved = _monorepo_flow(manifest) + mutate(moved["components"]["flow"]) + report = Report() + check_structure(moved, report) + assert any("component flow provenance" in error for error in report.errors) + + +def test_validator_refuses_evidence_outside_the_package_directory( + manifest: dict, +) -> None: + moved = _monorepo_flow(manifest) + evidence = next( + row for row in moved["qualification_evidence"] if row.get("source_component") + ) + evidence["path"] = evidence["path"].removeprefix("packages/flow/") + report = Report() + check_structure(moved, report) + assert any( + f"qualification evidence {evidence['id']!r} path drift" in error + for error in report.errors + )