fix(ci): resolve dep-PR container digest via registry v2 API
CI / validate (pull_request) Failing after 16s
CI / auto-merge (pull_request) Skipped

The packages-API manifest.json blob sha256 is not pullable via
repo@sha256:... — production deploy 6251 failed pulling the recorded
digest (404 not found). Existence check stays on the packages API
(keeps the 404 retry), but the digest now comes from the registry v2
Docker-Content-Digest header.

Implements DEVX-173 REQ-1/REQ-2.
This commit is contained in:
Emil Simeonov
2026-09-19 22:14:09 +02:00
parent 203d6971b3
commit 48e5dd5a34
3 changed files with 163 additions and 37 deletions
+57 -17
View File
@@ -200,23 +200,39 @@ class TestCreateVikunjaTask:
assert mock_client.create_task.call_args.args[0] == 3
class TestResolveContainerDigest:
"""REQ-1: pre-PR artifact verification via the packages API."""
def _v2_mocks(digest: str = "sha256:deadbeef") -> list[MagicMock]:
"""Token + manifest responses for the registry v2 digest lookup."""
tok = MagicMock()
tok.raise_for_status = MagicMock()
tok.json.return_value = {"token": "bearer-tok"}
man = MagicMock()
man.raise_for_status = MagicMock()
man.headers = {"Docker-Content-Digest": digest}
return [tok, man]
def test_returns_digest_from_manifest_blob(self) -> None:
class TestResolveContainerDigest:
"""REQ-1: existence check via packages API; digest via registry v2."""
def test_returns_digest_from_registry_v2(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [
{"name": "sha256_layer", "sha256": "abc"},
{"name": "manifest.json", "sha256": "deadbeef"},
]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = [{"name": "manifest.json", "sha256": "blob-not-pullable"}]
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, *_v2_mocks()],
) as mock_get:
digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
)
assert digest == "sha256:deadbeef"
man_call = mock_get.call_args_list[2]
assert "manifests/0.9.1" in man_call.args[0]
assert "oci.image.index" in man_call.kwargs["headers"]["Accept"]
tok_call = mock_get.call_args_list[1]
assert tok_call.kwargs["params"]["scope"] == "repository:oblachno/sso-bridge:pull"
def test_raises_when_version_missing(self) -> None:
import requests
@@ -231,14 +247,38 @@ class TestResolveContainerDigest:
with pytest.raises(click.ClickException, match="unpublished artifact"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok")
def test_raises_when_no_manifest_blob(self) -> None:
def test_raises_when_v2_digest_missing(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
with pytest.raises(click.ClickException, match="no manifest blob"):
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = []
tok, man = _v2_mocks(digest="")
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="no Docker-Content-Digest"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_when_v2_manifest_request_fails(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = []
tok, _ = _v2_mocks()
err = requests.HTTPError("500")
err.response = MagicMock(status_code=500)
man = MagicMock()
man.raise_for_status.side_effect = err
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="v2 digest lookup failed"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_on_connection_error(self) -> None:
@@ -635,7 +675,7 @@ class TestBranchCreation:
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.requests.get", side_effect=[fail, ok, *_v2_mocks("sha256:cafe")]),
patch("devx.ci.create_dependency_pr.time.sleep"),
):
digest = resolve_container_digest(