Public Access
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9bc97be63 |
@@ -77,9 +77,6 @@ jobs:
|
|||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
# Serialize blob uploads to avoid Gitea registry race condition
|
|
||||||
# (BlobUploader.Append offset mismatch — see DEVX-162).
|
|
||||||
DOCKER_MAX_CONCURRENT_UPLOADS: "1"
|
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|||||||
+7
-20
@@ -181,31 +181,18 @@ jobs:
|
|||||||
- name: Post approval review
|
- name: Post approval review
|
||||||
env:
|
env:
|
||||||
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
# Post APPROVE review via Gitea API to satisfy branch protection.
|
# Post APPROVE review via Gitea API to satisfy branch protection
|
||||||
# Try REVIEWER_GITEA_API_TOKEN first; fall back to CI_GITEA_API_TOKEN
|
curl -s -X POST \
|
||||||
# (CI bot account) if the reviewer token is the same user as the PR
|
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||||
# creator (Gitea rejects self-approvals).
|
-H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
|
||||||
for TOKEN in "${REVIEWER_GITEA_API_TOKEN}" "${CI_GITEA_API_TOKEN}"; do
|
-H "Content-Type: application/json" \
|
||||||
[ -z "$TOKEN" ] && continue
|
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}' \
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|| echo "::warning::Failed to post approval review (best-effort)."
|
||||||
"${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
|
|
||||||
- name: Squash merge with task ID
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
|
|||||||
@@ -2,28 +2,6 @@
|
|||||||
|
|
||||||
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.4] - 2026-08-26
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Serialize registry uploads and retry on HTTP 500
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- Remove cross-repo contract tests from devx
|
|
||||||
|
|
||||||
## [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
|
## [0.51.1] - 2026-08-25
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -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.4",
|
"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.4"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.51.1"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.51.4,<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.4",
|
"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.4"` or `"devx>=0.51.4,<0.52"`.
|
Pin a specific version if needed: `"devx==0.51.1"` or `"devx>=0.51.1,<0.52"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# DEVX-160: Remove cross-repo contract tests from devx
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
devx unit tests (`test_spec_driven_workflows.py`) were validating workflow
|
|
||||||
YAML and skill files in infra, grm, sso-bridge, and Mattermost OIDC repos.
|
|
||||||
This is an architecture violation — devx must not be aware of other repos.
|
|
||||||
Those repos consume devx; devx does not test them.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
Rewrite `test_spec_driven_workflows.py` to only test devx's own workflows
|
|
||||||
and skills. Remove all references to `_OBLACHNO_ROOT`, `_INFRA`, `_GRM`,
|
|
||||||
`_SSO_BRIDGE`, and parametrized repo lists.
|
|
||||||
|
|
||||||
REQ-1: No references to other repos in devx tests
|
|
||||||
REQ-2: All devx workflow/skill tests still pass
|
|
||||||
REQ-3: 100% coverage maintained
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
- Run `pytest tests/unit/test_spec_driven_workflows.py` — all pass
|
|
||||||
- Run full test suite with coverage — 100%
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
- Merge to master
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
- Revert the merge commit
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] REQ-1: No references to other repos in devx tests
|
|
||||||
- [x] REQ-2: All devx workflow/skill tests still pass
|
|
||||||
- [x] REQ-3: 100% coverage maintained
|
|
||||||
@@ -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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# DEVX-162: Fix registry push race condition: serialize uploads + retry on HTTP 500
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
The Gitea container registry (v1.27.2) has a known race condition in
|
|
||||||
`BlobUploader.Append()` where concurrent blob uploads cause the file
|
|
||||||
offset and DB model to get out of sync, producing HTTP 500 "offset
|
|
||||||
mismatch between file and model" errors. This causes the build-images
|
|
||||||
workflow to fail intermittently when pushing runner images.
|
|
||||||
|
|
||||||
The `package_blob_upload` table accumulates stale entries from failed
|
|
||||||
uploads that worsen the problem over time.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
Two fixes in devx (a third fix — scheduled cleanup — is tracked
|
|
||||||
separately as OBL-INFRA-537):
|
|
||||||
|
|
||||||
1. Set `DOCKER_MAX_CONCURRENT_UPLOADS=1` in the build-images workflow
|
|
||||||
to serialize blob uploads and avoid the race condition.
|
|
||||||
|
|
||||||
2. Add HTTP 500 retry logic to `push_image` in `build_image.py`.
|
|
||||||
When a push fails with HTTP 500 (not "already exists"), retry up
|
|
||||||
to 3 times with exponential backoff (5s, 10s, 20s).
|
|
||||||
|
|
||||||
REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
|
|
||||||
REQ-2: push_image retries on HTTP 500 with exponential backoff
|
|
||||||
REQ-3: All existing tests pass with 100% coverage
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
- Unit tests for retry logic (mock subprocess)
|
|
||||||
- Manual: trigger build-images workflow and verify push succeeds
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
- Merge to master
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
- Revert the merge commit
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
|
|
||||||
- [x] REQ-2: push_image retries on HTTP 500 with exponential backoff
|
|
||||||
- [x] REQ-3: All existing tests pass with 100% coverage
|
|
||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.51.4",
|
"devx>=0.51.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.51.4",
|
"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.4"
|
__version__ = "0.51.1"
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.tokens import get_developer_token
|
from devx.tokens import get_developer_token
|
||||||
@@ -255,29 +254,6 @@ def delete_remote_manifest(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class PushHTTP500Error(Exception):
|
|
||||||
"""Raised when docker push fails with an HTTP 500 from the registry."""
|
|
||||||
|
|
||||||
|
|
||||||
def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a docker push command, raising PushHTTP500Error on registry 500.
|
|
||||||
|
|
||||||
The Gitea container registry (v1.27.x) has a race condition in
|
|
||||||
BlobUploader.Append that causes intermittent HTTP 500 "offset
|
|
||||||
mismatch" errors during concurrent blob uploads. Retrying the
|
|
||||||
push gives the registry time to recover.
|
|
||||||
"""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 and "500" in result.stderr:
|
|
||||||
raise PushHTTP500Error(result.stderr.strip())
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def push_image(
|
def push_image(
|
||||||
spec: ImageSpec,
|
spec: ImageSpec,
|
||||||
registry: str,
|
registry: str,
|
||||||
@@ -294,9 +270,6 @@ def push_image(
|
|||||||
with Gitea #31964 ("package version already exists") do we delete
|
with Gitea #31964 ("package version already exists") do we delete
|
||||||
the old manifest and retry. This avoids losing the existing tag
|
the old manifest and retry. This avoids losing the existing tag
|
||||||
when the push fails for unrelated reasons (e.g. HTTP 500).
|
when the push fails for unrelated reasons (e.g. HTTP 500).
|
||||||
|
|
||||||
HTTP 500 errors from the Gitea registry race condition are retried
|
|
||||||
up to 3 times with exponential backoff (5s, 10s) via tenacity.
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -306,26 +279,12 @@ def push_image(
|
|||||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
continue
|
continue
|
||||||
click.echo(f"Pushing {ft}...")
|
click.echo(f"Pushing {ft}...")
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
@retry(
|
cmd,
|
||||||
stop=stop_after_attempt(3),
|
capture_output=True,
|
||||||
wait=wait_exponential(multiplier=5, min=5, max=20),
|
text=True,
|
||||||
retry=retry_if_exception_type(PushHTTP500Error),
|
check=False,
|
||||||
reraise=True,
|
|
||||||
)
|
)
|
||||||
def _attempt(_cmd: list[str] = cmd) -> subprocess.CompletedProcess[str]:
|
|
||||||
return _run_push(_cmd)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = _attempt()
|
|
||||||
except PushHTTP500Error as e:
|
|
||||||
click.echo(
|
|
||||||
_("Push failed for {tag}: {error}", tag=ft, error=str(e)),
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
all_ok = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
click.echo(f"Pushed {ft}")
|
click.echo(f"Pushed {ft}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from click.testing import CliRunner
|
|||||||
import devx.tools.build_image as build_image
|
import devx.tools.build_image as build_image
|
||||||
from devx.tools.build_image import (
|
from devx.tools.build_image import (
|
||||||
ImageSpec,
|
ImageSpec,
|
||||||
PushHTTP500Error,
|
|
||||||
build_full_tag,
|
build_full_tag,
|
||||||
delete_remote_manifest,
|
delete_remote_manifest,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
@@ -243,7 +242,7 @@ class TestPushImage:
|
|||||||
"""Gitea #31964: push fails with 'already exists', delete + retry."""
|
"""Gitea #31964: push fails with 'already exists', delete + retry."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
results = [
|
results = [
|
||||||
MagicMock(returncode=1, stderr="package version already exists", stdout=""),
|
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
|
||||||
MagicMock(returncode=0, stderr="", stdout=""),
|
MagicMock(returncode=0, stderr="", stdout=""),
|
||||||
]
|
]
|
||||||
with (
|
with (
|
||||||
@@ -261,9 +260,11 @@ class TestPushImage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_delete_on_non_already_exists_failure(self) -> None:
|
def test_no_delete_on_non_already_exists_failure(self) -> None:
|
||||||
"""Push fails for other reasons (non-500) — old manifest preserved."""
|
"""Push fails for other reasons (HTTP 500) — old manifest preserved."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
mock_result = MagicMock(returncode=1, stderr="denied: requested access to the resource is denied", stdout="")
|
mock_result = MagicMock(
|
||||||
|
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""
|
||||||
|
)
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
||||||
@@ -275,7 +276,7 @@ class TestPushImage:
|
|||||||
"""Gitea #31964 retry also fails — both pushes fail."""
|
"""Gitea #31964 retry also fails — both pushes fail."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
results = [
|
results = [
|
||||||
MagicMock(returncode=1, stderr="package version already exists", stdout=""),
|
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
|
||||||
MagicMock(returncode=1, stderr="push failed again", stdout=""),
|
MagicMock(returncode=1, stderr="push failed again", stdout=""),
|
||||||
]
|
]
|
||||||
with (
|
with (
|
||||||
@@ -284,62 +285,6 @@ class TestPushImage:
|
|||||||
):
|
):
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
||||||
|
|
||||||
def test_http_500_retries_then_succeeds(self) -> None:
|
|
||||||
"""HTTP 500 from registry race condition — retry succeeds."""
|
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
|
||||||
results = [
|
|
||||||
MagicMock(returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", 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") as mock_del,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is True
|
|
||||||
mock_del.assert_not_called()
|
|
||||||
|
|
||||||
def test_http_500_retries_all_fail(self) -> None:
|
|
||||||
"""HTTP 500 retries exhausted — push fails, no delete attempted."""
|
|
||||||
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,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
|
||||||
mock_del.assert_not_called()
|
|
||||||
|
|
||||||
def test_run_push_raises_on_500(self) -> None:
|
|
||||||
"""_run_push raises PushHTTP500Error when stderr contains 500."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stderr="HTTP 500 Internal Server Error", stdout="")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(PushHTTP500Error, match="HTTP 500"):
|
|
||||||
_run_push(["docker", "push", "img:latest"])
|
|
||||||
|
|
||||||
def test_run_push_no_raise_on_non_500(self) -> None:
|
|
||||||
"""_run_push returns result when stderr has no 500."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stderr="denied: access denied", stdout="")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
result = _run_push(["docker", "push", "img:latest"])
|
|
||||||
assert result.returncode == 1
|
|
||||||
|
|
||||||
def test_run_push_no_raise_on_success(self) -> None:
|
|
||||||
"""_run_push returns result on success."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
result = _run_push(["docker", "push", "img:latest"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRemoteManifest:
|
class TestDeleteRemoteManifest:
|
||||||
def test_dry_run(self) -> None:
|
def test_dry_run(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user