DEVX-142: fix: tea CLI login failure handling, error messages, release retry
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m2s

This commit was merged in pull request #224.
This commit is contained in:
2026-07-16 14:26:15 +00:00
parent cdf3408a35
commit a7a8637244
5 changed files with 220 additions and 18 deletions
+37 -7
View File
@@ -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()
+28 -5
View File
@@ -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: