feat(release): auto-run publish after release with --no-publish opt-out
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / linter-shell (push) Has been cancelled
Mark stable commit / linter-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled

- 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
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-14 22:59:43 +01:00
parent 783d2b921a
commit 48a0d1d458
4 changed files with 179 additions and 53 deletions

View File

@@ -1,31 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- 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 from __future__ import annotations
import os import os
import sys
from typing import Any, Dict, List 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.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.actions.release import release as run_release
Repository = Dict[str, Any] Repository = Dict[str, Any]
@@ -35,23 +21,10 @@ def handle_release(
ctx: CLIContext, ctx: CLIContext,
selected: List[Repository], selected: List[Repository],
) -> None: ) -> 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: if not selected:
print("[pkgmgr] No repositories selected for release.") print("[pkgmgr] No repositories selected for release.")
return return
# List-only mode: show which repositories would be affected.
if getattr(args, "list", False): if getattr(args, "list", False):
print("[pkgmgr] Repositories that would be affected by this release:") print("[pkgmgr] Repositories that would be affected by this release:")
for repo in selected: for repo in selected:
@@ -62,29 +35,22 @@ def handle_release(
for repo in selected: for repo in selected:
identifier = get_repo_identifier(repo, ctx.all_repositories) identifier = get_repo_identifier(repo, ctx.all_repositories)
repo_dir = repo.get("directory")
if not repo_dir:
try: try:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) repo_dir = repo.get("directory") or get_repo_dir(ctx.repositories_base_dir, repo)
except Exception: except Exception as exc:
repo_dir = None print(f"[WARN] Skipping repository {identifier}: failed to resolve directory: {exc}")
if not repo_dir or not os.path.isdir(repo_dir):
print(
f"[WARN] Skipping repository {identifier}: "
"local directory does not exist."
)
continue continue
print( if not os.path.isdir(repo_dir):
f"[pkgmgr] Running release for repository {identifier} " print(f"[WARN] Skipping repository {identifier}: directory missing.")
f"in '{repo_dir}'..." continue
)
print(f"[pkgmgr] Running release for repository {identifier}...")
# Change to repo directory and invoke the helper.
cwd_before = os.getcwd() cwd_before = os.getcwd()
try: try:
os.chdir(repo_dir) os.chdir(repo_dir)
run_release( run_release(
pyproject_path="pyproject.toml", pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md", changelog_path="CHANGELOG.md",
@@ -94,5 +60,17 @@ def handle_release(
force=getattr(args, "force", False), force=getattr(args, "force", False),
close=getattr(args, "close", 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: finally:
os.chdir(cwd_before) os.chdir(cwd_before)

View File

@@ -21,22 +21,22 @@ def add_release_subparser(
"and updating the changelog." "and updating the changelog."
), ),
) )
release_parser.add_argument( release_parser.add_argument(
"release_type", "release_type",
choices=["major", "minor", "patch"], choices=["major", "minor", "patch"],
help="Type of version increment for the release (major, minor, patch).", help="Type of version increment for the release (major, minor, patch).",
) )
release_parser.add_argument( release_parser.add_argument(
"-m", "-m",
"--message", "--message",
default=None, default=None,
help=( help="Optional release message to add to the changelog and tag.",
"Optional release message to add to the changelog and tag."
),
) )
# Generic selection / preview / list / extra_args
add_identifier_arguments(release_parser) add_identifier_arguments(release_parser)
# Close current branch after successful release
release_parser.add_argument( release_parser.add_argument(
"--close", "--close",
action="store_true", action="store_true",
@@ -45,7 +45,7 @@ def add_release_subparser(
"repository, if it is not main/master." "repository, if it is not main/master."
), ),
) )
# Force: skip preview+confirmation and run release directly
release_parser.add_argument( release_parser.add_argument(
"-f", "-f",
"--force", "--force",
@@ -55,3 +55,9 @@ def add_release_subparser(
"release directly." "release directly."
), ),
) )
release_parser.add_argument(
"--no-publish",
action="store_true",
help="Do not run publish automatically after a successful release.",
)

View File

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

View File

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