Compare commits

...
9 Commits
Author SHA1 Message Date
devx-ci-bot 127f781076 release: v0.55.2 [skip ci] 2026-09-19 20:56:24 +00:00
kireto d41972093b DEVX-174: fix(ci): supply git identity for dep-PR manifest commits
Post-merge / release-and-maintain (push) Successful in 1m4s
Post-merge / detect-and-configure (push) Successful in 9s
2026-09-19 20:55:42 +00:00
gitea-actions-bot 5e0befe777 chore: update badge URLs to commit ee346d97 [skip ci] 2026-09-19 20:20:40 +00:00
devx-ci-bot fcc171183c release: v0.55.1 [skip ci] 2026-09-19 20:20:01 +00:00
kireto 9d0e4cf429 DEVX-173: fix(ci): resolve dep-PR container digest via registry v2 API
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-09-19 20:19:22 +00:00
gitea-actions-bot 203d6971b3 chore: update badge URLs to commit 954c28d4 [skip ci] 2026-09-19 19:50:47 +00:00
devx-ci-bot f1dc00682d release: v0.55.0 [skip ci] 2026-09-19 19:49:53 +00:00
kireto af24a6a771 DEVX-172: feat(ci): retry dep-PR container verification until publish lands
Post-merge / detect-and-configure (push) Successful in 11s
Post-merge / release-and-maintain (push) Successful in 1m25s
2026-09-19 19:49:09 +00:00
gitea-actions-bot 457f52cba7 chore: update badge URLs to commit 9eabfdd8 [skip ci] 2026-09-19 19:31:24 +00:00
11 changed files with 379 additions and 79 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
## [0.55.2] - 2026-09-19
### Bug Fixes
- *(ci)* Supply git identity for dep-PR manifest commits
## [0.55.1] - 2026-09-19
### Bug Fixes
- *(ci)* Resolve dep-PR container digest via registry v2 API
## [0.55.0] - 2026-09-19
### Features
- *(ci)* Retry dep-PR container verification until publish lands
## [0.54.0] - 2026-09-19
### 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/a281b56d1ab5db23f55813a38900549d740ea527/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/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.54.0",
"devx>=0.55.2",
]
[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.54.0"`) or use a version constraint
> (for example, `"devx>=0.54.0,<0.55"`).
> `dependencies` (for example, `"devx==0.55.2"`) or use a version constraint
> (for example, `"devx>=0.55.2,<0.56"`).
### 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/a281b56d1ab5db23f55813a38900549d740ea527/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/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.54.0",
"devx>=0.55.2",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.54.0"` or `"devx>=0.54.0,<0.55"`.
Pin a specific version if needed: `"devx==0.55.2"` or `"devx>=0.55.2,<0.56"`.
### Optional extras
+32
View File
@@ -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.
+49
View File
@@ -0,0 +1,49 @@
# DEVX-173: Resolve container digest via registry v2 API
## Problem
`resolve_container_digest` returns the sha256 of the `manifest.json`
blob listed by the Gitea packages API (`/packages/<owner>/container/
<name>/<tag>/files`). That blob digest is NOT the OCI manifest digest —
`docker pull repo@sha256:<blob>` fails with "not found." Production
deploy run 6251 died pulling `sso-bridge@sha256:5ca9...` which the dep
PR had recorded in `deploy/sso-bridge-release.json`. The registry serves
0.4.1 as `sha256:c0212ed1...` — different digest entirely.
## Approach
REQ-1: Keep the packages-API call as the existence check (it has the
404-retry semantics needed for the publish race) but resolve the
pullable digest via the registry v2 API: `GET /v2/token` with
`scope=repository:<owner>/<name>:pull` (basic-auth with the Gitea
token), then `GET /v2/<owner>/<name>/manifests/<tag>` with OCI/Docker
manifest Accept headers and read `Docker-Content-Digest`. Registry base
URL is derived from `GITEA_API_URL` (strip `/api/v1`).
REQ-2: If the v2 digest lookup fails (non-2xx, missing header), fail
closed with a ClickException — never record a blob sha256 as a pullable
digest.
## Test Plan
- Mocked v2 token + manifest endpoints return digest; recorded value is
the `Docker-Content-Digest` header.
- 404 retry semantics on the packages-API existence check unchanged.
- Missing digest header / non-2xx manifest response → ClickException.
- Unit tests cover token request scope and Accept headers.
## Deploy Plan
devx release tag; the sso-bridge dep-PR pin bump follows in its own PR.
The wrong digest already recorded in infra's manifest is corrected by
re-running the dep-PR with the fixed version.
## Rollback Plan
Revert; dep-PR records the (unpullable) blob digest again — deploys must
then use tag pulls until a corrected manifest lands.
## Acceptance Criteria
- [x] REQ-1: Digest resolved from `Docker-Content-Digest` via v2 API.
- [x] REQ-2: Lookup failures fail closed; no blob-sha256 fallback.
+31
View File
@@ -0,0 +1,31 @@
# DEVX-174: Set git identity in dep-PR target clone
## Problem
`create_dependency_pr` commits the manifest bump in a fresh clone of the
target repo. The clone has no `user.name`/`user.email`, so `git commit`
exits 128 ("Please tell me who you are") — observed in sso-bridge
post-merge run 6284 after the digest and manifest bump both succeeded.
## Approach
REQ-1: Pass a CI bot identity inline (`git -c user.name=... -c
user.email=... commit`) so the commit works in any environment without
mutating global git config.
## Test Plan
- Unit test asserting the commit invocation carries `-c user.name` /
`-c user.email` arguments.
## Deploy Plan
devx release; sso-bridge picks it up via its devx pin on next bump.
## Rollback Plan
Revert; dep-PR commit fails again in containers without git identity.
## Acceptance Criteria
- [x] REQ-1: dep-PR commit supplies explicit identity flags.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.54.0",
"devx>=0.55.2",
]
[project.optional-dependencies]
dev = [
"devx>=0.54.0",
"devx>=0.55.2",
]
```
+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.54.0"
__version__ = "0.55.2"
+117 -36
View File
@@ -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,53 +96,108 @@ 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:
"""Resolve the OCI digest for a container image tag via the packages API.
_MANIFEST_ACCEPT = (
"application/vnd.oci.image.index.v1+json, "
"application/vnd.docker.distribution.manifest.list.v2+json, "
"application/vnd.oci.image.manifest.v1+json, "
"application/vnd.docker.distribution.manifest.v2+json"
)
Implements REQ-1: dependency PRs must only be opened after the producer
artifact exists — this raises ClickException when the tag is missing or
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-1 — existence check via packages API (keeps the 404-retry
# semantics for the publish race); the pullable digest is then resolved via
# the registry v2 API's Docker-Content-Digest header.
def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str:
"""Verify the container tag exists, then resolve its pullable OCI digest.
The packages API proves the tag was published (and 404s while the
producer's image-build workflow races us — ``timeout_s`` retries every
15s). The packages-API ``manifest.json`` blob sha256 is NOT pullable
via ``repo@sha256:...``, so the digest comes from the registry v2
``Docker-Content-Digest`` header instead.
"""
url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files"
headers = {"Authorization": f"token {token}"}
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
return _resolve_registry_digest(api_url, owner, name, tag, token)
# Implements: REQ-2 — v2 digest resolution fails closed: non-2xx, missing
# token, or absent Docker-Content-Digest all raise; a blob sha256 is never
# recorded as a pullable digest.
def _resolve_registry_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str:
registry = api_url.removesuffix("/api/v1").removesuffix("/")
repo = f"{owner}/{name}"
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 "?"
tok_resp = requests.get( # nosec B310
f"{registry}/v2/token",
params={"service": "container_registry", "scope": f"repository:{repo}:pull"},
auth=("ci", token),
timeout=30,
)
tok_resp.raise_for_status()
bearer = tok_resp.json().get("token", "")
man_resp = requests.get( # nosec B310
f"{registry}/v2/{repo}/manifests/{tag}",
headers={"Authorization": f"Bearer {bearer}", "Accept": _MANIFEST_ACCEPT},
timeout=30,
)
man_resp.raise_for_status()
except requests.RequestException as e:
status = getattr(getattr(e, "response", None), "status_code", "?")
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,
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
repo=repo,
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
for f in resp.json():
if f.get("name") == "manifest.json" and f.get("sha256"):
return f"sha256:{f['sha256']}"
raise click.ClickException(
_(
"Registry returned no manifest blob for {owner}/{name}:{tag}.",
owner=owner,
name=name,
tag=tag,
digest = man_resp.headers.get("Docker-Content-Digest", "")
if not digest:
raise click.ClickException(
_("Registry returned no Docker-Content-Digest for {repo}:{tag}.", repo=repo, tag=tag)
)
)
return digest
def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool:
@@ -243,6 +299,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 +328,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 +349,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}",
@@ -439,7 +505,22 @@ def cli(
add_files.append(spec_rel)
subprocess.run(["git", "add", *add_files], check=True, cwd=workdir) # nosec B603 B607
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607
# Implements: REQ-1 — the fresh clone has no git identity in CI
# containers; supply it inline (same bot identity as push_badges).
subprocess.run( # nosec B603 B607
[
"git",
"-c",
"user.name=gitea-actions-bot",
"-c",
"user.email=actions@oblachno.fyi",
"commit",
"-m",
commit_msg,
],
check=True,
cwd=workdir,
)
subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "push", "origin", branch_name],
check=True,
+31 -7
View File
@@ -2967,13 +2967,21 @@
"ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}",
"zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}"
},
"Registry returned no manifest blob for {owner}/{name}:{tag}.": {
"bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.",
"de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.",
"en": "Registry returned no manifest blob for {owner}/{name}:{tag}.",
"pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.",
"ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.",
"zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。"
"Registry returned no Docker-Content-Digest for {repo}:{tag}.": {
"bg": "Регистърът не върна Docker-Content-Digest за {repo}:{tag}.",
"de": "Registry hat keinen Docker-Content-Digest für {repo}:{tag} zurückgegeben.",
"en": "Registry returned no Docker-Content-Digest for {repo}:{tag}.",
"pl": "Rejestr nie zwrócił Docker-Content-Digest dla {repo}:{tag}.",
"ru": "Реестр не вернул Docker-Content-Digest для {repo}:{tag}.",
"zh": "注册表未返回 {repo}:{tag} 的 Docker-Content-Digest。"
},
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}": {
"bg": "Неуспешна заявка за дайджест към регистър v2 за {repo}:{tag} (HTTP {status}): {error}",
"de": "Registry-v2-Digest-Abfrage für {repo}:{tag} fehlgeschlagen (HTTP {status}): {error}",
"en": "Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
"pl": "Zapytanie o skrót do rejestru v2 dla {repo}:{tag} nie powiodło się (HTTP {status}): {error}",
"ru": "Ошибка запроса дайджеста к реестру v2 для {repo}:{tag} (HTTP {status}): {error}",
"zh": "注册表 v2 摘要查询 {repo}:{tag} 失败 (HTTP {status}){error}"
},
"Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
@@ -3159,6 +3167,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 +3847,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.",
+81 -16
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:
@@ -621,3 +661,28 @@ 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()
# Commit supplies an explicit identity (fresh clones have none)
commit = next(c for c in subprocess.run.call_args_list if "commit" in c.args[0])
assert "user.name=gitea-actions-bot" in commit.args[0]
assert "user.email=actions@oblachno.fyi" in commit.args[0]
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, *_v2_mocks("sha256:cafe")]),
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"