Skip to content

Commit 4ac2db8

Browse files
committed
✨👷‍♂️Add Github action step to check the version tag consistency
1 parent 0f99a84 commit 4ac2db8

4 files changed

Lines changed: 279 additions & 12 deletions

File tree

.github/workflows/python-publish.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,31 @@ jobs:
2828
- name: Run the Tests
2929
run: |
3030
tox -e tests
31+
check_version_tag:
32+
name: Check if the version tag is correct
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Check out Git repository
36+
uses: actions/checkout@v4
37+
- name: Set up Python ${{ matrix.python-version }}
38+
uses: actions/setup-python@v5
39+
with:
40+
python-version: "3.12"
41+
- name: Install dependencies
42+
run: |
43+
python -m pip install --upgrade pip
44+
pip install tox
45+
pip install -r requirements.txt docs/requirements.txt
46+
- name: Build JSON Schemas
47+
run: tox -e generate_json_schemas
48+
env:
49+
TARGET_VERSION: ${{ github.ref_name }}
50+
- name: Check version tag
51+
run: python docs/compatibility/versioning.py --gh-version ${{ github.ref_name }} --gh-token ${{ secrets.GITHUB_TOKEN }}
3152
json_schemas:
3253
name: Generate JSON-Schemas
3354
runs-on: ubuntu-latest
34-
needs: tests
55+
needs: [tests, check_version_tag]
3556
steps:
3657
- name: Check out Git repository
3758
uses: actions/checkout@v4
@@ -78,7 +99,7 @@ jobs:
7899
# This setup is inspired by
79100
# https://github.com/KernelTuner/kernel_tuner/blob/master/.github/workflows/docs-on-release.yml
80101
runs-on: ubuntu-latest
81-
needs: tests
102+
needs: [tests, check_version_tag]
82103
steps:
83104
- uses: actions/checkout@v4
84105
with:

docs/compatibility/versioning.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
This module provides a CLI to check if a version tag has the expected format we expect in the BO4E repository.
3+
"""
4+
5+
import functools
6+
import logging
7+
import re
8+
import subprocess
9+
from typing import ClassVar, Literal, Optional
10+
11+
import click
12+
from bost.pull import get_source_repo
13+
from github.GitRelease import GitRelease
14+
from pydantic import BaseModel, ConfigDict
15+
16+
from .__main__ import compare_bo4e_versions_iteratively
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@functools.total_ordering
22+
class Version(BaseModel):
23+
"""
24+
A class to represent a BO4E version number.
25+
"""
26+
27+
version_pattern_with_rc: ClassVar[re.Pattern] = re.compile(
28+
r"^v(?P<major>\d{6})\.(?P<functional>\d+)\.(?P<technical>\d+)(?:-rc(?P<candidate>\d+))?$"
29+
)
30+
version_pattern: ClassVar[re.Pattern] = re.compile(r"^v(?P<major>\d{6})\.(?P<functional>\d+)\.(?P<technical>\d+)$")
31+
32+
major: int
33+
functional: int
34+
technical: int
35+
candidate: Optional[int] = None
36+
model_config = ConfigDict(frozen=True)
37+
38+
@classmethod
39+
def from_string(cls, version: str, allow_candidate: bool = False) -> "Version":
40+
"""
41+
Parse a version string and return a Version object.
42+
Raises a ValueError if the version string does not match the expected pattern.
43+
Raises a ValueError if allow_candidate is False and the version string contains a candidate version.
44+
"""
45+
if allow_candidate:
46+
pattern = cls.version_pattern_with_rc
47+
else:
48+
pattern = cls.version_pattern
49+
match = pattern.fullmatch(version)
50+
if match is None:
51+
raise ValueError(f"Expected version to match {pattern}, got {version}")
52+
return cls(
53+
major=int(match.group("major")),
54+
functional=int(match.group("functional")),
55+
technical=int(match.group("technical")),
56+
candidate=int(match.group("candidate")) if allow_candidate else None,
57+
)
58+
59+
@property
60+
def tag(self) -> str:
61+
"""
62+
Return the tag name for this version.
63+
"""
64+
return f"v{self.major}.{self.functional}.{self.technical}" + (
65+
f"-rc{self.candidate}" if self.is_candidate() else ""
66+
)
67+
68+
def is_candidate(self) -> bool:
69+
"""
70+
Return True if this version is a candidate version.
71+
"""
72+
return self.candidate is not None
73+
74+
def bumped_major(self, other: "Version") -> bool:
75+
"""
76+
Return True if this version is a major bump from the other version.
77+
"""
78+
return self.major > other.major
79+
80+
def bumped_functional(self, other: "Version") -> bool:
81+
"""
82+
Return True if this version is a functional bump from the other version.
83+
Return False if major bump is detected.
84+
"""
85+
return not self.bumped_major(other) and self.functional > other.functional
86+
87+
def bumped_technical(self, other: "Version") -> bool:
88+
"""
89+
Return True if this version is a technical bump from the other version.
90+
Return False if major or functional bump is detected.
91+
"""
92+
return not self.bumped_functional(other) and self.technical > other.technical
93+
94+
def bumped_candidate(self, other: "Version") -> bool:
95+
"""
96+
Return True if this version is a candidate bump from the other version.
97+
Return False if major, functional or technical bump is detected.
98+
Raises ValueError if one of the versions is not a candidate version.
99+
"""
100+
if not self.is_candidate() or not other.is_candidate():
101+
raise ValueError("Cannot compare candidate versions if one of them is not a candidate.")
102+
return not self.bumped_technical(other) and self.candidate > other.candidate
103+
104+
def __lt__(self, other):
105+
if not isinstance(other, Version):
106+
return NotImplemented
107+
return (
108+
self.major < other.major
109+
or self.functional < other.functional
110+
or self.technical < other.technical
111+
or (self.is_candidate() and (not other.is_candidate() or self.candidate < other.candidate))
112+
)
113+
114+
def __eq__(self, other):
115+
if not isinstance(other, Version):
116+
return NotImplemented
117+
return (
118+
self.major == other.major
119+
and self.functional == other.functional
120+
and self.technical == other.technical
121+
and self.is_candidate() == other.is_candidate()
122+
and (not self.is_candidate() or self.candidate == other.candidate)
123+
)
124+
125+
def __str__(self):
126+
return self.tag
127+
128+
129+
def get_latest_release(gh_token: str | None = None) -> GitRelease:
130+
"""
131+
Get the release from BO4E-python repository which is marked as 'latest'.
132+
"""
133+
repo = get_source_repo(gh_token)
134+
latest_release = repo.get_latest_release()
135+
# Ensure that the latest release is on main branch
136+
if latest_release.target_commitish != "main":
137+
raise ValueError(f"Fatal Error: Latest release {latest_release.tag_name} is not on main branch.")
138+
return latest_release
139+
140+
141+
def determine_commits_ahead_behind(cur_version: Version, base_version: Version) -> tuple[int, int]:
142+
expected_output_pattern = re.compile(r"^\s*(\d+)\s+(\d+)\s*$")
143+
output = subprocess.check_output(f"git rev-list --left-right --count {cur_version}...{base_version}")
144+
match = expected_output_pattern.fullmatch(output.decode())
145+
if match is None:
146+
raise ValueError(f"Expected output to match {expected_output_pattern}, got {output}")
147+
return int(match.group(1)), int(match.group(2))
148+
149+
150+
def check_version_consistent_with_commit_history(
151+
cur_version: Version, latest_version: Version
152+
) -> Literal["behind", "ahead"]:
153+
"""
154+
Check if the current version is consistent with the commit history.
155+
Returns "behind" if the current version is behind the latest version.
156+
Returns "ahead" if the current version is ahead of the latest version.
157+
"""
158+
commits_ahead, commits_behind = determine_commits_ahead_behind(cur_version, latest_version)
159+
if commits_behind == 0:
160+
if cur_version < latest_version:
161+
raise ValueError(
162+
f"Current version is {commits_ahead} commits ahead and 0 commits behind the latest version "
163+
f"but version number has decreased. {cur_version} < {latest_version}"
164+
)
165+
return "ahead"
166+
# commits_behind > 0
167+
if cur_version > latest_version:
168+
raise ValueError(
169+
f"Current version is {commits_ahead} commits ahead and {commits_behind} commits behind the latest version "
170+
f"but version number has increased. {cur_version} > {latest_version}"
171+
)
172+
return "behind"
173+
174+
175+
def compare_work_tree_with_latest_version(gh_version: str, gh_token: str | None = None):
176+
"""
177+
Compare the work tree with the latest release from the BO4E repository.
178+
"""
179+
logger.info("Github Access Token %s", "provided" if gh_token is not None else "not provided")
180+
new_version = Version.from_string(gh_version, allow_candidate=True)
181+
logger.info("Retrieving the latest release version")
182+
latest_release = get_latest_release(gh_token).tag_name
183+
latest_version = Version.from_string(latest_release, allow_candidate=False)
184+
185+
if new_version == latest_version:
186+
logger.info("Version is equal to the latest version %s. Skipping further checks.", latest_version)
187+
return
188+
mode = check_version_consistent_with_commit_history(new_version, latest_version)
189+
if mode == "ahead":
190+
version_ahead = new_version
191+
version_behind = latest_version
192+
else:
193+
version_ahead = latest_version
194+
version_behind = new_version
195+
196+
logger.info("Mode '%s': Comparing versions: %s -> %s", mode, version_behind, version_ahead)
197+
if version_ahead.bumped_major(version_behind):
198+
logger.info("Major version bump detected. No further checks needed.")
199+
return
200+
logger.info("Comparing versions iteratively: %s -> %s", version_behind, version_ahead)
201+
changes_iterables = compare_bo4e_versions_iteratively([version_behind.tag], version_ahead.tag, gh_token=gh_token)
202+
assert len(changes_iterables) == 1, "Internal error: Expected exactly one comparison"
203+
logger.info("Check if functional or technical release bump is needed")
204+
functional_changes = any(changes_iterables[version_behind.tag, version_ahead.tag])
205+
logger.info("%s release bump is needed", "Functional" if functional_changes else "Technical")
206+
207+
if not functional_changes and version_ahead.bumped_functional(version_behind):
208+
raise ValueError(
209+
"Functional version bump detected but no functional changes found. "
210+
"Please bump the technical release count instead of the functional."
211+
)
212+
if functional_changes and not version_ahead.bumped_functional(version_behind):
213+
raise ValueError(
214+
"No functional version bump detected but functional changes found. "
215+
"Please bump the functional release count."
216+
)
217+
218+
219+
@click.command()
220+
@click.option("--gh-version", type=str, required=True, help="The new version to compare the latest release with.")
221+
@click.option(
222+
"--gh-token", type=str, default=None, help="GitHub Access token. This helps to avoid rate limiting errors."
223+
)
224+
def compare_work_tree_with_latest_version_cli(gh_version: str, gh_token: str | None = None):
225+
"""
226+
Check a version tag and compare the work tree with the latest release from the BO4E repository.
227+
Exits with status code 1 iff the version is inconsistent with the commit history or if the detected changes in
228+
the JSON-schemas are inconsistent with the version bump.
229+
"""
230+
try:
231+
compare_work_tree_with_latest_version(gh_version, gh_token)
232+
except Exception as error:
233+
logger.error("An error occurred.", exc_info=error)
234+
raise click.exceptions.Exit(1)
235+
logger.info("All checks passed.")
236+
237+
238+
if __name__ == "__main__":
239+
compare_work_tree_with_latest_version_cli()

docs/requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ Sphinx
77
sphinx_rtd_theme
88
typeguard
99
BO4E-Schema-Tool
10+
click

docs/requirements.txt

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ annotated-types==0.5.0
1111
babel==2.12.1
1212
# via sphinx
1313
bo4e-schema-tool==0.0.7
14-
# via -r requirements.in
14+
# via -r .\docs\requirements.in
1515
certifi==2023.7.22
1616
# via requests
1717
cffi==1.16.0
@@ -21,7 +21,13 @@ cffi==1.16.0
2121
charset-normalizer==2.1.0
2222
# via requests
2323
click==8.1.7
24-
# via bo4e-schema-tool
24+
# via
25+
# -r .\docs\requirements.in
26+
# bo4e-schema-tool
27+
colorama==0.4.6
28+
# via
29+
# click
30+
# sphinx
2531
cryptography==42.0.5
2632
# via pyjwt
2733
deprecated==1.2.14
@@ -35,22 +41,22 @@ idna==3.7
3541
imagesize==1.4.1
3642
# via sphinx
3743
iso3166==2.1.1
38-
# via -r requirements.in
44+
# via -r .\docs\requirements.in
3945
jinja2==3.1.4
4046
# via sphinx
4147
markupsafe==2.1.3
4248
# via jinja2
4349
more-itertools==10.2.0
4450
# via bo4e-schema-tool
4551
networkx==3.3
46-
# via -r requirements.in
52+
# via -r .\docs\requirements.in
4753
packaging==24.0
4854
# via sphinx
4955
pycparser==2.21
5056
# via cffi
5157
pydantic==2.4.2
5258
# via
53-
# -r requirements.in
59+
# -r .\docs\requirements.in
5460
# bo4e-schema-tool
5561
pydantic-core==2.10.1
5662
# via pydantic
@@ -59,22 +65,22 @@ pygithub==2.2.0
5965
pygments==2.16.1
6066
# via sphinx
6167
pyhumps==3.8.0
62-
# via -r requirements.in
68+
# via -r .\docs\requirements.in
6369
pyjwt[crypto]==2.8.0
6470
# via pygithub
6571
pynacl==1.5.0
6672
# via pygithub
6773
requests==2.31.0
6874
# via
69-
# -r requirements.in
75+
# -r .\docs\requirements.in
7076
# bo4e-schema-tool
7177
# pygithub
7278
# sphinx
7379
snowballstemmer==2.2.0
7480
# via sphinx
7581
sphinx==7.3.7
7682
# via
77-
# -r requirements.in
83+
# -r .\docs\requirements.in
7884
# sphinx-rtd-theme
7985
# sphinxcontrib-applehelp
8086
# sphinxcontrib-devhelp
@@ -83,7 +89,7 @@ sphinx==7.3.7
8389
# sphinxcontrib-qthelp
8490
# sphinxcontrib-serializinghtml
8591
sphinx-rtd-theme==2.0.0
86-
# via -r requirements.in
92+
# via -r .\docs\requirements.in
8793
sphinxcontrib-applehelp==1.0.7
8894
# via sphinx
8995
sphinxcontrib-devhelp==1.0.5
@@ -99,7 +105,7 @@ sphinxcontrib-qthelp==1.0.6
99105
sphinxcontrib-serializinghtml==1.1.9
100106
# via sphinx
101107
typeguard==4.2.1
102-
# via -r requirements.in
108+
# via -r .\docs\requirements.in
103109
typing-extensions==4.11.0
104110
# via
105111
# pydantic

0 commit comments

Comments
 (0)