diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 1372cb5..9d3470d 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -108,13 +108,8 @@ jobs: echo "No tag found — skipping publish" exit 0 fi - HEAD_MSG=$(git log -1 --format=%s) - if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then - echo "Publishing release $TAG..." - python3 -m devx.ci.publish "$TAG" "${{ github.repository }}" - else - echo "HEAD is not a release commit for $TAG — skipping publish" - fi + echo "Publishing release $TAG (idempotent — skips if already published)..." + python3 -m devx.ci.publish "$TAG" "${{ github.repository }}" - name: Notify on failure if: failure() env: diff --git a/src/devx/ci/detect_release_commit.py b/src/devx/ci/detect_release_commit.py index d7695df..72ac930 100644 --- a/src/devx/ci/detect_release_commit.py +++ b/src/devx/ci/detect_release_commit.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Detect whether the latest git commit is a release commit. -Release commits have the format ``release: vX.Y.Z [skip ci]``. +Release commits have the format ``release: vX.Y.Z``. This script writes ``is-release=true`` or ``is-release=false`` to ``$GITHUB_OUTPUT`` for use in CI workflow conditionals. diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index 592ff63..4c1dd50 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None: check=False, ) if result.returncode != 0: - raise click.ClickException( - _( - "Oops! Gitea PyPI registry publish failed:\n{stderr}", - stderr=result.stderr.strip(), + # Twine writes errors to stdout (not stderr), so check both. + combined = f"{result.stdout}\n{result.stderr}".strip() + # 409 Conflict means the package version is already published — + # this is not an error, just a sign we're re-running publish. + if "409" in combined or "Conflict" in combined: + click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag="")) + else: + raise click.ClickException( + _( + "Oops! Gitea PyPI registry publish failed:\n{stderr}", + stderr=combined, + ) ) - ) - click.echo(_("Published to Gitea PyPI registry.")) + else: + click.echo(_("Published to Gitea PyPI registry.")) def _default_gitea_registry_url() -> str: diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index a519f38..8238a38 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool: if status.returncode == 0: click.echo(_("No staged changes — version and changelog already up to date.")) return False - run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"]) + run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"]) return True diff --git a/src/devx/translations.json b/src/devx/translations.json index 240bfad..1d8fa86 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -967,6 +967,14 @@ "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.", diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 96dd00b..409d415 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -122,11 +122,18 @@ class TestPublishToGiteaRegistry: @patch("devx.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed") + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed") with pytest.raises(click.ClickException) as exc: publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") assert "Gitea PyPI registry" in str(exc.value) + @patch("devx.ci.publish.subprocess.run") + def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None: + """409 Conflict (already published) should not raise — just continue.""" + mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="") + # Should not raise + publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") + class TestDefaultGiteaRegistryUrl: @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index 64ea42c..fc1efd6 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -766,7 +766,7 @@ class TestCommitReleaseChanges: assert result is True calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls - assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls + assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls @patch("devx.ci.release.run_cmd") def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None: