diff --git a/.github/scripts/tests/test_version_plugins.py b/.github/scripts/tests/test_version_plugins.py new file mode 100644 index 00000000..8d6c6212 --- /dev/null +++ b/.github/scripts/tests/test_version_plugins.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for version-plugins.py decision logic. + +Focuses on the `decide`/`build_plan` layer, which is pure and needs no git +or worktree access. The module file carries a hyphen, so it is loaded via +importlib rather than a plain `import`. +""" + +import importlib.util +import sys +import unittest +from pathlib import Path + +_SCRIPTS = Path(__file__).resolve().parent.parent +_spec = importlib.util.spec_from_file_location("version_plugins", _SCRIPTS / "version-plugins.py") +vp = importlib.util.module_from_spec(_spec) +sys.modules["version_plugins"] = vp +_spec.loader.exec_module(vp) + + +def analysis(**kw): + defaults = dict( + name="foo", + yaml_path=Path("plugins.d/foo.yml"), + base_version=vp.SemVer.parse("1.0.0"), + head_version=vp.SemVer.parse("1.0.0"), + builder_changed_version=False, + change_kind=vp.ChangeKind.NONE, + is_new=False, + structural_reasons=[], + ) + defaults.update(kw) + return vp.PluginAnalysis(**defaults) + + +class TestNewPluginAcceptsInitialVersion(unittest.TestCase): + """A plugin yaml newly introduced at head has no base version to + increase from; its initial version is the builder's stamp and must be + accepted, not reported as a non-monotonic edit.""" + + def test_new_plugin_is_accepted(self): + a = analysis( + is_new=True, + builder_changed_version=True, + change_kind=vp.ChangeKind.STRUCTURAL, + structural_reasons=["plugin yaml is newly introduced"], + ) + verdict, payload = vp.decide(a) + self.assertEqual(verdict, "accept") + self.assertIsNone(payload) + + def test_new_plugin_lands_in_no_ops_not_findings(self): + plan = vp.build_plan( + [analysis( + is_new=True, + builder_changed_version=True, + change_kind=vp.ChangeKind.STRUCTURAL, + structural_reasons=["plugin yaml is newly introduced"], + )] + ) + self.assertEqual(plan.findings, []) + self.assertEqual(len(plan.no_ops), 1) + + +class TestExistingPluginDecisions(unittest.TestCase): + """Pre-existing behavior must not change.""" + + def test_builder_set_version_accepts_when_monotonic(self): + a = analysis( + base_version=vp.SemVer.parse("1.0.0"), + head_version=vp.SemVer.parse("1.1.0"), + builder_changed_version=True, + change_kind=vp.ChangeKind.CONTENT, + ) + verdict, _ = vp.decide(a) + self.assertEqual(verdict, "accept") + + def test_builder_set_version_fails_when_not_increasing(self): + a = analysis( + base_version=vp.SemVer.parse("1.0.0"), + head_version=vp.SemVer.parse("1.0.0"), + builder_changed_version=True, + change_kind=vp.ChangeKind.CONTENT, + ) + verdict, payload = vp.decide(a) + self.assertEqual(verdict, "fail") + self.assertIn("version did not increase", payload) + + def test_major_skip_is_rejected(self): + a = analysis( + base_version=vp.SemVer.parse("1.0.0"), + head_version=vp.SemVer.parse("3.0.0"), + builder_changed_version=True, + change_kind=vp.ChangeKind.CONTENT, + ) + verdict, payload = vp.decide(a) + self.assertEqual(verdict, "fail") + self.assertIn("major version jumped by more than 1", payload) + + def test_no_change_is_noop(self): + verdict, payload = vp.decide(analysis()) + self.assertEqual(verdict, "noop") + self.assertIsNone(payload) + + def test_unbumped_structural_change_auto_bumps_minor(self): + a = analysis(change_kind=vp.ChangeKind.STRUCTURAL, structural_reasons=["skills added: x"]) + verdict, payload = vp.decide(a) + self.assertEqual(verdict, "bump") + self.assertEqual(payload, "1.1.0") + + def test_unbumped_content_change_auto_bumps_patch(self): + a = analysis(change_kind=vp.ChangeKind.CONTENT) + verdict, payload = vp.decide(a) + self.assertEqual(verdict, "bump") + self.assertEqual(payload, "1.0.1") + + +class TestSemVerHelpers(unittest.TestCase): + def test_parse_rejects_prerelease_tags(self): + with self.assertRaises(ValueError): + vp.SemVer.parse("1.0.0-rc1") + + def test_bumped_part(self): + base = vp.SemVer.parse("1.2.3") + self.assertEqual(base.bumped_part(vp.SemVer.parse("1.2.4")), "patch") + self.assertEqual(base.bumped_part(vp.SemVer.parse("1.3.0")), "minor") + self.assertEqual(base.bumped_part(vp.SemVer.parse("2.0.0")), "major") + self.assertIsNone(base.bumped_part(vp.SemVer.parse("1.2.3"))) + self.assertIsNone(base.bumped_part(vp.SemVer.parse("1.2.2"))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/scripts/version-plugins.py b/.github/scripts/version-plugins.py index 00f79f1c..c66fe400 100755 --- a/.github/scripts/version-plugins.py +++ b/.github/scripts/version-plugins.py @@ -409,6 +409,7 @@ class PluginAnalysis: head_version: SemVer builder_changed_version: bool change_kind: str + is_new: bool = False structural_reasons: list[str] = field(default_factory=list) @@ -544,6 +545,7 @@ def analyze_plugin( base_spec = None base_version = head_version # placeholder; classify_change handles None spec builder_changed_version = True # everything is new + is_new = True else: base_spec = _merged_spec(base_defaults, base_plugin_yaml) try: @@ -565,6 +567,7 @@ def analyze_plugin( ) else: builder_changed_version = False + is_new = False head_plugin_dir = PLUGINS_DIR / name head_file_hashes = _hash_plugin_tree(head_plugin_dir) @@ -593,6 +596,7 @@ def analyze_plugin( head_version=head_version, builder_changed_version=builder_changed_version, change_kind=change_kind, + is_new=is_new, structural_reasons=reasons, ) @@ -614,9 +618,16 @@ def decide(analysis: PluginAnalysis) -> tuple[str, str | None]: noop -> nothing changed, nothing to do. bump -> auto-bump (z for content, y for structural). Payload is the new SemVer string. - accept -> builder already changed version; validated OK. + accept -> builder already changed version; validated OK. A newly + introduced plugin is also accepted: there is no prior + version for it to increase from, and `SemVer.parse` in + `analyze_plugin` has already required the initial version + to be a strict MAJOR.MINOR.PATCH. fail -> validation failure; payload is the message. """ + if analysis.is_new: + return "accept", None + if analysis.builder_changed_version: findings = validate_builder_version( analysis.base_version, analysis.head_version @@ -660,11 +671,12 @@ def print_plan(plan: Plan, apply_mode: bool) -> None: if plan.no_ops: print(f"── no-op ({len(plan.no_ops)}) ──") for a in plan.no_ops: - note = ( - "(builder-set version validated)" - if a.builder_changed_version - else "(no payload change)" - ) + if a.is_new: + note = "(new plugin; initial version accepted)" + elif a.builder_changed_version: + note = "(builder-set version validated)" + else: + note = "(no payload change)" print(f" · {a.name} {a.head_version} {note}") if plan.bumps: verb = "applying" if apply_mode else "would apply"