DEVX-158: fix: delete existing manifest before push (Gitea #31964 workaround)
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m22s

Co-authored-by: emo <emo@oblachno.com>
This commit was merged in pull request #290.
This commit is contained in:
emo
2026-08-25 17:26:04 +00:00
committed by kireto
parent 92a14c8e68
commit f94ce03a04
3 changed files with 250 additions and 7 deletions
+84 -2
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,11 +191,76 @@ 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.
@@ -200,7 +268,17 @@ def push_image(
"""
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):
# 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)}")
@@ -320,11 +398,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: