diff --git a/docs/specs/DEVX-172.md b/docs/specs/DEVX-172.md new file mode 100644 index 0000000..38d7d72 --- /dev/null +++ b/docs/specs/DEVX-172.md @@ -0,0 +1,32 @@ +# DEVX-172: dep-PR retries container verification until publish lands + +## Problem + +Post-merge dep-PR runs concurrently with the producer's image-build +workflow. `--verify-container` hits HTTP 404 before the push lands and +skips PR creation — observed for sso-bridge v0.4.1 and v0.4.3. + +## Approach + +REQ-1: `resolve_container_digest(..., timeout_s)` retries a 404 lookup +every 15s until the deadline. +REQ-2: `--verify-timeout` CLI option (default 600s, 0 disables) wires the +retry into the dep-PR step. + +## Test Plan + +- Unit test: 404-then-200 resolves the digest without raising. +- Existing no-retry path (timeout_s=0) still fails immediately. + +## Deploy Plan + +devx release tag; producers inherit the 600s default on next pin bump. + +## Rollback Plan + +Revert; dep-PR verification fails fast on 404 again (status quo). + +## Acceptance Criteria + +- [x] REQ-1: 404 responses retry until `timeout_s` deadline. +- [x] REQ-2: `--verify-timeout` option exposed, default 600. diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index ba0e547..93bb5d8 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -22,6 +22,7 @@ from __future__ import annotations import re import subprocess # nosec B404 import tempfile +import time from datetime import UTC, datetime from pathlib import Path @@ -95,7 +96,7 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve return changed -def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str: +def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str: """Resolve the OCI digest for a container image tag via the packages API. Implements REQ-1: dependency PRs must only be opened after the producer @@ -103,34 +104,52 @@ def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, toke the registry call fails, so the PR is never opened against an artifact that has not been published. The sha256 of the stored ``manifest.json`` blob is the manifest content digest (what ``docker pull`` reports). + + Implements REQ-2: ``timeout_s`` > 0 retries the lookup every 15s until + the deadline — the dep-PR step races the producer's image-build + workflow, which pushes the tag concurrently. """ url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files" headers = {"Authorization": f"token {token}"} - try: - resp = requests.get(url, headers=headers, timeout=30) # nosec B310 - resp.raise_for_status() - except requests.HTTPError as e: - status = e.response.status_code if e.response is not None else "?" - raise click.ClickException( - _( - "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). " - "Refusing to open a dependency PR for an unpublished artifact.", - owner=owner, - name=name, - tag=tag, - status=status, - ) - ) from e - except requests.RequestException as e: - raise click.ClickException( - _( - "Registry lookup failed for {owner}/{name}:{tag}: {error}", - owner=owner, - name=name, - tag=tag, - error=e, - ) - ) from e + deadline = time.monotonic() + timeout_s + while True: + try: + resp = requests.get(url, headers=headers, timeout=30) # nosec B310 + resp.raise_for_status() + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 404 and time.monotonic() < deadline: + click.echo( + _( + "[dep-pr] {owner}/{name}:{tag} not published yet — retrying.", + owner=owner, + name=name, + tag=tag, + ) + ) + time.sleep(15) + continue + status = e.response.status_code if e.response is not None else "?" + raise click.ClickException( + _( + "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). " + "Refusing to open a dependency PR for an unpublished artifact.", + owner=owner, + name=name, + tag=tag, + status=status, + ) + ) from e + except requests.RequestException as e: + raise click.ClickException( + _( + "Registry lookup failed for {owner}/{name}:{tag}: {error}", + owner=owner, + name=name, + tag=tag, + error=e, + ) + ) from e + break for f in resp.json(): if f.get("name") == "manifest.json" and f.get("sha256"): return f"sha256:{f['sha256']}" @@ -243,6 +262,15 @@ def create_vikunja_task(title: str, description: str, project_id: int = 0) -> st "__version__ tag vs a release git tag." ), ) +@click.option( + "--verify-timeout", + type=int, + default=600, + help=_( + "Seconds to keep retrying --verify-container while the artifact returns 404 " + "(the image build races this step). 0 disables retries." + ), +) @click.option( "--task-project-id", type=int, @@ -263,6 +291,7 @@ def cli( verify_container: str, source_ref: str, container_tag: str, + verify_timeout: int, task_project_id: int, dry_run: bool, ) -> None: @@ -283,7 +312,7 @@ def cli( ) c_owner, c_name = verify_container.split("/", 1) image_tag = container_tag or new_version - image_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, image_tag, token) + image_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, image_tag, token, verify_timeout) click.echo( _( "[dep-pr] Verified {container}:{version} -> {digest}", diff --git a/src/devx/translations.json b/src/devx/translations.json index 381b032..b93fb02 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -3159,6 +3159,14 @@ "ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа", "zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置" }, + "Seconds to keep retrying --verify-container while the artifact returns 404 (the image build races this step). 0 disables retries.": { + "bg": "Секунди за повторни опити на --verify-container, докато артефактът връща 404 (компилацията на изображението е конкурентна). 0 изключва повторните опити.", + "de": "Sekunden, die --verify-container bei HTTP 404 weiter versucht wird (der Image-Build läuft parallel). 0 deaktiviert Wiederholungen.", + "en": "Seconds to keep retrying --verify-container while the artifact returns 404 (the image build races this step). 0 disables retries.", + "pl": "Sekundy ponawiania --verify-container, gdy artefakt zwraca 404 (budowa obrazu jest współbieżna). 0 wyłącza ponawianie.", + "ru": "Секунды повторных попыток --verify-container, пока артефакт возвращает 404 (сборка образа идёт параллельно). 0 отключает повторы.", + "zh": "当构件返回 404 时 --verify-container 的重试秒数(镜像构建与此步骤并行)。0 禁用重试。" + }, "Show what would be done without creating PR": { "bg": "Покажи какво би било направено без създаване на PR", "de": "Zeigen, was getan würde, ohne PR zu erstellen", @@ -3831,6 +3839,14 @@ "ru": "[dep-pr] Проверено {container}:{version} -> {digest}", "zh": "[dep-pr] 已验证 {container}:{version} -> {digest}" }, + "[dep-pr] {owner}/{name}:{tag} not published yet — retrying.": { + "bg": "[dep-pr] {owner}/{name}:{tag} още не е публикуван — повторен опит.", + "de": "[dep-pr] {owner}/{name}:{tag} noch nicht veröffentlicht — neuer Versuch.", + "en": "[dep-pr] {owner}/{name}:{tag} not published yet — retrying.", + "pl": "[dep-pr] {owner}/{name}:{tag} jeszcze nie opublikowano — ponawianie.", + "ru": "[dep-pr] {owner}/{name}:{tag} ещё не опубликован — повторная попытка.", + "zh": "[dep-pr] {owner}/{name}:{tag} 尚未发布 — 正在重试。" + }, "[dep-pr] {pkg} already at {version} — no PR needed.": { "bg": "[dep-pr] {pkg} вече е на {version} — не е нужен PR.", "de": "[dep-pr] {pkg} bereits auf {version} — kein PR nötig.", diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index a1d66c1..181ddde 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -621,3 +621,24 @@ class TestBranchCreation: # PR title carries the task ID assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1" mock_task.assert_called_once() + + def test_retries_404_until_published(self) -> None: + import requests + + from devx.ci.create_dependency_pr import resolve_container_digest + + err = requests.HTTPError("404") + err.response = MagicMock(status_code=404) + fail = MagicMock() + fail.raise_for_status.side_effect = err + ok = MagicMock() + ok.raise_for_status = MagicMock() + ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}] + with ( + patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok]), + patch("devx.ci.create_dependency_pr.time.sleep"), + ): + digest = resolve_container_digest( + "https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok", timeout_s=60 + ) + assert digest == "sha256:cafe"