From e16b2603e69f57284c046463d878b891cba291bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Mon, 13 Jul 2026 15:04:34 +0200 Subject: [PATCH 01/11] fix: Dereference tags when building a snapshot --- firestarter/workflows/build_images/build_images.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index 554e42bd..fb4e5c34 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -225,11 +225,19 @@ def dereference_from_input(self, input_value): git_output = proc.stdout.decode('utf-8').strip() if git_output: + if self.type == 'snapshots': + proc = subprocess.run( + ['git', 'rev-parse', f"{git_output}^{{commit}}"], + stdout=subprocess.PIPE + ) + proc.check_returncode() + return proc.stdout.decode('utf-8')[:7] return git_output # if the input value is a branch, we need to get the sha of the branch proc = subprocess.run( - ['git', 'rev-parse', f"origin/{input_value}"], stdout=subprocess.PIPE + ['git', 'rev-parse', f"origin/{input_value}"], + stdout=subprocess.PIPE ) proc.check_returncode() return proc.stdout.decode('utf-8')[:7] From 61669f62802a6eb7e95910b667c6f37ce4469bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Mon, 13 Jul 2026 15:18:55 +0200 Subject: [PATCH 02/11] fix: Tests --- .../tests/test_build_images_functionality.py | 70 +++++++++---------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/firestarter/tests/test_build_images_functionality.py b/firestarter/tests/test_build_images_functionality.py index 45c9a281..41d01b46 100644 --- a/firestarter/tests/test_build_images_functionality.py +++ b/firestarter/tests/test_build_images_functionality.py @@ -134,52 +134,50 @@ def test_dereference_from_input(mocker) -> None: return_value=True ) - subprocess_mock_tag_return_value = completed_process_mock( - args=None, returncode=0 - ) - subprocess_mock_tag_return_value.stdout = TAG_INPUT.encode("windows-1252") + mock_tag = completed_process_mock(args=None, returncode=0) + mock_tag.stdout = TAG_INPUT.encode("windows-1252") - subprocess_mock_empty_return_value = completed_process_mock( - args=None, returncode=0 - ) - subprocess_mock_empty_return_value.stdout = "".encode("windows-1252") + mock_empty = completed_process_mock(args=None, returncode=0) + mock_empty.stdout = "".encode("windows-1252") - subprocess_mock_sha_return_value = completed_process_mock( - args=None, returncode=0 - ) - subprocess_mock_sha_return_value.stdout = LONG_SHA_INPUT.encode( - "windows-1252" - ) + mock_sha = completed_process_mock(args=None, returncode=0) + mock_sha.stdout = LONG_SHA_INPUT.encode("windows-1252") - # Test tag input - subprocess_mock = subprocess - subprocess_mock.run = mocker.MagicMock( + # Test tag input with snapshots type (builder defaults to snapshots) + # Flow: git tag -l → mock_tag, git rev-parse tag^{commit} → mock_sha + subprocess.run = mocker.MagicMock( name="subprocess.run.mock", - side_effect=[ - subprocess_mock_tag_return_value, - subprocess_mock_empty_return_value, - subprocess_mock_sha_return_value, - ] + side_effect=[mock_tag, mock_sha] ) + result = builder.dereference_from_input(TAG_INPUT) + assert result == SHORT_SHA_INPUT - tag_input_dereference = builder.dereference_from_input(TAG_INPUT) - - assert tag_input_dereference == TAG_INPUT - - # Test long sha input - long_sha_input_dereference = builder.dereference_from_input(LONG_SHA_INPUT) + # Test long sha input — no subprocess calls + result = builder.dereference_from_input(LONG_SHA_INPUT) + assert result == SHORT_SHA_INPUT - assert long_sha_input_dereference == SHORT_SHA_INPUT - - # Test short sha input - short_sha_input_dereference = builder.dereference_from_input(SHORT_SHA_INPUT) - - assert short_sha_input_dereference == SHORT_SHA_INPUT + # Test short sha input — no subprocess calls + result = builder.dereference_from_input(SHORT_SHA_INPUT) + assert result == SHORT_SHA_INPUT # Test branch input - branch_input_dereference = builder.dereference_from_input(BRANCH_INPUT) + # Flow: git tag -l → mock_empty, git rev-parse origin/branch → mock_sha + subprocess.run = mocker.MagicMock( + name="subprocess.run.mock", + side_effect=[mock_empty, mock_sha] + ) + result = builder.dereference_from_input(BRANCH_INPUT) + assert result == SHORT_SHA_INPUT - assert branch_input_dereference == SHORT_SHA_INPUT + # Test tag input with releases type — tag returned as-is, no rev-parse + # Flow: git tag -l → mock_tag only (no additional dereference) + builder._type = "releases" + subprocess.run = mocker.MagicMock( + name="subprocess.run.mock", + side_effect=[mock_tag] + ) + result = builder.dereference_from_input(TAG_INPUT) + assert result == TAG_INPUT # Secrets are correctly solved, using the corresponding SecretResolver From e0c5b82bf6a61b039169137ae137770c712929a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Mon, 13 Jul 2026 16:22:14 +0200 Subject: [PATCH 03/11] fix: Don't overwrite existing platforms --- .../workflows/build_images/build_images.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index fb4e5c34..9720c6f4 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -325,6 +325,13 @@ async def compile_image_and_publish( # so that they can be included in the published multi-platform manifest list variants = [] other_platforms = [p for p in platforms if p not in platforms_to_build] + + # Preserve any platforms that already exist in the registry manifest + existing_platforms = self._get_existing_platforms(image) + for p in existing_platforms: + if p not in platforms_to_build and p not in other_platforms: + other_platforms.append(p) + if len(other_platforms) > 0: logger.info( f"Not building for these platforms as they are not in the filtered list: {other_platforms}, but including them as variants in the published multi-platform manifest list." @@ -625,6 +632,20 @@ def get_extra_tags_for_registry(self, registry_address, extra_tags): return extra_full_registry_addresses + def _get_existing_platforms(self, image): + proc = subprocess.run( + ['docker', 'manifest', 'inspect', image], + capture_output=True, text=True + ) + if proc.returncode != 0: + return [] + manifest = json.loads(proc.stdout) + platforms = [] + for m in manifest.get('manifests', []): + arch = m.get('platform', {}).get('architecture') + if arch: + platforms.append(f"linux/{arch}") + return platforms def is_auto_build(self): return self.flavors is None or len(self.flavors) == 0 From a8252f246b8591224387ed88aa3d1f502737763e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Mon, 13 Jul 2026 16:26:11 +0200 Subject: [PATCH 04/11] fix: Tests --- firestarter/tests/test_build_images_functionality.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/firestarter/tests/test_build_images_functionality.py b/firestarter/tests/test_build_images_functionality.py index 41d01b46..ec547cf1 100644 --- a/firestarter/tests/test_build_images_functionality.py +++ b/firestarter/tests/test_build_images_functionality.py @@ -348,6 +348,9 @@ async def call_and_test_compile_image_and_publish( platforms = ["linux/amd64"] platforms_to_build = ["linux/amd64"] + mocker.patch.object(ciap_builder, "_get_existing_platforms") + ciap_builder._get_existing_platforms.return_value = [] + mocker.patch.object(ciap_builder, "test_image") ciap_builder_test_image_mock = ciap_builder.test_image ciap_builder_test_image_mock.return_value = "Mock test image result" From 0196966cee249aedb2f2bfac23219bebb41cde59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Tue, 14 Jul 2026 11:24:12 +0200 Subject: [PATCH 05/11] fix: _type error --- firestarter/workflows/build_images/build_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index 9720c6f4..e1d4d716 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -37,6 +37,7 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._secrets = self.resolve_secrets(self.secrets) + self._type = self.vars.get('type', 'snapshots') # We checkout the correct sha/tag self._from = self.dereference_from_input(self.vars.get('from')) @@ -47,7 +48,6 @@ def __init__(self, **kwargs) -> None: self._releases_registry_creds = self.vars.get('releases_registry_creds', None) self._auth_strategy = self.vars.get('auth_strategy', None) self._output_results = self.vars.get('output_results', 'results.yaml') - self._type = self.vars.get('type', 'snapshots') self._workflow_run_id = self.vars.get('workflow_run_id', None) self._workflow_run_url = self.vars.get('workflow_run_url', None) self._service_path = self.vars.get('service_path', '') From 8164f75c6d820c92faa0ad942a008ffd796a1216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Tue, 14 Jul 2026 12:29:25 +0200 Subject: [PATCH 06/11] fix: Copilot comments --- .../tests/test_build_images_functionality.py | 33 +++++-------------- .../workflows/build_images/build_images.py | 20 ++++++++--- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/firestarter/tests/test_build_images_functionality.py b/firestarter/tests/test_build_images_functionality.py index ec547cf1..6fd1a6cc 100644 --- a/firestarter/tests/test_build_images_functionality.py +++ b/firestarter/tests/test_build_images_functionality.py @@ -128,27 +128,18 @@ def test_dereference_from_input(mocker) -> None: SHORT_SHA_INPUT = "6a32377" BRANCH_INPUT = "test_branch" - completed_process_mock = subprocess.CompletedProcess - completed_process_mock.check_return_code = mocker.MagicMock( - name="completed_process.check_return_code.mock", - return_value=True - ) - - mock_tag = completed_process_mock(args=None, returncode=0) - mock_tag.stdout = TAG_INPUT.encode("windows-1252") + mock_tag = mocker.MagicMock(returncode=0) + mock_tag.stdout = TAG_INPUT.encode() - mock_empty = completed_process_mock(args=None, returncode=0) - mock_empty.stdout = "".encode("windows-1252") + mock_empty = mocker.MagicMock(returncode=0) + mock_empty.stdout = b"" - mock_sha = completed_process_mock(args=None, returncode=0) - mock_sha.stdout = LONG_SHA_INPUT.encode("windows-1252") + mock_sha = mocker.MagicMock(returncode=0) + mock_sha.stdout = LONG_SHA_INPUT.encode() # Test tag input with snapshots type (builder defaults to snapshots) # Flow: git tag -l → mock_tag, git rev-parse tag^{commit} → mock_sha - subprocess.run = mocker.MagicMock( - name="subprocess.run.mock", - side_effect=[mock_tag, mock_sha] - ) + mocker.patch('subprocess.run', side_effect=[mock_tag, mock_sha]) result = builder.dereference_from_input(TAG_INPUT) assert result == SHORT_SHA_INPUT @@ -162,20 +153,14 @@ def test_dereference_from_input(mocker) -> None: # Test branch input # Flow: git tag -l → mock_empty, git rev-parse origin/branch → mock_sha - subprocess.run = mocker.MagicMock( - name="subprocess.run.mock", - side_effect=[mock_empty, mock_sha] - ) + mocker.patch('subprocess.run', side_effect=[mock_empty, mock_sha]) result = builder.dereference_from_input(BRANCH_INPUT) assert result == SHORT_SHA_INPUT # Test tag input with releases type — tag returned as-is, no rev-parse # Flow: git tag -l → mock_tag only (no additional dereference) builder._type = "releases" - subprocess.run = mocker.MagicMock( - name="subprocess.run.mock", - side_effect=[mock_tag] - ) + mocker.patch('subprocess.run', side_effect=[mock_tag]) result = builder.dereference_from_input(TAG_INPUT) assert result == TAG_INPUT diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index e1d4d716..f66d589c 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -327,7 +327,9 @@ async def compile_image_and_publish( other_platforms = [p for p in platforms if p not in platforms_to_build] # Preserve any platforms that already exist in the registry manifest - existing_platforms = self._get_existing_platforms(image) + existing_platforms = await anyio.to_thread.run_sync( + self._get_existing_platforms, image + ) for p in existing_platforms: if p not in platforms_to_build and p not in other_platforms: other_platforms.append(p) @@ -639,12 +641,22 @@ def _get_existing_platforms(self, image): ) if proc.returncode != 0: return [] - manifest = json.loads(proc.stdout) + try: + manifest = json.loads(proc.stdout) + except json.JSONDecodeError: + logger.info(f"Failed to parse manifest for {image}: non-JSON output") + return [] platforms = [] for m in manifest.get('manifests', []): - arch = m.get('platform', {}).get('architecture') + p = m.get('platform', {}) + os_val = p.get('os', 'linux') + arch = p.get('architecture') + variant = p.get('variant') if arch: - platforms.append(f"linux/{arch}") + platform_str = f"{os_val}/{arch}" + if variant: + platform_str = f"{platform_str}/{variant}" + platforms.append(platform_str) return platforms def is_auto_build(self): From e0077ed8ab2853f0ec4c5fa007dca19be11d73f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Tue, 14 Jul 2026 12:34:57 +0200 Subject: [PATCH 07/11] fix: Copilot comments --- .../workflows/build_images/build_images.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index f66d589c..d1503a17 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -327,9 +327,11 @@ async def compile_image_and_publish( other_platforms = [p for p in platforms if p not in platforms_to_build] # Preserve any platforms that already exist in the registry manifest - existing_platforms = await anyio.to_thread.run_sync( - self._get_existing_platforms, image - ) + existing_platforms = [] + if self.publish: + existing_platforms = await anyio.to_thread.run_sync( + self._get_existing_platforms, image + ) for p in existing_platforms: if p not in platforms_to_build and p not in other_platforms: other_platforms.append(p) @@ -635,10 +637,14 @@ def get_extra_tags_for_registry(self, registry_address, extra_tags): return extra_full_registry_addresses def _get_existing_platforms(self, image): - proc = subprocess.run( - ['docker', 'manifest', 'inspect', image], - capture_output=True, text=True - ) + try: + proc = subprocess.run( + ['docker', 'manifest', 'inspect', image], + capture_output=True, text=True + ) + except (FileNotFoundError, OSError): + logger.info(f"Docker CLI not available, skipping registry manifest inspection for {image}") + return [] if proc.returncode != 0: return [] try: From 99aa5b893d5015bc0c8ebe17e77bfac9b04b2e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Tue, 14 Jul 2026 13:06:07 +0200 Subject: [PATCH 08/11] fix: Copilot comments --- firestarter/workflows/build_images/build_images.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index d1503a17..b719fb43 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -224,6 +224,10 @@ def dereference_from_input(self, input_value): proc.check_returncode() git_output = proc.stdout.decode('utf-8').strip() + if git_output: + # git tag -l uses glob matching; filter to exact match + git_output = input_value if input_value in git_output.split('\n') else None + if git_output: if self.type == 'snapshots': proc = subprocess.run( @@ -663,6 +667,15 @@ def _get_existing_platforms(self, image): if variant: platform_str = f"{platform_str}/{variant}" platforms.append(platform_str) + + if not platforms: + # Single-arch manifest (schema2) — architecture is in config + config = manifest.get('config', {}) + arch = config.get('architecture') + os_val = config.get('os', 'linux') + if arch: + platforms.append(f"{os_val}/{arch}") + return platforms def is_auto_build(self): From 4bceb524c420c0700e615051ee0de7bdde294f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20V=C3=A1zquez=20Gil?= Date: Tue, 14 Jul 2026 13:51:11 +0200 Subject: [PATCH 09/11] fix: Timeout --- firestarter/workflows/build_images/build_images.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index b719fb43..e4334b0b 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -644,11 +644,15 @@ def _get_existing_platforms(self, image): try: proc = subprocess.run( ['docker', 'manifest', 'inspect', image], - capture_output=True, text=True + capture_output=True, text=True, + timeout=30 ) except (FileNotFoundError, OSError): logger.info(f"Docker CLI not available, skipping registry manifest inspection for {image}") return [] + except subprocess.TimeoutExpired: + logger.info(f"Timeout inspecting manifest for {image}, skipping") + return [] if proc.returncode != 0: return [] try: From 25c91dd8501d4c56729fe3af4c445bd7857aa5bc Mon Sep 17 00:00:00 2001 From: juanjosevazquezgil <123170708+juanjosevazquezgil@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:57:10 +0200 Subject: [PATCH 10/11] fix: Improved publish flow (#225) --- .../tests/test_build_images_functionality.py | 28 +++- .../workflows/build_images/build_images.py | 146 +++++++++--------- .../build_images/resources/schema.json | 6 +- 3 files changed, 94 insertions(+), 86 deletions(-) diff --git a/firestarter/tests/test_build_images_functionality.py b/firestarter/tests/test_build_images_functionality.py index 6fd1a6cc..11ed7966 100644 --- a/firestarter/tests/test_build_images_functionality.py +++ b/firestarter/tests/test_build_images_functionality.py @@ -330,28 +330,42 @@ async def call_and_test_compile_image_and_publish( secrets = { "test_secret": "b" } dockerfile = "/path/to/dockerfile" image = "image_tag" - platforms = ["linux/amd64"] platforms_to_build = ["linux/amd64"] - mocker.patch.object(ciap_builder, "_get_existing_platforms") - ciap_builder._get_existing_platforms.return_value = [] - mocker.patch.object(ciap_builder, "test_image") ciap_builder_test_image_mock = ciap_builder.test_image ciap_builder_test_image_mock.return_value = "Mock test image result" ctx_mock = DaggerContextMock() - mocker.patch.object(ctx_mock, "publish") - ctx_mock_publish_mock = ctx_mock.publish + publish_digest = "sha256:mockedpublishdigest" + async def _publish(*args, **kwargs): + return f"{image}@{publish_digest}" + ctx_mock_publish_mock = mocker.patch.object(ctx_mock, "publish", side_effect=_publish) + + subprocess_run_mock = mocker.patch("subprocess.run") + subprocess_run_mock.return_value = subprocess.CompletedProcess(args=[], returncode=0) + + if publish: + mock_existing = mocker.patch.object( + ciap_builder, "get_existing_platform_digests", + return_value={"__unknown__": "sha256:oldsingledigest"} + ) await ciap_builder.compile_image_and_publish( - ctx_mock, build_args, secrets, dockerfile, image, platforms_to_build, platforms + ctx_mock, build_args, secrets, dockerfile, image, platforms_to_build ) if publish: ctx_mock_publish_mock.assert_called_with(image, platform_variants=ANY) + mock_existing.assert_called_once_with(image) + subprocess_run_mock.assert_called_once_with( + ["docker", "buildx", "imagetools", "create", "--tag", image, + f"{image}@{publish_digest}", f"{image}@sha256:oldsingledigest"], + capture_output=True, text=True, check=True, timeout=60 + ) else: ctx_mock_publish_mock.assert_not_called() + subprocess_run_mock.assert_not_called() if container_structure_filename is not None: ciap_builder_test_image_mock.assert_called_with(ctx_mock) diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index e4334b0b..9416d815 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -1,5 +1,4 @@ import datetime -import json import re import os import sys @@ -320,40 +319,48 @@ async def test_image(self, ctx): os.remove(file_name) + def get_existing_platform_digests(self, image): + """Get a mapping of platform -> digest from the existing manifest in the registry. + + Returns {"__unknown__": digest} for single-platform manifests, or an empty dict + if the image doesn't exist. + """ + try: + result = subprocess.run( + ["docker", "buildx", "imagetools", "inspect", image], + capture_output=True, text=True, check=True, timeout=30 + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return {} + + output = result.stdout + + if "Manifests:" not in output: + digest_match = re.search(r'Digest:\s+(sha256:[a-f0-9]+)', output) + if digest_match: + return {"__unknown__": digest_match.group(1)} + return {} + + existing = {} + lines = output.split('\n') + current_digest = None + + for line in lines: + line = line.strip() + name_match = re.match(r'Name:\s+\S+@(sha256:[a-f0-9]+)', line) + if name_match: + current_digest = name_match.group(1) + platform_match = re.match(r'Platform:\s+(\S+)', line) + if platform_match and current_digest: + existing[platform_match.group(1)] = current_digest + + return existing + # Define a coroutine function to compile an image using Docker async def compile_image_and_publish( - self, ctx, build_args, secrets, dockerfile, image, platforms_to_build, platforms + self, ctx, build_args, secrets, dockerfile, image, platforms_to_build ): - # If there are platforms that are not being built for this flavor, log them - # and create container variants for them without building, - # so that they can be included in the published multi-platform manifest list variants = [] - other_platforms = [p for p in platforms if p not in platforms_to_build] - - # Preserve any platforms that already exist in the registry manifest - existing_platforms = [] - if self.publish: - existing_platforms = await anyio.to_thread.run_sync( - self._get_existing_platforms, image - ) - for p in existing_platforms: - if p not in platforms_to_build and p not in other_platforms: - other_platforms.append(p) - - if len(other_platforms) > 0: - logger.info( - f"Not building for these platforms as they are not in the filtered list: {other_platforms}, but including them as variants in the published multi-platform manifest list." - ) - for p in other_platforms: - logger.info(f"Creating container variant for platform {p} without building...") - v = ctx.container(platform=dagger.Platform(p)).from_(image) - try: - await v.sync() - variants.append(v) - except Exception as e: - logger.info( - f"Failed to create container variant for platform {p} using image {image}. Error: {e}. This variant will not be included in the published multi-platform manifest list." - ) # Set a current working directory src = ctx.host().directory(".") @@ -381,7 +388,35 @@ async def compile_image_and_publish( await self.test_image(variant) if self.publish: - await ctx.container().publish(image, platform_variants=variants) + existing_platforms = self.get_existing_platform_digests(image) + platforms_built = set(platforms_to_build) + old_refs = [ + f"{image}@{d}" + for p, d in existing_platforms.items() + if p not in platforms_built or p == "__unknown__" + ] + + published_ref = await ctx.container().publish(image, platform_variants=variants) + + if "@" not in published_ref: + logger.warning( + f"Publish result {published_ref} did not return a digest reference; " + "the image has been published but the manifest merge was skipped." + ) + elif old_refs: + digest = published_ref.split("@")[-1] + all_refs = [f"{image}@{digest}"] + old_refs + try: + subprocess.run( + ["docker", "buildx", "imagetools", "create", "--tag", image] + all_refs, + capture_output=True, text=True, check=True, timeout=60 + ) + except subprocess.CalledProcessError as e: + logger.warning( + f"Failed to merge existing platforms into manifest for {image}: " + f"{e.stderr}. The image has been published but may only contain " + "the platforms from this build." + ) # Define a coroutine function to execute the compilation process # for all flavors @@ -521,8 +556,7 @@ async def compile_images_for_all_flavors(self): secrets, dockerfile, image, - platforms_to_build, - platforms + platforms_to_build ) image_tag = image.split(":")[1] @@ -589,7 +623,7 @@ def get_flavor_data(self, flavor): dockerfile = flavor_data.dockerfile or "" extra_registries = flavor_data.extra_registries or [] extra_tags = flavor_data.extra_tags or [] - platforms = flavor_data.platforms or ["linux/amd64"] + platforms = list(dict.fromkeys(flavor_data.platforms or ["linux/amd64"])) return ( flavor_registry_data["name"], @@ -640,48 +674,6 @@ def get_extra_tags_for_registry(self, registry_address, extra_tags): return extra_full_registry_addresses - def _get_existing_platforms(self, image): - try: - proc = subprocess.run( - ['docker', 'manifest', 'inspect', image], - capture_output=True, text=True, - timeout=30 - ) - except (FileNotFoundError, OSError): - logger.info(f"Docker CLI not available, skipping registry manifest inspection for {image}") - return [] - except subprocess.TimeoutExpired: - logger.info(f"Timeout inspecting manifest for {image}, skipping") - return [] - if proc.returncode != 0: - return [] - try: - manifest = json.loads(proc.stdout) - except json.JSONDecodeError: - logger.info(f"Failed to parse manifest for {image}: non-JSON output") - return [] - platforms = [] - for m in manifest.get('manifests', []): - p = m.get('platform', {}) - os_val = p.get('os', 'linux') - arch = p.get('architecture') - variant = p.get('variant') - if arch: - platform_str = f"{os_val}/{arch}" - if variant: - platform_str = f"{platform_str}/{variant}" - platforms.append(platform_str) - - if not platforms: - # Single-arch manifest (schema2) — architecture is in config - config = manifest.get('config', {}) - arch = config.get('architecture') - os_val = config.get('os', 'linux') - if arch: - platforms.append(f"{os_val}/{arch}") - - return platforms - def is_auto_build(self): return self.flavors is None or len(self.flavors) == 0 diff --git a/firestarter/workflows/build_images/resources/schema.json b/firestarter/workflows/build_images/resources/schema.json index 8c583c2e..12bf8469 100644 --- a/firestarter/workflows/build_images/resources/schema.json +++ b/firestarter/workflows/build_images/resources/schema.json @@ -82,7 +82,8 @@ "type": "string", "pattern": "^(linux/)?(amd64|arm64)$" }, - "minItems": 1 + "minItems": 1, + "uniqueItems": true } }, "required": ["dockerfile"], @@ -174,7 +175,8 @@ "type": "string", "pattern": "^(linux/)?(amd64|arm64)$" }, - "minItems": 1 + "minItems": 1, + "uniqueItems": true } }, "required": ["dockerfile"], From 9681f2eeb728cbaef7f817e74fe6b0a5ad454683 Mon Sep 17 00:00:00 2001 From: juanjosevazquezgil <123170708+juanjosevazquezgil@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:58:55 +0200 Subject: [PATCH 11/11] fix: Improved publish flow (#225)