Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/scripts/tests/test_version_plugins.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 18 additions & 6 deletions .github/scripts/version-plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)

Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down