Skip to content

Commit 780ce9f

Browse files
committed
release workflow
1 parent 140ab55 commit 780ce9f

4 files changed

Lines changed: 457 additions & 15 deletions

File tree

.github/release.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#!/usr/bin/env python3
2+
"""Bump xlm-core version, push to main, and create a GitHub release.
3+
4+
Used locally and from .github/workflows/release.yml. The release tag is always
5+
``v`` + the version read from ``src/xlm/version.py`` after the bump, matching
6+
publish.yml / docs-release.yml.
7+
8+
Examples:
9+
python .github/release.py 0.1.4
10+
python .github/release.py 0.1.4 --dry-run
11+
python .github/release.py --publish-only
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import os
18+
import re
19+
import subprocess
20+
import sys
21+
from dataclasses import dataclass
22+
from pathlib import Path
23+
24+
REPO_ROOT = Path(__file__).resolve().parents[1]
25+
VERSION_PY = REPO_ROOT / "src" / "xlm" / "version.py"
26+
TAG_PREFIX = "v"
27+
28+
_VERSION_ENV_KEYS = (
29+
"XLM_CORE_VERSION_MAJOR",
30+
"XLM_CORE_VERSION_MINOR",
31+
"XLM_CORE_VERSION_PATCH",
32+
"XLM_CORE_VERSION_SUFFIX",
33+
)
34+
35+
_VERSION_DEFAULT_PATTERNS = {
36+
"major": re.compile(
37+
r'(_MAJOR = os\.environ\.get\("XLM_CORE_VERSION_MAJOR", ")([^"]*)("\))'
38+
),
39+
"minor": re.compile(
40+
r'(_MINOR = os\.environ\.get\("XLM_CORE_VERSION_MINOR", ")([^"]*)("\))'
41+
),
42+
"patch": re.compile(
43+
r'(_PATCH = os\.environ\.get\("XLM_CORE_VERSION_PATCH", ")([^"]*)("\))'
44+
),
45+
"suffix": re.compile(
46+
r'(_SUFFIX = os\.environ\.get\("XLM_CORE_VERSION_SUFFIX", ")([^"]*)("\))'
47+
),
48+
}
49+
50+
_VERSION_RE = re.compile(
51+
r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?P<suffix>.*)$"
52+
)
53+
54+
55+
@dataclass(frozen=True)
56+
class VersionParts:
57+
major: str
58+
minor: str
59+
patch: str
60+
suffix: str
61+
62+
@property
63+
def version(self) -> str:
64+
return f"{self.major}.{self.minor}.{self.patch}{self.suffix}"
65+
66+
@property
67+
def tag(self) -> str:
68+
return f"{TAG_PREFIX}{self.version}"
69+
70+
71+
def parse_version(version: str) -> VersionParts:
72+
"""Parse a version string using the same shape as publish.yml."""
73+
version = version.strip()
74+
if version.startswith(TAG_PREFIX):
75+
version = version[len(TAG_PREFIX) :]
76+
match = _VERSION_RE.match(version)
77+
if not match:
78+
raise ValueError(
79+
f"Invalid version {version!r}; expected MAJOR.MINOR.PATCH "
80+
f"(optional -suffix, e.g. 0.1.4 or 0.1.4-alpha)"
81+
)
82+
suffix = match.group("suffix")
83+
if suffix and not suffix.startswith("-"):
84+
raise ValueError(
85+
f"Invalid suffix {suffix!r}; pre-release suffix must start with '-' "
86+
f"(e.g. 0.1.4-alpha)"
87+
)
88+
return VersionParts(
89+
major=match.group("major"),
90+
minor=match.group("minor"),
91+
patch=match.group("patch"),
92+
suffix=suffix,
93+
)
94+
95+
96+
def read_version(path: Path = VERSION_PY) -> str:
97+
"""Read VERSION from version.py file defaults (ignore XLM_CORE_VERSION_* env)."""
98+
saved = {k: os.environ.pop(k) for k in _VERSION_ENV_KEYS if k in os.environ}
99+
try:
100+
namespace: dict = {}
101+
exec(path.read_text(encoding="utf-8"), namespace) # noqa: S102
102+
return namespace["VERSION"]
103+
finally:
104+
os.environ.update(saved)
105+
106+
107+
def write_version(parts: VersionParts, path: Path = VERSION_PY) -> None:
108+
"""Update the default values in version.py."""
109+
text = path.read_text(encoding="utf-8")
110+
replacements = {
111+
"major": parts.major,
112+
"minor": parts.minor,
113+
"patch": parts.patch,
114+
"suffix": parts.suffix,
115+
}
116+
for key, value in replacements.items():
117+
pattern = _VERSION_DEFAULT_PATTERNS[key]
118+
if not pattern.search(text):
119+
raise RuntimeError(f"Could not find {key} default in {path}")
120+
text = pattern.sub(rf"\g<1>{value}\3", text, count=1)
121+
path.write_text(text, encoding="utf-8")
122+
123+
124+
def run(cmd: list[str], *, dry_run: bool, cwd: Path = REPO_ROOT) -> None:
125+
display = " ".join(cmd)
126+
if dry_run:
127+
print(f"[dry-run] {display}")
128+
return
129+
subprocess.run(cmd, cwd=cwd, check=True)
130+
131+
132+
def confirm(message: str) -> bool:
133+
try:
134+
answer = input(f"{message} [y/N]: ").strip().lower()
135+
except EOFError:
136+
return False
137+
return answer in ("y", "yes")
138+
139+
140+
def tag_exists(tag: str) -> bool:
141+
result = subprocess.run(
142+
["git", "rev-parse", tag],
143+
cwd=REPO_ROOT,
144+
capture_output=True,
145+
)
146+
return result.returncode == 0
147+
148+
149+
def gh_release_exists(tag: str) -> bool:
150+
result = subprocess.run(
151+
["gh", "release", "view", tag],
152+
cwd=REPO_ROOT,
153+
capture_output=True,
154+
)
155+
return result.returncode == 0
156+
157+
158+
def require_clean_tree(dry_run: bool) -> None:
159+
result = subprocess.run(
160+
["git", "status", "--porcelain"],
161+
cwd=REPO_ROOT,
162+
capture_output=True,
163+
text=True,
164+
check=True,
165+
)
166+
if result.stdout.strip():
167+
raise RuntimeError(
168+
"Working tree is not clean. Commit or stash changes before releasing."
169+
)
170+
if dry_run:
171+
print("[dry-run] working tree is clean")
172+
173+
174+
def main(argv: list[str] | None = None) -> int:
175+
parser = argparse.ArgumentParser(
176+
description="Bump version.py, push to main, and publish a GitHub release.",
177+
)
178+
parser.add_argument(
179+
"version",
180+
nargs="?",
181+
help="Release version (e.g. 0.1.4 or v0.1.4-alpha). Omit with --publish-only.",
182+
)
183+
parser.add_argument(
184+
"--publish-only",
185+
action="store_true",
186+
help="Skip bump/commit; create a release for the version in version.py.",
187+
)
188+
parser.add_argument(
189+
"--dry-run",
190+
action="store_true",
191+
help="Print actions without writing files, pushing, or creating a release.",
192+
)
193+
parser.add_argument(
194+
"--skip-push",
195+
action="store_true",
196+
help="Bump version.py and commit locally but do not push.",
197+
)
198+
parser.add_argument(
199+
"--skip-release",
200+
action="store_true",
201+
help="Bump and push only; do not run gh release create.",
202+
)
203+
parser.add_argument(
204+
"--branch",
205+
default="main",
206+
help="Branch to commit and push (default: main).",
207+
)
208+
parser.add_argument(
209+
"--yes",
210+
"-y",
211+
action="store_true",
212+
help="Skip confirmation prompt.",
213+
)
214+
args = parser.parse_args(argv)
215+
216+
in_ci = os.environ.get("GITHUB_ACTIONS") == "true"
217+
if in_ci:
218+
args.yes = True
219+
220+
if args.publish_only:
221+
if args.version:
222+
parser.error("do not pass version with --publish-only")
223+
target = parse_version(read_version())
224+
else:
225+
if not args.version:
226+
parser.error("version is required unless --publish-only is set")
227+
target = parse_version(args.version)
228+
229+
current = parse_version(read_version())
230+
print(f"Current version: {current.version}")
231+
print(f"Target version: {target.version}")
232+
print(f"Release tag: {target.tag}")
233+
234+
if target.tag != current.tag and not args.publish_only:
235+
if not args.yes and not args.dry_run:
236+
if not confirm("Proceed with release?"):
237+
print("Aborted.")
238+
return 1
239+
elif args.publish_only and not args.yes and not args.dry_run:
240+
if not confirm(f"Create GitHub release {target.tag}?"):
241+
print("Aborted.")
242+
return 1
243+
244+
if tag_exists(target.tag) or gh_release_exists(target.tag):
245+
raise RuntimeError(f"Release {target.tag} already exists")
246+
247+
if not args.publish_only:
248+
require_clean_tree(args.dry_run)
249+
write_version(target)
250+
written = read_version()
251+
if written != target.version:
252+
raise RuntimeError(
253+
f"version.py mismatch after write: expected {target.version}, got {written}"
254+
)
255+
print(f"Updated {VERSION_PY.relative_to(REPO_ROOT)}")
256+
257+
commit_msg = f"Release version {target.version}"
258+
run(["git", "add", str(VERSION_PY.relative_to(REPO_ROOT))], dry_run=args.dry_run)
259+
run(["git", "commit", "-m", commit_msg], dry_run=args.dry_run)
260+
261+
if not args.skip_push:
262+
run(["git", "push", "origin", f"HEAD:{args.branch}"], dry_run=args.dry_run)
263+
264+
if not args.skip_release:
265+
gh_cmd = [
266+
"gh",
267+
"release",
268+
"create",
269+
target.tag,
270+
"--title",
271+
target.tag,
272+
"--target",
273+
args.branch,
274+
"--generate-notes",
275+
]
276+
run(gh_cmd, dry_run=args.dry_run)
277+
278+
if args.dry_run:
279+
print("Dry run complete; no changes were pushed and no release was created.")
280+
else:
281+
print(f"Done. Published {target.tag} — PyPI/docs workflows run on release publish.")
282+
return 0
283+
284+
285+
if __name__ == "__main__":
286+
try:
287+
raise SystemExit(main())
288+
except (RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
289+
print(f"error: {exc}", file=sys.stderr)
290+
raise SystemExit(1) from exc

.github/workflows/release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Release xlm-core
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: "Version to release (e.g. 0.1.4 or 0.1.4-alpha)"
8+
required: true
9+
type: string
10+
dry_run:
11+
description: "Dry run (no push or GitHub release)"
12+
required: false
13+
type: boolean
14+
default: false
15+
16+
permissions:
17+
contents: write
18+
19+
jobs:
20+
release:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v4
24+
with:
25+
ref: main
26+
fetch-depth: 0
27+
28+
- uses: actions/setup-python@v5
29+
with:
30+
python-version: "3.11"
31+
32+
- name: Configure git
33+
run: |
34+
git config user.name "github-actions[bot]"
35+
git config user.email "github-actions[bot]@users.noreply.github.com"
36+
37+
- name: Release
38+
env:
39+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40+
run: |
41+
ARGS=("${{ inputs.version }}" --yes)
42+
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
43+
ARGS+=(--dry-run)
44+
fi
45+
python .github/release.py "${ARGS[@]}"

tests/test_github_release.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Tests for .github/release.py (version parse/read/write; no git/gh)."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
import os
7+
import sys
8+
import tempfile
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
_REPO_ROOT = Path(__file__).resolve().parents[1]
14+
_RELEASE_PY = _REPO_ROOT / ".github" / "release.py"
15+
16+
_spec = importlib.util.spec_from_file_location("github_release", _RELEASE_PY)
17+
assert _spec and _spec.loader
18+
release_mod = importlib.util.module_from_spec(_spec)
19+
sys.modules[_spec.name] = release_mod
20+
_spec.loader.exec_module(release_mod)
21+
22+
23+
def test_parse_version():
24+
parts = release_mod.parse_version("0.1.4")
25+
assert parts.version == "0.1.4"
26+
assert parts.tag == "v0.1.4"
27+
28+
parts = release_mod.parse_version("v0.2.0-alpha")
29+
assert parts.major == "0"
30+
assert parts.minor == "2"
31+
assert parts.patch == "0"
32+
assert parts.suffix == "-alpha"
33+
assert parts.version == "0.2.0-alpha"
34+
35+
36+
def test_parse_version_rejects_invalid():
37+
with pytest.raises(ValueError):
38+
release_mod.parse_version("not-a-version")
39+
with pytest.raises(ValueError):
40+
release_mod.parse_version("0.1.4alpha")
41+
42+
43+
def test_write_and_read_roundtrip():
44+
with tempfile.TemporaryDirectory() as tmp:
45+
path = Path(tmp) / "version.py"
46+
path.write_text(release_mod.VERSION_PY.read_text(encoding="utf-8"), encoding="utf-8")
47+
target = release_mod.VersionParts("1", "2", "3", "-rc1")
48+
release_mod.write_version(target, path=path)
49+
assert release_mod.read_version(path) == "1.2.3-rc1"
50+
51+
52+
def test_read_version_ignores_env_override():
53+
with tempfile.TemporaryDirectory() as tmp:
54+
path = Path(tmp) / "version.py"
55+
path.write_text(release_mod.VERSION_PY.read_text(encoding="utf-8"), encoding="utf-8")
56+
os.environ["XLM_CORE_VERSION_MAJOR"] = "9"
57+
try:
58+
assert release_mod.read_version(path) == release_mod.read_version(
59+
release_mod.VERSION_PY
60+
)
61+
finally:
62+
os.environ.pop("XLM_CORE_VERSION_MAJOR", None)

0 commit comments

Comments
 (0)