Skip to content

Commit 8001573

Browse files
committed
test(golden): semver gate enforcing major bump on pin modify/delete
Pure-function check() takes a changed-files list + base/head versions and returns (exit_code, message). git interaction lives behind a thin CLI shim that reads git diff --name-status and pulls __version__ from both refs.
1 parent ac2771b commit 8001573

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

tests/golden/check_semver_bump.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Semver gate: require a major version bump when an existing pinned
2+
golden output is modified or deleted.
3+
4+
Usage (CI):
5+
python tests/golden/check_semver_bump.py --base origin/develop --head HEAD
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import re
12+
import subprocess
13+
import sys
14+
from dataclasses import dataclass
15+
from typing import List, Optional, Tuple
16+
17+
EXPECTED_GLOB = re.compile(r"^tests/golden/templates/.+/expected\.(build|package)\.yaml$")
18+
19+
20+
@dataclass(frozen=True)
21+
class Change:
22+
path: str
23+
status: str # "A" added, "M" modified, "D" deleted
24+
25+
26+
def _parse_version(s: str) -> Tuple[int, int, int]:
27+
m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:.*)?", s.strip())
28+
if not m:
29+
raise ValueError(f"unparseable version: {s!r}")
30+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
31+
32+
33+
def _is_corpus_pin(path: str) -> bool:
34+
return bool(EXPECTED_GLOB.match(path))
35+
36+
37+
def check(
38+
changed: List[Change],
39+
base_version: str,
40+
head_version: str,
41+
) -> Tuple[int, str]:
42+
"""Return (exit_code, message)."""
43+
relevant = [c for c in changed if _is_corpus_pin(c.path)]
44+
45+
if not relevant:
46+
return 0, "No corpus pin changes; gate passes."
47+
48+
modifications = [c for c in relevant if c.status == "M"]
49+
deletions = [c for c in relevant if c.status == "D"]
50+
additions = [c for c in relevant if c.status == "A"]
51+
52+
if not (modifications or deletions):
53+
# Additions only — no bump required.
54+
return 0, f"{len(additions)} new pin(s); no version bump required."
55+
56+
base_major, _, _ = _parse_version(base_version)
57+
head_major, _, _ = _parse_version(head_version)
58+
59+
if head_major > base_major:
60+
return 0, "Major version bumped; gate passes."
61+
62+
suggested = f"{base_major + 1}.0.0"
63+
summary_lines = [
64+
"Semver gate FAILED.",
65+
f" base version: {base_version}",
66+
f" head version: {head_version}",
67+
f" required: major bump (suggested {suggested})",
68+
"",
69+
"Reason: an existing pinned golden output was modified or deleted.",
70+
" Modifications:",
71+
*[f" M {c.path}" for c in modifications],
72+
" Deletions:",
73+
*[f" D {c.path}" for c in deletions],
74+
"",
75+
f'To fix: edit samcli/__init__.py and set __version__ = "{suggested}".',
76+
"If the change is intentional, the major bump signals that to consumers.",
77+
"If unintentional, run python tests/golden/update_goldens.py --diff to inspect.",
78+
]
79+
return 1, "\n".join(summary_lines)
80+
81+
82+
def _read_version_at_ref(ref: str) -> str:
83+
"""Read __version__ from samcli/__init__.py at a git ref."""
84+
out = subprocess.check_output(
85+
["git", "show", f"{ref}:samcli/__init__.py"], text=True
86+
)
87+
m = re.search(r'__version__\s*=\s*"([^"]+)"', out)
88+
if not m:
89+
raise RuntimeError(f"cannot find __version__ at {ref}")
90+
return m.group(1)
91+
92+
93+
def _git_changed_files(base: str, head: str) -> List[Change]:
94+
"""Return all files changed between base and head with their git status."""
95+
out = subprocess.check_output(
96+
["git", "diff", "--name-status", f"{base}...{head}"], text=True
97+
)
98+
changes: List[Change] = []
99+
for line in out.splitlines():
100+
if not line.strip():
101+
continue
102+
parts = line.split("\t")
103+
status, path = parts[0], parts[-1]
104+
# Renames present as "R<score>\tOLD\tNEW" — split into D + A for our purposes.
105+
if status.startswith("R") and len(parts) == 3:
106+
old, new = parts[1], parts[2]
107+
changes.append(Change(old, "D"))
108+
changes.append(Change(new, "A"))
109+
else:
110+
changes.append(Change(path, status[0]))
111+
return changes
112+
113+
114+
def main(argv: Optional[List[str]] = None) -> int:
115+
parser = argparse.ArgumentParser()
116+
parser.add_argument("--base", required=True, help="git ref of merge base / target branch")
117+
parser.add_argument("--head", required=True, help="git ref of PR head")
118+
args = parser.parse_args(argv)
119+
120+
changed = _git_changed_files(args.base, args.head)
121+
base_version = _read_version_at_ref(args.base)
122+
head_version = _read_version_at_ref(args.head)
123+
rc, msg = check(changed, base_version, head_version)
124+
print(msg)
125+
return rc
126+
127+
128+
if __name__ == "__main__":
129+
sys.exit(main())
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Unit tests for check_semver_bump.py — the semver-gate logic.
2+
3+
Avoids actual git invocation by passing changed-files lists directly to
4+
the lower-level entrypoint.
5+
"""
6+
7+
import pytest
8+
9+
from tests.golden import check_semver_bump as csb
10+
11+
12+
def test_no_changes_passes():
13+
rc, msg = csb.check(
14+
changed=[],
15+
base_version="1.161.1",
16+
head_version="1.161.1",
17+
)
18+
assert rc == 0
19+
20+
21+
def test_addition_only_does_not_require_bump():
22+
rc, msg = csb.check(
23+
changed=[
24+
csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "A"),
25+
],
26+
base_version="1.161.1",
27+
head_version="1.161.1",
28+
)
29+
assert rc == 0
30+
31+
32+
def test_modification_without_major_bump_fails():
33+
rc, msg = csb.check(
34+
changed=[
35+
csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "M"),
36+
],
37+
base_version="1.161.1",
38+
head_version="1.161.2",
39+
)
40+
assert rc != 0
41+
assert "major" in msg.lower()
42+
assert "2.0.0" in msg # suggested next version
43+
44+
45+
def test_modification_with_major_bump_passes():
46+
rc, msg = csb.check(
47+
changed=[
48+
csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "M"),
49+
],
50+
base_version="1.161.1",
51+
head_version="2.0.0",
52+
)
53+
assert rc == 0
54+
55+
56+
def test_deletion_requires_major_bump():
57+
rc, msg = csb.check(
58+
changed=[
59+
csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "D"),
60+
],
61+
base_version="1.161.1",
62+
head_version="1.162.0",
63+
)
64+
assert rc != 0
65+
66+
67+
def test_rename_treated_as_delete_plus_add():
68+
rc, msg = csb.check(
69+
changed=[
70+
csb.Change("tests/golden/templates/x/old/expected.build.yaml", "D"),
71+
csb.Change("tests/golden/templates/x/new/expected.build.yaml", "A"),
72+
],
73+
base_version="1.161.1",
74+
head_version="1.161.2",
75+
)
76+
# The deletion alone forces major; rename is conservative.
77+
assert rc != 0
78+
79+
80+
def test_changes_outside_corpus_ignored():
81+
rc, msg = csb.check(
82+
changed=[
83+
csb.Change("samcli/lib/foo.py", "M"),
84+
csb.Change("tests/golden/harness.py", "M"),
85+
],
86+
base_version="1.161.1",
87+
head_version="1.161.1",
88+
)
89+
assert rc == 0
90+
91+
92+
def test_template_yaml_changes_ignored_only_expected_yaml_gates():
93+
rc, msg = csb.check(
94+
changed=[
95+
csb.Change("tests/golden/templates/x/case_a/template.yaml", "M"),
96+
],
97+
base_version="1.161.1",
98+
head_version="1.161.1",
99+
)
100+
assert rc == 0

0 commit comments

Comments
 (0)