Skip to content

Commit 4c9b60f

Browse files
committed
fix release process
1 parent cdaecb2 commit 4c9b60f

3 files changed

Lines changed: 286 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
release:
55
types:
66
- released
7-
- prereleased
87

98
jobs:
109
test-package:
@@ -36,20 +35,32 @@ jobs:
3635
id-token: write
3736
contents: read
3837

38+
env:
39+
RELEASE_TAG: ${{ github.event.release.tag_name }}
40+
PACKAGE_NAME: crowdsec_service_api
41+
OPENAPI_FILE: openapi.json
42+
3943
steps:
4044
- name: Checkout
4145
uses: actions/checkout@v4
46+
4247
- name: Set up Python
4348
uses: actions/setup-python@v5
4449
with:
4550
python-version: '3.11'
51+
52+
- name: Replace version with release tag
53+
run: python scripts/update_version.py "$RELEASE_TAG" "$PACKAGE_NAME" "$OPENAPI_FILE"
54+
4655
- name: Install dependencies
4756
run: |
4857
python -m pip install --upgrade pip
4958
pip install build
59+
5060
- name: Build package
5161
run: python -m build
62+
5263
- name: Publish package to PyPI
5364
uses: pypa/gh-action-pypi-publish@release/v1
5465
with:
55-
package-dir: ./dist
66+
package-dir: ./dist

scripts/test_update_version.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for scripts/update_version.py."""
2+
import importlib
3+
import json
4+
import sys
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
SCRIPTS_DIR = Path(__file__).parent
10+
11+
12+
@pytest.fixture
13+
def uv(monkeypatch, tmp_path):
14+
"""Import update_version fresh with cwd = tmp_path so relative `pyproject.toml` resolves there."""
15+
monkeypatch.chdir(tmp_path)
16+
monkeypatch.syspath_prepend(str(SCRIPTS_DIR))
17+
if "update_version" in sys.modules:
18+
del sys.modules["update_version"]
19+
module = importlib.import_module("update_version")
20+
yield module
21+
22+
23+
PYPROJECT_TEMPLATE = """\
24+
[build-system]
25+
requires = ["setuptools>=61.0"]
26+
27+
[project]
28+
name = "crowdsec_service_api"
29+
version = "{version}"
30+
license = {{ text = "MIT" }}
31+
"""
32+
33+
34+
def _seed(tmp_path, version, *, openapi_pretty=False, service_files=None, package="crowdsec_service_api"):
35+
(tmp_path / "pyproject.toml").write_text(PYPROJECT_TEMPLATE.format(version=version))
36+
37+
spec = {"openapi": "3.1.0", "info": {"title": "T", "version": version}, "paths": {}}
38+
openapi_path = tmp_path / "openapi.json"
39+
if openapi_pretty:
40+
openapi_path.write_text(json.dumps(spec, indent=2))
41+
else:
42+
openapi_path.write_text(json.dumps(spec))
43+
44+
if service_files:
45+
svc_dir = tmp_path / package / "services"
46+
svc_dir.mkdir(parents=True)
47+
for name in service_files:
48+
(svc_dir / name).write_text(
49+
f'class X:\n def __init__(self):\n super().__init__(user_agent="{package}/{version}")\n'
50+
)
51+
return openapi_path
52+
53+
54+
class TestNormalize:
55+
def test_strips_v_prefix(self, uv):
56+
assert uv.normalize("v1.0.0") == "1.0.0"
57+
58+
def test_converts_dash_to_dot(self, uv):
59+
assert uv.normalize("v1.0.0-dev15") == "1.0.0.dev15"
60+
61+
def test_clean_version_unchanged(self, uv):
62+
assert uv.normalize("1.2.3") == "1.2.3"
63+
64+
def test_multiple_dashes(self, uv):
65+
assert uv.normalize("v1.0.0-rc-1") == "1.0.0.rc.1"
66+
67+
68+
class TestReadPyprojectVersion:
69+
def test_extracts_version(self, uv, tmp_path):
70+
_seed(tmp_path, "1.2.3")
71+
assert uv.read_pyproject_version() == "1.2.3"
72+
73+
def test_exits_when_missing(self, uv, tmp_path):
74+
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n")
75+
with pytest.raises(SystemExit):
76+
uv.read_pyproject_version()
77+
78+
79+
class TestUpdatePyproject:
80+
def test_replaces_version(self, uv, tmp_path):
81+
_seed(tmp_path, "1.0.0")
82+
uv.update_pyproject("2.0.0")
83+
assert uv.read_pyproject_version() == "2.0.0"
84+
85+
def test_replaces_only_project_version(self, uv, tmp_path):
86+
(tmp_path / "pyproject.toml").write_text(
87+
'[project]\nversion = "1.0.0"\n\n[tool.other]\nversion = "9.9.9"\n'
88+
)
89+
uv.update_pyproject("2.0.0")
90+
text = (tmp_path / "pyproject.toml").read_text()
91+
assert 'version = "2.0.0"' in text
92+
assert 'version = "9.9.9"' in text
93+
94+
95+
class TestUpdateOpenapi:
96+
def test_preserves_minified(self, uv, tmp_path):
97+
path = _seed(tmp_path, "1.0.0", openapi_pretty=False)
98+
uv.update_openapi(path, "2.0.0")
99+
text = path.read_text()
100+
assert "\n" not in text
101+
assert json.loads(text)["info"]["version"] == "2.0.0"
102+
103+
def test_preserves_pretty(self, uv, tmp_path):
104+
path = _seed(tmp_path, "1.0.0", openapi_pretty=True)
105+
uv.update_openapi(path, "2.0.0")
106+
text = path.read_text()
107+
assert "\n" in text
108+
assert json.loads(text)["info"]["version"] == "2.0.0"
109+
110+
def test_creates_info_if_missing(self, uv, tmp_path):
111+
path = tmp_path / "openapi.json"
112+
path.write_text(json.dumps({"openapi": "3.1.0", "paths": {}}))
113+
uv.update_openapi(path, "2.0.0")
114+
assert json.loads(path.read_text())["info"]["version"] == "2.0.0"
115+
116+
117+
class TestUpdateServiceFiles:
118+
def test_replaces_user_agent_in_all_services(self, uv, tmp_path):
119+
_seed(tmp_path, "1.0.0", service_files=["allowlists.py", "blocklists.py"])
120+
uv.update_service_files("crowdsec_service_api", "1.0.0", "2.0.0")
121+
for name in ("allowlists.py", "blocklists.py"):
122+
text = (tmp_path / "crowdsec_service_api" / "services" / name).read_text()
123+
assert 'user_agent="crowdsec_service_api/2.0.0"' in text
124+
assert 'user_agent="crowdsec_service_api/1.0.0"' not in text
125+
126+
def test_no_services_dir_is_ok(self, uv, tmp_path):
127+
_seed(tmp_path, "1.0.0")
128+
uv.update_service_files("crowdsec_service_api", "1.0.0", "2.0.0")
129+
130+
def test_leaves_unrelated_user_agents_alone(self, uv, tmp_path):
131+
_seed(tmp_path, "1.0.0", service_files=["a.py"])
132+
path = tmp_path / "crowdsec_service_api" / "services" / "a.py"
133+
path.write_text('user_agent="other_pkg/1.0.0"')
134+
uv.update_service_files("crowdsec_service_api", "1.0.0", "2.0.0")
135+
assert path.read_text() == 'user_agent="other_pkg/1.0.0"'
136+
137+
138+
class TestMain:
139+
def _args(self, monkeypatch, *args):
140+
monkeypatch.setattr(sys, "argv", ["update_version.py", *args])
141+
142+
def test_happy_path_updates_everything(self, uv, tmp_path, monkeypatch, capsys):
143+
_seed(tmp_path, "1.0.0", openapi_pretty=True, service_files=["a.py"])
144+
self._args(monkeypatch, "v2.0.0", "crowdsec_service_api", "openapi.json")
145+
uv.main()
146+
assert uv.read_pyproject_version() == "2.0.0"
147+
spec = json.loads((tmp_path / "openapi.json").read_text())
148+
assert spec["info"]["version"] == "2.0.0"
149+
text = (tmp_path / "crowdsec_service_api" / "services" / "a.py").read_text()
150+
assert 'user_agent="crowdsec_service_api/2.0.0"' in text
151+
152+
def test_normalizes_dev_tag(self, uv, tmp_path, monkeypatch):
153+
_seed(tmp_path, "1.0.0", service_files=["a.py"])
154+
self._args(monkeypatch, "v1.0.0-dev15", "crowdsec_service_api", "openapi.json")
155+
uv.main()
156+
assert uv.read_pyproject_version() == "1.0.0.dev15"
157+
text = (tmp_path / "crowdsec_service_api" / "services" / "a.py").read_text()
158+
assert 'user_agent="crowdsec_service_api/1.0.0.dev15"' in text
159+
160+
def test_idempotent_when_version_matches(self, uv, tmp_path, monkeypatch, capsys):
161+
_seed(tmp_path, "1.0.0", openapi_pretty=True, service_files=["a.py"])
162+
svc = tmp_path / "crowdsec_service_api" / "services" / "a.py"
163+
before_pyproject = (tmp_path / "pyproject.toml").read_text()
164+
before_openapi = (tmp_path / "openapi.json").read_text()
165+
before_service = svc.read_text()
166+
167+
self._args(monkeypatch, "v1.0.0", "crowdsec_service_api", "openapi.json")
168+
uv.main()
169+
170+
assert (tmp_path / "pyproject.toml").read_text() == before_pyproject
171+
assert (tmp_path / "openapi.json").read_text() == before_openapi
172+
assert svc.read_text() == before_service
173+
assert "already matches" in capsys.readouterr().out
174+
175+
def test_wrong_arg_count_exits(self, uv, tmp_path, monkeypatch):
176+
_seed(tmp_path, "1.0.0")
177+
self._args(monkeypatch, "v1.0.0")
178+
with pytest.raises(SystemExit):
179+
uv.main()
180+
181+
def test_no_services_dir(self, uv, tmp_path, monkeypatch):
182+
_seed(tmp_path, "1.0.0")
183+
self._args(monkeypatch, "v2.0.0", "crowdsec_service_api", "openapi.json")
184+
uv.main()
185+
assert uv.read_pyproject_version() == "2.0.0"

scripts/update_version.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env python3
2+
"""Replace SDK version in pyproject.toml, OpenAPI JSON, and service files.
3+
4+
Usage: update_version.py <release_tag> <package_name> <openapi_file>
5+
6+
Normalizes the tag to PEP 440 (strips leading `v`, converts `-` to `.`) so
7+
`v1.0.0-dev15` becomes `1.0.0.dev15`. Idempotent: re-running with the same
8+
tag is a no-op.
9+
"""
10+
import json
11+
import re
12+
import sys
13+
from pathlib import Path
14+
15+
PYPROJECT = Path("pyproject.toml")
16+
17+
18+
def normalize(tag: str) -> str:
19+
return tag.lstrip("v").replace("-", ".")
20+
21+
22+
def read_pyproject_version() -> str:
23+
m = re.search(r'(?m)^version\s*=\s*"([^"]+)"', PYPROJECT.read_text())
24+
if not m:
25+
raise SystemExit("Could not find version in pyproject.toml")
26+
return m.group(1)
27+
28+
29+
def update_pyproject(new_version: str) -> None:
30+
text = PYPROJECT.read_text()
31+
text = re.sub(
32+
r'(?m)^version\s*=\s*"[^"]+"',
33+
f'version = "{new_version}"',
34+
text,
35+
count=1,
36+
)
37+
PYPROJECT.write_text(text)
38+
39+
40+
def update_openapi(path: Path, new_version: str) -> None:
41+
text = path.read_text()
42+
spec = json.loads(text)
43+
spec.setdefault("info", {})["version"] = new_version
44+
pretty = "\n" in text.strip()
45+
result = json.dumps(spec, indent=2) if pretty else json.dumps(spec)
46+
path.write_text(result)
47+
48+
49+
def update_service_files(package: str, old_version: str, new_version: str) -> None:
50+
services_dir = Path(package) / "services"
51+
if not services_dir.is_dir():
52+
return
53+
old = f'user_agent="{package}/{old_version}"'
54+
new = f'user_agent="{package}/{new_version}"'
55+
for py in services_dir.glob("*.py"):
56+
text = py.read_text()
57+
if old in text:
58+
py.write_text(text.replace(old, new))
59+
60+
61+
def main() -> None:
62+
if len(sys.argv) != 4:
63+
raise SystemExit(
64+
"Usage: update_version.py <release_tag> <package_name> <openapi_file>"
65+
)
66+
release_tag, package, openapi_file = sys.argv[1:]
67+
new_version = normalize(release_tag)
68+
old_version = read_pyproject_version()
69+
print(f"Release tag: {release_tag}")
70+
print(f"Normalized version: {new_version}")
71+
print(f"Previous pyproject version: {old_version}")
72+
73+
if old_version == new_version:
74+
print("Version already matches — nothing to do.")
75+
return
76+
77+
update_pyproject(new_version)
78+
update_openapi(Path(openapi_file), new_version)
79+
update_service_files(package, old_version, new_version)
80+
81+
print("---")
82+
print(f"pyproject.toml version: {read_pyproject_version()}")
83+
with open(openapi_file) as f:
84+
print(f"openapi version: {json.load(f)['info']['version']}")
85+
86+
87+
if __name__ == "__main__":
88+
main()

0 commit comments

Comments
 (0)