This is an automated email from the ASF dual-hosted git repository.

acosentino pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 5c75cc8d3221 Check Container Upgrade - Improve container version 
upgrade filters to prevent bad proposals (#21491)
5c75cc8d3221 is described below

commit 5c75cc8d3221d5ad3f4dff5a658a5d26e3f34bd6
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Feb 16 11:57:22 2026 +0100

    Check Container Upgrade - Improve container version upgrade filters to 
prevent bad proposals (#21491)
    
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../check-container-versions.py                    | 193 +++++++++++++++++----
 .../test/infra/kafka/services/container.properties |   3 +
 .../microprofile/lra/services/container.properties |   1 +
 .../infra/milvus/services/container.properties     |   2 +
 .../infra/mongodb/services/container.properties    |   1 +
 .../infra/postgres/services/container.properties   |   1 +
 .../test/infra/redis/services/container.properties |   1 +
 7 files changed, 167 insertions(+), 35 deletions(-)

diff --git 
a/.github/actions/check-container-upgrade/check-container-versions.py 
b/.github/actions/check-container-upgrade/check-container-versions.py
index 13c13581c66b..9716313c92d7 100755
--- a/.github/actions/check-container-upgrade/check-container-versions.py
+++ b/.github/actions/check-container-upgrade/check-container-versions.py
@@ -57,12 +57,27 @@ Version Filtering:
         mysql.container=mysql:8.0.35
         mysql.container.version.exclude=alpine,slim,debian
 
+    Major version freeze:
+        Prevents major version jumps (e.g., 3.x → 4.x):
+        <property>.version.freeze.major=true
+
+        Example:
+            kafka3.container=mirror.gcr.io/apache/kafka:3.9.1
+            kafka3.container.version.freeze.major=true
+
+    Structural pattern matching:
+        Version tags are automatically filtered to match the same format as the
+        current version. For example, if the current version is '17.5-alpine',
+        only tags matching the pattern X.Y-alpine will be considered. This 
prevents
+        non-version tags (branch names, base image tags) from being proposed.
+
     Notes:
+        - Structural pattern matching is applied first (automatic, no config 
needed)
         - Filters are case-insensitive
         - Include filter: version must contain at least ONE of the words
         - Exclude filter: version must NOT contain ANY of the words
         - Exclude filters are checked first, then include filters
-        - If no filters specified, all versions are considered
+        - If no filters specified, all versions matching the structural 
pattern are considered
 
 Usage:
     python3 check-container-versions.py [options]
@@ -96,6 +111,87 @@ import time
 # Initialize colorama for cross-platform colored output
 init(autoreset=True)
 
+
+def extract_numeric_segments(version_str: str) -> List[int]:
+    """Extract all numeric segments from a version string for comparison.
+
+    Examples:
+        '17.5-alpine' → [17, 5]
+        'v2.5.11' → [2, 5, 11]
+        'latest-kafka-3.9.1' → [3, 9, 1]
+        'RELEASE.2025-09-07T16-13-09Z-cpuv1' → [2025, 9, 7, 16, 13, 9, 1]
+    """
+    return [int(n) for n in re.findall(r'\d+', version_str)]
+
+
+def compare_version_tuples(v1: str, v2: str) -> int:
+    """Compare two version strings by their numeric segments.
+
+    Returns positive if v1 > v2, negative if v1 < v2, 0 if equal.
+    This avoids the broken string comparison fallback (e.g., "v1.9.7" > 
"v1.15.1").
+    """
+    nums1 = extract_numeric_segments(v1)
+    nums2 = extract_numeric_segments(v2)
+
+    if not nums1 or not nums2:
+        return 0  # Can't compare
+
+    # Compare component by component
+    for n1, n2 in zip(nums1, nums2):
+        if n1 != n2:
+            return n1 - n2
+
+    return len(nums1) - len(nums2)
+
+
+def is_same_major_version(v1: str, v2: str) -> bool:
+    """Check if two versions share the same major version (first numeric 
segment).
+
+    Used with version.freeze.major to prevent major version jumps.
+    """
+    nums1 = extract_numeric_segments(v1)
+    nums2 = extract_numeric_segments(v2)
+
+    if not nums1 or not nums2:
+        return True  # Can't determine, allow
+
+    return nums1[0] == nums2[0]
+
+
+def infer_version_pattern(current_version: str) -> Optional[re.Pattern]:
+    """Infer a structural regex pattern from the current version tag.
+
+    Replaces numeric segments with \\d+ patterns while keeping literal text
+    segments intact. This ensures candidate versions match the same format
+    as the current version.
+
+    Examples:
+        '17.5-alpine'       → '^\\d+\\.\\d+-alpine$'   (matches '18.0-alpine', 
not 'alpine3.23')
+        'v2.5.11'           → '^v\\d+\\.\\d+\\.\\d+$'  (matches 'v2.6.0', not 
'2.5.12')
+        'latest-kafka-3.9.1'→ '^latest\\-kafka\\-\\d+\\.\\d+\\.\\d+$'
+        '0.12.0-cpu'        → '^\\d+\\.\\d+\\.\\d+\\-cpu$' (matches 
'0.13.0-cpu', not 'latest-gpu')
+        '7.0.12-jammy'      → '^\\d+\\.\\d+\\.\\d+\\-jammy$'
+    """
+    if not current_version:
+        return None
+
+    parts = re.split(r'(\d+)', current_version)
+    pattern_parts = []
+    for part in parts:
+        if part == '':
+            continue
+        if re.match(r'^\d+$', part):
+            pattern_parts.append(r'\d+')
+        else:
+            pattern_parts.append(re.escape(part))
+
+    pattern = '^' + ''.join(pattern_parts) + '$'
+    try:
+        return re.compile(pattern)
+    except re.error:
+        return None
+
+
 @dataclass
 class ContainerImage:
     """Represents a container image with its registry, name, and version."""
@@ -107,6 +203,7 @@ class ContainerImage:
     file_path: str
     version_include: List[str] = None  # Whitelist: version must contain one 
of these words
     version_exclude: List[str] = None  # Blacklist: version must not contain 
any of these words
+    version_freeze_major: bool = False  # Lock to same major version when True
 
     def __post_init__(self):
         """Initialize default values for optional fields."""
@@ -114,6 +211,8 @@ class ContainerImage:
             self.version_include = []
         if self.version_exclude is None:
             self.version_exclude = []
+        # Infer structural pattern from current version (not a dataclass 
field, excluded from asdict)
+        self._version_pattern = infer_version_pattern(self.current_version)
 
     @property
     def full_name(self) -> str:
@@ -128,11 +227,23 @@ class ContainerImage:
         """Returns the complete image reference with version."""
         return f"{self.full_name}:{self.current_version}"
 
-    def is_version_allowed(self, version: str) -> bool:
-        """Check if a version matches the whitelist/blacklist criteria."""
-        version_lower = version.lower()
+    def is_version_allowed(self, version_tag: str) -> bool:
+        """Check if a version matches structural pattern and 
whitelist/blacklist criteria.
+
+        Checks are applied in order:
+        1. Structural pattern: version must match the same format as current 
version
+        2. Blacklist (exclude): version must NOT contain any excluded words
+        3. Whitelist (include): version must contain at least one included word
+        4. Major version freeze: version must share the same first numeric 
segment
+        """
+        # Check structural pattern first - this prevents non-version tags
+        # like branch names, base image tags, etc.
+        if self._version_pattern and not 
self._version_pattern.match(version_tag):
+            return False
+
+        version_lower = version_tag.lower()
 
-        # Check blacklist first (exclusions)
+        # Check blacklist (exclusions)
         if self.version_exclude:
             for exclude_word in self.version_exclude:
                 if exclude_word.lower() in version_lower:
@@ -141,13 +252,17 @@ class ContainerImage:
         # Check whitelist (inclusions)
         if self.version_include:
             # If whitelist is specified, version must contain at least one of 
the words
-            for include_word in self.version_include:
-                if include_word.lower() in version_lower:
-                    return True
-            # If whitelist exists but no match found, reject
-            return False
+            matched = any(include_word.lower() in version_lower
+                         for include_word in self.version_include)
+            if not matched:
+                return False
+
+        # Check major version freeze
+        if self.version_freeze_major:
+            if not is_same_major_version(self.current_version, version_tag):
+                return False
 
-        # No whitelist specified or version passed all checks
+        # Version passed all checks
         return True
 
 @dataclass
@@ -455,6 +570,9 @@ class MicrosoftRegistryAPI(ContainerRegistryAPI):
 class ContainerVersionChecker:
     """Main class for checking container versions."""
 
+    # Pre-release indicators for version tags
+    PRERELEASE_INDICATORS = ['alpha', 'beta', 'rc', 'dev', 'snapshot', 
'preview', 'nightly', 'canary']
+
     def __init__(self,
                  include_prereleases: bool = False,
                  registry_timeout: int = 30,
@@ -477,6 +595,11 @@ class ContainerVersionChecker:
             'cr.weaviate.io': DockerV2RegistryAPI(registry_timeout, "Weaviate 
Container Registry"),
         }
 
+    def _is_prerelease(self, ver: str) -> bool:
+        """Check if a version tag appears to be a pre-release."""
+        lower = ver.lower()
+        return any(indicator in lower for indicator in 
self.PRERELEASE_INDICATORS)
+
     def parse_container_reference(self, container_ref: str) -> Tuple[str, str, 
str, str]:
         """Parse container reference into registry, namespace, name, and 
version."""
         # Handle cases like:
@@ -642,8 +765,8 @@ class ContainerVersionChecker:
             processed_keys = set()
 
             for key, value in properties.items():
-                # Skip if already processed or if it's a filter property
-                if key in processed_keys or '.version.include' in key or 
'.version.exclude' in key:
+                # Skip if already processed or if it's a version filter/config 
property
+                if key in processed_keys or '.version.include' in key or 
'.version.exclude' in key or '.version.freeze' in key:
                     continue
 
                 try:
@@ -663,6 +786,12 @@ class ContainerVersionChecker:
                     if exclude_key in properties:
                         version_exclude = [word.strip() for word in 
properties[exclude_key].split(',') if word.strip()]
 
+                    # Look for .version.freeze.major property
+                    freeze_major_key = f"{key}.version.freeze.major"
+                    version_freeze_major = False
+                    if freeze_major_key in properties:
+                        version_freeze_major = 
properties[freeze_major_key].lower() in ['true', 'yes', '1']
+
                     image = ContainerImage(
                         registry=registry,
                         namespace=namespace,
@@ -671,7 +800,8 @@ class ContainerVersionChecker:
                         property_name=key,
                         file_path=file_path,
                         version_include=version_include,
-                        version_exclude=version_exclude
+                        version_exclude=version_exclude,
+                        version_freeze_major=version_freeze_major
                     )
                     images.append(image)
                     processed_keys.add(key)
@@ -682,6 +812,8 @@ class ContainerVersionChecker:
                             print(f"    Include filter: {', 
'.join(version_include)}")
                         if version_exclude:
                             print(f"    Exclude filter: {', 
'.join(version_exclude)}")
+                        if version_freeze_major:
+                            print(f"    Major version freeze: enabled")
 
                 except ValueError as e:
                     if self.verbose:
@@ -752,32 +884,23 @@ class ContainerVersionChecker:
                 excluded_count = len(available_versions) - 
len(filtered_versions)
                 print(f"      Filtered out {excluded_count} versions based on 
include/exclude rules")
 
-            # Sort versions
+            # Sort versions using numeric segment comparison (avoids 
packaging.version failures)
             def version_sort_key(v):
-                try:
-                    return version.parse(v)
-                except version.InvalidVersion:
-                    # For non-semantic versions, sort lexicographically
-                    return v
-
-            try:
-                sorted_versions = sorted(filtered_versions, 
key=version_sort_key, reverse=True)
-            except:
-                # If sorting fails, use lexicographic sort
-                sorted_versions = sorted(filtered_versions, reverse=True)
-
-            # Find newer versions
+                nums = extract_numeric_segments(v)
+                if nums:
+                    # Pad to 20 segments for consistent tuple comparison
+                    return tuple(nums + [0] * (20 - len(nums)))
+                return (0,) * 20
+
+            sorted_versions = sorted(filtered_versions, key=version_sort_key, 
reverse=True)
+
+            # Find newer versions using numeric segment comparison
             newer_versions = []
             current_ver = image.current_version
 
             for ver in sorted_versions:
-                try:
-                    if version.parse(ver) > version.parse(current_ver):
-                        if self.include_prereleases or not 
version.parse(ver).is_prerelease:
-                            newer_versions.append(ver)
-                except version.InvalidVersion:
-                    # For non-semantic versions, do string comparison
-                    if ver > current_ver:
+                if compare_version_tuples(ver, current_ver) > 0:
+                    if self.include_prereleases or not 
self._is_prerelease(ver):
                         newer_versions.append(ver)
 
             latest_version = sorted_versions[0] if sorted_versions else None
diff --git 
a/test-infra/camel-test-infra-kafka/src/main/resources/org/apache/camel/test/infra/kafka/services/container.properties
 
b/test-infra/camel-test-infra-kafka/src/main/resources/org/apache/camel/test/infra/kafka/services/container.properties
index 51beeb8725e1..45d8de482a6c 100644
--- 
a/test-infra/camel-test-infra-kafka/src/main/resources/org/apache/camel/test/infra/kafka/services/container.properties
+++ 
b/test-infra/camel-test-infra-kafka/src/main/resources/org/apache/camel/test/infra/kafka/services/container.properties
@@ -22,4 +22,7 @@ strimzi.container.image.version.include=latest-kafka
 strimzi.container.image.version.exclude=s390x,ppc64le,arm64,amd64
 
confluent.container.image.version.exclude=amd64,arm64,ppc64le,s390x,x86_64,latest,ubi
 kafka3.container.version.exclude=rc,beta,alpha
+kafka3.container.version.freeze.major=true
 redpanda.container.image.version.exclude=fips,amd64,arm64
+strimzi.container.image.version.freeze.major=true
+confluent.container.image.version.freeze.major=true
diff --git 
a/test-infra/camel-test-infra-microprofile-lra/src/main/resources/org/apache/camel/test/infra/microprofile/lra/services/container.properties
 
b/test-infra/camel-test-infra-microprofile-lra/src/main/resources/org/apache/camel/test/infra/microprofile/lra/services/container.properties
index a141915d6268..11ce8375f761 100644
--- 
a/test-infra/camel-test-infra-microprofile-lra/src/main/resources/org/apache/camel/test/infra/microprofile/lra/services/container.properties
+++ 
b/test-infra/camel-test-infra-microprofile-lra/src/main/resources/org/apache/camel/test/infra/microprofile/lra/services/container.properties
@@ -16,4 +16,5 @@
 ## ---------------------------------------------------------------------------
 
microprofile.lra.container=quay.io/jbosstm/lra-coordinator:5.13.1.Final-2.16.6.Final
 
microprofile.lra.container.ppc64le=icr.io/ppc64le-oss/quarkus/lra-coordinator-quarkus-jvm:latest
+microprofile.lra.container.version.freeze.major=true
 microprofile.lra.container.ppc64le.version.exclude=latest,amd64,arm64
diff --git 
a/test-infra/camel-test-infra-milvus/src/main/resources/org/apache/camel/test/infra/milvus/services/container.properties
 
b/test-infra/camel-test-infra-milvus/src/main/resources/org/apache/camel/test/infra/milvus/services/container.properties
index 1130c63ad958..cf61ef74d490 100644
--- 
a/test-infra/camel-test-infra-milvus/src/main/resources/org/apache/camel/test/infra/milvus/services/container.properties
+++ 
b/test-infra/camel-test-infra-milvus/src/main/resources/org/apache/camel/test/infra/milvus/services/container.properties
@@ -16,3 +16,5 @@
 ## ---------------------------------------------------------------------------
 milvus.container=mirror.gcr.io/milvusdb/milvus:v2.5.11
 milvus.container.ppc64le=icr.io/ppc64le-oss/milvus-ppc64le:v2.4.11
+milvus.container.version.freeze.major=true
+milvus.container.ppc64le.version.freeze.major=true
diff --git 
a/test-infra/camel-test-infra-mongodb/src/main/resources/org/apache/camel/test/infra/mongodb/services/container.properties
 
b/test-infra/camel-test-infra-mongodb/src/main/resources/org/apache/camel/test/infra/mongodb/services/container.properties
index 0890de44be4b..4c53b7b23c2b 100644
--- 
a/test-infra/camel-test-infra-mongodb/src/main/resources/org/apache/camel/test/infra/mongodb/services/container.properties
+++ 
b/test-infra/camel-test-infra-mongodb/src/main/resources/org/apache/camel/test/infra/mongodb/services/container.properties
@@ -18,3 +18,4 @@ mongodb.container=mirror.gcr.io/mongo:7.0.12-jammy
 mongodb.container.ppc64le=icr.io/ppc64le-oss/mongodb-ppc64le:4.4.24
 mongodb.container.version.include=jammy
 mongodb.container.ppc64le.version.exclude=bv
+mongodb.container.ppc64le.version.freeze.major=true
diff --git 
a/test-infra/camel-test-infra-postgres/src/main/resources/org/apache/camel/test/infra/postgres/services/container.properties
 
b/test-infra/camel-test-infra-postgres/src/main/resources/org/apache/camel/test/infra/postgres/services/container.properties
index c6e5d30799d4..b7f386950911 100644
--- 
a/test-infra/camel-test-infra-postgres/src/main/resources/org/apache/camel/test/infra/postgres/services/container.properties
+++ 
b/test-infra/camel-test-infra-postgres/src/main/resources/org/apache/camel/test/infra/postgres/services/container.properties
@@ -16,3 +16,4 @@
 ## ---------------------------------------------------------------------------
 postgres.container=mirror.gcr.io/postgres:17.5-alpine
 postgres.container.version.include=alpine
+postgres.container.version.freeze.major=true
diff --git 
a/test-infra/camel-test-infra-redis/src/main/resources/org/apache/camel/test/infra/redis/services/container.properties
 
b/test-infra/camel-test-infra-redis/src/main/resources/org/apache/camel/test/infra/redis/services/container.properties
index b0453e5cb238..b13833d993e9 100644
--- 
a/test-infra/camel-test-infra-redis/src/main/resources/org/apache/camel/test/infra/redis/services/container.properties
+++ 
b/test-infra/camel-test-infra-redis/src/main/resources/org/apache/camel/test/infra/redis/services/container.properties
@@ -17,3 +17,4 @@
 redis.container=mirror.gcr.io/redis:7.4.0-alpine
 redis.container.version.include=alpine
 redis.container.version.exclude=rc,beta
+redis.container.version.freeze.major=true

Reply via email to