DEVX-88: fix: correct clean_images delete URL and add retry with error handling
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Build Images / detect-type (push) Successful in 36s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 19s
Build Images / build-and-push (push) Successful in 2m55s
Build Images / cleanup (push) Successful in 5m47s

This commit was merged in pull request #140.
This commit is contained in:
2026-06-27 21:40:49 +00:00
parent 925b99b7db
commit 598238e4d6
3 changed files with 176 additions and 13 deletions
+34 -4
View File
@@ -34,6 +34,7 @@ Authentication uses ``CI_GITEA_TOKEN`` environment variable.
from __future__ import annotations from __future__ import annotations
import os import os
import time
from typing import Any from typing import Any
import click import click
@@ -85,15 +86,37 @@ def delete_package_version(
token: str, token: str,
*, *,
timeout: int = 30, timeout: int = 30,
package_type: str = "container",
max_retries: int = 3,
) -> bool: ) -> bool:
"""Delete a specific version of a container package. """Delete a specific version of a container package.
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
Returns True on success, False on failure. Returns True on success, False on failure.
""" """
url = f"{api_url}/packages/{owner}/{name}/{version}" url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout) for attempt in range(max_retries):
return resp.status_code in (204, 200) try:
resp = requests.delete(url, headers=headers, timeout=timeout)
except requests.RequestException:
if attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
if resp.status_code in (204, 200):
return True
# 404 means already deleted — treat as success
if resp.status_code == 404:
return True
# 5xx is transient — retry
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
return False
def sort_versions_by_date( def sort_versions_by_date(
@@ -171,6 +194,7 @@ def main(
total_deleted = 0 total_deleted = 0
total_kept = 0 total_kept = 0
total_failed = 0
for name in names: for name in names:
click.echo(f"\n{'=' * 60}") click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}") click.echo(f"Package: {owner}/{name}")
@@ -182,6 +206,7 @@ def main(
_("Failed to list versions for {name}: {error}", name=name, error=exc), _("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True, err=True,
) )
total_failed += 1
continue continue
if not versions: if not versions:
@@ -203,6 +228,7 @@ def main(
continue continue
deleted_count = 0 deleted_count = 0
failed_count = 0
for v in to_delete: for v in to_delete:
version = str(v.get("version", "")) version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token): if delete_package_version(base_url, owner, name, version, token):
@@ -210,11 +236,15 @@ def main(
deleted_count += 1 deleted_count += 1
else: else:
click.echo(f" FAILED to delete: {version}", err=True) click.echo(f" FAILED to delete: {version}", err=True)
failed_count += 1
total_deleted += deleted_count total_deleted += deleted_count
total_kept += kept_count total_kept += kept_count
total_failed += failed_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.") click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
if total_failed > 0:
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+8
View File
@@ -1391,6 +1391,14 @@
"ru": "CI_GITEA_TOKEN environment variable required", "ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required" "zh": "CI_GITEA_TOKEN environment variable required"
}, },
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": { "CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.", "bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.", "de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
+134 -9
View File
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
from devx.tools.clean_images import delete_package_version from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204) mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
# Verify URL includes container type
url = mock_del.call_args.args[0]
assert "/container/" in url
def test_delete_package_version_404_treated_as_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert ( assert (
delete_package_version( delete_package_version(
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
def test_delete_package_version_failure(self) -> None: def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404) mock_resp = MagicMock(status_code=403)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert ( assert (
delete_package_version( delete_package_version(
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
is False is False
) )
def test_delete_package_version_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
responses = [
MagicMock(status_code=500),
MagicMock(status_code=502),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
responses = [
req.ConnectionError("network down"),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
side_effect=req.ConnectionError("network down"),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
return_value=MagicMock(status_code=500),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_zero_retries(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch("devx.tools.clean_images.requests.delete") as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=0,
)
is False
)
mock_del.assert_not_called()
class TestCLIBuildImage: class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None: def test_single_image_build(self, tmp_path: Path) -> None:
@@ -557,7 +680,7 @@ class TestCLICleanImages:
clean_main, clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
) )
assert result.exit_code == 0 assert result.exit_code != 0
assert "Failed to list" in result.output assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None: def test_delete_failure_in_cli(self) -> None:
@@ -571,13 +694,15 @@ class TestCLICleanImages:
{"version": "0.3.0", "created_at": "2025-03-01"}, {"version": "0.3.0", "created_at": "2025-03-01"},
] ]
list_resp.raise_for_status = MagicMock() list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500) delete_resp = MagicMock(status_code=403)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp): with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke( with patch("devx.tools.clean_images.time.sleep"):
clean_main, result = runner.invoke(
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], clean_main,
) ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
assert result.exit_code == 0 )
assert "FAILED" in result.output assert result.exit_code != 0
assert "FAILED" in result.output
assert "failed" in result.output.lower()