Skip to content

Commit 88cda62

Browse files
committed
ci: add milestone selection logic to release PR workflows
1 parent 42f2c40 commit 88cda62

File tree

3 files changed

+189
-25
lines changed

3 files changed

+189
-25
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import os
7+
import re
8+
import subprocess
9+
import sys
10+
from urllib import error, request
11+
12+
13+
def warn(message: str) -> None:
14+
print(message, file=sys.stderr)
15+
16+
17+
def parse_version(value: str | None) -> tuple[int, int, int] | None:
18+
if not value:
19+
return None
20+
match = re.match(r"^v?(\d+)\.(\d+)(?:\.(\d+))?", value)
21+
if not match:
22+
return None
23+
major = int(match.group(1))
24+
minor = int(match.group(2))
25+
patch = int(match.group(3) or 0)
26+
return major, minor, patch
27+
28+
29+
def latest_tag_version(exclude_version: tuple[int, int, int] | None) -> tuple[int, int, int] | None:
30+
try:
31+
output = subprocess.check_output(["git", "tag", "--list", "v*"], text=True)
32+
except Exception as exc:
33+
warn(f"Milestone assignment skipped (failed to list tags: {exc}).")
34+
return None
35+
versions: list[tuple[int, int, int]] = []
36+
for tag in output.splitlines():
37+
parsed = parse_version(tag)
38+
if not parsed:
39+
continue
40+
if exclude_version and parsed == exclude_version:
41+
continue
42+
versions.append(parsed)
43+
if not versions:
44+
return None
45+
return max(versions)
46+
47+
48+
def classify_bump(
49+
target: tuple[int, int, int] | None,
50+
previous: tuple[int, int, int] | None,
51+
) -> str | None:
52+
if not target or not previous:
53+
return None
54+
if target < previous:
55+
warn("Milestone assignment skipped (release version is behind latest tag).")
56+
return None
57+
if target[0] != previous[0]:
58+
return "major"
59+
if target[1] != previous[1]:
60+
return "minor"
61+
return "patch"
62+
63+
64+
def parse_milestone_title(title: str | None) -> tuple[int, int] | None:
65+
if not title:
66+
return None
67+
match = re.match(r"^(\d+)\.(\d+)\.x$", title)
68+
if not match:
69+
return None
70+
return int(match.group(1)), int(match.group(2))
71+
72+
73+
def fetch_open_milestones(owner: str, repo: str, token: str) -> list[dict]:
74+
url = f"https://api.github.com/repos/{owner}/{repo}/milestones?state=open&per_page=100"
75+
headers = {
76+
"Accept": "application/vnd.github+json",
77+
"Authorization": f"Bearer {token}",
78+
}
79+
req = request.Request(url, headers=headers)
80+
try:
81+
with request.urlopen(req) as response:
82+
return json.load(response)
83+
except error.HTTPError as exc:
84+
warn(f"Milestone assignment skipped (failed to list milestones: {exc.code}).")
85+
except Exception as exc:
86+
warn(f"Milestone assignment skipped (failed to list milestones: {exc}).")
87+
return []
88+
89+
90+
def select_milestone(milestones: list[dict], required_bump: str) -> str | None:
91+
parsed: list[dict] = []
92+
for milestone in milestones:
93+
parsed_title = parse_milestone_title(milestone.get("title"))
94+
if not parsed_title:
95+
continue
96+
parsed.append(
97+
{
98+
"milestone": milestone,
99+
"major": parsed_title[0],
100+
"minor": parsed_title[1],
101+
}
102+
)
103+
104+
parsed.sort(key=lambda entry: (entry["major"], entry["minor"]))
105+
if not parsed:
106+
warn("Milestone assignment skipped (no open milestones matching X.Y.x).")
107+
return None
108+
109+
majors = sorted({entry["major"] for entry in parsed})
110+
current_major = majors[0]
111+
next_major = majors[1] if len(majors) > 1 else None
112+
113+
current_major_entries = [entry for entry in parsed if entry["major"] == current_major]
114+
patch_target = current_major_entries[0]
115+
minor_target = current_major_entries[1] if len(current_major_entries) > 1 else patch_target
116+
117+
major_target = None
118+
if next_major is not None:
119+
next_major_entries = [entry for entry in parsed if entry["major"] == next_major]
120+
if next_major_entries:
121+
major_target = next_major_entries[0]
122+
123+
target_entry = None
124+
if required_bump == "major":
125+
target_entry = major_target
126+
elif required_bump == "minor":
127+
target_entry = minor_target
128+
else:
129+
target_entry = patch_target
130+
131+
if not target_entry:
132+
warn("Milestone assignment skipped (not enough open milestones for selection).")
133+
return None
134+
135+
return target_entry["milestone"].get("title")
136+
137+
138+
def main() -> int:
139+
parser = argparse.ArgumentParser()
140+
parser.add_argument("--version", help="Release version (e.g., 0.6.6).")
141+
parser.add_argument(
142+
"--required-bump",
143+
choices=("major", "minor", "patch"),
144+
help="Override bump type (major/minor/patch).",
145+
)
146+
parser.add_argument("--repo", help="GitHub repository (owner/repo).")
147+
parser.add_argument("--token", help="GitHub token.")
148+
args = parser.parse_args()
149+
150+
required_bump = args.required_bump
151+
if not required_bump:
152+
target_version = parse_version(args.version)
153+
if not target_version:
154+
warn("Milestone assignment skipped (missing or invalid release version).")
155+
return 0
156+
previous_version = latest_tag_version(target_version)
157+
required_bump = classify_bump(target_version, previous_version)
158+
if not required_bump:
159+
warn("Milestone assignment skipped (unable to determine required bump).")
160+
return 0
161+
162+
token = args.token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
163+
if not token:
164+
warn("Milestone assignment skipped (missing GitHub token).")
165+
return 0
166+
167+
repo = args.repo or os.environ.get("GITHUB_REPOSITORY")
168+
if not repo or "/" not in repo:
169+
warn("Milestone assignment skipped (missing repository info).")
170+
return 0
171+
owner, name = repo.split("/", 1)
172+
173+
milestones = fetch_open_milestones(owner, name, token)
174+
if not milestones:
175+
return 0
176+
177+
milestone_title = select_milestone(milestones, required_bump)
178+
if milestone_title:
179+
print(milestone_title)
180+
return 0
181+
182+
183+
if __name__ == "__main__":
184+
sys.exit(main())

.github/workflows/release-pr-update.yml

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,26 +85,16 @@ jobs:
8585
if: steps.find.outputs.found == 'true'
8686
env:
8787
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
88+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8889
PR_NUMBER: ${{ steps.find.outputs.number }}
8990
RELEASE_BRANCH: ${{ steps.find.outputs.branch }}
9091
RELEASE_REVIEW_PATH: ${{ steps.codex-output.outputs.output_file }}
9192
run: |
9293
set -euo pipefail
9394
git push --force-with-lease origin "$RELEASE_BRANCH"
9495
gh pr edit "$PR_NUMBER" --body-file "$RELEASE_REVIEW_PATH"
95-
milestone_name="$(python - <<'PY'
96-
import os
97-
import re
98-
99-
branch = os.environ.get("RELEASE_BRANCH", "")
100-
version = branch.replace("release/v", "", 1)
101-
match = re.match(r"^(\d+)\.(\d+)", version)
102-
if not match:
103-
print("")
104-
else:
105-
print(f"{match.group(1)}.{match.group(2)}.x")
106-
PY
107-
)"
96+
version="${RELEASE_BRANCH#release/v}"
97+
milestone_name="$(python .github/scripts/select-release-milestone.py --version "$version")"
10898
if [ -n "$milestone_name" ]; then
10999
if ! gh pr edit "$PR_NUMBER" --add-label "project" --milestone "$milestone_name"; then
110100
echo "PR label/milestone update failed; continuing without changes." >&2

.github/workflows/release-pr.yml

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,22 +122,12 @@ jobs:
122122
- name: Create or update PR
123123
env:
124124
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
125+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
125126
RELEASE_VERSION: ${{ inputs.version }}
126127
run: |
127128
set -euo pipefail
128129
head_branch="release/v${RELEASE_VERSION}"
129-
milestone_name="$(python - <<'PY'
130-
import os
131-
import re
132-
133-
version = os.environ.get("RELEASE_VERSION", "")
134-
match = re.match(r"^(\d+)\.(\d+)", version)
135-
if not match:
136-
print("")
137-
else:
138-
print(f"{match.group(1)}.{match.group(2)}.x")
139-
PY
140-
)"
130+
milestone_name="$(python .github/scripts/select-release-milestone.py --version "$RELEASE_VERSION")"
141131
pr_number="$(gh pr list --head "$head_branch" --base "main" --json number --jq '.[0].number // empty')"
142132
if [ -z "$pr_number" ]; then
143133
create_args=(

0 commit comments

Comments
 (0)