Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7101908a78 | ||
|
|
a637448f83 | ||
|
|
f94ce03a04 | ||
|
|
92a14c8e68 |
@@ -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.1] - 2026-08-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Delete existing manifest before push (Gitea #31964 workaround)
|
||||||
|
|
||||||
## [0.51.0] - 2026-08-25
|
## [0.51.0] - 2026-08-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](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.0",
|
"devx>=0.51.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.51.1"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.51.0,<0.52"`).
|
> (for example, `"devx>=0.51.1,<0.52"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](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.0",
|
"devx>=0.51.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.0"` or `"devx>=0.51.0,<0.52"`.
|
Pin a specific version if needed: `"devx==0.51.1"` or `"devx>=0.51.1,<0.52"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.51.0",
|
"devx>=0.51.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.51.0",
|
"devx>=0.51.1",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.0"
|
__version__ = "0.51.1"
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workfl
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -188,11 +191,76 @@ def build_image(
|
|||||||
return True
|
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(
|
def push_image(
|
||||||
spec: ImageSpec,
|
spec: ImageSpec,
|
||||||
registry: str,
|
registry: str,
|
||||||
*,
|
*,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
username: str = "",
|
||||||
|
token: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Push all tags of a Docker image to the registry.
|
"""Push all tags of a Docker image to the registry.
|
||||||
|
|
||||||
@@ -200,7 +268,17 @@ def push_image(
|
|||||||
"""
|
"""
|
||||||
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||||
all_ok = True
|
all_ok = True
|
||||||
for ft in full_tags:
|
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]
|
cmd = ["docker", "push", ft]
|
||||||
if dry_run:
|
if dry_run:
|
||||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
@@ -320,11 +398,15 @@ def main(
|
|||||||
raise click.ClickException(_("Registry login failed"))
|
raise click.ClickException(_("Registry login failed"))
|
||||||
|
|
||||||
failed: list[str] = []
|
failed: list[str] = []
|
||||||
|
push_username = "" # nosec B105
|
||||||
|
push_token = "" # nosec B105
|
||||||
|
if push:
|
||||||
|
push_username, push_token = _get_registry_creds()
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
|
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
|
||||||
failed.append(spec.name)
|
failed.append(spec.name)
|
||||||
continue
|
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)
|
failed.append(spec.name)
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import devx.tools.build_image as build_image
|
|||||||
from devx.tools.build_image import (
|
from devx.tools.build_image import (
|
||||||
ImageSpec,
|
ImageSpec,
|
||||||
build_full_tag,
|
build_full_tag,
|
||||||
|
delete_remote_manifest,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
push_image,
|
push_image,
|
||||||
registry_login,
|
registry_login,
|
||||||
@@ -216,6 +217,119 @@ class TestPushImage:
|
|||||||
assert push_image(spec, "git.example.com", dry_run=True) is True
|
assert push_image(spec, "git.example.com", dry_run=True) is True
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_before_push_with_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", 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_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()
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class TestSortVersions:
|
||||||
def test_sort_by_created_at_desc(self) -> None:
|
def test_sort_by_created_at_desc(self) -> None:
|
||||||
@@ -588,11 +702,20 @@ class TestCLIBuildImage:
|
|||||||
"devx.tools.build_image.subprocess.run",
|
"devx.tools.build_image.subprocess.run",
|
||||||
side_effect=[login_result, build_result, push_result],
|
side_effect=[login_result, build_result, push_result],
|
||||||
):
|
):
|
||||||
result = runner.invoke(
|
with patch("devx.tools.build_image.delete_remote_manifest", return_value=True):
|
||||||
build_image.main,
|
result = runner.invoke(
|
||||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
build_image.main,
|
||||||
)
|
[
|
||||||
assert result.exit_code != 0
|
"--dockerfile",
|
||||||
|
str(dockerfile),
|
||||||
|
"--name",
|
||||||
|
"ci-base",
|
||||||
|
"--push",
|
||||||
|
"--registry",
|
||||||
|
"git.example.com",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
|
||||||
class TestCLICleanImages:
|
class TestCLICleanImages:
|
||||||
|
|||||||
Reference in New Issue
Block a user