From d21da0afce4d90875de72daa6d001de45ed28aa3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 20:23:30 +0200 Subject: [PATCH] Add Elsa release skill --- .agents/skills/elsa-release/SKILL.md | 218 +++++++++++++++ .../skills/elsa-release/agents/openai.yaml | 4 + .../skills/elsa-release/scripts/release.py | 254 ++++++++++++++++++ .../elsa-release/scripts/release_notes.py | 218 +++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 .agents/skills/elsa-release/SKILL.md create mode 100644 .agents/skills/elsa-release/agents/openai.yaml create mode 100755 .agents/skills/elsa-release/scripts/release.py create mode 100755 .agents/skills/elsa-release/scripts/release_notes.py diff --git a/.agents/skills/elsa-release/SKILL.md b/.agents/skills/elsa-release/SKILL.md new file mode 100644 index 000000000..b88605637 --- /dev/null +++ b/.agents/skills/elsa-release/SKILL.md @@ -0,0 +1,218 @@ +--- +name: elsa-release +description: Release Elsa repositories from GitHub tags. Use when Codex needs to create a preview or stable GitHub release for Elsa Core, Elsa Studio, Elsa Extensions, or any similarly configured Elsa repository where releases are driven by Git tags and GitHub release events; supports curated release notes, retagging an existing RC tag as a stable tag, publishing prereleases without NuGet, publishing stable releases that trigger NuGet, and sequencing downstream Elsa repository releases after packages are available. +--- + +# Elsa Release + +## Overview + +Use this skill to release an Elsa repository by preparing curated release notes, creating or reusing a Git tag, creating the matching GitHub release, and letting the repository's GitHub Actions pipeline build and publish packages. Stable releases are normal GitHub releases; preview releases are GitHub prereleases. + +The bundled helper `scripts/release.py` performs the repeatable release checks and prints the exact Git/GitHub commands before execution. It defaults to dry-run mode. The bundled helper `scripts/release_notes.py` collects commits into a categorized Markdown scaffold for curated release notes. + +## Inputs + +Collect or infer: + +- Repository path or GitHub repo, e.g. `elsa-workflows/elsa-core`. +- Desired release tag, e.g. `3.7.0`. +- Release kind: `stable` or `preview`. +- Source ref, if the desired tag should point at a specific existing ref. For stable-from-RC releases, use the RC tag, e.g. `3.7.0-rc1`. +- Release notes range: previous release tag/ref to desired release tag/ref. For stable releases, compare against the previous stable tag unless the user requests another range. +- Release notes strategy: curated notes by default; generated GitHub notes only when the user explicitly wants the quick path or there is not enough time/context. + +If the user asks for "stable 3.7 from RC1", interpret that as: + +```bash +python3 .agents/skills/elsa-release/scripts/release.py \ + --repo-path /path/to/repo \ + --source-ref 3.7.0-rc1 \ + --tag 3.7.0 \ + --release-kind stable +``` + +## Release Workflow + +1. Inspect the repository's release workflow before acting. + - Confirm it has a `release` trigger. + - Confirm stable vs preview behavior. In Elsa Core, `.github/workflows/packages.yml` publishes to feedz.io for release events and publishes to nuget.org only when `github.event.action == 'published'`. + - Treat draft releases carefully because draft publication can change release event behavior. + +2. Prepare the dry run. + - Run the helper without `--execute`. + - Confirm the source commit, destination tag, remote repository, containing remote branches, and release command. + - For Elsa-style pipelines, the source commit should be reachable from `origin/main` or an `origin/release/*` branch unless the user explicitly accepts the risk. + +3. Prepare curated release notes. + - Generate a scaffold with `scripts/release_notes.py`. + - Rewrite the scaffold into polished, developer-facing release notes before publishing. + - Store Elsa Core release notes under `doc/changelogs/.md` when working inside this repository. + - Pass the curated notes to `scripts/release.py` with `--notes-file`. + +4. Ask for explicit confirmation before live operations. + - Pushing a tag and publishing a GitHub release are production release actions. + - Show the exact source ref, resolved commit, desired tag, release kind, and target GitHub repository. + - Show the release notes file path and compare range. + - Do not use `--execute` until the user confirms. + +5. Execute the release. + - Re-run the same command with `--execute`. + - The helper creates an annotated tag if needed, pushes it, then runs `gh release create`. + - Stable releases are created without `--prerelease` and with `--latest`. + - Preview releases are created with `--prerelease` and `--latest=false`. + +6. Verify GitHub. + - Check `gh release view --repo `. + - Check the repository's Actions tab or `gh run list --repo --workflow --limit 5`. + - Confirm the pipeline started from the `release` event and is using the expected version tag. + +7. Sequence Elsa repositories. + - Release Elsa Core first. + - Wait until packages are available in NuGet and/or feedz.io according to the release kind. + - Update Elsa Studio and Elsa Extensions to consume the newly published Elsa Core package versions using their repository-specific dependency update process. + - Release Elsa Studio and Elsa Extensions with the same skill when they use the same tag-and-GitHub-release pattern. + +## Curated Release Notes + +Recommendation: use curated release notes for stable releases and meaningful previews. GitHub generated notes are useful raw input, but the final release should explain why changes matter to developers consuming Elsa packages. + +Use this structure: + +```markdown +Compare: ... + +--- + +## ๐ŸŒŸ Highlights + +--- + +## โš ๏ธ Breaking changes / upgrade notes + +--- + +## โœจ New features + +### Component or theme + +--- + +## ๐Ÿ”ง Improvements + +--- + +## ๐Ÿ› Fixes + +--- + +## ๐Ÿ”’ Security + +--- + +## ๐Ÿงฉ Developer-facing changes + +--- + +## ๐Ÿงช Tests + +--- + +## ๐Ÿ” CI / Build + +--- + +## ๐Ÿ“ฆ Dependencies + +--- + +## ๐Ÿ“ฆ Full changelog (short) +``` + +Omit empty sections. Put the highest-signal user-facing changes in `Highlights` first, limited to 3-6 bullets. Keep `Full changelog` comprehensive so every commit or PR in the range is represented somewhere. + +Writing rules: + +- Prefer PR titles and labels when available; otherwise use commit subjects. +- Do not paste a flat generated changelog as the final result. +- Group related changes under component-oriented subsection headings when that improves scanning, e.g. `#### Workflows`, `#### Shells`, `#### HTTP`, `#### Persistence`. +- Follow the Elsa `3.7.0-rc1` style: compare line first, `---` separators, `##` category headings with small icons, component prefix before the colon, and a short full changelog at the end. +- For breaking changes, include who is affected and what to do. +- For fixes, explain the observable problem that was corrected, not only the implementation detail. +- For dependency/package changes, include package names and versions when available. +- End bullets with a PR number or short SHA when available, e.g. `(#7400)` or `(b88af1e02)`. +- Never invent PR numbers, affected components, migration steps, or known issues. + +Generate a scaffold: + +```bash +python3 .agents/skills/elsa-release/scripts/release_notes.py \ + --repo-path . \ + --from-ref 3.6.2 \ + --to-ref 3.7.0 \ + --version 3.7.0 \ + --output doc/changelogs/3.7.0.md +``` + +Then edit the scaffold into polished notes and release with: + +```bash +python3 .agents/skills/elsa-release/scripts/release.py \ + --repo-path . \ + --source-ref origin/release/3.7.0 \ + --tag 3.7.0 \ + --release-kind stable \ + --notes-file doc/changelogs/3.7.0.md +``` + +If the GitHub release already exists and only the notes need improvement, update it with: + +```bash +gh release edit 3.7.0 \ + --repo elsa-workflows/elsa-core \ + --notes-file doc/changelogs/3.7.0.md +``` + +## Helper Usage + +Dry run a stable release by retagging an RC: + +```bash +python3 .agents/skills/elsa-release/scripts/release.py \ + --repo-path . \ + --source-ref 3.7.0-rc1 \ + --tag 3.7.0 \ + --release-kind stable +``` + +Execute after confirmation: + +```bash +python3 .agents/skills/elsa-release/scripts/release.py \ + --repo-path . \ + --source-ref 3.7.0-rc1 \ + --tag 3.7.0 \ + --release-kind stable \ + --execute +``` + +Dry run a preview release from the current commit: + +```bash +python3 .agents/skills/elsa-release/scripts/release.py \ + --repo-path . \ + --tag 3.8.0-preview2 \ + --release-kind preview +``` + +Use `--github-repo owner/name` when the local remote is ambiguous. Use `--notes-file path/to/notes.md` to publish supplied release notes instead of generated notes. Use `--notes-start-tag ` to control GitHub's generated-notes comparison range. + +## Guardrails + +- Never move, delete, or force-update an existing release tag unless the user explicitly requests that exact destructive operation. +- Never publish a stable release when the user asked for preview. +- Do not create a draft release for the normal automated pipeline unless the repository workflow has been reviewed and the user explicitly wants a draft. +- Keep release notes and release titles factual. Prefer the exact tag as the GitHub release title unless the repository has a different convention. +- Do not publish curated notes without reviewing the compare range and confirming all included commits belong in the release. +- If `gh` is unauthenticated, stop and ask the user to authenticate; do not try to handle credentials. +- If the pipeline semantics are unclear, inspect the workflow or ask before publishing. diff --git a/.agents/skills/elsa-release/agents/openai.yaml b/.agents/skills/elsa-release/agents/openai.yaml new file mode 100644 index 000000000..418bb1bf2 --- /dev/null +++ b/.agents/skills/elsa-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Elsa Release" + short_description: "Release Elsa repositories from GitHub tags" + default_prompt: "Use $elsa-release to create a stable GitHub release from the existing RC tag." diff --git a/.agents/skills/elsa-release/scripts/release.py b/.agents/skills/elsa-release/scripts/release.py new file mode 100755 index 000000000..6e9b3f902 --- /dev/null +++ b/.agents/skills/elsa-release/scripts/release.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Prepare or execute Elsa-style GitHub releases from Git tags.""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Command: + args: list[str] + cwd: Path | None = None + + +def main() -> int: + args = parse_args() + repo_path = Path(args.repo_path).expanduser().resolve() + ensure_tool("git") + ensure_tool("gh") + ensure_git_repo(repo_path) + + remote = args.remote + github_repo = args.github_repo or infer_github_repo(repo_path, remote) + + fetch_command = ["git", "fetch", "--tags", "--prune", remote] + print("$ " + shell_join(fetch_command), flush=True) + run(fetch_command, cwd=repo_path, execute=True) + + source_ref = args.source_ref or "HEAD" + source_commit = git(["rev-parse", f"{source_ref}^{{commit}}"], repo_path) + containing_branches = remote_branches_containing(repo_path, source_commit) + validate_containing_branches(containing_branches, args.allow_uncontained) + + tag_exists_local = ref_exists(repo_path, f"refs/tags/{args.tag}") + tag_exists_remote = remote_tag_exists(repo_path, remote, args.tag) + if tag_exists_local or tag_exists_remote: + existing_commit = git(["rev-list", "-n", "1", f"{args.tag}^{{commit}}"], repo_path, check=False) + if existing_commit != source_commit: + fail(f"Tag {args.tag} already exists and points to {existing_commit or 'an unknown commit'}, not {source_commit}.") + print(f"Tag {args.tag} already exists at the requested commit; the helper will reuse it.") + + title = args.title or args.tag + commands = build_commands( + repo_path=repo_path, + remote=remote, + github_repo=github_repo, + tag=args.tag, + source_commit=source_commit, + title=title, + release_kind=args.release_kind, + notes_file=args.notes_file, + notes_start_tag=args.notes_start_tag, + tag_exists_local=tag_exists_local, + tag_exists_remote=tag_exists_remote, + ) + + print_summary( + repo_path=repo_path, + github_repo=github_repo, + source_ref=source_ref, + source_commit=source_commit, + tag=args.tag, + release_kind=args.release_kind, + containing_branches=containing_branches, + execute=args.execute, + ) + + for command in commands: + print("$ " + shell_join(command.args), flush=True) + if args.execute: + run(command.args, cwd=command.cwd, execute=True) + + if not args.execute: + print("\nDry run only. Re-run with --execute after explicit release approval.") + + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-path", default=".", help="Local repository path.") + parser.add_argument("--github-repo", help="GitHub repository as owner/name. Inferred from the git remote when omitted.") + parser.add_argument("--remote", default="origin", help="Git remote to fetch and push tags to.") + parser.add_argument("--source-ref", help="Existing tag, branch, or commit for the release tag. Defaults to HEAD.") + parser.add_argument("--tag", required=True, help="Desired release tag, e.g. 3.7.0 or 3.8.0-preview2.") + parser.add_argument("--release-kind", required=True, choices=("stable", "preview"), help="Stable creates a normal release; preview creates a prerelease.") + parser.add_argument("--title", help="GitHub release title. Defaults to the tag.") + parser.add_argument("--notes-file", help="Release notes Markdown file. Defaults to GitHub generated notes.") + parser.add_argument("--notes-start-tag", help="Starting tag for GitHub generated release notes.") + parser.add_argument("--allow-uncontained", action="store_true", help="Allow source commits not contained in origin/main or origin/release/*.") + parser.add_argument("--execute", action="store_true", help="Create/push the tag and publish the GitHub release.") + return parser.parse_args() + + +def build_commands( + *, + repo_path: Path, + remote: str, + github_repo: str, + tag: str, + source_commit: str, + title: str, + release_kind: str, + notes_file: str | None, + notes_start_tag: str | None, + tag_exists_local: bool, + tag_exists_remote: bool, +) -> list[Command]: + commands: list[Command] = [] + + if not tag_exists_local: + commands.append(Command(["git", "tag", "-a", tag, source_commit, "-m", f"Release {tag}"], repo_path)) + + if not tag_exists_remote: + commands.append(Command(["git", "push", remote, f"refs/tags/{tag}"], repo_path)) + + release = [ + "gh", + "release", + "create", + tag, + "--repo", + github_repo, + "--verify-tag", + "--title", + title, + ] + + if notes_file: + release.extend(["--notes-file", notes_file]) + else: + release.append("--generate-notes") + if notes_start_tag: + release.extend(["--notes-start-tag", notes_start_tag]) + + if release_kind == "preview": + release.extend(["--prerelease", "--latest=false"]) + else: + release.append("--latest") + + commands.append(Command(release, repo_path)) + return commands + + +def print_summary( + *, + repo_path: Path, + github_repo: str, + source_ref: str, + source_commit: str, + tag: str, + release_kind: str, + containing_branches: list[str], + execute: bool, +) -> None: + mode = "EXECUTE" if execute else "DRY RUN" + print(f"Mode: {mode}") + print(f"Repository path: {repo_path}") + print(f"GitHub repository: {github_repo}") + print(f"Source ref: {source_ref}") + print(f"Source commit: {source_commit}") + print(f"Release tag: {tag}") + print(f"Release kind: {release_kind}") + print("Containing remote branches:") + for branch in containing_branches: + print(f" - {branch}") + print() + + +def validate_containing_branches(branches: list[str], allow_uncontained: bool) -> None: + if allow_uncontained: + return + + for branch in branches: + normalized = branch.strip() + if normalized == "origin/main" or normalized.startswith("origin/release/"): + return + + fail("Source commit is not contained in origin/main or origin/release/*. Use --allow-uncontained only after reviewing the workflow risk.") + + +def remote_branches_containing(repo_path: Path, commit: str) -> list[str]: + output = git(["branch", "--remote", "--contains", commit], repo_path) + return [line.strip().lstrip("* ").strip() for line in output.splitlines() if line.strip()] + + +def ref_exists(repo_path: Path, ref: str) -> bool: + result = subprocess.run(["git", "rev-parse", "--quiet", "--verify", ref], cwd=repo_path, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return result.returncode == 0 + + +def remote_tag_exists(repo_path: Path, remote: str, tag: str) -> bool: + result = subprocess.run(["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"], cwd=repo_path, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return result.returncode == 0 + + +def infer_github_repo(repo_path: Path, remote: str) -> str: + url = git(["remote", "get-url", remote], repo_path) + match = re.search(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?$", url) + if match: + return f"{match.group(1)}/{match.group(2)}" + fail(f"Could not infer GitHub repository from remote URL: {url}. Pass --github-repo owner/name.") + + +def ensure_tool(name: str) -> None: + if shutil.which(name) is None: + fail(f"Required tool not found on PATH: {name}") + + +def ensure_git_repo(path: Path) -> None: + if not path.exists(): + fail(f"Repository path does not exist: {path}") + git(["rev-parse", "--git-dir"], path) + + +def git(args: list[str], cwd: Path, *, check: bool = True) -> str: + result = subprocess.run(["git", *args], cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if check and result.returncode != 0: + fail(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def run(args: list[str], cwd: Path | None, *, execute: bool) -> None: + if not execute: + print("$ " + shell_join(args)) + return + result = subprocess.run(args, cwd=cwd) + if result.returncode != 0: + fail(f"Command failed with exit code {result.returncode}: {shell_join(args)}") + + +def shell_join(args: list[str]) -> str: + return " ".join(quote(arg) for arg in args) + + +def quote(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:=@%+-]+", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def fail(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/elsa-release/scripts/release_notes.py b/.agents/skills/elsa-release/scripts/release_notes.py new file mode 100755 index 000000000..ed615ef5b --- /dev/null +++ b/.agents/skills/elsa-release/scripts/release_notes.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Generate a categorized Markdown scaffold for Elsa release notes.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Commit: + sha: str + subject: str + + @property + def short_sha(self) -> str: + return self.sha[:10] + + @property + def reference(self) -> str: + pr = extract_pr_number(self.subject) + return f"(#{pr})" if pr else f"({self.short_sha})" + + +SECTIONS: tuple[tuple[str, str], ...] = ( + ("breaking", "## โš ๏ธ Breaking changes / upgrade notes"), + ("features", "## โœจ New features"), + ("improvements", "## ๐Ÿ”ง Improvements"), + ("fixes", "## ๐Ÿ› Fixes"), + ("security", "## ๐Ÿ”’ Security"), + ("developer", "## ๐Ÿงฉ Developer-facing changes"), + ("tests", "## ๐Ÿงช Tests"), + ("ci", "## ๐Ÿ” CI / Build"), + ("dependencies", "## ๐Ÿ“ฆ Dependencies"), + ("docs", "## Documentation"), + ("maintenance", "## Maintenance"), +) + + +def main() -> int: + args = parse_args() + repo_path = Path(args.repo_path).expanduser().resolve() + commits = get_commits(repo_path, args.from_ref, args.to_ref) + notes = render_notes(args.version, args.from_ref, args.to_ref, commits) + + if args.output: + output = Path(args.output).expanduser() + if not output.is_absolute(): + output = repo_path / output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(notes, encoding="utf-8") + print(output) + else: + print(notes) + + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-path", default=".", help="Local repository path.") + parser.add_argument("--from-ref", required=True, help="Previous tag/ref for the compare range.") + parser.add_argument("--to-ref", required=True, help="Release tag/ref for the compare range.") + parser.add_argument("--version", required=True, help="Release version for the title.") + parser.add_argument("--output", help="Optional Markdown output path.") + return parser.parse_args() + + +def get_commits(repo_path: Path, from_ref: str, to_ref: str) -> list[Commit]: + output = git( + [ + "log", + "--reverse", + "--no-merges", + "--pretty=format:%H%x1f%s", + f"{from_ref}..{to_ref}", + ], + repo_path, + ) + commits: list[Commit] = [] + for line in output.splitlines(): + if not line.strip(): + continue + sha, subject = line.split("\x1f", 1) + commits.append(Commit(sha=sha, subject=subject)) + return commits + + +def render_notes(version: str, from_ref: str, to_ref: str, commits: list[Commit]) -> str: + categorized: dict[str, list[Commit]] = {key: [] for key, _ in SECTIONS} + for commit in commits: + categorized[categorize(commit.subject)].append(commit) + + lines: list[str] = [f"Compare: `{from_ref}...{to_ref}`", "", "---", "", "## ๐ŸŒŸ Highlights", ""] + + for commit in select_highlights(commits): + lines.append(f"- {format_subject(commit.subject)} {commit.reference}") + if not commits: + lines.append("- No commits found in the selected range.") + + for key, heading in SECTIONS: + section_commits = categorized[key] + if not section_commits: + continue + lines.extend(["", "---", "", heading, ""]) + for commit in section_commits: + lines.append(f"- {format_subject(commit.subject)} {commit.reference}") + + lines.extend(["", "---", "", "## ๐Ÿ“ฆ Full changelog (short)", ""]) + for commit in commits: + lines.append(f"- {commit.subject} ({commit.short_sha})") + + lines.extend( + [ + "", + "", + "", + ] + ) + return "\n".join(lines) + + +def select_highlights(commits: list[Commit]) -> list[Commit]: + priority = {"breaking": 0, "features": 1, "fixes": 2, "improvements": 3, "security": 4, "developer": 5} + ranked = sorted( + commits, + key=lambda commit: ( + priority.get(categorize(commit.subject), 99), + is_noise(commit.subject), + commit.subject.lower(), + ), + ) + return ranked[:6] + + +def categorize(subject: str) -> str: + lower = subject.lower() + conventional = lower.split(":", 1)[0] + + if "breaking change" in lower or re.search(r"^[a-z]+(?:\([^)]+\))?!:", lower): + return "breaking" + if "security" in lower or "cve-" in lower or "vulnerab" in lower: + return "security" + if conventional.startswith("feat") or re.search(r"^(add|added|introduce|introduced|new)\b", lower): + return "features" + if conventional.startswith("fix") or contains_any(lower, "fix ", "fixed ", "bug", "issue"): + return "fixes" + if conventional.startswith("test") or " test" in lower or lower.startswith("test"): + return "tests" + if conventional.startswith("docs") or lower.startswith("doc") or "readme" in lower: + return "docs" + if contains_any(lower, "package", "dependency", "dependencies", "nuget", "props"): + return "dependencies" + if is_ci_or_build_change(lower): + return "ci" + if contains_any(lower, "api", "contract", "extension", "attribute", "options"): + return "developer" + if contains_any(lower, "improve", "enhance", "refactor", "optimiz", "cleanup"): + return "improvements" + return "maintenance" + + +def contains_any(value: str, *needles: str) -> bool: + return any(needle in value for needle in needles) + + +def is_ci_or_build_change(value: str) -> bool: + return bool( + re.search(r"\b(ci|build|pack|versioning)\b", value) + or "github action" in value + or "github workflow" in value + or ".github/workflows" in value + ) + + +def is_noise(subject: str) -> bool: + return categorize(subject) in {"tests", "ci", "docs", "maintenance"} + + +def format_subject(subject: str) -> str: + value = re.sub(r"\s+\(#\d+\)$", "", subject).strip() + value = re.sub(r"^[a-z]+(?:\([^)]+\))?!?:\s*", "", value, flags=re.IGNORECASE) + if not value: + return subject + return value[0].upper() + value[1:] + + +def extract_pr_number(subject: str) -> str | None: + match = re.search(r"\(#(\d+)\)\s*$", subject) + if match: + return match.group(1) + match = re.search(r"merge pull request #(\d+)", subject, flags=re.IGNORECASE) + if match: + return match.group(1) + return None + + +def git(args: list[str], cwd: Path) -> str: + result = subprocess.run(["git", *args], cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + print(result.stderr.strip() or f"git {' '.join(args)} failed", file=sys.stderr) + raise SystemExit(result.returncode) + return result.stdout.strip() + + +if __name__ == "__main__": + raise SystemExit(main())