2929 * "{{ digest_sha }}" -> The image digest of the respective component
3030 * "{{ incrementer }}" -> Automatically finds the highest existing incremented tag in the
3131 repository and generates the next sequential tag (e.g., if the highest tag is v1.0.0-2, it will generate v1.0.0-3)
32+ * "{{ component-incrementer }}" -> Like {{ incrementer }}, but finds the highest existing tag
33+ across ALL repositories in the component and generates the next sequential tag uniformly.
34+ Use this instead of {{ incrementer }} when pushing to multiple registries to ensure every
35+ registry receives the same tag (e.g., if repo-a has v1.0.0-3 and repo-b has v1.0.0-5,
36+ both will receive v1.0.0-6).
3237 * "{{ oci_version }}" -> The version from OCI image annotations (org.opencontainers.image.version), with fallback
3338 to OCI image labels if not present in annotations (converts + to _ for tag compliance)
3439
@@ -228,6 +233,84 @@ spec:
228233 echo "$tag" # Return the final tag
229234 }
230235
236+ # Function to handle component-incrementer logic: finds the highest increment across
237+ # ALL repositories in a component and returns the next uniform sequential tag.
238+ # Results are cached by version_prefix to avoid redundant skopeo calls.
239+ # Expected arguments are: [tag_template, all_repos_json]
240+ component_increment_tag() {
241+ local tag_template="$1"
242+ local all_repos_json="${2:-[]}"
243+
244+ # Remove {{ component-incrementer }} placeholder to get the version prefix
245+ local version_prefix
246+ # shellcheck disable=SC2001
247+ version_prefix=$(echo "${tag_template}" | sed 's/{{ *component-incrementer *}}//g')
248+
249+ # Return cached result if available for this prefix.
250+ # Cache files survive subshell boundaries; associative arrays do not.
251+ local cache_key
252+ cache_key=$(printf '%s' "${version_prefix}" | base64 | tr -d '=\n' | tr '+/' '-_')
253+ local cache_file="${_inc_cache_dir}/${cache_key}"
254+ if [[ -f "${cache_file}" ]]; then
255+ local cached_increment
256+ cached_increment=$(< "${cache_file}")
257+ local tag
258+ # shellcheck disable=SC2001
259+ tag=$(echo "${tag_template}" | sed "s/{{ *component-incrementer *}}/${cached_increment}/g")
260+ if [[ ! "${tag}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
261+ echo "Error: Invalid tag format after substitution: ${tag}" >&2
262+ exit 1
263+ fi
264+ echo "$tag"
265+ return
266+ fi
267+
268+ # Escape version_prefix for safe use in ERE (grep -E) and sed -E.
269+ # Without escaping, a prefix like "v1.0.0-" would treat the dots as
270+ # regex wildcards, potentially matching unintended tags.
271+ local escaped_prefix
272+ # shellcheck disable=SC2016
273+ escaped_prefix=$(printf '%s' "${version_prefix}" | sed 's/[.[\\*^$()+?{|]/\\&/g')
274+
275+ # Match tags with 1–6 digit increments only. Ignore 7+ digit tags to avoid
276+ # treating short commit SHAs as incrementer values
277+ local tag_pattern="^${escaped_prefix}[0-9]{1,6}$"
278+
279+ # Find the global maximum increment across all repositories in the component
280+ local global_max=0
281+ local num_repos
282+ num_repos=$(jq 'length' <<< "$all_repos_json")
283+ for ((r = 0; r < num_repos; r++)); do
284+ local repo
285+ repo=$(jq -r --argjson r "$r" '.[$r]' <<< "$all_repos_json")
286+ local existing_tags
287+ existing_tags=$(skopeo list-tags --retry-times 3 docker://"${repo}" | jq -r '.Tags[]')
288+ local repo_max
289+ repo_max=$(echo "${existing_tags}" | { grep -E "${tag_pattern}" || true; } \
290+ | sed -E "s/^${escaped_prefix}//" | sort -nr | head -n1)
291+ # Use 10# to force decimal input preventing leading 0 from being treated as octal
292+ if [[ -n "$repo_max" ]] && [[ $((10#${repo_max})) -gt $global_max ]]; then
293+ global_max=$((10#${repo_max}))
294+ fi
295+ done
296+
297+ local increment=$((global_max + 1))
298+
299+ # Cache the result so subsequent repos in this component reuse the same value
300+ echo "${increment}" > "${cache_file}"
301+
302+ local tag
303+ # shellcheck disable=SC2001
304+ tag=$(echo "${tag_template}" | sed "s/{{ *component-incrementer *}}/${increment}/g")
305+
306+ if [[ ! "${tag}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
307+ echo "Error: Invalid tag format after substitution: ${tag}" >&2
308+ exit 1
309+ fi
310+
311+ echo "$tag"
312+ }
313+
231314 # Expected arguments are: [variable, substitute_map, labels_map]
232315 substitute() {
233316 variable=$1
@@ -261,20 +344,23 @@ spec:
261344 echo "$tags_json" | jq -c --arg ts "$timestamp_val" '. + [$ts] | unique'
262345 }
263346
264- # Expected arguments are [tags, substitute_map, labels_map, repo]
347+ # Expected arguments are [tags, substitute_map, labels_map, repo, all_repos_json ]
265348 # The tags argument is a json array
266349 translate_tags () {
267- tags=$1
268- substitute_map=$2
269- labels_map=$3
270- repo=$4
350+ local tags=$1
351+ local substitute_map=$2
352+ local labels_map=$3
353+ local repo=$4
354+ local all_repos_json="${5:-[]}"
271355 if [ "$tags" = '' ] ; then
272356 echo ''
273357 return
274358 fi
275359
276- translated_tags='[]'
360+ local translated_tags='[]'
361+ local NUM_TAGS
277362 NUM_TAGS="$(jq 'length' <<< "${tags}")"
363+ local i tag var_name replacement
278364 for ((i = 0; i < NUM_TAGS; i++)); do
279365 tag="$(jq -r --argjson i "$i" '.[$i]' <<< "${tags}")"
280366
@@ -292,6 +378,8 @@ spec:
292378 # Handle incrementer logic
293379 if [[ "$var_name" == "incrementer" ]]; then
294380 tag=$(increment_tag "$tag" "$repo")
381+ elif [[ "$var_name" == "component-incrementer" ]]; then
382+ tag=$(component_increment_tag "$tag" "$all_repos_json")
295383 else
296384 replacement=$(substitute "$var_name" "$substitute_map" "$labels_map")
297385 if [ -z "$replacement" ]; then
@@ -423,7 +511,18 @@ spec:
423511 currentTimestamp="$(date "+%Y%m%d %T")"
424512 defaultCGWSettings=$(jq -c '.defaults.contentGateway // {}' <<< "$MAPPING")
425513 NUM_MAPPED_COMPONENTS=$(jq '.components | length' "${SNAPSHOT_SPEC_FILE}")
514+
515+ # File-based cache dir for component-incrementer results. A file-based
516+ # approach is required because component_increment_tag is invoked inside
517+ # $(...) subshells (via translate_tags), so bash associative array writes
518+ # would be lost on subshell exit. Files persist across subshell boundaries.
519+ _inc_cache_dir=$(mktemp -d)
520+ trap 'rm -rf "${_inc_cache_dir}"' EXIT
521+
426522 for ((i = 0; i < NUM_MAPPED_COMPONENTS; i++)) ; do
523+ # Clear the cache at the start of each component so that different
524+ # components with the same tag template use independent repo sets.
525+ rm -f "${_inc_cache_dir}/"* 2>/dev/null || true
427526 component=$(jq -c --argjson i "$i" '.components[$i]' "${SNAPSHOT_SPEC_FILE}")
428527 componentTags=$(jq '.componentTags // []' <<< "$component")
429528 defaultComponentTags=$(jq -n --argjson defaults "$defaultTags" --argjson componentTags \
@@ -574,6 +673,17 @@ spec:
574673 "${SNAPSHOT_SPEC_FILE}" > /tmp/temp && mv /tmp/temp "${SNAPSHOT_SPEC_FILE}"
575674 fi
576675
676+ # Build a JSON array of all repository URLs for this component.
677+ # This is used by {{ component-incrementer }} to query all repos and compute a
678+ # uniform increment value across registries.
679+ component_repos_json='[]'
680+ _ci_num_repos=$(jq '.repositories | length' <<< "$component")
681+ for ((_ci_j = 0; _ci_j < _ci_num_repos; _ci_j++)) ; do
682+ _ci_repo_url=$(jq -r --argjson j "$_ci_j" '.repositories[$j].url' <<< "$component")
683+ component_repos_json=$(jq -c --arg url "$_ci_repo_url" '. + [$url]' \
684+ <<< "$component_repos_json")
685+ done
686+
577687 NUM_REPOSITORIES=$(jq '.repositories | length' <<< "$component")
578688 for ((j = 0; j < NUM_REPOSITORIES; j++)) ; do
579689 repository=$(jq -c --argjson j "$j" '.repositories[$j]' <<< "$component")
@@ -583,7 +693,8 @@ spec:
583693
584694 allTagsPreSubstitution=$(jq -n --argjson defaults "$defaultComponentTags" --argjson repoTags \
585695 "$repoTags" '$defaults? + $repoTags? | unique')
586- tags=$(translate_tags "${allTagsPreSubstitution}" "${substitute_map}" "${labels}" "${url}")
696+ tags=$(translate_tags "${allTagsPreSubstitution}" "${substitute_map}" "${labels}" "${url}" \
697+ "${component_repos_json}")
587698 tags=$(ensure_implicit_timestamp_value "${tags}" "${timestamp}")
588699 if [ "$(jq 'length' <<< "$tags")" -gt 0 ] ; then
589700 jq --argjson i "$i" --argjson j "$j" --argjson updatedTags "$tags" \
0 commit comments