Skip to content

Commit 2fa673e

Browse files
authored
Merge pull request #2 from osodevops/production-readiness-v0.1.3
Production-ready Keito API v2 CLI release
2 parents 195cd92 + 5ae06e5 commit 2fa673e

43 files changed

Lines changed: 2702 additions & 548 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
#!/usr/bin/env python3
2+
"""Validate the package version used by release CI workflows."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import re
9+
import subprocess
10+
import sys
11+
import tomllib
12+
from pathlib import Path
13+
from typing import Any
14+
15+
16+
def fail(message: str) -> None:
17+
print(f"::error::{message}", file=sys.stderr)
18+
raise SystemExit(1)
19+
20+
21+
def parse_toml(text: str, source: str) -> dict[str, Any]:
22+
try:
23+
return tomllib.loads(text)
24+
except tomllib.TOMLDecodeError as exc:
25+
fail(f"Could not parse {source}: {exc}")
26+
27+
28+
def load_package_version(text: str, source: str) -> str:
29+
manifest = parse_toml(text, source)
30+
31+
try:
32+
version = manifest["package"]["version"]
33+
except KeyError:
34+
try:
35+
version = manifest["workspace"]["package"]["version"]
36+
except KeyError as exc:
37+
fail(f"Could not read package version from {source}: {exc}")
38+
39+
if not isinstance(version, str):
40+
fail(f"Package version in {source} must be a string.")
41+
42+
return version
43+
44+
45+
def load_head_version() -> str:
46+
return load_package_version(Path("Cargo.toml").read_text(), "Cargo.toml")
47+
48+
49+
def load_base_version(base_ref: str) -> str:
50+
try:
51+
text = subprocess.check_output(
52+
["git", "show", f"{base_ref}:Cargo.toml"],
53+
text=True,
54+
stderr=subprocess.PIPE,
55+
)
56+
except subprocess.CalledProcessError as exc:
57+
fail(f"Could not read Cargo.toml at {base_ref}: {exc.stderr.strip()}")
58+
59+
return load_package_version(text, f"{base_ref}:Cargo.toml")
60+
61+
62+
def load_workspace_package_names() -> set[str]:
63+
manifest_path = Path("Cargo.toml")
64+
manifest = parse_toml(manifest_path.read_text(), str(manifest_path))
65+
66+
names: set[str] = set()
67+
package = manifest.get("package")
68+
if isinstance(package, dict) and isinstance(package.get("name"), str):
69+
names.add(package["name"])
70+
71+
workspace = manifest.get("workspace")
72+
if isinstance(workspace, dict):
73+
for member in workspace.get("members", []):
74+
member_manifest_path = Path(member) / "Cargo.toml"
75+
try:
76+
member_manifest = parse_toml(
77+
member_manifest_path.read_text(), str(member_manifest_path)
78+
)
79+
member_package = member_manifest["package"]
80+
member_name = member_package["name"]
81+
except (OSError, KeyError, TypeError) as exc:
82+
fail(f"Could not read package name from {member_manifest_path}: {exc}")
83+
84+
if not isinstance(member_name, str):
85+
fail(f"Package name in {member_manifest_path} must be a string.")
86+
names.add(member_name)
87+
88+
if not names:
89+
fail("No package names found in Cargo.toml.")
90+
91+
return names
92+
93+
94+
def parse_semver(version: str) -> tuple[int, int, int, str | None]:
95+
match = re.fullmatch(
96+
r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
97+
r"(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?",
98+
version,
99+
)
100+
if not match:
101+
fail(f"Invalid Cargo SemVer version: {version}")
102+
103+
major, minor, patch, prerelease = match.groups()
104+
return int(major), int(minor), int(patch), prerelease
105+
106+
107+
def compare_prerelease(left: str | None, right: str | None) -> int:
108+
if left == right:
109+
return 0
110+
if left is None:
111+
return 1
112+
if right is None:
113+
return -1
114+
115+
left_parts = left.split(".")
116+
right_parts = right.split(".")
117+
for left_part, right_part in zip(left_parts, right_parts):
118+
if left_part == right_part:
119+
continue
120+
121+
left_numeric = left_part.isdigit()
122+
right_numeric = right_part.isdigit()
123+
if left_numeric and right_numeric:
124+
return 1 if int(left_part) > int(right_part) else -1
125+
if left_numeric:
126+
return -1
127+
if right_numeric:
128+
return 1
129+
return 1 if left_part > right_part else -1
130+
131+
if len(left_parts) == len(right_parts):
132+
return 0
133+
return 1 if len(left_parts) > len(right_parts) else -1
134+
135+
136+
def compare_semver(left: str, right: str) -> int:
137+
left_major, left_minor, left_patch, left_pre = parse_semver(left)
138+
right_major, right_minor, right_patch, right_pre = parse_semver(right)
139+
140+
left_core = (left_major, left_minor, left_patch)
141+
right_core = (right_major, right_minor, right_patch)
142+
if left_core != right_core:
143+
return 1 if left_core > right_core else -1
144+
145+
return compare_prerelease(left_pre, right_pre)
146+
147+
148+
def verify_lockfile_version(version: str) -> None:
149+
try:
150+
lockfile = tomllib.loads(Path("Cargo.lock").read_text())
151+
except (OSError, tomllib.TOMLDecodeError) as exc:
152+
fail(f"Could not read Cargo.lock: {exc}")
153+
154+
package_names = load_workspace_package_names()
155+
mismatches: list[str] = []
156+
for package in lockfile.get("package", []):
157+
name = package.get("name")
158+
package_version = package.get("version")
159+
if name in package_names and package_version != version:
160+
mismatches.append(f"{name} is {package_version}")
161+
162+
if mismatches:
163+
fail(
164+
"Cargo.lock is not in sync with Cargo.toml version "
165+
f"{version}: {', '.join(mismatches)}"
166+
)
167+
168+
169+
def verify_changelog_version(version: str) -> None:
170+
try:
171+
changelog = Path("CHANGELOG.md").read_text()
172+
except OSError as exc:
173+
fail(f"Could not read CHANGELOG.md: {exc}")
174+
175+
heading = re.compile(rf"^## \[{re.escape(version)}\]", re.MULTILINE)
176+
if not heading.search(changelog):
177+
fail(f"CHANGELOG.md is missing a ## [{version}] release section.")
178+
179+
180+
def tag_exists(tag: str) -> bool:
181+
return (
182+
subprocess.run(
183+
["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"],
184+
stdout=subprocess.DEVNULL,
185+
stderr=subprocess.DEVNULL,
186+
check=False,
187+
).returncode
188+
== 0
189+
)
190+
191+
192+
def write_env(path: str | None, values: dict[str, str]) -> None:
193+
if not path:
194+
return
195+
196+
with open(path, "a", encoding="utf-8") as env_file:
197+
for key, value in values.items():
198+
env_file.write(f"{key}={value}\n")
199+
200+
201+
def main() -> None:
202+
parser = argparse.ArgumentParser()
203+
parser.add_argument("--base-ref", required=True)
204+
parser.add_argument("--mode", choices=("guard", "tagger"), required=True)
205+
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
206+
parser.add_argument("--github-env", default=os.environ.get("GITHUB_ENV"))
207+
args = parser.parse_args()
208+
209+
base_version = load_base_version(args.base_ref)
210+
head_version = load_head_version()
211+
version_changed = head_version != base_version
212+
tag = f"v{head_version}"
213+
214+
write_env(
215+
args.github_env,
216+
{
217+
"BASE_VERSION": base_version,
218+
"HEAD_VERSION": head_version,
219+
"RELEASE_TAG": tag,
220+
"VERSION_CHANGED": "true" if version_changed else "false",
221+
},
222+
)
223+
224+
if args.mode == "tagger" and not version_changed:
225+
print(f"Package version is still {head_version}; no release tag required.")
226+
return
227+
228+
if compare_semver(head_version, base_version) <= 0:
229+
fail(
230+
"Release-impacting changes require Cargo.toml version to increase "
231+
f"(base: {base_version}, head: {head_version})."
232+
)
233+
234+
verify_lockfile_version(head_version)
235+
verify_changelog_version(head_version)
236+
237+
if args.mode == "guard" and args.event_name == "pull_request" and tag_exists(tag):
238+
fail(f"Release tag {tag} already exists. Bump Cargo.toml to a fresh version.")
239+
240+
print(f"Release version check passed: {base_version} -> {head_version} ({tag}).")
241+
242+
243+
if __name__ == "__main__":
244+
main()

.github/workflows/auto-tag.yml

Lines changed: 0 additions & 53 deletions
This file was deleted.

0 commit comments

Comments
 (0)