diff --git a/.github/workflows/production-lifecycle-policy.yml b/.github/workflows/production-lifecycle-policy.yml new file mode 100644 index 0000000..c22e3a9 --- /dev/null +++ b/.github/workflows/production-lifecycle-policy.yml @@ -0,0 +1,75 @@ +name: Production lifecycle policy + +on: + schedule: + - cron: "37 8 * * *" + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/production-lifecycle-policy.yml" + - "docs/production-lifecycle.json" + - "docs/reference/production-lifecycle.md" + - "docs/schemas/production-lifecycle-public.schema.json" + - "mkdocs.yml" + - "production-lifecycle-source.json" + - "scripts/render_production_lifecycle.py" + - "tests/test_production_lifecycle_policy.py" + push: + branches: [main] + paths: + - ".github/workflows/production-lifecycle-policy.yml" + - "docs/production-lifecycle.json" + - "docs/reference/production-lifecycle.md" + - "docs/schemas/production-lifecycle-public.schema.json" + - "mkdocs.yml" + - "production-lifecycle-source.json" + - "scripts/render_production_lifecycle.py" + - "tests/test_production_lifecycle_policy.py" + +permissions: + contents: read + +concurrency: + group: production-lifecycle-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Check exact canonical Production lifecycle projection + run: python3 scripts/render_production_lifecycle.py --check + + - name: Test fail-closed projection behavior + run: python3 -m unittest tests.test_production_lifecycle_policy + + report-failure: + needs: validate + if: >- + ${{ always() && needs.validate.result == 'failure' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: File or update lifecycle projection failure + env: + GH_TOKEN: ${{ github.token }} + run: | + TITLE="Production lifecycle projection needs attention" + BODY="The exact-source Production lifecycle check failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + The public projection must continue to fail closed. Fix the source pin or evidence before any consumer derives Production." + EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body "$BODY" + else + gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body "$BODY" + fi diff --git a/docs/production-lifecycle.json b/docs/production-lifecycle.json new file mode 100644 index 0000000..ce4fa9e --- /dev/null +++ b/docs/production-lifecycle.json @@ -0,0 +1,201 @@ +{ + "$schema": "schemas/production-lifecycle-public.schema.json", + "derivation": { + "expired_or_revoked_latest_behavior": "no_production", + "fallback_to_older_release": false, + "mode": "latest_signed_admission_at_read_time", + "static_production_state": false + }, + "maximum_admission_days": 30, + "policy_revision": 1, + "schema_version": "openadapt.public-production-lifecycle/v1", + "source": { + "files": { + "admissions": { + "path": "production-lifecycle-admissions.json", + "sha256": "sha256:aa13f9abf9c70dacdb9b13fdf237c6b5f193011ce834ac7f616ff7441c3ecbd9", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/production-lifecycle-admissions.json" + }, + "admissions_schema": { + "path": "schemas/production-lifecycle-admissions.schema.json", + "sha256": "sha256:a263f929549a4098244965f1e4bee73707c99173829771af2d79bedc081bdd81", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-admissions.schema.json" + }, + "evidence_manifest_schema": { + "path": "schemas/production-lifecycle-evidence-manifest.schema.json", + "sha256": "sha256:4bb0aff068c38ea0a407e27fac68ca9f4a178983af4f560f9dac5ebf8c672376", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-evidence-manifest.schema.json" + }, + "evidence_summary_schema": { + "path": "schemas/production-lifecycle-evidence-summary.schema.json", + "sha256": "sha256:01dd60e8e68391434a8bfd81a5c610f29b9d4a7ec2a36761622da141cdba7d58", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-evidence-summary.schema.json" + }, + "lifecycle": { + "path": "repository-lifecycle.yml", + "sha256": "sha256:dc8af3941e3f865e2303ad075a0e7f4597d601c53f5795594cd4f9bcdc1f6420", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/repository-lifecycle.yml" + }, + "policy": { + "path": "production-lifecycle-policy.json", + "sha256": "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/production-lifecycle-policy.json" + }, + "policy_schema": { + "path": "schemas/production-lifecycle-policy.schema.json", + "sha256": "sha256:7ba6daadf2bd3b4382cebc958177e709272bb360fa0a0d231f648727ed34058c", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-policy.schema.json" + }, + "validator": { + "path": "scripts/validate_production_lifecycle.py", + "sha256": "sha256:7f665f8c385d496aed21a580278c2af75666ea57a749ed1bb404622b383e0b5a", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/scripts/validate_production_lifecycle.py" + } + }, + "repository": "OpenAdaptAI/.github", + "schema_version": "openadapt.production-lifecycle-source/v1", + "source_commit": "d60445bc8617adc4a27c20e6ce05681a95d83a2c" + }, + "targets": [ + { + "admission_history": [], + "artifact_authority_by_kind": { + "sdist": "pypi", + "wheel": "pypi" + }, + "display_name": "OpenAdapt Agent", + "id": "agent", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-agent", + "package_index_project": "openadapt-agent", + "release_kind": "public_package", + "required_artifact_kinds": [ + "sdist", + "wheel" + ], + "required_claim_scope": "qualified_agent_bridge_release", + "source_repository": "OpenAdaptAI/openadapt-agent" + }, + { + "admission_history": [], + "artifact_authority_by_kind": { + "sdist": "pypi", + "wheel": "pypi" + }, + "display_name": "OpenAdapt Capture", + "id": "capture", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-capture", + "package_index_project": "openadapt-capture", + "release_kind": "public_package", + "required_artifact_kinds": [ + "sdist", + "wheel" + ], + "required_claim_scope": "qualified_native_recorder_release", + "source_repository": "OpenAdaptAI/openadapt-capture" + }, + { + "admission_history": [], + "artifact_authority_by_kind": {}, + "display_name": "OpenAdapt Cloud", + "id": "cloud", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-cloud", + "package_index_project": null, + "release_kind": "private_deployment", + "required_artifact_kinds": [], + "required_claim_scope": "qualified_workflow_control_plane_deployment", + "source_repository": "OpenAdaptAI/openadapt-cloud" + }, + { + "admission_history": [], + "artifact_authority_by_kind": { + "linux-installer": "github_release", + "macos-installer": "github_release", + "sdist": "pypi", + "wheel": "pypi", + "windows-installer": "github_release" + }, + "display_name": "OpenAdapt Desktop", + "id": "desktop", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-desktop", + "package_index_project": "openadapt-desktop", + "release_kind": "public_package", + "required_artifact_kinds": [ + "linux-installer", + "macos-installer", + "sdist", + "wheel", + "windows-installer" + ], + "required_claim_scope": "qualified_native_workflow_desktop_release", + "source_repository": "OpenAdaptAI/openadapt-desktop" + }, + { + "admission_history": [], + "artifact_authority_by_kind": { + "deployment-manifest": "managed_evidence", + "site-archive": "managed_evidence" + }, + "display_name": "OpenAdapt Documentation", + "id": "docs", + "latest_admission": null, + "lifecycle_scope": "public_surface", + "lifecycle_subject": "docs.openadapt.ai", + "package_index_project": null, + "release_kind": "public_deployment", + "required_artifact_kinds": [ + "deployment-manifest", + "site-archive" + ], + "required_claim_scope": "production_documentation_deployment", + "source_repository": "OpenAdaptAI/openadapt-ops" + }, + { + "admission_history": [], + "artifact_authority_by_kind": { + "sdist": "pypi", + "wheel": "pypi" + }, + "display_name": "OpenAdapt Flow", + "id": "flow", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-flow", + "package_index_project": "openadapt-flow", + "release_kind": "public_package", + "required_artifact_kinds": [ + "sdist", + "wheel" + ], + "required_claim_scope": "qualified_workflow_runtime_release", + "source_repository": "OpenAdaptAI/openadapt-flow" + }, + { + "admission_history": [], + "artifact_authority_by_kind": { + "sdist": "pypi", + "wheel": "pypi" + }, + "display_name": "OpenAdapt", + "id": "openadapt", + "latest_admission": null, + "lifecycle_scope": "repository", + "lifecycle_subject": "OpenAdapt", + "package_index_project": "openadapt", + "release_kind": "public_package", + "required_artifact_kinds": [ + "sdist", + "wheel" + ], + "required_claim_scope": "qualified_workflow_launcher_release", + "source_repository": "OpenAdaptAI/OpenAdapt" + } + ] +} diff --git a/docs/reference/production-lifecycle.md b/docs/reference/production-lifecycle.md new file mode 100644 index 0000000..68d5dd1 --- /dev/null +++ b/docs/reference/production-lifecycle.md @@ -0,0 +1,57 @@ +# Production admission + +OpenAdapt uses evidence to derive Production for an exact release. Production +is not a static repository label. A release enters the signed Production +channel only when its target-specific acceptance record passes the public +policy. + +## Qualified workflow + +A qualified workflow is one exact compiled workflow version that passed its +declared qualification contract on its bound execution environment. Its signed +identity binds the workflow bundle, runtime release, dependency set, +environment, input schema, policy, identity checks, effect checks, and +verification rules. + +A Production runtime accepts only an exact qualified workflow identity. It +refuses an absent, expired, revoked, or mismatched qualification. A change to a +workflow version or any bound input requires a new qualification. + +## Release admission + +The organization policy defines seven Production targets: the launcher, Flow, +Desktop, Cloud, Capture, Agent, and this documentation deployment. Each target +has its own claim scope, release shape, artifact authorities, and evidence +adapter. Evidence for one target cannot admit another target. + +An admission binds: + +- the exact target and claim scope; +- the monotonic Production release identity and its predecessor; +- the release or deployment and complete artifact inventory; +- the canonical lifecycle policy and acceptance policy; +- an independently attested, remote-safe acceptance summary; +- the oracle, task count, condition count, and trial count; +- every failure-taxonomy and reliability count; and +- an immutable evidence-retention record. + +The admission validator checks current PyPI metadata, immutable GitHub release +metadata, or managed-evidence object metadata. It also verifies the GitHub +artifact attestation for the acceptance summary. Healthy-path model calls, +silent incorrect success, wrong-record effects, duplicate effects, collateral +effects, and uncertain delivery must all remain zero. + +## Current-state derivation + +Each target keeps an append-only hash chain of signed release identities. The +highest sequence is the current Production release. If its admission expires +or is revoked, that target has no current Production release. The validator +does not fall back to an older release. + +The machine-readable [Production lifecycle record](../production-lifecycle.json) +contains the exact source commit, input hashes, policy, and admission history. +It does not store a static `production: true` flag. A consumer must use the +pinned validator and derive the state at read time. + +Runnable does not mean admitted. An installation, release, or successful demo +cannot create Production state without this complete evidence contract. diff --git a/docs/schemas/production-lifecycle-public.schema.json b/docs/schemas/production-lifecycle-public.schema.json new file mode 100644 index 0000000..df6b7a9 --- /dev/null +++ b/docs/schemas/production-lifecycle-public.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docs.openadapt.ai/schemas/production-lifecycle-public.schema.json", + "title": "OpenAdapt public Production lifecycle projection", + "description": "Remote-safe projection of the evidence-gated Production lifecycle. Production is derived at read time from the latest signed admission; this file never stores a static Production flag.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schema_version", + "source", + "policy_revision", + "maximum_admission_days", + "derivation", + "targets" + ], + "properties": { + "$schema": {"const": "schemas/production-lifecycle-public.schema.json"}, + "schema_version": {"const": "openadapt.public-production-lifecycle/v1"}, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "repository", "source_commit", "files"], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-source/v1"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "files": { + "type": "object", + "additionalProperties": false, + "required": [ + "admissions", + "admissions_schema", + "evidence_manifest_schema", + "evidence_summary_schema", + "lifecycle", + "policy", + "policy_schema", + "validator" + ], + "patternProperties": { + "^[a-z_]+$": {"$ref": "#/$defs/sourceFile"} + } + } + } + }, + "policy_revision": {"type": "integer", "minimum": 1}, + "maximum_admission_days": {"type": "integer", "minimum": 1}, + "derivation": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "static_production_state", + "expired_or_revoked_latest_behavior", + "fallback_to_older_release" + ], + "properties": { + "mode": {"const": "latest_signed_admission_at_read_time"}, + "static_production_state": {"const": false}, + "expired_or_revoked_latest_behavior": {"const": "no_production"}, + "fallback_to_older_release": {"const": false} + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/target"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "sourceFile": { + "type": "object", + "additionalProperties": false, + "required": ["path", "url", "sha256"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "url": { + "type": "string", + "pattern": "^https://raw\\.githubusercontent\\.com/OpenAdaptAI/\\.github/[0-9a-f]{40}/" + }, + "sha256": {"$ref": "#/$defs/digest"} + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "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" + ], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "display_name": {"type": "string", "minLength": 1}, + "lifecycle_scope": {"enum": ["repository", "public_surface"]}, + "lifecycle_subject": {"type": "string", "minLength": 1}, + "source_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "release_kind": { + "enum": ["public_package", "public_deployment", "private_deployment"] + }, + "required_claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "required_artifact_kinds": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "package_index_project": { + "oneOf": [{"type": "null"}, {"type": "string", "minLength": 1}] + }, + "artifact_authority_by_kind": { + "type": "object", + "additionalProperties": { + "enum": ["pypi", "github_release", "managed_evidence"] + } + }, + "latest_admission": { + "oneOf": [{"type": "null"}, {"type": "object"}] + }, + "admission_history": { + "type": "array", + "items": {"type": "object"} + } + } + } + } +} diff --git a/mkdocs.yml b/mkdocs.yml index b5ea6d8..2b39868 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -184,6 +184,7 @@ nav: - openadapt flow CLI: reference/cli.md - Versions and compatibility: reference/compatibility.md - Deployment configuration: reference/deployment-config.md + - Production admission: reference/production-lifecycle.md - The bundle format: reference/bundle-format.md - Configuration and env vars: reference/configuration.md - Glossary: reference/glossary.md diff --git a/production-lifecycle-source.json b/production-lifecycle-source.json new file mode 100644 index 0000000..f9b7573 --- /dev/null +++ b/production-lifecycle-source.json @@ -0,0 +1,47 @@ +{ + "schema_version": "openadapt.production-lifecycle-source/v1", + "repository": "OpenAdaptAI/.github", + "source_commit": "d60445bc8617adc4a27c20e6ce05681a95d83a2c", + "files": { + "admissions": { + "path": "production-lifecycle-admissions.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/production-lifecycle-admissions.json", + "sha256": "sha256:aa13f9abf9c70dacdb9b13fdf237c6b5f193011ce834ac7f616ff7441c3ecbd9" + }, + "admissions_schema": { + "path": "schemas/production-lifecycle-admissions.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-admissions.schema.json", + "sha256": "sha256:a263f929549a4098244965f1e4bee73707c99173829771af2d79bedc081bdd81" + }, + "evidence_manifest_schema": { + "path": "schemas/production-lifecycle-evidence-manifest.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-evidence-manifest.schema.json", + "sha256": "sha256:4bb0aff068c38ea0a407e27fac68ca9f4a178983af4f560f9dac5ebf8c672376" + }, + "evidence_summary_schema": { + "path": "schemas/production-lifecycle-evidence-summary.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-evidence-summary.schema.json", + "sha256": "sha256:01dd60e8e68391434a8bfd81a5c610f29b9d4a7ec2a36761622da141cdba7d58" + }, + "lifecycle": { + "path": "repository-lifecycle.yml", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/repository-lifecycle.yml", + "sha256": "sha256:dc8af3941e3f865e2303ad075a0e7f4597d601c53f5795594cd4f9bcdc1f6420" + }, + "policy": { + "path": "production-lifecycle-policy.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/production-lifecycle-policy.json", + "sha256": "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11" + }, + "policy_schema": { + "path": "schemas/production-lifecycle-policy.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/schemas/production-lifecycle-policy.schema.json", + "sha256": "sha256:7ba6daadf2bd3b4382cebc958177e709272bb360fa0a0d231f648727ed34058c" + }, + "validator": { + "path": "scripts/validate_production_lifecycle.py", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/.github/d60445bc8617adc4a27c20e6ce05681a95d83a2c/scripts/validate_production_lifecycle.py", + "sha256": "sha256:7f665f8c385d496aed21a580278c2af75666ea57a749ed1bb404622b383e0b5a" + } + } +} diff --git a/scripts/render_production_lifecycle.py b/scripts/render_production_lifecycle.py new file mode 100755 index 0000000..723c8b7 --- /dev/null +++ b/scripts/render_production_lifecycle.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Render the public lifecycle from an exact, validated organization policy. + +The source descriptor pins the canonical organization-profile commit and the +hash of every input. The renderer fetches those exact bytes, runs the pinned +canonical validator, and only then writes the remote-safe public projection. +It never invents or downgrades lifecycle state. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_PATH = ROOT / "production-lifecycle-source.json" +OUTPUT_PATH = ROOT / "docs" / "production-lifecycle.json" +SOURCE_SCHEMA = "openadapt.production-lifecycle-source/v1" +OUTPUT_SCHEMA = "openadapt.public-production-lifecycle/v1" +POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v1" +ADMISSIONS_SCHEMA = "openadapt.production-lifecycle-admissions/v1" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +MAX_SOURCE_BYTES = 2 * 1024 * 1024 +EXPECTED_FILE_KEYS = { + "admissions", + "admissions_schema", + "evidence_manifest_schema", + "evidence_summary_schema", + "lifecycle", + "policy", + "policy_schema", + "validator", +} +EXPECTED_PATHS = { + "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", +} + + +class RenderError(ValueError): + """The public policy cannot be derived from verified canonical inputs.""" + + +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 RenderError(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 RenderError(f"{label} is not valid JSON") from exc + if not isinstance(parsed, dict): + raise RenderError(f"{label} must be a JSON object") + return parsed + + +def _fetch(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-docs-production-lifecycle-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 RenderError("canonical lifecycle input exceeds the size limit") + body = response.read(MAX_SOURCE_BYTES + 1) + if len(body) > MAX_SOURCE_BYTES: + raise RenderError("canonical 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 RenderError( + 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 RenderError("production lifecycle source schema is not supported") + if source["repository"] != "OpenAdaptAI/.github": + raise RenderError("production lifecycle source repository is not canonical") + commit = source["source_commit"] + if not isinstance(commit, str) or HEX40.fullmatch(commit) is None: + raise RenderError("production lifecycle source commit is not exact") + files = source["files"] + if not isinstance(files, dict) or set(files) != EXPECTED_FILE_KEYS: + raise RenderError("production lifecycle source file inventory is not exact") + for key in sorted(EXPECTED_FILE_KEYS): + item = _closed(files[key], {"path", "url", "sha256"}, f"source file {key}") + if item["path"] != EXPECTED_PATHS[key]: + raise RenderError(f"source file {key} path is not canonical") + if ( + not isinstance(item["sha256"], str) + or SHA256.fullmatch(item["sha256"]) is None + ): + raise RenderError(f"source file {key} digest is invalid") + parsed = urlsplit(item["url"]) + expected_path = f"/OpenAdaptAI/.github/{commit}/{item['path']}" + if ( + parsed.scheme != "https" + or parsed.netloc != "raw.githubusercontent.com" + or parsed.path != expected_path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + raise RenderError(f"source file {key} URL is not bound to the exact commit") + return source + + +def fetch_inputs( + source: Mapping[str, Any], + *, + fetch: Callable[[str], bytes] = _fetch, +) -> dict[str, bytes]: + inputs: dict[str, bytes] = {} + for key in sorted(EXPECTED_FILE_KEYS): + item = source["files"][key] + try: + body = fetch(item["url"]) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise RenderError( + f"canonical source file {key} could not be fetched: {exc}" + ) from exc + if _digest_bytes(body) != item["sha256"]: + raise RenderError(f"canonical source file {key} digest changed") + inputs[key] = body + return inputs + + +def validate_inputs(inputs: Mapping[str, bytes]) -> None: + """Run the exact canonical validator pinned by the source descriptor.""" + + with tempfile.TemporaryDirectory( + prefix="openadapt-production-policy-" + ) as directory: + root = Path(directory) + for key, relative in EXPECTED_PATHS.items(): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(inputs[key]) + environment = dict(os.environ) + environment["PYTHONPYCACHEPREFIX"] = str(root / ".pycache") + completed = subprocess.run( + [ + sys.executable, + str(root / EXPECTED_PATHS["validator"]), + "--root", + str(root), + ], + check=False, + capture_output=True, + text=True, + timeout=120, + env=environment, + ) + if completed.returncode != 0: + detail = ( + completed.stderr or completed.stdout or "validation failed" + ).strip() + raise RenderError( + f"canonical Production lifecycle refused: {detail.splitlines()[-1]}" + ) + + +def render(source: Mapping[str, Any], inputs: Mapping[str, bytes]) -> dict[str, Any]: + policy = _load_json_bytes(inputs["policy"], "canonical policy") + admissions = _load_json_bytes(inputs["admissions"], "canonical admissions") + if policy.get("schema_version") != POLICY_SCHEMA: + raise RenderError("canonical policy schema is not supported") + if admissions.get("schema_version") != ADMISSIONS_SCHEMA: + raise RenderError("canonical admissions schema is not supported") + policy_digest = source["files"]["policy"]["sha256"] + if admissions.get("policy_sha256") != policy_digest: + raise RenderError("canonical admissions do not bind the exact policy") + targets = policy.get("targets") + current_admissions = admissions.get("admissions") + if not isinstance(targets, list) or not isinstance(current_admissions, list): + raise RenderError("canonical policy target or admission inventory is invalid") + by_target: dict[str, list[dict[str, Any]]] = {} + for admission in current_admissions: + if not isinstance(admission, dict) or not isinstance( + admission.get("target"), str + ): + raise RenderError("canonical admission target is invalid") + by_target.setdefault(admission["target"], []).append(admission) + rendered_targets: list[dict[str, Any]] = [] + for target in targets: + if not isinstance(target, dict) or not isinstance(target.get("id"), str): + raise RenderError("canonical policy target is invalid") + target_id = target["id"] + target_admissions = by_target.pop(target_id, []) + target_admissions.sort(key=lambda item: item["release_identity"]["sequence"]) + latest_admission = target_admissions[-1] if target_admissions else None + rendered_targets.append( + { + "id": target_id, + "display_name": target["display_name"], + "lifecycle_scope": target["lifecycle_scope"], + "lifecycle_subject": target["lifecycle_subject"], + "source_repository": target["source_repository"], + "release_kind": target["release_kind"], + "required_claim_scope": target["required_claim_scope"], + "required_artifact_kinds": target["required_artifact_kinds"], + "package_index_project": target["package_index_project"], + "artifact_authority_by_kind": target["artifact_authority_by_kind"], + "latest_admission": latest_admission, + "admission_history": target_admissions, + } + ) + if by_target: + raise RenderError( + f"canonical admissions contain unknown targets: {sorted(by_target)}" + ) + return { + "$schema": "schemas/production-lifecycle-public.schema.json", + "schema_version": OUTPUT_SCHEMA, + "source": source, + "policy_revision": policy["revision"], + "maximum_admission_days": policy["maximum_admission_days"], + "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": rendered_targets, + } + + +def encode(value: Mapping[str, Any]) -> bytes: + return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", action="store_true", help="Refuse generated-output drift" + ) + args = parser.parse_args() + try: + source = load_source() + inputs = fetch_inputs(source) + validate_inputs(inputs) + output = encode(render(source, inputs)) + if args.check: + if not OUTPUT_PATH.is_file() or OUTPUT_PATH.read_bytes() != output: + raise RenderError( + "docs/production-lifecycle.json differs from the exact canonical source" + ) + else: + temporary = OUTPUT_PATH.with_suffix(".json.tmp") + temporary.write_bytes(output) + temporary.replace(OUTPUT_PATH) + except (OSError, RenderError, subprocess.SubprocessError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + action = "Validated" if args.check else "Rendered" + print(f"{action} the public Production lifecycle from exact canonical inputs.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_production_lifecycle_policy.py b/tests/test_production_lifecycle_policy.py new file mode 100644 index 0000000..31edb33 --- /dev/null +++ b/tests/test_production_lifecycle_policy.py @@ -0,0 +1,150 @@ +"""Tests for the exact public Production lifecycle projection.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "render_production_lifecycle", ROOT / "scripts" / "render_production_lifecycle.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/.github", + "source_commit": commit, + "files": { + key: { + "path": path, + "url": ( + "https://raw.githubusercontent.com/OpenAdaptAI/.github/" + f"{commit}/{path}" + ), + "sha256": "sha256:" + f"{index:x}" * 64, + } + for index, (key, path) in enumerate( + sorted(MODULE.EXPECTED_PATHS.items()), start=1 + ) + }, + } + + +def _inputs(admissions: list[dict] | None = None) -> dict[str, bytes]: + target = { + "id": "flow", + "display_name": "OpenAdapt Flow", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "release_kind": "public_package", + "required_claim_scope": "qualified_workflow_runtime_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-flow", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + } + values = { + "policy": { + "schema_version": MODULE.POLICY_SCHEMA, + "revision": 1, + "maximum_admission_days": 30, + "targets": [target], + }, + "admissions": { + "schema_version": MODULE.ADMISSIONS_SCHEMA, + "policy_sha256": "sha256:" + "4" * 64, + "admissions": admissions or [], + }, + } + return {key: json.dumps(value).encode() for key, value in values.items()} + + +class ProductionLifecycleProjectionTests(unittest.TestCase): + def test_render_never_publishes_static_production_state(self) -> None: + source = _source() + source["files"]["policy"]["sha256"] = "sha256:" + "4" * 64 + output = MODULE.render(source, _inputs()) + + self.assertEqual( + output["derivation"], + { + "mode": "latest_signed_admission_at_read_time", + "static_production_state": False, + "expired_or_revoked_latest_behavior": "no_production", + "fallback_to_older_release": False, + }, + ) + self.assertIsNone(output["targets"][0]["latest_admission"]) + self.assertNotIn("status", output["targets"][0]) + self.assertNotIn("production_targets", output) + + def test_render_preserves_history_and_selects_highest_sequence(self) -> None: + first = { + "target": "flow", + "release_identity": {"sequence": 1}, + "admission_id": "production:flow:1", + } + second = { + "target": "flow", + "release_identity": {"sequence": 2}, + "admission_id": "production:flow:2", + } + source = _source() + source["files"]["policy"]["sha256"] = "sha256:" + "4" * 64 + output = MODULE.render(source, _inputs([second, first])) + + target = output["targets"][0] + self.assertEqual( + [item["admission_id"] for item in target["admission_history"]], + ["production:flow:1", "production:flow:2"], + ) + self.assertEqual( + target["latest_admission"]["admission_id"], "production:flow:2" + ) + + def test_committed_projection_is_admission_free_and_schema_bound(self) -> None: + output = json.loads( + (ROOT / "docs" / "production-lifecycle.json").read_text(encoding="utf-8") + ) + self.assertEqual(output["schema_version"], MODULE.OUTPUT_SCHEMA) + self.assertEqual( + output["$schema"], "schemas/production-lifecycle-public.schema.json" + ) + self.assertTrue( + (ROOT / "docs" / "schemas" / "production-lifecycle-public.schema.json") + .is_file() + ) + self.assertFalse(output["derivation"]["static_production_state"]) + source = json.loads( + (ROOT / "production-lifecycle-source.json").read_text(encoding="utf-8") + ) + self.assertEqual(output["source"], source) + self.assertEqual(len(output["targets"]), 7) + for target in output["targets"]: + self.assertIsNone(target["latest_admission"]) + self.assertEqual(target["admission_history"], []) + + def test_source_requires_exact_commit_bound_inventory(self) -> None: + source = _source() + source["files"]["policy"]["url"] = ( + "https://raw.githubusercontent.com/OpenAdaptAI/.github/main/" + "production-lifecycle-policy.json" + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "source.json" + path.write_text(json.dumps(source)) + with self.assertRaisesRegex(MODULE.RenderError, "exact commit"): + MODULE.load_source(path) + + +if __name__ == "__main__": + unittest.main()