From 9d04f277b0e800ffcad2f638915ebfb6e78d3c0e Mon Sep 17 00:00:00 2001 From: rhartuv Date: Sun, 4 Oct 2026 10:38:57 +0300 Subject: [PATCH] feat: formalize component lifecycle model and CI validation --- .../compass-manifest-maintenance/SKILL.md | 4 +- .../references/relationship-rules.md | 8 +- CLAUDE.md | 2 +- LIFECYCLE.md | 91 ++++++++ Makefile | 10 +- scripts/test_validate_lifecycle_ceiling.py | 173 ++++++++++++++-- scripts/validate_lifecycle_ceiling.py | 196 +++++++++++++++--- 7 files changed, 425 insertions(+), 59 deletions(-) create mode 100644 LIFECYCLE.md diff --git a/.claude/skills/compass-manifest-maintenance/SKILL.md b/.claude/skills/compass-manifest-maintenance/SKILL.md index a8d69845..0d787361 100644 --- a/.claude/skills/compass-manifest-maintenance/SKILL.md +++ b/.claude/skills/compass-manifest-maintenance/SKILL.md @@ -62,7 +62,7 @@ test -f CLAUDE.md && echo "✓ repo root" || echo "✗ wrong directory" 1. **Resolve** `` and `` — confirm `/skills//SKILL.md` exists. 2. **Read golden sources** (precedence): - - `/-plugin.yaml` — `spec.lifecycle` (default for new skill manifest; see [relationship-rules.md](references/relationship-rules.md) Lifecycle) + - `/-plugin.yaml` — `spec.lifecycle` (default for new skill manifest; see [LIFECYCLE.md](../../../LIFECYCLE.md) and [relationship-rules.md](references/relationship-rules.md) Lifecycle) - `SKILL.md` frontmatter: `name`, `description`, `allowed-tools` - `SKILL.md` body: `Required MCP Servers`, `/skill-name` invocations, Dependencies, validator prerequisites - `/mcps.json` — server keys (map via [mcp-mapping.md](references/mcp-mapping.md)) @@ -77,7 +77,7 @@ test -f CLAUDE.md && echo "✓ repo root" || echo "✗ wrong directory" 4. **Set `spec.lifecycle`** (do not hardcode `beta`): - Read `spec.lifecycle` from `/-plugin.yaml` — use as the **default** for the skill. - **Human in the loop:** ask whether to change it. The skill may match the plugin or use a **less mature** value only (e.g. plugin `beta` → skill `development` is OK; plugin `development` → skill `beta` is **not** allowed). - - See [relationship-rules.md](references/relationship-rules.md) Lifecycle. + - Allowed values: `development`, `beta`, `GA`, `deprecated`, `archived` — see [LIFECYCLE.md](../../../LIFECYCLE.md). 5. **Write** `/skills//catalog-info.yaml` from [assets/skill-catalog-info.yaml](assets/skill-catalog-info.yaml): - `namespace: ai5-marketplace` diff --git a/.claude/skills/compass-manifest-maintenance/references/relationship-rules.md b/.claude/skills/compass-manifest-maintenance/references/relationship-rules.md index 5cd75908..ca40131c 100644 --- a/.claude/skills/compass-manifest-maintenance/references/relationship-rules.md +++ b/.claude/skills/compass-manifest-maintenance/references/relationship-rules.md @@ -33,8 +33,14 @@ All skills, plugins, and owned MCPs use `metadata.namespace: ai5-marketplace`. ## Lifecycle (`spec.lifecycle`) -- **New skill:** copy `spec.lifecycle` from `/-plugin.yaml` (ask before changing). Skill must not exceed plugin maturity (`development` < `beta` < `production`). +Canonical model: [LIFECYCLE.md](../../../../LIFECYCLE.md) at the repository root. + +Allowed values: `development`, `beta`, `GA`, `deprecated`, `archived`. + +- **New skill:** copy `spec.lifecycle` from `/-plugin.yaml` (ask before changing). Skill must not exceed plugin maturity (`development` < `beta` < `GA`). - **New pack:** default plugin to `development`. +- **Retirement:** use `deprecated` then `archived` when sunsetting a component (see LIFECYCLE.md). +- **Distribution:** only `beta` and `GA` with `distribution: external` are published externally; `development`, `deprecated`, and `archived` are always internal-only. ## Files to touch when adding a skill diff --git a/CLAUDE.md b/CLAUDE.md index 7987b134..2c3f4aa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ All entities (skills, plugins, and MCP servers) share a single namespace: `ai5-m #### Adding Compass Manifests for a New Skill -When adding a skill, create `skills//catalog-info.yaml`. Set `spec.lifecycle` from the pack plugin (`/-plugin.yaml`); default to the plugin value and ask the user before changing it — a skill may match the plugin or use a **less mature** lifecycle only (never above the plugin). New packs default the plugin to `development`. +When adding a skill, create `skills//catalog-info.yaml`. Set `spec.lifecycle` from the pack plugin (`/-plugin.yaml`); default to the plugin value and ask the user before changing it — a skill may match the plugin or use a **less mature** lifecycle only (never above the plugin). New packs default the plugin to `development`. Valid values and publication rules: [LIFECYCLE.md](LIFECYCLE.md) (`development` < `beta` < `GA`; plus `deprecated` / `archived`). ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/LIFECYCLE.md b/LIFECYCLE.md new file mode 100644 index 00000000..e6d3fccb --- /dev/null +++ b/LIFECYCLE.md @@ -0,0 +1,91 @@ +# Component Lifecycle Model + +Canonical reference for `spec.lifecycle` on skills, pack plugins, and MCP servers in this repository. + +Compass manifests (`catalog-info.yaml`, `*-plugin.yaml`, `mcps/*.yaml`) declare lifecycle so owners, reviewers, and the catalog publication pipeline share one maturity vocabulary. + +## Valid lifecycle values + +| Value | Meaning | +|-------|---------| +| `development` | In active development, not ready for consumption | +| `beta` | Functional and available for early adoption; may have rough edges | +| `GA` | Generally Available — production-ready, fully supported | +| `deprecated` | Still functional but no longer maintained — consumers should migrate | +| `archived` | End of life — preserved for reference only, not maintained | + +These are the **only** allowed values for `spec.lifecycle`. CI rejects any other string (including the former name `production`; use `GA` instead). + +### Maturity ordering (ceiling) + +For the skill-vs-pack ceiling rule, maturity ranks as: + +```text +development < beta < GA +``` + +`deprecated` and `archived` are retirement states. They are not ranked against the ceiling (see [Ceiling rule](#ceiling-rule)). + +## How owners change lifecycle + +1. Edit `spec.lifecycle` in the component’s Compass manifest: + - Skill: `/skills//catalog-info.yaml` + - Pack plugin: `/-plugin.yaml` + - MCP server: `mcps/.yaml` +2. Open a pull request with the change. +3. Reviewers approve the transition; merge is the governance gate. + +There is **no enforced state machine**. Owners may move between any allowed values (for example `development` → `GA`) when the PR is justified. Prefer gradual promotion (`development` → `beta` → `GA`) and explicit retirement (`GA`/`beta` → `deprecated` → `archived`) when that matches product reality. + +### Defaults when authoring + +- **New pack plugin:** default `development` (confirm before raising maturity). +- **New skill:** copy `spec.lifecycle` from the parent pack plugin. The skill may match the plugin or use a **less mature** value only — never above the plugin (see ceiling rule). + +## Ceiling rule + +A skill’s lifecycle must not exceed its parent pack plugin’s lifecycle: + +```text +skill lifecycle ≤ pack lifecycle +``` + +Examples: + +| Pack | Skill | Result | +|------|-------|--------| +| `beta` | `beta` | Allowed | +| `GA` | `development` | Allowed | +| `development` | `beta` | **Rejected** | +| `beta` | `GA` | **Rejected** | + +Retirement exemptions: if the skill or the pack plugin is `deprecated` or `archived`, the ceiling comparison is skipped for that entity (deprecated/archived plugins skip enforcement for all of their skills). + +Enforced by `scripts/validate_lifecycle_ceiling.py` (`make validate-lifecycle-ceiling`, included in `make validate-structure`). + +## Interaction with `distribution` + +Manifests may set `metadata.labels.distribution` (commonly `external`). Lifecycle and distribution together control external publication: + +| Lifecycle | `distribution: external` | External publication | +|-----------|--------------------------|----------------------| +| `development` | any | **Never** — always internal-only | +| `beta` | `external` | Eligible for external publish | +| `GA` | `external` | Eligible for external publish | +| `deprecated` | any | **Never** — always internal-only | +| `archived` | any | **Never** — always internal-only | + +Notes: + +- Only `beta` and `GA` components with `distribution: external` are published externally. +- `deprecated` and `archived` are always internal-only, regardless of the distribution label. +- `development` is always internal-only. CI emits a **non-blocking warning** when `distribution: external` is paired with `lifecycle: development`, because the label will not cause external publication. + +## Catalog publication pipeline + +The catalog build/publication pipeline (APPENG-6026) uses lifecycle (and distribution) when routing components for internal vs external publication. Lifecycle awareness for that pipeline is tracked under APPENG-6026; this repository’s role is to keep `spec.lifecycle` accurate and CI-valid so the pipeline can trust the field. + +## Related documentation + +- Compass relationship and authoring rules: [`.claude/skills/compass-manifest-maintenance/references/relationship-rules.md`](.claude/skills/compass-manifest-maintenance/references/relationship-rules.md) +- Validator: [`scripts/validate_lifecycle_ceiling.py`](scripts/validate_lifecycle_ceiling.py) diff --git a/Makefile b/Makefile index df395bcb..2740cc39 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ help: @echo " validate-collection-schema - Schema + roster + banners (subset of compliance)" @echo " validate-collection-compliance - Full .catalog compliance (includes collection.json drift)" @echo " validate-compass-manifests - Compass manifests, roster, refs, and skill references/ layout" - @echo " validate-lifecycle-ceiling - Compass lifecycle ceiling (skill <= plugin lifecycle) + unit tests" + @echo " validate-lifecycle-ceiling - Compass lifecycle (allowed values, ceiling, warnings) + unit tests" @echo " validate-skill-design - Validate all skills (use PACK=rh-sre for a specific pack)" @echo " validate-skill-design-changed - Validate only changed skills (staged + unstaged, for local dev)" @echo " validate-mcp-tools - Validate allowed-tools against live MCP servers (requires podman)" @@ -63,9 +63,9 @@ validate: check-uv uv run python scripts/validate_collection_compliance.py || EXIT=1; \ echo "=== Validating Compass manifests..."; \ uv run python scripts/validate_compass_manifests.py || EXIT=1; \ - echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \ + echo "=== Validating Compass lifecycle (allowed values + ceiling)..."; \ uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \ - echo "=== Running lifecycle ceiling unit tests..."; \ + echo "=== Running lifecycle unit tests..."; \ uv run python scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \ echo "=== Validating MCP tool references (skips gracefully without podman)..."; \ uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \ @@ -93,9 +93,9 @@ validate-structure: check-uv uv run python scripts/validate_collection_compliance.py || EXIT=1; \ echo "=== Validating Compass manifests..."; \ uv run python scripts/validate_compass_manifests.py || EXIT=1; \ - echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \ + echo "=== Validating Compass lifecycle (allowed values + ceiling)..."; \ uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \ - echo "=== Running lifecycle ceiling unit tests..."; \ + echo "=== Running lifecycle unit tests..."; \ uv run python scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \ echo "=== Validating MCP tool references (skips gracefully without podman)..."; \ uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \ diff --git a/scripts/test_validate_lifecycle_ceiling.py b/scripts/test_validate_lifecycle_ceiling.py index 56b40aa6..8007d224 100644 --- a/scripts/test_validate_lifecycle_ceiling.py +++ b/scripts/test_validate_lifecycle_ceiling.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unit tests for the Compass lifecycle ceiling validator.""" +"""Unit tests for the Compass lifecycle validator.""" from __future__ import annotations @@ -27,7 +27,14 @@ def _load_module(name: str, filename: str): lifecycle_ceiling = _load_module("validate_lifecycle_ceiling", "validate_lifecycle_ceiling.py") -def _write_manifest(path: Path, *, name: str, kind: str = "AiResource", lifecycle: str | None = "__unset__") -> None: +def _write_manifest( + path: Path, + *, + name: str, + kind: str = "AiResource", + lifecycle: str | None = "__unset__", + distribution: str | None = None, +) -> None: """Write a minimal Compass manifest. lifecycle='__unset__' omits the field entirely.""" data: dict = { "apiVersion": "backstage.io/v1alpha1", @@ -35,6 +42,8 @@ def _write_manifest(path: Path, *, name: str, kind: str = "AiResource", lifecycl "metadata": {"name": name}, "spec": {}, } + if distribution is not None: + data["metadata"]["labels"] = {"distribution": distribution} if lifecycle != "__unset__": data["spec"]["lifecycle"] = lifecycle path.parent.mkdir(parents=True, exist_ok=True) @@ -56,21 +65,48 @@ def _write_root_catalog(root: Path, packs: list[str]) -> None: ) +def _write_mcp(root: Path, name: str, *, lifecycle: str, distribution: str | None = None) -> None: + path = root / "mcps" / f"{name}.yaml" + _write_manifest( + path, + name=name, + kind="MCPServer", + lifecycle=lifecycle, + distribution=distribution, + ) + catalog = root / "mcps" / "catalog-info.yaml" + data = yaml.safe_load(catalog.read_text(encoding="utf-8")) + targets = data.setdefault("spec", {}).setdefault("targets", []) + target = f"./{name}.yaml" + if target not in targets: + targets.append(target) + catalog.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + def _write_pack( root: Path, pack: str, *, plugin_lifecycle: str | None = "__unset__", skills: dict[str, str | None] | None = None, + plugin_distribution: str | None = None, + skill_distributions: dict[str, str] | None = None, ) -> None: """Create /-plugin.yaml and /skills//catalog-info.yaml files.""" pack_dir = root / pack - _write_manifest(pack_dir / f"{pack}-plugin.yaml", name=pack, lifecycle=plugin_lifecycle) + _write_manifest( + pack_dir / f"{pack}-plugin.yaml", + name=pack, + lifecycle=plugin_lifecycle, + distribution=plugin_distribution, + ) for skill_name, skill_lifecycle in (skills or {}).items(): + dist = (skill_distributions or {}).get(skill_name) _write_manifest( pack_dir / "skills" / skill_name / "catalog-info.yaml", name=skill_name, lifecycle=skill_lifecycle, + distribution=dist, ) (root / pack / "catalog-info.yaml").write_text( yaml.safe_dump({"apiVersion": "backstage.io/v1alpha1", "kind": "Location", "spec": {"targets": []}}), @@ -92,7 +128,11 @@ class TestLifecycleRank(unittest.TestCase): def test_known_lifecycles_ordered(self) -> None: self.assertEqual(lifecycle_ceiling.lifecycle_rank("development"), 0) self.assertEqual(lifecycle_ceiling.lifecycle_rank("beta"), 1) - self.assertEqual(lifecycle_ceiling.lifecycle_rank("production"), 2) + self.assertEqual(lifecycle_ceiling.lifecycle_rank("GA"), 2) + + def test_ga_case_insensitive(self) -> None: + self.assertEqual(lifecycle_ceiling.lifecycle_rank("ga"), 2) + self.assertEqual(lifecycle_ceiling.normalize_lifecycle("Ga"), "GA") def test_missing_lifecycle_defaults_to_development(self) -> None: self.assertEqual( @@ -102,7 +142,9 @@ def test_missing_lifecycle_defaults_to_development(self) -> None: def test_unknown_lifecycle_raises(self) -> None: with self.assertRaises(ValueError): - lifecycle_ceiling.lifecycle_rank("ga") + lifecycle_ceiling.lifecycle_rank("production") + with self.assertRaises(ValueError): + lifecycle_ceiling.lifecycle_rank("preview") def test_is_deprecated(self) -> None: self.assertTrue(lifecycle_ceiling.is_deprecated("deprecated")) @@ -110,6 +152,19 @@ def test_is_deprecated(self) -> None: self.assertFalse(lifecycle_ceiling.is_deprecated("beta")) self.assertFalse(lifecycle_ceiling.is_deprecated(None)) + def test_is_ceiling_exempt(self) -> None: + self.assertTrue(lifecycle_ceiling.is_ceiling_exempt("deprecated")) + self.assertTrue(lifecycle_ceiling.is_ceiling_exempt("archived")) + self.assertFalse(lifecycle_ceiling.is_ceiling_exempt("GA")) + self.assertFalse(lifecycle_ceiling.is_ceiling_exempt("beta")) + + def test_is_allowed_lifecycle(self) -> None: + for value in ("development", "beta", "GA", "deprecated", "archived", "ga"): + self.assertTrue(lifecycle_ceiling.is_allowed_lifecycle(value), value) + self.assertTrue(lifecycle_ceiling.is_allowed_lifecycle(None)) + self.assertFalse(lifecycle_ceiling.is_allowed_lifecycle("production")) + self.assertFalse(lifecycle_ceiling.is_allowed_lifecycle("preview")) + class TestPassingCases(_TempRepoTestCase): def test_skill_equal_to_plugin_passes(self) -> None: @@ -122,7 +177,7 @@ def test_skill_equal_to_plugin_passes(self) -> None: def test_skill_less_mature_than_plugin_passes(self) -> None: _write_pack( - self.repo_root, "rh-demo", plugin_lifecycle="production", skills={"demo-skill": "development"} + self.repo_root, "rh-demo", plugin_lifecycle="GA", skills={"demo-skill": "development"} ) errors: list[str] = [] @@ -169,8 +224,8 @@ def test_skill_more_mature_than_plugin_fails(self) -> None: self.assertIn("'beta'", errors[0]) self.assertIn("'development'", errors[0]) - def test_production_skill_under_beta_plugin_fails(self) -> None: - _write_pack(self.repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "production"}) + def test_ga_skill_under_beta_plugin_fails(self) -> None: + _write_pack(self.repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "GA"}) errors: list[str] = [] lifecycle_ceiling.check_pack(self.repo_root, "rh-demo", errors) @@ -182,7 +237,7 @@ def test_only_offending_skill_is_reported(self) -> None: self.repo_root, "rh-demo", plugin_lifecycle="development", - skills={"ok-skill": "development", "bad-skill": "production"}, + skills={"ok-skill": "development", "bad-skill": "GA"}, ) errors: list[str] = [] @@ -202,12 +257,33 @@ def test_deprecated_skill_is_skipped_even_if_more_mature(self) -> None: self.assertEqual(errors, []) + def test_archived_skill_is_skipped(self) -> None: + _write_pack(self.repo_root, "rh-demo", plugin_lifecycle="development", skills={"demo-skill": "archived"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(self.repo_root, "rh-demo", errors) + + self.assertEqual(errors, []) + def test_deprecated_plugin_skips_all_skills(self) -> None: _write_pack( self.repo_root, "rh-demo", plugin_lifecycle="deprecated", - skills={"demo-skill": "production"}, + skills={"demo-skill": "GA"}, + ) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(self.repo_root, "rh-demo", errors) + + self.assertEqual(errors, []) + + def test_archived_plugin_skips_all_skills(self) -> None: + _write_pack( + self.repo_root, + "rh-demo", + plugin_lifecycle="archived", + skills={"demo-skill": "GA"}, ) errors: list[str] = [] @@ -231,40 +307,105 @@ def test_deprecated_skill_among_others_only_skips_itself(self) -> None: self.assertNotIn("deprecated-skill", errors[0]) +class TestAllowedValuesAndWarnings(_TempRepoTestCase): + def test_invalid_lifecycle_on_skill_is_rejected(self) -> None: + _write_root_catalog(self.repo_root, ["rh-demo"]) + _write_pack(self.repo_root, "rh-demo", plugin_lifecycle="beta", skills={"bad-skill": "production"}) + + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) + + self.assertTrue(any("production" in err for err in errors)) + self.assertEqual(warnings, []) + + def test_invalid_lifecycle_on_mcp_is_rejected(self) -> None: + _write_root_catalog(self.repo_root, []) + _write_mcp(self.repo_root, "demo-mcp", lifecycle="production") + + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) + + self.assertTrue(any("production" in err for err in errors)) + self.assertEqual(warnings, []) + + def test_valid_ga_on_mcp_passes(self) -> None: + _write_root_catalog(self.repo_root, []) + _write_mcp(self.repo_root, "demo-mcp", lifecycle="GA") + + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) + + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_external_development_warns_but_does_not_fail(self) -> None: + _write_root_catalog(self.repo_root, ["rh-demo"]) + _write_pack( + self.repo_root, + "rh-demo", + plugin_lifecycle="development", + skills={"demo-skill": "development"}, + skill_distributions={"demo-skill": "external"}, + ) + + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) + + self.assertEqual(errors, []) + self.assertEqual(len(warnings), 1) + self.assertIn("demo-skill", warnings[0]) + self.assertIn("will not be published externally", warnings[0]) + + def test_external_beta_does_not_warn(self) -> None: + _write_root_catalog(self.repo_root, ["rh-demo"]) + _write_pack( + self.repo_root, + "rh-demo", + plugin_lifecycle="beta", + skills={"demo-skill": "beta"}, + skill_distributions={"demo-skill": "external"}, + ) + + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) + + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + class TestValidateAll(_TempRepoTestCase): def test_validate_all_discovers_registered_packs_from_root_catalog(self) -> None: _write_root_catalog(self.repo_root, ["rh-good", "rh-bad"]) _write_pack(self.repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"}) - _write_pack(self.repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"}) + _write_pack(self.repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "GA"}) - errors = lifecycle_ceiling.validate_all(self.repo_root) + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) self.assertEqual(len(errors), 1) self.assertIn("bad-skill", errors[0]) + self.assertEqual(warnings, []) def test_validate_all_ignores_unregistered_packs(self) -> None: _write_root_catalog(self.repo_root, ["rh-good"]) _write_pack(self.repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"}) - _write_pack(self.repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"}) + _write_pack(self.repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "GA"}) - errors = lifecycle_ceiling.validate_all(self.repo_root) + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) self.assertEqual(errors, []) + self.assertEqual(warnings, []) def test_validate_all_missing_root_catalog_reports_error(self) -> None: - errors = lifecycle_ceiling.validate_all(self.repo_root) + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) self.assertEqual(len(errors), 1) self.assertIn("catalog-info.yaml", errors[0]) + self.assertEqual(warnings, []) def test_pack_missing_plugin_manifest_reports_error(self) -> None: _write_root_catalog(self.repo_root, ["rh-orphan"]) (self.repo_root / "rh-orphan").mkdir(parents=True) - errors = lifecycle_ceiling.validate_all(self.repo_root) + errors, warnings = lifecycle_ceiling.validate_all(self.repo_root) self.assertEqual(len(errors), 1) self.assertIn("rh-orphan", errors[0]) + self.assertEqual(warnings, []) if __name__ == "__main__": diff --git a/scripts/validate_lifecycle_ceiling.py b/scripts/validate_lifecycle_ceiling.py index 0ee3210f..1b979d99 100644 --- a/scripts/validate_lifecycle_ceiling.py +++ b/scripts/validate_lifecycle_ceiling.py @@ -1,16 +1,23 @@ #!/usr/bin/env python3 """ -Validate the Compass "lifecycle ceiling" rule. - -By design, a child skill cannot have a more mature ``spec.lifecycle`` than -its parent plugin (pack). This script enforces that rule in CI: - - - The allowed lifecycle order is: development (0) < beta (1) < production (2). - - Missing lifecycles default to "development". - - Entities with lifecycle "deprecated" (skills or plugins) are skipped — - they are not compared against the ceiling. - - Packs are discovered from the root ``catalog-info.yaml`` ``spec.targets`` - (the same set Compass ingests), mirroring ``validate_compass_manifests.py``. +Validate Compass component lifecycle rules. + +Enforces (APPENG-6307 and lifecycle formalization): + + - Allowed ``spec.lifecycle`` values: + development, beta, GA, deprecated, archived. + - Maturity order for the ceiling rule: + development (0) < beta (1) < GA (2). + ``production`` is not accepted; use ``GA``. + - Ceiling: a child skill cannot be more mature than its parent plugin. + - Missing lifecycles default to "development" for ceiling comparison. + - ``deprecated`` and ``archived`` entities are exempt from the ceiling. + - Non-blocking warning when ``distribution: external`` is paired with + ``lifecycle: development`` (development is never published externally). + +Packs are discovered from the root ``catalog-info.yaml`` ``spec.targets`` +(the same set Compass ingests), mirroring ``validate_compass_manifests.py``. +See ``LIFECYCLE.md`` for the canonical lifecycle model. """ from __future__ import annotations @@ -22,10 +29,14 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent -# Allowed lifecycle maturity order: development < beta < production. -LIFECYCLE_RANK = {"development": 0, "beta": 1, "production": 2} +# Canonical lifecycle values (exact spellings used in manifests). +ALLOWED_LIFECYCLES = ("development", "beta", "GA", "deprecated", "archived") +_ALLOWED_BY_LOWER = {value.lower(): value for value in ALLOWED_LIFECYCLES} + +# Maturity order for ceiling: development < beta < GA. +LIFECYCLE_RANK = {"development": 0, "beta": 1, "GA": 2} DEFAULT_LIFECYCLE = "development" -DEPRECATED_LIFECYCLE = "deprecated" +CEILING_EXEMPT_LIFECYCLES = frozenset({"deprecated", "archived"}) def _load_yaml(path: Path) -> dict: @@ -35,26 +46,52 @@ def _load_yaml(path: Path) -> dict: return data +def canonicalize_lifecycle(lifecycle: str | None) -> str | None: + """ + Map a lifecycle string to its canonical form, or None if empty/missing. + + Matching is case-insensitive so ``ga`` / ``GA`` both become ``GA``. + Unknown values are returned stripped but not rewritten. + """ + if lifecycle is None: + return None + value = str(lifecycle).strip() + if not value: + return None + return _ALLOWED_BY_LOWER.get(value.lower(), value) + + def normalize_lifecycle(lifecycle: str | None) -> str: """Return the effective lifecycle string, defaulting missing values to development.""" - if lifecycle is None: - return DEFAULT_LIFECYCLE - value = str(lifecycle).strip().lower() - return value or DEFAULT_LIFECYCLE + canonical = canonicalize_lifecycle(lifecycle) + return canonical if canonical is not None else DEFAULT_LIFECYCLE + + +def is_allowed_lifecycle(lifecycle: str | None) -> bool: + """Return True when lifecycle is missing (defaults) or a known allowed value.""" + canonical = canonicalize_lifecycle(lifecycle) + if canonical is None: + return True + return canonical in ALLOWED_LIFECYCLES + + +def is_ceiling_exempt(lifecycle: str | None) -> bool: + """Return True when the lifecycle is deprecated or archived (ceiling skipped).""" + return normalize_lifecycle(lifecycle) in CEILING_EXEMPT_LIFECYCLES def is_deprecated(lifecycle: str | None) -> bool: """Return True when the (normalized) lifecycle is 'deprecated'.""" - return normalize_lifecycle(lifecycle) == DEPRECATED_LIFECYCLE + return normalize_lifecycle(lifecycle) == "deprecated" def lifecycle_rank(lifecycle: str | None) -> int: """Map a lifecycle string to its maturity rank (missing -> development).""" value = normalize_lifecycle(lifecycle) if value not in LIFECYCLE_RANK: + allowed = ", ".join(ALLOWED_LIFECYCLES) raise ValueError( - f"unknown lifecycle '{lifecycle}'; expected one of " - f"{sorted(LIFECYCLE_RANK)} or '{DEPRECATED_LIFECYCLE}'" + f"unknown lifecycle '{lifecycle}'; expected one of: {allowed}" ) return LIFECYCLE_RANK[value] @@ -85,6 +122,77 @@ def _skill_manifests(pack_dir: Path) -> list[Path]: return sorted(skills_dir.glob("*/catalog-info.yaml")) +def _mcp_manifests(root: Path) -> list[Path]: + mcps_catalog = root / "mcps" / "catalog-info.yaml" + if not mcps_catalog.is_file(): + return [] + try: + data = _load_yaml(mcps_catalog) + except (OSError, ValueError, yaml.YAMLError): + return [] + manifests: list[Path] = [] + mcps_dir = root / "mcps" + for target in data.get("spec", {}).get("targets", []): + if not isinstance(target, str): + continue + path = (mcps_dir / target).resolve() + if path.is_file(): + manifests.append(path) + return manifests + + +def iter_component_manifests(root: Path) -> list[Path]: + """Return plugin, skill, and MCP manifests for registered packs + mcps Location.""" + paths: list[Path] = [] + for pack in registered_packs(root): + pack_dir = root / pack + plugin_path = pack_dir / f"{pack}-plugin.yaml" + if plugin_path.is_file(): + paths.append(plugin_path) + paths.extend(_skill_manifests(pack_dir)) + paths.extend(_mcp_manifests(root)) + return paths + + +def _distribution_label(data: dict) -> str | None: + labels = data.get("metadata", {}).get("labels") or {} + if not isinstance(labels, dict): + return None + value = labels.get("distribution") + if value is None: + return None + return str(value).strip() or None + + +def check_allowed_lifecycles(root: Path, errors: list[str], warnings: list[str]) -> None: + """Reject unknown lifecycle values; warn on external + development.""" + allowed = ", ".join(ALLOWED_LIFECYCLES) + for manifest in iter_component_manifests(root): + try: + data = _load_yaml(manifest) + except (OSError, ValueError, yaml.YAMLError) as exc: + errors.append(f"{manifest.relative_to(root)}: failed to load ({exc})") + continue + + raw_lifecycle = data.get("spec", {}).get("lifecycle") + if raw_lifecycle is not None and str(raw_lifecycle).strip() != "": + if not is_allowed_lifecycle(raw_lifecycle): + errors.append( + f"{manifest.relative_to(root)}: invalid lifecycle " + f"'{raw_lifecycle}'; expected one of: {allowed}" + ) + continue + + effective = normalize_lifecycle(raw_lifecycle) + if effective == "development" and _distribution_label(data) == "external": + name = data.get("metadata", {}).get("name", manifest.name) + warnings.append( + f"{manifest.relative_to(root)}: component '{name}' has " + f"distribution: external with lifecycle: development — " + f"it will not be published externally" + ) + + def check_pack(root: Path, pack: str, errors: list[str]) -> None: """Validate the lifecycle ceiling for a single pack (skills vs. their plugin).""" pack_dir = root / pack @@ -99,9 +207,14 @@ def check_pack(root: Path, pack: str, errors: list[str]) -> None: errors.append(f"{plugin_path.relative_to(root)}: failed to load ({exc})") return - plugin_lifecycle = normalize_lifecycle(plugin_data.get("spec", {}).get("lifecycle")) - if is_deprecated(plugin_lifecycle): - # Deprecated plugins are exempt — none of their skills are enforced either. + raw_plugin_lifecycle = plugin_data.get("spec", {}).get("lifecycle") + # Invalid values are reported by check_allowed_lifecycles; skip ceiling here. + if not is_allowed_lifecycle(raw_plugin_lifecycle): + return + + plugin_lifecycle = normalize_lifecycle(raw_plugin_lifecycle) + if is_ceiling_exempt(plugin_lifecycle): + # Retired plugins are exempt — none of their skills are enforced either. return try: @@ -118,10 +231,14 @@ def check_pack(root: Path, pack: str, errors: list[str]) -> None: continue skill_name = skill_data.get("metadata", {}).get("name", manifest.parent.name) - skill_lifecycle = normalize_lifecycle(skill_data.get("spec", {}).get("lifecycle")) + raw_skill_lifecycle = skill_data.get("spec", {}).get("lifecycle") + if not is_allowed_lifecycle(raw_skill_lifecycle): + continue # reported by check_allowed_lifecycles + + skill_lifecycle = normalize_lifecycle(raw_skill_lifecycle) - if is_deprecated(skill_lifecycle): - continue # deprecated skills are exempt from the ceiling check + if is_ceiling_exempt(skill_lifecycle): + continue # retired skills are exempt from the ceiling check try: skill_rank = lifecycle_rank(skill_lifecycle) @@ -137,29 +254,40 @@ def check_pack(root: Path, pack: str, errors: list[str]) -> None: ) -def validate_all(root: Path) -> list[str]: - """Run the lifecycle ceiling check for every pack registered in catalog-info.yaml.""" +def validate_all(root: Path) -> tuple[list[str], list[str]]: + """ + Run lifecycle validation for registered packs and MCP manifests. + + Returns (errors, warnings). Warnings are non-blocking. + """ errors: list[str] = [] + warnings: list[str] = [] root_catalog = root / "catalog-info.yaml" if not root_catalog.is_file(): errors.append(f"missing root catalog Location: {root_catalog}") - return errors + return errors, warnings + check_allowed_lifecycles(root, errors, warnings) for pack in registered_packs(root): check_pack(root, pack, errors) - return errors + return errors, warnings def main() -> int: - errors = validate_all(_REPO_ROOT) + errors, warnings = validate_all(_REPO_ROOT) + + if warnings: + print("Lifecycle validation warnings:") + for warn in warnings: + print(f" ⚠ {warn}") if errors: - print("Lifecycle ceiling validation failed:", file=sys.stderr) + print("Lifecycle validation failed:", file=sys.stderr) for err in errors: print(f" • {err}", file=sys.stderr) return 1 - print("✓ Lifecycle ceiling validation passed") + print("✓ Lifecycle validation passed") return 0