Compare commits

..
3 Commits
Author SHA1 Message Date
devx-ci-bot 990f70845c release: v0.51.6 [skip ci] 2026-08-26 14:10:44 +00:00
kireto 149e8846b8 DEVX-163: fix: use stderr=STDOUT to capture all docker push output in one stream
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m18s
2026-08-26 14:09:59 +00:00
gitea-actions-bot a7cdebc0dc chore: update badge URLs to commit 73dcdbaf [skip ci] 2026-08-26 13:42:00 +00:00
7 changed files with 56 additions and 60 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.6] - 2026-08-26
### Bug Fixes
- Use stderr=STDOUT to capture all docker push output in one stream
## [0.51.5] - 2026-08-26 ## [0.51.5] - 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/7660d5019b904710f01bf56fc45e0b9d4d87b53d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/coverage.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) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/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.5", "devx>=0.51.6",
] ]
[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.5"`) or use a version constraint > `dependencies` (for example, `"devx==0.51.6"`) or use a version constraint
> (for example, `"devx>=0.51.5,<0.52"`). > (for example, `"devx>=0.51.6,<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/7660d5019b904710f01bf56fc45e0b9d4d87b53d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/coverage.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) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7660d5019b904710f01bf56fc45e0b9d4d87b53d/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/73dcdbaf413ffefce23219f40524252cae669d30/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.5", "devx>=0.51.6",
] ]
[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.5"` or `"devx>=0.51.5,<0.52"`. Pin a specific version if needed: `"devx==0.51.6"` or `"devx>=0.51.6,<0.52"`.
### Optional extras ### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.5", "devx>=0.51.6",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.51.5", "devx>=0.51.6",
] ]
``` ```
+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.5" __version__ = "0.51.6"
+11 -8
View File
@@ -269,17 +269,19 @@ def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
Docker sends push progress/errors to both stdout and stderr depending Docker sends push progress/errors to both stdout and stderr depending
on the error type, so both streams are checked for the 500 status. on the error type, so both streams are checked for the 500 status.
Uses stderr=STDOUT to merge both streams into stdout, ensuring all
output is captured in one place (docker push output behavior varies
depending on TTY detection).
""" """
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
capture_output=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, text=True,
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0 and "500" in (result.stdout or ""):
combined = f"{result.stderr}\n{result.stdout}" raise PushHTTP500Error(result.stdout.strip())
if "500" in combined:
raise PushHTTP500Error(combined.strip())
return result return result
@@ -334,7 +336,7 @@ def push_image(
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft}") click.echo(f"Pushed {ft}")
continue continue
combined_output = f"{result.stderr}\n{result.stdout}".strip() combined_output = (result.stdout or "").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 combined_output.lower(): if username and token and "already exists" in combined_output.lower():
@@ -350,14 +352,15 @@ def push_image(
click.echo(f" Retrying push {ft}...") click.echo(f" Retrying push {ft}...")
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
capture_output=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, text=True,
check=False, check=False,
) )
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft} (after retry)") click.echo(f"Pushed {ft} (after retry)")
continue continue
combined_output = f"{result.stderr}\n{result.stdout}".strip() combined_output = (result.stdout or "").strip()
click.echo( click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=combined_output), _("Push failed for {tag}: {error}", tag=ft, error=combined_output),
err=True, err=True,
+19 -32
View File
@@ -198,7 +198,7 @@ class TestBuildImage:
class TestPushImage: class TestPushImage:
def test_success(self) -> None: def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stderr="", stdout="") mock_result = MagicMock(returncode=0, stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2 assert mock_run.call_count == 2
@@ -206,8 +206,8 @@ class TestPushImage:
def test_partial_failure(self) -> None: def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [ results = [
MagicMock(returncode=0, stderr="", stdout=""), MagicMock(returncode=0, stdout=""),
MagicMock(returncode=1, stderr="push failed", stdout=""), MagicMock(returncode=1, stdout="push failed"),
] ]
with patch("devx.tools.build_image.subprocess.run", side_effect=results): with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False assert push_image(spec, "git.example.com") is False
@@ -221,7 +221,7 @@ class TestPushImage:
def test_no_delete_on_success_with_creds(self) -> None: def test_no_delete_on_success_with_creds(self) -> None:
"""Push-first: no delete needed when push succeeds.""" """Push-first: no delete needed when push succeeds."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stderr="", stdout="") mock_result = MagicMock(returncode=0, stdout="")
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -231,7 +231,7 @@ class TestPushImage:
def test_no_delete_without_creds(self) -> None: def test_no_delete_without_creds(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stderr="", stdout="") mock_result = MagicMock(returncode=0, stdout="")
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -243,8 +243,8 @@ class TestPushImage:
"""Gitea #31964: push fails with 'already exists', delete + retry.""" """Gitea #31964: push fails with 'already exists', delete + retry."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [ results = [
MagicMock(returncode=1, stderr="package version already exists", stdout=""), MagicMock(returncode=1, stdout="package version already exists"),
MagicMock(returncode=0, stderr="", stdout=""), MagicMock(returncode=0, stdout=""),
] ]
with ( with (
patch("devx.tools.build_image.subprocess.run", side_effect=results), patch("devx.tools.build_image.subprocess.run", side_effect=results),
@@ -263,7 +263,7 @@ class TestPushImage:
def test_no_delete_on_non_already_exists_failure(self) -> None: def test_no_delete_on_non_already_exists_failure(self) -> None:
"""Push fails for other reasons (non-500) — old manifest preserved.""" """Push fails for other reasons (non-500) — old manifest preserved."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=1, stderr="denied: requested access to the resource is denied", stdout="") mock_result = MagicMock(returncode=1, stdout="denied: requested access to the resource is denied")
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -275,8 +275,8 @@ class TestPushImage:
"""Gitea #31964 retry also fails — both pushes fail.""" """Gitea #31964 retry also fails — both pushes fail."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [ results = [
MagicMock(returncode=1, stderr="package version already exists", stdout=""), MagicMock(returncode=1, stdout="package version already exists"),
MagicMock(returncode=1, stderr="push failed again", stdout=""), MagicMock(returncode=1, stdout="push failed again"),
] ]
with ( with (
patch("devx.tools.build_image.subprocess.run", side_effect=results), patch("devx.tools.build_image.subprocess.run", side_effect=results),
@@ -288,8 +288,8 @@ 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="", stdout="received unexpected HTTP status: 500 Internal Server Error"), MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error"),
MagicMock(returncode=0, stderr="", stdout=""), MagicMock(returncode=0, stdout=""),
] ]
with ( with (
patch("devx.tools.build_image.subprocess.run", side_effect=results), patch("devx.tools.build_image.subprocess.run", side_effect=results),
@@ -302,9 +302,7 @@ class TestPushImage:
def test_http_500_retries_all_fail(self) -> None: def test_http_500_retries_all_fail(self) -> None:
"""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, stdout="received unexpected HTTP status: 500 Internal Server Error")
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),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -313,31 +311,20 @@ 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_stderr(self) -> None: def test_run_push_raises_on_500(self) -> None:
"""_run_push raises PushHTTP500Error when stderr contains 500.""" """_run_push raises PushHTTP500Error when stdout contains 500."""
from devx.tools.build_image import _run_push from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=1, stderr="HTTP 500 Internal Server Error", stdout="") mock_result = MagicMock(returncode=1, 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="HTTP 500"):
_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 patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
with pytest.raises(PushHTTP500Error, match="500"): with pytest.raises(PushHTTP500Error, match="500"):
_run_push(["docker", "push", "img:latest"]) _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 stdout has no 500."""
from devx.tools.build_image import _run_push from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=1, stderr="denied: access denied", stdout="") mock_result = MagicMock(returncode=1, stdout="denied: access denied")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = _run_push(["docker", "push", "img:latest"]) result = _run_push(["docker", "push", "img:latest"])
assert result.returncode == 1 assert result.returncode == 1
@@ -346,7 +333,7 @@ class TestPushImage:
"""_run_push returns result on success.""" """_run_push returns result on success."""
from devx.tools.build_image import _run_push from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=0, stderr="", stdout="") mock_result = MagicMock(returncode=0, stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = _run_push(["docker", "push", "img:latest"]) result = _run_push(["docker", "push", "img:latest"])
assert result.returncode == 0 assert result.returncode == 0