Skip to content

Commit 7e90d79

Browse files
Enforce monotonic exact SemVer for the shared release-recovery contract
1 parent 6b0b49e commit 7e90d79

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

scripts/ci/test-component-release-recovery.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import importlib.util
99
import io
1010
import json
11+
import subprocess
1112
import sys
1213
import tempfile
1314
import unittest
@@ -30,6 +31,8 @@
3031
)
3132

3233
RECOVERY_SCRIPT = Path(__file__).with_name("component-release-recovery.py")
34+
CONSUMER_CONFORMANCE_SCRIPT = Path(__file__).with_name("release_recovery_consumer_conformance.py")
35+
CONSUMER_CONTRACT_PATH = Path(__file__).with_name("release-recovery-consumer-contract.json")
3336
RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml")
3437
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
3538
RECOVERY_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/release-plan-recovery.yml"
@@ -75,6 +78,164 @@ def github_http_error(status: int, body: bytes = b"error", **headers: str) -> ur
7578
)
7679

7780

81+
class SharedContractVersionGuardTest(unittest.TestCase):
82+
def contract(self, version: str, content_marker: str) -> dict[str, object]:
83+
contract = json.loads(CONSUMER_CONTRACT_PATH.read_text())
84+
contract["version"] = version
85+
contract["cases"][0]["requirement"] += f" ({content_marker})"
86+
return contract
87+
88+
def write_contract(self, root: Path, contract: dict[str, object]) -> Path:
89+
path = root / "scripts/ci/release-recovery-consumer-contract.json"
90+
path.parent.mkdir(parents=True, exist_ok=True)
91+
path.write_text(
92+
json.dumps(
93+
contract,
94+
indent=2,
95+
sort_keys=True,
96+
ensure_ascii=True,
97+
)
98+
+ "\n"
99+
)
100+
return path
101+
102+
def git(self, root: Path, *arguments: str) -> str:
103+
result = subprocess.run(
104+
["git", *arguments],
105+
cwd=root,
106+
check=True,
107+
capture_output=True,
108+
text=True,
109+
)
110+
return result.stdout.strip()
111+
112+
def run_transition(
113+
self,
114+
previous: dict[str, object] | None,
115+
current: dict[str, object],
116+
*,
117+
previous_ref: str | None = None,
118+
) -> subprocess.CompletedProcess[str]:
119+
with tempfile.TemporaryDirectory() as temporary:
120+
root = Path(temporary)
121+
self.git(root, "init", "--quiet")
122+
if previous is None:
123+
(root / "README.md").write_text("contract not adopted\n")
124+
else:
125+
self.write_contract(root, previous)
126+
self.git(root, "add", "--all")
127+
self.git(
128+
root,
129+
"-c",
130+
"user.name=Release Recovery Test",
131+
"-c",
132+
"user.email=release-recovery@example.invalid",
133+
"commit",
134+
"--quiet",
135+
"--message=baseline",
136+
)
137+
baseline = self.git(root, "rev-parse", "HEAD")
138+
contract_path = self.write_contract(root, current)
139+
return subprocess.run(
140+
[
141+
sys.executable,
142+
str(CONSUMER_CONFORMANCE_SCRIPT),
143+
"--contract",
144+
str(contract_path),
145+
"--previous-ref",
146+
previous_ref or baseline,
147+
],
148+
cwd=root,
149+
check=False,
150+
capture_output=True,
151+
text=True,
152+
)
153+
154+
def assert_transition_passes(
155+
self,
156+
previous: dict[str, object] | None,
157+
current: dict[str, object],
158+
) -> None:
159+
result = self.run_transition(previous, current)
160+
self.assertEqual(0, result.returncode, result.stderr)
161+
162+
def assert_transition_fails(
163+
self,
164+
previous: dict[str, object] | None,
165+
current: dict[str, object],
166+
message: str,
167+
*,
168+
previous_ref: str | None = None,
169+
) -> None:
170+
result = self.run_transition(
171+
previous,
172+
current,
173+
previous_ref=previous_ref,
174+
)
175+
self.assertNotEqual(0, result.returncode, result.stdout)
176+
self.assertIn(message, result.stderr)
177+
178+
def test_changed_content_with_unchanged_version_is_rejected(self):
179+
self.assert_transition_fails(
180+
self.contract("1.3.0", "previous"),
181+
self.contract("1.3.0", "current"),
182+
"strictly advancing SemVer version",
183+
)
184+
185+
def test_patch_minor_and_major_advances_are_accepted(self):
186+
for label, current_version in {
187+
"patch": "1.2.4",
188+
"minor": "1.3.0",
189+
"major": "2.0.0",
190+
}.items():
191+
with self.subTest(label=label):
192+
self.assert_transition_passes(
193+
self.contract("1.2.3", "previous"),
194+
self.contract(current_version, "current"),
195+
)
196+
197+
def test_prerelease_advance_is_accepted(self):
198+
self.assert_transition_passes(
199+
self.contract("1.3.0-rc.1", "previous"),
200+
self.contract("1.3.0-rc.2", "current"),
201+
)
202+
203+
def test_downgrade_is_rejected(self):
204+
self.assert_transition_fails(
205+
self.contract("2.0.0", "previous"),
206+
self.contract("1.9.9", "current"),
207+
"strictly advancing SemVer version",
208+
)
209+
210+
def test_build_metadata_only_change_is_rejected(self):
211+
self.assert_transition_fails(
212+
self.contract("1.3.0+previous", "previous"),
213+
self.contract("1.3.0+current", "current"),
214+
"strictly advancing SemVer version",
215+
)
216+
217+
def test_leading_zero_numeric_prerelease_is_rejected(self):
218+
self.assert_transition_fails(
219+
self.contract("1.2.0", "previous"),
220+
self.contract("1.3.0-rc.01", "current"),
221+
"shared contract version must be exact SemVer",
222+
)
223+
224+
def test_first_adoption_without_a_previous_contract_is_accepted(self):
225+
self.assert_transition_passes(
226+
None,
227+
self.contract("1.0.0", "current"),
228+
)
229+
230+
def test_unavailable_previous_commit_is_rejected(self):
231+
self.assert_transition_fails(
232+
self.contract("1.2.0", "previous"),
233+
self.contract("1.3.0", "current"),
234+
"previous contract commit is unavailable",
235+
previous_ref="f" * 40,
236+
)
237+
238+
78239
def load_recovery_for_retry_tests():
79240
loaded = globals().get("recovery")
80241
if loaded is not None:

0 commit comments

Comments
 (0)