Compare commits

..
Author SHA1 Message Date
emil ca41f3025c fix: vale prose lint in DEVX-158 spec
CI / validate (pull_request) Successful in 1m17s
CI / auto-merge (pull_request) Failing after 12s
2026-08-25 19:24:27 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 84fc223327 fix: delete existing manifest before push (Gitea #31964 workaround)
CI / validate (pull_request) Failing after 50s
CI / auto-merge (pull_request) Skipped
Gitea 1.27 fails with HTTP 500 when pushing a tag that already exists.
Add delete_remote_manifest() to delete the existing manifest via the
Docker registry v2 API before each push.

Implements: REQ-1, REQ-2, REQ-3, REQ-4, REQ-5

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-25 19:22:01 +02:00
10 changed files with 48 additions and 207 deletions
+7 -20
View File
@@ -181,31 +181,18 @@ jobs:
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
. .venv/bin/activate 2>/dev/null || true
# Post APPROVE review via Gitea API to satisfy branch protection.
# Try REVIEWER_GITEA_API_TOKEN first; fall back to CI_GITEA_API_TOKEN
# (CI bot account) if the reviewer token is the same user as the PR
# creator (Gitea rejects self-approvals).
for TOKEN in "${REVIEWER_GITEA_API_TOKEN}" "${CI_GITEA_API_TOKEN}"; do
[ -z "$TOKEN" ] && continue
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "Approval posted successfully (HTTP $HTTP_CODE)."
break
fi
echo "::warning::Approval with token failed (HTTP $HTTP_CODE): ${BODY}"
done
# Post APPROVE review via Gitea API to satisfy branch protection
curl -s -X POST \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}' \
|| echo "::warning::Failed to post approval review (best-effort)."
- name: Squash merge with task ID
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
-18
View File
@@ -2,24 +2,6 @@
All notable changes to this project will be documented in this file.
## [0.51.3] - 2026-08-26
### Bug Fixes
- Fall back to CI bot token for auto-merge approval
## [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/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/python.svg)](https://www.python.org/downloads/)
[![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/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.51.3",
"devx>=0.51.0",
]
[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.3"`) or use a version constraint
> (for example, `"devx>=0.51.3,<0.52"`).
> `dependencies` (for example, `"devx==0.51.0"`) or use a version constraint
> (for example, `"devx>=0.51.0,<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/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/python.svg)](https://www.python.org/downloads/)
[![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/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.51.3",
"devx>=0.51.0",
]
[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.3"` or `"devx>=0.51.3,<0.52"`.
Pin a specific version if needed: `"devx==0.51.0"` or `"devx>=0.51.0,<0.52"`.
### Optional extras
-41
View File
@@ -1,41 +0,0 @@
# 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
-27
View File
@@ -1,27 +0,0 @@
# DEVX-161: Fix auto-merge self-approval: use CI bot token fallback
## Problem
The auto-merge workflow posts an APPROVE review using
`REVIEWER_GITEA_API_TOKEN`. When this token belongs to the same user
who created the PR, Gitea rejects the self-approval, causing the merge
to fail with HTTP 405 "Does not have enough approvals."
## Approach
Try `REVIEWER_GITEA_API_TOKEN` first; if it fails (self-approval
rejection), fall back to `CI_GITEA_API_TOKEN` (kireto — CI bot account).
REQ-1: Auto-merge posts approval with fallback to CI bot token
REQ-2: Approval step reports which token succeeded
## Test Plan
- Create a PR and observe auto-merge succeeds
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Auto-merge posts approval with fallback to CI bot token
- [x] REQ-2: Approval step reports which token succeeded
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.51.3",
"devx>=0.51.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.51.3",
"devx>=0.51.0",
]
```
+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.3"
__version__ = "0.51.0"
+17 -36
View File
@@ -265,15 +265,20 @@ 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)}")
@@ -285,38 +290,14 @@ def push_image(
text=True,
check=False,
)
if result.returncode == 0:
if result.returncode != 0:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
err=True,
)
all_ok = False
else:
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
+4 -45
View File
@@ -217,36 +217,11 @@ 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."""
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") 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
@@ -259,32 +234,16 @@ class TestPushImage:
dry_run=False,
)
def test_no_delete_on_non_already_exists_failure(self) -> None:
"""Push fails for other reasons (HTTP 500) — old manifest preserved."""
def test_no_delete_without_creds(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""
)
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 False
assert push_image(spec, "git.example.com") is True
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: