diff --git a/docs/specs/DEVX-165.md b/docs/specs/DEVX-165.md index e77d9c6..e05aba9 100644 --- a/docs/specs/DEVX-165.md +++ b/docs/specs/DEVX-165.md @@ -17,11 +17,19 @@ in a JSON manifest. Two gaps in devx block that: ## Approach REQ-1: `create_dependency_pr` gains `--manifest ` + -`--verify-container `: 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. +`--verify-container ` + `--container-tag` + +`--source-ref`: before opening the PR it resolves the container tag's +OCI digest via the packages API (`manifest.json` blob sha256), then +updates manifest fields `{version, git_ref, image_tag, image_digest, +source_run_id, updated_at}` in the PR branch instead of a regex bump. +`--container-tag` decouples the image tag from the release version +(sso-bridge images tag `__init__.py.__version__`, not the git tag). + +REQ-1b: `create_dependency_pr` clones the *target* repo into a tempdir +and performs all file lookups and git operations inside it. Previously +it operated on CWD — the producer repo's own checkout — so file updates +silently targeted the wrong repo and the whole dep-PR path no-oped +behind `|| echo warning`. REQ-2: `clean_images` gains `--protect` (repeatable): named versions are never deleted regardless of `--keep` trimming. diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index 1953482..366eacf 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -21,7 +21,7 @@ from __future__ import annotations import re import subprocess # nosec B404 -from datetime import UTC +from datetime import UTC, datetime from pathlib import Path import click @@ -282,15 +282,29 @@ def cli( ) ) + # Clone the target repo — this tool runs from the *producer* repo's CI, + # so every file lookup and git operation must happen in a clone of the + # target repo, not the producer checkout in CWD. + import tempfile + + 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}" + subprocess.run( # nosec B603 B607 + ["git", "-c", auth_cfg, "clone", "--depth", "50", clone_url, str(workdir)], + check=True, + capture_output=True, + ) + # Find current pinned version old_version = None changed_file = None if manifest_path: - old_version = read_manifest_version(manifest_path, package) + old_version = read_manifest_version(str(workdir / 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) + old_version = find_pinned_version(package, str(workdir / f)) if old_version: changed_file = f break @@ -342,13 +356,11 @@ def cli( else: raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None - # Clone, update file, commit, push - 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 + # Check out the API-created branch inside the target clone. + subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True, cwd=workdir) # nosec B603 B607 + subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True, cwd=workdir) # nosec B603 B607 if manifest_path: - from datetime import datetime - fields = { "version": new_version, "git_ref": source_ref or f"v{new_version}", @@ -359,16 +371,20 @@ def cli( fields["image_digest"] = image_digest if source_run_id: fields["source_run_id"] = source_run_id - if not update_manifest(manifest_path, package, fields): + if not update_manifest(str(workdir / 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): + elif 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)) 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 + 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) # nosec B603 B607 - subprocess.run(["git", "push", "origin", branch_name], check=True) # nosec B603 B607 + subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607 + subprocess.run( # nosec B603 B607 + ["git", "-c", auth_cfg, "push", "origin", branch_name], + check=True, + cwd=workdir, + ) # Create Vikunja task for tracking task_title = f"Bump {package} to {new_version}" diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 46f2745..2099b4a 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -16,6 +16,13 @@ from devx.ci.create_dependency_pr import ( ) +@pytest.fixture(autouse=True) +def _mock_subprocess() -> MagicMock: + """Mock subprocess so CLI tests never run a real git clone.""" + with patch("devx.ci.create_dependency_pr.subprocess.run") as m: + yield m + + class TestFindPinnedVersion: def test_finds_pip_git_pin(self, tmp_path: Path) -> None: content = "grm @ git+https://git.example.com/repo.git@v0.5.1"