From ed3e52a833426714af7527907828d25ed2b7295e Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 12:47:16 -0300 Subject: [PATCH 1/9] chore(workspace): propagate unified make and release automation --- scripts/release/shared.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/release/shared.py b/scripts/release/shared.py index a1abb23c..b7110ddb 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -38,6 +38,21 @@ def resolve_projects(root: Path, names: list[str]) -> list[Project]: ) from exc +def resolve_projects(root: Path, names: list[str]) -> list[Project]: + projects = discover_projects(root) + if not names: + return projects + + by_name = {project.name: project for project in projects} + missing = [name for name in names if name not in by_name] + if missing: + missing_text = ", ".join(sorted(missing)) + raise RuntimeError(f"unknown release projects: {missing_text}") + + resolved = [by_name[name] for name in names] + return sorted(resolved, key=lambda project: project.name) + + def parse_semver(version: str) -> tuple[int, int, int]: match = SEMVER_RE.match(version) if not match: From 905b8479f60492bf6820606e1d01005eccada591 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 13:23:26 -0300 Subject: [PATCH 2/9] chore: checkpoint pending 0.11.0-dev changes --- scripts/release/shared.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/scripts/release/shared.py b/scripts/release/shared.py index b7110ddb..570d197e 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -39,18 +39,12 @@ def resolve_projects(root: Path, names: list[str]) -> list[Project]: def resolve_projects(root: Path, names: list[str]) -> list[Project]: - projects = discover_projects(root) - if not names: - return projects - - by_name = {project.name: project for project in projects} - missing = [name for name in names if name not in by_name] - if missing: - missing_text = ", ".join(sorted(missing)) - raise RuntimeError(f"unknown release projects: {missing_text}") - - resolved = [by_name[name] for name in names] - return sorted(resolved, key=lambda project: project.name) + try: + return _resolve_projects(root, names) + except RuntimeError as exc: + raise RuntimeError( + str(exc).replace("unknown projects", "unknown release projects") + ) from exc def parse_semver(version: str) -> tuple[int, int, int]: From 2f30b9031bd4129d8117f5dc5405d30a3dd79a9d Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 14:12:21 -0300 Subject: [PATCH 3/9] chore: checkpoint pending 0.11.0-dev changes --- scripts/github/pr_manager.py | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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": From 8d1b57871406a8bad10b5dc6e941c967d37674a2 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 15:00:31 -0300 Subject: [PATCH 4/9] chore: checkpoint pending workspace automation changes --- scripts/github/pr_workspace.py | 176 +++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 scripts/github/pr_workspace.py diff --git a/scripts/github/pr_workspace.py b/scripts/github/pr_workspace.py new file mode 100644 index 00000000..40cdbea9 --- /dev/null +++ b/scripts/github/pr_workspace.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import time +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from libs.selection import resolve_projects +from libs.subprocess import run_capture, run_checked + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + _ = parser.add_argument("--workspace-root", type=Path, default=Path(".")) + _ = parser.add_argument("--project", action="append", default=[]) + _ = parser.add_argument("--include-root", type=int, default=1) + _ = parser.add_argument("--branch", default="") + _ = parser.add_argument("--fail-fast", type=int, default=0) + _ = parser.add_argument("--checkpoint", type=int, default=1) + _ = parser.add_argument("--pr-action", default="status") + _ = parser.add_argument("--pr-base", default="main") + _ = parser.add_argument("--pr-head", default="") + _ = parser.add_argument("--pr-number", default="") + _ = parser.add_argument("--pr-title", default="") + _ = parser.add_argument("--pr-body", default="") + _ = parser.add_argument("--pr-draft", default="0") + _ = parser.add_argument("--pr-merge-method", default="squash") + _ = parser.add_argument("--pr-auto", default="0") + _ = parser.add_argument("--pr-delete-branch", default="0") + _ = parser.add_argument("--pr-checks-strict", default="0") + _ = parser.add_argument("--pr-release-on-merge", default="1") + return parser.parse_args() + + +def _repo_display_name(repo_root: Path, workspace_root: Path) -> str: + return workspace_root.name if repo_root == workspace_root else repo_root.name + + +def _has_changes(repo_root: Path) -> bool: + return bool(run_capture(["git", "status", "--porcelain"], cwd=repo_root).strip()) + + +def _current_branch(repo_root: Path) -> str: + return run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root) + + +def _checkout_branch(repo_root: Path, branch: str) -> None: + if not branch: + return + current = _current_branch(repo_root) + if current == branch: + return + checkout = subprocess.run( + ["git", "checkout", branch], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if checkout.returncode == 0: + return + fetch = subprocess.run( + ["git", "fetch", "origin", branch], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if fetch.returncode == 0: + run_checked( + ["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=repo_root + ) + else: + run_checked(["git", "checkout", "-B", branch], cwd=repo_root) + + +def _checkpoint(repo_root: Path, branch: str) -> None: + if not _has_changes(repo_root): + return + run_checked(["git", "add", "-A"], cwd=repo_root) + staged = run_capture(["git", "diff", "--cached", "--name-only"], cwd=repo_root) + if not staged.strip(): + return + run_checked( + ["git", "commit", "-m", "chore: checkpoint pending 0.11.0-dev changes"], + cwd=repo_root, + ) + if branch: + push = subprocess.run( + ["git", "push", "-u", "origin", branch], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if push.returncode != 0: + run_checked(["git", "push"], cwd=repo_root) + else: + run_checked(["git", "push"], cwd=repo_root) + + +def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) -> int: + report_dir = workspace_root / ".reports" / "workspace" / "pr" + report_dir.mkdir(parents=True, exist_ok=True) + display = _repo_display_name(repo_root, workspace_root) + log_path = report_dir / f"{display}.log" + command = [ + "make", + "-C", + str(repo_root), + "pr", + f"PR_ACTION={args.pr_action}", + f"PR_BASE={args.pr_base}", + f"PR_DRAFT={args.pr_draft}", + f"PR_MERGE_METHOD={args.pr_merge_method}", + f"PR_AUTO={args.pr_auto}", + f"PR_DELETE_BRANCH={args.pr_delete_branch}", + f"PR_CHECKS_STRICT={args.pr_checks_strict}", + f"PR_RELEASE_ON_MERGE={args.pr_release_on_merge}", + ] + if args.pr_head: + command.append(f"PR_HEAD={args.pr_head}") + if args.pr_number: + command.append(f"PR_NUMBER={args.pr_number}") + if args.pr_title: + command.append(f"PR_TITLE={args.pr_title}") + if args.pr_body: + command.append(f"PR_BODY={args.pr_body}") + + started = time.monotonic() + with log_path.open("w", encoding="utf-8") as handle: + result = subprocess.run( + command, stdout=handle, stderr=subprocess.STDOUT, check=False + ) + elapsed = int(time.monotonic() - started) + status = "OK" if result.returncode == 0 else "FAIL" + print( + f"[{status}] {display} pr ({elapsed}s) exit={result.returncode} log={log_path}" + ) + return result.returncode + + +def main() -> int: + args = _parse_args() + workspace_root = args.workspace_root.resolve() + projects = resolve_projects(workspace_root, list(args.project)) + repos = [project.path for project in projects] + if args.include_root == 1: + repos.append(workspace_root) + + failures = 0 + for repo_root in repos: + _checkout_branch(repo_root, args.branch) + if args.checkpoint == 1: + _checkpoint(repo_root, args.branch) + exit_code = _run_pr(repo_root, workspace_root, args) + if exit_code != 0: + failures += 1 + if args.fail_fast == 1: + break + + total = len(repos) + success = total - failures + print(f"summary total={total} success={success} fail={failures} skip=0") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9335c7d2a9ce2b35c30b5863a376096d2ae76de6 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 15:02:02 -0300 Subject: [PATCH 5/9] chore: checkpoint pending workspace automation changes --- scripts/github/pr_workspace.py | 4 ++++ scripts/release/run.py | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/github/pr_workspace.py b/scripts/github/pr_workspace.py index 40cdbea9..b87710aa 100644 --- a/scripts/github/pr_workspace.py +++ b/scripts/github/pr_workspace.py @@ -66,6 +66,10 @@ def _checkout_branch(repo_root: Path, branch: str) -> None: ) if checkout.returncode == 0: return + detail = (checkout.stderr or checkout.stdout).lower() + if "local changes" in detail or "would be overwritten" in detail: + run_checked(["git", "checkout", "-B", branch], cwd=repo_root) + return fetch = subprocess.run( ["git", "fetch", "origin", branch], cwd=repo_root, diff --git a/scripts/release/run.py b/scripts/release/run.py index 36758acf..43596b62 100644 --- a/scripts/release/run.py +++ b/scripts/release/run.py @@ -164,10 +164,12 @@ def _phase_publish( ], cwd=root, ) - tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) - if tag_exists.strip() != tag: - run_checked(["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root) if push: + tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) + if tag_exists.strip() != tag: + run_checked( + ["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root + ) run_checked(["git", "push", "origin", "HEAD"], cwd=root) run_checked(["git", "push", "origin", tag], cwd=root) From 8c504ff7b4c6c9f12117c46b087d39cd8d0b1fdf Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 15:04:47 -0300 Subject: [PATCH 6/9] chore: checkpoint pending workspace automation changes --- scripts/github/pr_workspace.py | 19 +++++++++---------- scripts/release/run.py | 8 +++----- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/scripts/github/pr_workspace.py b/scripts/github/pr_workspace.py index b87710aa..1e000107 100644 --- a/scripts/github/pr_workspace.py +++ b/scripts/github/pr_workspace.py @@ -96,18 +96,17 @@ def _checkpoint(repo_root: Path, branch: str) -> None: ["git", "commit", "-m", "chore: checkpoint pending 0.11.0-dev changes"], cwd=repo_root, ) + push_cmd = ["git", "push", "-u", "origin", branch] if branch else ["git", "push"] + push = subprocess.run( + push_cmd, cwd=repo_root, check=False, capture_output=True, text=True + ) + if push.returncode == 0: + return if branch: - push = subprocess.run( - ["git", "push", "-u", "origin", branch], - cwd=repo_root, - check=False, - capture_output=True, - text=True, - ) - if push.returncode != 0: - run_checked(["git", "push"], cwd=repo_root) + run_checked(["git", "pull", "--rebase", "origin", branch], cwd=repo_root) else: - run_checked(["git", "push"], cwd=repo_root) + run_checked(["git", "pull", "--rebase"], cwd=repo_root) + run_checked(push_cmd, cwd=repo_root) def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) -> int: diff --git a/scripts/release/run.py b/scripts/release/run.py index 43596b62..36758acf 100644 --- a/scripts/release/run.py +++ b/scripts/release/run.py @@ -164,12 +164,10 @@ def _phase_publish( ], cwd=root, ) + tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) + if tag_exists.strip() != tag: + run_checked(["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root) if push: - tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) - if tag_exists.strip() != tag: - run_checked( - ["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root - ) run_checked(["git", "push", "origin", "HEAD"], cwd=root) run_checked(["git", "push", "origin", tag], cwd=root) From 1b34d8217d5407fb5c904f1bfa5c7eb3c60718bb Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 15:34:33 -0300 Subject: [PATCH 7/9] chore: checkpoint pending 0.11.0-dev changes --- scripts/github/pr_manager.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/github/pr_manager.py b/scripts/github/pr_manager.py index 0c3c53ad..2e1a2f77 100644 --- a/scripts/github/pr_manager.py +++ b/scripts/github/pr_manager.py @@ -29,6 +29,23 @@ def _run_stream(command: list[str], cwd: Path) -> int: return result.returncode +def _run_stream_with_output(command: list[str], cwd: Path) -> tuple[int, str]: + result = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + output_parts = [ + part.strip() for part in (result.stdout, result.stderr) if part.strip() + ] + output = "\n".join(output_parts) + if output: + print(output) + return result.returncode, output + + def _current_branch(repo_root: Path) -> str: return _run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root) @@ -171,7 +188,14 @@ def _merge_pr( command.append("--auto") if delete_branch == 1: command.append("--delete-branch") - exit_code = _run_stream(command, repo_root) + exit_code, output = _run_stream_with_output(command, repo_root) + if exit_code != 0 and "not mergeable" in output: + update_code, _ = _run_stream_with_output( + ["gh", "pr", "update-branch", selector, "--rebase"], + repo_root, + ) + if update_code == 0: + exit_code, _ = _run_stream_with_output(command, repo_root) if exit_code == 0: print("status=merged") if release_on_merge == 1: From b8f36721d2dacc148d02050281bff0f8f9a51019 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 15:58:47 -0300 Subject: [PATCH 8/9] chore: checkpoint pending workspace automation changes --- scripts/release/shared.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/scripts/release/shared.py b/scripts/release/shared.py index 570d197e..a1abb23c 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -38,15 +38,6 @@ def resolve_projects(root: Path, names: list[str]) -> list[Project]: ) from exc -def resolve_projects(root: Path, names: list[str]) -> list[Project]: - try: - return _resolve_projects(root, names) - except RuntimeError as exc: - raise RuntimeError( - str(exc).replace("unknown projects", "unknown release projects") - ) from exc - - def parse_semver(version: str) -> tuple[int, int, int]: match = SEMVER_RE.match(version) if not match: From a48c59c0243ab1ad1345184b465c87b06c2a9413 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Fri, 20 Feb 2026 16:02:17 -0300 Subject: [PATCH 9/9] chore: checkpoint pending workspace automation changes --- scripts/github/pr_workspace.py | 78 ++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/scripts/github/pr_workspace.py b/scripts/github/pr_workspace.py index 1e000107..c2962743 100644 --- a/scripts/github/pr_workspace.py +++ b/scripts/github/pr_workspace.py @@ -114,28 +114,60 @@ def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) -> report_dir.mkdir(parents=True, exist_ok=True) display = _repo_display_name(repo_root, workspace_root) log_path = report_dir / f"{display}.log" - command = [ - "make", - "-C", - str(repo_root), - "pr", - f"PR_ACTION={args.pr_action}", - f"PR_BASE={args.pr_base}", - f"PR_DRAFT={args.pr_draft}", - f"PR_MERGE_METHOD={args.pr_merge_method}", - f"PR_AUTO={args.pr_auto}", - f"PR_DELETE_BRANCH={args.pr_delete_branch}", - f"PR_CHECKS_STRICT={args.pr_checks_strict}", - f"PR_RELEASE_ON_MERGE={args.pr_release_on_merge}", - ] - if args.pr_head: - command.append(f"PR_HEAD={args.pr_head}") - if args.pr_number: - command.append(f"PR_NUMBER={args.pr_number}") - if args.pr_title: - command.append(f"PR_TITLE={args.pr_title}") - if args.pr_body: - command.append(f"PR_BODY={args.pr_body}") + if repo_root == workspace_root: + command = [ + "python", + "scripts/github/pr_manager.py", + "--repo-root", + str(repo_root), + "--action", + args.pr_action, + "--base", + args.pr_base, + "--draft", + args.pr_draft, + "--merge-method", + args.pr_merge_method, + "--auto", + args.pr_auto, + "--delete-branch", + args.pr_delete_branch, + "--checks-strict", + args.pr_checks_strict, + "--release-on-merge", + args.pr_release_on_merge, + ] + if args.pr_head: + command.extend(["--head", args.pr_head]) + if args.pr_number: + command.extend(["--number", args.pr_number]) + if args.pr_title: + command.extend(["--title", args.pr_title]) + if args.pr_body: + command.extend(["--body", args.pr_body]) + else: + command = [ + "make", + "-C", + str(repo_root), + "pr", + f"PR_ACTION={args.pr_action}", + f"PR_BASE={args.pr_base}", + f"PR_DRAFT={args.pr_draft}", + f"PR_MERGE_METHOD={args.pr_merge_method}", + f"PR_AUTO={args.pr_auto}", + f"PR_DELETE_BRANCH={args.pr_delete_branch}", + f"PR_CHECKS_STRICT={args.pr_checks_strict}", + f"PR_RELEASE_ON_MERGE={args.pr_release_on_merge}", + ] + if args.pr_head: + command.append(f"PR_HEAD={args.pr_head}") + if args.pr_number: + command.append(f"PR_NUMBER={args.pr_number}") + if args.pr_title: + command.append(f"PR_TITLE={args.pr_title}") + if args.pr_body: + command.append(f"PR_BODY={args.pr_body}") started = time.monotonic() with log_path.open("w", encoding="utf-8") as handle: @@ -160,6 +192,8 @@ def main() -> int: failures = 0 for repo_root in repos: + display = _repo_display_name(repo_root, workspace_root) + print(f"[RUN] {display}", flush=True) _checkout_branch(repo_root, args.branch) if args.checkpoint == 1: _checkpoint(repo_root, args.branch)