diff --git a/.github/workflows/bakery-build-pr.yml b/.github/workflows/bakery-build-pr.yml index 16f249bc..839d7450 100644 --- a/.github/workflows/bakery-build-pr.yml +++ b/.github/workflows/bakery-build-pr.yml @@ -90,6 +90,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Install uses: "posit-dev/images-shared/setup-bakery@main" @@ -103,8 +105,13 @@ jobs: DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} + BASE_REF: ${{ github.event.pull_request.base.sha }} run: | - FULL_MATRIX=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" --context "$BAKERY_CONTEXT" | jq --compact-output .) + BASE_FLAG=() + if [ -n "$BASE_REF" ]; then + BASE_FLAG=(--base-ref "$BASE_REF") + fi + FULL_MATRIX=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" "${BASE_FLAG[@]}" --context "$BAKERY_CONTEXT" | jq --compact-output .) if [ "$IS_FORK" = "true" ]; then # Skip arm64 for fork PRs — paid runners may not be available FULL_MATRIX=$(echo "$FULL_MATRIX" | jq --compact-output '[.[] | select(.platform != "linux/arm64")]') @@ -117,8 +124,13 @@ jobs: DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} + BASE_REF: ${{ github.event.pull_request.base.sha }} run: | - result=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" --exclude platform --context "$BAKERY_CONTEXT") + BASE_FLAG=() + if [ -n "$BASE_REF" ]; then + BASE_FLAG=(--base-ref "$BASE_REF") + fi + result=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" "${BASE_FLAG[@]}" --exclude platform --context "$BAKERY_CONTEXT") echo "versions_matrix=$(echo "$result" | jq --compact-output .)" >> "$GITHUB_OUTPUT" build-test: @@ -126,6 +138,9 @@ jobs: needs: - detect - matrix + # GitHub Actions fails (not skips) a matrix job when the matrix evaluates to []. + # Guard here so an empty change-aware matrix produces 'skipped' instead of 'failure'. + if: ${{ needs.matrix.outputs.platform-matrix != '[]' }} timeout-minutes: 120 permissions: contents: read @@ -188,13 +203,18 @@ jobs: IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} IMAGE_PLATFORM: ${{ matrix.img.platform }} - DEV_VERSIONS: ${{ inputs.dev-versions }} + IMG_DEV: ${{ matrix.img.dev }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} REGISTRY_OWNER: ${{ github.repository_owner }} CACHE: ${{ inputs.cache }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + if [ "$IMG_DEV" = "true" ]; then + DEV_VERSIONS=only + else + DEV_VERSIONS=exclude + fi CACHE_FLAGS=() if [ "$IS_FORK" != "true" ] && [ "$CACHE" = "true" ]; then CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}") @@ -215,11 +235,16 @@ jobs: IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} IMAGE_PLATFORM: ${{ matrix.img.platform }} - DEV_VERSIONS: ${{ inputs.dev-versions }} + IMG_DEV: ${{ matrix.img.dev }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + if [ "$IMG_DEV" = "true" ]; then + DEV_VERSIONS=only + else + DEV_VERSIONS=exclude + fi GOSS_PATH=${GITHUB_WORKSPACE}/tools/goss \ DGOSS_PATH=${GITHUB_WORKSPACE}/tools/dgoss \ bakery dgoss run \ @@ -229,3 +254,22 @@ jobs: --dev-versions "$DEV_VERSIONS" \ --matrix-versions "$MATRIX_VERSIONS" \ --context "$BAKERY_CONTEXT" + + build-test-result: + name: Build/Test result + needs: build-test + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Report + env: + RESULT: ${{ needs.build-test.result }} + run: | + # 'skipped' (empty matrix) and 'success' both pass; only real failures block. + if [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then + echo "build-test result: $RESULT" + exit 1 + fi + echo "build-test result: $RESULT (ok)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28357e6d..659fc311 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,6 +99,36 @@ Do not backport cosmetic changes, new feature additions, or non-security depende - **Version format mismatch.** Product repos dispatch with raw git-describe versions (e.g., `v2026.03.0-473-g072bb6fd1f`). Bakery normalizes these to semver-with-metadata (e.g., `2026.03.0-dev+473-g072bb6fd1f`). If `bakery ci matrix` produces an empty matrix after a dispatch, the formats did not align. The shared workflows strip a leading `v` automatically. Check the rest of the version string against bakery's normalization. +## Change-aware PR builds + +`bakery ci matrix` supports `--base-ref ` and `--changed-files-from ` to emit +only the matrix entries affected by a PR's changed files. This keeps PR CI fast: a template +change builds only the images whose templates changed; a version-directory change builds only +that version. + +**This filtering applies to PR builds only.** Push-to-main, scheduled, and release builds +call `bakery ci matrix` without `--base-ref`, so they always build the full matrix. + +### Classification rules + +After ignoring Markdown (`*.md`) paths, changed files are classified as follows: + +| Changed path | What builds | +|---|---| +| `bakery.yaml` / `bakery.yml` or `.github/workflows/**` | Full matrix (fail-safe) | +| `/template/**` | That image's dev versions (if any are declared) | +| `//**` | That release version only | +| An image-root file or unrecognized subdir under an image | That image's release versions (fail-safe) | +| Matrix image (e.g. `connect-content`) | Latest matrix slice + dev versions if declared | +| Anything else not attributable and not ignored | Full matrix (fail-safe) | + +### Branch protection + +Because per-image build jobs may not run on docs-only PRs, branch protection rules should +require the always-green **Build/Test result** gate job rather than individual build jobs. +The gate job is defined in `bakery-build-pr.yml` and always runs, even when no images need +to be built. + ## Diagnose a build failure **1. Find the failing run:** diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index e8183fa5..f17cbc06 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -2,6 +2,8 @@ import json import logging import re +import subprocess +import sys import python_on_whales from enum import Enum from pathlib import Path @@ -11,8 +13,10 @@ from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec from posit_bakery.config import BakeryConfig +from posit_bakery.config.changeset import classify_changes, ImageChangeSet, MatrixSelection from posit_bakery.config.config import BakerySettings, BakeryConfigFilter, version_matches from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum +from posit_bakery.config.image.version import ImageVersion from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.log import stderr_console, stdout_console from posit_bakery.registry_management.dockerhub.readme import push_readmes @@ -34,6 +38,54 @@ class BakeryCIMatrixFieldEnum(str, Enum): PLATFORM = "platform" +def _resolve_changed_files(base_ref: str | None, changed_files_from: str | None, rebase_root: Path) -> list[str] | None: + """Return the changed-file list for change-aware filtering, or None to disable it. + + ``--changed-files-from`` takes precedence over ``--base-ref``. Git paths are + relative to the repo root; they are rebased onto ``rebase_root`` and paths + outside the root are dropped. + """ + if changed_files_from is not None: + if changed_files_from == "-": + raw = sys.stdin.read() + else: + raw = Path(changed_files_from).read_text() + return [line.strip() for line in raw.splitlines() if line.strip()] + + if base_ref is None: + return None + + # Local import: git diff is only needed on this rarely-used code path. + from posit_bakery.config.changeset import git_changed_files + + toplevel = subprocess.run( + ["git", "-C", str(rebase_root), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + repo_root = Path(toplevel) + rebased: list[str] = [] + for rel in git_changed_files(repo_root, base_ref): + abs_path = repo_root / rel + try: + rebased.append((abs_path.relative_to(rebase_root)).as_posix()) + except ValueError: + # Changed file lives outside this bakery context (monorepo) -> not our concern. + continue + return rebased + + +def _version_selected(ver: ImageVersion, cs: ImageChangeSet) -> bool: + """Whether a candidate version is wanted by a change set.""" + if getattr(ver, "isDevelopmentVersion", False): + return cs.include_dev + if getattr(ver, "isMatrixVersion", False): + return cs.include_matrix_latest and bool(getattr(ver, "latest", False)) + # Plain release version. + return cs.include_all_release or ver.name in cs.versions + + @app.command() @with_verbosity_flags def matrix( @@ -101,6 +153,27 @@ def matrix( callback=parse_dev_spec, ), ] = None, + base_ref: Annotated[ + Optional[str], + typer.Option( + "--base-ref", + envvar="BAKERY_BASE_REF", + show_default=False, + help="Git ref to diff against (merge-base) to build only changed images/versions. " + "When unset, the full matrix is emitted.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + changed_files_from: Annotated[ + Optional[str], + typer.Option( + "--changed-files-from", + show_default=False, + help="Read changed file paths (one per line; '-' for stdin) instead of running git diff. " + "Overrides --base-ref.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, ) -> None: """Generates a JSON matrix of image versions for CI workflows to consume @@ -135,27 +208,66 @@ def matrix( c = BakeryConfig.from_context(context=context, settings=settings) images = [i for i in c.model.images if image_name is None or re.search(image_name, i.name) is not None] + selection: MatrixSelection | None = None + changed = _resolve_changed_files(base_ref, changed_files_from, c.base_path) + if changed is not None: + selection = classify_changes(c, changed) + if selection.full: + # Fail-safe / repo-wide change: behave exactly as a full matrix. + selection = None + else: + log.info( + "Change-aware matrix: %s", + {name: vars(cs) for name, cs in selection.images.items()} or "no affected images", + ) + data = [] for img in images: - entry = {"image": img.name} - versions = img.versions - if img.matrix is None and matrix_versions == MatrixVersionInclusionEnum.ONLY: + if selection is not None and img.name not in selection.images: continue - elif img.matrix is not None: - if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE: - if dev_versions == DevVersionInclusionEnum.ONLY: - pass # img.versions has dev versions; matrix prod versions all fail the dev filter - elif dev_versions == DevVersionInclusionEnum.INCLUDE: - dev_versions_loaded = [v for v in img.versions if v.isDevelopmentVersion] - versions = img.matrix.to_image_versions() + dev_versions_loaded - else: - versions = img.matrix.to_image_versions() - # If EXCLUDE: fall through using img.versions (devVersions are appended - # there by load_dev_versions). The dev_versions filter below handles the rest. + cs = selection.images[img.name] if selection is not None else None + + entry = {"image": img.name} + + if cs is not None: + # Change-aware: build candidates purely from this image's change set. + # _version_selected() gates each candidate by type (release / dev / + # matrix-latest), so the list only needs to *contain* each wanted + # version exactly once. + if cs.include_dev and dev_versions == DevVersionInclusionEnum.EXCLUDE: + # Dev versions are not loaded under the EXCLUDE baseline; load them + # now. (Under INCLUDE/ONLY, BakeryConfig already loaded them at + # config time, so loading again would duplicate entries.) + img.load_dev_versions() + versions = list(img.versions) + if img.matrix is not None: + versions = versions + img.matrix.to_image_versions() + else: + # Full-matrix path (no change-awareness). Preserves the matrix+dev + # filtering fix (commit 92c72833 / generate_image_targets): when matrix + # versions are included, fold the already-loaded dev versions into the + # matrix product per the dev_versions setting so they survive the + # matches_dev_filter check below. + versions = list(img.versions) + if img.matrix is None and matrix_versions == MatrixVersionInclusionEnum.ONLY: + continue + elif img.matrix is not None: + if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE: + if dev_versions == DevVersionInclusionEnum.ONLY: + pass # img.versions has dev versions; matrix prod versions all fail the dev filter + elif dev_versions == DevVersionInclusionEnum.INCLUDE: + dev_versions_loaded = [v for v in img.versions if v.isDevelopmentVersion] + versions = img.matrix.to_image_versions() + dev_versions_loaded + else: + versions = img.matrix.to_image_versions() + for ver in versions: - included, _ = ver.matches_dev_filter(dev_versions, dev_channel) - if not included: + if cs is not None and not _version_selected(ver, cs): continue + if cs is None: + included, _ = ver.matches_dev_filter(dev_versions, dev_channel) + if not included: + continue if image_version is not None and not version_matches(ver.name, image_version): continue diff --git a/posit-bakery/posit_bakery/config/changeset.py b/posit-bakery/posit_bakery/config/changeset.py new file mode 100644 index 00000000..3049e919 --- /dev/null +++ b/posit-bakery/posit_bakery/config/changeset.py @@ -0,0 +1,144 @@ +"""Map a PR's changed files to the image/version build matrix it affects. + +The classifier (:func:`classify_changes`) is pure so it can be unit-tested with +synthetic file lists; git I/O lives in :func:`git_changed_files`. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from posit_bakery.config import BakeryConfig + from posit_bakery.config.image.image import Image + + +def git_changed_files(repo_root: Path, base_ref: str) -> list[str]: + """Return repo-root-relative POSIX paths changed between the merge-base of + ``base_ref`` and ``HEAD``. + + Uses three-dot/``--merge-base`` semantics so commits landed on the base + branch after this branch diverged are not counted. + """ + result = subprocess.run( + ["git", "-C", str(repo_root), "diff", "--name-only", "--merge-base", base_ref, "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +# Paths (relative to the bakery context root) that never trigger a build. +_IGNORE_EXACT = {".gitignore", ".pre-commit-config.yaml"} +_IGNORE_PREFIXES = (".idea/", ".claude/", ".github/") +# Paths that conservatively trigger a full build (fail safe). +# NOTE: the _FULL check must run before the _IGNORE check below, because +# ".github/workflows/" is a strict sub-prefix of the ignored ".github/". +_FULL_EXACT = {"bakery.yaml", "bakery.yml"} +_FULL_PREFIXES = (".github/workflows/",) + + +@dataclass +class ImageChangeSet: + """What to build for a single image, derived from a PR's changed files.""" + + versions: set[str] = field(default_factory=set) + include_all_release: bool = False + include_dev: bool = False + include_matrix_latest: bool = False + + @property + def empty(self) -> bool: + return not (self.versions or self.include_all_release or self.include_dev or self.include_matrix_latest) + + +@dataclass +class MatrixSelection: + """The full result of classifying a changeset. + + ``full`` means "build everything per the caller's normal flags" (the + fail-safe / unrecognized-change fallback). Otherwise ``images`` maps image + name -> the subset to build. + """ + + full: bool = False + images: dict[str, ImageChangeSet] = field(default_factory=dict) + + def for_image(self, name: str) -> ImageChangeSet: + return self.images.setdefault(name, ImageChangeSet()) + + +def _attribute(cs: ImageChangeSet, image: "Image", remainder: str) -> None: + """Update an image's change set for a path under that image's directory. + + ``remainder`` is the path relative to the image directory ("" for the + directory itself). + """ + has_dev = bool(image.devVersions) + + if image.matrix is not None: + # Matrix images: any attributed change exercises the latest slice, plus + # dev versions when declared. + cs.include_matrix_latest = True + cs.include_dev = cs.include_dev or has_dev + return + + if remainder == "template" or remainder.startswith("template/"): + cs.include_dev = cs.include_dev or has_dev + return + + first_segment = remainder.split("/", 1)[0] if remainder else "" + matched_version = False + for version in image.versions: + if version.subpath == first_segment: + cs.versions.add(version.name) + matched_version = True + if matched_version: + return + + # Image-root file or unrecognized subdirectory: fail safe to all release versions. + cs.include_all_release = True + + +def classify_changes(config: "BakeryConfig", changed_files: Iterable[str]) -> MatrixSelection: + """Classify changed file paths (relative to the bakery context) into a build selection.""" + selection = MatrixSelection() + base = config.base_path + + # (relative-posix-image-dir, Image) pairs, computed once. + image_dirs: list[tuple[str, "Image"]] = [] + for image in config.model.images: + rel = PurePosixPath(image.path.relative_to(base)).as_posix() + image_dirs.append((rel, image)) + + for raw in changed_files: + path = PurePosixPath(raw).as_posix() + + if path.lower().endswith(".md"): + continue + if path in _FULL_EXACT or path.startswith(_FULL_PREFIXES): + selection.full = True + continue + if path in _IGNORE_EXACT or path.startswith(_IGNORE_PREFIXES): + continue + + matched = False + for rel, image in image_dirs: + prefix = rel + "/" + if path == rel or path.startswith(prefix): + remainder = path[len(prefix) :] if path.startswith(prefix) else "" + _attribute(selection.for_image(image.name), image, remainder) + matched = True + break + if matched: + continue + + # Not Markdown, not ignored, not attributable -> fail safe. + selection.full = True + + selection.images = {name: cs for name, cs in selection.images.items() if not cs.empty} + return selection diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 356c3cc3..4fe06e3e 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -1,11 +1,13 @@ import json import re +from pathlib import Path from unittest.mock import MagicMock import pytest from pytest_bdd import scenarios, then, parsers, given from posit_bakery.config.config import version_matches +from posit_bakery.config.image.version import ImageVersion from posit_bakery.plugins.protocol import ToolCallResult @@ -23,6 +25,14 @@ def check_matrix_output(bakery_command, ci_testdata, testdata_file): assert actual_matrix == expected_matrix +@given(parsers.parse("with changed files in {filename}:")) +def write_changed_files_to_context(bakery_command, filename, datatable): + changed_file_path = bakery_command.context / filename + lines = [row[0] for row in datatable] + changed_file_path.write_text("\n".join(lines) + "\n") + bakery_command.add_args(["--changed-files-from", str(changed_file_path)]) + + @given(parsers.parse("with testdata {testdata_path} copied to context")) def copy_ci_testdata_to_context(bakery_command, ci_testdata, testdata_path): testdata_source = ci_testdata / testdata_path @@ -148,6 +158,70 @@ def check_log_metadata_targets(bakery_command, datatable, ci_patched_merge_metho assert expected in calls +class TestDevVersionDedup: + """Regression tests for Bug 1: dev versions must not appear twice in change-aware matrix output.""" + + def test_no_duplicate_dev_versions_with_dev_versions_exclude(self, mocker, resource_path, tmp_path): + """When --dev-versions exclude (default) and a template change triggers include_dev, + the matrix must contain each dev version exactly once. + + load_dev_versions is monkeypatched to return one deterministic fake dev ImageVersion + so the test remains hermetic (no external HTTP calls). + """ + # Stable fake dev version with a deterministic name. + fake_dev = ImageVersion( + name="2026.01.0-dev+1-gABC", + isDevelopmentVersion=True, + subpath="dev", + path=tmp_path, + ) + + def _patched_load_dev_versions(self_image): + """Append exactly one fake dev version, mimicking the real unconditional append.""" + self_image.versions.append(fake_dev) + + mocker.patch( + "posit_bakery.config.image.image.Image.load_dev_versions", + _patched_load_dev_versions, + ) + + # A template change for `app` sets include_dev=True on the change set. + changed_files_path = tmp_path / "changed.txt" + changed_files_path.write_text("app/template/Containerfile.ubuntu2204.jinja2\n") + + from typer.testing import CliRunner + from posit_bakery.cli.main import app as bakery_app + + runner = CliRunner() + result = runner.invoke( + bakery_app, + [ + "ci", + "matrix", + "--quiet", + "--context", + str(resource_path / "changeset"), + "--changed-files-from", + str(changed_files_path), + # Default: --dev-versions exclude; do not pass it explicitly to + # verify the bug regression under the default baseline. + ], + catch_exceptions=True, + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + matrix = json.loads(result.stdout.strip()) + + dev_entries = [e for e in matrix if e.get("dev") is True] + dev_names = [e["version"] for e in dev_entries] + + # The fake dev version must appear exactly once (Bug 1 regression). + assert dev_names.count("2026.01.0-dev+1-gABC") == 1, ( + f"Expected dev version to appear exactly once, got: {dev_names}" + ) + + class TestVersionMatches: @pytest.mark.parametrize( "ver_name,filter_version", diff --git a/posit-bakery/test/cli/testdata/ci/matrix/changeset/empty.json b/posit-bakery/test/cli/testdata/ci/matrix/changeset/empty.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/posit-bakery/test/cli/testdata/ci/matrix/changeset/empty.json @@ -0,0 +1 @@ +[] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json b/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json new file mode 100644 index 00000000..dbb15a2e --- /dev/null +++ b/posit-bakery/test/cli/testdata/ci/matrix/changeset/full.json @@ -0,0 +1 @@ +[{"image": "app", "version": "2.0.0", "dev": false, "platform": "linux/amd64"}, {"image": "app", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json b/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json new file mode 100644 index 00000000..0de82517 --- /dev/null +++ b/posit-bakery/test/cli/testdata/ci/matrix/changeset/version_only.json @@ -0,0 +1 @@ +[{"image": "app", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/config/test_changeset.py b/posit-bakery/test/config/test_changeset.py new file mode 100644 index 00000000..4ac2fc2f --- /dev/null +++ b/posit-bakery/test/config/test_changeset.py @@ -0,0 +1,174 @@ +import subprocess +import types +from pathlib import Path + +import pytest + +from posit_bakery.config import BakeryConfig +from posit_bakery.config.changeset import classify_changes, git_changed_files, ImageChangeSet +from posit_bakery.cli.ci import _version_selected + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) + + +@pytest.fixture +def temp_git_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q", "-b", "main") + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "base.txt").write_text("base\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "base") + _git(repo, "checkout", "-q", "-b", "feature") + (repo / "connect" / "2026.05").mkdir(parents=True) + (repo / "connect" / "2026.05" / "Containerfile").write_text("FROM scratch\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "change") + return repo + + +def test_git_changed_files_lists_paths_against_merge_base(temp_git_repo: Path): + changed = git_changed_files(temp_git_repo, "main") + assert "connect/2026.05/Containerfile" in changed + assert "base.txt" not in changed + + +def test_git_changed_files_returns_posix_relative_paths(temp_git_repo: Path): + changed = git_changed_files(temp_git_repo, "main") + assert all(not c.startswith("/") for c in changed) + assert all("\\" not in c for c in changed) + + +@pytest.fixture +def changeset_config(resource_path) -> BakeryConfig: + return BakeryConfig(resource_path / "changeset" / "bakery.yaml") + + +def test_markdown_changes_are_ignored(changeset_config): + sel = classify_changes(changeset_config, ["README.md", "app/README.md", "app/2.0.0/notes.md"]) + assert sel.full is False + assert sel.images == {} + + +def test_bakery_yaml_change_forces_full(changeset_config): + sel = classify_changes(changeset_config, ["bakery.yaml"]) + assert sel.full is True + + +def test_workflow_change_forces_full(changeset_config): + sel = classify_changes(changeset_config, [".github/workflows/production.yml"]) + assert sel.full is True + + +def test_meta_files_are_ignored(changeset_config): + sel = classify_changes(changeset_config, [".gitignore", ".idea/x.xml", ".github/ISSUE_TEMPLATE/bug.yml"]) + assert sel.full is False + assert sel.images == {} + + +def test_version_dir_change_selects_that_version(changeset_config): + sel = classify_changes(changeset_config, ["app/2.0.0/Containerfile.ubuntu2204.std"]) + assert sel.full is False + assert set(sel.images.keys()) == {"app"} + cs = sel.images["app"] + assert cs.versions == {"2.0.0"} + assert cs.include_dev is False + assert cs.include_all_release is False + + +def test_template_change_selects_dev_versions(changeset_config): + sel = classify_changes(changeset_config, ["app/template/Containerfile.ubuntu2204.jinja2"]) + assert set(sel.images.keys()) == {"app"} + cs = sel.images["app"] + assert cs.include_dev is True + assert cs.versions == set() + assert cs.include_all_release is False + + +def test_version_and_template_change_unions(changeset_config): + sel = classify_changes( + changeset_config, + ["app/1.0.0/scripts/startup.sh", "app/template/deps/packages.txt.jinja2"], + ) + cs = sel.images["app"] + assert cs.versions == {"1.0.0"} + assert cs.include_dev is True + + +def test_image_root_change_fails_safe_to_all_release(changeset_config): + sel = classify_changes(changeset_config, ["app/some-shared-file.sh"]) + cs = sel.images["app"] + assert cs.include_all_release is True + + +def test_matrix_image_change_selects_latest_and_dev(changeset_config): + # content has a matrix but no declared dev versions -> latest slice only. + sel = classify_changes(changeset_config, ["content/template/Containerfile.ubuntu2404.jinja2"]) + cs = sel.images["content"] + assert cs.include_matrix_latest is True + assert cs.include_dev is False + + +def test_unattributable_path_fails_safe_to_full(changeset_config): + sel = classify_changes(changeset_config, ["scripts/shared-tool.sh"]) + assert sel.full is True + + +def _fake_ver(**kwargs) -> types.SimpleNamespace: + """Create a minimal fake version object for _version_selected tests.""" + defaults = { + "isDevelopmentVersion": False, + "isMatrixVersion": False, + "latest": False, + "name": "1.0.0", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + +class TestVersionSelected: + """Unit tests for the _version_selected helper in posit_bakery.cli.ci.""" + + def test_dev_version_included_when_include_dev_true(self): + ver = _fake_ver(isDevelopmentVersion=True) + cs = ImageChangeSet(include_dev=True) + assert _version_selected(ver, cs) is True + + def test_dev_version_excluded_when_include_dev_false(self): + ver = _fake_ver(isDevelopmentVersion=True) + cs = ImageChangeSet(include_dev=False) + assert _version_selected(ver, cs) is False + + def test_matrix_latest_version_included_when_flag_and_latest(self): + ver = _fake_ver(isMatrixVersion=True, latest=True) + cs = ImageChangeSet(include_matrix_latest=True) + assert _version_selected(ver, cs) is True + + def test_matrix_non_latest_version_excluded_even_when_flag(self): + ver = _fake_ver(isMatrixVersion=True, latest=False) + cs = ImageChangeSet(include_matrix_latest=True) + assert _version_selected(ver, cs) is False + + def test_matrix_latest_version_excluded_when_flag_false(self): + ver = _fake_ver(isMatrixVersion=True, latest=True) + cs = ImageChangeSet(include_matrix_latest=False) + assert _version_selected(ver, cs) is False + + def test_release_version_included_by_name(self): + ver = _fake_ver(name="2.0.0") + cs = ImageChangeSet(versions={"2.0.0"}) + assert _version_selected(ver, cs) is True + + def test_release_version_excluded_when_not_in_set(self): + ver = _fake_ver(name="1.0.0") + cs = ImageChangeSet(versions={"2.0.0"}) + assert _version_selected(ver, cs) is False + + def test_release_version_included_by_include_all_release(self): + ver = _fake_ver(name="1.0.0") + cs = ImageChangeSet(include_all_release=True) + assert _version_selected(ver, cs) is True diff --git a/posit-bakery/test/conftest.py b/posit-bakery/test/conftest.py index c4ab1b52..474e2a7d 100644 --- a/posit-bakery/test/conftest.py +++ b/posit-bakery/test/conftest.py @@ -75,6 +75,24 @@ def patch_temporary_directory(request, tmp_path): SETTINGS.temporary_storage = tmp_path +@pytest.fixture(autouse=True) +def restore_settings_log_level(): + """Restore SETTINGS.log_level after each test. + + SETTINGS is a module-global singleton, and CLI verbosity flags (--quiet/--verbose) + mutate SETTINGS.log_level in-process when commands are exercised via CliRunner. + Without restoring it, the mutation leaks into later tests in the same worker — e.g. + a --quiet invocation sets log_level=ERROR, which then makes + ParallelShellExecutor._resolve_use_live (which reads SETTINGS.log_level) return False + unexpectedly. + """ + from posit_bakery.settings import SETTINGS + + original = SETTINGS.log_level + yield + SETTINGS.log_level = original + + @pytest.fixture(autouse=True) def _disable_image_build_cache(request, mocker: MockFixture): """Disable Docker layer caching for image_build tests. diff --git a/posit-bakery/test/features/cli/ci/matrix.feature b/posit-bakery/test/features/cli/ci/matrix.feature index b5c6f4d5..15e9eee3 100644 --- a/posit-bakery/test/features/cli/ci/matrix.feature +++ b/posit-bakery/test/features/cli/ci/matrix.feature @@ -56,3 +56,30 @@ Feature: matrix When I execute the command Then The command succeeds * the matrix matches testdata ci/matrix/merge-multi-image/image_name_filter.json + + Scenario: A version-dir change builds only that version + Given I call bakery ci matrix + * in a temp changeset context + * with changed files in changed-files.txt: + | app/1.0.0/Containerfile.ubuntu2204.std | + When I execute the command + Then The command succeeds + * the matrix matches testdata ci/matrix/changeset/version_only.json + + Scenario: A Markdown-only change yields an empty matrix + Given I call bakery ci matrix + * in a temp changeset context + * with changed files in changed-files.txt: + | README.md | + When I execute the command + Then The command succeeds + * the matrix matches testdata ci/matrix/changeset/empty.json + + Scenario: A bakery.yaml change falls back to the full matrix + Given I call bakery ci matrix + * in a temp changeset context + * with changed files in changed-files.txt: + | bakery.yaml | + When I execute the command + Then The command succeeds + * the matrix matches testdata ci/matrix/changeset/full.json diff --git a/posit-bakery/test/resources/changeset/bakery.yaml b/posit-bakery/test/resources/changeset/bakery.yaml new file mode 100644 index 00000000..d46a9cc8 --- /dev/null +++ b/posit-bakery/test/resources/changeset/bakery.yaml @@ -0,0 +1,59 @@ +repository: + url: "github.com/posit-dev/images-shared" + vendor: "Posit Software, PBC" + maintainer: + name: "Posit Docker Team" + email: "docker@posit.co" + authors: + - name: "Author 1" + email: "author1@posit.co" + +registries: + - host: "ghcr.io" + namespace: "posit-dev" + +images: + - name: "app" + variants: + - name: "Standard" + extension: "std" + tagDisplayName: "std" + primary: true + devVersions: + - sourceType: stream + product: connect + stream: daily + os: + - name: Ubuntu 24.04 + primary: true + versions: + - name: 2.0.0 + subpath: "2.0.0" + latest: true + os: + - name: Ubuntu 22.04 + primary: true + - name: 1.0.0 + subpath: "1.0.0" + os: + - name: Ubuntu 22.04 + primary: true + - name: "content" + variants: + - name: "Base" + extension: "base" + tagDisplayName: "base" + primary: true + matrix: + dependencyConstraints: + - dependency: R + constraint: + count: 2 + latest: true + - dependency: python + constraint: + count: 2 + latest: true + os: + - name: "Ubuntu 24.04" + primary: true