From fd0c4de31e861ed870c357610c83d5d3e1284ad6 Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 24 Jun 2026 22:16:37 +0000 Subject: [PATCH] DEVX-43: feat: add publish step to post-merge release job, make publish idempotent --- .gitea/workflows/post-merge.yml | 21 +++++++++++++++- src/devx/ci/publish.py | 11 +++++++++ src/devx/translations.json | 7 ++++++ tests/unit/test_publish.py | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 54c9c23..12b2303 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -75,7 +75,7 @@ jobs: needs: [detect-type] if: needs.detect-type.outputs.is-release == 'false' runs-on: docker - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -94,6 +94,25 @@ jobs: . .venv/bin/activate export PATH="$HOME/.local/bin:$PATH" python3 -m devx.ci.release + - name: Publish release + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + PYTHONPATH: src + run: | + . .venv/bin/activate + export PATH="$HOME/.local/bin:$PATH" + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$TAG" ]; then + 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 - name: Notify on failure if: failure() env: diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index 4fed5d5..592ff63 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -211,6 +211,17 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non click.echo(_("--skip-build: skipping package build and PyPI publish.")) tea = TeaCLI(repo=repo) + + # Check if release already exists (idempotent — avoids failure when + # called multiple times, e.g. by both post-merge and publish workflows) + try: + releases = tea.list_releases(repo) + if any(r.get("tag_name") == tag for r in releases): + click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag)) + return + except TeaCLIError: + pass # If listing fails, proceed to create + release_body = generate_release_notes(tag) try: diff --git a/src/devx/translations.json b/src/devx/translations.json index 0d0b913..ccc08bb 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -657,6 +657,13 @@ "ru": "Директория molecule не найдена: {path}", "zh": "未找到 molecule 目录: {path}" }, + "Gitea release {tag} already exists — skipping creation.": { + "bg": "Gitea release {tag} вече съществува — прескачане на създаването.", + "de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.", + "en": "Gitea release {tag} already exists — skipping creation.", + "ru": "Gitea release {tag} уже существует — пропуск создания.", + "zh": "Gitea release {tag} 已存在 — 跳过创建。" + }, "Nice! Gitea release {tag} created.": { "bg": "Отлично! Gitea release {tag} е създаден.", "de": "Prima! Gitea-Release {tag} erstellt.", diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 416508f..96dd00b 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -162,6 +162,7 @@ class TestMain: mock_notes: MagicMock, ) -> None: mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) @@ -187,6 +188,7 @@ class TestMain: ) -> None: """When no PYPI_TOKEN, publishes to Gitea PyPI registry.""" mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) @@ -209,6 +211,7 @@ class TestMain: ) -> None: """--registry-url flag publishes to the specified Gitea registry.""" mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke( @@ -236,6 +239,7 @@ class TestMain: ) -> None: """DEVX_PYPI_REGISTRY_URL env var sets the registry URL.""" mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) @@ -256,6 +260,7 @@ class TestMain: ) -> None: """When no PYPI_TOKEN and no registry URL, skips publish and creates release only.""" mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""]) @@ -307,6 +312,7 @@ class TestMain: self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea.create_release.side_effect = TeaCLIError("server error") mock_tea_cls.return_value = mock_tea runner = CliRunner() @@ -323,6 +329,7 @@ class TestMain: ) -> None: """--skip-build skips build_package and PyPI publish, only creates Gitea release.""" mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) @@ -330,3 +337,39 @@ class TestMain: assert "skip" in result.output.lower() mock_build.assert_not_called() mock_tea.create_release.assert_called_once() + + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") + @patch("devx.ci.publish.TeaCLI") + @patch("devx.ci.publish.publish_to_pypi") + @patch("devx.ci.publish.build_package") + def test_skips_release_creation_when_already_exists( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock + ) -> None: + """If the Gitea release already exists, skip creation (idempotent).""" + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}] + mock_tea_cls.return_value = mock_tea + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 0 + assert "already exists" in result.output + mock_tea.create_release.assert_not_called() + + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") + @patch("devx.ci.publish.TeaCLI") + @patch("devx.ci.publish.publish_to_pypi") + @patch("devx.ci.publish.build_package") + def test_proceeds_to_create_when_list_releases_fails( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock + ) -> None: + """If list_releases raises TeaCLIError, proceed to create the release.""" + mock_tea = MagicMock() + mock_tea.list_releases.side_effect = TeaCLIError("api error") + mock_tea_cls.return_value = mock_tea + 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()