Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:"
Expand Down Expand Up @@ -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 \
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use $(POETRY_ENV) python instead of bare python for the root-repo pr_manager.py invocation. The orchestrator call on the lines above uses $(POETRY_ENV) to ensure the workspace venv is active, but this new direct call doesn't, risking execution under system Python.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Makefile, line 481:

<comment>Use `$(POETRY_ENV) python` instead of bare `python` for the root-repo `pr_manager.py` invocation. The orchestrator call on the lines above uses `$(POETRY_ENV)` to ensure the workspace venv is active, but this new direct call doesn't, risking execution under system Python.</comment>

<file context>
@@ -471,7 +475,24 @@ pr: ## Manage pull requests for selected projects
+		--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)" \
</file context>
Suggested change
python scripts/github/pr_manager.py \
$(POETRY_ENV) python scripts/github/pr_manager.py \
Fix with Cubic

--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)
Expand Down
5 changes: 4 additions & 1 deletion base.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
43 changes: 43 additions & 0 deletions scripts/github/pr_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import argparse
import json
import re
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -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<version>\d+\.\d+\.\d+)", head)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Inconsistent -dev suffix handling: the second regex matches against original head instead of the -dev-stripped version. A branch named release/X.Y.Z-dev will silently fail to produce a tag, unlike the bare X.Y.Z-dev pattern which is handled correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/github/pr_manager.py, line 87:

<comment>Inconsistent `-dev` suffix handling: the second regex matches against original `head` instead of the `-dev`-stripped `version`. A branch named `release/X.Y.Z-dev` will silently fail to produce a tag, unlike the bare `X.Y.Z-dev` pattern which is handled correctly.</comment>

<file context>
@@ -79,6 +80,41 @@ def _selector(pr_number: str, head: str) -> str:
+    version = head.removesuffix("-dev")
+    if re.fullmatch(r"\d+\.\d+\.\d+", version):
+        return f"v{version}"
+    match = re.fullmatch(r"release/(?P<version>\d+\.\d+\.\d+)", head)
+    if match:
+        return f"v{match.group('version')}"
</file context>
Suggested change
match = re.fullmatch(r"release/(?P<version>\d+\.\d+\.\d+)", head)
match = re.fullmatch(r"release/(?P<version>\d+\.\d+\.\d+)", version)
Fix with Cubic

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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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


Expand All @@ -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()


Expand Down Expand Up @@ -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":
Expand Down
8 changes: 5 additions & 3 deletions scripts/release/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/scripts/github/pr_manager_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions tests/unit/scripts/release/release_shared_and_run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,70 @@ def test_current_version_reads_project_table(tmp_path: Path) -> None:
)

assert run_mod._current_version(tmp_path) == "0.10.0"


def test_phase_publish_does_not_tag_when_push_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
run_mod = _load_module("release_run_no_tag", "scripts/release/run.py")

recorded: list[list[str]] = []

def _fake_run_checked(command: list[str], cwd: Path | None = None) -> None:
_ = cwd
recorded.append(command)

def _fake_mkdir(
self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
) -> None:
_ = self, mode, parents, exist_ok

monkeypatch.setattr(run_mod, "run_checked", _fake_run_checked)
monkeypatch.setattr(run_mod.Path, "mkdir", _fake_mkdir)

run_mod._phase_publish(
root=tmp_path,
version="0.11.0",
tag="v0.11.0",
push=False,
dry_run=False,
project_names=[],
)

assert not any(cmd[:2] == ["git", "tag"] for cmd in recorded)


def test_phase_publish_tags_when_push_enabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
run_mod = _load_module("release_run_with_tag", "scripts/release/run.py")

recorded: list[list[str]] = []

def _fake_run_checked(command: list[str], cwd: Path | None = None) -> None:
_ = cwd
recorded.append(command)

def _fake_run_capture(command: list[str], cwd: Path | None = None) -> str:
_ = command, cwd
return ""

def _fake_mkdir(
self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
) -> None:
_ = self, mode, parents, exist_ok

monkeypatch.setattr(run_mod, "run_checked", _fake_run_checked)
monkeypatch.setattr(run_mod, "run_capture", _fake_run_capture)
monkeypatch.setattr(run_mod.Path, "mkdir", _fake_mkdir)

run_mod._phase_publish(
root=tmp_path,
version="0.11.0",
tag="v0.11.0",
push=True,
dry_run=False,
project_names=[],
)

assert any(cmd[:2] == ["git", "tag"] for cmd in recorded)
Loading