Skip to content

Commit e6766a7

Browse files
authored
Merge pull request #610 from posit-dev/fix/dev-versions-on-matrix-images
fix(config): dev versions on matrix images use matrix subpath and build args
2 parents 0f9fd41 + 92c7283 commit e6766a7

8 files changed

Lines changed: 363 additions & 6 deletions

File tree

posit-bakery/posit_bakery/cli/ci.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ def matrix(
143143
continue
144144
elif img.matrix is not None:
145145
if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE:
146-
versions = img.matrix.to_image_versions()
146+
if dev_versions == DevVersionInclusionEnum.ONLY:
147+
pass # img.versions has dev versions; matrix prod versions all fail the dev filter
148+
elif dev_versions == DevVersionInclusionEnum.INCLUDE:
149+
dev_versions_loaded = [v for v in img.versions if v.isDevelopmentVersion]
150+
versions = img.matrix.to_image_versions() + dev_versions_loaded
151+
else:
152+
versions = img.matrix.to_image_versions()
147153
# If EXCLUDE: fall through using img.versions (devVersions are appended
148154
# there by load_dev_versions). The dev_versions filter below handles the rest.
149155
for ver in versions:

posit-bakery/posit_bakery/config/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,16 @@ def generate_image_targets(self, settings: BakerySettings = BakerySettings()):
953953
log.warning(f"Image '{image.name}' matches --image-name filter but is being skipped: {reason}")
954954
continue
955955
elif image.matrix is not None and settings.matrix_versions != MatrixVersionInclusionEnum.EXCLUDE:
956-
versions = image.matrix.to_image_versions()
956+
if settings.dev_versions == DevVersionInclusionEnum.ONLY:
957+
# Dev versions are already in image.versions (from load_dev_versions()).
958+
# Matrix production versions (isDevelopmentVersion=False) would all be
959+
# filtered out by --dev-versions only, so there is nothing to merge.
960+
pass
961+
elif settings.dev_versions == DevVersionInclusionEnum.INCLUDE:
962+
dev_versions_loaded = [v for v in image.versions if v.isDevelopmentVersion]
963+
versions = image.matrix.to_image_versions() + dev_versions_loaded
964+
else:
965+
versions = image.matrix.to_image_versions()
957966
targets_before = len(targets)
958967
for version in versions:
959968
version_filter_matched = settings.filter.image_version is not None and version_matches(

posit-bakery/posit_bakery/config/image/dev_version/base.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,27 @@ def _resolve_os_urls(self) -> list[ImageVersionOS]:
246246
os_version.artifactDownloadURL = default_urls.get(os_version.name, "")
247247
return list(self.os)
248248

249+
def _get_dependencies_for_matrix(self, version: str) -> list:
250+
"""Return the dependency list to attach when the parent image has a matrix.
251+
252+
The default returns the parent's image-level constraints, which is correct
253+
for stream-based dev versions. Subclasses override this when the dev version
254+
itself carries the meaningful dependency dimension (e.g. a dependency-sourced
255+
dev version where the resolved version *is* the matrix axis).
256+
"""
257+
return self.parent.resolve_dependency_versions()
258+
249259
def as_image_version(self):
250260
"""Convert this development version to a standard image version.
251261
252262
Calls _resolve_os_urls() to populate artifact download URLs.
253263
The channel subclass overrides _resolve_os_urls() to exclude
254264
OSes whose platform is unavailable in the product channel.
255265
266+
When the parent image has a matrix, the returned ImageVersion reuses the
267+
matrix's subpath and is flagged as a matrix version so it shares the same
268+
Containerfile template as the production matrix builds.
269+
256270
:raises RuntimeError: If no OSes remain after URL resolution.
257271
"""
258272
resolved_os = self._resolve_os_urls()
@@ -264,17 +278,31 @@ def as_image_version(self):
264278
release_channel = self.get_release_channel()
265279
if release_channel is not None:
266280
metadata["release_channel"] = release_channel
281+
282+
has_matrix_parent = self.parent is not None and self.parent.matrix is not None
283+
if has_matrix_parent:
284+
subpath = self.parent.matrix.subpath
285+
ephemeral = False
286+
is_matrix_version = True
287+
dependencies = self._get_dependencies_for_matrix(version)
288+
else:
289+
subpath = f".dev-{version}".replace(" ", "-").lower()
290+
ephemeral = True
291+
is_matrix_version = False
292+
dependencies = self.parent.resolve_dependency_versions()
293+
267294
return ImageVersion(
268295
name=version,
269-
subpath=f".dev-{version}".replace(" ", "-").lower(),
296+
subpath=subpath,
270297
parent=self.parent,
271298
extraRegistries=self.extraRegistries,
272299
overrideRegistries=self.overrideRegistries,
273300
os=resolved_os,
274301
values=self.values,
275302
latest=False,
276-
dependencies=self.parent.resolve_dependency_versions(),
277-
ephemeral=True,
303+
dependencies=dependencies,
304+
ephemeral=ephemeral,
278305
isDevelopmentVersion=True,
306+
isMatrixVersion=is_matrix_version,
279307
metadata=metadata,
280308
)

posit-bakery/posit_bakery/config/image/dev_version/dependency.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pydantic import Field, field_validator
44

5-
from posit_bakery.config.dependencies import get_dependency_constraint_class
5+
from posit_bakery.config.dependencies import get_dependency_constraint_class, get_dependency_versions_class
66
from posit_bakery.config.dependencies.const import SupportedDependencies
77
from posit_bakery.config.dependencies.version import VersionConstraint
88
from posit_bakery.config.image.dev_version.base import BaseImageDevelopmentVersion
@@ -54,6 +54,10 @@ def get_version(self) -> str:
5454
result = constraint.resolve_versions()
5555
return str(result.versions[0])
5656

57+
def _get_dependencies_for_matrix(self, version: str) -> list:
58+
cls = get_dependency_versions_class(self.dependency)
59+
return [cls(versions=[version])]
60+
5761
def get_url_by_os(self, generalize_architecture: bool = False) -> dict[str, str]:
5862
return {}
5963

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Tests for bakery ci matrix with images that have both matrix: and devVersions:.
2+
3+
Covers two regression scenarios where dev versions from a matrix image were
4+
silently dropped from the output:
5+
6+
Issue 1: --matrix-versions only --dev-versions only → returned []
7+
Issue 2: --matrix-versions include --dev-versions only → excluded matrix images with dev versions
8+
"""
9+
10+
import json
11+
from pathlib import Path
12+
from unittest.mock import MagicMock, patch
13+
14+
import pytest
15+
from typer.testing import CliRunner
16+
17+
from posit_bakery.cli.main import app
18+
from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum
19+
from posit_bakery.const import DevVersionInclusionEnum
20+
21+
runner = CliRunner()
22+
BASIC_CONTEXT = str(Path(__file__).parent.parent / "resources" / "basic")
23+
24+
25+
def _make_version(name: str, *, is_dev: bool, channel: ReleaseChannelEnum | None = None):
26+
"""Return a minimal MagicMock ImageVersion with working matches_dev_filter."""
27+
ver = MagicMock()
28+
ver.name = name
29+
ver.isDevelopmentVersion = is_dev
30+
ver.metadata = {"release_channel": channel} if channel else {}
31+
ver.supported_platforms = ["linux/amd64"]
32+
33+
def matches_dev_filter(dev_versions, dev_channel=None):
34+
if is_dev and dev_versions == DevVersionInclusionEnum.EXCLUDE:
35+
return False, "excluded by --dev-versions exclude"
36+
if not is_dev and dev_versions == DevVersionInclusionEnum.ONLY:
37+
return False, "not a development version (excluded by --dev-versions only)"
38+
if dev_channel is not None and is_dev:
39+
if channel != dev_channel:
40+
return False, f"channel mismatch"
41+
return True, None
42+
43+
ver.matches_dev_filter = matches_dev_filter
44+
return ver
45+
46+
47+
def _make_matrix_image(name: str, dev_versions: list, prod_versions: list):
48+
"""Return a mock Image with both a matrix and pre-loaded dev versions."""
49+
img = MagicMock()
50+
img.name = name
51+
52+
matrix = MagicMock()
53+
matrix.to_image_versions.return_value = prod_versions
54+
img.matrix = matrix
55+
56+
# img.versions simulates the state *after* load_dev_versions() has been called.
57+
# In practice bakery loads dev versions into image.versions; prod matrix versions
58+
# come from img.matrix.to_image_versions() at filter time.
59+
img.versions = dev_versions
60+
61+
return img
62+
63+
64+
@pytest.fixture
65+
def mock_config_with_matrix_dev_image():
66+
"""Patch BakeryConfig to return a single matrix image with a dev version loaded."""
67+
dev_ver = _make_version("2026.99.0-dev+1", is_dev=True, channel=ReleaseChannelEnum.DAILY)
68+
prod_ver1 = _make_version("2026.1.0", is_dev=False)
69+
prod_ver2 = _make_version("2026.2.0", is_dev=False)
70+
img = _make_matrix_image("positron-session", [dev_ver], [prod_ver1, prod_ver2])
71+
72+
with patch("posit_bakery.cli.ci.BakeryConfig") as mock:
73+
instance = MagicMock()
74+
instance.model.images = [img]
75+
mock.from_context.return_value = instance
76+
yield mock, dev_ver, prod_ver1, prod_ver2
77+
78+
79+
class TestCiMatrixDevVersionsOnly:
80+
"""Issue 1: --matrix-versions only --dev-versions only returned []."""
81+
82+
def test_dev_version_included(self, mock_config_with_matrix_dev_image):
83+
_, dev_ver, _, _ = mock_config_with_matrix_dev_image
84+
result = runner.invoke(
85+
app,
86+
[
87+
"ci",
88+
"matrix",
89+
"--context",
90+
BASIC_CONTEXT,
91+
"--matrix-versions",
92+
"only",
93+
"--dev-versions",
94+
"only",
95+
],
96+
catch_exceptions=False,
97+
)
98+
assert result.exit_code == 0, result.output
99+
data = json.loads(result.stdout.strip())
100+
assert len(data) == 1
101+
assert data[0]["version"] == dev_ver.name
102+
assert data[0]["dev"] is True
103+
104+
def test_prod_versions_excluded(self, mock_config_with_matrix_dev_image):
105+
_, _, prod_ver1, prod_ver2 = mock_config_with_matrix_dev_image
106+
result = runner.invoke(
107+
app,
108+
[
109+
"ci",
110+
"matrix",
111+
"--context",
112+
BASIC_CONTEXT,
113+
"--matrix-versions",
114+
"only",
115+
"--dev-versions",
116+
"only",
117+
],
118+
catch_exceptions=False,
119+
)
120+
assert result.exit_code == 0, result.output
121+
data = json.loads(result.stdout.strip())
122+
versions_in_output = {e["version"] for e in data}
123+
assert prod_ver1.name not in versions_in_output
124+
assert prod_ver2.name not in versions_in_output
125+
126+
127+
class TestCiMatrixDevVersionsInclude:
128+
"""Issue 2: --matrix-versions include --dev-versions only omitted matrix images' dev versions."""
129+
130+
def test_dev_version_included(self, mock_config_with_matrix_dev_image):
131+
_, dev_ver, _, _ = mock_config_with_matrix_dev_image
132+
result = runner.invoke(
133+
app,
134+
[
135+
"ci",
136+
"matrix",
137+
"--context",
138+
BASIC_CONTEXT,
139+
"--matrix-versions",
140+
"include",
141+
"--dev-versions",
142+
"only",
143+
],
144+
catch_exceptions=False,
145+
)
146+
assert result.exit_code == 0, result.output
147+
data = json.loads(result.stdout.strip())
148+
dev_entries = [e for e in data if e["dev"]]
149+
assert len(dev_entries) == 1
150+
assert dev_entries[0]["version"] == dev_ver.name
151+
152+
def test_prod_versions_excluded(self, mock_config_with_matrix_dev_image):
153+
"""With --dev-versions only, production matrix versions are filtered out."""
154+
_, _, prod_ver1, prod_ver2 = mock_config_with_matrix_dev_image
155+
result = runner.invoke(
156+
app,
157+
[
158+
"ci",
159+
"matrix",
160+
"--context",
161+
BASIC_CONTEXT,
162+
"--matrix-versions",
163+
"include",
164+
"--dev-versions",
165+
"only",
166+
],
167+
catch_exceptions=False,
168+
)
169+
assert result.exit_code == 0, result.output
170+
data = json.loads(result.stdout.strip())
171+
versions_in_output = {e["version"] for e in data}
172+
assert prod_ver1.name not in versions_in_output
173+
assert prod_ver2.name not in versions_in_output

posit-bakery/test/config/image/dev_version/test_dependency.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
DevelopmentVersionField,
1010
ImageDevelopmentVersionFromDependency,
1111
)
12+
from posit_bakery.config.image.matrix import ImageMatrix
1213

1314
pytestmark = [
1415
pytest.mark.unit,
@@ -22,10 +23,19 @@
2223
def _mock_parent():
2324
parent = MagicMock(spec=Image)
2425
parent.path = Path("/tmp/test")
26+
parent.matrix = None
2527
parent.resolve_dependency_versions.return_value = []
2628
return parent
2729

2830

31+
def _mock_parent_with_matrix(matrix_subpath="matrix"):
32+
parent = _mock_parent()
33+
matrix = MagicMock(spec=ImageMatrix)
34+
matrix.subpath = matrix_subpath
35+
parent.matrix = matrix
36+
return parent
37+
38+
2939
class TestValidation:
3040
def test_dependency_required(self):
3141
"""dependency is a required field."""
@@ -370,3 +380,63 @@ def test_version_override_short_circuits_resolution(self):
370380
dev.version_override = "2026.06.0-99"
371381
# No patch_requests_get fixture: a network call would error, proving none happens.
372382
assert dev.get_version() == "2026.06.0-99"
383+
384+
385+
class TestAsImageVersionWithMatrixParent:
386+
def test_uses_matrix_subpath(self, patch_requests_get):
387+
"""When parent has a matrix, subpath is the matrix subpath, not an ephemeral .dev-* path."""
388+
dev = ImageDevelopmentVersionFromDependency(
389+
parent=_mock_parent_with_matrix("matrix"),
390+
dependency="positron",
391+
prerelease=True,
392+
os=[_UBUNTU_24_OS],
393+
)
394+
iv = dev.as_image_version()
395+
assert iv.subpath == "matrix"
396+
397+
def test_not_ephemeral_for_matrix_parent(self, patch_requests_get):
398+
"""When parent has a matrix, the ImageVersion is not ephemeral (directory already exists)."""
399+
dev = ImageDevelopmentVersionFromDependency(
400+
parent=_mock_parent_with_matrix(),
401+
dependency="positron",
402+
prerelease=True,
403+
os=[_UBUNTU_24_OS],
404+
)
405+
iv = dev.as_image_version()
406+
assert iv.ephemeral is False
407+
408+
def test_is_matrix_version_for_matrix_parent(self, patch_requests_get):
409+
"""When parent has a matrix, the ImageVersion is flagged as a matrix version."""
410+
dev = ImageDevelopmentVersionFromDependency(
411+
parent=_mock_parent_with_matrix(),
412+
dependency="positron",
413+
prerelease=True,
414+
os=[_UBUNTU_24_OS],
415+
)
416+
iv = dev.as_image_version()
417+
assert iv.isMatrixVersion is True
418+
419+
def test_dependency_version_passed_as_build_arg(self, patch_requests_get):
420+
"""When parent has a matrix, the resolved dependency version appears in dependencies."""
421+
dev = ImageDevelopmentVersionFromDependency(
422+
parent=_mock_parent_with_matrix(),
423+
dependency="positron",
424+
prerelease=True,
425+
os=[_UBUNTU_24_OS],
426+
)
427+
iv = dev.as_image_version()
428+
positron_deps = [d for d in iv.dependencies if d.dependency == "positron"]
429+
assert len(positron_deps) == 1
430+
assert "2026.07.0-55" in positron_deps[0].versions
431+
432+
def test_non_matrix_parent_still_ephemeral(self, patch_requests_get):
433+
"""When parent has no matrix, the original ephemeral .dev-* behaviour is unchanged."""
434+
dev = ImageDevelopmentVersionFromDependency(
435+
parent=_mock_parent(),
436+
dependency="positron",
437+
prerelease=True,
438+
os=[_UBUNTU_24_OS],
439+
)
440+
iv = dev.as_image_version()
441+
assert iv.ephemeral is True
442+
assert iv.subpath.startswith(".dev-")

0 commit comments

Comments
 (0)