Compare commits

...
3 Commits
Author SHA1 Message Date
devx-ci-bot 012f0979ce release: v0.51.5 [skip ci] 2026-08-26 13:41:09 +00:00
kireto 62412755cb DEVX-163: fix: check stdout for HTTP 500 in _run_push (docker sends to stdout)
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m20s
2026-08-26 13:40:24 +00:00
gitea-actions-bot 25f00335df chore: update badge URLs to commit 7660d501 [skip ci] 2026-08-26 13:14:22 +00:00
8 changed files with 84 additions and 29 deletions
+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.51.5] - 2026-08-26
### Bug Fixes
- Check stdout for HTTP 500 in _run_push (docker sends to stdout)
## [0.51.4] - 2026-08-26 ## [0.51.4] - 2026-08-26
### Bug Fixes ### 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) [![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) [![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/b1023118a52d75330683f6bc83367b78826a4088/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.4", "devx>=0.51.5",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.51.4"`) or use a version constraint > `dependencies` (for example, `"devx==0.51.5"`) or use a version constraint
> (for example, `"devx>=0.51.4,<0.52"`). > (for example, `"devx>=0.51.5,<0.52"`).
### Optional extras ### Optional extras
+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) [![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) [![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/b1023118a52d75330683f6bc83367b78826a4088/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1023118a52d75330683f6bc83367b78826a4088/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.4", "devx>=0.51.5",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.51.4"` or `"devx>=0.51.4,<0.52"`. Pin a specific version if needed: `"devx==0.51.5"` or `"devx>=0.51.5,<0.52"`.
### Optional extras ### Optional extras
+33
View File
@@ -0,0 +1,33 @@
# DEVX-163: Fix _run_push to check stdout for HTTP 500
## Problem
`_run_push` only checked `result.stderr` for HTTP 500, but docker push
sends the "received unexpected HTTP status: 500 Internal Server Error"
message to **stdout**, not stderr. This means the tenacity retry logic
added in DEVX-162 never triggered — the push failed immediately without
retrying.
## Approach
Check both `result.stdout` and `result.stderr` for the "500" status code.
Also update the "already exists" check in `push_image` to check both
streams, since docker may send that message to stdout as well.
REQ-1: _run_push checks both stdout and stderr for HTTP 500
REQ-2: push_image "already exists" check uses combined stdout+stderr
REQ-3: All existing tests pass with 100% coverage
## Test Plan
- Unit tests for stdout 500 detection
- Unit tests for stderr 500 detection
- Manual: trigger build-images workflow and verify retry works
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: _run_push checks both stdout and stderr for HTTP 500
- [x] REQ-2: push_image "already exists" check uses combined stdout+stderr
- [x] REQ-3: All existing tests pass with 100% coverage
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.4", "devx>=0.51.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.51.4", "devx>=0.51.5",
] ]
``` ```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects. molecule testing helpers for Ansible projects.
""" """
__version__ = "0.51.4" __version__ = "0.51.5"
+11 -6
View File
@@ -266,6 +266,9 @@ def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
BlobUploader.Append that causes intermittent HTTP 500 "offset BlobUploader.Append that causes intermittent HTTP 500 "offset
mismatch" errors during concurrent blob uploads. Retrying the mismatch" errors during concurrent blob uploads. Retrying the
push gives the registry time to recover. push gives the registry time to recover.
Docker sends push progress/errors to both stdout and stderr depending
on the error type, so both streams are checked for the 500 status.
""" """
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
@@ -273,8 +276,10 @@ def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
text=True, text=True,
check=False, check=False,
) )
if result.returncode != 0 and "500" in result.stderr: if result.returncode != 0:
raise PushHTTP500Error(result.stderr.strip()) combined = f"{result.stderr}\n{result.stdout}"
if "500" in combined:
raise PushHTTP500Error(combined.strip())
return result return result
@@ -329,10 +334,10 @@ def push_image(
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft}") click.echo(f"Pushed {ft}")
continue continue
stderr = result.stderr.strip() combined_output = f"{result.stderr}\n{result.stdout}".strip()
# Gitea #31964: push fails because tag already exists. # Gitea #31964: push fails because tag already exists.
# Delete the old manifest and retry once. # Delete the old manifest and retry once.
if username and token and "already exists" in stderr.lower(): if username and token and "already exists" in combined_output.lower():
click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...") click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...")
delete_remote_manifest( delete_remote_manifest(
registry, registry,
@@ -352,9 +357,9 @@ def push_image(
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft} (after retry)") click.echo(f"Pushed {ft} (after retry)")
continue continue
stderr = result.stderr.strip() combined_output = f"{result.stderr}\n{result.stdout}".strip()
click.echo( click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=stderr), _("Push failed for {tag}: {error}", tag=ft, error=combined_output),
err=True, err=True,
) )
all_ok = False all_ok = False
+14 -3
View File
@@ -288,7 +288,7 @@ class TestPushImage:
"""HTTP 500 from registry race condition — retry succeeds.""" """HTTP 500 from registry race condition — retry succeeds."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [ results = [
MagicMock(returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""), MagicMock(returncode=1, stderr="", stdout="received unexpected HTTP status: 500 Internal Server Error"),
MagicMock(returncode=0, stderr="", stdout=""), MagicMock(returncode=0, stderr="", stdout=""),
] ]
with ( with (
@@ -303,7 +303,7 @@ class TestPushImage:
"""HTTP 500 retries exhausted — push fails, no delete attempted.""" """HTTP 500 retries exhausted — push fails, no delete attempted."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock( mock_result = MagicMock(
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout="" returncode=1, stderr="", stdout="received unexpected HTTP status: 500 Internal Server Error"
) )
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
@@ -313,7 +313,7 @@ class TestPushImage:
assert push_image(spec, "git.example.com", username="user", token="tok") is False assert push_image(spec, "git.example.com", username="user", token="tok") is False
mock_del.assert_not_called() mock_del.assert_not_called()
def test_run_push_raises_on_500(self) -> None: def test_run_push_raises_on_500_stderr(self) -> None:
"""_run_push raises PushHTTP500Error when stderr contains 500.""" """_run_push raises PushHTTP500Error when stderr contains 500."""
from devx.tools.build_image import _run_push from devx.tools.build_image import _run_push
@@ -322,6 +322,17 @@ class TestPushImage:
with pytest.raises(PushHTTP500Error, match="HTTP 500"): with pytest.raises(PushHTTP500Error, match="HTTP 500"):
_run_push(["docker", "push", "img:latest"]) _run_push(["docker", "push", "img:latest"])
def test_run_push_raises_on_500_stdout(self) -> None:
"""_run_push raises PushHTTP500Error when stdout contains 500 (docker sends to stdout)."""
from devx.tools.build_image import _run_push
mock_result = MagicMock(
returncode=1, stderr="", stdout="received unexpected HTTP status: 500 Internal Server Error"
)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
with pytest.raises(PushHTTP500Error, match="500"):
_run_push(["docker", "push", "img:latest"])
def test_run_push_no_raise_on_non_500(self) -> None: def test_run_push_no_raise_on_non_500(self) -> None:
"""_run_push returns result when stderr has no 500.""" """_run_push returns result when stderr has no 500."""
from devx.tools.build_image import _run_push from devx.tools.build_image import _run_push