Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion posit-bakery/posit_bakery/cli/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion posit-bakery/posit_bakery/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 31 additions & 3 deletions posit-bakery/posit_bakery/config/image/dev_version/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,27 @@ 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.

Calls _resolve_os_urls() to populate artifact download URLs.
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()
Expand All @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}

Expand Down
173 changes: 173 additions & 0 deletions posit-bakery/test/cli/test_ci_matrix_dev_versions.py
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions posit-bakery/test/config/image/dev_version/test_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DevelopmentVersionField,
ImageDevelopmentVersionFromDependency,
)
from posit_bakery.config.image.matrix import ImageMatrix

pytestmark = [
pytest.mark.unit,
Expand All @@ -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."""
Expand Down Expand Up @@ -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-")
Loading
Loading