diff --git a/posit-bakery/posit_bakery/config/dependencies/version.py b/posit-bakery/posit_bakery/config/dependencies/version.py index 2ddd447d..678f4d13 100644 --- a/posit-bakery/posit_bakery/config/dependencies/version.py +++ b/posit-bakery/posit_bakery/config/dependencies/version.py @@ -1,6 +1,7 @@ +import re from typing import Annotated, Self, Any -from packaging.version import Version +from packaging.version import InvalidVersion, Version from pydantic import Field, model_validator, field_validator from ruamel.yaml.scalarfloat import ScalarFloat from ruamel.yaml.scalarint import ScalarInt @@ -8,6 +9,44 @@ from posit_bakery.config.shared import BakeryYAMLModel +_STRIP_PATCH_RE = re.compile(r"(\d+\.\d+)(?:\.\d+)+") +_VERSION_SUBSTRING_RE = re.compile(r"\d+(?:\.\d+)+") + + +def strip_patch(s: str) -> str: + """Collapse dotted numeric runs in a string to their first two segments. + + Any ``\\d+(\\.\\d+)+`` substring with three or more components reduces to + ``MAJOR.MINOR``; 4+ component versions (e.g. ``1.2.3.4``) collapse the + same way, not partially (which the old ``(\\d+\\.\\d+)\\.\\d+`` regex did, + producing ``1.2.4``). + + Shared between the ``stripPatch`` Jinja filter and matrix latest-patch + grouping so the two stay consistent — anything that would render the + same after the filter must land in the same group, otherwise rows + collide on the rendered tag. + """ + return _STRIP_PATCH_RE.sub(r"\1", s) + + +def extract_versions(s: str) -> tuple["DependencyVersion", ...]: + """Return every ``\\d+(\\.\\d+)+`` substring in ``s`` parsed as a + ``DependencyVersion``. + + Returning a tuple (rather than the first match) is load-bearing: it lets + values with several version segments (e.g. ``"go1.24-lib2.3.1"``) sort on + the later segment when the earlier one ties. Unparseable substrings are + skipped; the empty tuple means no version was found. + """ + parsed: list[DependencyVersion] = [] + for match in _VERSION_SUBSTRING_RE.finditer(s): + try: + parsed.append(DependencyVersion(match.group())) + except InvalidVersion: + continue + return tuple(parsed) + + class DependencyVersion(Version): """A version class for dependencies that extends packaging's Version. diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 46462085..3bb315b6 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -19,7 +19,7 @@ ) from packaging.version import InvalidVersion -from posit_bakery.config.dependencies.version import DependencyVersion +from posit_bakery.config.dependencies.version import DependencyVersion, extract_versions, strip_patch from posit_bakery.config.image.build_os import TargetPlatform, DEFAULT_PLATFORMS from posit_bakery.config.registry import BaseRegistry, Registry from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel @@ -742,8 +742,12 @@ def to_image_versions(self) -> list[ImageVersion]: resolved_deps = self.resolved_dependencies latest_pick = self._compute_latest_combination(resolved_deps) products = self._cartesian_product(resolved_deps, self.values) + latest_patch_signatures = self._compute_latest_patch_signatures(products) for product in products: is_latest = latest_pick is not None and self._matches_latest(product, latest_pick) + is_latest_patch = ( + latest_patch_signatures is not None and self._row_signature(product) in latest_patch_signatures + ) image_version = ImageVersion( parent=self.parent, name=self._render_name_pattern(self.namePattern, product["dependencies"], product["values"]), @@ -755,6 +759,7 @@ def to_image_versions(self) -> list[ImageVersion]: values=product["values"], isMatrixVersion=True, latest=is_latest, + isLatestPatchCombination=is_latest_patch, buildTarget=self.buildTarget, ) image_versions.append(image_version) @@ -779,3 +784,119 @@ def _matches_latest(product: dict[str, list | dict], latest_pick: dict[str, str] if axis_key in latest_pick and str(value) != latest_pick[axis_key]: return False return True + + @staticmethod + def _row_signature(product: dict[str, list | dict]) -> tuple: + """Hashable signature for a cartesian-product row, used for set membership checks.""" + dep_parts = tuple(sorted((d.dependency, d.versions[0]) for d in product["dependencies"])) + value_parts = tuple(sorted((k, str(v)) for k, v in product["values"].items())) + return dep_parts, value_parts + + def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict]]) -> set[tuple] | None: + """Identify cartesian-product rows that are the latest patch for their stripped group. + + Validate all dependency versions upfront, then group every row by the result of + applying :func:`strip_patch` to each of its axis values. Within each group, the + row whose ``_patch_sort_key`` ranks highest is the "latest patch" row. + + Grouping by the stripped form keeps the selection consistent with what + ``stripPatch`` would render: any two rows that would produce the same stripped + tag share a group, so only one of them is flagged and no ``LATEST_PATCH`` + targets collide on push. + + :param products: All cartesian-product rows. + + :return: A set of row signatures (see :pymeth:`_row_signature`) identifying + latest-patch rows. Returns ``None`` if any dependency version is unparseable; + in that case no ``LATEST_PATCH``-family tags are emitted for the matrix. + """ + if not products: + return set() + + # Validate dependency versions in a single explicit pass so the rest of the + # function can treat them as parseable. Mirrors `_compute_latest_combination`'s + # behaviour: bad dep input aborts emission of the family, and an unexpected + # internal error from the version constructor is caught and treated the same way + # rather than killing the whole build. + for product in products: + for dep in product["dependencies"]: + try: + DependencyVersion(dep.versions[0]) + except InvalidVersion as e: + log.warning( + f"Image matrix '{self.namePattern}': cannot determine latest patch combinations " + f"because dependency '{dep.dependency}' version '{dep.versions[0]}' is unparseable " + f"({e}). No 'latestPatch'-family tags will be emitted for this matrix." + ) + return None + except Exception as e: + log.warning( + f"Image matrix '{self.namePattern}': cannot determine latest patch combinations " + f"because dependency '{dep.dependency}' raised an unexpected error processing " + f"'{dep.versions[0]}' ({type(e).__name__}: {e}). " + f"No 'latestPatch'-family tags will be emitted for this matrix." + ) + return None + + groups: dict[tuple, list[dict[str, list | dict]]] = {} + for product in products: + groups.setdefault(self._minor_group_key(product), []).append(product) + + latest_signatures: set[tuple] = set() + for group_rows in groups.values(): + try: + max_row = max(group_rows, key=self._patch_sort_key) + except Exception as e: + # _patch_sort_key parses value substrings as ``DependencyVersion``, + # which the dep pre-validation pass above doesn't cover. An + # unexpected error there shouldn't kill the build — drop + # latestPatch tags for the matrix instead. + log.warning( + f"Image matrix '{self.namePattern}': cannot determine latest patch combinations " + f"because computing the patch sort key raised an unexpected error " + f"({type(e).__name__}: {e}). " + f"No 'latestPatch'-family tags will be emitted for this matrix." + ) + return None + latest_signatures.add(self._row_signature(max_row)) + return latest_signatures + + @staticmethod + def _minor_group_key(product: dict[str, list | dict]) -> tuple: + """Group key derived from :func:`strip_patch` applied to every axis value. + + Two rows that would render to the same stripped tag share the same group key, + regardless of whether the value is a plain version (``3.12.3``), a prefixed + version (``go1.24.3``), or a non-version label (``alpha``). + + Pure function — assumes dependency versions are already validated by the + caller; this method does not raise. + """ + dep_parts = tuple( + (dep.dependency, strip_patch(dep.versions[0])) + for dep in sorted(product["dependencies"], key=lambda d: d.dependency) + ) + value_parts = tuple((k, strip_patch(str(val))) for k, val in sorted(product["values"].items())) + return dep_parts, value_parts + + @staticmethod + def _patch_sort_key(product: dict[str, list | dict]) -> tuple: + """Sort key for finding the highest-patch row within a group. + + Extract *every* numeric ``MAJOR.MINOR[.PATCH...]`` substring from each value + and use the tuple of parsed versions as the sort key, so multi-version strings + (e.g. ``"go1.24-lib2.3.1"`` vs ``"go1.24-lib2.3.2"``) cascade comparison to + the later segment that actually differs. + + Within a group all rows share the same stripped form, which forces the same + set of version-bearing positions, so every row produces the same shape of + tuple (empty when no version is present and the group has a single row, or + non-empty in lockstep otherwise). That invariant prevents mixed-type + comparisons during ``max()``. + """ + sort_keys = [] + for dep in sorted(product["dependencies"], key=lambda d: d.dependency): + sort_keys.append(DependencyVersion(dep.versions[0])) + for k, val in sorted(product["values"].items()): + sort_keys.append(extract_versions(str(val))) + return tuple(sort_keys) diff --git a/posit-bakery/posit_bakery/config/image/version.py b/posit-bakery/posit_bakery/config/image/version.py index f5f09ae2..56772327 100644 --- a/posit-bakery/posit_bakery/config/image/version.py +++ b/posit-bakery/posit_bakery/config/image/version.py @@ -85,6 +85,15 @@ class ImageVersion(BakeryPathMixin, BakeryYAMLModel): description="Flag to indicate if this is a matrix version.", ), ] + isLatestPatchCombination: Annotated[ + bool, + Field( + exclude=True, + default=False, + description="Flag set on matrix versions whose dependency versions are the latest patch for their " + "(major.minor, ...) group. Used to gate ``LATEST_PATCH``-filtered tag patterns.", + ), + ] os: Annotated[ list[ImageVersionOS], Field( diff --git a/posit-bakery/posit_bakery/config/tag.py b/posit-bakery/posit_bakery/config/tag.py index bfca8803..a750acee 100644 --- a/posit-bakery/posit_bakery/config/tag.py +++ b/posit-bakery/posit_bakery/config/tag.py @@ -15,6 +15,7 @@ class TagPatternFilter(str, Enum): ALL = "all" # Matches all image targets. LATEST = "latest" # Matches the image targets at the latest image version. + LATEST_PATCH = "latestPatch" # Matches matrix rows that are the latest patch for their minor combination. PRIMARY_OS = "primaryOS" # Matches image targets using the primary OS. PRIMARY_VARIANT = "primaryVariant" # Matches image targets of the primary variant. @@ -153,6 +154,10 @@ def default_matrix_tag_patterns() -> list[TagPattern]: hyphen onward. This set excludes stripMetadata patterns to avoid tag collisions across matrix combinations. + The ``stripPatch`` variants emit additional minor-only tags (e.g., "R4.3-python3.11") + for rows that represent the latest patch in their (minor, ...) group, gated by the + ``LATEST_PATCH`` filter so non-latest rows do not collide on the stripped tag. + :return: A list of TagPattern objects representing the default matrix tag patterns. """ return [ @@ -160,17 +165,33 @@ def default_matrix_tag_patterns() -> list[TagPattern]: patterns=["{{ Version }}-{{ OS }}-{{ Variant }}"], only=[TagPatternFilter.ALL], ), + TagPattern( + patterns=["{{ Version | stripPatch }}-{{ OS }}-{{ Variant }}"], + only=[TagPatternFilter.LATEST_PATCH], + ), TagPattern( patterns=["{{ Version }}-{{ Variant }}"], only=[TagPatternFilter.PRIMARY_OS], ), + TagPattern( + patterns=["{{ Version | stripPatch }}-{{ Variant }}"], + only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_OS], + ), TagPattern( patterns=["{{ Version }}-{{ OS }}"], only=[TagPatternFilter.PRIMARY_VARIANT], ), + TagPattern( + patterns=["{{ Version | stripPatch }}-{{ OS }}"], + only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_VARIANT], + ), TagPattern( patterns=["{{ Version }}"], only=[TagPatternFilter.PRIMARY_OS, TagPatternFilter.PRIMARY_VARIANT], ), + TagPattern( + patterns=["{{ Version | stripPatch }}"], + only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_OS, TagPatternFilter.PRIMARY_VARIANT], + ), *_shared_latest_tag_patterns(), ] diff --git a/posit-bakery/posit_bakery/config/templating/render.py b/posit-bakery/posit_bakery/config/templating/render.py index a7ffdb23..c4d75451 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -2,6 +2,7 @@ import jinja2 +from posit_bakery.config.dependencies.version import strip_patch from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN from posit_bakery.error import BakeryTemplateError @@ -25,6 +26,7 @@ def jinja2_env(**kwargs) -> jinja2.Environment: env = jinja2.Environment(**kwargs) env.filters["tagSafe"] = lambda s: re.sub(REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", s).strip("-._") env.filters["stripMetadata"] = lambda s: re.sub(r"[+-](?=[^+-]*$).*", "", s) + env.filters["stripPatch"] = strip_patch env.filters["condense"] = lambda s: re.sub(r"[ .-]", "", s) env.filters["regexReplace"] = lambda s, find, replace: re.sub(find, replace, s) env.filters["quote"] = lambda s: '"' + s + '"' diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index 04264f07..6e0d74bb 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -303,6 +303,11 @@ def is_latest(self) -> bool: """Check if the image version is marked as latest.""" return self.image_version.latest + @property + def is_latest_patch_combination(self) -> bool: + """Check if the image version is the latest patch for its matrix (minor, ...) group.""" + return self.image_version.isLatestPatchCombination + @property def is_primary_os(self) -> bool: """Check if the image OS is marked as primary.""" @@ -391,6 +396,9 @@ def tag_patterns(self) -> list[TagPattern]: # Skip pattern marked as latest if not latest version. if TagPatternFilter.LATEST in tag_pattern.only and not self.is_latest: continue + # Skip pattern for latest patch if this row is not the latest patch in its group. + if TagPatternFilter.LATEST_PATCH in tag_pattern.only and not self.is_latest_patch_combination: + continue # Skip pattern for primary OS if not primary OS. if TagPatternFilter.PRIMARY_OS in tag_pattern.only and not self.is_primary_os: continue diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 7b2b1e79..c1679b73 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -519,6 +519,275 @@ def test_to_image_versions_no_latest_when_combination_is_none(self): assert len(image_versions) == 4 # 2 python * 2 flavor assert not any(iv.latest for iv in image_versions) + def test_to_image_versions_marks_all_latest_patch_when_minors_unique(self): + """When every row's (minor, ...) tuple is unique, every row is a latest-patch combination.""" + matrix = ImageMatrix( + dependencies=[ + PythonDependencyVersions(dependency="python", versions=["3.11.15", "3.12.3"]), + RDependencyVersions(dependency="R", versions=["4.3.3", "4.4.1"]), + ], + ) + + image_versions = matrix.to_image_versions() + assert len(image_versions) == 4 + # Each (python_minor, R_minor) pair appears once → all are latest-patch. + assert all(iv.isLatestPatchCombination for iv in image_versions) + + def test_to_image_versions_picks_highest_patch_per_minor_group(self): + """Within a (minor, ...) group, only the highest-patch row is the latest-patch combination.""" + matrix = ImageMatrix( + dependencies=[ + PythonDependencyVersions(dependency="python", versions=["3.12.1", "3.12.3", "3.12.5"]), + RDependencyVersions(dependency="R", versions=["4.3.3"]), + ], + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # One python minor group (3.12), one R minor group (4.3) → one latest-patch row. + assert len(latest_patch_versions) == 1 + latest = latest_patch_versions[0] + dep_versions = {dep.dependency: dep.versions[0] for dep in latest.dependencies} + assert dep_versions == {"python": "3.12.5", "R": "4.3.3"} + + def test_to_image_versions_latest_patch_per_minor_combination(self): + """Each unique (minor, minor) pair gets its own latest-patch row.""" + matrix = ImageMatrix( + dependencies=[ + PythonDependencyVersions(dependency="python", versions=["3.11.10", "3.11.15", "3.12.3", "3.12.5"]), + RDependencyVersions(dependency="R", versions=["4.3.2", "4.3.3", "4.4.1"]), + ], + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # (3.11, 4.3), (3.11, 4.4), (3.12, 4.3), (3.12, 4.4) → 4 latest-patch rows. + assert len(latest_patch_versions) == 4 + + def deps_dict(iv): + return {dep.dependency: dep.versions[0] for dep in iv.dependencies} + + latest_pairs = {(deps_dict(iv)["python"], deps_dict(iv)["R"]) for iv in latest_patch_versions} + assert latest_pairs == { + ("3.11.15", "4.3.3"), + ("3.11.15", "4.4.1"), + ("3.12.5", "4.3.3"), + ("3.12.5", "4.4.1"), + } + + def test_to_image_versions_latest_patch_per_list_value(self): + """List-typed values partition the latest-patch grouping; each value gets its own latest-patch row.""" + matrix = ImageMatrix( + dependencies=[ + PythonDependencyVersions(dependency="python", versions=["3.12.3", "3.12.5"]), + ], + values={"flavor": ["alpha", "beta"]}, + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # (3.12, alpha) and (3.12, beta) → 2 latest-patch rows, both using 3.12.5. + assert len(latest_patch_versions) == 2 + for iv in latest_patch_versions: + dep_versions = {dep.dependency: dep.versions[0] for dep in iv.dependencies} + assert dep_versions == {"python": "3.12.5"} + flavors = {iv.values["flavor"] for iv in latest_patch_versions} + assert flavors == {"alpha", "beta"} + + def test_to_image_versions_unparseable_dependency_skips_latest_patch(self, caplog): + """An unparseable dependency version means no row gets the latest-patch flag.""" + matrix = ImageMatrix.model_validate( + { + "dependencies": [ + {"dependency": "python", "versions": ["3.12.3", "not-a-version"]}, + ], + } + ) + caplog.clear() + with caplog.at_level("WARNING"): + image_versions = matrix.to_image_versions() + + assert len(image_versions) == 2 + assert not any(iv.isLatestPatchCombination for iv in image_versions) + warnings = [r for r in caplog.records if r.levelname == "WARNING" and "latestPatch" in r.message] + assert len(warnings) >= 1 + + def test_to_image_versions_non_parsing_exception_skips_latest_patch(self, caplog, mocker): + """An unexpected exception from DependencyVersion is caught and skips latest-patch emission.""" + matrix = ImageMatrix( + dependencies=[PythonDependencyVersions(dependency="python", versions=["3.12.3"])], + ) + mocker.patch( + "posit_bakery.config.image.matrix.DependencyVersion", + side_effect=RuntimeError("disk on fire"), + ) + caplog.clear() + with caplog.at_level("WARNING"): + image_versions = matrix.to_image_versions() + + assert len(image_versions) == 1 + assert not any(iv.isLatestPatchCombination for iv in image_versions) + warnings = [r for r in caplog.records if r.levelname == "WARNING" and "latestPatch" in r.message] + assert len(warnings) == 1, f"Expected exactly one warning, got: {[r.message for r in warnings]}" + message = warnings[0].message + # Should not falsely claim the version is unparseable. + assert "unparseable" not in message.lower() + # Should still surface the dependency, candidate, and underlying error. + assert "python" in message + assert "3.12.3" in message + assert "disk on fire" in message + + def test_to_image_versions_values_only_non_parsing_exception_skips_latest_patch(self, caplog, mocker): + """A non-InvalidVersion exception on the value-axis path is also caught. + + Dependency pre-validation in ``_compute_latest_patch_signatures`` doesn't cover + values, so the safety net lives at the ``max()`` call site. Patches + ``DependencyVersion`` at the source module — that's the one ``extract_versions`` + imports — and uses a values-only matrix so the only ``DependencyVersion`` + call path is through ``extract_versions``. Regression for the asymmetric + hedge that previously only protected the dependency axis. + """ + matrix = ImageMatrix( + values={"build": ["1.24.1", "1.24.2"]}, + ) + mocker.patch( + "posit_bakery.config.dependencies.version.DependencyVersion", + side_effect=RuntimeError("disk on fire"), + ) + caplog.clear() + with caplog.at_level("WARNING"): + image_versions = matrix.to_image_versions() + + assert len(image_versions) == 2 + assert not any(iv.isLatestPatchCombination for iv in image_versions) + warnings = [r for r in caplog.records if r.levelname == "WARNING" and "latestPatch" in r.message] + assert len(warnings) == 1, f"Expected exactly one warning, got: {[r.message for r in warnings]}" + message = warnings[0].message + assert "unparseable" not in message.lower() + assert "disk on fire" in message + + def test_to_image_versions_no_dependencies_marks_all_latest_patch(self): + """A matrix with only ``values`` axes has no patch versions to compete on; every row qualifies.""" + matrix = ImageMatrix( + values={"go_version": ["1.24", "1.25"]}, + ) + + image_versions = matrix.to_image_versions() + assert len(image_versions) == 2 + # No dependency versions means each (no-deps, value) group has one row → all latest-patch. + assert all(iv.isLatestPatchCombination for iv in image_versions) + + def test_to_image_versions_values_only_groups_by_minor(self): + """Version-like list values compete on patch within their minor — matching ``stripPatch``. + + Without this, two rows with values like ``1.24.1`` and ``1.24.2`` would both be flagged + latest-patch but render the same stripped tag (``1.24``), colliding on push. + """ + matrix = ImageMatrix( + values={"go_version": ["1.24.1", "1.24.2", "1.25.0"]}, + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # (1.24, *) collapses to one row — 1.24.2; (1.25, *) is its own group — 1.25.0. + assert len(latest_patch_versions) == 2 + latest_values = {iv.values["go_version"] for iv in latest_patch_versions} + assert latest_values == {"1.24.2", "1.25.0"} + + def test_to_image_versions_values_only_non_version_keeps_all(self): + """Values without ``MAJOR.MINOR.PATCH`` substrings are untouched by ``stripPatch`` and + therefore land in distinct groups, leaving each row eligible as latest-patch. + """ + matrix = ImageMatrix( + values={"flavor": ["alpha", "beta"]}, + ) + + image_versions = matrix.to_image_versions() + assert len(image_versions) == 2 + # "alpha" and "beta" have no patch component for stripPatch to collapse, + # so the stripped group key for each is its own raw string → both latest-patch. + assert all(iv.isLatestPatchCombination for iv in image_versions) + + def test_to_image_versions_metadata_bearing_dep_grouped_by_stripped_minor(self): + """Pre-release / build metadata on dep versions survives ``stripPatch`` intact. + + Constraint resolution today never emits metadata-bearing strings (clean patches win + in ``_filter_minor``), but an explicit ``dependencies:`` list can carry e.g. + ``3.12.3-rc1``. Pin that the patch numeric is the only thing collapsed, so prerelease + rows group with their own siblings rather than with clean-version rows. Guards + against regex widenings that would eat the trailing ``-rc1``. + """ + matrix = ImageMatrix( + dependencies=[ + PythonDependencyVersions( + dependency="python", + versions=["3.12.3-rc1", "3.12.5-rc1", "3.12.5", "3.13.0"], + ), + ], + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + + # Three stripped groups: "python3.12-rc1" → 3.12.5-rc1 wins; + # "python3.12" → 3.12.5; "python3.13" → 3.13.0. + assert len(latest_patch_versions) == 3 + assert {iv.name for iv in latest_patch_versions} == { + "python3.12.5-rc1", + "python3.12.5", + "python3.13.0", + } + + def test_to_image_versions_multi_version_value_sorts_on_later_segment(self): + """Values with multiple version-like segments must sort on the later segment when the + earlier one ties, otherwise ``max()`` picks an arbitrary row and the ``LATEST_PATCH`` + tag can end up pointing at an older patch. Regression test for the single-segment + sort key. + """ + matrix = ImageMatrix( + values={"build": ["go1.24-lib2.3.1", "go1.24-lib2.3.2", "go1.24-lib2.3.5"]}, + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # All three strip to ``go1.24-lib2.3`` → one group, only the highest lib patch wins. + assert len(latest_patch_versions) == 1 + assert latest_patch_versions[0].values["build"] == "go1.24-lib2.3.5" + + def test_to_image_versions_values_only_groups_four_segment_versions(self): + """Four-segment versions must collapse fully to ``MAJOR.MINOR`` for grouping, then the + highest-version row in the group wins. Regression for the old regex which collapsed + ``1.2.3.4`` to ``1.2.4`` and split rows that share a minor into separate groups. + """ + matrix = ImageMatrix( + values={"build": ["1.2.3.4", "1.2.3.5", "1.2.5.0", "1.3.0.0"]}, + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # All three 1.2.* rows share the ``1.2`` group; 1.3.0.0 is its own group. + # Within ``1.2``, 1.2.5.0 outranks the 1.2.3.* rows. + assert len(latest_patch_versions) == 2 + latest_values = {iv.values["build"] for iv in latest_patch_versions} + assert latest_values == {"1.2.5.0", "1.3.0.0"} + + def test_to_image_versions_values_only_groups_prefixed_versions(self): + """Prefixed version strings (e.g. ``go1.24.1``) that ``stripPatch`` collapses to the + same form must land in the same group, even though they don't parse as a standalone + ``DependencyVersion``. Otherwise the two rows would both be flagged latest-patch and + emit the same stripped tag, racing on push. + """ + matrix = ImageMatrix( + values={"build": ["go1.24.1", "go1.24.2", "go1.25.0"]}, + ) + + image_versions = matrix.to_image_versions() + latest_patch_versions = [iv for iv in image_versions if iv.isLatestPatchCombination] + # (go1.24, *) collapses to one row — go1.24.2; (go1.25, *) is its own group — go1.25.0. + assert len(latest_patch_versions) == 2 + latest_values = {iv.values["build"] for iv in latest_patch_versions} + assert latest_values == {"go1.24.2", "go1.25.0"} + def test_check_duplicate_dependency_constraints(self): """Test that duplicate dependency constraints raise error.""" with pytest.raises( @@ -721,6 +990,50 @@ def test_latest_matrix_target_emits_latest_tag(self, patch_requests_get): # Existing matrix tag still emitted. assert "python3.12.3" in suffixes + def test_latest_patch_matrix_target_emits_minor_tag(self, patch_requests_get): + """End-to-end: a non-latest row that IS latest-patch emits the stripped minor tag. + + Pins the full chain isLatestPatchCombination → filter retains LATEST_PATCH + patterns → tag renders. Picks 3.11.10 (highest patch in the 3.11 minor group) + rather than 3.12.3 (overall latest) so the assertion isn't satisfied accidentally + by the LATEST-filtered patterns. + """ + image = Image( + name="content", + matrix={ + "namePattern": "python{{ Dependencies.python }}", + "dependencies": [ + {"dependency": "python", "versions": ["3.11.5", "3.11.10", "3.12.3"]}, + ], + "os": [ + {"name": "Ubuntu 24.04", "primary": True}, + ], + }, + ) + + mock_config_parent = MagicMock(spec=BakeryConfigDocument) + mock_config_parent.path = Path("/tmp/path") + mock_config_parent.registries = [] + image.parent = mock_config_parent + + repo = Repository(url="https://example.com/repo", vendor="Example", maintainer="dev ") + image_versions = image.matrix.to_image_versions() + latest_patch_iv = next( + iv + for iv in image_versions + if iv.isLatestPatchCombination and not iv.latest and iv.dependencies[0].versions[0] == "3.11.10" + ) + primary_os = latest_patch_iv.os[0] + + target = ImageTarget.new_image_target( + repository=repo, + image_version=latest_patch_iv, + image_variant=None, + image_os=primary_os, + ) + + assert "python3.11" in set(target.tag_suffixes) + def test_render_files_preserves_template_file_mode(self, tmp_path): """Test that matrix render_files propagates the template file mode to rendered output.""" image_dir = tmp_path / "test-image" diff --git a/posit-bakery/test/config/templating/test_render.py b/posit-bakery/test/config/templating/test_render.py index 72af1563..7c8a0ceb 100644 --- a/posit-bakery/test/config/templating/test_render.py +++ b/posit-bakery/test/config/templating/test_render.py @@ -16,6 +16,7 @@ def test_jinja2_env_creates_environment(self): assert isinstance(env, jinja2.Environment) assert "tagSafe" in env.filters.keys() assert "stripMetadata" in env.filters.keys() + assert "stripPatch" in env.filters.keys() assert "condense" in env.filters.keys() assert "regexReplace" in env.filters.keys() @@ -38,6 +39,23 @@ def test_stripMetadata_filter(self): assert env.from_string("{{ '2025.04.1-8' | stripMetadata }}").render() == "2025.04.1" assert env.from_string("{{ '2025.05.0' | stripMetadata }}").render() == "2025.05.0" + def test_stripPatch_filter(self): + """Test the stripPatch filter — drops the patch component from MAJOR.MINOR.PATCH groups.""" + env = jinja2_env() + # Simple 3-component versions reduce to MAJOR.MINOR. + assert env.from_string("{{ '3.12.3' | stripPatch }}").render() == "3.12" + assert env.from_string("{{ '4.3.3' | stripPatch }}").render() == "4.3" + # Composite matrix names — both version segments get stripped. + assert env.from_string("{{ 'python3.12.3-R4.3.3' | stripPatch }}").render() == "python3.12-R4.3" + assert env.from_string("{{ 'R4.3.3-python3.11.15' | stripPatch }}").render() == "R4.3-python3.11" + # 2-component versions are untouched (no patch to strip). + assert env.from_string("{{ '2026.04' | stripPatch }}").render() == "2026.04" + # 4+ component versions collapse fully to MAJOR.MINOR, not partially. + assert env.from_string("{{ '1.2.3.4' | stripPatch }}").render() == "1.2" + assert env.from_string("{{ '1.2.3.4.5' | stripPatch }}").render() == "1.2" + # Strings without version-like sequences are untouched. + assert env.from_string("{{ 'standard-min' | stripPatch }}").render() == "standard-min" + def test_condense_filter(self): """Test the condense filter.""" env = jinja2_env() diff --git a/posit-bakery/test/config/test_tag.py b/posit-bakery/test/config/test_tag.py index eba6aa16..e8677c46 100644 --- a/posit-bakery/test/config/test_tag.py +++ b/posit-bakery/test/config/test_tag.py @@ -119,11 +119,14 @@ def test_default_matrix_tag_patterns_no_tag_collisions(): Checks that different versions within the same OS produce unique tags. Cross-OS overlap (e.g., the PRIMARY_OS pattern producing "R4.3.3-python3.11.15" for both OSes) is expected - and handled by tag pattern filters at the ImageTarget level. LATEST-filtered patterns - are similarly filter-handled (only applied to the latest combination), so they are - excluded here. + and handled by tag pattern filters at the ImageTarget level. LATEST- and LATEST_PATCH-filtered + patterns are similarly filter-handled (only applied to specific rows), so they are excluded here. """ - patterns = [p for p in default_matrix_tag_patterns() if TagPatternFilter.LATEST not in p.only] + patterns = [ + p + for p in default_matrix_tag_patterns() + if TagPatternFilter.LATEST not in p.only and TagPatternFilter.LATEST_PATCH not in p.only + ] versions = ["R4.3.3-python3.11.15", "R4.3.3-python3.12.13", "R4.3.3-python3.13.12"] os_values = ["ubuntu2404", "ubuntu2204"] @@ -141,3 +144,35 @@ def test_default_matrix_tag_patterns_no_tag_collisions(): for v2 in version_list[i + 1 :]: overlap = tags_by_version[v1] & tags_by_version[v2] assert not overlap, f"Tag collision for OS {os} between {v1} and {v2}: {overlap}" + + +def test_default_matrix_tag_patterns_includes_strip_patch_under_latest_patch_filter(): + """Matrix tag patterns include stripPatch variants gated by LATEST_PATCH.""" + patterns = default_matrix_tag_patterns() + strip_patch_patterns = [p for p in patterns if any("stripPatch" in pat for pat in p.patterns)] + # One per OS/Variant combination: ALL-equivalent, PRIMARY_OS, PRIMARY_VARIANT, both-primary. + assert len(strip_patch_patterns) == 4 + for pattern in strip_patch_patterns: + assert TagPatternFilter.LATEST_PATCH in pattern.only, ( + f"stripPatch pattern must be gated by LATEST_PATCH: {pattern.patterns} only={pattern.only}" + ) + + +def test_default_matrix_tag_patterns_strip_patch_renders_minor_tags(): + """stripPatch-filtered tag patterns produce minor-only tags from composite matrix versions.""" + patterns = [p for p in default_matrix_tag_patterns() if TagPatternFilter.LATEST_PATCH in p.only] + version = "R4.3.3-python3.11.15" + os = "ubuntu2404" + variant = "min" + + rendered_tags = [] + for pattern in patterns: + rendered_tags.extend(pattern.render(Version=version, OS=os, Variant=variant)) + + expected = { + f"R4.3-python3.11-{os}-{variant}", + f"R4.3-python3.11-{variant}", + f"R4.3-python3.11-{os}", + "R4.3-python3.11", + } + assert expected.issubset(set(rendered_tags)) diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index c7ca026b..a2363d6b 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -150,6 +150,14 @@ def test_is_latest(self, basic_standard_image_target): assert not basic_standard_image_target.is_latest + def test_is_latest_patch_combination(self, basic_standard_image_target): + """Test the is_latest_patch_combination property of an ImageTarget.""" + # Default is False — non-matrix image versions don't get the flag. + assert not basic_standard_image_target.is_latest_patch_combination + + basic_standard_image_target.image_version.isLatestPatchCombination = True + assert basic_standard_image_target.is_latest_patch_combination + def test_is_primary_os(self, basic_standard_image_target): """Test the is_primary_os property of an ImageTarget.""" assert basic_standard_image_target.is_primary_os @@ -351,6 +359,39 @@ def test_tag_patterns_filtering(self, get_config_obj): assert len(target.tag_patterns) == 6 assert not any(TagPatternFilter.PRIMARY_OS in pattern.only for pattern in target.tag_patterns) + def test_tag_patterns_filters_latest_patch(self, get_config_obj): + """LATEST_PATCH-filtered patterns are dropped unless the version is the latest patch.""" + from posit_bakery.config.tag import default_matrix_tag_patterns + + basic_config_obj = get_config_obj("basic") + image = basic_config_obj.model.get_image("test-image") + version = image.get_version("1.0.0") + variant = image.get_variant("Standard") + os = version.os[0] + + # Use matrix patterns so LATEST_PATCH-filtered entries are present. + image.tagPatterns = default_matrix_tag_patterns() + + # When not a latest-patch row, the LATEST_PATCH patterns are filtered out. + version.isLatestPatchCombination = False + target = ImageTarget.new_image_target( + repository=basic_config_obj.model.repository, + image_version=version, + image_variant=variant, + image_os=os, + ) + assert not any(TagPatternFilter.LATEST_PATCH in p.only for p in target.tag_patterns) + + # When marked as the latest patch row, LATEST_PATCH patterns are retained. + version.isLatestPatchCombination = True + target = ImageTarget.new_image_target( + repository=basic_config_obj.model.repository, + image_version=version, + image_variant=variant, + image_os=os, + ) + assert any(TagPatternFilter.LATEST_PATCH in p.only for p in target.tag_patterns) + @pytest.mark.parametrize( "target_name,expected_tag_suffixes", [