Public Access
feat(ci): manifest-aware dependency PRs and cleanup protection
Implements: DEVX-165 REQ-1..3 - create_dependency_pr gains --manifest/--verify-container/--source-ref: resolves the producer image's OCI digest via the packages API before opening the PR, then writes structured manifest fields (version/git_ref/image_tag/image_digest/updated_at) in the PR branch - clean_images gains --protect: pinned versions survive --keep trimming so a manifest-referenced digest can't be garbage-collected - regression tests for digest resolution, manifest helpers, verify ordering, and protect filtering
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# DEVX-165: Accept deps: as valid conventional commit type
|
||||
|
||||
## Problem
|
||||
The commit validator rejects `deps:` as a conventional commit type, causing
|
||||
post-merge CI failures on grm and sso-bridge repos where automated dependency
|
||||
bump PRs use `deps: bump devx...` as the commit message.
|
||||
|
||||
## Approach
|
||||
REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
|
||||
REQ-2: Update the allowed types list in the error message in
|
||||
`src/devx/ci/validate_commit_msg.py`
|
||||
REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
|
||||
and `tests/unit/test_validate_commit_msg.py`
|
||||
|
||||
## Test Plan
|
||||
- `make pytest-cov` passes with 100% coverage
|
||||
- `make lint-all` passes
|
||||
|
||||
## Deploy Plan
|
||||
- Merge to master → post-merge auto-publishes new devx version
|
||||
- grm and sso-bridge bump devx version to pick up the fix
|
||||
|
||||
## Rollback Plan
|
||||
- Revert the merge commit
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
|
||||
- [x] REQ-2: Update the allowed types list in the error message in
|
||||
`src/devx/ci/validate_commit_msg.py`
|
||||
- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
|
||||
and `tests/unit/test_validate_commit_msg.py`
|
||||
+43
-19
@@ -1,31 +1,55 @@
|
||||
# DEVX-165: Accept deps: as valid conventional commit type
|
||||
# DEVX-165: Manifest-aware dependency PRs and registry cleanup protection
|
||||
|
||||
## Problem
|
||||
The commit validator rejects `deps:` as a conventional commit type, causing
|
||||
post-merge CI failures on grm and sso-bridge repos where automated dependency
|
||||
bump PRs use `deps: bump devx...` as the commit message.
|
||||
|
||||
S03 (OBL-INFRA-548 REQ-3) requires an immutable delivery contract: infra
|
||||
pins the sso-bridge release as {version, git ref, image tag, OCI digest}
|
||||
in a JSON manifest. Two gaps in devx block that:
|
||||
|
||||
1. `create_dependency_pr` only regex-bumps a version string in
|
||||
pyproject/ansible vars — it cannot update a structured manifest with
|
||||
the resolved image digest, and it does not verify the producer
|
||||
artifact exists before opening the PR.
|
||||
2. `clean_images` deletes all but the newest N tags — a tag/digest that
|
||||
infra still pins gets deleted once newer releases land, breaking
|
||||
deploys.
|
||||
|
||||
## Approach
|
||||
REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
|
||||
REQ-2: Update the allowed types list in the error message in
|
||||
`src/devx/ci/validate_commit_msg.py`
|
||||
REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
|
||||
and `tests/unit/test_validate_commit_msg.py`
|
||||
|
||||
REQ-1: `create_dependency_pr` gains `--manifest <path>` +
|
||||
`--verify-container <owner/name>`: before opening the PR it resolves the
|
||||
container tag's OCI digest via the registry `manifests` API
|
||||
(`Docker-Content-Digest`), then updates manifest fields
|
||||
`{version, git_ref, image_tag, image_digest, source_commit, updated_at}`
|
||||
in the PR branch instead of a regex bump.
|
||||
|
||||
REQ-2: `clean_images` gains `--protect` (repeatable): named versions are
|
||||
never deleted regardless of `--keep` trimming.
|
||||
|
||||
REQ-3: Regression tests for manifest update, digest resolution,
|
||||
verify-then-PR ordering, and protect filtering.
|
||||
|
||||
## Files Affected
|
||||
|
||||
- `src/devx/ci/create_dependency_pr.py`
|
||||
- `src/devx/tools/clean_images.py`
|
||||
- `tests/unit/test_create_dependency_pr.py`
|
||||
- `tests/unit/test_clean_images.py`
|
||||
|
||||
## Test Plan
|
||||
- `make pytest-cov` passes with 100% coverage
|
||||
- `make lint-all` passes
|
||||
|
||||
- New unit tests per REQ; `make pytest-cov`, `make lint-all`.
|
||||
|
||||
## Deploy Plan
|
||||
- Merge to master → post-merge auto-publishes new devx version
|
||||
- grm and sso-bridge bump devx version to pick up the fix
|
||||
|
||||
- Merge → devx release → consumer repos pick up via dependency PRs.
|
||||
|
||||
## Rollback Plan
|
||||
- Revert the merge commit
|
||||
|
||||
- Revert; regex version bump and unprotected cleanup return.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
|
||||
- [x] REQ-2: Update the allowed types list in the error message in
|
||||
`src/devx/ci/validate_commit_msg.py`
|
||||
- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
|
||||
and `tests/unit/test_validate_commit_msg.py`
|
||||
|
||||
- [x] REQ-1: Manifest update + pre-PR OCI digest verification
|
||||
- [x] REQ-2: `--protect` exempts versions from cleanup
|
||||
- [x] REQ-3: Regression tests added and passing
|
||||
|
||||
@@ -21,9 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
@@ -92,6 +94,103 @@ 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 update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool:
|
||||
"""Update a release-manifest JSON section in place. Returns True if changed.
|
||||
|
||||
Implements REQ-1: manifest fields record the immutable release contract
|
||||
(version, git ref, image tag, resolved OCI digest) in the target repo.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise click.ClickException(_("Manifest file not found: {file}", file=file_path))
|
||||
try:
|
||||
manifest = _json.loads(path.read_text(encoding="utf-8"))
|
||||
except _json.JSONDecodeError as e:
|
||||
raise click.ClickException(_("Manifest file {file} is not valid JSON: {error}", file=file_path, error=e)) from e
|
||||
existing = manifest.get(section)
|
||||
if not isinstance(existing, dict):
|
||||
raise click.ClickException(
|
||||
_("Manifest file {file} has no object section {section}", file=file_path, section=section)
|
||||
)
|
||||
changed = False
|
||||
for k, v in fields.items():
|
||||
if existing.get(k) != v:
|
||||
existing[k] = v
|
||||
changed = True
|
||||
if changed:
|
||||
path.write_text(_json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
return changed
|
||||
|
||||
|
||||
def read_manifest_version(file_path: str, section: str) -> str | None:
|
||||
"""Read the currently pinned version from a release manifest section."""
|
||||
import json as _json
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
manifest = _json.loads(path.read_text(encoding="utf-8"))
|
||||
except _json.JSONDecodeError:
|
||||
return None
|
||||
existing = manifest.get(section)
|
||||
if isinstance(existing, dict):
|
||||
version = existing.get("version")
|
||||
return str(version) if version is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def create_vikunja_task(title: str, description: str) -> str | None:
|
||||
"""Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531)."""
|
||||
try:
|
||||
@@ -112,6 +211,28 @@ 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(
|
||||
"--manifest",
|
||||
"manifest_path",
|
||||
default="",
|
||||
help=_(
|
||||
"Path to a release-manifest JSON in the target repo. When set, the PR updates "
|
||||
"the manifest section named after --package instead of a regex version bump."
|
||||
),
|
||||
)
|
||||
@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 and records it in the manifest."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--source-ref",
|
||||
default="",
|
||||
help=_("Git ref of the producer release (default: v<new-version>), recorded in the manifest."),
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
|
||||
def cli(
|
||||
repo: str,
|
||||
@@ -119,6 +240,9 @@ def cli(
|
||||
new_version: str,
|
||||
source_repo: str,
|
||||
source_run_id: str,
|
||||
manifest_path: str,
|
||||
verify_container: str,
|
||||
source_ref: str,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Create an infra PR to bump a pinned dependency version."""
|
||||
@@ -128,14 +252,37 @@ def cli(
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
# 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_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, new_version, token)
|
||||
click.echo(
|
||||
_(
|
||||
"[dep-pr] Verified {container}:{version} -> {digest}",
|
||||
container=verify_container,
|
||||
version=new_version,
|
||||
digest=image_digest,
|
||||
)
|
||||
)
|
||||
|
||||
# Find current pinned version
|
||||
old_version = None
|
||||
changed_file = None
|
||||
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
|
||||
old_version = find_pinned_version(package, f)
|
||||
if old_version:
|
||||
changed_file = f
|
||||
break
|
||||
if manifest_path:
|
||||
old_version = read_manifest_version(manifest_path, package)
|
||||
changed_file = manifest_path if old_version else None
|
||||
else:
|
||||
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
|
||||
old_version = find_pinned_version(package, f)
|
||||
if old_version:
|
||||
changed_file = f
|
||||
break
|
||||
|
||||
if not old_version:
|
||||
click.echo(_("[dep-pr] Could not find pinned version for {pkg} in infra repo.", pkg=package))
|
||||
@@ -188,9 +335,25 @@ def cli(
|
||||
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True) # nosec B603 B607
|
||||
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True) # nosec B603 B607
|
||||
|
||||
if not changed_file or not update_pinned_version(changed_file, package, old_version, new_version):
|
||||
if manifest_path:
|
||||
from datetime import datetime
|
||||
|
||||
fields = {
|
||||
"version": new_version,
|
||||
"git_ref": source_ref or f"v{new_version}",
|
||||
"image_tag": new_version,
|
||||
"updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
if image_digest:
|
||||
fields["image_digest"] = image_digest
|
||||
if source_run_id:
|
||||
fields["source_run_id"] = source_run_id
|
||||
if not update_manifest(manifest_path, package, fields):
|
||||
raise click.ClickException(_("Failed to update {file}", file=manifest_path))
|
||||
elif not changed_file or not update_pinned_version(changed_file, package, old_version, new_version):
|
||||
raise click.ClickException(_("Failed to update {file}", file=changed_file))
|
||||
|
||||
assert changed_file is not None # nosec B101 — narrowed by the early exit above
|
||||
subprocess.run(["git", "add", changed_file], check=True) # nosec B603 B607
|
||||
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
|
||||
subprocess.run(["git", "commit", "-m", commit_msg], check=True) # nosec B603 B607
|
||||
|
||||
@@ -150,15 +150,20 @@ def sort_versions_by_date(
|
||||
def select_for_deletion(
|
||||
versions: list[dict[str, Any]],
|
||||
keep: int,
|
||||
protect: frozenset[str] = frozenset(),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Select versions to delete, keeping the most recent ``keep`` versions.
|
||||
|
||||
Versions named ``latest`` are always preserved.
|
||||
Versions named ``latest`` and any version listed in ``protect`` are
|
||||
always preserved. Implements REQ-2 (DEVX-165): tags/digests that a
|
||||
release manifest still pins must survive registry cleanup.
|
||||
"""
|
||||
sorted_versions = sort_versions_by_date(versions)
|
||||
to_delete = sorted_versions[keep:]
|
||||
# Always preserve 'latest' tag
|
||||
to_delete = [v for v in to_delete if v.get("version") != "latest"]
|
||||
# Preserve explicitly protected versions (e.g., pinned by a release manifest)
|
||||
to_delete = [v for v in to_delete if v.get("version") not in protect]
|
||||
return to_delete
|
||||
|
||||
|
||||
@@ -182,6 +187,12 @@ def select_for_deletion(
|
||||
show_default=True,
|
||||
help="Number of recent versions to keep (excluding 'latest').",
|
||||
)
|
||||
@click.option(
|
||||
"--protect",
|
||||
"protect",
|
||||
multiple=True,
|
||||
help="Version/tag to never delete (e.g., pinned by a release manifest). Can be repeated.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
@@ -197,6 +208,7 @@ def main(
|
||||
owner: str | None,
|
||||
names: tuple[str, ...],
|
||||
keep: int,
|
||||
protect: tuple[str, ...],
|
||||
dry_run: bool,
|
||||
api_url: str | None,
|
||||
) -> None:
|
||||
@@ -238,7 +250,7 @@ def main(
|
||||
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
|
||||
)
|
||||
|
||||
to_delete = select_for_deletion(versions, keep)
|
||||
to_delete = select_for_deletion(versions, keep, frozenset(protect))
|
||||
kept_count = len(versions) - len(to_delete)
|
||||
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
|
||||
|
||||
|
||||
@@ -1343,6 +1343,22 @@
|
||||
"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 to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.": {
|
||||
"bg": "Контейнер за проверка преди отваряне на PR (owner/name). Разрешава OCI дайджеста на тага, съвпадащ с --new-version, и го записва в манифеста.",
|
||||
"de": "Container, der vor dem Öffnen des PR verifiziert wird (owner/name). Ermittelt den OCI-Digest des zu --new-version passenden Tags und trägt ihn ins Manifest ein.",
|
||||
"en": "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.",
|
||||
"pl": "Kontener do zweryfikowania przed otwarciem PR (owner/name). Rozwiązuje skrót OCI tagu pasującego do --new-version i zapisuje go w manifeście.",
|
||||
"ru": "Контейнер для проверки перед открытием PR (owner/name). Разрешает OCI-дайджест тега, соответствующего --new-version, и записывает его в манифест.",
|
||||
"zh": "在打开 PR 前要验证的容器 (owner/name)。解析与 --new-version 匹配的标签的 OCI 摘要并记录到清单中。"
|
||||
},
|
||||
"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.",
|
||||
@@ -1823,6 +1839,14 @@
|
||||
"ru": "Генерация значков в {out}...",
|
||||
"zh": "正在 {out} 中生成徽章..."
|
||||
},
|
||||
"Git ref of the producer release (default: v<new-version>), recorded in the manifest.": {
|
||||
"bg": "Git референция на продуцентското издание (по подразбиране: v<new-version>), записана в манифеста.",
|
||||
"de": "Git-Ref des Producer-Releases (Standard: v<new-version>), im Manifest verzeichnet.",
|
||||
"en": "Git ref of the producer release (default: v<new-version>), recorded in the manifest.",
|
||||
"pl": "Referencja git wydania producenta (domyślnie: v<new-version>), zapisana w manifeście.",
|
||||
"ru": "Git-ссылка релиза производителя (по умолчанию: v<new-version>), записанная в манифест.",
|
||||
"zh": "生产者发布的 git 引用(默认:v<new-version>),记录在清单中。"
|
||||
},
|
||||
"Git tag or ref that was deployed": {
|
||||
"bg": "Git таг или референция, която беше разгърната",
|
||||
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
|
||||
@@ -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}",
|
||||
@@ -2071,6 +2103,14 @@
|
||||
"ru": "Цикл с {count} итерациями в тесте '{test}' — используйте property-based тестирование (hypothesis) или уменьшите до <= {max} итераций.",
|
||||
"zh": "测试 '{test}' 中有 {count} 次迭代的循环 — 考虑使用基于属性的测试 (hypothesis) 或减少到 <= {max} 次迭代。"
|
||||
},
|
||||
"Manifest file not found: {file}": {
|
||||
"bg": "Файлът на манифеста не е намерен: {file}",
|
||||
"de": "Manifest-Datei nicht gefunden: {file}",
|
||||
"en": "Manifest file not found: {file}",
|
||||
"pl": "Nie znaleziono pliku manifestu: {file}",
|
||||
"ru": "Файл манифеста не найден: {file}",
|
||||
"zh": "未找到清单文件:{file}"
|
||||
},
|
||||
"Manifest file not found: {path}": {
|
||||
"bg": "Файлът на манифеста не е намерен: {path}",
|
||||
"de": "Manifestdatei nicht gefunden: {path}",
|
||||
@@ -2079,6 +2119,22 @@
|
||||
"ru": "Файл манифеста не найден: {path}",
|
||||
"zh": "未找到清单文件:{path}"
|
||||
},
|
||||
"Manifest file {file} has no object section {section}": {
|
||||
"bg": "Файлът на манифеста {file} няма обектна секция {section}",
|
||||
"de": "Manifest-Datei {file} hat keinen Objektabschnitt {section}",
|
||||
"en": "Manifest file {file} has no object section {section}",
|
||||
"pl": "Plik manifestu {file} nie ma sekcji obiektu {section}",
|
||||
"ru": "Файл манифеста {file} не содержит объектного раздела {section}",
|
||||
"zh": "清单文件 {file} 没有对象节 {section}"
|
||||
},
|
||||
"Manifest file {file} is not valid JSON: {error}": {
|
||||
"bg": "Файлът на манифеста {file} не е валиден JSON: {error}",
|
||||
"de": "Manifest-Datei {file} ist kein gültiges JSON: {error}",
|
||||
"en": "Manifest file {file} is not valid JSON: {error}",
|
||||
"pl": "Plik manifestu {file} nie jest prawidłowym JSON: {error}",
|
||||
"ru": "Файл манифеста {file} не является допустимым JSON: {error}",
|
||||
"zh": "清单文件 {file} 不是有效的 JSON:{error}"
|
||||
},
|
||||
"Manifest must be a JSON list": {
|
||||
"bg": "Манифестът трябва да е JSON списък",
|
||||
"de": "Manifest muss eine JSON-Liste sein",
|
||||
@@ -2671,6 +2727,14 @@
|
||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||
},
|
||||
"Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.": {
|
||||
"bg": "Път към release-manifest JSON в целевото хранилище. Когато е зададен, PR актуализира секцията на манифеста, наречена след --package, вместо regex bump на версията.",
|
||||
"de": "Pfad zu einer Release-Manifest-JSON im Ziel-Repo. Wenn gesetzt, aktualisiert der PR den nach --package benannten Manifest-Abschnitt statt eines Regex-Versionsbumps.",
|
||||
"en": "Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.",
|
||||
"pl": "Ścieżka do pliku JSON manifestu wydania w docelowym repozytorium. Po ustawieniu PR aktualizuje sekcję manifestu nazwaną po --package zamiast podbicia wersji regexem.",
|
||||
"ru": "Путь к JSON манифеста релиза в целевом репозитории. Если задан, PR обновляет раздел манифеста, названный по --package, вместо повышения версии по regex.",
|
||||
"zh": "目标仓库中发布清单 JSON 的路径。设置后,PR 更新以 --package 命名的清单节,而不是正则版本提升。"
|
||||
},
|
||||
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
|
||||
"bg": "Път до pyproject.toml (по подразбиране: pyproject.toml в CWD).",
|
||||
"de": "Pfad zu pyproject.toml (Standard: pyproject.toml im CWD).",
|
||||
@@ -2887,6 +2951,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 +3807,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.",
|
||||
|
||||
@@ -485,6 +485,38 @@ class TestSelectForDeletion:
|
||||
to_delete = select_for_deletion(versions, keep=5)
|
||||
assert len(to_delete) == 0
|
||||
|
||||
def test_protect_exempts_version(self) -> None:
|
||||
"""REQ-2: a manifest-pinned version survives cleanup beyond --keep."""
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=2, protect=frozenset({"0.1.0"}))
|
||||
deleted = {v["version"] for v in to_delete}
|
||||
assert "0.1.0" not in deleted
|
||||
assert deleted == {"0.2.0"}
|
||||
|
||||
def test_protect_multiple_versions(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=1, protect=frozenset({"0.1.0", "0.2.0"}))
|
||||
assert {v["version"] for v in to_delete} == {"0.3.0"}
|
||||
|
||||
def test_protect_default_empty_behaves_as_before(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=2)
|
||||
assert {v["version"] for v in to_delete} == {"0.1.0"}
|
||||
|
||||
|
||||
class TestCleanImagesAPI:
|
||||
"""Tests for the clean_images module's API functions."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.create_dependency_pr import (
|
||||
@@ -180,3 +181,243 @@ class TestCreateVikunjaTask:
|
||||
mock_client.create_task.return_value = {"identifier": "OBL-INFRA-999"}
|
||||
result = create_vikunja_task("Test", "desc")
|
||||
assert result == "OBL-INFRA-999"
|
||||
|
||||
|
||||
class TestResolveContainerDigest:
|
||||
"""REQ-1: pre-PR artifact verification via the packages API."""
|
||||
|
||||
def test_returns_digest_from_manifest_blob(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):
|
||||
digest = resolve_container_digest(
|
||||
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
|
||||
)
|
||||
assert digest == "sha256:deadbeef"
|
||||
|
||||
def test_raises_when_version_missing(self) -> None:
|
||||
import requests
|
||||
|
||||
from devx.ci.create_dependency_pr import resolve_container_digest
|
||||
|
||||
mock_resp = MagicMock()
|
||||
http_err = requests.HTTPError("404")
|
||||
http_err.response = MagicMock(status_code=404)
|
||||
mock_resp.raise_for_status.side_effect = http_err
|
||||
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
|
||||
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:
|
||||
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"):
|
||||
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
|
||||
|
||||
def test_raises_on_connection_error(self) -> None:
|
||||
import requests
|
||||
|
||||
from devx.ci.create_dependency_pr import resolve_container_digest
|
||||
|
||||
with patch(
|
||||
"devx.ci.create_dependency_pr.requests.get",
|
||||
side_effect=requests.ConnectionError("refused"),
|
||||
):
|
||||
with pytest.raises(click.ClickException, match="Registry lookup failed"):
|
||||
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
|
||||
|
||||
|
||||
class TestManifestHelpers:
|
||||
"""REQ-1: manifest read/update helpers."""
|
||||
|
||||
def test_read_version(self, tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
from devx.ci.create_dependency_pr import read_manifest_version
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}}))
|
||||
assert read_manifest_version(str(p), "sso_bridge") == "0.9.0"
|
||||
|
||||
def test_read_version_missing_file(self, tmp_path: Path) -> None:
|
||||
from devx.ci.create_dependency_pr import read_manifest_version
|
||||
|
||||
assert read_manifest_version(str(tmp_path / "nope.json"), "sso_bridge") is None
|
||||
|
||||
def test_read_version_bad_json(self, tmp_path: Path) -> None:
|
||||
from devx.ci.create_dependency_pr import read_manifest_version
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text("not json{")
|
||||
assert read_manifest_version(str(p), "sso_bridge") is None
|
||||
|
||||
def test_read_version_missing_section(self, tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
from devx.ci.create_dependency_pr import read_manifest_version
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text(json.dumps({"schema_version": 1, "other": "x"}))
|
||||
assert read_manifest_version(str(p), "sso_bridge") is None
|
||||
|
||||
def test_update_manifest_fields(self, tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
from devx.ci.create_dependency_pr import update_manifest
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}}))
|
||||
changed = update_manifest(
|
||||
str(p),
|
||||
"sso_bridge",
|
||||
{"version": "0.9.1", "git_ref": "v0.9.1", "image_digest": "sha256:x"},
|
||||
)
|
||||
assert changed is True
|
||||
data = json.loads(p.read_text())
|
||||
assert data["sso_bridge"]["version"] == "0.9.1"
|
||||
assert data["sso_bridge"]["git_ref"] == "v0.9.1"
|
||||
assert data["sso_bridge"]["image_digest"] == "sha256:x"
|
||||
|
||||
def test_update_manifest_no_change(self, tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
from devx.ci.create_dependency_pr import update_manifest
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text(json.dumps({"sso_bridge": {"version": "0.9.1"}}))
|
||||
assert update_manifest(str(p), "sso_bridge", {"version": "0.9.1"}) is False
|
||||
|
||||
def test_update_manifest_missing_file(self, tmp_path: Path) -> None:
|
||||
from devx.ci.create_dependency_pr import update_manifest
|
||||
|
||||
with pytest.raises(click.ClickException, match="not found"):
|
||||
update_manifest(str(tmp_path / "nope.json"), "sso_bridge", {"version": "1"})
|
||||
|
||||
def test_update_manifest_bad_json(self, tmp_path: Path) -> None:
|
||||
from devx.ci.create_dependency_pr import update_manifest
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text("broken{")
|
||||
with pytest.raises(click.ClickException, match="not valid JSON"):
|
||||
update_manifest(str(p), "sso_bridge", {"version": "1"})
|
||||
|
||||
def test_update_manifest_missing_section(self, tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
from devx.ci.create_dependency_pr import update_manifest
|
||||
|
||||
p = tmp_path / "m.json"
|
||||
p.write_text(json.dumps({"schema_version": 1}))
|
||||
with pytest.raises(click.ClickException, match="no object section"):
|
||||
update_manifest(str(p), "sso_bridge", {"version": "1"})
|
||||
|
||||
|
||||
class TestCliManifestMode:
|
||||
@patch("devx.ci.create_dependency_pr.read_manifest_version")
|
||||
@patch("devx.ci.create_dependency_pr.get_ci_token")
|
||||
def test_manifest_same_version_no_pr(self, mock_token: MagicMock, mock_read: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
mock_read.return_value = "0.9.1"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--package",
|
||||
"sso_bridge",
|
||||
"--new-version",
|
||||
"0.9.1",
|
||||
"--source-repo",
|
||||
"oblachno/sso-bridge",
|
||||
"--manifest",
|
||||
"deploy/sso-bridge-release.json",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "no pr needed" in result.output.lower()
|
||||
|
||||
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
|
||||
@patch("devx.ci.create_dependency_pr.read_manifest_version")
|
||||
@patch("devx.ci.create_dependency_pr.get_ci_token")
|
||||
def test_verify_container_runs_before_lookup(
|
||||
self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock
|
||||
) -> None:
|
||||
"""Verification failure aborts before the version lookup/PR steps."""
|
||||
mock_token.return_value = "fake-token"
|
||||
mock_digest.side_effect = click.ClickException("unpublished artifact")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--package",
|
||||
"sso_bridge",
|
||||
"--new-version",
|
||||
"0.9.1",
|
||||
"--source-repo",
|
||||
"oblachno/sso-bridge",
|
||||
"--manifest",
|
||||
"deploy/m.json",
|
||||
"--verify-container",
|
||||
"oblachno/sso-bridge",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
mock_read.assert_not_called()
|
||||
|
||||
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
|
||||
@patch("devx.ci.create_dependency_pr.read_manifest_version")
|
||||
@patch("devx.ci.create_dependency_pr.get_ci_token")
|
||||
def test_verify_container_resolves_digest(
|
||||
self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock
|
||||
) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
mock_read.return_value = "0.9.1"
|
||||
mock_digest.return_value = "sha256:abc"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--package",
|
||||
"sso_bridge",
|
||||
"--new-version",
|
||||
"0.9.1",
|
||||
"--source-repo",
|
||||
"oblachno/sso-bridge",
|
||||
"--manifest",
|
||||
"deploy/m.json",
|
||||
"--verify-container",
|
||||
"oblachno/sso-bridge",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_digest.assert_called_once()
|
||||
args = mock_digest.call_args[0]
|
||||
assert args[1:4] == ("oblachno", "sso-bridge", "0.9.1")
|
||||
|
||||
@patch("devx.ci.create_dependency_pr.get_ci_token")
|
||||
def test_verify_container_invalid_format(self, mock_token: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--package",
|
||||
"sso_bridge",
|
||||
"--new-version",
|
||||
"0.9.1",
|
||||
"--source-repo",
|
||||
"oblachno/sso-bridge",
|
||||
"--verify-container",
|
||||
"no-slash",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
Reference in New Issue
Block a user