diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 68abc1597..78f16d6b6 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1,5 +1,5 @@ { - "version": 12, + "version": 13, "shared_ownerships": [ { "path": "frontend-modern/src/api/agentProfiles.ts", @@ -2742,6 +2742,13 @@ "tests/integration/tests/runtime-defaults.ts", "VERSION" ], + "release_cycle_artifact_globs": [ + "docs/RELEASE_NOTES.md", + "docs/UPGRADE_v6.md", + "docs/releases/RELEASE_NOTES_v6_RC*_DRAFT.md", + "docs/releases/V6_CHANGELOG_RC*_DRAFT.md", + "docs/releases/V6_RC*_OPERATOR_SUPPORT_PACK_DRAFT.md" + ], "verification": { "allow_same_subsystem_tests": false, "test_prefixes": [], diff --git a/docs/release-control/v6/internal/subsystems/registry.schema.json b/docs/release-control/v6/internal/subsystems/registry.schema.json index 8a5d63d87..2079ee24a 100644 --- a/docs/release-control/v6/internal/subsystems/registry.schema.json +++ b/docs/release-control/v6/internal/subsystems/registry.schema.json @@ -12,7 +12,7 @@ "properties": { "version": { "type": "integer", - "const": 12 + "const": 13 }, "shared_ownerships": { "type": "array", @@ -69,6 +69,15 @@ "minLength": 1 } }, + "release_cycle_artifact_globs": { + "description": "Glob patterns for files this subsystem owns but that are per-release-cycle artifacts (RC packet drafts, version-pointer docs, regenerated record files) rather than contract-shape surfaces. Changes matching any of these globs skip the contract-update requirement check for this subsystem; the file is still considered subsystem-tracked otherwise. Use sparingly: contract-shape files (Dockerfiles, workflow YAMLs, runbook procedures) belong in owned_files, not here.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, "verification": { "$ref": "#/$defs/verification" } diff --git a/internal/repoctl/canonical_development_protocol_test.go b/internal/repoctl/canonical_development_protocol_test.go index b8b3ecf2a..231883c9d 100644 --- a/internal/repoctl/canonical_development_protocol_test.go +++ b/internal/repoctl/canonical_development_protocol_test.go @@ -374,7 +374,7 @@ func TestSubsystemRegistryExistsAndReferencesContracts(t *testing.T) { rel := "docs/release-control/v6/internal/subsystems/registry.json" content := readRepoFile(t, rel) assertContainsAll(t, rel, content, []string{ - "\"version\": 12", + "\"version\": 13", "\"shared_ownerships\":", "\"subsystems\":", "\"verification\":", diff --git a/scripts/release_control/canonical_completion_guard.py b/scripts/release_control/canonical_completion_guard.py index 6370b5350..126a7d22b 100644 --- a/scripts/release_control/canonical_completion_guard.py +++ b/scripts/release_control/canonical_completion_guard.py @@ -9,6 +9,7 @@ staged in the same commit. from __future__ import annotations import argparse +import fnmatch import json import os from pathlib import Path @@ -154,6 +155,19 @@ def subsystem_matches_path(rule: dict, path: str) -> bool: ) +def is_release_cycle_artifact(rule: dict, path: str) -> bool: + """Return True if path matches one of this subsystem's release_cycle_artifact_globs. + + Release-cycle artifacts (RC packet drafts, version-pointer docs, regenerated + record files) are tracked by the subsystem but are per-release-cycle content + rather than contract-shape surfaces. They skip the contract-update + requirement and verification requirements so routine RC-cycle maintenance + doesn't need the contract-neutral bypass. + """ + globs = tuple(rule.get("release_cycle_artifact_globs", [])) + return any(fnmatch.fnmatchcase(path, glob) for glob in globs) + + def path_policy_matches(policy: dict, path: str) -> bool: prefixes = tuple(policy.get("match_prefixes", [])) exact_files = tuple(policy.get("match_files", [])) @@ -253,20 +267,36 @@ def infer_impacted_subsystems( for rule in rules: if not subsystem_matches_path(rule, path): continue - impacted.setdefault( + entry = impacted.setdefault( str(rule["id"]), { "id": str(rule["id"]), "contract": str(rule["contract"]), "touched_runtime_files": [], + "cycle_artifact_files": [], "verification": dict(rule.get("verification", {})), }, - )["touched_runtime_files"].append(path) + ) + entry["touched_runtime_files"].append(path) + if is_release_cycle_artifact(rule, path): + # Path is owned by this subsystem but is a per-release-cycle + # artifact (RC packet draft, version-pointer doc, regenerated + # record). Tracked here so the lookup tool can still report + # ownership; contract-update / verification requirement + # builders consult this list to exclude these paths. + entry["cycle_artifact_files"].append(path) for subsystem_id, data in impacted.items(): + # Verification requirements derive from non-cycle-artifact touches only. + # Release-cycle artifacts (RC packet drafts, version-pointer docs) don't + # trigger verification proof requirements. + cycle_artifacts = set(data.get("cycle_artifact_files", [])) + non_artifact_files = [ + path for path in data["touched_runtime_files"] if path not in cycle_artifacts + ] data["verification_requirements"] = build_verification_requirements( rules_by_id[subsystem_id], - data["touched_runtime_files"], + non_artifact_files, ) return impacted @@ -283,19 +313,38 @@ def required_contract_updates( contract_index = load_contract_index(staged=use_staged_contract_index) for subsystem_id, data in impacted_subsystems.items(): + # Contract update requirement triggers on non-cycle-artifact touches + # only. If every touched file is a release-cycle artifact (RC packet + # draft, version-pointer doc, regenerated record), there's no + # contract-shape change to require — the contract describes the + # release process, not the per-RC content of any specific cycle. + cycle_artifacts = set(data.get("cycle_artifact_files", [])) + contract_impacting = sorted( + set(data["touched_runtime_files"]) - cycle_artifacts + ) + if not contract_impacting: + continue required[data["contract"]] = { "subsystem": subsystem_id, "contract": data["contract"], "reason": "owner", - "touched_runtime_files": sorted(set(data["touched_runtime_files"])), + "touched_runtime_files": contract_impacting, "matched_references": [], } + # Dependent-reference contracts also skip cycle artifacts — the contract + # describes the release process, not specific RC packet contents. + cycle_artifact_paths = { + path + for data in impacted_subsystems.values() + for path in data.get("cycle_artifact_files", []) + } touched_runtime_files = sorted( { path for data in impacted_subsystems.values() for path in data.get("touched_runtime_files", []) + if path not in cycle_artifact_paths } ) for path in touched_runtime_files: diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 30fd4519c..aac2b40e8 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -4069,5 +4069,78 @@ class ContractNeutralOverrideTest(unittest.TestCase): self.assertIn("contract-neutral", stderr_value) +class ReleaseCycleArtifactGuardTest(unittest.TestCase): + """The release_cycle_artifact_globs field in a subsystem rule lets + per-RC-cycle artifacts (packet drafts, version-pointer docs, regenerated + record files) skip the contract-update + verification requirements + without losing the subsystem-tracked status. The lookup tool still + reports ownership; the shape guard just doesn't demand a contract delta. + Contract-shape files (Dockerfile, workflow YAMLs, runbook) still + trigger as normal.""" + + def test_rc_draft_packets_register_as_cycle_artifacts(self): + impacted = infer_impacted_subsystems( + [ + "docs/releases/RELEASE_NOTES_v6_RC5_DRAFT.md", + "docs/releases/V6_CHANGELOG_RC5_DRAFT.md", + "docs/releases/V6_RC5_OPERATOR_SUPPORT_PACK_DRAFT.md", + ] + ) + self.assertIn("deployment-installability", impacted) + entry = impacted["deployment-installability"] + # All three files are tracked as touches AND as cycle artifacts. + self.assertEqual(set(entry["touched_runtime_files"]), set(entry["cycle_artifact_files"])) + # No contract-update requirement since every touch is a cycle artifact. + self.assertEqual(required_contract_updates([ + "docs/releases/RELEASE_NOTES_v6_RC5_DRAFT.md", + "docs/releases/V6_CHANGELOG_RC5_DRAFT.md", + "docs/releases/V6_RC5_OPERATOR_SUPPORT_PACK_DRAFT.md", + ]), {}) + + def test_version_pointer_docs_register_as_cycle_artifacts(self): + impacted = infer_impacted_subsystems( + ["docs/RELEASE_NOTES.md", "docs/UPGRADE_v6.md"] + ) + self.assertIn("deployment-installability", impacted) + self.assertEqual( + required_contract_updates(["docs/RELEASE_NOTES.md", "docs/UPGRADE_v6.md"]), + {}, + ) + + def test_contract_shape_files_still_require_contract_update(self): + impacted = infer_impacted_subsystems(["docker-compose.yml"]) + self.assertIn("deployment-installability", impacted) + self.assertEqual( + impacted["deployment-installability"]["cycle_artifact_files"], + [], + "docker-compose.yml is a contract-shape file, not a cycle artifact", + ) + required = required_contract_updates(["docker-compose.yml"]) + self.assertIn( + "docs/release-control/v6/internal/subsystems/deployment-installability.md", + required, + ) + + def test_mixed_artifact_and_shape_only_requires_contract_update_for_shape(self): + paths = [ + "docs/releases/RELEASE_NOTES_v6_RC5_DRAFT.md", + "scripts/install-docker.sh", + ] + impacted = infer_impacted_subsystems(paths) + entry = impacted["deployment-installability"] + self.assertEqual( + set(entry["cycle_artifact_files"]), + {"docs/releases/RELEASE_NOTES_v6_RC5_DRAFT.md"}, + ) + required = required_contract_updates(paths) + contract_path = "docs/release-control/v6/internal/subsystems/deployment-installability.md" + self.assertIn(contract_path, required) + # The contract update requirement carries the non-artifact file only. + self.assertEqual( + required[contract_path]["touched_runtime_files"], + ["scripts/install-docker.sh"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/release_control/registry_audit_test.py b/scripts/release_control/registry_audit_test.py index 06f239753..b074b4ea2 100644 --- a/scripts/release_control/registry_audit_test.py +++ b/scripts/release_control/registry_audit_test.py @@ -11,7 +11,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_accepts_valid_minimal_registry(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -55,7 +55,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_accepts_cross_repo_owned_prefixes(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -101,7 +101,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_flags_unknown_lane_and_missing_contract(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -130,7 +130,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_flags_explicit_coverage_gap(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -173,7 +173,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_requires_explicit_coverage_flag_true(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -218,7 +218,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_rejects_uncanonical_ordering(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -318,7 +318,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_rejects_fully_shadowed_path_policy(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -371,7 +371,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_requires_declared_shared_ownership(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [], "subsystems": [ { @@ -441,7 +441,7 @@ class RegistryAuditTest(unittest.TestCase): def test_audit_registry_payload_rejects_stale_or_wrong_shared_ownership(self) -> None: payload = { - "version": 12, + "version": 13, "shared_ownerships": [ { "path": "internal/api/resources.go",