diff --git a/src/devx/ci/classify_changes.py b/src/devx/ci/classify_changes.py index 4baece0..d812ee8 100644 --- a/src/devx/ci/classify_changes.py +++ b/src/devx/ci/classify_changes.py @@ -627,6 +627,10 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo classifier = _get_classifier() available_tags = list(classifier.config.tags.keys()) + # --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch) + if os.environ.get("FORCE_DEPLOY", "").lower() == "true": + force = True + if force and github_output: _write_github_output("user-facing-changed", "true") for tag in available_tags: diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index 10a10c2..f6b6981 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -169,8 +169,36 @@ def _default_gitea_registry_url() -> str: return f"{base}/api/packages/{owner}/pypi" +def get_latest_tag() -> str | None: + """Get the latest git tag, or None if no tags exist.""" + try: + result = subprocess.run( # nosec + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return None + + +def is_release_commit(tag: str) -> bool: + """Check if HEAD commit message starts with 'release: '.""" + try: + result = subprocess.run( # nosec + ["git", "log", "-1", "--format=%s"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip().startswith(f"release: {tag}") + except subprocess.CalledProcessError: + return False + + @click.command() -@click.argument("tag") +@click.argument("tag", required=False) @click.argument("repo") @click.option( "--registry-url", @@ -186,7 +214,33 @@ def _default_gitea_registry_url() -> str: help="Skip package build and PyPI publish (for non-Python repos that only " "need a Gitea release with git-cliff notes).", ) -def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None: +@click.option( + "--from-tag", + is_flag=True, + default=False, + help="Auto-detect latest tag and check if HEAD is a release commit. " + "Skips publish if no tag or HEAD is not a release commit for that tag.", +) +def main( + tag: str | None, + repo: str, + registry_url: str | None, + skip_build: bool, + from_tag: bool, +) -> None: + if from_tag: + detected_tag = get_latest_tag() + if not detected_tag: + click.echo(_("No tag found — skipping publish.")) + return + if not is_release_commit(detected_tag): + click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag)) + return + tag = detected_tag + click.echo(_("Publishing release {tag}...", tag=tag)) + + if not tag: + raise click.ClickException(_("Tag is required (or use --from-tag).")) gitea_token = os.environ.get("REPO_TOKEN", "") if not gitea_token: raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) diff --git a/src/devx/ci/validate_commit_msg.py b/src/devx/ci/validate_commit_msg.py index b0f4a0a..ca5b0b5 100644 --- a/src/devx/ci/validate_commit_msg.py +++ b/src/devx/ci/validate_commit_msg.py @@ -14,6 +14,7 @@ task ID format for each project. import re import subprocess # nosec B404 +import sys import click @@ -23,6 +24,17 @@ from devx.i18n import _ MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:") +def get_latest_commit_msg() -> str: + """Get the latest commit message from git.""" + result = subprocess.run( # nosec + ["git", "log", "-1", "--format=%B"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + def first_line(text: str) -> str: return text.split("\n")[0] @@ -41,11 +53,26 @@ def get_branch() -> str: @click.command() -@click.argument("commit_msg_file") +@click.argument("commit_msg_file", required=False) @click.option("--branch", default=None, help="Override branch detection (for CI use).") -def main(commit_msg_file: str, branch: str | None) -> None: - with open(commit_msg_file) as f: - msg = f.read().strip() +@click.option( + "--git", + "from_git", + is_flag=True, + default=False, + help="Read commit message from git log instead of a file.", +) +def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None: + if from_git: + msg = get_latest_commit_msg() + elif commit_msg_file: + if commit_msg_file == "-": + msg = sys.stdin.read().strip() + else: + with open(commit_msg_file) as f: + msg = f.read().strip() + else: + raise click.ClickException(_("Provide a commit message file or use --git.")) if branch is None: branch = get_branch() diff --git a/src/devx/translations.json b/src/devx/translations.json index 8cf7843..a5c64a4 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -599,6 +599,14 @@ "ru": "Generated {file} with prefix '{prefix}'.", "zh": "Generated {file} with prefix '{prefix}'." }, + "Gitea PyPI registry: {tag} already published — continuing.": { + "bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.", + "de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.", + "en": "Gitea PyPI registry: {tag} already published — continuing.", + "pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.", + "ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.", + "zh": "Gitea PyPI registry: {tag} 已发布 — 继续。" + }, "Gitea release {tag} already exists — skipping creation.": { "bg": "Gitea release {tag} вече съществува — прескачане на създаването.", "de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.", @@ -631,6 +639,14 @@ "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping." }, + "HEAD is not a release commit for {tag} — skipping publish.": { + "bg": "HEAD is not a release commit for {tag} — skipping publish.", + "de": "HEAD is not a release commit for {tag} — skipping publish.", + "en": "HEAD is not a release commit for {tag} — skipping publish.", + "pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.", + "ru": "HEAD is not a release commit for {tag} — skipping publish.", + "zh": "HEAD is not a release commit for {tag} — skipping publish." + }, "HTTP error: {status} — {message}": { "bg": "HTTP грешка: {status} — {message}", "de": "HTTP-Fehler: {status} — {message}", @@ -791,6 +807,14 @@ "ru": "No staged changes — version and changelog already up to date.", "zh": "No staged changes — version and changelog already up to date." }, + "No tag found — skipping publish.": { + "bg": "No tag found — skipping publish.", + "de": "No tag found — skipping publish.", + "en": "No tag found — skipping publish.", + "pl": "Nie znaleziono tagu — pomijanie publikacji.", + "ru": "No tag found — skipping publish.", + "zh": "No tag found — skipping publish." + }, "No tags found — treating all changes as user-facing.": { "bg": "No tags found — treating all changes as user-facing.", "de": "No tags found — treating all changes as user-facing.", @@ -911,14 +935,6 @@ "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", "zh": "哎呀!PyPI 发布失败:\n{stderr}" }, - "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": { - "bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}", - "de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}", - "en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}", - "pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}", - "ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}", - "zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}" - }, "PASSED: {pair}": { "bg": "PASSED: {pair}", "de": "PASSED: {pair}", @@ -967,6 +983,14 @@ "ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit." }, + "Provide a commit message file or use --git.": { + "bg": "Provide a commit message file or use --git.", + "de": "Provide a commit message file or use --git.", + "en": "Provide a commit message file or use --git.", + "pl": "Podaj plik komunikatu commitu lub użyj --git.", + "ru": "Provide a commit message file or use --git.", + "zh": "Provide a commit message file or use --git." + }, "Published to Gitea PyPI registry.": { "bg": "Публикувано в Gitea PyPI registry.", "de": "In der Gitea PyPI-Registry veröffentlicht.", @@ -975,14 +999,6 @@ "ru": "Опубликовано в Gitea PyPI registry.", "zh": "已发布到 Gitea PyPI registry。" }, - "Gitea PyPI registry: {tag} already published — continuing.": { - "bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.", - "de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.", - "en": "Gitea PyPI registry: {tag} already published — continuing.", - "pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.", - "ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.", - "zh": "Gitea PyPI registry: {tag} 已发布 — 继续。" - }, "Published to PyPI.": { "bg": "Публикувано в PyPI.", "de": "In PyPI veröffentlicht.", @@ -991,6 +1007,14 @@ "ru": "Опубликовано в PyPI.", "zh": "已发布到 PyPI。" }, + "Publishing release {tag}...": { + "bg": "Publishing release {tag}...", + "de": "Publishing release {tag}...", + "en": "Publishing release {tag}...", + "pl": "Publikowanie wydania {tag}...", + "ru": "Publishing release {tag}...", + "zh": "Publishing release {tag}..." + }, "Pushed release commit to master.": { "bg": "Pushed release commit to master.", "de": "Pushed release commit to master.", @@ -999,6 +1023,14 @@ "ru": "Pushed release commit to master.", "zh": "Pushed release commit to master." }, + "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": { + "bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}", + "de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}", + "en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}", + "pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}", + "ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}", + "zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}" + }, "Release creation failed: {error}": { "bg": "Release creation failed: {error}", "de": "Release creation failed: {error}", @@ -1095,6 +1127,14 @@ "ru": "Tag consistency check failed.", "zh": "Tag consistency check failed." }, + "Tag is required (or use --from-tag).": { + "bg": "Tag is required (or use --from-tag).", + "de": "Tag is required (or use --from-tag).", + "en": "Tag is required (or use --from-tag).", + "pl": "Tag jest wymagany (lub użyj --from-tag).", + "ru": "Tag is required (or use --from-tag).", + "zh": "Tag is required (or use --from-tag)." + }, "Tag v{version} already existed. Publish workflow should already have been triggered.": { "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", "de": "Tag v{version} already existed. Publish workflow should already have been triggered.", diff --git a/tests/unit/test_classify_changes.py b/tests/unit/test_classify_changes.py index 785982b..a5ebdde 100644 --- a/tests/unit/test_classify_changes.py +++ b/tests/unit/test_classify_changes.py @@ -788,3 +788,49 @@ class TestGithubOutput: content = gh_file.read_text() assert "user-facing-changed=true" in content assert "ansible-changed" not in content + + @patch("devx.ci.classify_changes._get_classifier") + def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_DEPLOY=true env var activates force mode without --force flag.""" + mock_clf.return_value = self._make_classifier_with_ansible() + gh_file = tmp_path / "output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) + monkeypatch.setenv("FORCE_DEPLOY", "true") + runner = CliRunner() + result = runner.invoke(main, ["--github-output"]) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "user-facing-changed=true" in content + assert "ansible-changed=true" in content + + @patch("devx.ci.classify_changes._get_classifier") + def test_force_deploy_env_var_false( + self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """FORCE_DEPLOY=false does not activate force mode.""" + mock_clf.return_value = self._make_classifier_with_ansible() + gh_file = tmp_path / "output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) + monkeypatch.setenv("FORCE_DEPLOY", "false") + with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"): + with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): + runner = CliRunner() + result = runner.invoke(main, ["--github-output"]) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "user-facing-changed=false" in content + + @patch("devx.ci.classify_changes._get_classifier") + def test_force_flag_overrides_env_var( + self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """--force flag works even when FORCE_DEPLOY=false.""" + mock_clf.return_value = self._make_classifier_with_ansible() + gh_file = tmp_path / "output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) + monkeypatch.setenv("FORCE_DEPLOY", "false") + runner = CliRunner() + result = runner.invoke(main, ["--github-output", "--force"]) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "user-facing-changed=true" in content diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index d82b557..a86b3d9 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -11,6 +11,8 @@ from devx.ci.publish import ( _default_gitea_registry_url, build_package, generate_release_notes, + get_latest_tag, + is_release_commit, main, publish_to_gitea_registry, publish_to_pypi, @@ -385,5 +387,82 @@ class TestMain: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 - assert "Gitea release v1.0.0 created" in result.output - mock_tea.create_release.assert_called_once() + + +class TestFromTag: + def test_get_latest_tag_success(self) -> None: + import subprocess + + with patch("devx.ci.publish.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n") + result = get_latest_tag() + assert result == "v1.2.3" + + def test_get_latest_tag_no_tags(self) -> None: + import subprocess + + with patch("devx.ci.publish.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, []) + result = get_latest_tag() + assert result is None + + def test_is_release_commit_match(self) -> None: + import subprocess + + with patch("devx.ci.publish.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n" + ) + result = is_release_commit("v1.2.3") + assert result is True + + def test_is_release_commit_no_match(self) -> None: + import subprocess + + with patch("devx.ci.publish.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n") + result = is_release_commit("v1.2.3") + assert result is False + + def test_is_release_commit_git_error(self) -> None: + import subprocess + + with patch("devx.ci.publish.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, []) + result = is_release_commit("v1.2.3") + assert result is False + + @patch("devx.ci.publish.get_latest_tag", return_value=None) + def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) + assert result.exit_code == 0 + assert "No tag found" in result.output + + @patch("devx.ci.publish.is_release_commit", return_value=False) + @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") + def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) + assert result.exit_code == 0 + assert "not a release commit" in result.output + + @patch("devx.ci.publish.is_release_commit", return_value=True) + @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") + def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] + mock_tea_cls.return_value = mock_tea + with patch("devx.ci.publish.generate_release_notes", return_value="notes"): + runner = CliRunner() + result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) + assert result.exit_code == 0 + assert "Publishing release v1.0.0" in result.output + + def test_no_tag_no_from_tag_raises(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["", "owner/repo", "--skip-build"]) + assert result.exit_code != 0 + assert "Tag is required" in result.output diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py index b2ec8ee..dbbff1f 100644 --- a/tests/unit/test_validate_commit_msg.py +++ b/tests/unit/test_validate_commit_msg.py @@ -7,7 +7,7 @@ from unittest.mock import patch from click.testing import CliRunner -from devx.ci.validate_commit_msg import first_line, get_branch, main +from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main from devx.config import CONVENTIONAL_RE, TASK_ID_RE @@ -126,7 +126,7 @@ class TestMain: def test_usage_message_without_args(self) -> None: runner = CliRunner() result = runner.invoke(main, []) - assert result.exit_code == 2 + assert result.exit_code != 0 def test_branch_override_accepts_master_commit(self) -> None: """--branch master overrides branch detection (for CI use).""" @@ -257,3 +257,45 @@ def test_main_module_block() -> None: namespace["main"]([msg_path], standalone_mode=False) os.unlink(msg_path) + + +class TestGitMode: + def test_git_flag_reads_from_git(self, tmp_path) -> None: + with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"): + with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"): + runner = CliRunner() + result = runner.invoke(main, ["--git"]) + assert result.exit_code == 0 + + def test_git_flag_master_valid(self) -> None: + msg = "DEVX-24: fix: resolve timeout" + with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg): + with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): + runner = CliRunner() + result = runner.invoke(main, ["--git", "--branch", "master"]) + assert result.exit_code == 0 + + def test_git_flag_master_invalid(self) -> None: + msg = "fix: resolve timeout" + with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg): + with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): + runner = CliRunner() + result = runner.invoke(main, ["--git", "--branch", "master"]) + assert result.exit_code != 0 + + def test_no_file_no_git_raises(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--branch", "master"]) + assert result.exit_code != 0 + + def test_get_latest_commit_msg_success(self) -> None: + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody") + result = get_latest_commit_msg() + assert result == "feat: test\n\nBody" + + def test_stdin_input(self) -> None: + with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"): + runner = CliRunner() + result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"]) + assert result.exit_code == 0