diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c29df0b..2c7bae9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,7 +37,9 @@ jobs: - name: Verify the public distribution boundary if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - run: python scripts/check_source_boundary.py --require-dist + run: | + python scripts/verify_release_artifacts.py + python scripts/check_source_boundary.py --require-dist # v1.14.0 bundles twine 6.1.0 and packaging 25.0, which reject the # Metadata-Version 2.5 that current hatchling emits. That pin failed the diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a23904..68d4d8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,5 @@ jobs: - name: Build and verify the public distribution boundary run: | uv build + python scripts/verify_release_artifacts.py python scripts/check_source_boundary.py --require-dist diff --git a/README.md b/README.md index 30d8651..53b575c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # openadapt-types > [!IMPORTANT] -> **Status: Experimental. Interoperability schemas, not the product.** This -> package publishes shared schemas for computer-use agents as an optional -> component, with no production support promise. +> **Lifecycle: Support.** `openadapt-types` is the current public schema +> dependency for OpenAdapt components and partner integrations. Support +> identifies its role in the stack. It does not create an additional OpenAdapt +> product target or a separate Production claim. > > The OpenAdapt product is the demonstration compiler, > [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow), installed @@ -12,9 +13,9 @@ > on Windows `cmd.exe` use `pip install "openadapt[browser]"`): it compiles a > demonstrated GUI workflow into a > deterministic, locally executable program. Healthy runs make no model calls, -> and it halts instead of guessing when verification fails. Lifecycle labels for -> every repository are in the -> [repository lifecycle registry](https://github.com/OpenAdaptAI/.github/blob/main/REPOSITORY_LIFECYCLE.md). +> and it halts instead of guessing when verification fails. The live admission +> result for the seven OpenAdapt product targets is available from +> [`openadapt.ai/status.json`](https://openadapt.ai/status.json). Canonical Pydantic schemas for computer-use agents. @@ -34,24 +35,22 @@ OpenAdapt is a governed demonstration compiler: record a workflow once, compile the recording into a deterministic program, and replay that program with zero model calls on the healthy path. When the live screen does not match what was demonstrated it halts instead of guessing, using identity gates and independent -effect verification. Every substrate is first-class. +effect verification. -Substrate maturity, stated the same way across the OpenAdapt repositories: - -| Substrate | Maturity | -| --- | --- | -| Browser (web) | Beta; available in production today through the managed browser product | -| Native desktop (Windows, macOS, Linux) | Available for customer-controlled execution; qualification evidence is task- and environment-specific | -| Remote display (RDP) | Available for customer-controlled execution; qualification evidence is task- and environment-specific | -| Citrix / VDI | Available for customer-controlled execution; real-environment ICA/HDX qualification is deployment-specific | +Schemas describe a stable interface. They do not assign a static maturity state +to Browser, native desktop, RDP, or Citrix/VDI. Product status is derived from +signed, expiring, and revocable release admissions for the exact seven product +targets. A current product release can execute only an exact workflow version +that has its own active admission for the bound application, environment, +contracts, evidence authority, validity, and revocation state. The packages in the stack: | Package | Role | | --- | --- | | [`openadapt`](https://github.com/OpenAdaptAI/OpenAdapt) | Launcher and installer (`pip install openadapt`) | -| [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow) | Records, compiles, verifies, and replays workflows | -| [`openadapt-capture`](https://github.com/OpenAdaptAI/openadapt-capture) | Cross-platform local desktop recording | +| [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow) | Normalizes demonstrations, then compiles, verifies, and replays workflows | +| [`openadapt-capture`](https://github.com/OpenAdaptAI/openadapt-capture) | Canonical native screen, mouse, keyboard, timing, window, and media capture | | **`openadapt-types`** | Canonical action and UI-state schema (this package) | | [`openadapt-grounding`](https://github.com/OpenAdaptAI/openadapt-grounding) | Local OCR text-anchoring plus optional model grounding | | [`openadapt-privacy`](https://github.com/OpenAdaptAI/openadapt-privacy) | PHI/PII detection and redaction | diff --git a/pyproject.toml b/pyproject.toml index b6a1bd0..82982ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ authors = [ ] keywords = ["computer-use", "gui-automation", "schemas", "pydantic", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -38,6 +37,7 @@ Issues = "https://github.com/OpenAdaptAI/openadapt-types/issues" dev = [ "pytest>=7.0", "pytest-cov", + "tomli>=2.0.0; python_version < '3.11'", ] @@ -57,7 +57,7 @@ allow_zero_version = true # existing editable lock entry without resolving dependencies, stage it in the # generated release commit, and build exactly those reviewed inputs. The PSR # action is a Docker action, so uv must be installed inside its container. -build_command = "python -m pip install uv==0.11.29 && python scripts/verify_release_lock.py --write && git add uv.lock && uv build" +build_command = "python -m pip install uv==0.11.29 && python scripts/verify_release_lock.py --write && git add uv.lock && uv build && python scripts/verify_release_artifacts.py" [tool.semantic_release.branches.main] match = "main" diff --git a/scripts/verify_release_artifacts.py b/scripts/verify_release_artifacts.py new file mode 100644 index 0000000..c7a8e3f --- /dev/null +++ b/scripts/verify_release_artifacts.py @@ -0,0 +1,103 @@ +"""Verify the exact wheel and source distribution before publication.""" + +from __future__ import annotations + +import argparse +import email +import re +import tarfile +import zipfile +from email.message import Message +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised by supported Python 3.10 + import tomli as tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +class ArtifactError(RuntimeError): + """A release artifact does not match the reviewed project metadata.""" + + +def _canonical_name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def _project_identity(root: Path) -> tuple[str, str]: + project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + name = project.get("name") + version = project.get("version") + if not isinstance(name, str) or not name or not isinstance(version, str) or not version: + raise ArtifactError("pyproject.toml must declare a project name and version") + classifiers = project.get("classifiers", []) + if any(str(item).startswith("Development Status ::") for item in classifiers): + raise ArtifactError("project metadata must not publish a static maturity classifier") + return name, version + + +def _wheel_metadata(path: Path) -> bytes: + with zipfile.ZipFile(path) as archive: + names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(names) != 1: + raise ArtifactError(f"{path.name} must contain exactly one METADATA file") + return archive.read(names[0]) + + +def _sdist_metadata(path: Path) -> bytes: + with tarfile.open(path, "r:gz") as archive: + members = [ + member + for member in archive.getmembers() + if member.isfile() and member.name.count("/") == 1 and member.name.endswith("/PKG-INFO") + ] + if len(members) != 1: + raise ArtifactError(f"{path.name} must contain exactly one root PKG-INFO file") + stream = archive.extractfile(members[0]) + if stream is None: + raise ArtifactError(f"{path.name} PKG-INFO cannot be read") + return stream.read() + + +def _verify_metadata(path: Path, payload: bytes, name: str, version: str) -> None: + metadata: Message = email.message_from_bytes(payload) + if _canonical_name(metadata.get("Name", "")) != _canonical_name(name): + raise ArtifactError(f"{path.name} has the wrong package name") + if metadata.get("Version") != version: + raise ArtifactError(f"{path.name} has the wrong package version") + classifiers = metadata.get_all("Classifier", []) + if any(value.startswith("Development Status ::") for value in classifiers): + raise ArtifactError(f"{path.name} publishes a static maturity classifier") + + +def verify_distributions(root: Path = ROOT) -> tuple[Path, Path]: + """Verify one wheel and one source archive against ``pyproject.toml``.""" + name, version = _project_identity(root) + dist = root / "dist" + wheels = sorted(dist.glob("*.whl")) + sdists = sorted(dist.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ArtifactError("dist must contain exactly one wheel and one source distribution") + _verify_metadata(wheels[0], _wheel_metadata(wheels[0]), name, version) + _verify_metadata(sdists[0], _sdist_metadata(sdists[0]), name, version) + return wheels[0], sdists[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + args = parser.parse_args() + try: + wheel, sdist = verify_distributions(args.root.resolve()) + except (ArtifactError, OSError, KeyError, tarfile.TarError, zipfile.BadZipFile) as exc: + parser.exit(1, f"release artifact verification failed: {exc}\n") + print(f"verified {wheel.name} and {sdist.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 0000000..5f46d62 --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +import io +import tarfile +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "verify_release_artifacts.py" +SPEC = importlib.util.spec_from_file_location("verify_release_artifacts", SCRIPT) +assert SPEC and SPEC.loader +artifacts = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(artifacts) + + +def _metadata(*, classifier: str | None = None) -> bytes: + lines = [ + "Metadata-Version: 2.4", + "Name: example-package", + "Version: 1.2.3", + ] + if classifier is not None: + lines.append(f"Classifier: {classifier}") + return ("\n".join(lines) + "\n\n").encode() + + +def _write_release(root: Path, *, artifact_classifier: str | None = None) -> None: + (root / "pyproject.toml").write_text( + '[project]\nname = "example-package"\nversion = "1.2.3"\nclassifiers = []\n', + encoding="utf-8", + ) + dist = root / "dist" + dist.mkdir() + payload = _metadata(classifier=artifact_classifier) + with zipfile.ZipFile(dist / "example_package-1.2.3-py3-none-any.whl", "w") as archive: + archive.writestr("example_package-1.2.3.dist-info/METADATA", payload) + with tarfile.open(dist / "example_package-1.2.3.tar.gz", "w:gz") as archive: + member = tarfile.TarInfo("example_package-1.2.3/PKG-INFO") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + +def test_matching_wheel_and_source_distribution_pass(tmp_path: Path) -> None: + _write_release(tmp_path) + wheel, sdist = artifacts.verify_distributions(tmp_path) + assert wheel.suffix == ".whl" + assert sdist.name.endswith(".tar.gz") + + +def test_static_maturity_classifier_in_archive_fails(tmp_path: Path) -> None: + _write_release(tmp_path, artifact_classifier="Development Status :: 3 - Alpha") + with pytest.raises(artifacts.ArtifactError, match="static maturity classifier"): + artifacts.verify_distributions(tmp_path) + + +def test_extra_release_archive_fails(tmp_path: Path) -> None: + _write_release(tmp_path) + (tmp_path / "dist" / "unexpected.whl").touch() + with pytest.raises(artifacts.ArtifactError, match="exactly one wheel"): + artifacts.verify_distributions(tmp_path) diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index ce5f87b..45347ed 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -67,11 +67,14 @@ def test_release_configuration_is_fail_closed() -> None: assert ( "python -m pip install uv==0.11.29 && " "python scripts/verify_release_lock.py --write && " - "git add uv.lock && uv build" + "git add uv.lock && uv build && " + "python scripts/verify_release_artifacts.py" ) in metadata assert metadata.index("python -m pip install uv==0.11.29") < metadata.index( "python scripts/verify_release_lock.py --write" - ) < metadata.index("git add uv.lock") < metadata.index("uv build") + ) < metadata.index("git add uv.lock") < metadata.index("uv build") < metadata.index( + "python scripts/verify_release_artifacts.py" + ) assert "run: uv build" not in workflow assert "astral-sh/setup-uv" not in workflow assert "actions/setup-python" not in workflow @@ -80,6 +83,13 @@ def test_release_configuration_is_fail_closed() -> None: assert 'version: "0.11.29"' in test_workflow assert 'python-version: "3.12"' in test_workflow assert "uv sync --locked --extra dev" in test_workflow + assert "python scripts/verify_release_artifacts.py" in test_workflow + assert "python scripts/verify_release_artifacts.py" in workflow + + +def test_project_has_no_static_maturity_classifier() -> None: + metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "Development Status ::" not in metadata def test_all_third_party_actions_are_commit_pinned() -> None: diff --git a/uv.lock b/uv.lock index 1f93199..10daf5e 100644 --- a/uv.lock +++ b/uv.lock @@ -170,6 +170,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -177,6 +178,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0.0" }, ] [[package]]