diff --git a/.github/workflows/production-lifecycle.yml b/.github/workflows/production-lifecycle.yml new file mode 100644 index 000000000..b8a68673b --- /dev/null +++ b/.github/workflows/production-lifecycle.yml @@ -0,0 +1,76 @@ +name: README Production lifecycle + +on: + pull_request: + paths: + - README.md + - production-lifecycle-source.json + - scripts/render_readme_maturity.py + - tests/test_readme_maturity.py + - .github/workflows/production-lifecycle.yml + push: + branches: [main] + paths: + - README.md + - production-lifecycle-source.json + - scripts/render_readme_maturity.py + - tests/test_readme_maturity.py + - .github/workflows/production-lifecycle.yml + schedule: + - cron: "23 7 * * *" + workflow_dispatch: + repository_dispatch: + types: [production-lifecycle-changed] + +concurrency: + group: readme-production-lifecycle-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Prove the consumer contract + run: python -m unittest discover -s tests -p test_readme_maturity.py + - name: Validate the exact source and README projection + run: >- + python scripts/render_readme_maturity.py + --check + --require-current-source + + report-drift: + needs: validate + if: >- + ${{ always() && needs.validate.result == 'failure' && + (github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'repository_dispatch') }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Open or update the lifecycle drift issue + env: + GH_TOKEN: ${{ github.token }} + TITLE: "README Production lifecycle source has drifted" + BODY: >- + The signed Production lifecycle consumer refused the current source + or README projection. Review the exact failure at + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. + run: | + set -euo pipefail + EXISTING=$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open \ + --search "in:title \"${TITLE}\"" --json number,title \ + --jq '[.[] | select(.title == env.TITLE)][0].number // empty') + if [ -n "${EXISTING}" ]; then + gh issue edit "${EXISTING}" --repo "${GITHUB_REPOSITORY}" --body "${BODY}" + else + gh issue create --repo "${GITHUB_REPOSITORY}" --title "${TITLE}" --body "${BODY}" + fi diff --git a/CLAUDE.md b/CLAUDE.md index 14b0b7ac4..938cd6986 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Repository role -This public repository is the Beta launcher/meta-package and stable community +This public repository is the launcher/meta-package and stable community entry point for OpenAdapt. It owns `pip install openadapt`, the unified `openadapt` CLI, release compatibility, and launcher packaging. diff --git a/README.md b/README.md index b63a6b966..86908a05a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,21 @@ unavoidable and the outcome needs proof. > and governed runtime are implemented in > [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow). This > repository provides the unified `openadapt` CLI and compatibility surface, -> not a second engine. Lifecycle: **Beta**. +> not a second engine. + + +> **Built for qualified production workflows.** A Production run requires both +> active signed product admissions for the exact OpenAdapt component and +> deployment releases, and an active signed, expiring, revocable workflow +> admission for the exact compiled workflow version. The workflow admission binds +> the organization and workflow identity; bundle version and digest; admitted +> runtime release; application and environment; input, action, identity, effect, +> and policy contracts; evidence authority; and its issue, expiry, and revocation +> state. Qualification requires at least three trials for each task and condition. +> A closed result schema must report silent incorrect success and over-halt. Any +> bound change requires a new qualification. +> [Check the live signed Production record](https://docs.openadapt.ai/production-lifecycle.json). + ## Try it locally @@ -126,6 +140,24 @@ state, record identity, target uniqueness, and the fresh application view. Afterward it waits for settled state and evaluates the declared effect. If the contract cannot be established, it returns evidence and halts. +### Complete run outcomes + +Every terminal run records what the runtime knows about the business effect: + +| Outcome | Meaning | +|---|---| +| `VERIFIED` | Every declared effect and collateral-effect check passed at the required evidence tier. This is the only production success. | +| `HALTED_BEFORE_EFFECT` | The run stopped and positive evidence established that no consequential effect occurred. | +| `RECONCILIATION_REQUIRED` | Delivery or persistence is uncertain, conflicting, or temporarily unverifiable. The runtime never blind-retries it. | +| `FAILED_PLATFORM` | An OpenAdapt platform failure occurred before any possible business effect. | +| `CANCELED` | The run was canceled before any business effect. | +| `REJECTED_POLICY` | Authorization, identity, qualification, or environment policy refused execution before any effect. | +| `COMPLETED_UNVERIFIED` | A Demo run completed without production-grade effect evidence. | +| `ROLLED_BACK` | A detected duplicate or collateral write was compensated and re-verified. | + +These terminal outcomes are not interchangeable. A resumed +`RECONCILIATION_REQUIRED` run must first reacquire and reconcile the live state. + ### A focused question when a person is needed A halted run can send one signed task to the OpenAdapt phone view. The task can @@ -175,14 +207,15 @@ Every workflow is qualified against its exact application, version, environment, identity contract, and effect verifier rather than inheriting a blanket platform claim. -Substrate maturity, stated the same way across the OpenAdapt repositories: +Each qualification binds the exact surface, application, version, environment, +identity contract, and effect verifier: -| Substrate | Maturity | +| Substrate | Evidence available to a qualified workflow | | --- | --- | -| 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 | +| Browser (web) | DOM, accessibility, visual, OCR, field geometry, and source-time secret exclusion | +| Native desktop (Windows, macOS, Linux) | Visual, OCR, and local window scope, plus adapter-supplied UI Automation, Accessibility, or AT-SPI evidence when present | +| Remote display (RDP) | External pixels, OCR, anchors, keyboard, mouse, and fresh-frame verification | +| Citrix / VDI | External pixels, OCR, anchors, keyboard, mouse, and deployment-bound verification | See the [substrate model](https://docs.openadapt.ai/concepts/substrate-model/), [qualification evidence](https://docs.openadapt.ai/get-started/what-works-today/), diff --git a/docs/architecture.md b/docs/architecture.md index 55c40e541..28330b7c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,13 +73,19 @@ not make a recording safe to upload. A derivative crosses a boundary only after local sanitization, complete inventory, review, exact-hash approval, and destination policy checks. -## Maturity boundary - -The launcher and Flow engine are Beta. Browser workflows run through the -complete clean-machine lifecycle on Linux, macOS, and Windows. Native and -remote evidence is bounded to named tasks and environments. Citrix is -code-qualified and requires a live deployment qualification. No repository -status certifies an arbitrary customer workflow. +## Production qualification + +Production applies to exact admitted product and deployment releases and an +exact qualified workflow, not to a substrate name. The workflow admission binds +the organization and workflow identity; bundle version and digest; admitted +runtime release; application and environment; input, action, identity, effect, +and policy contracts; evidence authority; and its issue, expiry, and revocation +state. Qualification requires at least three trials for each task and condition. +A closed result schema must report silent incorrect success and over-halt. A +Production run gate must reject an absent, expired, revoked, or mismatched +product or workflow admission. The +[live signed Production record](https://docs.openadapt.ai/production-lifecycle.json) +is the maturity authority for public releases. The former model-training architecture remains in Git history and optional research packages. It is not the current product architecture. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index aaa0cc06b..d94d3a19e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -76,10 +76,12 @@ record time. Read the [canonical recording guide](https://docs.openadapt.ai/guides/record-your-app/) before a real-data demonstration. -## Product boundary - -The launcher and Flow engine are Beta. Browser workflows run end to end in the -three-OS clean-machine lifecycle. Native and remote evidence is task- and -environment-specific. Citrix support is code-qualified and still requires a -real deployment qualification. A runnable workflow is not automatically a -certified production workflow. +## Production qualification + +A runnable workflow is not automatically a qualified production workflow. A +qualification binds the exact application, version, environment, input schema, +identity checks, effect checks, policy, and verification rules. A Production +run gate must reject an absent, expired, revoked, or mismatched qualification. +Check the +[live signed Production record](https://docs.openadapt.ai/production-lifecycle.json) +for the current admitted releases. diff --git a/docs/packages/capture.md b/docs/packages/capture.md index a30a962c4..accffeafd 100644 --- a/docs/packages/capture.md +++ b/docs/packages/capture.md @@ -1,10 +1,11 @@ # openadapt-capture -**Lifecycle: Experimental**, as recorded in the canonical -[organization lifecycle registry](https://github.com/OpenAdaptAI/.github/blob/main/REPOSITORY_LIFECYCLE.md). `openadapt-capture` is the component OpenAdapt uses for native screen, mouse, keyboard, timing, window-scope, and media capture. +An exact Capture release can receive a Production admission only through the +[live signed Production record](https://docs.openadapt.ai/production-lifecycle.json). + Repository: [`OpenAdaptAI/openadapt-capture`](https://github.com/OpenAdaptAI/openadapt-capture) diff --git a/docs/packages/index.md b/docs/packages/index.md index 3caf008f2..1ed5170d0 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -5,11 +5,11 @@ cockpit, privacy tools, and optional managed control plane. | Package | Current role | Install route | | --- | --- | --- | -| `openadapt` | Beta launcher and unified CLI | `pip install openadapt` | -| `openadapt-flow` | Beta compiler and governed runtime; installed by the launcher | `pip install openadapt-flow` for engine-only use | -| `openadapt-capture` | Experimental native capture component | `pip install 'openadapt[capture]'` | +| `openadapt` | Launcher and unified CLI | `pip install openadapt` | +| `openadapt-flow` | Compiler and governed runtime; installed by the launcher | `pip install openadapt-flow` for engine-only use | +| `openadapt-capture` | Native capture component | `pip install 'openadapt[capture]'` | | `openadapt-privacy` | Local privacy and sanitization support | `pip install 'openadapt[privacy]'` | -| `openadapt-desktop` | Beta visual authoring and operator application | [Download an installer](https://openadapt.ai/download) | +| `openadapt-desktop` | Visual authoring and operator application | [Download an installer](https://openadapt.ai/download) | Install the browser tutorial path: diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md index c0023523b..5a0addbef 100644 --- a/docs/platform-compatibility-report.md +++ b/docs/platform-compatibility-report.md @@ -3,7 +3,6 @@ - Generated: `2026-08-20T15:18:32+00:00` -- Release channel: **beta** - Selection: `latest-published` - Compatibility: **dependency-compatible** - Signature: `unsigned (signing infrastructure pending)` @@ -67,15 +66,17 @@ These edges show whether each selected integration version satisfies its publish ## Substrate delivery -| Substrate | Public label | Delivery | -|---|---|---| -| Browser | Available | Local, managed OpenAdapt Cloud, or customer-controlled | -| Windows | Available | Local or customer-controlled | -| macOS | Available | Local or customer-controlled | -| Linux | Available | Local or customer-controlled | -| RDP | Available | Local or customer-controlled | -| Citrix / VDI | Available | Local or customer-controlled | -| Hosted Cloud | Beta | Managed browser runner and control plane | +Compatibility and delivery facts do not assign lifecycle state. Use the signed Production record for release maturity. + +| Substrate | Delivery | +|---|---| +| Browser | Local, managed OpenAdapt Cloud, or customer-controlled | +| Windows | Local or customer-controlled | +| macOS | Local or customer-controlled | +| Linux | Local or customer-controlled | +| RDP | Local or customer-controlled | +| Citrix / VDI | Local or customer-controlled | +| Hosted Cloud | Managed browser runner and control plane | ## Qualification evidence diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md index aa87db6c7..83719bcab 100644 --- a/docs/platform-manifest.md +++ b/docs/platform-manifest.md @@ -212,7 +212,7 @@ Top-level fields: | `schema_version` | Semver of this schema; validators reject unknown majors. | | `generated_at` | UTC timestamp of generation. | | `generation` | SHA-256 bindings for the generator and report renderer. | -| `release_channel` | Lowercased product lifecycle from status.json (currently `beta`). | +| `release_channel` | Legacy artifact-channel compatibility field copied from status.json. It is not a lifecycle authority. Consumers use the signed Production record for release maturity. | | `release_selection` | Latest-published selection or exact published version inputs supplied for an ordered release train. | | `components` | `launcher`, `flow`, `capture`, `privacy`, `types`, `desktop`, and `agent`: package name, published version, Python range, dependency constraints and markers, release source commit/tree, and the exact artifact set. | | `runtime_units` | Launcher, runner, Desktop, and Agent deployment views. Non-locked units name selected packages. The Desktop sidecar names the exact lock-resolved closure. | diff --git a/openadapt/__init__.py b/openadapt/__init__.py index e755485d8..97699ff0b 100644 --- a/openadapt/__init__.py +++ b/openadapt/__init__.py @@ -6,8 +6,8 @@ research packages: pip install openadapt # launcher + openadapt-flow - pip install openadapt[capture] # experimental native capture - pip install openadapt[privacy] # experimental privacy scrubbing + pip install openadapt[capture] # native capture + pip install openadapt[privacy] # privacy scrubbing pip install openadapt[ml,evals] # research toolkits """ diff --git a/openadapt/cli.py b/openadapt/cli.py index 1e5761a9c..e64a315d2 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -52,7 +52,7 @@ def list_commands(self, ctx): @click.group(cls=_FlowFirstGroup) @click.version_option(version=__version__, prog_name="openadapt") def main(): - """OpenAdapt - Beta launcher for the openadapt-flow compiler. + """OpenAdapt launcher for the openadapt-flow compiler. Compile a demonstrated workflow into deterministic local replay. Healthy runs make no model calls; configured checks can halt on ambiguity. Native diff --git a/platform-manifest.json b/platform-manifest.json index c5a093167..f3f10cf51 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -10,7 +10,7 @@ }, { "path": "scripts/render_platform_versions.py", - "sha256": "d63fa4d56e219551be2d416f5c4fea62c49c6c515fcf26183475ec4a1587808e" + "sha256": "ae3e52b501e6f5fcf497e1aa3973478a333b3fefe6dd8b9b82514e8f570bdd1f" } ] }, diff --git a/production-lifecycle-source.json b/production-lifecycle-source.json new file mode 100644 index 000000000..e8fb4b71a --- /dev/null +++ b/production-lifecycle-source.json @@ -0,0 +1,17 @@ +{ + "schema_version": "openadapt.production-readme-source/v1", + "repository": "OpenAdaptAI/openadapt-ops", + "source_commit": "ef29c08ad27b05637abc75fc3ceb3e026821fdda", + "files": { + "projection": { + "path": "docs/production-lifecycle.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/ef29c08ad27b05637abc75fc3ceb3e026821fdda/docs/production-lifecycle.json", + "sha256": "sha256:6c3819c7c68acb1bea8045fa1f059721e535c960707998b8705438f8541fb3d7" + }, + "schema": { + "path": "docs/schemas/production-lifecycle-public.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/ef29c08ad27b05637abc75fc3ceb3e026821fdda/docs/schemas/production-lifecycle-public.schema.json", + "sha256": "sha256:f681a9bb47ff727b409df994e69e62e7c45b38e912911af4c99eca7dd3be40eb" + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 69743361e..380cf239b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ keywords = [ "citrix", ] classifiers = [ - "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/scripts/render_platform_versions.py b/scripts/render_platform_versions.py index a18be3a46..978a728d1 100644 --- a/scripts/render_platform_versions.py +++ b/scripts/render_platform_versions.py @@ -71,7 +71,6 @@ def render_markdown(manifest: dict) -> str: "", "", f"- Generated: `{manifest.get('generated_at')}`", - f"- Release channel: **{manifest.get('release_channel')}**", f"- Selection: `{selection.get('mode')}`", f"- Compatibility: **{status.get('status')}**", f"- Signature: `{manifest.get('signature', {}).get('status')}`", @@ -175,15 +174,15 @@ def render_markdown(manifest: dict) -> str: "", "## Substrate delivery", "", - "| Substrate | Public label | Delivery |", - "|---|---|---|", + "Compatibility and delivery facts do not assign lifecycle state. " + "Use the signed Production record for release maturity.", + "", + "| Substrate | Delivery |", + "|---|---|", ] ) for substrate in manifest.get("substrate_drivers", []): - lines.append( - f"| {substrate.get('name')} | {substrate.get('public_label')} | " - f"{substrate.get('delivery')} |" - ) + lines.append(f"| {substrate.get('name')} | {substrate.get('delivery')} |") lines.extend(["", "## Qualification evidence", ""]) for evidence in manifest.get("qualification_evidence", []): @@ -220,7 +219,6 @@ def main() -> int: if args.format == "json": rendered = json.dumps( { - "release_channel": manifest.get("release_channel"), "compatibility_status": manifest.get("compatibility_status", {}).get( "status" ), @@ -234,7 +232,6 @@ def main() -> int: rendered = render_markdown(manifest) else: text_lines = [ - f"Release channel: {manifest.get('release_channel')}", f"Compatibility: {manifest.get('compatibility_status', {}).get('status')}", ] for row in rows: diff --git a/scripts/render_readme_maturity.py b/scripts/render_readme_maturity.py new file mode 100644 index 000000000..03016142e --- /dev/null +++ b/scripts/render_readme_maturity.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +"""Render the README maturity block from the verified public lifecycle record. + +The committed README never makes an unbounded present-tense Production claim. +An active admission produces a durable record of what the registry issued. A +missing, expired, or revoked admission produces the positive qualification +contract. Current state always comes from the live machine record. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import sys +import tempfile +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[1] +README_PATH = ROOT / "README.md" +SOURCE_PATH = ROOT / "production-lifecycle-source.json" +LIVE_RECORD_URL = "https://docs.openadapt.ai/production-lifecycle.json" +SOURCE_SCHEMA = "openadapt.production-readme-source/v1" +PROJECTION_SCHEMA = "openadapt.public-production-lifecycle/v1" +PROJECTION_JSON_SCHEMA = "schemas/production-lifecycle-public.schema.json" +BEGIN = "" +END = "" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +MAX_SOURCE_BYTES = 4 * 1024 * 1024 + +EXPECTED_SOURCE_FILES = { + "projection": "docs/production-lifecycle.json", + "schema": "docs/schemas/production-lifecycle-public.schema.json", +} +EXPECTED_CANONICAL_FILES = { + "admissions": "production-lifecycle-admissions.json", + "admissions_schema": "schemas/production-lifecycle-admissions.schema.json", + "evidence_manifest_schema": ( + "schemas/production-lifecycle-evidence-manifest.schema.json" + ), + "evidence_summary_schema": ( + "schemas/production-lifecycle-evidence-summary.schema.json" + ), + "lifecycle": "repository-lifecycle.yml", + "policy": "production-lifecycle-policy.json", + "policy_schema": "schemas/production-lifecycle-policy.schema.json", + "validator": "scripts/validate_production_lifecycle.py", +} +EXPECTED_TARGETS = { + "agent", + "capture", + "cloud", + "desktop", + "docs", + "flow", + "openadapt", +} +TARGET_KEYS = { + "id", + "display_name", + "lifecycle_scope", + "lifecycle_subject", + "source_repository", + "release_kind", + "required_claim_scope", + "required_artifact_kinds", + "package_index_project", + "artifact_authority_by_kind", + "latest_admission", + "admission_history", +} +ADMISSION_KEYS = { + "admission_id", + "target", + "claim_scope", + "release_identity", + "policy_revision", + "release", + "acceptance_evidence", + "issued_at", + "expires_at", + "revoked_at", +} + + +class MaturityError(ValueError): + """The README maturity source or derived state is not trustworthy.""" + + +def _closed(value: object, keys: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise MaturityError( + f"{label} must contain exactly {sorted(keys)}; got {actual}" + ) + return value + + +def _digest_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _load_json_bytes(value: bytes, label: str) -> dict[str, Any]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise MaturityError(f"{label} is not valid JSON") from exc + if not isinstance(parsed, dict): + raise MaturityError(f"{label} must be a JSON object") + return parsed + + +def _timestamp(value: object, label: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise MaturityError(f"{label} must be a UTC timestamp") + try: + parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as exc: + raise MaturityError(f"{label} is not a valid UTC timestamp") from exc + if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0: + raise MaturityError(f"{label} must use UTC") + return parsed.astimezone(timezone.utc) + + +def _fetch(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-readme-maturity-renderer/1"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + length = response.headers.get("Content-Length") + if length is not None and int(length) > MAX_SOURCE_BYTES: + raise MaturityError("Production lifecycle input exceeds the size limit") + body = response.read(MAX_SOURCE_BYTES + 1) + if len(body) > MAX_SOURCE_BYTES: + raise MaturityError("Production lifecycle input exceeds the size limit") + return body + + +def load_source(path: Path = SOURCE_PATH) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MaturityError( + f"Production lifecycle source is missing or invalid: {exc}" + ) from exc + source = _closed( + value, + {"schema_version", "repository", "source_commit", "files"}, + "Production lifecycle source", + ) + if source["schema_version"] != SOURCE_SCHEMA: + raise MaturityError("Production lifecycle source schema is not supported") + if source["repository"] != "OpenAdaptAI/openadapt-ops": + raise MaturityError("Production lifecycle source repository is not canonical") + commit = source["source_commit"] + if not isinstance(commit, str) or HEX40.fullmatch(commit) is None: + raise MaturityError("Production lifecycle source commit is not exact") + files = source["files"] + if not isinstance(files, dict) or set(files) != set(EXPECTED_SOURCE_FILES): + raise MaturityError("Production lifecycle source file inventory is not exact") + for key, expected_path in EXPECTED_SOURCE_FILES.items(): + item = _closed(files[key], {"path", "url", "sha256"}, f"source file {key}") + if item["path"] != expected_path: + raise MaturityError(f"source file {key} path is not canonical") + if ( + not isinstance(item["sha256"], str) + or SHA256.fullmatch(item["sha256"]) is None + ): + raise MaturityError(f"source file {key} digest is invalid") + parsed = urlsplit(item["url"]) + expected_url_path = f"/OpenAdaptAI/openadapt-ops/{commit}/{expected_path}" + if ( + parsed.scheme != "https" + or parsed.netloc != "raw.githubusercontent.com" + or parsed.path != expected_url_path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + raise MaturityError( + f"source file {key} URL is not bound to the exact commit" + ) + return source + + +def fetch_source_files( + source: Mapping[str, Any], + *, + fetch: Callable[[str], bytes] = _fetch, +) -> dict[str, bytes]: + values: dict[str, bytes] = {} + for key in sorted(EXPECTED_SOURCE_FILES): + item = source["files"][key] + try: + body = fetch(item["url"]) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise MaturityError( + f"source file {key} could not be fetched: {exc}" + ) from exc + if _digest_bytes(body) != item["sha256"]: + raise MaturityError(f"source file {key} digest changed") + values[key] = body + return values + + +def require_current_source( + source: Mapping[str, Any], + *, + fetch: Callable[[str], bytes] = _fetch, +) -> None: + """Reject a safe but stale pin when either relevant file changed on main.""" + + for key, path in EXPECTED_SOURCE_FILES.items(): + url = f"https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/main/{path}" + try: + current = fetch(url) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise MaturityError( + f"current source file {key} could not be fetched: {exc}" + ) from exc + if _digest_bytes(current) != source["files"][key]["sha256"]: + raise MaturityError( + f"source file {key} changed on openadapt-ops main; repin and rerender" + ) + + +def _validate_canonical_source(source: object) -> dict[str, Any]: + canonical = _closed( + source, + {"schema_version", "repository", "source_commit", "files"}, + "canonical lifecycle source", + ) + if canonical["schema_version"] != "openadapt.production-lifecycle-source/v1": + raise MaturityError("canonical lifecycle source schema is not supported") + if canonical["repository"] != "OpenAdaptAI/.github": + raise MaturityError("canonical lifecycle repository differs") + commit = canonical["source_commit"] + if not isinstance(commit, str) or HEX40.fullmatch(commit) is None: + raise MaturityError("canonical lifecycle source commit is not exact") + files = canonical["files"] + if not isinstance(files, dict) or set(files) != set(EXPECTED_CANONICAL_FILES): + raise MaturityError("canonical lifecycle file inventory is not exact") + for key, expected_path in EXPECTED_CANONICAL_FILES.items(): + item = _closed(files[key], {"path", "url", "sha256"}, f"canonical file {key}") + if item["path"] != expected_path: + raise MaturityError(f"canonical file {key} path differs") + if ( + not isinstance(item["sha256"], str) + or SHA256.fullmatch(item["sha256"]) is None + ): + raise MaturityError(f"canonical file {key} digest is invalid") + parsed = urlsplit(item["url"]) + expected_url_path = f"/OpenAdaptAI/.github/{commit}/{expected_path}" + if ( + parsed.scheme != "https" + or parsed.netloc != "raw.githubusercontent.com" + or parsed.path != expected_url_path + or parsed.query + or parsed.fragment + ): + raise MaturityError(f"canonical file {key} URL is not exact") + return canonical + + +def fetch_canonical_files( + canonical: Mapping[str, Any], + *, + fetch: Callable[[str], bytes] = _fetch, +) -> dict[str, bytes]: + values: dict[str, bytes] = {} + for key in sorted(EXPECTED_CANONICAL_FILES): + item = canonical["files"][key] + try: + body = fetch(item["url"]) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise MaturityError( + f"canonical file {key} could not be fetched: {exc}" + ) from exc + if _digest_bytes(body) != item["sha256"]: + raise MaturityError(f"canonical file {key} digest changed") + values[key] = body + return values + + +def _run_canonical_validator(inputs: Mapping[str, bytes]) -> dict[str, str]: + with tempfile.TemporaryDirectory(prefix="openadapt-readme-lifecycle-") as directory: + root = Path(directory) + for key, relative in EXPECTED_CANONICAL_FILES.items(): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(inputs[key]) + validator_path = root / EXPECTED_CANONICAL_FILES["validator"] + spec = importlib.util.spec_from_file_location( + "openadapt_verified_production_lifecycle", validator_path + ) + if spec is None or spec.loader is None: + raise MaturityError("canonical lifecycle validator cannot be loaded") + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + active = module.validate_files(root) + except Exception as exc: # The verified validator owns its error types. + raise MaturityError( + f"canonical lifecycle validator refused: {exc}" + ) from exc + if not isinstance(active, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in active.items() + ): + raise MaturityError("canonical lifecycle validator returned invalid state") + return active + + +def validate_projection( + value: object, + canonical_inputs: Mapping[str, bytes], +) -> dict[str, Any]: + projection = _closed( + value, + { + "$schema", + "schema_version", + "source", + "policy_revision", + "maximum_admission_days", + "derivation", + "targets", + }, + "public Production lifecycle projection", + ) + if projection["$schema"] != PROJECTION_JSON_SCHEMA: + raise MaturityError("public projection JSON schema binding differs") + if projection["schema_version"] != PROJECTION_SCHEMA: + raise MaturityError("public projection schema is not supported") + if projection["derivation"] != { + "mode": "latest_signed_admission_at_read_time", + "static_production_state": False, + "expired_or_revoked_latest_behavior": "no_production", + "fallback_to_older_release": False, + }: + raise MaturityError("public projection derivation contract differs") + canonical = _validate_canonical_source(projection["source"]) + for key in EXPECTED_CANONICAL_FILES: + if _digest_bytes(canonical_inputs[key]) != canonical["files"][key]["sha256"]: + raise MaturityError(f"public projection canonical binding {key} differs") + + policy = _load_json_bytes(canonical_inputs["policy"], "canonical policy") + admissions = _load_json_bytes( + canonical_inputs["admissions"], "canonical admissions" + ) + policy_targets = policy.get("targets") + admission_records = admissions.get("admissions") + if not isinstance(policy_targets, list) or not isinstance(admission_records, list): + raise MaturityError("canonical policy or admission inventory is invalid") + projected_targets = projection["targets"] + if not isinstance(projected_targets, list): + raise MaturityError("public projection targets must be a list") + by_policy = { + target.get("id"): target + for target in policy_targets + if isinstance(target, dict) + } + if set(by_policy) != EXPECTED_TARGETS or len(by_policy) != len(policy_targets): + raise MaturityError("canonical policy target inventory differs") + by_projection: dict[str, dict[str, Any]] = {} + for target in projected_targets: + item = _closed(target, TARGET_KEYS, "public projection target") + target_id = item["id"] + if target_id in by_projection or target_id not in EXPECTED_TARGETS: + raise MaturityError("public projection target inventory differs") + by_projection[target_id] = item + if set(by_projection) != EXPECTED_TARGETS: + raise MaturityError("public projection target inventory is incomplete") + + for target_id, projected in by_projection.items(): + expected_target = by_policy[target_id] + for key in TARGET_KEYS - {"latest_admission", "admission_history"}: + if projected[key] != expected_target[key]: + raise MaturityError( + f"public projection target {target_id} {key} differs" + ) + history = [ + record + for record in admission_records + if isinstance(record, dict) and record.get("target") == target_id + ] + history.sort(key=lambda item: item["release_identity"]["sequence"]) + if projected["admission_history"] != history: + raise MaturityError(f"public projection target {target_id} history differs") + latest = history[-1] if history else None + if projected["latest_admission"] != latest: + raise MaturityError( + f"public projection target {target_id} latest record differs" + ) + return projection + + +def active_admission( + projection: Mapping[str, Any], + target_id: str, + *, + now: datetime | None = None, +) -> dict[str, Any] | None: + """Return only the latest active target admission. Never fall back.""" + + now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + target = next( + (item for item in projection["targets"] if item["id"] == target_id), None + ) + if target is None: + raise MaturityError(f"public projection has no target {target_id!r}") + latest = target["latest_admission"] + if latest is None: + return None + admission = _closed(latest, ADMISSION_KEYS, f"latest {target_id} admission") + if admission["target"] != target_id: + raise MaturityError(f"latest {target_id} admission target differs") + issued_at = _timestamp(admission["issued_at"], f"{target_id} issued_at") + expires_at = _timestamp(admission["expires_at"], f"{target_id} expires_at") + if admission["revoked_at"] is not None: + _timestamp(admission["revoked_at"], f"{target_id} revoked_at") + return None + if not issued_at <= now < expires_at: + return None + return admission + + +def _qualification_block() -> str: + return "\n".join( + [ + BEGIN, + "> **Built for qualified production workflows.** A Production run requires both", + "> active signed product admissions for the exact OpenAdapt component and", + "> deployment releases, and an active signed, expiring, revocable workflow", + "> admission for the exact compiled workflow version. The workflow admission binds", + "> the organization and workflow identity; bundle version and digest; admitted", + "> runtime release; application and environment; input, action, identity, effect,", + "> and policy contracts; evidence authority; and its issue, expiry, and revocation", + "> state. Qualification requires at least three trials for each task and condition.", + "> A closed result schema must report silent incorrect success and over-halt. Any", + "> bound change requires a new qualification.", + f"> [Check the live signed Production record]({LIVE_RECORD_URL}).", + END, + ] + ) + + +def render_block( + projection: Mapping[str, Any], + active: Mapping[str, str], + *, + now: datetime | None = None, +) -> str: + admission = active_admission(projection, "openadapt", now=now) + active_id = active.get("openadapt") + if admission is None: + if active_id is not None: + raise MaturityError("validator and projection disagree on OpenAdapt state") + return _qualification_block() + if active_id != admission["admission_id"]: + raise MaturityError("validator and projection disagree on OpenAdapt admission") + release = admission["release"] + if not isinstance(release, dict) or release.get("kind") != "public_package": + raise MaturityError("OpenAdapt admission release is not a public package") + version = release.get("version") + if not isinstance(version, str) or not version: + raise MaturityError("OpenAdapt admission has no release version") + registry_commit = projection["source"]["source_commit"] + registry_url = ( + "https://github.com/OpenAdaptAI/.github/blob/" + f"{registry_commit}/production-lifecycle-admissions.json" + ) + evidence_url = admission["acceptance_evidence"]["summary_url"] + return "\n".join( + [ + BEGIN, + "> **Production admission record.** The verified registry issued admission", + f"> `{admission['admission_id']}` for OpenAdapt `{version}` at", + f"> `{admission['issued_at']}`, with declared expiry `{admission['expires_at']}`", + "> and no revocation in this exact registry snapshot. This is an immutable", + "> historical record, not a present-tense maturity assertion. Verify current", + f"> state in the [live record]({LIVE_RECORD_URL}).", + f"> [Registry snapshot]({registry_url}) ยท [Acceptance evidence]({evidence_url})", + END, + ] + ) + + +def replace_block(readme: str, block: str) -> str: + if readme.count(BEGIN) != 1 or readme.count(END) != 1: + raise MaturityError( + "README must contain one complete Production lifecycle block" + ) + before, remainder = readme.split(BEGIN, 1) + _old, after = remainder.split(END, 1) + return before + block + after + + +def validated_block( + source: Mapping[str, Any], + *, + fetch: Callable[[str], bytes] = _fetch, + now: datetime | None = None, + check_current: bool = False, +) -> str: + if check_current: + require_current_source(source, fetch=fetch) + source_files = fetch_source_files(source, fetch=fetch) + schema = _load_json_bytes(source_files["schema"], "public projection schema") + if schema.get("$id") != ( + "https://docs.openadapt.ai/schemas/production-lifecycle-public.schema.json" + ): + raise MaturityError("public projection schema identity differs") + projection_value = _load_json_bytes(source_files["projection"], "public projection") + canonical = _validate_canonical_source(projection_value.get("source")) + canonical_files = fetch_canonical_files(canonical, fetch=fetch) + projection = validate_projection(projection_value, canonical_files) + active = _run_canonical_validator(canonical_files) + return render_block(projection, active, now=now) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Refuse README drift") + parser.add_argument( + "--require-current-source", + action="store_true", + help="Refuse when the relevant openadapt-ops main bytes changed", + ) + args = parser.parse_args() + try: + source = load_source() + block = validated_block( + source, + check_current=args.require_current_source, + ) + current = README_PATH.read_text(encoding="utf-8") + rendered = replace_block(current, block) + if args.check: + if rendered != current: + raise MaturityError("README Production lifecycle block has drifted") + else: + temporary = README_PATH.with_suffix(".md.tmp") + temporary.write_text(rendered, encoding="utf-8") + temporary.replace(README_PATH) + except (OSError, MaturityError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + print("Validated the README against the public Production lifecycle record.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_release_artifacts.py b/scripts/verify_release_artifacts.py index b55b8eb6e..476942bf5 100644 --- a/scripts/verify_release_artifacts.py +++ b/scripts/verify_release_artifacts.py @@ -13,7 +13,6 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -BETA_CLASSIFIER = "Development Status :: 4 - Beta" def _quoted_table_value(text: str, table: str, key: str) -> str: @@ -133,8 +132,11 @@ def verify_release_artifacts( raise ValueError( f"{source} Requires-Python does not match {project_requires_python}" ) - if _lifecycle(metadata) != [BETA_CLASSIFIER]: - raise ValueError(f"{source} does not declare the Beta launcher lifecycle") + if _lifecycle(metadata): + raise ValueError( + f"{source} publishes a static lifecycle classifier; " + "use the signed public Production record" + ) comparable_fields = ("Name", "Version", "Summary", "Requires-Python") if any( diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index aec814efa..c67bd2ea4 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -201,8 +201,8 @@ def test_version_flag_matches_installed_metadata(): assert __version__ == expected -def test_distribution_metadata_advertises_beta_lifecycle(): - """Published launcher metadata must match the documented Beta lifecycle.""" +def test_distribution_metadata_does_not_publish_a_static_lifecycle(): + """The signed public record, not immutable package metadata, owns maturity.""" from importlib.metadata import metadata classifiers = metadata("openadapt").get_all("Classifier") or [] @@ -211,7 +211,7 @@ def test_distribution_metadata_advertises_beta_lifecycle(): for classifier in classifiers if classifier.startswith("Development Status :: ") ] - assert lifecycle == ["Development Status :: 4 - Beta"] + assert lifecycle == [] def test_distribution_metadata_matches_engine_python_range(): @@ -454,7 +454,6 @@ def test_top_level_help_leads_with_flow(): result = runner.invoke(cli_main, ["--help"]) assert result.exit_code == 0 # Quick Start headline and Commands listing both lead with flow. - assert "Beta launcher" in result.output assert "openadapt flow demo-record" in result.output assert "openadapt quickstart" in result.output assert "effect-verified first run" in result.output diff --git a/tests/test_readme_maturity.py b/tests/test_readme_maturity.py new file mode 100644 index 000000000..dcdaefba3 --- /dev/null +++ b/tests/test_readme_maturity.py @@ -0,0 +1,255 @@ +"""Contract tests for the README Production lifecycle consumer.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "render_readme_maturity", ROOT / "scripts" / "render_readme_maturity.py" +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _source() -> dict: + commit = "a" * 40 + return { + "schema_version": MODULE.SOURCE_SCHEMA, + "repository": "OpenAdaptAI/openadapt-ops", + "source_commit": commit, + "files": { + key: { + "path": path, + "url": ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/" + f"{commit}/{path}" + ), + "sha256": "sha256:" + str(index) * 64, + } + for index, (key, path) in enumerate(MODULE.EXPECTED_SOURCE_FILES.items(), 1) + }, + } + + +def _admission( + *, + target: str, + sequence: int, + issued_at: str = "2026-08-20T00:00:00Z", + expires_at: str = "2026-08-30T00:00:00Z", + revoked_at: str | None = None, +) -> dict: + return { + "admission_id": f"production:{target}:{sequence}", + "target": target, + "claim_scope": ( + "qualified_workflow_launcher_release" + if target == "openadapt" + else "qualified_workflow_runtime_release" + ), + "release_identity": { + "schema_version": "openadapt.monotonic-production-release/v1", + "channel": "production", + "sequence": sequence, + "previous_admission_sha256": None, + }, + "policy_revision": 1, + "release": { + "kind": "public_package", + "version": "2.0.0", + "tag": "v2.0.0", + "source_commit": "b" * 40, + "immutable_release_url": "https://github.com/OpenAdaptAI/OpenAdapt/releases/tag/v2.0.0", + "artifacts": [], + }, + "acceptance_evidence": { + "summary_url": "https://github.com/OpenAdaptAI/openadapt-evals/raw/" + + "c" * 40 + + "/summary.json", + "summary_sha256": "sha256:" + "1" * 64, + "attestation_bundle_url": "https://github.com/OpenAdaptAI/openadapt-evals/raw/" + + "c" * 40 + + "/summary.sigstore.json", + "attestation_bundle_sha256": "sha256:" + "2" * 64, + "authority_source_commit": "c" * 40, + }, + "issued_at": issued_at, + "expires_at": expires_at, + "revoked_at": revoked_at, + } + + +def _projection(records: list[dict] | None = None) -> dict: + records = records or [] + targets = [] + for target_id in sorted(MODULE.EXPECTED_TARGETS): + history = sorted( + [item for item in records if item["target"] == target_id], + key=lambda item: item["release_identity"]["sequence"], + ) + targets.append( + { + "id": target_id, + "display_name": target_id.title(), + "lifecycle_scope": "repository", + "lifecycle_subject": target_id, + "source_repository": f"OpenAdaptAI/{target_id}", + "release_kind": "public_package", + "required_claim_scope": ( + "qualified_workflow_launcher_release" + if target_id == "openadapt" + else "qualified_workflow_runtime_release" + ), + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": target_id, + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + "latest_admission": history[-1] if history else None, + "admission_history": history, + } + ) + return { + "$schema": MODULE.PROJECTION_JSON_SCHEMA, + "schema_version": MODULE.PROJECTION_SCHEMA, + "source": { + "schema_version": "openadapt.production-lifecycle-source/v1", + "repository": "OpenAdaptAI/.github", + "source_commit": "d" * 40, + "files": { + key: { + "path": path, + "url": ( + "https://raw.githubusercontent.com/OpenAdaptAI/.github/" + f"{'d' * 40}/{path}" + ), + "sha256": "sha256:" + f"{index:x}" * 64, + } + for index, (key, path) in enumerate( + sorted(MODULE.EXPECTED_CANONICAL_FILES.items()), 1 + ) + }, + }, + "policy_revision": 1, + "maximum_admission_days": 30, + "derivation": { + "mode": "latest_signed_admission_at_read_time", + "static_production_state": False, + "expired_or_revoked_latest_behavior": "no_production", + "fallback_to_older_release": False, + }, + "targets": targets, + } + + +NOW = datetime(2026, 8, 20, 12, tzinfo=timezone.utc) + + +class ReadmeMaturityContractTests(unittest.TestCase): + def test_source_rejects_a_mutable_url(self) -> None: + source = _source() + source["files"]["projection"]["url"] = ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/main/" + "docs/production-lifecycle.json" + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "source.json" + path.write_text(json.dumps(source), encoding="utf-8") + with self.assertRaisesRegex(MODULE.MaturityError, "exact commit"): + MODULE.load_source(path) + + def test_source_digest_mismatch_fails_closed(self) -> None: + source = _source() + with self.assertRaisesRegex(MODULE.MaturityError, "digest changed"): + MODULE.fetch_source_files(source, fetch=lambda _url: b"changed") + + def test_empty_registry_uses_positive_qualification_contract(self) -> None: + projection = _projection() + block = MODULE.render_block(projection, {}, now=NOW) + + self.assertIn(MODULE.LIVE_RECORD_URL, block) + self.assertIn("active signed product admissions", block) + self.assertIn("active signed, expiring, revocable workflow", block) + self.assertIn("exact compiled workflow version", block) + self.assertIn("three trials for each task and condition", block) + self.assertIn("closed result schema", block) + self.assertIn("silent incorrect success and over-halt", block) + self.assertNotIn("Beta", block) + self.assertNotIn("not Production", block) + + def test_an_active_flow_admission_does_not_admit_the_launcher(self) -> None: + flow = _admission(target="flow", sequence=1) + projection = _projection([flow]) + block = MODULE.render_block( + projection, + {"flow": flow["admission_id"]}, + now=NOW, + ) + + self.assertNotIn(flow["admission_id"], block) + self.assertIn("active signed product admissions", block) + self.assertIn("active signed, expiring, revocable workflow", block) + + def test_active_launcher_renders_only_a_durable_registry_record(self) -> None: + launcher = _admission(target="openadapt", sequence=1) + projection = _projection([launcher]) + block = MODULE.render_block( + projection, + {"openadapt": launcher["admission_id"]}, + now=NOW, + ) + + self.assertIn(launcher["admission_id"], block) + self.assertIn(launcher["issued_at"], block) + self.assertIn(launcher["expires_at"], block) + self.assertIn("historical record", block) + self.assertNotIn("OpenAdapt is Production", block) + + def test_expired_latest_never_falls_back_to_an_older_record(self) -> None: + old = _admission( + target="openadapt", + sequence=1, + issued_at="2026-08-01T00:00:00Z", + expires_at="2026-08-29T00:00:00Z", + ) + latest = _admission( + target="openadapt", + sequence=2, + issued_at="2026-08-02T00:00:00Z", + expires_at="2026-08-20T12:00:00Z", + ) + projection = _projection([old, latest]) + + self.assertIsNone(MODULE.active_admission(projection, "openadapt", now=NOW)) + block = MODULE.render_block(projection, {}, now=NOW) + self.assertNotIn(old["admission_id"], block) + self.assertNotIn(latest["admission_id"], block) + + def test_revoked_latest_never_falls_back_to_an_older_record(self) -> None: + old = _admission(target="openadapt", sequence=1) + latest = _admission( + target="openadapt", + sequence=2, + revoked_at="2026-08-20T11:00:00Z", + ) + projection = _projection([old, latest]) + + self.assertIsNone(MODULE.active_admission(projection, "openadapt", now=NOW)) + block = MODULE.render_block(projection, {}, now=NOW) + self.assertNotIn(old["admission_id"], block) + + def test_replace_block_refuses_missing_or_duplicate_markers(self) -> None: + with self.assertRaisesRegex(MODULE.MaturityError, "one complete"): + MODULE.replace_block("no markers", MODULE._qualification_block()) + duplicate = f"{MODULE.BEGIN}\n{MODULE.END}\n{MODULE.BEGIN}\n{MODULE.END}" + with self.assertRaisesRegex(MODULE.MaturityError, "one complete"): + MODULE.replace_block(duplicate, MODULE._qualification_block()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 48e11f80a..e2c4b8033 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -92,14 +92,19 @@ def _flow_packages_for_extras( return packages -def _metadata(version: str, requires_python: str = ">=3.10,<3.13") -> bytes: +def _metadata( + version: str, + requires_python: str = ">=3.10,<3.13", + lifecycle_classifier: str | None = None, +) -> bytes: + classifier = f"Classifier: {lifecycle_classifier}\n" if lifecycle_classifier else "" return ( "Metadata-Version: 2.4\n" "Name: openadapt\n" f"Version: {version}\n" - "Summary: Beta launcher\n" + "Summary: OpenAdapt launcher\n" f"Requires-Python: {requires_python}\n" - "Classifier: Development Status :: 4 - Beta\n\n" + f"{classifier}\n" ).encode() @@ -107,6 +112,7 @@ def _release_tree( tmp_path: Path, artifact_version: str = "2.0.0", artifact_requires_python: str = ">=3.10,<3.13", + lifecycle_classifier: str | None = None, ) -> tuple[Path, Path]: (tmp_path / "pyproject.toml").write_text( "[project]\n" @@ -122,11 +128,19 @@ def _release_tree( with zipfile.ZipFile(wheel, mode="w") as archive: archive.writestr( "openadapt-2.0.0.dist-info/METADATA", - _metadata(artifact_version, artifact_requires_python), + _metadata( + artifact_version, + artifact_requires_python, + lifecycle_classifier, + ), ) sdist = dist / "openadapt-2.0.0.tar.gz" - raw = _metadata(artifact_version, artifact_requires_python) + raw = _metadata( + artifact_version, + artifact_requires_python, + lifecycle_classifier, + ) info = tarfile.TarInfo("openadapt-2.0.0/PKG-INFO") info.size = len(raw) with tarfile.open(sdist, mode="w:gz") as archive: @@ -172,6 +186,18 @@ def test_release_artifacts_reject_python_range_drift(tmp_path: Path): verify_release_artifacts(dist, root=tmp_path) +def test_release_artifacts_reject_static_lifecycle_metadata(tmp_path: Path): + """Immutable package bytes must not override the signed lifecycle record.""" + + dist, _ = _release_tree( + tmp_path, + lifecycle_classifier="Development Status :: 4 - Beta", + ) + + with pytest.raises(ValueError, match="static lifecycle classifier"): + verify_release_artifacts(dist, root=tmp_path) + + @pytest.mark.parametrize( ("sys_platform", "platform_extra", "excluded_extra"), [