Compare commits

..
Author SHA1 Message Date
devx-ci-bot 5f0b9d3a71 release: v0.51.2 [skip ci] 2026-08-26 07:47:20 +00:00
emil 816f27ed7a DEVX-159: fix: push-first strategy in build_image to avoid losing latest tag
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m26s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-26 07:46:35 +00:00
gitea-actions-bot 7101908a78 chore: update badge URLs to commit eaad6892 [skip ci] 2026-08-25 17:27:42 +00:00
devx-ci-bot a637448f83 release: v0.51.1 [skip ci] 2026-08-25 17:26:52 +00:00
emo f94ce03a04 DEVX-158: fix: delete existing manifest before push (Gitea #31964 workaround)
Post-merge / release-and-maintain (push) Successful in 1m22s
Post-merge / detect-and-configure (push) Successful in 12s
Co-authored-by: emo <emo@oblachno.com>
2026-08-25 17:26:04 +00:00
8 changed files with 154 additions and 41 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## [0.51.2] - 2026-08-26
### Bug Fixes
- Push-first strategy in build_image to avoid losing latest tag
## [0.51.1] - 2026-08-25
### Bug Fixes
- Delete existing manifest before push (Gitea #31964 workaround)
## [0.51.0] - 2026-08-25
### Features
+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/b1e8ed3565a332336655f37edace2344a186c0bb/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/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.51.0",
"devx>=0.51.2",
]
[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.51.0"`) or use a version constraint
> (for example, `"devx>=0.51.0,<0.52"`).
> `dependencies` (for example, `"devx==0.51.2"`) or use a version constraint
> (for example, `"devx>=0.51.2,<0.52"`).
### 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)
[![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/b1e8ed3565a332336655f37edace2344a186c0bb/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b1e8ed3565a332336655f37edace2344a186c0bb/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/eaad6892ec0248e2a8b08a7c4e165d5517ff3f92/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.51.0",
"devx>=0.51.2",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.51.0"` or `"devx>=0.51.0,<0.52"`.
Pin a specific version if needed: `"devx==0.51.2"` or `"devx>=0.51.2,<0.52"`.
### Optional extras
+41
View File
@@ -0,0 +1,41 @@
# DEVX-159: Fix build_image push-first strategy to avoid losing latest tag
## Problem
The `push_image` function in `build_image.py` deletes the existing
manifest *before* pushing (Gitea #31964 workaround). When the push
fails for other reasons (HTTP 500), the old tag is lost, breaking all
CI jobs that use that image.
This caused `ci-base:latest` to disappear from the registry when
build-images run #4104 failed with HTTP 500 on push, after already
deleting the old `latest` manifest.
## Approach
Switch to a push-first strategy:
1. Try pushing directly
2. Only if push fails with "already exists" (Gitea #31964), delete
the old manifest and retry
3. If push fails for any other reason, the old manifest is preserved
REQ-1: Push first, no pre-emptive delete
REQ-2: Delete + retry only on "already exists" error
REQ-3: Old manifest preserved on non-already-exists failures
REQ-4: 100% test coverage of new logic
## Test Plan
- Unit tests for all push paths (success, already-exists retry,
non-already-exists failure, retry-also-fails)
- Verify existing tests still pass
## Deploy Plan
- Merge to master, build-images workflow uses new push logic on next
image rebuild
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Push first, no pre-emptive delete
- [x] REQ-2: Delete + retry only on "already exists" error
- [x] REQ-3: Old manifest preserved on non-already-exists failures
- [x] REQ-4: 100% test coverage of new logic
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.51.0",
"devx>=0.51.2",
]
[project.optional-dependencies]
dev = [
"devx>=0.51.0",
"devx>=0.51.2",
]
```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects.
"""
__version__ = "0.51.0"
__version__ = "0.51.2"
+36 -17
View File
@@ -265,20 +265,15 @@ def push_image(
"""Push all tags of a Docker image to the registry.
Returns True if all pushes succeed, False if any fail.
Push-first strategy: try pushing directly. Only if the push fails
with Gitea #31964 ("package version already exists") do we delete
the old manifest and retry. This avoids losing the existing tag
when the push fails for unrelated reasons (e.g. HTTP 500).
"""
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
all_ok = True
for ft, tag in zip(full_tags, spec.tags, strict=False):
# Workaround for Gitea #31964: delete existing tag before push
if username and token:
delete_remote_manifest(
registry,
spec.name,
tag,
username,
token,
dry_run=dry_run,
)
cmd = ["docker", "push", ft]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
@@ -290,14 +285,38 @@ def push_image(
text=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
err=True,
)
all_ok = False
else:
if result.returncode == 0:
click.echo(f"Pushed {ft}")
continue
stderr = result.stderr.strip()
# Gitea #31964: push fails because tag already exists.
# Delete the old manifest and retry once.
if username and token and "already exists" in stderr.lower():
click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...")
delete_remote_manifest(
registry,
spec.name,
tag,
username,
token,
dry_run=dry_run,
)
click.echo(f" Retrying push {ft}...")
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
click.echo(f"Pushed {ft} (after retry)")
continue
stderr = result.stderr.strip()
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=stderr),
err=True,
)
all_ok = False
return all_ok
+45 -4
View File
@@ -217,11 +217,36 @@ class TestPushImage:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
def test_delete_before_push_with_creds(self) -> None:
def test_no_delete_on_success_with_creds(self) -> None:
"""Push-first: no delete needed when push succeeds."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
):
assert push_image(spec, "git.example.com", username="user", token="tok") is True
mock_del.assert_not_called()
def test_no_delete_without_creds(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
):
assert push_image(spec, "git.example.com") is True
mock_del.assert_not_called()
def test_delete_and_retry_on_already_exists(self) -> None:
"""Gitea #31964: push fails with 'already exists', delete + retry."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
MagicMock(returncode=0, stderr="", stdout=""),
]
with (
patch("devx.tools.build_image.subprocess.run", side_effect=results),
patch("devx.tools.build_image.delete_remote_manifest", return_value=True) as mock_del,
):
assert push_image(spec, "git.example.com", username="user", token="tok") is True
@@ -234,16 +259,32 @@ class TestPushImage:
dry_run=False,
)
def test_no_delete_without_creds(self) -> None:
def test_no_delete_on_non_already_exists_failure(self) -> None:
"""Push fails for other reasons (HTTP 500) — old manifest preserved."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
mock_result = MagicMock(
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""
)
with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
):
assert push_image(spec, "git.example.com") is True
assert push_image(spec, "git.example.com", username="user", token="tok") is False
mock_del.assert_not_called()
def test_retry_also_fails(self) -> None:
"""Gitea #31964 retry also fails — both pushes fail."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
MagicMock(returncode=1, stderr="push failed again", stdout=""),
]
with (
patch("devx.tools.build_image.subprocess.run", side_effect=results),
patch("devx.tools.build_image.delete_remote_manifest", return_value=True),
):
assert push_image(spec, "git.example.com", username="user", token="tok") is False
class TestDeleteRemoteManifest:
def test_dry_run(self) -> None: