DEVX-167: feat(ci): verify producer container artifact before opening dependency PR
Post-merge / detect-and-configure (push) Successful in 10s
Post-merge / release-and-maintain (push) Successful in 1m15s

This commit was merged in pull request #332.
This commit is contained in:
2026-09-19 02:43:55 +00:00
parent 779aa0dfa4
commit f15a8beb66
5 changed files with 414 additions and 51 deletions
+93 -4
View File
@@ -25,6 +25,7 @@ import tempfile
from pathlib import Path
import click
import requests
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
@@ -93,6 +94,55 @@ 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.
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).
"""
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
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,
)
)
def create_vikunja_task(title: str, description: str) -> str | None:
"""Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531)."""
try:
@@ -113,6 +163,24 @@ def create_vikunja_task(title: str, description: str) -> str | None:
@click.option("--new-version", required=True, help=_("New version to pin"))
@click.option("--source-repo", required=True, help=_("Source repo that published (owner/name)"))
@click.option("--source-run-id", default="", help=_("CI run ID that triggered the publish"))
@click.option(
"--verify-container",
default="",
help=_(
"Container to verify before opening the PR (owner/name). Resolves the OCI "
"digest of the tag matching --new-version (or --container-tag); the PR is "
"refused when the artifact is missing or unreadable."
),
)
@click.option(
"--container-tag",
default="",
help=_(
"Container tag to verify with --verify-container (default: --new-version). "
"Use when the image tag differs from the release version, e.g. a package "
"__version__ tag vs a release git tag."
),
)
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
def cli(
repo: str,
@@ -120,6 +188,8 @@ def cli(
new_version: str,
source_repo: str,
source_run_id: str,
verify_container: str,
container_tag: str,
dry_run: bool,
) -> None:
"""Create an infra PR to bump a pinned dependency version."""
@@ -129,9 +199,29 @@ def cli(
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Implements: REQ-1 — this tool runs from the *producer* repo's CI, so
# every file lookup and git operation must happen inside a clone of the
# target repo, not the producer checkout in CWD.
# Implements: REQ-1 — verify the producer artifact exists and resolve its
# digest before any branch/PR work begins.
image_digest = ""
if verify_container:
if "/" not in verify_container:
raise click.ClickException(
_("Invalid container format: {container} (expected owner/name)", container=verify_container)
)
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)
click.echo(
_(
"[dep-pr] Verified {container}:{version} -> {digest}",
container=verify_container,
version=image_tag,
digest=image_digest,
)
)
# Clone the target repo — this tool runs from the *producer* repo's CI,
# so every file lookup and git operation must happen inside a clone of
# the target repo, not the producer checkout in CWD.
workdir = Path(tempfile.mkdtemp(prefix="dep-pr-"))
clone_url = f"{GITEA_API_URL.removesuffix('/api/v1')}/{repo}.git"
auth_cfg = f"http.extraHeader=Authorization: token {token}"
@@ -203,7 +293,6 @@ def cli(
if not changed_file or not update_pinned_version(str(workdir / changed_file), package, old_version, new_version):
raise click.ClickException(_("Failed to update {file}", file=changed_file))
subprocess.run(["git", "add", changed_file], 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
+56
View File
@@ -1343,6 +1343,30 @@
"ru": "Настройка входа tea '{name}' для {url}...",
"zh": "正在为 {url} 配置 tea 登录 '{name}'..."
},
"Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.": {
"bg": "Артефактът на контейнера {owner}/{name}:{tag} не е намерен или не може да бъде прочетен (HTTP {status}). Отказвам да отворя PR за зависимост за непубликуван артефакт.",
"de": "Container-Artefakt {owner}/{name}:{tag} nicht gefunden oder nicht lesbar (HTTP {status}). Kein Dependency-PR für ein unveröffentlichtes Artefakt.",
"en": "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.",
"pl": "Artefakt kontenera {owner}/{name}:{tag} nie został znaleziony lub jest nieczytelny (HTTP {status}). Odmawiam otwarcia PR zależności dla nieopublikowanego artefaktu.",
"ru": "Артефакт контейнера {owner}/{name}:{tag} не найден или недоступен для чтения (HTTP {status}). Отказ открывать PR зависимости для неопубликованного артефакта.",
"zh": "容器构件 {owner}/{name}:{tag} 未找到或不可读 (HTTP {status})。拒绝为未发布的构件创建依赖 PR。"
},
"Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.": {
"bg": "Таг на контейнер за проверка с --verify-container (по подразбиране: --new-version). Използвайте, когато тагът на изображението се различава от версията на изданието, напр. таг на __version__ на пакет срещу git таг на издание.",
"de": "Container-Tag, der mit --verify-container geprüft wird (Standard: --new-version). Zu verwenden, wenn sich das Image-Tag von der Release-Version unterscheidet, z. B. ein __version__-Tag eines Pakets vs. ein Release-Git-Tag.",
"en": "Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.",
"pl": "Tag kontenera do weryfikacji z --verify-container (domyślnie: --new-version). Użyj, gdy tag obrazu różni się od wersji wydania, np. tag __version__ pakietu a tag git wydania.",
"ru": "Тег контейнера для проверки с --verify-container (по умолчанию: --new-version). Используйте, когда тег образа отличается от версии релиза, например тег __version__ пакета против git-тега релиза.",
"zh": "用 --verify-container 验证的容器标签(默认:--new-version)。当镜像标签与发布版本不同时使用,例如包的 __version__ 标签与发布 git 标签。"
},
"Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version (or --container-tag); the PR is refused when the artifact is missing or unreadable.": {
"bg": "Контейнер за проверка преди отваряне на PR (собственик/име). Разрешава OCI дайджеста на тага, отговарящ на --new-version (или --container-tag); PR се отказва, ако артефактът липсва или е нечетим.",
"de": "Container zur Verifizierung vor dem Öffnen des PR (owner/name). Löst den OCI-Digest des zu --new-version (oder --container-tag) passenden Tags auf; der PR wird abgelehnt, wenn das Artefakt fehlt oder unlesbar ist.",
"en": "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version (or --container-tag); the PR is refused when the artifact is missing or unreadable.",
"pl": "Kontener do weryfikacji przed otwarciem PR (właściciel/nazwa). Rozwiązuje skrót OCI tagu pasującego do --new-version (lub --container-tag); PR jest odrzucany, gdy artefakt nie istnieje lub jest nieczytelny.",
"ru": "Контейнер для проверки перед открытием PR (владелец/имя). Разрешает OCI-дайджест тега, соответствующего --new-version (или --container-tag); PR отклоняется, если артефакт отсутствует или недоступен.",
"zh": "在打开 PR 前要验证的容器(所有者/名称)。解析与 --new-version(或 --container-tag)匹配标签的 OCI 摘要;当工件缺失或不可读时拒绝创建 PR。"
},
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Не може да се определи номерът на PR. Използвайте --pr, за да го зададете изрично,\nили изпълнете командата от клон с отворен PR.",
"de": "PR-Nummer konnte nicht ermittelt werden. Mit --pr explizit angeben,\noder den Befehl von einem Branch mit offenem PR ausführen.",
@@ -1991,6 +2015,14 @@
"ru": "Интеграционные тесты пройдены.",
"zh": "集成测试通过。"
},
"Invalid container format: {container} (expected owner/name)": {
"bg": "Невалиден формат на контейнер: {container} (очаква се owner/name)",
"de": "Ungültiges Container-Format: {container} (erwartet owner/name)",
"en": "Invalid container format: {container} (expected owner/name)",
"pl": "Nieprawidłowy format kontenera: {container} (oczekiwano owner/name)",
"ru": "Неверный формат контейнера: {container} (ожидается owner/name)",
"zh": "容器格式无效:{container}(应为 owner/name"
},
"Invalid repo format: {repo}": {
"bg": "Невалиден формат на репозитория: {repo}",
"de": "Ungültiges Repo-Format: {repo}",
@@ -2887,6 +2919,22 @@
"ru": "Вход в реестр не удался: {error}",
"zh": "注册表登录失败:{error}"
},
"Registry lookup failed for {owner}/{name}:{tag}: {error}": {
"bg": "Неуспешно търсене в регистъра за {owner}/{name}:{tag}: {error}",
"de": "Registry-Abfrage für {owner}/{name}:{tag} fehlgeschlagen: {error}",
"en": "Registry lookup failed for {owner}/{name}:{tag}: {error}",
"pl": "Wyszukiwanie w rejestrze nie powiodło się dla {owner}/{name}:{tag}: {error}",
"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。"
},
"Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
"de": "Regulärer Merge-Commit — alle Post-Merge-Jobs werden ausgeführt.",
@@ -3727,6 +3775,14 @@
"ru": "[dep-pr] PR уже существует: #{number}",
"zh": "[dep-pr] PR 已存在:#{number}"
},
"[dep-pr] Verified {container}:{version} -> {digest}": {
"bg": "[dep-pr] Проверено {container}:{version} -> {digest}",
"de": "[dep-pr] Verifiziert {container}:{version} -> {digest}",
"en": "[dep-pr] Verified {container}:{version} -> {digest}",
"pl": "[dep-pr] Zweryfikowano {container}:{version} -> {digest}",
"ru": "[dep-pr] Проверено {container}:{version} -> {digest}",
"zh": "[dep-pr] 已验证 {container}:{version} -> {digest}"
},
"[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.",