From af25c59f31184d36b149c1812de4a176126bb9cf Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 19 May 2026 16:02:06 -0500 Subject: [PATCH 01/12] Add minor-stripped tags for matrix latest-patch rows Matrix builds emit composite version tags like ``R4.3.3-python3.12.13``. To make it easy to pull "the latest patch for this minor combination," tag the latest-patch row in each ``(major.minor, ...)`` group with an additional minor-only tag like ``R4.3-python3.12``. - Add a ``stripPatch`` Jinja2 filter that drops the patch component from ``MAJOR.MINOR.PATCH`` groups in a composite tag string. - Add ``LATEST_PATCH`` to ``TagPatternFilter`` and gate the new ``stripPatch`` defaults on it so non-latest patches do not collide on the stripped tag. - Compute the latest-patch combinations in ``ImageMatrix`` by grouping cartesian rows by their dependency ``(major, minor)`` keys and the value-axis entries, then selecting the row with the highest versions in each group. - Expose the result on ``ImageVersion.isLatestPatchCombination`` and filter the new patterns out at ``ImageTarget.tag_patterns`` when the row is not the latest patch in its group. --- .../posit_bakery/config/image/matrix.py | 75 +++++++++++++ .../posit_bakery/config/image/version.py | 9 ++ posit-bakery/posit_bakery/config/tag.py | 21 ++++ .../posit_bakery/config/templating/render.py | 1 + .../posit_bakery/image/image_target.py | 8 ++ posit-bakery/test/config/image/test_matrix.py | 104 ++++++++++++++++++ .../test/config/templating/test_render.py | 15 +++ posit-bakery/test/config/test_tag.py | 43 +++++++- posit-bakery/test/image/test_image_target.py | 41 +++++++ 9 files changed, 313 insertions(+), 4 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 46462085..a89a3746 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -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,73 @@ 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 (minor, ...) group. + + Groups rows by (axis_key, (major, minor)) for each dependency axis and by (key, value) + for each ``values`` axis. Within each group, the row whose dependency versions are + highest is the "latest patch" row. + + :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() + + groups: dict[tuple, list[dict[str, list | dict]]] = {} + for product in products: + try: + group_key = self._minor_group_key(product) + except InvalidVersion as e: + log.warning( + f"Image matrix '{self.namePattern}': cannot determine latest patch combinations because a " + f"dependency version is unparseable ({e}). " + f"No 'latestPatch'-family tags will be emitted for this matrix." + ) + return None + groups.setdefault(group_key, []).append(product) + + try: + latest_signatures: set[tuple] = set() + for group_rows in groups.values(): + max_row = max(group_rows, key=self._patch_sort_key) + latest_signatures.add(self._row_signature(max_row)) + return latest_signatures + except InvalidVersion as e: + log.warning( + f"Image matrix '{self.namePattern}': cannot determine latest patch combinations because a " + f"dependency version is unparseable ({e}). " + f"No 'latestPatch'-family tags will be emitted for this matrix." + ) + return None + + @staticmethod + def _minor_group_key(product: dict[str, list | dict]) -> tuple: + """Group key for latest-patch logic: (dep, (major, minor)) per dep + (key, value) per value. + + Scalar values are constant across all rows so do not differentiate groups, but including + them in the key is harmless. ``InvalidVersion`` propagates to the caller. + """ + dep_parts = [] + for dep in sorted(product["dependencies"], key=lambda d: d.dependency): + v = DependencyVersion(dep.versions[0]) + dep_parts.append((dep.dependency, (v.major, v.minor))) + value_parts = sorted((k, str(v)) for k, v in product["values"].items()) + return tuple(dep_parts), tuple(value_parts) + + @staticmethod + def _patch_sort_key(product: dict[str, list | dict]) -> tuple: + """Sort key for finding the row with the maximum patch versions within a group.""" + deps = sorted(product["dependencies"], key=lambda d: d.dependency) + return tuple(DependencyVersion(dep.versions[0]) for dep in deps) 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..2cc4e749 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -25,6 +25,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"] = lambda s: re.sub(r"(\d+\.\d+)\.\d+", r"\1", s) 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..2a0c988a 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -519,6 +519,110 @@ 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_latest_patch_combinations_all_unique_minors(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_marks_only_highest_patch_when_multiple_patches_share_minor(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_groups_by_list_value_axis(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_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_check_duplicate_dependency_constraints(self): """Test that duplicate dependency constraints raise error.""" with pytest.raises( diff --git a/posit-bakery/test/config/templating/test_render.py b/posit-bakery/test/config/templating/test_render.py index 72af1563..9fdcad33 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,20 @@ 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" + # 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", [ From 9fd6f58aafa824007b6fc8213bb06b811db853bd Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 19 May 2026 16:09:08 -0500 Subject: [PATCH 02/12] Group latest-patch by minor for version-like values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values-only matrices with version-like list values (e.g. ``go_version: [1.24.1, 1.24.2]``) flagged every row as the latest patch because grouping was on the raw string. ``stripPatch`` then collapsed both rows to ``go1.24``, producing colliding tags. Group value axes by ``(major, minor)`` when the value parses as a version with a minor component — matching what ``stripPatch`` collapses — and include them in the patch sort key so the highest-patch row wins. Non-version values still fall back to raw strings. --- .../posit_bakery/config/image/matrix.py | 48 ++++++++++++++++--- posit-bakery/test/config/image/test_matrix.py | 28 +++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index a89a3746..15337f34 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -837,20 +837,54 @@ def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict] @staticmethod def _minor_group_key(product: dict[str, list | dict]) -> tuple: - """Group key for latest-patch logic: (dep, (major, minor)) per dep + (key, value) per value. + """Group key for latest-patch logic. - Scalar values are constant across all rows so do not differentiate groups, but including - them in the key is harmless. ``InvalidVersion`` propagates to the caller. + Dependencies group by ``(dep_name, (major, minor))``. Values group by + ``(key, (major, minor))`` when the value parses as a versioned string with a minor + component — matching what :func:`stripPatch` would collapse — and otherwise by + ``(key, raw_str)``. This keeps grouping consistent with the rendered tag, so two + values that ``stripPatch`` collapses to the same string land in the same group and + only the highest-patch row is selected. + + ``InvalidVersion`` from an unparseable *dependency* version propagates to the caller; + unparseable values fall back to the raw string. """ dep_parts = [] for dep in sorted(product["dependencies"], key=lambda d: d.dependency): v = DependencyVersion(dep.versions[0]) dep_parts.append((dep.dependency, (v.major, v.minor))) - value_parts = sorted((k, str(v)) for k, v in product["values"].items()) + + value_parts = [] + for k, val in sorted(product["values"].items()): + value_str = str(val) + try: + parsed = DependencyVersion(value_str) + except InvalidVersion: + value_parts.append((k, value_str)) + continue + if parsed.minor is None: + value_parts.append((k, value_str)) + else: + value_parts.append((k, (parsed.major, parsed.minor))) + return tuple(dep_parts), tuple(value_parts) @staticmethod def _patch_sort_key(product: dict[str, list | dict]) -> tuple: - """Sort key for finding the row with the maximum patch versions within a group.""" - deps = sorted(product["dependencies"], key=lambda d: d.dependency) - return tuple(DependencyVersion(dep.versions[0]) for dep in deps) + """Sort key for finding the row with the maximum patch versions within a group. + + Includes parseable list-typed values so version-like value axes (e.g. + ``go_version: [1.24.1, 1.24.2]``) participate in latest-patch selection alongside + dependency axes. Within a single group all values at a given axis share the same + parseability — they were grouped by it — so mixed-type tuples never compare here. + """ + 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()): + value_str = str(val) + try: + sort_keys.append(DependencyVersion(value_str)) + except InvalidVersion: + sort_keys.append(value_str) + return tuple(sort_keys) diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 2a0c988a..4f340cda 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -623,6 +623,34 @@ def test_to_image_versions_no_dependencies_marks_all_latest_patch(self): # 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_matrix_groups_by_minor_for_patch_values(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_matrix_non_version_axis_keeps_all_rows(self): + """Non-version list values fall back to raw-string grouping so distinct values stay distinct.""" + matrix = ImageMatrix( + values={"flavor": ["alpha", "beta"]}, + ) + + image_versions = matrix.to_image_versions() + assert len(image_versions) == 2 + # "alpha" and "beta" are unparseable → separate groups → both latest-patch. + assert all(iv.isLatestPatchCombination for iv in image_versions) + def test_check_duplicate_dependency_constraints(self): """Test that duplicate dependency constraints raise error.""" with pytest.raises( From 841a8b4fa7687ca2fab62607e145152a41c735fb Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 19 May 2026 16:16:56 -0500 Subject: [PATCH 03/12] Group latest-patch by stripped form to cover prefixed values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix grouped value axes by ``(major, minor)`` only when the whole value parsed as a ``DependencyVersion``. Prefix-bearing values like ``go1.24.1`` fail that parse and fell back to raw-string grouping, so ``go1.24.1`` and ``go1.24.2`` landed in separate groups and both rows were flagged latest-patch — yet ``stripPatch`` still collapsed both to ``go1.24`` on render, racing on push. Drive grouping from ``stripPatch`` itself. Anything that would render to the same stripped tag now lands in the same group, by construction. For ordering within a group, extract the first ``\\d+(\\.\\d+)+`` substring (e.g. ``1.24.10`` from ``go1.24.10``) and parse it as a ``DependencyVersion`` so prefix-bearing values participate in patch comparison alongside plain version strings. Extract ``strip_patch`` from the Jinja filter so both call sites share one implementation. --- .../posit_bakery/config/image/matrix.py | 73 ++++++++++--------- .../posit_bakery/config/templating/render.py | 14 +++- posit-bakery/test/config/image/test_matrix.py | 17 +++++ 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 15337f34..67aafd1c 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -24,7 +24,7 @@ from posit_bakery.config.registry import BaseRegistry, Registry from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel from posit_bakery.config.templating import jinja2_env -from posit_bakery.config.templating.render import normalize_rendered_output +from posit_bakery.config.templating.render import normalize_rendered_output, strip_patch from posit_bakery.const import JINJA2_TEMPLATE_EXTENSIONS from posit_bakery.error import BakeryFileError, BakeryRenderError, BakeryTemplateError, BakeryRenderErrorGroup from .variant import ImageVariant @@ -35,6 +35,8 @@ DEFAULT_MATRIX_SUBPATH: Literal["matrix"] = "matrix" +_VERSION_SUBSTRING_RE = re.compile(r"\d+(?:\.\d+)+") + def generate_default_name_pattern(data: dict[str, Any]) -> str: """Generates the default name pattern for image versions. @@ -839,52 +841,57 @@ def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict] def _minor_group_key(product: dict[str, list | dict]) -> tuple: """Group key for latest-patch logic. - Dependencies group by ``(dep_name, (major, minor))``. Values group by - ``(key, (major, minor))`` when the value parses as a versioned string with a minor - component — matching what :func:`stripPatch` would collapse — and otherwise by - ``(key, raw_str)``. This keeps grouping consistent with the rendered tag, so two - values that ``stripPatch`` collapses to the same string land in the same group and - only the highest-patch row is selected. + Apply :func:`strip_patch` to each axis value to derive the group key. Two rows + that would render to the same stripped tag share a group, so only the + highest-patch row in each group is later selected as the latest patch. - ``InvalidVersion`` from an unparseable *dependency* version propagates to the caller; - unparseable values fall back to the raw string. + Dependency versions are validated as parseable here so the caller can + short-circuit on bad input; the resulting group key is still the stripped form + for consistency with the rendered tag (covering prefix-bearing strings like + ``v3.12.3`` that the regex still collapses correctly). """ dep_parts = [] for dep in sorted(product["dependencies"], key=lambda d: d.dependency): - v = DependencyVersion(dep.versions[0]) - dep_parts.append((dep.dependency, (v.major, v.minor))) + # Validate parseability so unparseable deps short-circuit the caller. + DependencyVersion(dep.versions[0]) + dep_parts.append((dep.dependency, strip_patch(dep.versions[0]))) - value_parts = [] - for k, val in sorted(product["values"].items()): - value_str = str(val) - try: - parsed = DependencyVersion(value_str) - except InvalidVersion: - value_parts.append((k, value_str)) - continue - if parsed.minor is None: - value_parts.append((k, value_str)) - else: - value_parts.append((k, (parsed.major, parsed.minor))) + value_parts = [(k, strip_patch(str(val))) for k, val in sorted(product["values"].items())] return tuple(dep_parts), tuple(value_parts) @staticmethod def _patch_sort_key(product: dict[str, list | dict]) -> tuple: - """Sort key for finding the row with the maximum patch versions within a group. - - Includes parseable list-typed values so version-like value axes (e.g. - ``go_version: [1.24.1, 1.24.2]``) participate in latest-patch selection alongside - dependency axes. Within a single group all values at a given axis share the same - parseability — they were grouped by it — so mixed-type tuples never compare here. + """Sort key for finding the highest-patch row within a group. + + Extract the first numeric ``MAJOR.MINOR[.PATCH...]`` substring from each axis + value and parse it as a ``DependencyVersion`` for comparison. Within a single + group all rows share the same stripped form, so either every row produces an + extractable version (when the stripped form contains a numeric version segment) + or the group has only one row (when there is no numeric segment to compete on). + 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()): value_str = str(val) - try: - sort_keys.append(DependencyVersion(value_str)) - except InvalidVersion: - sort_keys.append(value_str) + extracted = ImageMatrix._extract_version(value_str) + sort_keys.append(extracted if extracted is not None else value_str) return tuple(sort_keys) + + @staticmethod + def _extract_version(s: str) -> DependencyVersion | None: + """Return the first ``\\d+(\\.\\d+)+`` substring parsed as a ``DependencyVersion``. + + Lets prefix-bearing values like ``"go1.24.10"`` participate in patch ordering + alongside plain ``"1.24.10"``. Returns ``None`` when no numeric version segment + is present or the segment fails to parse. + """ + match = _VERSION_SUBSTRING_RE.search(s) + if match is None: + return None + try: + return DependencyVersion(match.group()) + except InvalidVersion: + return None diff --git a/posit-bakery/posit_bakery/config/templating/render.py b/posit-bakery/posit_bakery/config/templating/render.py index 2cc4e749..67451602 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -5,6 +5,18 @@ from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN from posit_bakery.error import BakeryTemplateError +_STRIP_PATCH_RE = re.compile(r"(\d+\.\d+)\.\d+") + + +def strip_patch(s: str) -> str: + """Collapse ``MAJOR.MINOR.PATCH`` groups in a string to ``MAJOR.MINOR``. + + 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 raise_template_exception(message: str) -> None: """Raises a ValueError with the provided message. @@ -25,7 +37,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"] = lambda s: re.sub(r"(\d+\.\d+)\.\d+", r"\1", 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/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 4f340cda..c99bf727 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -651,6 +651,23 @@ def test_to_image_versions_values_only_matrix_non_version_axis_keeps_all_rows(se # "alpha" and "beta" are unparseable → separate groups → both latest-patch. assert all(iv.isLatestPatchCombination for iv in image_versions) + def test_to_image_versions_values_only_matrix_groups_prefixed_versions_by_stripped_form(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( From d30cd0cd45c36547183bec08b1e705e7b45db44e Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 19 May 2026 16:19:33 -0500 Subject: [PATCH 04/12] Shorten latest-patch matrix test names Drop the verbose phrases from the new ``test_to_image_versions_*`` tests so they read at a glance and line up with neighbors like ``test_to_image_versions_marks_latest_combination``. --- posit-bakery/test/config/image/test_matrix.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index c99bf727..08fdd439 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -519,7 +519,7 @@ 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_latest_patch_combinations_all_unique_minors(self): + 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=[ @@ -533,7 +533,7 @@ def test_to_image_versions_marks_latest_patch_combinations_all_unique_minors(sel # 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_marks_only_highest_patch_when_multiple_patches_share_minor(self): + 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=[ @@ -575,7 +575,7 @@ def deps_dict(iv): ("3.12.5", "4.4.1"), } - def test_to_image_versions_latest_patch_groups_by_list_value_axis(self): + 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=[ @@ -623,7 +623,7 @@ def test_to_image_versions_no_dependencies_marks_all_latest_patch(self): # 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_matrix_groups_by_minor_for_patch_values(self): + 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 @@ -640,7 +640,7 @@ def test_to_image_versions_values_only_matrix_groups_by_minor_for_patch_values(s 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_matrix_non_version_axis_keeps_all_rows(self): + def test_to_image_versions_values_only_non_version_keeps_all(self): """Non-version list values fall back to raw-string grouping so distinct values stay distinct.""" matrix = ImageMatrix( values={"flavor": ["alpha", "beta"]}, @@ -651,7 +651,7 @@ def test_to_image_versions_values_only_matrix_non_version_axis_keeps_all_rows(se # "alpha" and "beta" are unparseable → separate groups → both latest-patch. assert all(iv.isLatestPatchCombination for iv in image_versions) - def test_to_image_versions_values_only_matrix_groups_prefixed_versions_by_stripped_form(self): + 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 From a96ad92495c2ac72e1c26fe661134069bdb9009d Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Wed, 20 May 2026 08:42:21 -0500 Subject: [PATCH 05/12] Clean up latest-patch helper docs and dead branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After two rounds of fixes the latest-patch helper carried stale documentation and an unreachable exception handler that misled future readers. Cleanup only — no behaviour change. - ``_compute_latest_patch_signatures``: rewrite docstring to describe the ``strip_patch``-driven grouping that ``998de0a`` introduced; move dependency-version validation into a single explicit upfront pass and drop the second ``except InvalidVersion`` around ``max(...)``, which could never trigger because the same strings were already parsed in ``_minor_group_key``. - ``_minor_group_key``: drop the side-effect-only ``DependencyVersion(dep.versions[0])`` validation line now that the caller validates upfront. Function is now pure and the docstring says so. - Test ``test_to_image_versions_values_only_non_version_keeps_all``: rewrite the docstring and inline comment that still described the pre-``998de0a`` "raw-string fallback" behaviour. The grouping is now driven by ``strip_patch`` for every axis. --- .../posit_bakery/config/image/matrix.py | 92 +++++++++---------- posit-bakery/test/config/image/test_matrix.py | 7 +- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 67aafd1c..93b177ce 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -795,70 +795,68 @@ def _row_signature(product: dict[str, list | dict]) -> tuple: 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 (minor, ...) group. + """Identify cartesian-product rows that are the latest patch for their stripped group. - Groups rows by (axis_key, (major, minor)) for each dependency axis and by (key, value) - for each ``values`` axis. Within each group, the row whose dependency versions are - highest is the "latest patch" row. + 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. + :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. + 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 + groups: dict[tuple, list[dict[str, list | dict]]] = {} for product in products: - try: - group_key = self._minor_group_key(product) - except InvalidVersion as e: - log.warning( - f"Image matrix '{self.namePattern}': cannot determine latest patch combinations because a " - f"dependency version is unparseable ({e}). " - f"No 'latestPatch'-family tags will be emitted for this matrix." - ) - return None - groups.setdefault(group_key, []).append(product) + groups.setdefault(self._minor_group_key(product), []).append(product) - try: - latest_signatures: set[tuple] = set() - for group_rows in groups.values(): - max_row = max(group_rows, key=self._patch_sort_key) - latest_signatures.add(self._row_signature(max_row)) - return latest_signatures - except InvalidVersion as e: - log.warning( - f"Image matrix '{self.namePattern}': cannot determine latest patch combinations because a " - f"dependency version is unparseable ({e}). " - f"No 'latestPatch'-family tags will be emitted for this matrix." - ) - return None + latest_signatures: set[tuple] = set() + for group_rows in groups.values(): + max_row = max(group_rows, key=self._patch_sort_key) + latest_signatures.add(self._row_signature(max_row)) + return latest_signatures @staticmethod def _minor_group_key(product: dict[str, list | dict]) -> tuple: - """Group key for latest-patch logic. + """Group key derived from :func:`strip_patch` applied to every axis value. - Apply :func:`strip_patch` to each axis value to derive the group key. Two rows - that would render to the same stripped tag share a group, so only the - highest-patch row in each group is later selected as the latest patch. + 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``). - Dependency versions are validated as parseable here so the caller can - short-circuit on bad input; the resulting group key is still the stripped form - for consistency with the rendered tag (covering prefix-bearing strings like - ``v3.12.3`` that the regex still collapses correctly). + Pure function — assumes dependency versions are already validated by the + caller; this method does not raise. """ - dep_parts = [] - for dep in sorted(product["dependencies"], key=lambda d: d.dependency): - # Validate parseability so unparseable deps short-circuit the caller. - DependencyVersion(dep.versions[0]) - dep_parts.append((dep.dependency, strip_patch(dep.versions[0]))) - - value_parts = [(k, strip_patch(str(val))) for k, val in sorted(product["values"].items())] - - return tuple(dep_parts), tuple(value_parts) + 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: diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 08fdd439..2d6ab87d 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -641,14 +641,17 @@ def test_to_image_versions_values_only_groups_by_minor(self): assert latest_values == {"1.24.2", "1.25.0"} def test_to_image_versions_values_only_non_version_keeps_all(self): - """Non-version list values fall back to raw-string grouping so distinct values stay distinct.""" + """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" are unparseable → separate groups → both latest-patch. + # "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_values_only_groups_prefixed_versions(self): From 7ff543aaf7031b7b3d53fd408474c734c6f2b768 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Wed, 20 May 2026 09:01:42 -0500 Subject: [PATCH 06/12] Pin stripPatch behaviour for metadata-bearing dep versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint resolution never emits versions like ``3.12.3-rc1`` today (prereleases lose to clean patches in ``_filter_minor``), but an explicit ``dependencies:`` list can. Lock in that the patch numeric is the only segment ``stripPatch`` collapses, so prerelease rows group with their own siblings instead of clean-version rows. Doubles as a regression guard against future regex widenings — for instance ``(\\d+\\.\\d+)(?:\\.\\d+)+`` to handle 4-component calver strings would eat the ``-rc1`` too and silently collapse two distinct groups into one. --- posit-bakery/test/config/image/test_matrix.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 2d6ab87d..05998f61 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -654,6 +654,36 @@ def test_to_image_versions_values_only_non_version_keeps_all(self): # 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_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 From 52b091bea4e0603af01ee76fc3deccf236b59539 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Wed, 20 May 2026 09:06:54 -0500 Subject: [PATCH 07/12] Sort latest-patch on all version segments in a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values with multiple version-like substrings (e.g. ``go1.24-lib2.3.1`` vs ``go1.24-lib2.3.2``) collapse to the same ``stripPatch`` group, but the previous sort key extracted only the first segment. Both rows compared equal, so ``max()`` picked an arbitrary winner — the ``LATEST_PATCH`` tag could end up pointing at a non-highest patch for the group. Rename ``_extract_version`` to ``_extract_versions`` and have it return the tuple of every ``\\d+(\\.\\d+)+`` substring parsed as ``DependencyVersion``. Tuples compare element-wise, so the comparison cascades to the later segment when an earlier one ties. Within a single strip-patch group every row produces the same shape of tuple (same number of version-bearing positions), so mixed-type comparison during ``max()`` remains impossible. --- .../posit_bakery/config/image/matrix.py | 45 ++++++++++--------- posit-bakery/test/config/image/test_matrix.py | 16 +++++++ 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 93b177ce..3087b7e0 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -862,34 +862,37 @@ def _minor_group_key(product: dict[str, list | dict]) -> tuple: def _patch_sort_key(product: dict[str, list | dict]) -> tuple: """Sort key for finding the highest-patch row within a group. - Extract the first numeric ``MAJOR.MINOR[.PATCH...]`` substring from each axis - value and parse it as a ``DependencyVersion`` for comparison. Within a single - group all rows share the same stripped form, so either every row produces an - extractable version (when the stripped form contains a numeric version segment) - or the group has only one row (when there is no numeric segment to compete on). - That invariant prevents mixed-type comparisons during ``max()``. + 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()): - value_str = str(val) - extracted = ImageMatrix._extract_version(value_str) - sort_keys.append(extracted if extracted is not None else value_str) + sort_keys.append(ImageMatrix._extract_versions(str(val))) return tuple(sort_keys) @staticmethod - def _extract_version(s: str) -> DependencyVersion | None: - """Return the first ``\\d+(\\.\\d+)+`` substring parsed as a ``DependencyVersion``. + def _extract_versions(s: str) -> tuple[DependencyVersion, ...]: + """Return every ``\\d+(\\.\\d+)+`` substring in ``s`` parsed as a ``DependencyVersion``. - Lets prefix-bearing values like ``"go1.24.10"`` participate in patch ordering - alongside plain ``"1.24.10"``. Returns ``None`` when no numeric version segment - is present or the segment fails to parse. + 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 and the row is alone in its strip-patch group. """ - match = _VERSION_SUBSTRING_RE.search(s) - if match is None: - return None - try: - return DependencyVersion(match.group()) - except InvalidVersion: - return None + parsed: list[DependencyVersion] = [] + for match in _VERSION_SUBSTRING_RE.finditer(s): + try: + parsed.append(DependencyVersion(match.group())) + except InvalidVersion: + continue + return tuple(parsed) diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 05998f61..dbd02641 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -684,6 +684,22 @@ def test_to_image_versions_metadata_bearing_dep_grouped_by_stripped_minor(self): "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_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 From c3d5f8ea8d39b1f3d225143b4b9f4389cdc1c921 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 28 May 2026 10:34:26 -0500 Subject: [PATCH 08/12] Harden stripPatch and LATEST_PATCH emission The original stripPatch regex (\d+\.\d+)\.\d+ collapsed only the first three components, so 1.2.3.4 became 1.2.4 rather than 1.2. Tighten to (\d+\.\d+)(?:\.\d+)+ so 4+ component versions collapse fully to MAJOR.MINOR. Mirror _compute_latest_combination's defensive Exception hedge in _compute_latest_patch_signatures so an unexpected internal failure during version parsing skips latestPatch tag emission rather than killing the whole build. Add an end-to-end test pinning isLatestPatchCombination then filter retention then tag_suffixes emission. Coverage was split across three files, so a regression in any link could have slipped through. --- .../posit_bakery/config/image/matrix.py | 12 ++- .../posit_bakery/config/templating/render.py | 8 +- posit-bakery/test/config/image/test_matrix.py | 86 +++++++++++++++++++ .../test/config/templating/test_render.py | 3 + 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 3087b7e0..c439edb7 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -817,7 +817,9 @@ def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict] # 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. + # 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: @@ -829,6 +831,14 @@ def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict] 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: diff --git a/posit-bakery/posit_bakery/config/templating/render.py b/posit-bakery/posit_bakery/config/templating/render.py index 67451602..15b5be99 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -5,11 +5,15 @@ from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN from posit_bakery.error import BakeryTemplateError -_STRIP_PATCH_RE = re.compile(r"(\d+\.\d+)\.\d+") +_STRIP_PATCH_RE = re.compile(r"(\d+\.\d+)(?:\.\d+)+") def strip_patch(s: str) -> str: - """Collapse ``MAJOR.MINOR.PATCH`` groups in a string to ``MAJOR.MINOR``. + """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 diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index dbd02641..17040764 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -612,6 +612,31 @@ def test_to_image_versions_unparseable_dependency_skips_latest_patch(self, caplo 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_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( @@ -700,6 +725,23 @@ def test_to_image_versions_multi_version_value_sorts_on_later_segment(self): 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 @@ -919,6 +961,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 9fdcad33..7c8a0ceb 100644 --- a/posit-bakery/test/config/templating/test_render.py +++ b/posit-bakery/test/config/templating/test_render.py @@ -50,6 +50,9 @@ def test_stripPatch_filter(self): 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" From 1ebe10857abb88e1a1cdb1e87d202e4db80caf80 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 28 May 2026 12:02:18 -0500 Subject: [PATCH 09/12] Add strip_patch and extract_versions to config.dependencies.version Moves the version-string transforms next to DependencyVersion so callers in config.templating and config.image.matrix can share one definition instead of duplicating regex constants and parse loops. No call sites updated yet, so this commit only introduces the new functions. --- .../config/dependencies/version.py | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) 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. From f4ea7be547f6e8f67f31a8426a9eb53f13b2cf9b Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 28 May 2026 12:02:50 -0500 Subject: [PATCH 10/12] Import strip_patch from config.dependencies.version Replaces the local definition in config.templating.render with an import. The Jinja filter registration in jinja2_env continues to resolve the same callable, so behavior is unchanged. Removes the inline regex constant since the imported function owns the pattern. --- .../posit_bakery/config/templating/render.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/posit-bakery/posit_bakery/config/templating/render.py b/posit-bakery/posit_bakery/config/templating/render.py index 15b5be99..c4d75451 100644 --- a/posit-bakery/posit_bakery/config/templating/render.py +++ b/posit-bakery/posit_bakery/config/templating/render.py @@ -2,25 +2,10 @@ 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 -_STRIP_PATCH_RE = re.compile(r"(\d+\.\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 raise_template_exception(message: str) -> None: """Raises a ValueError with the provided message. From a7c6b4e19d4d6a3a343873a55e88620ba953fd7d Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 28 May 2026 12:03:53 -0500 Subject: [PATCH 11/12] Use shared strip_patch and extract_versions in matrix.py Removes the duplicated _VERSION_SUBSTRING_RE constant and the private _extract_versions static method in favor of the equivalents in config.dependencies.version. _patch_sort_key now calls the shared extract_versions directly. Behavior is unchanged. Addresses ianpittwood's PR #547 review comment about housing version transforms in config.dependencies. --- .../posit_bakery/config/image/matrix.py | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index c439edb7..a3c28960 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -19,12 +19,12 @@ ) 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 from posit_bakery.config.templating import jinja2_env -from posit_bakery.config.templating.render import normalize_rendered_output, strip_patch +from posit_bakery.config.templating.render import normalize_rendered_output from posit_bakery.const import JINJA2_TEMPLATE_EXTENSIONS from posit_bakery.error import BakeryFileError, BakeryRenderError, BakeryTemplateError, BakeryRenderErrorGroup from .variant import ImageVariant @@ -35,8 +35,6 @@ DEFAULT_MATRIX_SUBPATH: Literal["matrix"] = "matrix" -_VERSION_SUBSTRING_RE = re.compile(r"\d+(?:\.\d+)+") - def generate_default_name_pattern(data: dict[str, Any]) -> str: """Generates the default name pattern for image versions. @@ -887,22 +885,5 @@ def _patch_sort_key(product: dict[str, list | dict]) -> tuple: 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(ImageMatrix._extract_versions(str(val))) + sort_keys.append(extract_versions(str(val))) return tuple(sort_keys) - - @staticmethod - 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 and the row is alone in its strip-patch group. - """ - parsed: list[DependencyVersion] = [] - for match in _VERSION_SUBSTRING_RE.finditer(s): - try: - parsed.append(DependencyVersion(match.group())) - except InvalidVersion: - continue - return tuple(parsed) From ceba32448367f5f7ac0d6cf31ef67de96de12388 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 28 May 2026 13:05:25 -0500 Subject: [PATCH 12/12] Extend defensive hedge to the value-axis sort path The previous hedge only protected the dependency-pre-validation pass in _compute_latest_patch_signatures. The value-axis path goes through _patch_sort_key, which calls extract_versions and DependencyVersion directly without that pre-validation. A non-InvalidVersion exception there would still crash to_image_versions(). Wrap the max(...) call with a try/except Exception that logs once and returns None so the matrix degrades gracefully (skips latestPatch tags) instead of killing the whole build. extract_versions stays strict so other callers see real errors. Add a regression test that patches DependencyVersion for a values-only matrix, covering the path the dep-side test does not. --- .../posit_bakery/config/image/matrix.py | 15 +++++++++- posit-bakery/test/config/image/test_matrix.py | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index a3c28960..3bb315b6 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -844,7 +844,20 @@ def _compute_latest_patch_signatures(self, products: list[dict[str, list | dict] latest_signatures: set[tuple] = set() for group_rows in groups.values(): - max_row = max(group_rows, key=self._patch_sort_key) + 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 diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index 17040764..c1679b73 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -637,6 +637,35 @@ def test_to_image_versions_non_parsing_exception_skips_latest_patch(self, caplog 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(