From 2698145a3692fc390c8a7615a114c6cc3663def0 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 16 Jul 2026 16:24:38 +0200 Subject: [PATCH] fix: tea CLI login failure handling, error messages, release retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configure_tea_login silently ignored tea login add/default failures, causing cryptic "no available login" errors in subsequent tea commands. Now raises TeaCLIError with stdout/stderr on failure. TeaCLI._run now includes stdout in error messages (tea writes some errors like "no available login" to stdout, not stderr). publish.py retries Gitea release creation up to 3 times with exponential backoff (2s, 4s) on transient failures. "Already exists" errors are treated as success (idempotent, no retry). Root cause: CI run #2822 — post-merge/release-and-maintain failed creating Gitea release v0.38.1 because tea login add silently failed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 16 ++++++- src/devx/ci/publish.py | 44 ++++++++++++++++--- src/devx/gitea_cli.py | 33 +++++++++++--- tests/unit/test_gitea_cli.py | 61 ++++++++++++++++++++++++-- tests/unit/test_publish.py | 84 +++++++++++++++++++++++++++++++++++- 5 files changed, 220 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88c1392..695f3c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ src/devx/ ├── translations.json # Translation strings (en, bg, de, pl, ru, zh) ├── ci/ # CI/CD automation modules (run by workflows) │ ├── release.py # Automated versioning, tagging, changelog -│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos) +│ ├── publish.py # Build, publish to Gitea PyPI registry, create Gitea release (with retry) │ ├── auto_merge.py # Squash-merge PRs with task ID validation │ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master) │ ├── _shared.py # Shared utilities (get_latest_tag) @@ -310,6 +310,20 @@ by `python -m devx.tools.install_tools` and configured by - `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations - `create_release()` / `list_releases()` — Release management +**`devx.gitea_cli.configure_tea_login()`** — Configures tea login in +containerized CI environments where `make setup` was not called. Used by +`publish.py` (`--auto-login`) and `notify_failure.py` (`--auto-login`). +Raises `TeaCLIError` if login configuration fails — this prevents cryptic +"no available login" errors from subsequent tea commands. + +**Error handling**: `TeaCLI._run()` includes both stdout and stderr in +`TeaCLIError` messages, because `tea` writes some errors (for example, +"no available login") to stdout, not stderr. + +**Release creation retry**: `publish.py` retries Gitea release creation +up to 3 times with exponential backoff (2s, 4s) on transient failures. +"Already exists" errors are treated as success (idempotent). + ### git-cliff Commit Preprocessing Merge commits on master have the format `DEVX-N: `. The diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index a0ff244..b838d69 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -4,6 +4,10 @@ Uses git-cliff to generate the release notes from conventional commits. Uses the ``tea`` Gitea CLI for release creation. +Gitea release creation is retried up to 3 times with exponential backoff +(2s, 4s) to handle transient failures (network timeouts, 5xx errors). +If the release already exists, it is treated as success (idempotent). + Publishing destinations (checked in order): 1. **Gitea PyPI registry** — if ``--registry-url`` is given (or ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL`` @@ -27,6 +31,7 @@ from pathlib import Path import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from devx.config import GITEA_API_URL, REPO_OWNER from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login @@ -312,13 +317,7 @@ def main( release_body = generate_release_notes(tag) - try: - tea.create_release(repo, tag=tag, title=tag, body=release_body) - except TeaCLIError as e: - if "already" in str(e).lower() and "release" in str(e).lower(): - click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag)) - return - raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None + _create_release_with_retry(tea, repo, tag, release_body) click.echo( _( @@ -328,5 +327,36 @@ def main( ) +def _create_release_with_retry(tea: TeaCLI, repo: str, tag: str, release_body: str) -> None: + """Create a Gitea release with retry for transient failures. + + Retries up to 3 times with exponential backoff (2s, 4s) on TeaCLIError + unless the error indicates the release already exists (which is treated + as success). This handles transient issues like network timeouts, Gitea + rate limiting, or temporary 5xx errors that caused CI run #2822 to fail. + """ + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=2, min=2, max=10), + retry=retry_if_exception_type(TeaCLIError), + reraise=True, + ) + def _attempt() -> None: + try: + tea.create_release(repo, tag=tag, title=tag, body=release_body) + except TeaCLIError as e: + error_str = str(e).lower() + if "already" in error_str and "release" in error_str: + click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag)) + return + raise + + try: + _attempt() + except TeaCLIError as e: + raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None + + if __name__ == "__main__": # pragma: no cover main() diff --git a/src/devx/gitea_cli.py b/src/devx/gitea_cli.py index 583f31e..cea864d 100644 --- a/src/devx/gitea_cli.py +++ b/src/devx/gitea_cli.py @@ -61,6 +61,11 @@ def configure_tea_login(login_name: str = "devx") -> None: Idempotent: if a login with the same name already exists, it is not re-added. Skips silently if tea is not installed or no token is set. + Raises ``TeaCLIError`` if the login add or default command fails. This is + critical because subsequent tea commands (e.g. ``releases create``) will + fail with a cryptic "no available login" error if the login was not + configured successfully. + Used by CI scripts (publish, notify_failure) that need tea login but run in containerized environments where ``make setup`` was not called. """ @@ -88,18 +93,31 @@ def configure_tea_login(login_name: str = "devx") -> None: return click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url)) - subprocess.run( # nosec B603 + add_result = subprocess.run( # nosec B603 [tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token], capture_output=True, text=True, check=False, ) - subprocess.run( # nosec B603 + if add_result.returncode != 0: + raise TeaCLIError( + f"tea login add failed (rc={add_result.returncode})\n" + f"stdout: {add_result.stdout.strip()}\n" + f"stderr: {add_result.stderr.strip()}" + ) + + default_result = subprocess.run( # nosec B603 [tea_bin, "login", "default", login_name], capture_output=True, text=True, check=False, ) + if default_result.returncode != 0: + raise TeaCLIError( + f"tea login default failed (rc={default_result.returncode})\n" + f"stdout: {default_result.stdout.strip()}\n" + f"stderr: {default_result.stderr.strip()}" + ) class TeaCLI: @@ -145,9 +163,14 @@ class TeaCLI: except FileNotFoundError as e: raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e if result.returncode != 0: - raise TeaCLIError( - f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}" - ) + # tea writes some errors to stdout (for example, "no available + # login"), so include both stdout and stderr for debugging. + parts = [ + f"tea command failed (rc={result.returncode}): {' '.join(args)}", + f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "", + f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "", + ] + raise TeaCLIError("\n".join(p for p in parts if p)) return result.stdout.strip() def _run_raw(self, args: list[str]) -> str: diff --git a/tests/unit/test_gitea_cli.py b/tests/unit/test_gitea_cli.py index 8bc87ea..e816f7a 100644 --- a/tests/unit/test_gitea_cli.py +++ b/tests/unit/test_gitea_cli.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/gitea_cli.py.""" +"""Unit tests for devx/gitea_cli.py.""" from __future__ import annotations @@ -77,6 +77,25 @@ class TestTeaCLIRun: with pytest.raises(TeaCLIError, match="auth error"): cli._run(["labels", "list"]) + def test_run_failure_includes_stdout(self) -> None: + """tea writes some errors to stdout (e.g. 'no available login').""" + cli = TeaCLI(tea_bin="/fake/tea") + mock_result = MagicMock(returncode=1, stdout="no available login", stderr="") + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(TeaCLIError, match="no available login"): + cli._run(["releases", "create"]) + + def test_run_failure_includes_both_stdout_and_stderr(self) -> None: + """When both stdout and stderr have content, both are included.""" + cli = TeaCLI(tea_bin="/fake/tea") + mock_result = MagicMock(returncode=1, stdout="partial error", stderr="auth error") + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(TeaCLIError, match="partial error"): + cli._run(["labels", "list"]) + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(TeaCLIError, match="auth error"): + cli._run(["labels", "list"]) + def test_run_tea_not_found_raises_tea_error(self) -> None: cli = TeaCLI(tea_bin="tea") with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")): @@ -380,9 +399,11 @@ class TestConfigureTeaLogin: def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: """configure_tea_login adds login when not already configured.""" mock_list = MagicMock(returncode=0, stdout="") - mock_subprocess.return_value = mock_list + mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="") + mock_default = MagicMock(returncode=0, stdout="", stderr="") + mock_subprocess.side_effect = [mock_list, mock_add, mock_default] configure_tea_login() - assert mock_subprocess.call_count >= 2 # login list + login add + login default + assert mock_subprocess.call_count == 3 # login list + login add + login default @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") @@ -393,3 +414,37 @@ class TestConfigureTeaLogin: mock_subprocess.return_value = mock_list configure_tea_login() assert mock_subprocess.call_count == 1 # only login list, no add + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") + def test_raises_on_login_add_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: + """configure_tea_login raises TeaCLIError if tea login add fails.""" + mock_list = MagicMock(returncode=0, stdout="") + mock_add = MagicMock(returncode=1, stdout="", stderr="invalid token") + mock_subprocess.side_effect = [mock_list, mock_add] + with pytest.raises(TeaCLIError, match="login add failed"): + configure_tea_login() + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") + def test_raises_on_login_default_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: + """configure_tea_login raises TeaCLIError if tea login default fails.""" + mock_list = MagicMock(returncode=0, stdout="") + mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="") + mock_default = MagicMock(returncode=1, stdout="", stderr="login not found") + mock_subprocess.side_effect = [mock_list, mock_add, mock_default] + with pytest.raises(TeaCLIError, match="login default failed"): + configure_tea_login() + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") + def test_login_add_failure_includes_stdout(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: + """Error message includes stdout when tea writes errors there.""" + mock_list = MagicMock(returncode=0, stdout="") + mock_add = MagicMock(returncode=1, stdout="Error: invalid username", stderr="") + mock_subprocess.side_effect = [mock_list, mock_add] + with pytest.raises(TeaCLIError, match="invalid username"): + configure_tea_login() diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 80c1ae8..297ee74 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -387,8 +387,10 @@ class TestMain: @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") + @patch("time.sleep") def test_release_failure_raises_click( self, + mock_sleep: MagicMock, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, @@ -397,6 +399,7 @@ class TestMain: mock_tag: MagicMock, mock_login: MagicMock, ) -> None: + """Release creation failure after retries raises ClickException.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea.create_release.side_effect = TeaCLIError("server error") @@ -405,6 +408,8 @@ class TestMain: result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "Release creation failed" in result.output + # Retried 3 times (stop_after_attempt(3)) + assert mock_tea.create_release.call_count == 3 @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0") @@ -496,8 +501,10 @@ class TestMain: @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") + @patch("time.sleep") def test_create_release_already_exists_is_idempotent( self, + mock_sleep: MagicMock, mock_build: MagicMock, mock_publish: MagicMock, mock_gitea_pub: MagicMock, @@ -507,7 +514,7 @@ class TestMain: mock_tag: MagicMock, mock_login: MagicMock, ) -> None: - """If create_release fails with 'already exists', treat as success.""" + """If create_release fails with 'already exists', treat as success (no retry).""" mock_tea = MagicMock() mock_tea.list_releases.side_effect = TeaCLIError("api error") mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag") @@ -516,6 +523,8 @@ class TestMain: result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "already exists" in result.output + # "already exists" is caught immediately — no retry + assert mock_tea.create_release.call_count == 1 @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0") @@ -526,8 +535,10 @@ class TestMain: @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") + @patch("time.sleep") def test_create_release_other_error_raises( self, + mock_sleep: MagicMock, mock_build: MagicMock, mock_publish: MagicMock, mock_gitea_pub: MagicMock, @@ -537,7 +548,7 @@ class TestMain: mock_tag: MagicMock, mock_login: MagicMock, ) -> None: - """If create_release fails with a non-'already exists' error, raise.""" + """If create_release fails with a non-'already exists' error, raise after retries.""" mock_tea = MagicMock() mock_tea.list_releases.side_effect = TeaCLIError("api error") mock_tea.create_release.side_effect = TeaCLIError("network error") @@ -546,6 +557,75 @@ class TestMain: result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code != 0 assert "Release creation failed" in result.output + # Retried 3 times before giving up + assert mock_tea.create_release.call_count == 3 + + +class TestReleaseRetry: + """Tests for retry logic on transient release creation failures.""" + + @patch("devx.ci.publish.subprocess.run") + @patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0") + @patch("devx.gitea_cli.configure_tea_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}) + @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") + @patch("devx.ci.publish.TeaCLI") + @patch("devx.ci.publish.build_package") + @patch("time.sleep") + def test_transient_failure_retried_and_succeeds( + self, + mock_sleep: MagicMock, + mock_build: MagicMock, + mock_tea_cls: MagicMock, + mock_notes: MagicMock, + mock_run: MagicMock, + mock_tag: MagicMock, + mock_login: MagicMock, + ) -> None: + """Transient failure on first attempt succeeds on retry.""" + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] + mock_tea.create_release.side_effect = [ + TeaCLIError("connection timeout"), + None, # second attempt succeeds + ] + mock_tea_cls.return_value = mock_tea + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) + assert result.exit_code == 0 + assert "Gitea release v1.0.0 created" in result.output + assert mock_tea.create_release.call_count == 2 + mock_sleep.assert_called() # slept between attempts + + @patch("devx.ci.publish.subprocess.run") + @patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0") + @patch("devx.gitea_cli.configure_tea_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}) + @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") + @patch("devx.ci.publish.TeaCLI") + @patch("devx.ci.publish.build_package") + @patch("time.sleep") + def test_all_retries_exhausted_raises( + self, + mock_sleep: MagicMock, + mock_build: MagicMock, + mock_tea_cls: MagicMock, + mock_notes: MagicMock, + mock_run: MagicMock, + mock_tag: MagicMock, + mock_login: MagicMock, + ) -> None: + """All 3 retry attempts fail — raises ClickException.""" + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] + mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable") + mock_tea_cls.return_value = mock_tea + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) + assert result.exit_code == 1 + assert "Release creation failed" in result.output + assert mock_tea.create_release.call_count == 3 + assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps) class TestFromTag: