diff --git a/local-candidates/flow-1.35.1-measured/issue.py b/local-candidates/flow-1.35.1-measured/issue.py index 305079b..a981793 100755 --- a/local-candidates/flow-1.35.1-measured/issue.py +++ b/local-candidates/flow-1.35.1-measured/issue.py @@ -700,12 +700,19 @@ def prepared_target(inputs: dict) -> tuple[str, str]: fail("measured adapter does not support this target") contract = trust.TARGET_CONTRACTS[target] release = inputs["release"] + # The release's version fixes the repository it came from. + try: + source = trust.release_source( + target, None if release.get("kind") == "deployment" else release.get("version") + ) + except trust.TrustError: + fail("measured release differs from the validated target inventory") if ( inventory["claim_scope"] != contract["claim_scope"] or release["schema_version"] != "openadapt.production-release-candidate/v1" or release["kind"] != contract["release_kind"] - or release["source_repository"] != contract["repository"] - or release["source_repository_id"] != contract["repository_id"] + or release["source_repository"] != source["repository"] + or release["source_repository_id"] != source["repository_id"] or not isinstance(release["source_commit"], str) or trust.HEX40.fullmatch(release["source_commit"]) is None or release["artifacts"] != inventory["artifacts"] diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index cbf4cc1..a195c44 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -1,7 +1,7 @@ { "$schema": "schemas/production-lifecycle-policy.schema.json", "schema_version": "openadapt.production-lifecycle-policy/v3", - "revision": 7, + "revision": 8, "admission_validity": "until_revoked", "maximum_release_admission_days": null, "maximum_workflow_admission_days": null, @@ -12,12 +12,12 @@ "lifecycle_feed_schema_version": "openadapt.production-lifecycle-feed/v2", "lifecycle_feed_ref": "refs/heads/production-lifecycle-feed", "targets": [ - {"id":"agent","display_name":"OpenAdapt Agent","source_repository":"OpenAdaptAI/openadapt-agent","source_repository_id":"1136136670","release_kind":"package","claim_scope":"production_agent","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-agent"}, + {"id":"agent","display_name":"OpenAdapt Agent","source_repository":"OpenAdaptAI/OpenAdapt","source_repository_id":"627024850","release_kind":"package","claim_scope":"production_agent","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-agent"}, {"id":"capture","display_name":"OpenAdapt Capture","source_repository":"OpenAdaptAI/openadapt-capture","source_repository_id":"1115283835","release_kind":"package","claim_scope":"production_capture","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-capture"}, {"id":"cloud","display_name":"OpenAdapt Cloud","source_repository":"OpenAdaptAI/openadapt-cloud","source_repository_id":"1300570990","release_kind":"deployment","claim_scope":"production_cloud","required_artifact_kinds":["deployment-manifest"],"package_index_project":null}, {"id":"desktop","display_name":"OpenAdapt Desktop","source_repository":"OpenAdaptAI/openadapt-desktop","source_repository_id":"1171291730","release_kind":"package","claim_scope":"production_desktop","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-desktop"}, {"id":"docs","display_name":"OpenAdapt Documentation","source_repository":"OpenAdaptAI/openadapt-ops","source_repository_id":"1172011294","release_kind":"deployment","claim_scope":"production_docs","required_artifact_kinds":["deployment-manifest"],"package_index_project":null}, - {"id":"flow","display_name":"OpenAdapt Flow","source_repository":"OpenAdaptAI/openadapt-flow","source_repository_id":"1291376938","release_kind":"package","claim_scope":"production_flow","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-flow"}, + {"id":"flow","display_name":"OpenAdapt Flow","source_repository":"OpenAdaptAI/OpenAdapt","source_repository_id":"627024850","release_kind":"package","claim_scope":"production_flow","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-flow"}, {"id":"openadapt","display_name":"OpenAdapt","source_repository":"OpenAdaptAI/OpenAdapt","source_repository_id":"627024850","release_kind":"package","claim_scope":"production_openadapt","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt"} ] } diff --git a/production-release-authority-policy.json b/production-release-authority-policy.json index d79fa8e..2abf78a 100644 --- a/production-release-authority-policy.json +++ b/production-release-authority-policy.json @@ -1 +1 @@ -{"$schema":"schemas/production-release-authority-policy.schema.json","schema_version":"openadapt.production-release-authority-policy/v1","release_app":{"app_id":"4730708","app_slug":"openadapt-release","installation_id":"156835568","bot_user_id":"321543906","bot_login":"openadapt-release[bot]"},"repository_selection":"selected","permissions":{"administration":"read","contents":"write","metadata":"read"},"repositories":[{"target":"agent","repository":"OpenAdaptAI/openadapt-agent","repository_id":"1136136670"},{"target":"capture","repository":"OpenAdaptAI/openadapt-capture","repository_id":"1115283835"},{"target":"cloud","repository":"OpenAdaptAI/openadapt-cloud","repository_id":"1300570990"},{"target":"desktop","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730"},{"target":"docs","repository":"OpenAdaptAI/openadapt-ops","repository_id":"1172011294"},{"target":"flow","repository":"OpenAdaptAI/openadapt-flow","repository_id":"1291376938"},{"target":"openadapt","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850"}],"tag_rulesets":{"creation_authority":"OpenAdapt policy: release tag creation","immutability":"OpenAdapt policy: immutable release tags"},"support_release_authorities":[{"id":"desktop-managed-ffmpeg-runtime","lifecycle":"Support","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730","tag_pattern":"refs/tags/ffmpeg-runtime-v*","creation_ruleset":"OpenAdapt policy: FFmpeg runtime tag creation","immutability_ruleset":"OpenAdapt policy: immutable FFmpeg runtime tags"}]} +{"$schema":"schemas/production-release-authority-policy.schema.json","schema_version":"openadapt.production-release-authority-policy/v1","release_app":{"app_id":"4730708","app_slug":"openadapt-release","installation_id":"156835568","bot_user_id":"321543906","bot_login":"openadapt-release[bot]"},"repository_selection":"selected","permissions":{"administration":"read","contents":"write","metadata":"read"},"repositories":[{"target":"agent","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850","package_path":"packages/agent","tag_pattern":"refs/tags/openadapt-agent-v*","creation_ruleset":"OpenAdapt policy: openadapt-agent release tag creation","immutability_ruleset":"OpenAdapt policy: immutable openadapt-agent release tags"},{"target":"capture","repository":"OpenAdaptAI/openadapt-capture","repository_id":"1115283835"},{"target":"cloud","repository":"OpenAdaptAI/openadapt-cloud","repository_id":"1300570990"},{"target":"desktop","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730"},{"target":"docs","repository":"OpenAdaptAI/openadapt-ops","repository_id":"1172011294"},{"target":"flow","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850","package_path":"packages/flow","tag_pattern":"refs/tags/openadapt-flow-v*","creation_ruleset":"OpenAdapt policy: openadapt-flow release tag creation","immutability_ruleset":"OpenAdapt policy: immutable openadapt-flow release tags"},{"target":"openadapt","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850"}],"tag_rulesets":{"creation_authority":"OpenAdapt policy: release tag creation","immutability":"OpenAdapt policy: immutable release tags"},"support_release_authorities":[{"id":"desktop-managed-ffmpeg-runtime","lifecycle":"Support","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730","tag_pattern":"refs/tags/ffmpeg-runtime-v*","creation_ruleset":"OpenAdapt policy: FFmpeg runtime tag creation","immutability_ruleset":"OpenAdapt policy: immutable FFmpeg runtime tags"}]} diff --git a/repository-lifecycle.yml b/repository-lifecycle.yml index 45dece1..e7d67d2 100644 --- a/repository-lifecycle.yml +++ b/repository-lifecycle.yml @@ -5,8 +5,10 @@ reviewed_on: 2026-10-09 # openadapt-types repositories are frozen. Release admission is not configured # here: production-lifecycle-policy.json and # production-release-authority-policy.json bind each target's source -# repository, and their `flow` and `agent` targets still name the frozen -# repositories that hold the currently admitted releases. +# repository. Their `flow` and `agent` targets name OpenAdapt; releases from +# flow 2.2.0 and agent 2.2.1 on carry openadapt-flow-v* and openadapt-agent-v* +# tags there, and every earlier release stays bound to its frozen repository +# (scripts/production_trust.py, previous_sources). canonical_product: launcher: OpenAdapt engine: OpenAdapt/packages/flow diff --git a/schemas/production-publication-recovery-authorization.schema.json b/schemas/production-publication-recovery-authorization.schema.json index c6ccfe7..154f19a 100644 --- a/schemas/production-publication-recovery-authorization.schema.json +++ b/schemas/production-publication-recovery-authorization.schema.json @@ -25,8 +25,8 @@ "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, "repository_id": {"$ref": "#/$defs/decimal_id"}, "target": {"enum": ["agent", "capture", "desktop", "flow", "openadapt"]}, - "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, - "tag_ref": {"type": "string", "pattern": "^refs/tags/v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, + "tag": {"type": "string", "pattern": "^(openadapt-agent-|openadapt-flow-)?v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, + "tag_ref": {"type": "string", "pattern": "^refs/tags/(openadapt-agent-|openadapt-flow-)?v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, "tag_object_id": {"$ref": "#/$defs/commit"}, "target_commit": {"$ref": "#/$defs/commit"}, "draft_release_id": {"$ref": "#/$defs/decimal_id"}, diff --git a/schemas/production-release-authority-policy.schema.json b/schemas/production-release-authority-policy.schema.json index 177aea3..1f56ddd 100644 --- a/schemas/production-release-authority-policy.schema.json +++ b/schemas/production-release-authority-policy.schema.json @@ -36,12 +36,12 @@ }, "repositories": { "const": [ - {"target":"agent","repository":"OpenAdaptAI/openadapt-agent","repository_id":"1136136670"}, + {"target":"agent","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850","package_path":"packages/agent","tag_pattern":"refs/tags/openadapt-agent-v*","creation_ruleset":"OpenAdapt policy: openadapt-agent release tag creation","immutability_ruleset":"OpenAdapt policy: immutable openadapt-agent release tags"}, {"target":"capture","repository":"OpenAdaptAI/openadapt-capture","repository_id":"1115283835"}, {"target":"cloud","repository":"OpenAdaptAI/openadapt-cloud","repository_id":"1300570990"}, {"target":"desktop","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730"}, {"target":"docs","repository":"OpenAdaptAI/openadapt-ops","repository_id":"1172011294"}, - {"target":"flow","repository":"OpenAdaptAI/openadapt-flow","repository_id":"1291376938"}, + {"target":"flow","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850","package_path":"packages/flow","tag_pattern":"refs/tags/openadapt-flow-v*","creation_ruleset":"OpenAdapt policy: openadapt-flow release tag creation","immutability_ruleset":"OpenAdapt policy: immutable openadapt-flow release tags"}, {"target":"openadapt","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850"} ] }, diff --git a/schemas/qualification-release-verification-receipt-v2.schema.json b/schemas/qualification-release-verification-receipt-v2.schema.json index 4f0ccd0..1dc64c3 100644 --- a/schemas/qualification-release-verification-receipt-v2.schema.json +++ b/schemas/qualification-release-verification-receipt-v2.schema.json @@ -101,7 +101,7 @@ "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, "version": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, - "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, + "tag": {"type": "string", "pattern": "^(openadapt-agent-|openadapt-flow-)?v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, "package_identity": { "properties": { "release_kind": {"const": "package"}, @@ -134,7 +134,8 @@ "agent": { "allOf": [ {"$ref": "#/$defs/package_identity"}, - {"properties": {"target": {"const": "agent"}, "claim_scope": {"const": "production_agent"}, "source_repository": {"const": "OpenAdaptAI/openadapt-agent"}, "source_repository_id": {"const": "1136136670"}}} + {"properties": {"target": {"const": "agent"}, "claim_scope": {"const": "production_agent"}}}, + {"oneOf": [{"properties": {"source_repository": {"const": "OpenAdaptAI/openadapt-agent"}, "source_repository_id": {"const": "1136136670"}, "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}, {"properties": {"source_repository": {"const": "OpenAdaptAI/OpenAdapt"}, "source_repository_id": {"const": "627024850"}, "tag": {"type": "string", "pattern": "^openadapt-agent-v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}]} ] }, "capture": { @@ -164,7 +165,8 @@ "flow": { "allOf": [ {"$ref": "#/$defs/package_identity"}, - {"properties": {"target": {"const": "flow"}, "claim_scope": {"const": "production_flow"}, "source_repository": {"const": "OpenAdaptAI/openadapt-flow"}, "source_repository_id": {"const": "1291376938"}}} + {"properties": {"target": {"const": "flow"}, "claim_scope": {"const": "production_flow"}}}, + {"oneOf": [{"properties": {"source_repository": {"const": "OpenAdaptAI/openadapt-flow"}, "source_repository_id": {"const": "1291376938"}, "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}, {"properties": {"source_repository": {"const": "OpenAdaptAI/OpenAdapt"}, "source_repository_id": {"const": "627024850"}, "tag": {"type": "string", "pattern": "^openadapt-flow-v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}]} ] }, "openadapt": { diff --git a/schemas/qualification-release-verification-receipt.schema.json b/schemas/qualification-release-verification-receipt.schema.json index 3427634..b6e0031 100644 --- a/schemas/qualification-release-verification-receipt.schema.json +++ b/schemas/qualification-release-verification-receipt.schema.json @@ -42,6 +42,9 @@ "registry_head_sha256", "trust_state_source_commit" ], + "allOf": [ + {"oneOf": [{"properties": {"source_repository": {"const": "OpenAdaptAI/openadapt-flow"}, "source_repository_id": {"const": "1291376938"}, "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}, {"properties": {"source_repository": {"const": "OpenAdaptAI/OpenAdapt"}, "source_repository_id": {"const": "627024850"}, "tag": {"type": "string", "pattern": "^openadapt-flow-v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}}}]} + ], "properties": { "schema_version": {"const": "openadapt.qualification-release-verification-receipt/v1"}, "verification_id_sha256": {"$ref": "#/$defs/digest"}, @@ -55,11 +58,11 @@ "release_sha256": {"$ref": "#/$defs/digest"}, "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, "release_identity": {"$ref": "qualification-release.schema.json#/$defs/release_identity"}, - "source_repository": {"const": "OpenAdaptAI/openadapt-flow"}, - "source_repository_id": {"const": "1291376938"}, + "source_repository": {"enum": ["OpenAdaptAI/openadapt-flow", "OpenAdaptAI/OpenAdapt"]}, + "source_repository_id": {"enum": ["1291376938", "627024850"]}, "source_commit": {"$ref": "#/$defs/commit"}, "version": {"type": "string", "minLength": 5, "maxLength": 64}, - "tag": {"type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$"}, + "tag": {"type": "string", "pattern": "^(openadapt-flow-)?v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$"}, "draft_release_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, "publication_staging_sha256": {"$ref": "#/$defs/digest"}, "authority_state_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/qualification-release.schema.json b/schemas/qualification-release.schema.json index ba11124..8839d86 100644 --- a/schemas/qualification-release.schema.json +++ b/schemas/qualification-release.schema.json @@ -58,7 +58,7 @@ "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, "version": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, - "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, + "tag": {"type": "string", "pattern": "^(openadapt-agent-|openadapt-flow-)?v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, "release_identity": { "type": "object", @@ -184,7 +184,7 @@ "additionalProperties": false, "required": ["include", "exclude"], "properties": { - "include": {"const": ["refs/tags/v*"]}, + "include": {"enum": [["refs/tags/v*"], ["refs/tags/openadapt-agent-v*"], ["refs/tags/openadapt-flow-v*"]]}, "exclude": {"const": []} } } @@ -216,7 +216,7 @@ {"$ref": "#/$defs/tag_ruleset"}, {"properties": { "role": {"const": "creation_authority"}, - "name": {"const": "OpenAdapt policy: release tag creation"}, + "name": {"enum": ["OpenAdapt policy: release tag creation", "OpenAdapt policy: openadapt-agent release tag creation", "OpenAdapt policy: openadapt-flow release tag creation"]}, "bypass_actors": {"const": [{"actor_id": "4730708", "actor_type": "Integration", "bypass_mode": "always"}]}, "rules": {"const": [{"type": "creation"}]} }} @@ -227,7 +227,7 @@ {"$ref": "#/$defs/tag_ruleset"}, {"properties": { "role": {"const": "immutability"}, - "name": {"const": "OpenAdapt policy: immutable release tags"}, + "name": {"enum": ["OpenAdapt policy: immutable release tags", "OpenAdapt policy: immutable openadapt-agent release tags", "OpenAdapt policy: immutable openadapt-flow release tags"]}, "bypass_actors": {"const": []}, "rules": { "type": "array", @@ -351,7 +351,7 @@ "additionalProperties": false, "required": ["ref", "exists"], "properties": { - "ref": {"type": "string", "pattern": "^refs/tags/v[^/]+$"}, + "ref": {"type": "string", "pattern": "^refs/tags/(openadapt-agent-|openadapt-flow-)?v[^/]+$"}, "exists": {"type": "boolean"} } }, diff --git a/scripts/check_profile.py b/scripts/check_profile.py index b14aff3..75691da 100755 --- a/scripts/check_profile.py +++ b/scripts/check_profile.py @@ -17,6 +17,7 @@ load_public_metadata, render_apply_script, ) +import production_trust from validate_production_lifecycle import LifecycleError, validate_files ROOT = Path(__file__).resolve().parents[1] @@ -252,6 +253,23 @@ def parse_lifecycle_groups( return groups +def product_repository_names(policy: dict) -> set[str]: + """Repository names of every product target, current and earlier. + + A target that moved keeps its earlier releases bound to the repository + they came from, so that repository is still a product target repository. + """ + names = { + target["source_repository"].split("/", 1)[1] for target in policy["targets"] + } + names |= { + previous["repository"].split("/", 1)[1] + for contract in production_trust.TARGET_CONTRACTS.values() + for previous in contract.get("previous_sources", ()) + } + return names + + def metadata_errors( metadata: PublicMetadata, product_repositories: set[str], @@ -379,9 +397,7 @@ def main() -> int: (ROOT / "production-lifecycle-policy.json").read_text(encoding="utf-8") ) expected_profile_targets = tuple(target["id"] for target in policy["targets"]) - product_repositories = { - target["source_repository"].split("/", 1)[1] for target in policy["targets"] - } + product_repositories = product_repository_names(policy) product_section_parts = profile_text.split(f"{PRODUCT_HEADING}\n", maxsplit=1) if len(product_section_parts) != 2: errors.append("profile/README.md is missing the Product surfaces section") diff --git a/scripts/production_trust.py b/scripts/production_trust.py index 34e9a56..590fb4d 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -32,6 +32,14 @@ r"^v(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\." r"(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" ) +# A package released from the OpenAdapt monorepo tags as -v +# so it never shares the launcher's v* namespace in the same repository. The +# prefix is an exact literal per target; only the version part varies. +TAG_PREFIX = re.compile(r"^(|openadapt-[a-z]+-)$") +DEFAULT_TAG_RULESET_NAMES = ( + "OpenAdapt policy: release tag creation", + "OpenAdapt policy: immutable release tags", +) TARGETS = ("agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt") # Founder 2026-09-02: admit every product target. Native Desktop installers @@ -40,8 +48,22 @@ TARGET_CONTRACTS = { "agent": { "claim_scope": "production_agent", - "repository": "OpenAdaptAI/openadapt-agent", - "repository_id": "1136136670", + # Released from packages/agent of the OpenAdapt monorepo since + # 2.2.1, under package-scoped tags. + "repository": "OpenAdaptAI/OpenAdapt", + "repository_id": "627024850", + "package_path": "packages/agent", + "tag_prefix": "openadapt-agent-", + # Every earlier release came from the standalone repository under + # plain v tags; those releases stay bound to it. + "previous_sources": ( + { + "repository": "OpenAdaptAI/openadapt-agent", + "repository_id": "1136136670", + "tag_prefix": "", + "before_version": "2.2.1", + }, + ), "release_kind": "package", "artifacts": { "python-sdist": ("application/gzip", ("github-release", "pypi")), @@ -94,8 +116,22 @@ }, "flow": { "claim_scope": "production_flow", - "repository": "OpenAdaptAI/openadapt-flow", - "repository_id": "1291376938", + # Released from packages/flow of the OpenAdapt monorepo since + # 2.2.0, under package-scoped tags. + "repository": "OpenAdaptAI/OpenAdapt", + "repository_id": "627024850", + "package_path": "packages/flow", + "tag_prefix": "openadapt-flow-", + # Every earlier release came from the standalone repository under + # plain v tags; those releases stay bound to it. + "previous_sources": ( + { + "repository": "OpenAdaptAI/openadapt-flow", + "repository_id": "1291376938", + "tag_prefix": "", + "before_version": "2.2.0", + }, + ), "release_kind": "package", "artifacts": { "python-sdist": ("application/gzip", ("github-release", "pypi")), @@ -113,6 +149,92 @@ }, }, } + + +def _version_key(version: Any) -> tuple[int, int, int, int]: + match = RELEASE_TAG.fullmatch(f"v{version}") if isinstance(version, str) else None + if match is None: + raise TrustError("release version is invalid") + # A prerelease sorts before its release. + return (int(match[1]), int(match[2]), int(match[3]), 0 if match[4] else 1) + + +def release_source(target: str, version: str | None) -> dict[str, str]: + """The one repository and tag prefix a target's release of ``version`` + must come from. A deployment (``version`` None) uses the current source. + """ + + contract = TARGET_CONTRACTS.get(target) + if contract is None: + raise TrustError("release target is invalid") + if version is not None: + key = _version_key(version) + for previous in contract.get("previous_sources", ()): + if key < _version_key(previous["before_version"]): + return { + "repository": previous["repository"], + "repository_id": previous["repository_id"], + "tag_prefix": require_tag_prefix(previous["tag_prefix"]), + } + return { + "repository": contract["repository"], + "repository_id": contract["repository_id"], + "tag_prefix": require_tag_prefix(contract.get("tag_prefix", "")), + } + + +def release_tag(target: str, version: str) -> str: + """The only tag a target's release of ``version`` may carry.""" + + return f"{release_source(target, version)['tag_prefix']}v{version}" + + +def release_source_for_tag(target: str, tag: Any) -> dict[str, str] | None: + """The source of the release this exact tag names, or None if no source + of the target would carry it.""" + + if not isinstance(tag, str): + return None + contract = TARGET_CONTRACTS.get(target) + if contract is None: + return None + prefixes = {contract.get("tag_prefix", "")} | { + previous["tag_prefix"] for previous in contract.get("previous_sources", ()) + } + for prefix in sorted(prefixes): + rest = tag[len(prefix) :] if tag.startswith(prefix) else None + if rest is None or RELEASE_TAG.fullmatch(rest) is None: + continue + source = release_source(target, rest[1:]) + if source["tag_prefix"] == prefix: + return source + return None + + +def require_tag_prefix(prefix: Any) -> str: + if not isinstance(prefix, str) or TAG_PREFIX.fullmatch(prefix) is None: + raise TrustError("release tag prefix is invalid") + return prefix + + +def tag_ref_pattern(prefix: str) -> str: + """The single ruleset include pattern for tags with this exact prefix.""" + + return f"refs/tags/{require_tag_prefix(prefix)}v*" + + +def tag_ruleset_names(prefix: str) -> tuple[str, str]: + """Creation and immutability ruleset names for one tag prefix.""" + + if not require_tag_prefix(prefix): + return DEFAULT_TAG_RULESET_NAMES + project = prefix[:-1] + return ( + f"OpenAdapt policy: {project} release tag creation", + f"OpenAdapt policy: immutable {project} release tags", + ) + + CAMPAIGN_CLASSES = ( "healthy", "safe_halt", @@ -575,8 +697,10 @@ def artifact_inventory_digest(value: Mapping[str, Any]) -> str: def validate_tag_rulesets( - value: Any, *, repository: str, repository_id: str + value: Any, *, repository: str, repository_id: str, tag_prefix: str ) -> list[dict[str, Any]]: + names = tag_ruleset_names(tag_prefix) + pattern = tag_ref_pattern(tag_prefix) if not isinstance(value, list) or len(value) != 2: raise TrustError("tag rulesets must contain creation and immutability rulesets") roles = ["creation_authority", "immutability"] @@ -609,11 +733,7 @@ def validate_tag_rulesets( require_decimal_id(ruleset["ruleset_id"], "tag ruleset id") if ruleset["target"] != "tag" or ruleset["enforcement"] != "active": raise TrustError("tag ruleset is not active for tags") - expected_name = ( - "OpenAdapt policy: release tag creation" - if index == 0 - else "OpenAdapt policy: immutable release tags" - ) + expected_name = names[index] if ruleset["name"] != expected_name: raise TrustError("tag ruleset name differs from policy") actors = ruleset["bypass_actors"] @@ -644,8 +764,8 @@ def validate_tag_rulesets( or any(not isinstance(item, str) or not item for item in items) ): raise TrustError("tag ref patterns must be sorted unique strings") - if ref_name != {"include": ["refs/tags/v*"], "exclude": []}: - raise TrustError("tag ruleset must match exactly refs/tags/v*") + if ref_name != {"include": [pattern], "exclude": []}: + raise TrustError(f"tag ruleset must match exactly {pattern}") rules = ruleset["rules"] expected_rules = ( [{"type": "creation"}] @@ -822,7 +942,7 @@ def _validate_staged_assets( return assets -def validate_staging(value: Any) -> dict[str, Any]: +def validate_staging(value: Any, *, tag_prefix: str = "") -> dict[str, Any]: if not isinstance(value, dict) or "publication_mode" not in value: raise TrustError("publication staging mode is not supported") mode = value["publication_mode"] @@ -906,6 +1026,8 @@ def validate_staging(value: Any) -> dict[str, Any]: raise TrustError("publication staging target commit must be exact") if not isinstance(staging["tag"], str) or not staging["tag"]: raise TrustError("publication staging tag is invalid") + if not staging["tag"].startswith(require_tag_prefix(tag_prefix)): + raise TrustError("publication staging tag differs from the target prefix") assets = _validate_staged_assets( staging["assets"], require_release_app_uploader=require_bot_author ) @@ -915,6 +1037,7 @@ def validate_staging(value: Any) -> dict[str, Any]: staging["tag_rulesets"], repository=staging["repository"], repository_id=staging["repository_id"], + tag_prefix=tag_prefix, ) if staging["tag_rulesets_sha256"] != digest_bytes( TAG_RULESETS_DOMAIN, staging["tag_rulesets"] @@ -1040,10 +1163,20 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An "hybrid", }: raise TrustError("release candidate schema or kind is invalid") + if release["kind"] != target_contract["release_kind"]: + raise TrustError("release candidate differs from the target policy") + if release["kind"] != "deployment" and ( + not isinstance(release["version"], str) + or BUNDLE_VERSION.fullmatch(release["version"]) is None + ): + raise TrustError("release package version or tag is invalid") + source = release_source( + release_admission["target"], + None if release["kind"] == "deployment" else release["version"], + ) if ( - release["kind"] != target_contract["release_kind"] - or release["source_repository"] != target_contract["repository"] - or release["source_repository_id"] != target_contract["repository_id"] + release["source_repository"] != source["repository"] + or release["source_repository_id"] != source["repository_id"] ): raise TrustError("release candidate differs from the target policy") require_decimal_id(release["source_repository_id"], "release source repository id") @@ -1056,8 +1189,7 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An not isinstance(release["version"], str) or BUNDLE_VERSION.fullmatch(release["version"]) is None or not isinstance(release["tag"], str) - or RELEASE_TAG.fullmatch(release["tag"]) is None - or release["tag"] != f"v{release['version']}" + or release["tag"] != release_tag(release_admission["target"], release["version"]) ): raise TrustError("release package version or tag is invalid") if release["kind"] == "package": @@ -1124,7 +1256,10 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An }, ): raise TrustError("release candidate digest is invalid") - staging = validate_staging(release_admission["publication_staging"]) + staging = validate_staging( + release_admission["publication_staging"], + tag_prefix=source["tag_prefix"], + ) if release_admission["publication_staging_sha256"] != staging_digest(staging): raise TrustError("publication staging digest is invalid") expected_staging_tag = release["tag"] or ( @@ -1398,7 +1533,8 @@ def validate_support_release( }, ): raise TrustError("Support release candidate digest is invalid") - staging = validate_staging(admission["publication_staging"]) + # openadapt-tray releases under plain v tags. + staging = validate_staging(admission["publication_staging"], tag_prefix="") if ( admission["publication_staging_sha256"] != staging_digest(staging) or staging["repository"] != release["source_repository"] @@ -1509,9 +1645,14 @@ def validate_publication_recovery_authorization( contract = TARGET_CONTRACTS.get(target) if contract is None or contract["release_kind"] != "package": raise TrustError("publication recovery applies only to package targets") + # The tag names the release, and the release's version fixes the one + # repository it may come from. + source = release_source_for_tag(target, authorization["tag"]) + if source is None: + raise TrustError("publication recovery immutable tag binding differs") if ( - authorization["repository"] != contract["repository"] - or authorization["repository_id"] != contract["repository_id"] + authorization["repository"] != source["repository"] + or authorization["repository_id"] != source["repository_id"] ): raise TrustError("publication recovery target repository differs") for field in ( @@ -1536,9 +1677,7 @@ def validate_publication_recovery_authorization( ): raise TrustError("publication recovery release reference differs") if ( - not isinstance(authorization["tag"], str) - or RELEASE_TAG.fullmatch(authorization["tag"]) is None - or authorization["tag_ref"] != f"refs/tags/{authorization['tag']}" + authorization["tag_ref"] != f"refs/tags/{authorization['tag']}" or not isinstance(authorization["tag_object_id"], str) or HEX40.fullmatch(authorization["tag_object_id"]) is None or not isinstance(authorization["target_commit"], str) @@ -2669,11 +2808,21 @@ def validate_acceptance_manifest( "manifest release candidate", ) contract = TARGET_CONTRACTS[manifest["target"]] + if release["kind"] != "deployment" and ( + not isinstance(release["version"], str) + or BUNDLE_VERSION.fullmatch(release["version"]) is None + or release["tag"] != release_tag(manifest["target"], release["version"]) + ): + raise TrustError("manifest release candidate differs from the target policy") + source = release_source( + manifest["target"], + None if release["kind"] == "deployment" else release["version"], + ) if ( release["schema_version"] != "openadapt.production-release-candidate/v1" or release["kind"] != contract["release_kind"] - or release["source_repository"] != contract["repository"] - or release["source_repository_id"] != contract["repository_id"] + or release["source_repository"] != source["repository"] + or release["source_repository_id"] != source["repository_id"] or not isinstance(release["source_commit"], str) or HEX40.fullmatch(release["source_commit"]) is None ): @@ -2696,7 +2845,10 @@ def validate_acceptance_manifest( or manifest["artifact_inventory_sha256"] != artifact_inventory_digest(inventory) ): raise TrustError("manifest artifact inventory differs from the release") - staging = validate_staging(manifest["publication_staging"]) + staging = validate_staging( + manifest["publication_staging"], + tag_prefix=source["tag_prefix"], + ) if ( manifest["publication_staging_sha256"] != staging_digest(staging) or staging["repository"] != release["source_repository"] diff --git a/tests/test_github_metadata.py b/tests/test_github_metadata.py index 9604249..d49c3e8 100644 --- a/tests/test_github_metadata.py +++ b/tests/test_github_metadata.py @@ -38,7 +38,7 @@ def product_repositories() -> set[str]: policy = json.loads( (ROOT / "production-lifecycle-policy.json").read_text(encoding="utf-8") ) - return {target["source_repository"].split("/", 1)[1] for target in policy["targets"]} + return check_profile.product_repository_names(policy) def lifecycles() -> dict[str, str]: diff --git a/tests/test_package_scoped_release_tags.py b/tests/test_package_scoped_release_tags.py new file mode 100644 index 0000000..f70872c --- /dev/null +++ b/tests/test_package_scoped_release_tags.py @@ -0,0 +1,256 @@ +"""Flow and agent release from the OpenAdapt monorepo under package tags. + +Flow from 2.2.0 and agent from 2.2.1 come from OpenAdaptAI/OpenAdapt +(packages/flow, packages/agent) and carry openadapt-flow-v and +openadapt-agent-v tags, so they never share the launcher's v* +namespace there. Every earlier release stays bound to its standalone +repository and its plain v tag. The version decides which; nothing +else can choose. +""" + +from __future__ import annotations + +import copy +import json +import sys +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tests")) +import test_qualification_issuer as samples # noqa: E402 +from test_production_trust import rulesets, trust # noqa: E402 + +MONOREPO = {"repository": "OpenAdaptAI/OpenAdapt", "repository_id": "627024850"} +FLOW_REPO = {"repository": "OpenAdaptAI/openadapt-flow", "repository_id": "1291376938"} +AGENT_REPO = {"repository": "OpenAdaptAI/openadapt-agent", "repository_id": "1136136670"} + + +def package_rulesets(prefix: str, repository: dict) -> list[dict]: + value = rulesets() + names = trust.tag_ruleset_names(prefix) + for index, ruleset in enumerate(value): + ruleset.update(repository) + ruleset["name"] = names[index] + ruleset["conditions"]["ref_name"]["include"] = [trust.tag_ref_pattern(prefix)] + return value + + +class ReleaseSourceTests(unittest.TestCase): + def test_the_version_alone_selects_the_source(self) -> None: + cases = [ + ("flow", "2.1.0", {**FLOW_REPO, "tag_prefix": ""}), + ("flow", "2.2.0-rc.1", {**FLOW_REPO, "tag_prefix": ""}), + ("flow", "2.2.0", {**MONOREPO, "tag_prefix": "openadapt-flow-"}), + ("flow", "3.0.0", {**MONOREPO, "tag_prefix": "openadapt-flow-"}), + ("agent", "2.2.0", {**AGENT_REPO, "tag_prefix": ""}), + ("agent", "2.2.1", {**MONOREPO, "tag_prefix": "openadapt-agent-"}), + ("openadapt", "1.17.0", {**MONOREPO, "tag_prefix": ""}), + ( + "capture", + "1.2.2", + { + "repository": "OpenAdaptAI/openadapt-capture", + "repository_id": "1115283835", + "tag_prefix": "", + }, + ), + ] + for target, version, expected in cases: + with self.subTest(target=target, version=version): + self.assertEqual(trust.release_source(target, version), expected) + + def test_release_tag_is_exact_per_target_and_version(self) -> None: + self.assertEqual(trust.release_tag("flow", "2.2.0"), "openadapt-flow-v2.2.0") + self.assertEqual(trust.release_tag("flow", "2.1.0"), "v2.1.0") + self.assertEqual(trust.release_tag("agent", "2.2.1"), "openadapt-agent-v2.2.1") + self.assertEqual(trust.release_tag("agent", "2.2.0"), "v2.2.0") + self.assertEqual(trust.release_tag("openadapt", "1.17.0"), "v1.17.0") + + def test_a_tag_resolves_only_to_the_source_that_would_carry_it(self) -> None: + self.assertEqual( + trust.release_source_for_tag("flow", "openadapt-flow-v2.2.0"), + {**MONOREPO, "tag_prefix": "openadapt-flow-"}, + ) + self.assertEqual( + trust.release_source_for_tag("flow", "v2.1.0"), + {**FLOW_REPO, "tag_prefix": ""}, + ) + for target, tag in [ + ("flow", "v2.2.0"), # 2.2.0 is a monorepo release + ("flow", "openadapt-flow-v2.1.0"), # 2.1.0 came from openadapt-flow + ("flow", "openadapt-agent-v2.2.1"), # another package's tag + ("flow", "openadapt-flow-2.2.0"), + ("flow", "openadapt-flow-v2.2"), + ("flow", "xopenadapt-flow-v2.2.0"), + ("agent", "v2.2.1"), + ("agent", "openadapt-agent-v2.2.0"), + ("openadapt", "openadapt-flow-v2.2.0"), + ("capture", "openadapt-capture-v1.2.2"), + ("flow", None), + ]: + with self.subTest(target=target, tag=tag): + self.assertIsNone(trust.release_source_for_tag(target, tag)) + + def test_a_tag_prefix_is_an_exact_package_literal(self) -> None: + for prefix in ("", "openadapt-flow-", "openadapt-agent-"): + self.assertEqual(trust.require_tag_prefix(prefix), prefix) + for prefix in ("*", "openadapt-*", "flow-", "openadapt-flow", "openadapt-flow-v", None): + with self.subTest(prefix=prefix): + with self.assertRaises(trust.TrustError): + trust.require_tag_prefix(prefix) + + def test_the_authority_policy_names_each_moved_target_exactly(self) -> None: + policy = json.loads( + (ROOT / "production-release-authority-policy.json").read_text() + ) + by_target = {entry["target"]: entry for entry in policy["repositories"]} + for target in ("agent", "flow"): + with self.subTest(target=target): + contract = trust.TARGET_CONTRACTS[target] + entry = by_target[target] + prefix = contract["tag_prefix"] + self.assertEqual(entry["repository"], contract["repository"]) + self.assertEqual(entry["repository_id"], contract["repository_id"]) + self.assertEqual(entry["package_path"], contract["package_path"]) + self.assertEqual(entry["tag_pattern"], trust.tag_ref_pattern(prefix)) + self.assertEqual( + (entry["creation_ruleset"], entry["immutability_ruleset"]), + trust.tag_ruleset_names(prefix), + ) + lifecycle = json.loads((ROOT / "production-lifecycle-policy.json").read_text()) + for target in lifecycle["targets"]: + contract = trust.TARGET_CONTRACTS[target["id"]] + self.assertEqual(target["source_repository"], contract["repository"]) + self.assertEqual(target["source_repository_id"], contract["repository_id"]) + + +class PackageTagRulesetTests(unittest.TestCase): + def test_the_exact_package_rulesets_pass(self) -> None: + for prefix in ("openadapt-flow-", "openadapt-agent-"): + with self.subTest(prefix=prefix): + value = package_rulesets(prefix, MONOREPO) + self.assertEqual( + trust.validate_tag_rulesets(value, **MONOREPO, tag_prefix=prefix), + value, + ) + + def test_any_other_pattern_or_name_is_refused(self) -> None: + prefix = "openadapt-flow-" + + def refused(mutate) -> None: + value = package_rulesets(prefix, MONOREPO) + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_tag_rulesets(value, **MONOREPO, tag_prefix=prefix) + + for include in ( + ["refs/tags/v*"], + ["refs/tags/openadapt-*"], + ["refs/tags/*"], + ["refs/tags/openadapt-agent-v*"], + ["refs/tags/openadapt-flow-v*", "refs/tags/v*"], + ): + with self.subTest(include=include): + refused( + lambda value, include=include: [ + ruleset["conditions"]["ref_name"].__setitem__( + "include", list(include) + ) + for ruleset in value + ] + ) + with self.subTest("launcher ruleset names"): + refused( + lambda value: [ + ruleset.__setitem__("name", name) + for ruleset, name in zip(value, trust.DEFAULT_TAG_RULESET_NAMES) + ] + ) + with self.subTest("launcher rulesets for a package tag"): + with self.assertRaises(trust.TrustError): + trust.validate_tag_rulesets( + package_rulesets("", MONOREPO), **MONOREPO, tag_prefix=prefix + ) + with self.subTest("package rulesets for a launcher tag"): + with self.assertRaises(trust.TrustError): + trust.validate_tag_rulesets( + package_rulesets(prefix, MONOREPO), **MONOREPO, tag_prefix="" + ) + + +class MonorepoFlowAdmissionTests(unittest.TestCase): + def issue(self, version: str) -> dict: + fixture = samples.trust_fixture() + resolver = samples.RecordingResolver(fixture) + workflow = samples.issuer.issue_workflow_admission( + samples.workflow_request(fixture), + resolver=resolver, + issuer_source_commit=samples.WORKFLOW_REGISTRY_COMMIT, + now=samples.NOW, + consumer=samples.RecordingConsumer(), + ) + request = samples.flow_release_inputs( + fixture, workflow, resolver, version=version + ) + return samples.issuer.issue_release_admission( + request, + resolver=resolver, + issuer_source_commit=samples.RELEASE_REGISTRY_COMMIT, + now=samples.NOW, + consumer=samples.RecordingConsumer(), + ) + + def test_flow_2_2_0_is_admitted_from_the_monorepo_under_its_package_tag(self): + release = self.issue("2.2.0") + self.assertEqual(release["release"]["source_repository"], MONOREPO["repository"]) + self.assertEqual(release["release"]["tag"], "openadapt-flow-v2.2.0") + staging = release["publication_staging"] + self.assertEqual(staging["tag"], "openadapt-flow-v2.2.0") + self.assertEqual( + staging["tag_rulesets"][0]["conditions"]["ref_name"]["include"], + ["refs/tags/openadapt-flow-v*"], + ) + self.assertEqual(trust.validate_release(release, now=samples.NOW), release) + schema = json.loads((ROOT / "schemas/qualification-release.schema.json").read_text()) + registry = Registry().with_resources( + (path.name, Resource.from_contents(json.loads(path.read_text()))) + for path in (ROOT / "schemas").glob("*.schema.json") + ) + self.assertEqual( + list(Draft202012Validator(schema, registry=registry).iter_errors(release)), [] + ) + + def test_a_monorepo_release_cannot_claim_the_old_repository_or_tag(self): + release = self.issue("2.2.0") + for field, value in ( + ("source_repository", FLOW_REPO["repository"]), + ("source_repository_id", FLOW_REPO["repository_id"]), + ("tag", "v2.2.0"), + ): + with self.subTest(field=field): + changed = copy.deepcopy(release) + changed["release"][field] = value + with self.assertRaises(trust.TrustError): + trust.validate_release(changed, now=samples.NOW) + + def test_an_earlier_release_cannot_claim_the_monorepo(self): + release = self.issue("1.35.0") + self.assertEqual(release["release"]["source_repository"], FLOW_REPO["repository"]) + for field, value in ( + ("source_repository", MONOREPO["repository"]), + ("source_repository_id", MONOREPO["repository_id"]), + ("tag", "openadapt-flow-v1.35.0"), + ): + with self.subTest(field=field): + changed = copy.deepcopy(release) + changed["release"][field] = value + with self.assertRaises(trust.TrustError): + trust.validate_release(changed, now=samples.NOW) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qualification_issuer.py b/tests/test_qualification_issuer.py index 2cca3f6..11aa447 100644 --- a/tests/test_qualification_issuer.py +++ b/tests/test_qualification_issuer.py @@ -522,13 +522,19 @@ def flow_release_inputs( resolver: RecordingResolver, *, target: str = "flow", + version: str = "1.35.0", ) -> dict: + # The repository and tag prefix follow the version: flow releases before + # 2.2.0 came from openadapt-flow under v. + source = trust.release_source("flow", version) + tag = trust.release_tag("flow", version) + creation_name, immutability_name = trust.tag_ruleset_names(source["tag_prefix"]) admission_ref, admission_bundle_ref = reference_pair( "qualification-admission", admission ) artifacts = [ { - "name": "openadapt_flow-1.35.0.tar.gz", + "name": f"openadapt_flow-{version}.tar.gz", "kind": "python-sdist", "sha256": sha("flow-sdist"), "size_bytes": 101, @@ -536,7 +542,7 @@ def flow_release_inputs( "publish_destinations": ["github-release", "pypi"], }, { - "name": "openadapt_flow-1.35.0-py3-none-any.whl", + "name": f"openadapt_flow-{version}-py3-none-any.whl", "kind": "python-wheel", "sha256": sha("flow-wheel"), "size_bytes": 102, @@ -546,18 +552,23 @@ def flow_release_inputs( ] common_ruleset = { "schema_version": "openadapt.production-release-tag-ruleset/v1", - "repository": "OpenAdaptAI/openadapt-flow", - "repository_id": "1291376938", + "repository": source["repository"], + "repository_id": source["repository_id"], "target": "tag", "enforcement": "active", - "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + "conditions": { + "ref_name": { + "include": [trust.tag_ref_pattern(source["tag_prefix"])], + "exclude": [], + } + }, } rulesets = [ { **common_ruleset, "role": "creation_authority", "ruleset_id": "10", - "name": "OpenAdapt policy: release tag creation", + "name": creation_name, "bypass_actors": [ { "actor_id": "4730708", @@ -571,7 +582,7 @@ def flow_release_inputs( **common_ruleset, "role": "immutability", "ruleset_id": "11", - "name": "OpenAdapt policy: immutable release tags", + "name": immutability_name, "bypass_actors": [], "rules": [ {"type": "deletion"}, @@ -586,10 +597,10 @@ def flow_release_inputs( staging = { "schema_version": "openadapt.production-release-staging-evidence/v1", "publication_mode": "draft-before-tag", - "repository": "OpenAdaptAI/openadapt-flow", - "repository_id": "1291376938", + "repository": source["repository"], + "repository_id": source["repository_id"], "draft_release_id": "20", - "tag": "v1.35.0", + "tag": tag, "target_commitish": "e" * 40, "draft": True, "prerelease": False, @@ -616,21 +627,21 @@ def flow_release_inputs( ), "tag_rulesets": rulesets, "tag_rulesets_sha256": trust.digest_bytes(trust.TAG_RULESETS_DOMAIN, rulesets), - "tag_ref_state": {"ref": "refs/tags/v1.35.0", "exists": False}, + "tag_ref_state": {"ref": f"refs/tags/{tag}", "exists": False}, "tag_ref_state_sha256": trust.digest_bytes( trust.TAG_REF_STATE_DOMAIN, - {"ref": "refs/tags/v1.35.0", "exists": False}, + {"ref": f"refs/tags/{tag}", "exists": False}, ), "observed_at": utc(NOW), } candidate = { "schema_version": "openadapt.production-release-candidate/v1", "kind": "package", - "source_repository": "OpenAdaptAI/openadapt-flow", - "source_repository_id": "1291376938", + "source_repository": source["repository"], + "source_repository_id": source["repository_id"], "source_commit": "e" * 40, - "version": "1.35.0", - "tag": "v1.35.0", + "version": version, + "tag": tag, "deployment_id": None, "deployment_sha256": None, "artifacts": artifacts, @@ -733,14 +744,15 @@ def flow_release_inputs( } ) staging_tag = tag or f"v0.0.0-deployment.{deployment_id}" + target_source = trust.release_source(target, version) target_rulesets = copy.deepcopy(rulesets) for ruleset in target_rulesets: - ruleset["repository"] = contract["repository"] - ruleset["repository_id"] = contract["repository_id"] + ruleset["repository"] = target_source["repository"] + ruleset["repository_id"] = target_source["repository_id"] staging = { **staging, - "repository": contract["repository"], - "repository_id": contract["repository_id"], + "repository": target_source["repository"], + "repository_id": target_source["repository_id"], "tag": staging_tag, "assets": [ { @@ -766,8 +778,8 @@ def flow_release_inputs( candidate = { **candidate, "kind": contract["release_kind"], - "source_repository": contract["repository"], - "source_repository_id": contract["repository_id"], + "source_repository": target_source["repository"], + "source_repository_id": target_source["repository_id"], "version": version, "tag": tag, "deployment_id": deployment_id, @@ -1392,14 +1404,15 @@ def test_release_admission_accepts_every_product_target(self) -> None: consumer=RecordingConsumer(), ) contract = trust.TARGET_CONTRACTS[target] + source = trust.release_source(target, release["release"]["version"]) self.assertEqual(release["target"], target) self.assertEqual(release["claim_scope"], contract["claim_scope"]) self.assertEqual( - release["release"]["source_repository"], contract["repository"] + release["release"]["source_repository"], source["repository"] ) self.assertEqual( release["release"]["source_repository_id"], - contract["repository_id"], + source["repository_id"], ) self.assertEqual( release["release"]["kind"], contract["release_kind"] @@ -1443,7 +1456,7 @@ def test_release_admission_accepts_every_product_target(self) -> None: release["release"]["deployment_id"], ) self.assertEqual( - verification["source_repository"], contract["repository"] + verification["source_repository"], source["repository"] ) self.assertEqual( verification["workflow_bundle_sha256"], diff --git a/tests/test_tag_ruleset_representation.py b/tests/test_tag_ruleset_representation.py index 28af7f3..49328df 100644 --- a/tests/test_tag_ruleset_representation.py +++ b/tests/test_tag_ruleset_representation.py @@ -36,6 +36,7 @@ def validate_runtime(self, value): value, repository="OpenAdaptAI/openadapt-capture", repository_id="1115283835", + tag_prefix="", ) def assert_refused(self, value):