From dde46ff4d3b5401e5da798e972724547b400a19e Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 14:18:20 -0300 Subject: [PATCH] feat(pr): auto-dispatch workspace release on merge --- Makefile | 21 +++++++++ base.mk | 5 ++- scripts/github/pr_manager.py | 43 +++++++++++++++++++ tests/unit/scripts/github/pr_manager_tests.py | 42 ++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8f340f3a..8c582652 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,8 @@ PR_MERGE_METHOD ?= squash PR_AUTO ?= 0 PR_DELETE_BRANCH ?= 0 PR_CHECKS_STRICT ?= 0 +PR_RELEASE_ON_MERGE ?= 1 +PR_INCLUDE_ROOT ?= 1 Q := @ ifdef VERBOSE @@ -177,6 +179,8 @@ help: ## Show simple workspace verbs $(Q)echo " PR_TITLE='title' PR_BODY='body' PR_MERGE_METHOD=squash|merge|rebase" $(Q)echo " PR_AUTO=0|1 PR_DELETE_BRANCH=0|1" $(Q)echo " PR_CHECKS_STRICT=0|1 checks action strict failure toggle" + $(Q)echo " PR_RELEASE_ON_MERGE=0|1 merge action: dispatch release workflow" + $(Q)echo " PR_INCLUDE_ROOT=0|1 include root repo in workspace PR automation" $(Q)echo " DEPS_REPORT=0 Skip dependency report after upgrade/typings" $(Q)echo "" $(Q)echo "Examples:" @@ -471,7 +475,24 @@ pr: ## Manage pull requests for selected projects --make-arg "PR_AUTO=$(PR_AUTO)" \ --make-arg "PR_DELETE_BRANCH=$(PR_DELETE_BRANCH)" \ --make-arg "PR_CHECKS_STRICT=$(PR_CHECKS_STRICT)" \ + --make-arg "PR_RELEASE_ON_MERGE=$(PR_RELEASE_ON_MERGE)" \ $(SELECTED_PROJECTS) + $(Q)if [ "$(PR_INCLUDE_ROOT)" = "1" ]; then \ + python scripts/github/pr_manager.py \ + --repo-root "$(CURDIR)" \ + --action "$(PR_ACTION)" \ + --base "$(PR_BASE)" \ + $(if $(PR_HEAD),--head "$(PR_HEAD)",) \ + $(if $(PR_NUMBER),--number "$(PR_NUMBER)",) \ + $(if $(PR_TITLE),--title "$(PR_TITLE)",) \ + $(if $(PR_BODY),--body "$(PR_BODY)",) \ + --draft "$(PR_DRAFT)" \ + --merge-method "$(PR_MERGE_METHOD)" \ + --auto "$(PR_AUTO)" \ + --delete-branch "$(PR_DELETE_BRANCH)" \ + --checks-strict "$(PR_CHECKS_STRICT)" \ + --release-on-merge "$(PR_RELEASE_ON_MERGE)"; \ + fi security: ## Run all security checks in all projects $(Q)$(ENSURE_NO_PROJECT_CONFLICT) diff --git a/base.mk b/base.mk index cf69373b..207abfe2 100644 --- a/base.mk +++ b/base.mk @@ -30,6 +30,7 @@ PR_MERGE_METHOD ?= squash PR_AUTO ?= 0 PR_DELETE_BRANCH ?= 0 PR_CHECKS_STRICT ?= 0 +PR_RELEASE_ON_MERGE ?= 1 PYTEST_REPORT_ARGS := -ra --durations=25 --durations-min=0.001 --tb=short PYTEST_DIAG_ARGS := -rA --durations=0 --tb=long --showlocals @@ -190,6 +191,7 @@ help: ## Show commands $(Q)echo " PR_TITLE='title' PR_BODY='body' PR_DRAFT=0|1" $(Q)echo " PR_MERGE_METHOD=squash|merge|rebase PR_AUTO=0|1 PR_DELETE_BRANCH=0|1" $(Q)echo " PR_CHECKS_STRICT=0|1 (checks: fail command only when strict=1)" + $(Q)echo " PR_RELEASE_ON_MERGE=0|1 (merge: dispatch release workflow when branch maps to semver)" setup: ## Complete setup $(Q)if [ "$(CORE_STACK)" = "go" ]; then \ @@ -516,7 +518,8 @@ pr: ## Manage pull requests for this repository --merge-method "$(PR_MERGE_METHOD)" \ --auto "$(PR_AUTO)" \ --delete-branch "$(PR_DELETE_BRANCH)" \ - --checks-strict "$(PR_CHECKS_STRICT)" + --checks-strict "$(PR_CHECKS_STRICT)" \ + --release-on-merge "$(PR_RELEASE_ON_MERGE)" clean: ## Clean artifacts $(Q)if [ "$(CORE_STACK)" = "go" ]; then \ diff --git a/scripts/github/pr_manager.py b/scripts/github/pr_manager.py index d1cb7f7b..0c3c53ad 100644 --- a/scripts/github/pr_manager.py +++ b/scripts/github/pr_manager.py @@ -3,6 +3,7 @@ import argparse import json +import re import subprocess from pathlib import Path @@ -79,6 +80,41 @@ def _selector(pr_number: str, head: str) -> str: return pr_number if pr_number else head +def _release_tag_from_head(head: str) -> str | None: + version = head.removesuffix("-dev") + if re.fullmatch(r"\d+\.\d+\.\d+", version): + return f"v{version}" + match = re.fullmatch(r"release/(?P\d+\.\d+\.\d+)", head) + if match: + return f"v{match.group('version')}" + return None + + +def _is_workspace_release_repo(repo_root: Path) -> bool: + return (repo_root / ".github" / "workflows" / "release.yml").exists() + + +def _trigger_release_if_needed(repo_root: Path, head: str) -> None: + if not _is_workspace_release_repo(repo_root): + return + tag = _release_tag_from_head(head) + if tag is None: + return + + if _run_stream(["gh", "release", "view", tag], repo_root) == 0: + print(f"status=release-exists tag={tag}") + return + + run_code = _run_stream( + ["gh", "workflow", "run", "release.yml", "-f", f"tag={tag}"], + repo_root, + ) + if run_code == 0: + print(f"status=release-dispatched tag={tag}") + else: + print(f"status=release-dispatch-failed tag={tag} exit={run_code}") + + def _create_pr( repo_root: Path, base: str, @@ -118,9 +154,11 @@ def _create_pr( def _merge_pr( repo_root: Path, selector: str, + head: str, method: str, auto: int, delete_branch: int, + release_on_merge: int, ) -> int: command = ["gh", "pr", "merge", selector] merge_flag = { @@ -136,6 +174,8 @@ def _merge_pr( exit_code = _run_stream(command, repo_root) if exit_code == 0: print("status=merged") + if release_on_merge == 1: + _trigger_release_if_needed(repo_root, head) return exit_code @@ -157,6 +197,7 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--auto", type=int, default=0) _ = parser.add_argument("--delete-branch", type=int, default=0) _ = parser.add_argument("--checks-strict", type=int, default=0) + _ = parser.add_argument("--release-on-merge", type=int, default=1) return parser.parse_args() @@ -189,9 +230,11 @@ def main() -> int: return _merge_pr( repo_root, selector, + head, args.merge_method, args.auto, args.delete_branch, + args.release_on_merge, ) if args.action == "close": diff --git a/tests/unit/scripts/github/pr_manager_tests.py b/tests/unit/scripts/github/pr_manager_tests.py index 462e31ac..b67c3a32 100644 --- a/tests/unit/scripts/github/pr_manager_tests.py +++ b/tests/unit/scripts/github/pr_manager_tests.py @@ -151,3 +151,45 @@ def _fake_run_stream(_command: list[str], _cwd: Path) -> int: ) assert mod.main() == 8 + + +def test_release_tag_from_head_patterns() -> None: + mod = _load_module("pr_manager_release_tag", "scripts/github/pr_manager.py") + assert mod._release_tag_from_head("0.11.0-dev") == "v0.11.0" + assert mod._release_tag_from_head("release/0.12.3") == "v0.12.3" + assert mod._release_tag_from_head("feature/x") is None + + +def test_merge_triggers_release_dispatch_when_workspace_repo( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + mod = _load_module("pr_manager_merge_release", "scripts/github/pr_manager.py") + workflows = tmp_path / ".github" / "workflows" + _ = workflows.mkdir(parents=True) + _ = (workflows / "release.yml").write_text("name: release\n", encoding="utf-8") + + calls: list[list[str]] = [] + + def _fake_run_stream(command: list[str], _cwd: Path) -> int: + calls.append(command) + if command[:3] == ["gh", "release", "view"]: + return 1 + return 0 + + monkeypatch.setattr(mod, "_run_stream", _fake_run_stream) + + exit_code = mod._merge_pr( + repo_root=tmp_path, + selector="123", + head="0.11.0-dev", + method="squash", + auto=1, + delete_branch=0, + release_on_merge=1, + ) + + assert exit_code == 0 + assert calls[0] == ["gh", "pr", "merge", "123", "--squash", "--auto"] + assert calls[1] == ["gh", "release", "view", "v0.11.0"] + assert calls[2] == ["gh", "workflow", "run", "release.yml", "-f", "tag=v0.11.0"] + assert "status=release-dispatched tag=v0.11.0" in capsys.readouterr().out