Skip to content

Commit 7aa2e6b

Browse files
authored
Merge pull request #547 from posit-dev/worktree-matrix-strip-patch-tags
Tag matrix latest-patch rows with minor-only tags
2 parents 3510bae + ceba324 commit 7aa2e6b

10 files changed

Lines changed: 613 additions & 6 deletions

File tree

posit-bakery/posit_bakery/config/dependencies/version.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,52 @@
1+
import re
12
from typing import Annotated, Self, Any
23

3-
from packaging.version import Version
4+
from packaging.version import InvalidVersion, Version
45
from pydantic import Field, model_validator, field_validator
56
from ruamel.yaml.scalarfloat import ScalarFloat
67
from ruamel.yaml.scalarint import ScalarInt
78

89
from posit_bakery.config.shared import BakeryYAMLModel
910

1011

12+
_STRIP_PATCH_RE = re.compile(r"(\d+\.\d+)(?:\.\d+)+")
13+
_VERSION_SUBSTRING_RE = re.compile(r"\d+(?:\.\d+)+")
14+
15+
16+
def strip_patch(s: str) -> str:
17+
"""Collapse dotted numeric runs in a string to their first two segments.
18+
19+
Any ``\\d+(\\.\\d+)+`` substring with three or more components reduces to
20+
``MAJOR.MINOR``; 4+ component versions (e.g. ``1.2.3.4``) collapse the
21+
same way, not partially (which the old ``(\\d+\\.\\d+)\\.\\d+`` regex did,
22+
producing ``1.2.4``).
23+
24+
Shared between the ``stripPatch`` Jinja filter and matrix latest-patch
25+
grouping so the two stay consistent — anything that would render the
26+
same after the filter must land in the same group, otherwise rows
27+
collide on the rendered tag.
28+
"""
29+
return _STRIP_PATCH_RE.sub(r"\1", s)
30+
31+
32+
def extract_versions(s: str) -> tuple["DependencyVersion", ...]:
33+
"""Return every ``\\d+(\\.\\d+)+`` substring in ``s`` parsed as a
34+
``DependencyVersion``.
35+
36+
Returning a tuple (rather than the first match) is load-bearing: it lets
37+
values with several version segments (e.g. ``"go1.24-lib2.3.1"``) sort on
38+
the later segment when the earlier one ties. Unparseable substrings are
39+
skipped; the empty tuple means no version was found.
40+
"""
41+
parsed: list[DependencyVersion] = []
42+
for match in _VERSION_SUBSTRING_RE.finditer(s):
43+
try:
44+
parsed.append(DependencyVersion(match.group()))
45+
except InvalidVersion:
46+
continue
47+
return tuple(parsed)
48+
49+
1150
class DependencyVersion(Version):
1251
"""A version class for dependencies that extends packaging's Version.
1352

posit-bakery/posit_bakery/config/image/matrix.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
)
2020
from 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
2323
from posit_bakery.config.image.build_os import TargetPlatform, DEFAULT_PLATFORMS
2424
from posit_bakery.config.registry import BaseRegistry, Registry
2525
from 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)

posit-bakery/posit_bakery/config/image/version.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ class ImageVersion(BakeryPathMixin, BakeryYAMLModel):
8585
description="Flag to indicate if this is a matrix version.",
8686
),
8787
]
88+
isLatestPatchCombination: Annotated[
89+
bool,
90+
Field(
91+
exclude=True,
92+
default=False,
93+
description="Flag set on matrix versions whose dependency versions are the latest patch for their "
94+
"(major.minor, ...) group. Used to gate ``LATEST_PATCH``-filtered tag patterns.",
95+
),
96+
]
8897
os: Annotated[
8998
list[ImageVersionOS],
9099
Field(

posit-bakery/posit_bakery/config/tag.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class TagPatternFilter(str, Enum):
1515

1616
ALL = "all" # Matches all image targets.
1717
LATEST = "latest" # Matches the image targets at the latest image version.
18+
LATEST_PATCH = "latestPatch" # Matches matrix rows that are the latest patch for their minor combination.
1819
PRIMARY_OS = "primaryOS" # Matches image targets using the primary OS.
1920
PRIMARY_VARIANT = "primaryVariant" # Matches image targets of the primary variant.
2021

@@ -153,24 +154,44 @@ def default_matrix_tag_patterns() -> list[TagPattern]:
153154
hyphen onward. This set excludes stripMetadata patterns to avoid tag collisions across
154155
matrix combinations.
155156
157+
The ``stripPatch`` variants emit additional minor-only tags (e.g., "R4.3-python3.11")
158+
for rows that represent the latest patch in their (minor, ...) group, gated by the
159+
``LATEST_PATCH`` filter so non-latest rows do not collide on the stripped tag.
160+
156161
:return: A list of TagPattern objects representing the default matrix tag patterns.
157162
"""
158163
return [
159164
TagPattern(
160165
patterns=["{{ Version }}-{{ OS }}-{{ Variant }}"],
161166
only=[TagPatternFilter.ALL],
162167
),
168+
TagPattern(
169+
patterns=["{{ Version | stripPatch }}-{{ OS }}-{{ Variant }}"],
170+
only=[TagPatternFilter.LATEST_PATCH],
171+
),
163172
TagPattern(
164173
patterns=["{{ Version }}-{{ Variant }}"],
165174
only=[TagPatternFilter.PRIMARY_OS],
166175
),
176+
TagPattern(
177+
patterns=["{{ Version | stripPatch }}-{{ Variant }}"],
178+
only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_OS],
179+
),
167180
TagPattern(
168181
patterns=["{{ Version }}-{{ OS }}"],
169182
only=[TagPatternFilter.PRIMARY_VARIANT],
170183
),
184+
TagPattern(
185+
patterns=["{{ Version | stripPatch }}-{{ OS }}"],
186+
only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_VARIANT],
187+
),
171188
TagPattern(
172189
patterns=["{{ Version }}"],
173190
only=[TagPatternFilter.PRIMARY_OS, TagPatternFilter.PRIMARY_VARIANT],
174191
),
192+
TagPattern(
193+
patterns=["{{ Version | stripPatch }}"],
194+
only=[TagPatternFilter.LATEST_PATCH, TagPatternFilter.PRIMARY_OS, TagPatternFilter.PRIMARY_VARIANT],
195+
),
175196
*_shared_latest_tag_patterns(),
176197
]

posit-bakery/posit_bakery/config/templating/render.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import jinja2
44

5+
from posit_bakery.config.dependencies.version import strip_patch
56
from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN
67
from posit_bakery.error import BakeryTemplateError
78

@@ -25,6 +26,7 @@ def jinja2_env(**kwargs) -> jinja2.Environment:
2526
env = jinja2.Environment(**kwargs)
2627
env.filters["tagSafe"] = lambda s: re.sub(REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", s).strip("-._")
2728
env.filters["stripMetadata"] = lambda s: re.sub(r"[+-](?=[^+-]*$).*", "", s)
29+
env.filters["stripPatch"] = strip_patch
2830
env.filters["condense"] = lambda s: re.sub(r"[ .-]", "", s)
2931
env.filters["regexReplace"] = lambda s, find, replace: re.sub(find, replace, s)
3032
env.filters["quote"] = lambda s: '"' + s + '"'

posit-bakery/posit_bakery/image/image_target.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ def is_latest(self) -> bool:
324324
"""Check if the image version is marked as latest."""
325325
return self.image_version.latest
326326

327+
@property
328+
def is_latest_patch_combination(self) -> bool:
329+
"""Check if the image version is the latest patch for its matrix (minor, ...) group."""
330+
return self.image_version.isLatestPatchCombination
331+
327332
@property
328333
def is_primary_os(self) -> bool:
329334
"""Check if the image OS is marked as primary."""
@@ -410,6 +415,9 @@ def tag_patterns(self) -> list[TagPattern]:
410415
# Skip pattern marked as latest if not latest version.
411416
if TagPatternFilter.LATEST in tag_pattern.only and not self.is_latest:
412417
continue
418+
# Skip pattern for latest patch if this row is not the latest patch in its group.
419+
if TagPatternFilter.LATEST_PATCH in tag_pattern.only and not self.is_latest_patch_combination:
420+
continue
413421
# Skip pattern for primary OS if not primary OS.
414422
if TagPatternFilter.PRIMARY_OS in tag_pattern.only and not self.is_primary_os:
415423
continue

0 commit comments

Comments
 (0)