Compare commits

...
9 Commits
Author SHA1 Message Date
devx-ci-bot e01c39b4b8 release: v0.47.4 [skip ci] 2026-08-03 14:41:33 +00:00
emo aa93e894a6 DEVX-1: fix: add User-Agent header to _download in install_tools 2026-08-03 14:40:51 +00:00
gitea-actions-bot 004b890463 chore: update badge URLs to commit 82b4caf3 [skip ci] 2026-07-17 02:11:54 +00:00
devx-ci-bot 587906f518 release: v0.47.3 [skip ci] 2026-07-17 02:11:15 +00:00
emil d743ba93eb DEVX-144: fix: bake promtool into ci-full image, add download timeout, speed up tests
Post-merge / detect-and-configure (push) Failing after 511h57m37s
Post-merge / release-and-maintain (push) Failing after 511h57m16s
2026-07-17 02:10:17 +00:00
gitea-actions-bot c7351a495a chore: update badge URLs to commit eeaec1e7 [skip ci] 2026-07-17 00:45:48 +00:00
devx-ci-bot 4de11bfc18 release: v0.47.2 [skip ci] 2026-07-17 00:45:13 +00:00
emil a02bf6d70e DEVX-143: fix: add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
Post-merge / release-and-maintain (push) Failing after 513h23m10s
Post-merge / detect-and-configure (push) Failing after 513h23m27s
2026-07-17 00:44:27 +00:00
gitea-actions-bot 368c87aabf chore: update badge URLs to commit 4e6bada8 [skip ci] 2026-07-16 14:27:31 +00:00
13 changed files with 180 additions and 54 deletions
+2 -2
View File
@@ -90,7 +90,7 @@ jobs:
if: github.event_name == 'pull_request'
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
DEVX_VIKUNJA_PROJECT_ID: "2"
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
@@ -173,7 +173,7 @@ jobs:
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
DEVX_VIKUNJA_PROJECT_ID: "2"
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
+1 -1
View File
@@ -152,7 +152,7 @@ jobs:
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
DEVX_VIKUNJA_PROJECT_ID: "2"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
## [0.47.4] - 2026-08-03
### Bug Fixes
- Add User-Agent header to _download in install_tools
## [0.47.3] - 2026-07-17
### Bug Fixes
- Bake promtool into ci-full image, add download timeout, speed up tests
## [0.47.2] - 2026-07-17
### Bug Fixes
- Add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
## [0.47.1] - 2026-07-16
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.47.1",
"devx>=0.47.4",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.47.1"`) or use a version constraint
> (for example, `"devx>=0.47.1,<0.48"`).
> `dependencies` (for example, `"devx==0.47.4"`) or use a version constraint
> (for example, `"devx>=0.47.4,<0.48"`).
### Optional extras
+3 -2
View File
@@ -20,5 +20,6 @@ COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
&& rm -rf /tmp/devx
# Install git-cliff (changelog generator for release job) and OpenTofu (for infra deploy jobs)
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu
# Install git-cliff (changelog generator for release job), OpenTofu (for infra deploy jobs),
# and promtool (Prometheus rule validator — used by every infra CI run for alert validation)
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu --tool promtool
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e6827cecc8ac90f86902062d90f962e2ea9970e0/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/82b4caf3bcf5fb7058654abab87cd2fea339d882/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.47.1",
"devx>=0.47.4",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.47.1"` or `"devx>=0.47.1,<0.48"`.
Pin a specific version if needed: `"devx==0.47.4"` or `"devx>=0.47.4,<0.48"`.
### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.47.1",
"devx>=0.47.4",
]
[project.optional-dependencies]
dev = [
"devx>=0.47.1",
"devx>=0.47.4",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.47.1"
__version__ = "0.47.4"
+58 -20
View File
@@ -40,21 +40,35 @@ Usage::
from __future__ import annotations
import json
import logging
import shutil
import subprocess # nosec B404
from typing import Any
import click
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import GITEA_API_URL
from devx.config import GITEA_API_URL, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.i18n import _
from devx.tokens import get_ci_token
logger = logging.getLogger("gitea_cli")
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
class _TransientTeaError(TeaCLIError):
"""Tea CLI error caused by a transient HTTP status (502/503/504/429)."""
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
@@ -140,6 +154,10 @@ class TeaCLI:
def _run(self, args: list[str], json_output: bool = True) -> str:
"""Run a tea command and return stdout.
Retries up to ``MAX_RETRIES`` times on transient HTTP errors
(502/503/504/429) detected in stderr/stdout, with exponential
backoff. Non-transient errors fail immediately.
Args:
args: Command arguments (without the leading ``tea``).
json_output: If True, append ``--output json`` to the command.
@@ -148,30 +166,50 @@ class TeaCLI:
stdout as a string.
Raises:
TeaCLIError: If the command fails.
TeaCLIError: If the command fails after retries are exhausted.
"""
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
def _execute() -> str:
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
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:
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 "",
]
msg = "\n".join(p for p in parts if p)
combined = f"{result.stdout} {result.stderr}".lower()
if any(str(code) in combined for code in RETRY_STATUS_CODES):
raise _TransientTeaError(msg)
raise TeaCLIError(msg)
return result.stdout.strip()
retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(
multiplier=RETRY_BACKOFF_BASE,
min=RETRY_BACKOFF_BASE,
max=RETRY_BACKOFF_BASE**MAX_RETRIES,
),
retry=retry_if_exception_type(_TransientTeaError),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
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:
# 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()
return retry_decorator(_execute)()
except _TransientTeaError as e:
raise TeaCLIError(str(e)) from e
def _run_raw(self, args: list[str]) -> str:
"""Run a tea command without JSON output and return stdout."""
+1 -1
View File
@@ -309,7 +309,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-
# ── Testing ───────────────────────────────────────────────────────────────────
devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov -n 8
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
+8 -2
View File
@@ -65,8 +65,14 @@ def _ensure_target_dir() -> Path:
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest``."""
urllib.request.urlretrieve(url, dest) # nosec B310
"""Download a file from ``url`` to ``dest`` with a 60s timeout.
A User-Agent header is set because some CDNs (e.g. dl.gitea.com)
return 403 to requests with Python's default User-Agent.
"""
req = urllib.request.Request(url, headers={"User-Agent": "devx/install-tools"})
with urllib.request.urlopen(req, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
shutil.copyfileobj(resp, f)
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
+47 -1
View File
@@ -7,7 +7,13 @@ from unittest.mock import MagicMock, patch
import pytest
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
from devx.gitea_cli import (
TeaCLI,
TeaCLIError,
_extract_issue_number,
_extract_pr_number,
configure_tea_login,
)
class TestExtractIssueNumber:
@@ -119,6 +125,46 @@ class TestTeaCLIRun:
cmd = mock_run.call_args[0][0]
assert "--output" not in cmd
def test_run_retries_on_502(self) -> None:
"""Transient 502 errors should be retried, then succeed."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="502 Bad Gateway")
success_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
with patch("subprocess.run", side_effect=[fail_result, success_result]) as mock_run:
with patch("tenacity.nap.time.sleep"):
output = cli._run(["labels", "list"])
assert output == '[{"id": 1}]'
assert mock_run.call_count == 2
def test_run_retries_on_503_then_fails(self) -> None:
"""If all retries are exhausted on 503, raise TeaCLIError."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="503 Service Unavailable")
with patch("subprocess.run", return_value=fail_result):
with patch("tenacity.nap.time.sleep"):
with pytest.raises(TeaCLIError, match="503"):
cli._run(["issues", "create"])
# MAX_RETRIES=3, so 3 attempts total
def test_run_no_retry_on_non_transient_error(self) -> None:
"""Non-transient errors (e.g. auth) should fail immediately without retry."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="auth error")
with patch("subprocess.run", return_value=fail_result) as mock_run:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
assert mock_run.call_count == 1
def test_run_retries_on_429_in_stdout(self) -> None:
"""429 rate limit in stdout should trigger retry."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="429 Too Many Requests", stderr="")
success_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", side_effect=[fail_result, success_result]):
with patch("tenacity.nap.time.sleep"):
output = cli._run(["releases", "create"])
assert output == "ok"
class TestRepoArg:
def test_with_repo_arg(self) -> None:
+22 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import platform
import urllib.request
from pathlib import Path
from unittest.mock import patch
@@ -47,13 +48,29 @@ class TestDownload:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "file.bin"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"data")
return str(path), None
class _FakeResponse:
def __init__(self) -> None:
self._sent = False
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
def __enter__(self) -> _FakeResponse:
return self
def __exit__(self, *args: object) -> None:
pass
def read(self, n: int = -1) -> bytes:
if self._sent:
return b""
self._sent = True
return b"data"
with patch("urllib.request.urlopen", return_value=_FakeResponse()) as mock_urlopen:
install_tools._download("https://example.com/file", dest)
mock_retrieve.assert_called_once()
mock_urlopen.assert_called_once()
call_args = mock_urlopen.call_args
req = call_args.args[0]
assert isinstance(req, urllib.request.Request)
assert req.get_header("User-agent") == "devx/install-tools"
assert dest.read_bytes() == b"data"