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
9 changed files with 390 additions and 34 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
+38
View File
@@ -0,0 +1,38 @@
# DEVX-158: Fix build-images workflow: delete existing manifest before push
## Problem
Gitea 1.27 has a known bug (#31964) where pushing a Docker image tag that
already exists in the container registry fails with HTTP 500 "package
version already exists." The build-images workflow has been failing for weeks because
every push to `ci-base:latest`, `ci-quality:latest`, and `ci-full:latest`
hits this error.
## Approach
Add a `delete_remote_manifest` function that deletes the existing manifest
via the Docker registry v2 API before pushing. This works around the Gitea
bug by ensuring the tag doesn't exist when the push starts.
REQ-1: Add `delete_remote_manifest` function using Docker registry v2 API
REQ-2: Call `delete_remote_manifest` before each `docker push` in `push_image`
REQ-3: Pass registry credentials from `main` to `push_image`
REQ-4: Handle errors gracefully — never block the push if delete fails
REQ-5: 100% test coverage for new code
## Test Plan
- Unit tests for `delete_remote_manifest` (success, 404, 500, network error)
- Unit tests for `push_image` with and without credentials
- Verify existing tests still pass
## Deploy Plan
- Merge to master, auto-release new devx version
- The build-images workflow will use the new code on the next run
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: `delete_remote_manifest` function added
- [x] REQ-2: Called before each push in `push_image`
- [x] REQ-3: Credentials passed from `main` to `push_image`
- [x] REQ-4: Errors don't block the push (returns True on failure)
- [x] REQ-5: 100% test coverage
+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"
+110 -9
View File
@@ -40,9 +40,12 @@ and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workfl
from __future__ import annotations
import base64
import json
import os
import subprocess # nosec B404
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
@@ -188,19 +191,89 @@ def build_image(
return True
def delete_remote_manifest(
registry: str,
name: str,
tag: str,
username: str,
token: str,
*,
dry_run: bool = False,
) -> bool:
"""Delete an existing manifest from the Gitea container registry.
Gitea 1.27 has a bug (#31964) where pushing a tag that already exists
fails with HTTP 500 "package version already exists". This function
deletes the existing manifest before the push to work around it.
Returns True if deleted or not found, False on unexpected errors.
"""
manifest_url = f"https://{registry}/v2/{name}/manifests/{tag}"
if dry_run:
click.echo(f"[dry-run] DELETE {manifest_url}")
return True
# First, get the digest via HEAD
req = urllib.request.Request(manifest_url, method="HEAD") # nosec B310
auth_str = f"{username}:{token}"
req.add_header("Authorization", f"Basic {base64.b64encode(auth_str.encode()).decode()}")
req.add_header("Accept", "application/vnd.docker.distribution.manifest.v2+json")
try:
with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310
digest = resp.headers.get("Docker-Content-Digest")
except urllib.error.HTTPError as e:
if e.code == 404:
return True # Tag doesn't exist — nothing to delete
if e.code == 405:
# HEAD not supported — try GET with a range
pass
else:
click.echo(f" Warning: HEAD {tag} returned {e.code}", err=True)
return True # Don't block the push
except urllib.error.URLError as e:
click.echo(f" Warning: HEAD {tag} failed: {e}", err=True)
return True # Don't block the push
else:
if not digest:
return True
# Delete by digest
del_url = f"https://{registry}/v2/{name}/manifests/{digest}"
del_req = urllib.request.Request(del_url, method="DELETE") # nosec B310
del_req.add_header("Authorization", f"Basic {base64.b64encode(auth_str.encode()).decode()}")
try:
with urllib.request.urlopen(del_req, timeout=30) as resp: # nosec B310
click.echo(f" Deleted existing {tag} (digest: {digest[:19]}...)")
except urllib.error.HTTPError as e:
if e.code == 404:
return True # Already gone
click.echo(f" Warning: DELETE {tag} returned {e.code}", err=True)
return True # Don't block the push
except urllib.error.URLError as e:
click.echo(f" Warning: DELETE {tag} failed: {e}", err=True)
return True
return True
def push_image(
spec: ImageSpec,
registry: str,
*,
dry_run: bool = False,
username: str = "",
token: str = "",
) -> bool:
"""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 in full_tags:
for ft, tag in zip(full_tags, spec.tags, strict=False):
cmd = ["docker", "push", ft]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
@@ -212,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
@@ -320,11 +417,15 @@ def main(
raise click.ClickException(_("Registry login failed"))
failed: list[str] = []
push_username = "" # nosec B105
push_token = "" # nosec B105
if push:
push_username, push_token = _get_registry_creds()
for spec in specs:
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
failed.append(spec.name)
continue
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
if push and not push_image(spec, registry, dry_run=dry_run, username=push_username, token=push_token): # type: ignore[arg-type]
failed.append(spec.name)
if failed:
+169 -5
View File
@@ -14,6 +14,7 @@ import devx.tools.build_image as build_image
from devx.tools.build_image import (
ImageSpec,
build_full_tag,
delete_remote_manifest,
load_manifest,
push_image,
registry_login,
@@ -216,6 +217,160 @@ class TestPushImage:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
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
mock_del.assert_called_once_with(
"git.example.com",
"ci-base",
"latest",
"user",
"tok",
dry_run=False,
)
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=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", 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:
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t", dry_run=True) is True
def test_tag_not_found(self) -> None:
import urllib.error
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_delete_success(self) -> None:
mock_head_resp = MagicMock()
mock_head_resp.__enter__.return_value.headers.get.return_value = "sha256:abc123"
mock_del_resp = MagicMock()
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = [mock_head_resp, mock_del_resp]
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_delete_404_treated_as_success(self) -> None:
import urllib.error
mock_head_resp = MagicMock()
mock_head_resp.__enter__.return_value.headers.get.return_value = "sha256:abc123"
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = [
mock_head_resp,
urllib.error.HTTPError("url", 404, "Not Found", {}, None),
]
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_head_error_does_not_block(self) -> None:
import urllib.error
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.HTTPError("url", 500, "Server Error", {}, None)
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_url_error_does_not_block(self) -> None:
import urllib.error
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.URLError("network down")
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_no_digest_does_not_block(self) -> None:
mock_head_resp = MagicMock()
mock_head_resp.__enter__.return_value.headers.get.return_value = None
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.return_value = mock_head_resp
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_head_405_passes_through(self) -> None:
import urllib.error
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.HTTPError("url", 405, "Method Not Allowed", {}, None)
# 405 falls through with pass, digest never set, returns True
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
assert mock_urlopen.call_count == 1
def test_delete_500_does_not_block(self) -> None:
import urllib.error
mock_head_resp = MagicMock()
mock_head_resp.__enter__.return_value.headers.get.return_value = "sha256:abc123"
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = [
mock_head_resp,
urllib.error.HTTPError("url", 500, "Server Error", {}, None),
]
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
def test_delete_url_error_does_not_block(self) -> None:
import urllib.error
mock_head_resp = MagicMock()
mock_head_resp.__enter__.return_value.headers.get.return_value = "sha256:abc123"
with patch("devx.tools.build_image.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = [
mock_head_resp,
urllib.error.URLError("network down"),
]
assert delete_remote_manifest("git.example.com", "ci-base", "latest", "u", "t") is True
class TestSortVersions:
def test_sort_by_created_at_desc(self) -> None:
@@ -588,11 +743,20 @@ class TestCLIBuildImage:
"devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result],
):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
with patch("devx.tools.build_image.delete_remote_manifest", return_value=True):
result = runner.invoke(
build_image.main,
[
"--dockerfile",
str(dockerfile),
"--name",
"ci-base",
"--push",
"--registry",
"git.example.com",
],
)
assert result.exit_code != 0
class TestCLICleanImages: