1919)
2020from packaging .version import InvalidVersion
2121
22- from posit_bakery .config .dependencies .version import DependencyVersion
22+ from posit_bakery .config .dependencies .version import DependencyVersion , extract_versions , strip_patch
2323from posit_bakery .config .image .build_os import TargetPlatform , DEFAULT_PLATFORMS
2424from posit_bakery .config .registry import BaseRegistry , Registry
2525from posit_bakery .config .shared import BakeryPathMixin , BakeryYAMLModel
@@ -742,8 +742,12 @@ def to_image_versions(self) -> list[ImageVersion]:
742742 resolved_deps = self .resolved_dependencies
743743 latest_pick = self ._compute_latest_combination (resolved_deps )
744744 products = self ._cartesian_product (resolved_deps , self .values )
745+ latest_patch_signatures = self ._compute_latest_patch_signatures (products )
745746 for product in products :
746747 is_latest = latest_pick is not None and self ._matches_latest (product , latest_pick )
748+ is_latest_patch = (
749+ latest_patch_signatures is not None and self ._row_signature (product ) in latest_patch_signatures
750+ )
747751 image_version = ImageVersion (
748752 parent = self .parent ,
749753 name = self ._render_name_pattern (self .namePattern , product ["dependencies" ], product ["values" ]),
@@ -755,6 +759,7 @@ def to_image_versions(self) -> list[ImageVersion]:
755759 values = product ["values" ],
756760 isMatrixVersion = True ,
757761 latest = is_latest ,
762+ isLatestPatchCombination = is_latest_patch ,
758763 buildTarget = self .buildTarget ,
759764 )
760765 image_versions .append (image_version )
@@ -779,3 +784,119 @@ def _matches_latest(product: dict[str, list | dict], latest_pick: dict[str, str]
779784 if axis_key in latest_pick and str (value ) != latest_pick [axis_key ]:
780785 return False
781786 return True
787+
788+ @staticmethod
789+ def _row_signature (product : dict [str , list | dict ]) -> tuple :
790+ """Hashable signature for a cartesian-product row, used for set membership checks."""
791+ dep_parts = tuple (sorted ((d .dependency , d .versions [0 ]) for d in product ["dependencies" ]))
792+ value_parts = tuple (sorted ((k , str (v )) for k , v in product ["values" ].items ()))
793+ return dep_parts , value_parts
794+
795+ def _compute_latest_patch_signatures (self , products : list [dict [str , list | dict ]]) -> set [tuple ] | None :
796+ """Identify cartesian-product rows that are the latest patch for their stripped group.
797+
798+ Validate all dependency versions upfront, then group every row by the result of
799+ applying :func:`strip_patch` to each of its axis values. Within each group, the
800+ row whose ``_patch_sort_key`` ranks highest is the "latest patch" row.
801+
802+ Grouping by the stripped form keeps the selection consistent with what
803+ ``stripPatch`` would render: any two rows that would produce the same stripped
804+ tag share a group, so only one of them is flagged and no ``LATEST_PATCH``
805+ targets collide on push.
806+
807+ :param products: All cartesian-product rows.
808+
809+ :return: A set of row signatures (see :pymeth:`_row_signature`) identifying
810+ latest-patch rows. Returns ``None`` if any dependency version is unparseable;
811+ in that case no ``LATEST_PATCH``-family tags are emitted for the matrix.
812+ """
813+ if not products :
814+ return set ()
815+
816+ # Validate dependency versions in a single explicit pass so the rest of the
817+ # function can treat them as parseable. Mirrors `_compute_latest_combination`'s
818+ # behaviour: bad dep input aborts emission of the family, and an unexpected
819+ # internal error from the version constructor is caught and treated the same way
820+ # rather than killing the whole build.
821+ for product in products :
822+ for dep in product ["dependencies" ]:
823+ try :
824+ DependencyVersion (dep .versions [0 ])
825+ except InvalidVersion as e :
826+ log .warning (
827+ f"Image matrix '{ self .namePattern } ': cannot determine latest patch combinations "
828+ f"because dependency '{ dep .dependency } ' version '{ dep .versions [0 ]} ' is unparseable "
829+ f"({ e } ). No 'latestPatch'-family tags will be emitted for this matrix."
830+ )
831+ return None
832+ except Exception as e :
833+ log .warning (
834+ f"Image matrix '{ self .namePattern } ': cannot determine latest patch combinations "
835+ f"because dependency '{ dep .dependency } ' raised an unexpected error processing "
836+ f"'{ dep .versions [0 ]} ' ({ type (e ).__name__ } : { e } ). "
837+ f"No 'latestPatch'-family tags will be emitted for this matrix."
838+ )
839+ return None
840+
841+ groups : dict [tuple , list [dict [str , list | dict ]]] = {}
842+ for product in products :
843+ groups .setdefault (self ._minor_group_key (product ), []).append (product )
844+
845+ latest_signatures : set [tuple ] = set ()
846+ for group_rows in groups .values ():
847+ try :
848+ max_row = max (group_rows , key = self ._patch_sort_key )
849+ except Exception as e :
850+ # _patch_sort_key parses value substrings as ``DependencyVersion``,
851+ # which the dep pre-validation pass above doesn't cover. An
852+ # unexpected error there shouldn't kill the build — drop
853+ # latestPatch tags for the matrix instead.
854+ log .warning (
855+ f"Image matrix '{ self .namePattern } ': cannot determine latest patch combinations "
856+ f"because computing the patch sort key raised an unexpected error "
857+ f"({ type (e ).__name__ } : { e } ). "
858+ f"No 'latestPatch'-family tags will be emitted for this matrix."
859+ )
860+ return None
861+ latest_signatures .add (self ._row_signature (max_row ))
862+ return latest_signatures
863+
864+ @staticmethod
865+ def _minor_group_key (product : dict [str , list | dict ]) -> tuple :
866+ """Group key derived from :func:`strip_patch` applied to every axis value.
867+
868+ Two rows that would render to the same stripped tag share the same group key,
869+ regardless of whether the value is a plain version (``3.12.3``), a prefixed
870+ version (``go1.24.3``), or a non-version label (``alpha``).
871+
872+ Pure function — assumes dependency versions are already validated by the
873+ caller; this method does not raise.
874+ """
875+ dep_parts = tuple (
876+ (dep .dependency , strip_patch (dep .versions [0 ]))
877+ for dep in sorted (product ["dependencies" ], key = lambda d : d .dependency )
878+ )
879+ value_parts = tuple ((k , strip_patch (str (val ))) for k , val in sorted (product ["values" ].items ()))
880+ return dep_parts , value_parts
881+
882+ @staticmethod
883+ def _patch_sort_key (product : dict [str , list | dict ]) -> tuple :
884+ """Sort key for finding the highest-patch row within a group.
885+
886+ Extract *every* numeric ``MAJOR.MINOR[.PATCH...]`` substring from each value
887+ and use the tuple of parsed versions as the sort key, so multi-version strings
888+ (e.g. ``"go1.24-lib2.3.1"`` vs ``"go1.24-lib2.3.2"``) cascade comparison to
889+ the later segment that actually differs.
890+
891+ Within a group all rows share the same stripped form, which forces the same
892+ set of version-bearing positions, so every row produces the same shape of
893+ tuple (empty when no version is present and the group has a single row, or
894+ non-empty in lockstep otherwise). That invariant prevents mixed-type
895+ comparisons during ``max()``.
896+ """
897+ sort_keys = []
898+ for dep in sorted (product ["dependencies" ], key = lambda d : d .dependency ):
899+ sort_keys .append (DependencyVersion (dep .versions [0 ]))
900+ for k , val in sorted (product ["values" ].items ()):
901+ sort_keys .append (extract_versions (str (val )))
902+ return tuple (sort_keys )
0 commit comments