DEVX-43: feat: add publish step to post-merge release job, make publish idempotent
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Failing after 44s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 44s

This commit was merged in pull request #67.
This commit is contained in:
2026-06-24 22:16:37 +00:00
parent 8f7af97335
commit fd0c4de31e
4 changed files with 81 additions and 1 deletions
+20 -1
View File
@@ -75,7 +75,7 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
timeout-minutes: 10 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -94,6 +94,25 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release 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 - name: Notify on failure
if: failure() if: failure()
env: env:
+11
View File
@@ -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.")) click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo) 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) release_body = generate_release_notes(tag)
try: try:
+7
View File
@@ -657,6 +657,13 @@
"ru": "Директория molecule не найдена: {path}", "ru": "Директория molecule не найдена: {path}",
"zh": "未找到 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.": { "Nice! Gitea release {tag} created.": {
"bg": "Отлично! Gitea release {tag} е създаден.", "bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.", "de": "Prima! Gitea-Release {tag} erstellt.",
+43
View File
@@ -162,6 +162,7 @@ class TestMain:
mock_notes: MagicMock, mock_notes: MagicMock,
) -> None: ) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -187,6 +188,7 @@ class TestMain:
) -> None: ) -> None:
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry.""" """When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -209,6 +211,7 @@ class TestMain:
) -> None: ) -> None:
"""--registry-url flag publishes to the specified Gitea registry.""" """--registry-url flag publishes to the specified Gitea registry."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -236,6 +239,7 @@ class TestMain:
) -> None: ) -> None:
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL.""" """DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -256,6 +260,7 @@ class TestMain:
) -> None: ) -> None:
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only.""" """When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""]) 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 self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None: ) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error") mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
@@ -323,6 +329,7 @@ class TestMain:
) -> None: ) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release.""" """--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
@@ -330,3 +337,39 @@ class TestMain:
assert "skip" in result.output.lower() assert "skip" in result.output.lower()
mock_build.assert_not_called() mock_build.assert_not_called()
mock_tea.create_release.assert_called_once() 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()