From 71e6d56a4e04b2e2219d6aa55cabc5dcf21739d9 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 12 Jun 2026 18:38:23 -0600 Subject: [PATCH 1/2] fix(config): dev versions on matrix images use matrix subpath and build args Two related bugs prevented workbench-positron-init dev builds from appearing when using --dev-versions only --matrix-versions only. 1. as_image_version() in BaseImageDevelopmentVersion was naive to matrix parents: it always created an ephemeral .dev-{version} subpath, set ephemeral=True, and omitted isMatrixVersion. When the parent has a matrix the returned ImageVersion now reuses the matrix's subpath (e.g. matrix/), sets ephemeral=False, and sets isMatrixVersion=True so it shares the same Containerfile template as production matrix builds. ImageDevelopmentVersionFromDependency overrides _get_dependencies_for_matrix to pass the resolved dependency version as a build arg, matching how production matrix versions supply their dependency dimension. 2. generate_image_targets() discarded dev versions when --matrix-versions only/include was set: the matrix override (versions = matrix.to_image_versions()) replaced image.versions before the dev filter ran, leaving zero candidates. With --dev-versions only the override is now skipped (dev versions already live in image.versions and production matrix versions would be filtered out anyway); with --dev-versions include, matrix and dev versions are merged. Co-Authored-By: Claude Sonnet 4.6 --- posit-bakery/posit_bakery/config/config.py | 11 ++- .../config/image/dev_version/base.py | 34 ++++++++- .../config/image/dev_version/dependency.py | 6 +- .../image/dev_version/test_dependency.py | 70 +++++++++++++++++++ posit-bakery/test/config/test_config.py | 38 ++++++++++ .../test/testdata/valid/matrix-with-dev.yaml | 29 ++++++++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 posit-bakery/test/testdata/valid/matrix-with-dev.yaml diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index 92ab3915..900bb9a4 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -953,7 +953,16 @@ def generate_image_targets(self, settings: BakerySettings = BakerySettings()): log.warning(f"Image '{image.name}' matches --image-name filter but is being skipped: {reason}") continue elif image.matrix is not None and settings.matrix_versions != MatrixVersionInclusionEnum.EXCLUDE: - versions = image.matrix.to_image_versions() + if settings.dev_versions == DevVersionInclusionEnum.ONLY: + # Dev versions are already in image.versions (from load_dev_versions()). + # Matrix production versions (isDevelopmentVersion=False) would all be + # filtered out by --dev-versions only, so there is nothing to merge. + pass + elif settings.dev_versions == DevVersionInclusionEnum.INCLUDE: + dev_versions_loaded = [v for v in image.versions if v.isDevelopmentVersion] + versions = image.matrix.to_image_versions() + dev_versions_loaded + else: + versions = image.matrix.to_image_versions() targets_before = len(targets) for version in versions: version_filter_matched = settings.filter.image_version is not None and version_matches( diff --git a/posit-bakery/posit_bakery/config/image/dev_version/base.py b/posit-bakery/posit_bakery/config/image/dev_version/base.py index 7361f083..76257ae3 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/base.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/base.py @@ -246,6 +246,16 @@ def _resolve_os_urls(self) -> list[ImageVersionOS]: os_version.artifactDownloadURL = default_urls.get(os_version.name, "") return list(self.os) + def _get_dependencies_for_matrix(self, version: str) -> list: + """Return the dependency list to attach when the parent image has a matrix. + + The default returns the parent's image-level constraints, which is correct + for stream-based dev versions. Subclasses override this when the dev version + itself carries the meaningful dependency dimension (e.g. a dependency-sourced + dev version where the resolved version *is* the matrix axis). + """ + return self.parent.resolve_dependency_versions() + def as_image_version(self): """Convert this development version to a standard image version. @@ -253,6 +263,10 @@ def as_image_version(self): The channel subclass overrides _resolve_os_urls() to exclude OSes whose platform is unavailable in the product channel. + When the parent image has a matrix, the returned ImageVersion reuses the + matrix's subpath and is flagged as a matrix version so it shares the same + Containerfile template as the production matrix builds. + :raises RuntimeError: If no OSes remain after URL resolution. """ resolved_os = self._resolve_os_urls() @@ -264,17 +278,31 @@ def as_image_version(self): release_channel = self.get_release_channel() if release_channel is not None: metadata["release_channel"] = release_channel + + has_matrix_parent = self.parent is not None and self.parent.matrix is not None + if has_matrix_parent: + subpath = self.parent.matrix.subpath + ephemeral = False + is_matrix_version = True + dependencies = self._get_dependencies_for_matrix(version) + else: + subpath = f".dev-{version}".replace(" ", "-").lower() + ephemeral = True + is_matrix_version = False + dependencies = self.parent.resolve_dependency_versions() + return ImageVersion( name=version, - subpath=f".dev-{version}".replace(" ", "-").lower(), + subpath=subpath, parent=self.parent, extraRegistries=self.extraRegistries, overrideRegistries=self.overrideRegistries, os=resolved_os, values=self.values, latest=False, - dependencies=self.parent.resolve_dependency_versions(), - ephemeral=True, + dependencies=dependencies, + ephemeral=ephemeral, isDevelopmentVersion=True, + isMatrixVersion=is_matrix_version, metadata=metadata, ) diff --git a/posit-bakery/posit_bakery/config/image/dev_version/dependency.py b/posit-bakery/posit_bakery/config/image/dev_version/dependency.py index 556d3d2c..5332fcfa 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/dependency.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/dependency.py @@ -2,7 +2,7 @@ from pydantic import Field, field_validator -from posit_bakery.config.dependencies import get_dependency_constraint_class +from posit_bakery.config.dependencies import get_dependency_constraint_class, get_dependency_versions_class from posit_bakery.config.dependencies.const import SupportedDependencies from posit_bakery.config.dependencies.version import VersionConstraint from posit_bakery.config.image.dev_version.base import BaseImageDevelopmentVersion @@ -54,6 +54,10 @@ def get_version(self) -> str: result = constraint.resolve_versions() return str(result.versions[0]) + def _get_dependencies_for_matrix(self, version: str) -> list: + cls = get_dependency_versions_class(self.dependency) + return [cls(versions=[version])] + def get_url_by_os(self, generalize_architecture: bool = False) -> dict[str, str]: return {} diff --git a/posit-bakery/test/config/image/dev_version/test_dependency.py b/posit-bakery/test/config/image/dev_version/test_dependency.py index fb9cf6b2..d4e07973 100644 --- a/posit-bakery/test/config/image/dev_version/test_dependency.py +++ b/posit-bakery/test/config/image/dev_version/test_dependency.py @@ -9,6 +9,7 @@ DevelopmentVersionField, ImageDevelopmentVersionFromDependency, ) +from posit_bakery.config.image.matrix import ImageMatrix pytestmark = [ pytest.mark.unit, @@ -22,10 +23,19 @@ def _mock_parent(): parent = MagicMock(spec=Image) parent.path = Path("/tmp/test") + parent.matrix = None parent.resolve_dependency_versions.return_value = [] return parent +def _mock_parent_with_matrix(matrix_subpath="matrix"): + parent = _mock_parent() + matrix = MagicMock(spec=ImageMatrix) + matrix.subpath = matrix_subpath + parent.matrix = matrix + return parent + + class TestValidation: def test_dependency_required(self): """dependency is a required field.""" @@ -370,3 +380,63 @@ def test_version_override_short_circuits_resolution(self): dev.version_override = "2026.06.0-99" # No patch_requests_get fixture: a network call would error, proving none happens. assert dev.get_version() == "2026.06.0-99" + + +class TestAsImageVersionWithMatrixParent: + def test_uses_matrix_subpath(self, patch_requests_get): + """When parent has a matrix, subpath is the matrix subpath, not an ephemeral .dev-* path.""" + dev = ImageDevelopmentVersionFromDependency( + parent=_mock_parent_with_matrix("matrix"), + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + iv = dev.as_image_version() + assert iv.subpath == "matrix" + + def test_not_ephemeral_for_matrix_parent(self, patch_requests_get): + """When parent has a matrix, the ImageVersion is not ephemeral (directory already exists).""" + dev = ImageDevelopmentVersionFromDependency( + parent=_mock_parent_with_matrix(), + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + iv = dev.as_image_version() + assert iv.ephemeral is False + + def test_is_matrix_version_for_matrix_parent(self, patch_requests_get): + """When parent has a matrix, the ImageVersion is flagged as a matrix version.""" + dev = ImageDevelopmentVersionFromDependency( + parent=_mock_parent_with_matrix(), + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + iv = dev.as_image_version() + assert iv.isMatrixVersion is True + + def test_dependency_version_passed_as_build_arg(self, patch_requests_get): + """When parent has a matrix, the resolved dependency version appears in dependencies.""" + dev = ImageDevelopmentVersionFromDependency( + parent=_mock_parent_with_matrix(), + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + iv = dev.as_image_version() + positron_deps = [d for d in iv.dependencies if d.dependency == "positron"] + assert len(positron_deps) == 1 + assert "2026.07.0-55" in positron_deps[0].versions + + def test_non_matrix_parent_still_ephemeral(self, patch_requests_get): + """When parent has no matrix, the original ephemeral .dev-* behaviour is unchanged.""" + dev = ImageDevelopmentVersionFromDependency( + parent=_mock_parent(), + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + iv = dev.as_image_version() + assert iv.ephemeral is True + assert iv.subpath.startswith(".dev-") diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index f4847d13..e0553c91 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -583,6 +583,44 @@ def test_valid_matrix_version_enum( for target in matrix_versions: assert target.uid in expected_uids + @pytest.mark.usefixtures("patch_requests_get") + def test_dev_versions_only_with_matrix_image_yields_dev_target(self, testdata_path): + """--dev-versions only on a matrix image with devVersions yields the dev version, not zero targets.""" + yaml_file = testdata_path / "valid" / "matrix-with-dev.yaml" + with patch.object(posit_bakery.config.image.Image, "render_ephemeral_version_files"): + config = BakeryConfig( + yaml_file, + BakerySettings( + filter=BakeryConfigFilter(image_name="^positron-session$"), + dev_versions=DevVersionInclusionEnum.ONLY, + matrix_versions=MatrixVersionInclusionEnum.ONLY, + ), + ) + assert len(config.targets) == 1 + target = config.targets[0] + assert target.image_version.isDevelopmentVersion is True + assert target.image_version.name == "2026.07.0-55" + assert target.image_version.isMatrixVersion is True + + @pytest.mark.usefixtures("patch_requests_get") + def test_dev_versions_include_with_matrix_image_yields_all_targets(self, testdata_path): + """--dev-versions include on a matrix image with devVersions yields matrix + dev targets.""" + yaml_file = testdata_path / "valid" / "matrix-with-dev.yaml" + with patch.object(posit_bakery.config.image.Image, "render_ephemeral_version_files"): + config = BakeryConfig( + yaml_file, + BakerySettings( + filter=BakeryConfigFilter(image_name="^positron-session$"), + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.ONLY, + ), + ) + matrix_targets = [t for t in config.targets if not t.image_version.isDevelopmentVersion] + dev_targets = [t for t in config.targets if t.image_version.isDevelopmentVersion] + assert len(matrix_targets) == 2 + assert len(dev_targets) == 1 + assert dev_targets[0].image_version.name == "2026.07.0-55" + @pytest.mark.usefixtures("patch_requests_get") def test_latest_filters_standard_versions(self, testdata_path): """--latest keeps only the standard version marked latest: true.""" diff --git a/posit-bakery/test/testdata/valid/matrix-with-dev.yaml b/posit-bakery/test/testdata/valid/matrix-with-dev.yaml new file mode 100644 index 00000000..3c2caa2d --- /dev/null +++ b/posit-bakery/test/testdata/valid/matrix-with-dev.yaml @@ -0,0 +1,29 @@ +repository: + url: "github.com/posit-dev/images-shared" + +images: + - name: "positron-session" + devVersions: + - sourceType: "dependency" + dependency: "positron" + prerelease: true + channel: "daily" + values: + POSITRON_CHANNEL: "dailies" + os: + - name: "Ubuntu 24.04" + primary: true + platforms: + - "linux/amd64" + matrix: + namePattern: "{{ Dependencies.positron }}" + dependencyConstraints: + - dependency: positron + constraint: + latest: true + count: 2 + os: + - name: "Ubuntu 24.04" + primary: true + platforms: + - "linux/amd64" From 92c72833160717215cfca8f9e219d2d117d6c121 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Mon, 15 Jun 2026 09:03:21 -0600 Subject: [PATCH 2/2] fix(ci): apply matrix+dev version filtering fix to ci matrix command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ci matrix command had its own version-filtering loop that replaced img.versions with img.matrix.to_image_versions() unconditionally when matrix_versions != EXCLUDE, discarding dev versions that load_dev_versions() had already appended to img.versions. Apply the same three-way split (ONLY / INCLUDE / exclude) as was already fixed in generate_image_targets(), so that: --matrix-versions only --dev-versions only → includes matrix dev versions --matrix-versions include --dev-versions only → includes matrix dev versions Co-Authored-By: Claude Sonnet 4.6 --- posit-bakery/posit_bakery/cli/ci.py | 8 +- .../test/cli/test_ci_matrix_dev_versions.py | 173 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 posit-bakery/test/cli/test_ci_matrix_dev_versions.py diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index 04980d35..e8183fa5 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -143,7 +143,13 @@ def matrix( continue elif img.matrix is not None: if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE: - versions = img.matrix.to_image_versions() + 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. for ver in versions: diff --git a/posit-bakery/test/cli/test_ci_matrix_dev_versions.py b/posit-bakery/test/cli/test_ci_matrix_dev_versions.py new file mode 100644 index 00000000..cf9937c9 --- /dev/null +++ b/posit-bakery/test/cli/test_ci_matrix_dev_versions.py @@ -0,0 +1,173 @@ +"""Tests for bakery ci matrix with images that have both matrix: and devVersions:. + +Covers two regression scenarios where dev versions from a matrix image were +silently dropped from the output: + + Issue 1: --matrix-versions only --dev-versions only → returned [] + Issue 2: --matrix-versions include --dev-versions only → excluded matrix images with dev versions +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from posit_bakery.cli.main import app +from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum +from posit_bakery.const import DevVersionInclusionEnum + +runner = CliRunner() +BASIC_CONTEXT = str(Path(__file__).parent.parent / "resources" / "basic") + + +def _make_version(name: str, *, is_dev: bool, channel: ReleaseChannelEnum | None = None): + """Return a minimal MagicMock ImageVersion with working matches_dev_filter.""" + ver = MagicMock() + ver.name = name + ver.isDevelopmentVersion = is_dev + ver.metadata = {"release_channel": channel} if channel else {} + ver.supported_platforms = ["linux/amd64"] + + def matches_dev_filter(dev_versions, dev_channel=None): + if is_dev and dev_versions == DevVersionInclusionEnum.EXCLUDE: + return False, "excluded by --dev-versions exclude" + if not is_dev and dev_versions == DevVersionInclusionEnum.ONLY: + return False, "not a development version (excluded by --dev-versions only)" + if dev_channel is not None and is_dev: + if channel != dev_channel: + return False, f"channel mismatch" + return True, None + + ver.matches_dev_filter = matches_dev_filter + return ver + + +def _make_matrix_image(name: str, dev_versions: list, prod_versions: list): + """Return a mock Image with both a matrix and pre-loaded dev versions.""" + img = MagicMock() + img.name = name + + matrix = MagicMock() + matrix.to_image_versions.return_value = prod_versions + img.matrix = matrix + + # img.versions simulates the state *after* load_dev_versions() has been called. + # In practice bakery loads dev versions into image.versions; prod matrix versions + # come from img.matrix.to_image_versions() at filter time. + img.versions = dev_versions + + return img + + +@pytest.fixture +def mock_config_with_matrix_dev_image(): + """Patch BakeryConfig to return a single matrix image with a dev version loaded.""" + dev_ver = _make_version("2026.99.0-dev+1", is_dev=True, channel=ReleaseChannelEnum.DAILY) + prod_ver1 = _make_version("2026.1.0", is_dev=False) + prod_ver2 = _make_version("2026.2.0", is_dev=False) + img = _make_matrix_image("positron-session", [dev_ver], [prod_ver1, prod_ver2]) + + with patch("posit_bakery.cli.ci.BakeryConfig") as mock: + instance = MagicMock() + instance.model.images = [img] + mock.from_context.return_value = instance + yield mock, dev_ver, prod_ver1, prod_ver2 + + +class TestCiMatrixDevVersionsOnly: + """Issue 1: --matrix-versions only --dev-versions only returned [].""" + + def test_dev_version_included(self, mock_config_with_matrix_dev_image): + _, dev_ver, _, _ = mock_config_with_matrix_dev_image + result = runner.invoke( + app, + [ + "ci", + "matrix", + "--context", + BASIC_CONTEXT, + "--matrix-versions", + "only", + "--dev-versions", + "only", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout.strip()) + assert len(data) == 1 + assert data[0]["version"] == dev_ver.name + assert data[0]["dev"] is True + + def test_prod_versions_excluded(self, mock_config_with_matrix_dev_image): + _, _, prod_ver1, prod_ver2 = mock_config_with_matrix_dev_image + result = runner.invoke( + app, + [ + "ci", + "matrix", + "--context", + BASIC_CONTEXT, + "--matrix-versions", + "only", + "--dev-versions", + "only", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout.strip()) + versions_in_output = {e["version"] for e in data} + assert prod_ver1.name not in versions_in_output + assert prod_ver2.name not in versions_in_output + + +class TestCiMatrixDevVersionsInclude: + """Issue 2: --matrix-versions include --dev-versions only omitted matrix images' dev versions.""" + + def test_dev_version_included(self, mock_config_with_matrix_dev_image): + _, dev_ver, _, _ = mock_config_with_matrix_dev_image + result = runner.invoke( + app, + [ + "ci", + "matrix", + "--context", + BASIC_CONTEXT, + "--matrix-versions", + "include", + "--dev-versions", + "only", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout.strip()) + dev_entries = [e for e in data if e["dev"]] + assert len(dev_entries) == 1 + assert dev_entries[0]["version"] == dev_ver.name + + def test_prod_versions_excluded(self, mock_config_with_matrix_dev_image): + """With --dev-versions only, production matrix versions are filtered out.""" + _, _, prod_ver1, prod_ver2 = mock_config_with_matrix_dev_image + result = runner.invoke( + app, + [ + "ci", + "matrix", + "--context", + BASIC_CONTEXT, + "--matrix-versions", + "include", + "--dev-versions", + "only", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout.strip()) + versions_in_output = {e["version"] for e in data} + assert prod_ver1.name not in versions_in_output + assert prod_ver2.name not in versions_in_output