diff --git a/.github/workflows/check_version_markers.sh b/.github/workflows/check_version_markers.sh index b8d35aec9d..15a0a67dc8 100755 --- a/.github/workflows/check_version_markers.sh +++ b/.github/workflows/check_version_markers.sh @@ -20,9 +20,8 @@ grep_exit_code=0 # Exclude CONTRIBUTING.md, RELEASING.md because they document how to use these strings. grep --exclude=CONTRIBUTING.md \ --exclude=RELEASING.md \ - --exclude=release.py \ - --exclude=release_test.py \ --exclude-dir=.* \ + --exclude-dir=release \ VERSION_NEXT_ -r || grep_exit_code=$? if [[ $grep_exit_code -eq 0 ]]; then diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 636c9b2589..d06b9097a9 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -34,7 +34,8 @@ def _mock_git_and_gh(test_case): # Apply safe defaults mock_git.get_current_branch.return_value = None mock_git.get_tags.return_value = [] - mock_git.get_tags_at_head.return_value = [] + mock_git.get_remote_tags.return_value = [] + mock_git.status.return_value = "" mock_git.branch_exists.return_value = False mock_git.tag_exists.return_value = False @@ -588,6 +589,17 @@ def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + @patch("tools.private.release.git.get_remote_tags") + def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): + mock_get_remote_tags.return_value = [ + "2.0.0-rc0", + "2.0.0-rc2", + "2.0.0-rc1", + "2.1.0-rc0", + ] + self.assertEqual(utils.get_latest_rc_tag("2.0.0", remote="origin"), "2.0.0-rc2") + mock_get_remote_tags.assert_called_once_with("origin") + class DetermineNextVersionTest(TempDirTestCase): def setUp(self): @@ -942,8 +954,7 @@ def test_create_rc_success_first_rc(self): - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 - [ ] Tag RC0 | status=pending """ - self.mock_git.get_tags.return_value = [] - self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_remote_tags.return_value = [] self.mock_git.get_commit_sha.return_value = "1234567890" # Act @@ -954,9 +965,10 @@ def test_create_rc_success_first_rc(self): self.mock_git.fetch.assert_has_calls( [call("my-remote"), call("my-remote", tags=True, force=True)] ) - self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") - self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -988,8 +1000,7 @@ def test_create_rc_success_next_rc(self): - [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 - [ ] Tag RC1 | status=pending """ - self.mock_git.get_tags.return_value = ["2.0.0-rc0"] - self.mock_git.get_tags_at_head.return_value = [] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] self.mock_git.get_commit_sha.return_value = "1234567890" # Act @@ -1000,9 +1011,10 @@ def test_create_rc_success_next_rc(self): self.mock_git.fetch.assert_has_calls( [call("my-remote"), call("my-remote", tags=True, force=True)] ) - self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") - self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") + self.mock_git.checkout.assert_not_called() + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + self.mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -1022,28 +1034,6 @@ def test_create_rc_success_next_rc(self): ) self.assertNotIn("🚀", comment_call_args[1]) - def test_create_rc_already_tagged(self): - # Arrange - args = MagicMock(issue=123) - self.mock_gh.get_issue_title.return_value = "Release 2.0.0" - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [x] Prepare Release | status=done pr=#122 commit=abcdef12 -- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 -- [ ] Tag RC0 | status=pending -""" - self.mock_git.get_tags.return_value = [] - self.mock_git.get_tags_at_head.return_value = ["2.0.0-rc0"] - - # Act - result = releaser.cmd_create_rc(args) - - # Assert - self.assertEqual(result, 0) - self.mock_git.tag.assert_not_called() - self.mock_git.push.assert_not_called() - self.mock_gh.update_issue_body.assert_not_called() - class CmdPromoteRcTest(unittest.TestCase): def setUp(self): @@ -1052,7 +1042,7 @@ def setUp(self): def test_promote_rc_success(self): # Arrange args = MagicMock(version="2.0.0", issue=123, dry_run=False) - self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1088,7 +1078,7 @@ def test_promote_rc_success(self): def test_promote_rc_resolve_issue_success(self): # Arrange args = MagicMock(version="2.0.0", issue=None, dry_run=False) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 @@ -1124,7 +1114,8 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Arrange args = MagicMock(version=None, issue=123, dry_run=False) self.mock_git.get_current_branch.return_value = "release/2.0" - self.mock_git.get_tags.return_value = ["2.0.0", "2.0.1-rc0"] + self.mock_git.get_tags.return_value = ["2.0.0"] + self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"] self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1136,7 +1127,8 @@ def test_promote_rc_defaults_to_determine_next_version(self): # Assert self.assertEqual(result, 0) self.mock_git.get_current_branch.assert_called_once() - self.assertTrue(self.mock_git.get_tags.call_count >= 2) + self.mock_git.get_tags.assert_called_once() + self.mock_git.get_remote_tags.assert_called_once_with("upstream") self.mock_git.checkout.assert_not_called() self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0") @@ -1159,7 +1151,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): def test_promote_rc_dry_run_success(self): # Arrange args = MagicMock(version="2.0.0", issue=123, dry_run=True) - self.mock_git.get_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"] self.mock_git.get_commit_sha.return_value = "abcdef123456" self.mock_git.tag_exists.return_value = False initial_body = "- [ ] Tag Final" @@ -1183,7 +1175,7 @@ def test_promote_rc_dry_run_success(self): def test_promote_rc_tag_already_exists(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = True # Act @@ -1200,7 +1192,7 @@ def test_promote_rc_tag_already_exists(self): def test_promote_rc_issue_not_found(self): # Arrange args = MagicMock(version="2.0.0", issue=None) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( "Not found" @@ -1220,7 +1212,7 @@ def test_promote_rc_issue_not_found(self): def test_promote_rc_issue_malformed(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = ["2.0.0-rc1"] + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False self.mock_git.get_commit_sha.return_value = "abcdef123456" initial_body = "malformed body" @@ -1240,7 +1232,7 @@ def test_promote_rc_issue_malformed(self): def test_promote_rc_no_rc_found(self): # Arrange args = MagicMock(version="2.0.0", issue=123) - self.mock_git.get_tags.return_value = [] + self.mock_git.get_remote_tags.return_value = [] # Act result = releaser.cmd_promote_rc(args) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 9cd1a3b291..5273ca8195 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -53,7 +53,7 @@ def cmd_create_rc(args): # Determine next RC tag git.fetch(args.remote) git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version) + latest_rc = get_latest_rc_tag(version, remote=args.remote) if not latest_rc: next_rc_num = 0 @@ -79,19 +79,13 @@ def cmd_create_rc(args): ) return 1 - # Verify HEAD is not already tagged - git.checkout(f"{args.remote}/{branch_name}") - head_tags = git.get_tags_at_head() - if any(tag.startswith(f"{version}-rc") for tag in head_tags): - print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") - return 0 + target_ref = f"{args.remote}/{branch_name}" + commit_sha = git.get_commit_sha(target_ref) print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, "HEAD") + git.tag(next_rc, target_ref) git.push(args.remote, next_rc) - commit_sha = git.get_commit_sha("HEAD") - # Check off the appropriate "Tag RC{N}" task in the checklist print(f"Checking off Tag RC{next_rc_num} task...") metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} diff --git a/tools/private/release/git.py b/tools/private/release/git.py index ce2bd8ca03..f7021b6f5c 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -123,12 +123,6 @@ def sort_commits_chronologically(shas): return output.splitlines() if output else [] -def get_tags_at_head(): - """Returns a list of tags pointing at the current HEAD commit.""" - output = run_cmd("git", "tag", "--points-at", "HEAD") - return output.splitlines() if output else [] - - def get_current_branch(): """Returns the current git branch name.""" return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") @@ -150,3 +144,30 @@ def is_ancestor(ancestor, descendant): return True except subprocess.CalledProcessError: return False + + +def get_remote_tags(remote: str) -> list[str]: + """Returns a list of tags present on the specified remote repository. + + Args: + remote: The name of the git remote to query (e.g., 'origin', 'upstream'). + + Returns: + A list of tag names (strings) found on the remote, excluding peeled tags. + """ + output = run_cmd("git", "ls-remote", "--tags", remote) + tags = [] + for line in output.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + ref = parts[1] + if ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/") :] + # Skip peeled tags (e.g. tag^{}) to avoid + # duplicate tag names in the output. + if not tag.endswith("^{}"): + tags.append(tag) + return tags diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 7bc6f9ba05..e1ebf8ec2e 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -141,8 +141,8 @@ def cmd_process_backports(args): branch_name = f"release/{branch_version}" # Determine next RC tag to write to backport metadata - git.fetch("--tags", "--force") - latest_rc = get_latest_rc_tag(version) + git.fetch("origin", tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote="origin") if not latest_rc: next_rc_suffix = "rc0" else: @@ -236,13 +236,14 @@ def cmd_process_backports(args): def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" + # Fetch from upstream to ensure we have the latest tags + git.fetch("upstream", tags=True, force=True) + version = args.version if version is None: version = determine_next_version() - # Fetch from upstream to ensure we have the latest tags - git.fetch("upstream", tags=True, force=True) - latest_rc = get_latest_rc_tag(version) + latest_rc = get_latest_rc_tag(version, remote="upstream") if not latest_rc: print(f"Error: No release candidate tags found matching {version}-rc*") return 1 diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 6f7383f8ec..2d050022b1 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -67,9 +67,12 @@ def get_latest_version(): return stable_versions[-1] -def get_latest_rc_tag(version): +def get_latest_rc_tag(version, remote=None): """Queries git tags and returns the highest RC tag for the version.""" - tags = git.get_tags() + if remote: + tags = git.get_remote_tags(remote) + else: + tags = git.get_tags() pattern = rf"^{re.escape(version)}-rc\d+$" rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] if not rc_tags: