|
8 | 8 | import importlib.util |
9 | 9 | import io |
10 | 10 | import json |
| 11 | +import subprocess |
11 | 12 | import sys |
12 | 13 | import tempfile |
13 | 14 | import unittest |
|
30 | 31 | ) |
31 | 32 |
|
32 | 33 | 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") |
33 | 36 | RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml") |
34 | 37 | REPOSITORY_ROOT = Path(__file__).resolve().parents[2] |
35 | 38 | 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 |
75 | 78 | ) |
76 | 79 |
|
77 | 80 |
|
| 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 | + |
78 | 239 | def load_recovery_for_retry_tests(): |
79 | 240 | loaded = globals().get("recovery") |
80 | 241 | if loaded is not None: |
|
0 commit comments