From 48a0d1d4582a356b36af9d01fef94228ce81bddf Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 14 Dec 2025 22:59:43 +0100 Subject: [PATCH] feat(release): auto-run publish after release with --no-publish opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run publish automatically after successful release - Add --no-publish flag to disable auto-publish - Respect TTY for interactive/credential prompts - Harden repo directory resolution - Add integration and unit tests for release→publish hook https://chatgpt.com/share/693f335b-b820-800f-8666-68355f3c938f --- src/pkgmgr/cli/commands/release.py | 72 ++++++------------ src/pkgmgr/cli/parser/release_cmd.py | 18 +++-- .../integration/test_release_publish_hook.py | 66 ++++++++++++++++ .../cli/commands/test_release_publish_hook.py | 76 +++++++++++++++++++ 4 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 tests/integration/test_release_publish_hook.py create mode 100644 tests/unit/pkgmgr/cli/commands/test_release_publish_hook.py diff --git a/src/pkgmgr/cli/commands/release.py b/src/pkgmgr/cli/commands/release.py index 71077d8..84a1a73 100644 --- a/src/pkgmgr/cli/commands/release.py +++ b/src/pkgmgr/cli/commands/release.py @@ -1,31 +1,17 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -Release command wiring for the pkgmgr CLI. - -This module implements the `pkgmgr release` subcommand on top of the -generic selection logic from cli.dispatch. It does not define its -own subparser; the CLI surface is configured in cli.parser. - -Responsibilities: - - Take the parsed argparse.Namespace for the `release` command. - - Use the list of selected repositories provided by dispatch_command(). - - Optionally list affected repositories when --list is set. - - For each selected repository, run pkgmgr.actions.release.release(...) in - the context of that repository directory. -""" - from __future__ import annotations import os +import sys from typing import Any, Dict, List +from pkgmgr.actions.publish import publish as run_publish +from pkgmgr.actions.release import release as run_release from pkgmgr.cli.context import CLIContext from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.identifier import get_repo_identifier -from pkgmgr.actions.release import release as run_release - Repository = Dict[str, Any] @@ -35,23 +21,10 @@ def handle_release( ctx: CLIContext, selected: List[Repository], ) -> None: - """ - Handle the `pkgmgr release` subcommand. - - Flow: - 1) Use the `selected` repositories as computed by dispatch_command(). - 2) If --list is given, print the identifiers of the selected repos - and return without running any release. - 3) For each selected repository: - - Resolve its identifier and local directory. - - Change into that directory. - - Call pkgmgr.actions.release.release(...) with the parsed options. - """ if not selected: print("[pkgmgr] No repositories selected for release.") return - # List-only mode: show which repositories would be affected. if getattr(args, "list", False): print("[pkgmgr] Repositories that would be affected by this release:") for repo in selected: @@ -62,29 +35,22 @@ def handle_release( for repo in selected: identifier = get_repo_identifier(repo, ctx.all_repositories) - repo_dir = repo.get("directory") - if not repo_dir: - try: - repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) - except Exception: - repo_dir = None - - if not repo_dir or not os.path.isdir(repo_dir): - print( - f"[WARN] Skipping repository {identifier}: " - "local directory does not exist." - ) + try: + repo_dir = repo.get("directory") or get_repo_dir(ctx.repositories_base_dir, repo) + except Exception as exc: + print(f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}") continue - print( - f"[pkgmgr] Running release for repository {identifier} " - f"in '{repo_dir}'..." - ) + if not os.path.isdir(repo_dir): + print(f"[WARN] Skipping repository {identifier}: directory missing.") + continue + + print(f"[pkgmgr] Running release for repository {identifier}...") - # Change to repo directory and invoke the helper. cwd_before = os.getcwd() try: os.chdir(repo_dir) + run_release( pyproject_path="pyproject.toml", changelog_path="CHANGELOG.md", @@ -94,5 +60,17 @@ def handle_release( force=getattr(args, "force", False), close=getattr(args, "close", False), ) + + if not getattr(args, "no_publish", False): + print(f"[pkgmgr] Running publish for repository {identifier}...") + is_tty = sys.stdin.isatty() + run_publish( + repo=repo, + repo_dir=repo_dir, + preview=getattr(args, "preview", False), + interactive=is_tty, + allow_prompt=is_tty, + ) + finally: os.chdir(cwd_before) diff --git a/src/pkgmgr/cli/parser/release_cmd.py b/src/pkgmgr/cli/parser/release_cmd.py index 472be81..0ba63d3 100644 --- a/src/pkgmgr/cli/parser/release_cmd.py +++ b/src/pkgmgr/cli/parser/release_cmd.py @@ -21,22 +21,22 @@ def add_release_subparser( "and updating the changelog." ), ) + release_parser.add_argument( "release_type", choices=["major", "minor", "patch"], help="Type of version increment for the release (major, minor, patch).", ) + release_parser.add_argument( "-m", "--message", default=None, - help=( - "Optional release message to add to the changelog and tag." - ), + help="Optional release message to add to the changelog and tag.", ) - # Generic selection / preview / list / extra_args + add_identifier_arguments(release_parser) - # Close current branch after successful release + release_parser.add_argument( "--close", action="store_true", @@ -45,7 +45,7 @@ def add_release_subparser( "repository, if it is not main/master." ), ) - # Force: skip preview+confirmation and run release directly + release_parser.add_argument( "-f", "--force", @@ -55,3 +55,9 @@ def add_release_subparser( "release directly." ), ) + + release_parser.add_argument( + "--no-publish", + action="store_true", + help="Do not run publish automatically after a successful release.", + ) diff --git a/tests/integration/test_release_publish_hook.py b/tests/integration/test_release_publish_hook.py new file mode 100644 index 0000000..6187b13 --- /dev/null +++ b/tests/integration/test_release_publish_hook.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class TestIntegrationReleasePublishHook(unittest.TestCase): + def _ctx(self) -> SimpleNamespace: + # Minimal CLIContext shape used by handle_release() + return SimpleNamespace( + repositories_base_dir="/tmp", + all_repositories=[], + ) + + def _parse(self, argv: list[str]): + from pkgmgr.cli.parser import create_parser + + parser = create_parser("pkgmgr test") + return parser.parse_args(argv) + + def test_release_runs_publish_by_default_and_respects_tty(self) -> None: + from pkgmgr.cli.commands.release import handle_release + + with tempfile.TemporaryDirectory() as td: + selected = [{"directory": td}] + + # Go through real parser to ensure CLI surface is wired correctly + args = self._parse(["release", "patch"]) + + with patch("pkgmgr.cli.commands.release.run_release") as m_release, patch( + "pkgmgr.cli.commands.release.run_publish" + ) as m_publish, patch( + "pkgmgr.cli.commands.release.sys.stdin.isatty", return_value=False + ): + handle_release(args=args, ctx=self._ctx(), selected=selected) + + m_release.assert_called_once() + m_publish.assert_called_once() + + _, kwargs = m_publish.call_args + self.assertEqual(kwargs["repo"], selected[0]) + self.assertEqual(kwargs["repo_dir"], td) + self.assertFalse(kwargs["interactive"]) + self.assertFalse(kwargs["allow_prompt"]) + + def test_release_skips_publish_when_no_publish_flag_set(self) -> None: + from pkgmgr.cli.commands.release import handle_release + + with tempfile.TemporaryDirectory() as td: + selected = [{"directory": td}] + + args = self._parse(["release", "patch", "--no-publish"]) + + with patch("pkgmgr.cli.commands.release.run_release") as m_release, patch( + "pkgmgr.cli.commands.release.run_publish" + ) as m_publish: + handle_release(args=args, ctx=self._ctx(), selected=selected) + + m_release.assert_called_once() + m_publish.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/cli/commands/test_release_publish_hook.py b/tests/unit/pkgmgr/cli/commands/test_release_publish_hook.py new file mode 100644 index 0000000..6d5eb28 --- /dev/null +++ b/tests/unit/pkgmgr/cli/commands/test_release_publish_hook.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class TestCLIReleasePublishHook(unittest.TestCase): + def _ctx(self) -> SimpleNamespace: + # Minimal CLIContext shape used by handle_release + return SimpleNamespace( + repositories_base_dir="/tmp", + all_repositories=[], + ) + + def test_release_runs_publish_by_default_and_respects_tty(self) -> None: + from pkgmgr.cli.commands.release import handle_release + + with tempfile.TemporaryDirectory() as td: + repo = {"directory": td} + + args = SimpleNamespace( + list=False, + release_type="patch", + message=None, + preview=False, + force=False, + close=False, + no_publish=False, + ) + + with patch("pkgmgr.cli.commands.release.run_release") as m_release, patch( + "pkgmgr.cli.commands.release.run_publish" + ) as m_publish, patch( + "pkgmgr.cli.commands.release.sys.stdin.isatty", return_value=False + ): + handle_release(args=args, ctx=self._ctx(), selected=[repo]) + + m_release.assert_called_once() + m_publish.assert_called_once() + + _, kwargs = m_publish.call_args + self.assertEqual(kwargs["repo"], repo) + self.assertEqual(kwargs["repo_dir"], td) + self.assertFalse(kwargs["interactive"]) + self.assertFalse(kwargs["allow_prompt"]) + + def test_release_skips_publish_when_no_publish_flag_set(self) -> None: + from pkgmgr.cli.commands.release import handle_release + + with tempfile.TemporaryDirectory() as td: + repo = {"directory": td} + + args = SimpleNamespace( + list=False, + release_type="patch", + message=None, + preview=False, + force=False, + close=False, + no_publish=True, + ) + + with patch("pkgmgr.cli.commands.release.run_release") as m_release, patch( + "pkgmgr.cli.commands.release.run_publish" + ) as m_publish: + handle_release(args=args, ctx=self._ctx(), selected=[repo]) + + m_release.assert_called_once() + m_publish.assert_not_called() + + +if __name__ == "__main__": + unittest.main()