From 107ec765ec120eb305d1b01fdbf11c0e2453676a Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 27 Mar 2026 18:48:36 -0500 Subject: [PATCH 1/2] Add monthly release preparation workflow --- .github/workflows/prepare-monthly-release.yml | 122 +++++++++ ci/release/prepare_monthly_release.py | 246 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 .github/workflows/prepare-monthly-release.yml create mode 100644 ci/release/prepare_monthly_release.py diff --git a/.github/workflows/prepare-monthly-release.yml b/.github/workflows/prepare-monthly-release.yml new file mode 100644 index 000000000..aaf32ebf3 --- /dev/null +++ b/.github/workflows/prepare-monthly-release.yml @@ -0,0 +1,122 @@ +name: Prepare Monthly Release + +on: + workflow_dispatch: + inputs: + version_override: + description: Optional tag override (for example, v2026.03.1) + required: false + type: string + dry_run: + description: Preview artifacts only without creating the issue or draft release + required: true + default: true + type: boolean + +permissions: + contents: write + issues: write + pull-requests: read + +jobs: + prepare-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Guard non-main release creation + if: ${{ !inputs.dry_run && github.ref_name != 'main' }} + run: | + echo "Non-dry runs must be launched from the main branch." + exit 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Generate release artifacts + id: prep + shell: bash + env: + VERSION_OVERRIDE: ${{ inputs.version_override }} + run: | + args=(--output-dir .release-prep) + if [ -n "$VERSION_OVERRIDE" ]; then + args+=(--version "$VERSION_OVERRIDE") + fi + + python ci/release/prepare_monthly_release.py "${args[@]}" + + python - <<'PY' + import json + import os + from pathlib import Path + + metadata = json.loads(Path('.release-prep/release-metadata.json').read_text()) + summary = Path('.release-prep/summary.txt').read_text() + + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as handle: + handle.write(f"version={metadata['version']}\n") + handle.write(f"previous_tag={metadata['previous_tag'] or ''}\n") + + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a', encoding='utf-8') as handle: + handle.write("## Release Prep Preview\n\n") + handle.write("```text\n") + handle.write(summary) + handle.write("```\n\n") + handle.write("Generated files:\n\n") + handle.write("- `.release-prep/release-issue.md`\n") + handle.write("- `.release-prep/release-notes.md`\n") + PY + + - name: Upload release prep artifacts + uses: actions/upload-artifact@v4 + with: + name: release-prep-${{ steps.prep.outputs.version }} + path: .release-prep/ + + - name: Create release tracking issue + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.prep.outputs.version }} + shell: bash + run: | + existing_number=$(gh issue list \ + --state all \ + --json number,title \ + --jq ".[] | select(.title == \"[Release]: ${VERSION}\") | .number" \ + | head -n 1) + + if [ -n "$existing_number" ]; then + echo "Release issue already exists: #${existing_number}" + else + gh issue create \ + --title "[Release]: ${VERSION}" \ + --assignee philipc2 \ + --label release \ + --label high-priority \ + --body-file .release-prep/release-issue.md + fi + + - name: Create draft GitHub release + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.prep.outputs.version }} + shell: bash + run: | + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "Draft/release ${VERSION} already exists." + else + gh release create "$VERSION" \ + --target main \ + --title "$VERSION" \ + --notes-file .release-prep/release-notes.md \ + --draft + fi diff --git a/ci/release/prepare_monthly_release.py b/ci/release/prepare_monthly_release.py new file mode 100644 index 000000000..6b94d4785 --- /dev/null +++ b/ci/release/prepare_monthly_release.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from collections import Counter +from dataclasses import dataclass +from datetime import date, datetime, timezone +from pathlib import Path + + +VERSION_RE = re.compile(r"^v(?P\d{4})\.(?P\d{2})\.(?P\d+)$") +PR_RE = re.compile(r"\(#(?P\d+)\)") + + +@dataclass(frozen=True) +class VersionTag: + raw: str + year: int + month: int + patch: int + + +@dataclass(frozen=True) +class ReleaseEntry: + sha: str + title: str + author: str + pr_number: str | None + category: str + + +def _run_git(*args: str) -> str: + completed = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _parse_version_tag(tag: str) -> VersionTag | None: + match = VERSION_RE.match(tag) + if not match: + return None + + return VersionTag( + raw=tag, + year=int(match.group("year")), + month=int(match.group("month")), + patch=int(match.group("patch")), + ) + + +def _list_version_tags() -> list[VersionTag]: + raw_tags = _run_git("tag", "--list") + parsed_tags = [] + + for raw_tag in raw_tags.splitlines(): + parsed = _parse_version_tag(raw_tag) + if parsed is not None: + parsed_tags.append(parsed) + + return sorted(parsed_tags, key=lambda tag: (tag.year, tag.month, tag.patch)) + + +def _resolve_today(today_override: str | None) -> date: + if today_override: + return date.fromisoformat(today_override) + + return datetime.now(timezone.utc).date() + + +def _resolve_version(today: date, version_override: str | None) -> str: + if version_override: + if _parse_version_tag(version_override) is None: + raise ValueError( + "Version override must match the release tag format 'vYYYY.MM.PATCH'." + ) + return version_override + + monthly_tags = [ + tag for tag in _list_version_tags() if tag.year == today.year and tag.month == today.month + ] + + next_patch = monthly_tags[-1].patch + 1 if monthly_tags else 0 + return f"v{today.year:04d}.{today.month:02d}.{next_patch}" + + +def _latest_tag() -> str | None: + version_tags = _list_version_tags() + return version_tags[-1].raw if version_tags else None + + +def _categorize_title(title: str) -> str: + lowered = title.lower() + + if any(token in lowered for token in ["doc", "readme", "notebook", "tutorial"]): + return "docs" + if any(token in lowered for token in ["fix", "bug", "correct", "error", "issue", "typo"]): + return "fixes" + if any(token in lowered for token in ["test", "ci", "workflow", "pre-commit"]): + return "testing" + if any(token in lowered for token in ["add", "support", "implement", "introduce", "enable", "new"]): + return "features" + return "maintenance" + + +def _collect_release_entries(previous_tag: str | None) -> list[ReleaseEntry]: + log_range = f"{previous_tag}..HEAD" if previous_tag else "HEAD" + raw_log = _run_git( + "log", + "--first-parent", + "--pretty=format:%H%x1f%s%x1f%an", + log_range, + ) + + entries = [] + for line in raw_log.splitlines(): + sha, title, author = line.split("\x1f") + pr_match = PR_RE.search(title) + entries.append( + ReleaseEntry( + sha=sha, + title=title, + author=author, + pr_number=pr_match.group("number") if pr_match else None, + category=_categorize_title(title), + ) + ) + + return entries + + +def _format_entry(entry: ReleaseEntry) -> str: + if entry.pr_number is not None: + return f"- {entry.title} by {entry.author}" + + return f"- {entry.title} ({entry.sha[:7]}) by {entry.author}" + + +def _build_release_notes(version: str, previous_tag: str | None, entries: list[ReleaseEntry]) -> str: + top_candidates = entries[:5] + contributor_counts = Counter(entry.author for entry in entries) + + lines = [f"## UXarray {version}", ""] + if previous_tag: + lines.append(f"_Changes since {previous_tag}_") + else: + lines.append("_Initial release prep draft_") + lines.extend(["", "### Top Contributions"]) + + if top_candidates: + lines.append("- Curate the highlights below before publishing ✍️") + lines.extend(_format_entry(entry) for entry in top_candidates[:3]) + else: + lines.append("- Add this month's highlights here ✍️") + + lines.extend(["", "### What's Changed"]) + if entries: + lines.extend(_format_entry(entry) for entry in entries) + else: + lines.append("- No merged changes found since the previous release") + + lines.extend(["", "### Contributors"]) + if contributor_counts: + for author, count in contributor_counts.most_common(5): + lines.append(f"- {author} ({count} commits)") + else: + lines.append("- No release entries found") + + return "\n".join(lines) + "\n" + + +def _extract_issue_template_body() -> str: + template_path = Path(".github/ISSUE_TEMPLATE/release_request.md") + raw_text = template_path.read_text() + + if raw_text.startswith("---\n"): + parts = raw_text.split("---\n", 2) + if len(parts) == 3: + return parts[2].lstrip() + + return raw_text + + +def _build_release_issue(version: str, previous_tag: str | None, today: date) -> str: + body = _extract_issue_template_body() + body = body.replace("Date of intended release:", f"Date of intended release: {today.isoformat()}") + + context_lines = [f"Release version: `{version}`"] + if previous_tag: + context_lines.append(f"Previous release tag: `{previous_tag}`") + + context = "\n".join(context_lines) + return f"{context}\n\n{body}".rstrip() + "\n" + + +def _build_summary(version: str, previous_tag: str | None, entries: list[ReleaseEntry]) -> str: + lines = [f"Version: {version}"] + lines.append(f"Previous tag: {previous_tag or 'none'}") + lines.append(f"Commits included: {len(entries)}") + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Prepare local artifacts for the monthly UXarray release workflow." + ) + parser.add_argument("--output-dir", required=True, help="Directory for generated release artifacts.") + parser.add_argument("--version", help="Optional release tag override, e.g. v2026.03.1.") + parser.add_argument("--today", help="Optional ISO date override for local testing.") + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + today = _resolve_today(args.today) + version = _resolve_version(today=today, version_override=args.version) + previous_tag = _latest_tag() + entries = _collect_release_entries(previous_tag=previous_tag) + + release_notes = _build_release_notes(version=version, previous_tag=previous_tag, entries=entries) + release_issue = _build_release_issue(version=version, previous_tag=previous_tag, today=today) + summary = _build_summary(version=version, previous_tag=previous_tag, entries=entries) + + metadata = { + "version": version, + "previous_tag": previous_tag, + "commit_count": len(entries), + "generated_on": today.isoformat(), + } + + (output_dir / "release-notes.md").write_text(release_notes) + (output_dir / "release-issue.md").write_text(release_issue) + (output_dir / "summary.txt").write_text(summary) + (output_dir / "release-metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") + + print(summary, end="") + + +if __name__ == "__main__": + main() From bfdd529a681dc877d516122e58bddbcebebff7e7 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 27 Mar 2026 22:07:06 -0500 Subject: [PATCH 2/2] o format and document a bit --- ci/release/prepare_monthly_release.py | 66 +++++++++++++++++++++------ 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/ci/release/prepare_monthly_release.py b/ci/release/prepare_monthly_release.py index 6b94d4785..ff255a879 100644 --- a/ci/release/prepare_monthly_release.py +++ b/ci/release/prepare_monthly_release.py @@ -1,4 +1,16 @@ #!/usr/bin/env python3 +"""Prepare artifacts for the manual monthly UXarray release flow. + +This helper is used by `.github/workflows/prepare-monthly-release.yml` to: +- compute the next calendar-based release tag +- generate draft release notes +- generate the release issue body from the existing issue template + +It prepares text artifacts only. Publishing the GitHub Release still happens +manually, which then triggers the existing PyPI publish workflow. Conda-forge +steps remain manual. +""" + from __future__ import annotations import argparse @@ -10,7 +22,6 @@ from datetime import date, datetime, timezone from pathlib import Path - VERSION_RE = re.compile(r"^v(?P\d{4})\.(?P\d{2})\.(?P\d+)$") PR_RE = re.compile(r"\(#(?P\d+)\)") @@ -75,6 +86,7 @@ def _resolve_today(today_override: str | None) -> date: def _resolve_version(today: date, version_override: str | None) -> str: + """Return the next release tag for the current month or an explicit override.""" if version_override: if _parse_version_tag(version_override) is None: raise ValueError( @@ -83,7 +95,9 @@ def _resolve_version(today: date, version_override: str | None) -> str: return version_override monthly_tags = [ - tag for tag in _list_version_tags() if tag.year == today.year and tag.month == today.month + tag + for tag in _list_version_tags() + if tag.year == today.year and tag.month == today.month ] next_patch = monthly_tags[-1].patch + 1 if monthly_tags else 0 @@ -100,11 +114,17 @@ def _categorize_title(title: str) -> str: if any(token in lowered for token in ["doc", "readme", "notebook", "tutorial"]): return "docs" - if any(token in lowered for token in ["fix", "bug", "correct", "error", "issue", "typo"]): + if any( + token in lowered + for token in ["fix", "bug", "correct", "error", "issue", "typo"] + ): return "fixes" if any(token in lowered for token in ["test", "ci", "workflow", "pre-commit"]): return "testing" - if any(token in lowered for token in ["add", "support", "implement", "introduce", "enable", "new"]): + if any( + token in lowered + for token in ["add", "support", "implement", "introduce", "enable", "new"] + ): return "features" return "maintenance" @@ -142,7 +162,10 @@ def _format_entry(entry: ReleaseEntry) -> str: return f"- {entry.title} ({entry.sha[:7]}) by {entry.author}" -def _build_release_notes(version: str, previous_tag: str | None, entries: list[ReleaseEntry]) -> str: +def _build_release_notes( + version: str, previous_tag: str | None, entries: list[ReleaseEntry] +) -> str: + """Build editable release notes with highlights first and a flat change list.""" top_candidates = entries[:5] contributor_counts = Counter(entry.author for entry in entries) @@ -188,8 +211,11 @@ def _extract_issue_template_body() -> str: def _build_release_issue(version: str, previous_tag: str | None, today: date) -> str: + """Build the release tracking issue body from the repository template.""" body = _extract_issue_template_body() - body = body.replace("Date of intended release:", f"Date of intended release: {today.isoformat()}") + body = body.replace( + "Date of intended release:", f"Date of intended release: {today.isoformat()}" + ) context_lines = [f"Release version: `{version}`"] if previous_tag: @@ -199,7 +225,9 @@ def _build_release_issue(version: str, previous_tag: str | None, today: date) -> return f"{context}\n\n{body}".rstrip() + "\n" -def _build_summary(version: str, previous_tag: str | None, entries: list[ReleaseEntry]) -> str: +def _build_summary( + version: str, previous_tag: str | None, entries: list[ReleaseEntry] +) -> str: lines = [f"Version: {version}"] lines.append(f"Previous tag: {previous_tag or 'none'}") lines.append(f"Commits included: {len(entries)}") @@ -210,8 +238,12 @@ def main() -> None: parser = argparse.ArgumentParser( description="Prepare local artifacts for the monthly UXarray release workflow." ) - parser.add_argument("--output-dir", required=True, help="Directory for generated release artifacts.") - parser.add_argument("--version", help="Optional release tag override, e.g. v2026.03.1.") + parser.add_argument( + "--output-dir", required=True, help="Directory for generated release artifacts." + ) + parser.add_argument( + "--version", help="Optional release tag override, e.g. v2026.03.1." + ) parser.add_argument("--today", help="Optional ISO date override for local testing.") args = parser.parse_args() @@ -223,9 +255,15 @@ def main() -> None: previous_tag = _latest_tag() entries = _collect_release_entries(previous_tag=previous_tag) - release_notes = _build_release_notes(version=version, previous_tag=previous_tag, entries=entries) - release_issue = _build_release_issue(version=version, previous_tag=previous_tag, today=today) - summary = _build_summary(version=version, previous_tag=previous_tag, entries=entries) + release_notes = _build_release_notes( + version=version, previous_tag=previous_tag, entries=entries + ) + release_issue = _build_release_issue( + version=version, previous_tag=previous_tag, today=today + ) + summary = _build_summary( + version=version, previous_tag=previous_tag, entries=entries + ) metadata = { "version": version, @@ -237,7 +275,9 @@ def main() -> None: (output_dir / "release-notes.md").write_text(release_notes) (output_dir / "release-issue.md").write_text(release_issue) (output_dir / "summary.txt").write_text(summary) - (output_dir / "release-metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") + (output_dir / "release-metadata.json").write_text( + json.dumps(metadata, indent=2) + "\n" + ) print(summary, end="")