Skip to content

Commit a63ffd5

Browse files
authored
Merge pull request #505 from posit-dev/feature/ordered-push
Push image versions in deterministic order to fix Docker Hub display
2 parents ece0519 + 4af94c3 commit a63ffd5

5 files changed

Lines changed: 207 additions & 26 deletions

File tree

.github/workflows/bakery-build-native.yml

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ jobs:
9999
contents: read
100100
outputs:
101101
platform-matrix: ${{ steps.images-by-platform.outputs.platform_matrix }}
102-
versions-matrix: ${{ steps.images-by-version.outputs.versions_matrix }}
103102

104103
steps:
105104
- name: Checkout
@@ -125,21 +124,6 @@ jobs:
125124
[[ -n "$DEV_STREAM" ]] && ARGS+=(--dev-stream "$DEV_STREAM")
126125
result=$(bakery ci matrix "${ARGS[@]}")
127126
echo "platform_matrix=$(echo "$result" | jq --compact-output .)" >> "$GITHUB_OUTPUT"
128-
- name: Images by Version
129-
id: images-by-version
130-
env:
131-
DEV_VERSIONS: ${{ inputs.dev-versions }}
132-
MATRIX_VERSIONS: ${{ inputs.matrix-versions }}
133-
IMAGE_VERSION: ${{ inputs.image-version }}
134-
DEV_STREAM: ${{ inputs.dev-stream }}
135-
CONTEXT: ${{ inputs.context }}
136-
run: |
137-
IMAGE_VERSION="${IMAGE_VERSION#v}"
138-
ARGS=(--quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" --exclude platform --context "$CONTEXT")
139-
[[ -n "$IMAGE_VERSION" ]] && ARGS+=(--image-version "$IMAGE_VERSION")
140-
[[ -n "$DEV_STREAM" ]] && ARGS+=(--dev-stream "$DEV_STREAM")
141-
result=$(bakery ci matrix "${ARGS[@]}")
142-
echo "versions_matrix=$(echo "$result" | jq --compact-output .)" >> "$GITHUB_OUTPUT"
143127
144128
build-test:
145129
name: "Build/Test ${{ matrix.img.image }}:${{ matrix.img.version }} (${{ matrix.img.platform }})"
@@ -279,17 +263,11 @@ jobs:
279263
overwrite: 'true'
280264

281265
merge:
282-
name: "Merge/Push ${{ matrix.img.image }}:${{ matrix.img.version }}"
266+
name: "Merge/Push"
283267
permissions:
284268
contents: read
285269
packages: write
286-
needs:
287-
- matrix
288-
- build-test
289-
strategy:
290-
fail-fast: false
291-
matrix:
292-
img: ${{ fromJson(needs.matrix.outputs.versions-matrix) }}
270+
needs: [build-test]
293271
runs-on: ${{ inputs.merge-builder }}
294272

295273
steps:
@@ -357,7 +335,7 @@ jobs:
357335
- name: Download Metadata
358336
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
359337
with:
360-
pattern: "${{ matrix.img.image }}-${{ matrix.img.version }}-*-metadata"
338+
pattern: "*-metadata"
361339
merge-multiple: true
362340

363341
- name: List files

posit-bakery/posit_bakery/image/image_target.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
from datetime import datetime
66
from enum import Enum
77
from pathlib import Path
8-
from typing import Annotated
8+
from typing import Annotated, TYPE_CHECKING
99

1010
import python_on_whales
1111
from pydantic import BaseModel, computed_field, ConfigDict, Field, model_validator
1212

1313
from posit_bakery.config.image import ImageVersion, ImageVariant, ImageVersionOS
1414
from posit_bakery.config.image.build_os import DEFAULT_PLATFORMS
1515
from posit_bakery.config.image.build_secret import BuildSecret
16+
from posit_bakery.config.image.parsed_version import version_sort_key
1617
from posit_bakery.config.registry import Registry, BaseRegistry
1718
from posit_bakery.config.repository import Repository
1819
from posit_bakery.config.tag import TagPattern, TagPatternFilter
@@ -21,6 +22,10 @@
2122
from posit_bakery.image.image_metadata import MetadataFile, BuildMetadata
2223
from posit_bakery.settings import SETTINGS
2324

25+
# Local under TYPE_CHECKING to avoid a circular import
26+
if TYPE_CHECKING:
27+
from posit_bakery.config.image.parsed_version import ParsedVersion
28+
2429
log = logging.getLogger(__name__)
2530

2631

@@ -314,6 +319,31 @@ def is_primary_variant(self) -> bool:
314319
return True
315320
return self.image_variant.primary
316321

322+
@property
323+
def push_sort_key(self) -> tuple[str, bool, "ParsedVersion", int, str, str, str]:
324+
"""Deterministic ordering for push to ordered-display registries (e.g. Docker Hub).
325+
326+
Tuple semantics, ascending sort:
327+
1. image_name — group all targets of one image together.
328+
2. is_latest — False before True; latest target pushed LAST in its group.
329+
3. version — ParsedVersion (semver §11) or MIN for matrix/unparseable.
330+
4. primary_score — 0..2; (primary OS + primary variant) target pushes LAST
331+
within a version, so its simplest tag is most-recent.
332+
5. version.name — stable lex tiebreaker (load-bearing for matrix rows that
333+
collapse to MIN under (3)).
334+
6. variant.name — stable tiebreaker.
335+
7. os.name — stable tiebreaker.
336+
"""
337+
return (
338+
self.image_name,
339+
self.is_latest,
340+
version_sort_key(self.image_version),
341+
int(self.is_primary_os) + int(self.is_primary_variant),
342+
self.image_version.name,
343+
self.image_variant.name if self.image_variant else "",
344+
self.image_os.name if self.image_os else "",
345+
)
346+
317347
@computed_field
318348
@property
319349
def containerfile(self) -> Path:

posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ def execute(
105105
**kwargs,
106106
) -> list[ToolCallResult]:
107107
"""Execute ORAS merge workflow against the given image targets."""
108+
# Sort so latest pushes last; Docker Hub displays tags by push-time order.
109+
targets = sorted(targets, key=lambda t: t.push_sort_key)
110+
log.info("ORAS merge order: %s", ", ".join(str(t) for t in targets))
108111
results = []
109112

110113
for target in targets:

posit-bakery/test/image/test_image_target.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import python_on_whales
77

88
from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions
9+
from posit_bakery.config.image.parsed_version import ParsedVersion
910
from posit_bakery.config.tag import default_tag_patterns, TagPatternFilter
1011
from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX
1112
from posit_bakery.image.image_metadata import BuildMetadata
@@ -1037,3 +1038,113 @@ def test_get_merge_sources_single_platform(self, basic_standard_image_target):
10371038
sources = basic_standard_image_target.get_merge_sources()
10381039

10391040
assert sources == ["image@sha256:digest"]
1041+
1042+
1043+
class TestPushSortKey:
1044+
"""Tests for ImageTarget.push_sort_key — see ordered-push design spec."""
1045+
1046+
@staticmethod
1047+
def _make(
1048+
*,
1049+
image_name="connect",
1050+
is_latest=False,
1051+
version_name="2026.03.0",
1052+
is_primary_os=True,
1053+
is_primary_variant=True,
1054+
variant_name="Standard",
1055+
os_name="Ubuntu 24.04",
1056+
is_matrix=False,
1057+
):
1058+
"""Build a MagicMock(spec=ImageTarget) with the inputs push_sort_key reads."""
1059+
target = MagicMock(spec=ImageTarget)
1060+
target.image_name = image_name
1061+
target.is_latest = is_latest
1062+
target.is_primary_os = is_primary_os
1063+
target.is_primary_variant = is_primary_variant
1064+
1065+
image_version = MagicMock()
1066+
image_version.name = version_name
1067+
image_version.parsed_version = None if is_matrix else ParsedVersion.parse(version_name)
1068+
target.image_version = image_version
1069+
1070+
variant = MagicMock()
1071+
variant.name = variant_name
1072+
target.image_variant = variant
1073+
1074+
os_obj = MagicMock()
1075+
os_obj.name = os_name
1076+
target.image_os = os_obj
1077+
1078+
# Bind the real property — push_sort_key reads only these attributes,
1079+
# so we can borrow ImageTarget's actual implementation.
1080+
target.push_sort_key = ImageTarget.push_sort_key.fget(target)
1081+
return target
1082+
1083+
def test_latest_sorts_last(self):
1084+
"""Within one image+version, the is_latest=True target sorts after is_latest=False."""
1085+
non_latest = self._make(is_latest=False, version_name="2026.04.0")
1086+
latest = self._make(is_latest=True, version_name="2026.04.0")
1087+
assert sorted([latest, non_latest], key=lambda t: t.push_sort_key) == [non_latest, latest]
1088+
1089+
def test_within_version_primary_sorts_last(self):
1090+
"""Within one version, primary_score 0 < 1 < 2 — most-primary pushed last."""
1091+
non_primary = self._make(
1092+
is_primary_os=False, is_primary_variant=False, os_name="Ubuntu 22.04", variant_name="Minimal"
1093+
)
1094+
partial_primary = self._make(
1095+
is_primary_os=True, is_primary_variant=False, os_name="Ubuntu 24.04", variant_name="Minimal"
1096+
)
1097+
full_primary = self._make(
1098+
is_primary_os=True, is_primary_variant=True, os_name="Ubuntu 24.04", variant_name="Standard"
1099+
)
1100+
ordered = sorted(
1101+
[full_primary, non_primary, partial_primary],
1102+
key=lambda t: t.push_sort_key,
1103+
)
1104+
assert ordered == [non_primary, partial_primary, full_primary]
1105+
1106+
def test_older_version_sorts_first(self):
1107+
"""Two non-latest stable targets — older (2026.03.0) sorts before newer (2026.04.0)."""
1108+
old = self._make(version_name="2026.03.0")
1109+
new = self._make(version_name="2026.04.0")
1110+
assert sorted([new, old], key=lambda t: t.push_sort_key) == [old, new]
1111+
1112+
def test_dev_prerelease_orders_correctly(self):
1113+
"""Locks in ParsedVersion semver §11: daily < dev < release at same release tuple."""
1114+
daily = self._make(version_name="2026.04.0-daily+92")
1115+
dev = self._make(version_name="2026.04.0-dev+485-gdb8245deea")
1116+
release = self._make(version_name="2026.04.0")
1117+
assert sorted([release, dev, daily], key=lambda t: t.push_sort_key) == [daily, dev, release]
1118+
1119+
def test_matrix_non_latest_rows_use_name_tiebreaker(self):
1120+
"""Non-latest matrix rows collapse to MIN on version key; deterministic via version.name lex."""
1121+
a = self._make(is_matrix=True, version_name="R4.3-python3.11")
1122+
b = self._make(is_matrix=True, version_name="R4.3-python3.12")
1123+
c = self._make(is_matrix=True, version_name="R4.4-python3.11")
1124+
ordered = sorted([c, a, b], key=lambda t: t.push_sort_key)
1125+
assert [t.image_version.name for t in ordered] == [
1126+
"R4.3-python3.11",
1127+
"R4.3-python3.12",
1128+
"R4.4-python3.11",
1129+
]
1130+
1131+
def test_matrix_latest_row_sorts_last(self):
1132+
"""The is_latest=True matrix row sorts after all non-latest matrix rows."""
1133+
non_latest = self._make(is_matrix=True, version_name="R4.3-python3.11", is_latest=False)
1134+
latest = self._make(is_matrix=True, version_name="R4.4-python3.12", is_latest=True)
1135+
assert sorted([latest, non_latest], key=lambda t: t.push_sort_key) == [non_latest, latest]
1136+
1137+
def test_multi_image_grouped(self):
1138+
"""Two image_names in one list — output fully grouped, no interleaving."""
1139+
connect_old = self._make(image_name="connect", version_name="2026.03.0")
1140+
connect_new = self._make(image_name="connect", version_name="2026.04.0", is_latest=True)
1141+
content_a = self._make(image_name="connect-content", is_matrix=True, version_name="R4.3-python3.11")
1142+
content_b = self._make(
1143+
image_name="connect-content", is_matrix=True, version_name="R4.4-python3.12", is_latest=True
1144+
)
1145+
ordered = sorted(
1146+
[content_b, connect_old, content_a, connect_new],
1147+
key=lambda t: t.push_sort_key,
1148+
)
1149+
names = [t.image_name for t in ordered]
1150+
assert names == ["connect", "connect", "connect-content", "connect-content"]

posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the OrasPlugin."""
22

3+
import logging
34
import subprocess
45
from pathlib import Path
56
from unittest.mock import MagicMock, patch
@@ -36,6 +37,7 @@ def mock_target_with_sources():
3637
"ghcr.io/posit-dev/test/tmp@sha256:arm64digest",
3738
]
3839
mock_target.labels = {"org.opencontainers.image.title": "Test Image"}
40+
mock_target.push_sort_key = (0,)
3941

4042
mock_tag = MagicMock()
4143
mock_tag.destination = "ghcr.io/posit-dev/test-image"
@@ -53,6 +55,7 @@ def mock_target_without_sources():
5355
mock_target.image_name = "no-sources"
5456
mock_target.uid = "no-sources-1-0-0"
5557
mock_target.get_merge_sources.return_value = []
58+
mock_target.push_sort_key = (0,)
5659
return mock_target
5760

5861

@@ -153,6 +156,62 @@ def test_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_targ
153156
assert len(results) == 1
154157
assert results[0].target is mock_target_with_sources
155158

159+
def test_execute_processes_targets_in_push_sort_key_order(self, plugin, caplog):
160+
"""Targets are processed in ascending push_sort_key order, regardless of input order."""
161+
162+
def make_target(name, sort_key):
163+
t = MagicMock(spec=ImageTarget)
164+
t.image_name = name
165+
t.uid = f"{name}-uid"
166+
t.context = MagicMock(spec=ImageTargetContext)
167+
t.context.base_path = Path("/project")
168+
t.settings = MagicMock(spec=ImageTargetSettings)
169+
t.settings.temp_registry = "ghcr.io/posit-dev"
170+
t.get_merge_sources.return_value = [f"ghcr.io/posit-dev/{name}/tmp@sha256:digest"]
171+
t.labels = {}
172+
mock_tag = MagicMock()
173+
mock_tag.destination = f"ghcr.io/posit-dev/{name}"
174+
mock_tag.suffix = "1.0.0"
175+
mock_tag.__str__ = lambda self: f"ghcr.io/posit-dev/{name}:1.0.0"
176+
t.tags = StringableList([mock_tag])
177+
# Override push_sort_key to a controlled tuple so the test is independent of
178+
# ImageVersion / ImageVariant internals.
179+
t.push_sort_key = sort_key
180+
t.__str__ = lambda self: name
181+
return t
182+
183+
# Input order is intentionally scrambled.
184+
targets = [
185+
make_target("c-second", (1,)),
186+
make_target("a-first", (0,)),
187+
make_target("d-last", (3,)),
188+
make_target("b-third", (2,)),
189+
]
190+
expected_order = ["a-first", "c-second", "b-third", "d-last"]
191+
192+
call_order = []
193+
194+
def fake_run(self_workflow, dry_run=False):
195+
call_order.append(self_workflow.image_target.image_name)
196+
return OrasMergeWorkflowResult(success=True, destinations=[])
197+
198+
with (
199+
patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"),
200+
patch(
201+
"posit_bakery.plugins.builtin.oras.OrasMergeWorkflow.run",
202+
autospec=True,
203+
side_effect=fake_run,
204+
),
205+
caplog.at_level(logging.INFO, logger="posit_bakery.plugins.builtin.oras"),
206+
):
207+
plugin.execute(Path("/project"), targets)
208+
209+
assert call_order == expected_order, f"got {call_order}, want {expected_order}"
210+
order_log_lines = [r for r in caplog.records if "ORAS merge order:" in r.getMessage()]
211+
assert len(order_log_lines) == 1, "expected exactly one ORAS merge order log line"
212+
msg = order_log_lines[0].getMessage()
213+
assert msg.endswith("a-first, c-second, b-third, d-last"), msg
214+
156215

157216
class TestOrasPluginCLI:
158217
def test_register_cli_adds_oras_command(self, plugin):

0 commit comments

Comments
 (0)