From 6c8b7d090e40704a1412bd3196ca680f83df25d1 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 07:49:46 -0600 Subject: [PATCH 01/18] Remove ORAS manifest delete from merge workflow The OrasManifestDelete command frequently failed when cleaning up the temporary manifest index after a merge. Remove the command and the in-line cleanup step; the temporary index is now left in place and cleaned up out-of-band by the clean.yml workflow (bakery clean temp-registry). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posit_bakery/plugins/builtin/oras/oras.py | 36 +++------------ .../test/plugins/builtin/oras/test_oras.py | 45 ++----------------- 2 files changed, 8 insertions(+), 73 deletions(-) diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 8032bc0d..4f71d2aa 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -142,24 +142,6 @@ def command(self) -> list[str]: return cmd -class OrasManifestDelete(OrasCommand): - """Delete a manifest from a registry. - - This command deletes a manifest (image or index) from a registry. - """ - - reference: Annotated[str, Field(description="The manifest reference to delete.")] - - @property - def command(self) -> list[str]: - """Build the oras manifest delete command.""" - cmd = [self.oras_bin, "manifest", "delete", "--force"] - if self.plain_http: - cmd.append("--plain-http") - cmd.append(self.reference) - return cmd - - class OrasMergeWorkflowResult(BaseModel): """Result of an ORAS merge workflow execution.""" @@ -175,7 +157,9 @@ class OrasMergeWorkflow(BaseModel): This workflow: 1. Creates a temporary manifest index from platform-specific source images 2. Copies the index to all target registries/tags - 3. Deletes the temporary index + + The temporary index is left in place and is cleaned up out-of-band by the + ``clean.yml`` workflow (``bakery clean temp-registry``) rather than deleted here. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -241,18 +225,8 @@ def run(self, dry_run: bool = False) -> OrasMergeWorkflowResult: ) copy_cmd.run(dry_run=dry_run) - # Step 3: Delete the temporary index (non-fatal) - log.info(f"Cleaning up temporary index {self.temp_index_tag}") - delete_cmd = OrasManifestDelete( - oras_bin=self.oras_bin, - reference=self.temp_index_tag, - plain_http=self.plain_http, - ) - try: - delete_cmd.run(dry_run=dry_run) - except BakeryToolRuntimeError as e: - log.warning(f"Failed to clean up temporary index {self.temp_index_tag}: {e}") - + # The temporary index is intentionally left in place; it is cleaned up + # out-of-band by the clean.yml workflow (bakery clean temp-registry). log.info(f"ORAS merge workflow completed successfully") return OrasMergeWorkflowResult( success=True, diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/oras/test_oras.py index a51e9e30..bda95b05 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras.py @@ -13,7 +13,6 @@ find_oras_bin, get_repository_from_ref, OrasCopy, - OrasManifestDelete, OrasManifestIndexCreate, OrasMergeWorkflow, OrasMergeWorkflowResult, @@ -188,34 +187,6 @@ def test_run_success(self): assert result.returncode == 0 -class TestOrasManifestDelete: - """Tests for the OrasManifestDelete command.""" - - def test_command_construction(self): - """Test that the command is constructed correctly.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="ghcr.io/posit-dev/test/tmp:tag", - ) - - expected = ["oras", "manifest", "delete", "--force", "ghcr.io/posit-dev/test/tmp:tag"] - assert cmd.command == expected - - def test_run_success(self): - """Test successful delete execution.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="ghcr.io/posit-dev/test/tmp:tag", - ) - - with patch("subprocess.run") as mock_run: - mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=0, stdout=b"", stderr=b"") - result = cmd.run() - - mock_run.assert_called_once_with(cmd.command, capture_output=True) - assert result.returncode == 0 - - class TestOrasMergeWorkflow: """Tests for the OrasMergeWorkflow orchestrator.""" @@ -295,8 +266,9 @@ def test_execute_success(self, basic_workflow): assert result.temp_index_ref is not None # Should have called: - # 1 create + 2 copy (grouped by destination) + 1 delete = 4 calls - assert mock_run.call_count == 4 + # 1 create + 2 copy (grouped by destination) = 3 calls. + # The temporary index is no longer deleted here; clean.yml handles it. + assert mock_run.call_count == 3 def test_execute_dry_run(self, basic_workflow): """Test dry run mode.""" @@ -519,17 +491,6 @@ def test_copy_with_plain_http(self): expected = ["oras", "cp", "--plain-http", "localhost:5000/test:source", "localhost:5000/test:dest"] assert cmd.command == expected - def test_manifest_delete_with_plain_http(self): - """Test that --plain-http flag is included when plain_http=True.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="localhost:5000/test:tag", - plain_http=True, - ) - - expected = ["oras", "manifest", "delete", "--force", "--plain-http", "localhost:5000/test:tag"] - assert cmd.command == expected - @pytest.mark.slow class TestOrasMergeWorkflowIntegration: From 3e431893c59172b219434e3bcafa4650480f1126 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:19:31 -0600 Subject: [PATCH 02/18] feat: add ImageTarget.temp_tag_name for stable temp refs Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posit_bakery/image/image_target.py | 13 +++++++++ posit-bakery/test/image/test_image_target.py | 27 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index 289385f4..d321d1ce 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -553,6 +553,19 @@ def temp_name(self) -> str | None: return f"{self.settings.temp_registry}/{self.image_name}/tmp" + @property + def temp_tag_name(self) -> str | None: + """Generate a stable, human-pullable temp tag for debug/index-only pushes. + + Unlike ``temp_name`` (pushed by digest), this is a single multi-arch tag at + ``{temp_registry}/{image_name}/tmp:{uid}`` used by ORAS index-only merges and + emulation tagged temp pushes so a developer can pull one ref. + """ + if not self.settings.temp_registry: + return None + + return f"{self.settings.temp_registry}/{self.image_name}/tmp:{self.uid}" + @property def resolved_build_secrets(self) -> list[BuildSecret]: """Return the parent Image's BuildSecrets whose envVar is set in the environment. diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index 1fc0e0e0..70abae84 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -1,20 +1,25 @@ import datetime import re +from pathlib import Path from unittest.mock import patch, MagicMock import pytest import python_on_whales +from posit_bakery.config import BakeryConfig +from posit_bakery.config.config import BakerySettings from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions from posit_bakery.config.image.parsed_version import ParsedVersion from posit_bakery.config.image.posit_product.const import ReleaseStreamEnum from posit_bakery.config.tag import default_tag_patterns, TagPatternFilter -from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX +from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX, DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.image.image_metadata import BuildMetadata from posit_bakery.image.image_target import ImageTarget, ImageTargetSettings, Tag from posit_bakery.settings import SETTINGS from test.helpers import remove_images, SUCCESS_SUITES +RESOURCES = Path(__file__).parent.parent / "resources" + pytestmark = [ pytest.mark.unit, ] @@ -1185,3 +1190,23 @@ def test_multi_image_grouped(self): ) names = [t.image_name for t in ordered] assert names == ["connect", "connect", "connect-content", "connect-content"] + + +def test_temp_tag_name_format(): + settings = BakerySettings( + temp_registry="ghcr.io/posit-dev", + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + ) + config = BakeryConfig.from_context(RESOURCES / "multiplatform", settings) + target = config.targets[0] + assert target.temp_tag_name == f"ghcr.io/posit-dev/{target.image_name}/tmp:{target.uid}" + + +def test_temp_tag_name_none_without_temp_registry(): + settings = BakerySettings( + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + ) + config = BakeryConfig.from_context(RESOURCES / "multiplatform", settings) + assert config.targets[0].temp_tag_name is None From 2dfa62e07b4a656f78d93daaee6b04b6e5413ee4 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:32:35 -0600 Subject: [PATCH 03/18] refactor: align temp_tag_name test with file style, trim docstring Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posit_bakery/image/image_target.py | 7 +--- posit-bakery/test/image/test_image_target.py | 36 ++++++------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index d321d1ce..73e545cc 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -555,12 +555,7 @@ def temp_name(self) -> str | None: @property def temp_tag_name(self) -> str | None: - """Generate a stable, human-pullable temp tag for debug/index-only pushes. - - Unlike ``temp_name`` (pushed by digest), this is a single multi-arch tag at - ``{temp_registry}/{image_name}/tmp:{uid}`` used by ORAS index-only merges and - emulation tagged temp pushes so a developer can pull one ref. - """ + """Generate a stable, human-pullable temp tag ({temp_registry}/{image_name}/tmp:{uid}) for temporary image storage in multiplatform split/merge builds.""" if not self.settings.temp_registry: return None diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index 70abae84..65be120f 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -1,25 +1,20 @@ import datetime import re -from pathlib import Path from unittest.mock import patch, MagicMock import pytest import python_on_whales -from posit_bakery.config import BakeryConfig -from posit_bakery.config.config import BakerySettings from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions from posit_bakery.config.image.parsed_version import ParsedVersion from posit_bakery.config.image.posit_product.const import ReleaseStreamEnum from posit_bakery.config.tag import default_tag_patterns, TagPatternFilter -from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX, DevVersionInclusionEnum, MatrixVersionInclusionEnum +from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX from posit_bakery.image.image_metadata import BuildMetadata from posit_bakery.image.image_target import ImageTarget, ImageTargetSettings, Tag from posit_bakery.settings import SETTINGS from test.helpers import remove_images, SUCCESS_SUITES -RESOURCES = Path(__file__).parent.parent / "resources" - pytestmark = [ pytest.mark.unit, ] @@ -660,6 +655,15 @@ def test_temp_name(self, basic_standard_image_target): basic_standard_image_target.settings = ImageTargetSettings(temp_registry="ghcr.io/posit-dev") assert basic_standard_image_target.temp_name == "ghcr.io/posit-dev/test-image/tmp" + def test_temp_tag_name(self, basic_standard_image_target): + """Test the temp_tag_name property of an ImageTarget.""" + assert basic_standard_image_target.temp_tag_name is None + basic_standard_image_target.settings = ImageTargetSettings(temp_registry="ghcr.io/posit-dev") + assert ( + basic_standard_image_target.temp_tag_name + == f"ghcr.io/posit-dev/test-image/tmp:{basic_standard_image_target.uid}" + ) + @pytest.mark.build def test_build_args(self, basic_standard_image_target): """Test the build property of an ImageTarget.""" @@ -1190,23 +1194,3 @@ def test_multi_image_grouped(self): ) names = [t.image_name for t in ordered] assert names == ["connect", "connect", "connect-content", "connect-content"] - - -def test_temp_tag_name_format(): - settings = BakerySettings( - temp_registry="ghcr.io/posit-dev", - dev_versions=DevVersionInclusionEnum.INCLUDE, - matrix_versions=MatrixVersionInclusionEnum.INCLUDE, - ) - config = BakeryConfig.from_context(RESOURCES / "multiplatform", settings) - target = config.targets[0] - assert target.temp_tag_name == f"ghcr.io/posit-dev/{target.image_name}/tmp:{target.uid}" - - -def test_temp_tag_name_none_without_temp_registry(): - settings = BakerySettings( - dev_versions=DevVersionInclusionEnum.INCLUDE, - matrix_versions=MatrixVersionInclusionEnum.INCLUDE, - ) - config = BakeryConfig.from_context(RESOURCES / "multiplatform", settings) - assert config.targets[0].temp_tag_name is None From 4fd5116a7a0f9b15767955b5b07dc3bfde497648 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:34:52 -0600 Subject: [PATCH 04/18] feat: add index_only mode to OrasMergeWorkflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posit_bakery/plugins/builtin/oras/oras.py | 48 +++++++++++-------- .../test/plugins/builtin/oras/test_oras.py | 24 ++++++++++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 4f71d2aa..3a88c918 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -189,57 +189,65 @@ def temp_index_tag(self) -> str: f"{self.image_target.temp_registry}/{self.image_target.image_name}/tmp:{self.image_target.uid}{source_hash}" ) - def run(self, dry_run: bool = False) -> OrasMergeWorkflowResult: + def run(self, dry_run: bool = False, index_only: bool = False) -> OrasMergeWorkflowResult: """Run the merge workflow. :param dry_run: If True, log commands without executing them. + :param index_only: If True, create and push the multi-arch index to the stable + temp tag (``temp_tag_name``) but skip copying it to the final destinations. :return: Result of the workflow execution. """ + index_dest = self.image_target.temp_tag_name if index_only else self.temp_index_tag + log.info(f"Starting ORAS merge workflow for {self.image_target.image_name}") log.debug(f"Sources: {self.sources}") - log.debug(f"Temporary index: {self.temp_index_tag}") - log.debug(f"Destinations: {', '.join(self.image_target.tags.as_strings())}") + log.debug(f"Index: {index_dest}") + if not index_only: + log.debug(f"Destinations: {', '.join(self.image_target.tags.as_strings())}") try: # Step 1: Create the manifest index - log.info(f"Creating manifest index at {self.temp_index_tag}") + log.info(f"Creating manifest index at {index_dest}") create_cmd = OrasManifestIndexCreate( oras_bin=self.oras_bin, sources=self.image_target.get_merge_sources(), - destination=self.temp_index_tag, + destination=index_dest, annotations=self.annotations, plain_http=self.plain_http, ) create_cmd.run(dry_run=dry_run) - # Step 2: Copy to all destinations - for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): - log.info(f"Copying index to {destination}") - combine_tag_str = destination + ":" + ",".join(tag.suffix for tag in tags) - copy_cmd = OrasCopy( - oras_bin=self.oras_bin, - source=self.temp_index_tag, - destination=combine_tag_str, - plain_http=self.plain_http, - ) - copy_cmd.run(dry_run=dry_run) + # Step 2: Copy to all destinations (skipped in index-only mode) + destinations: list[str] = [] + if not index_only: + for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): + log.info(f"Copying index to {destination}") + combine_tag_str = destination + ":" + ",".join(tag.suffix for tag in tags) + copy_cmd = OrasCopy( + oras_bin=self.oras_bin, + source=index_dest, + destination=combine_tag_str, + plain_http=self.plain_http, + ) + copy_cmd.run(dry_run=dry_run) + destinations = self.image_target.tags.as_strings() # The temporary index is intentionally left in place; it is cleaned up # out-of-band by the clean.yml workflow (bakery clean temp-registry). log.info(f"ORAS merge workflow completed successfully") return OrasMergeWorkflowResult( success=True, - temp_index_ref=self.temp_index_tag, - destinations=self.image_target.tags.as_strings(), + temp_index_ref=index_dest, + destinations=destinations, ) except BakeryToolRuntimeError as e: log.error(f"ORAS merge workflow failed: {e}") return OrasMergeWorkflowResult( success=False, - temp_index_ref=self.temp_index_tag, - destinations=self.image_target.tags.as_strings(), + temp_index_ref=index_dest, + destinations=[] if index_only else self.image_target.tags.as_strings(), error=str(e), ) diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/oras/test_oras.py index bda95b05..07f99ad0 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras.py @@ -227,6 +227,7 @@ def mock_image_target(self): mock_tag4.__str__ = lambda self: "docker.io/posit/test-image:latest" mock_target.tags = StringableList([mock_tag1, mock_tag2, mock_tag3, mock_tag4]) + mock_target.temp_tag_name = "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" return mock_target @pytest.fixture @@ -279,6 +280,29 @@ def test_execute_dry_run(self, basic_workflow): assert result.success is True assert len(result.destinations) == 4 + def test_index_only_creates_index_and_skips_copy(self, basic_workflow): + """index_only runs only the create step (1 call), no copy-to-final.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = basic_workflow.run(index_only=True) + + assert result.success is True + assert result.error is None + # Only the index-create call runs; the 2 copy calls are skipped. + assert mock_run.call_count == 1 + # The index is pushed to the stable temp tag, and no final destinations are reported. + assert result.temp_index_ref == "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" + assert result.destinations == [] + + def test_index_only_dry_run_runs_nothing(self, basic_workflow): + """dry_run takes precedence over index_only.""" + with patch("subprocess.run") as mock_run: + result = basic_workflow.run(dry_run=True, index_only=True) + + mock_run.assert_not_called() + assert result.success is True + assert result.temp_index_ref == "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" + def test_execute_failure_on_create(self, basic_workflow): """Test workflow handles failure during index creation.""" with patch("subprocess.run") as mock_run: From d23635e431f9ae271559acb954dade87a36dc3b6 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:38:12 -0600 Subject: [PATCH 05/18] fix: guard index_only merge against missing temp_registry Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/plugins/builtin/oras/oras.py | 3 +++ posit-bakery/test/plugins/builtin/oras/test_oras.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 3a88c918..ae050702 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -198,6 +198,9 @@ def run(self, dry_run: bool = False, index_only: bool = False) -> OrasMergeWorkf :return: Result of the workflow execution. """ + if index_only and self.image_target.temp_tag_name is None: + raise ValueError("index_only mode requires temp_registry to be configured.") + index_dest = self.image_target.temp_tag_name if index_only else self.temp_index_tag log.info(f"Starting ORAS merge workflow for {self.image_target.image_name}") diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/oras/test_oras.py index 07f99ad0..decbcd14 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras.py @@ -303,6 +303,12 @@ def test_index_only_dry_run_runs_nothing(self, basic_workflow): assert result.success is True assert result.temp_index_ref == "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" + def test_index_only_without_temp_registry_raises(self, basic_workflow): + """index_only requires temp_registry; a None temp_tag_name is a clear error.""" + basic_workflow.image_target.temp_tag_name = None + with pytest.raises(ValueError, match="temp_registry"): + basic_workflow.run(index_only=True) + def test_execute_failure_on_create(self, basic_workflow): """Test workflow handles failure during index creation.""" with patch("subprocess.run") as mock_run: From c216937b59012d47eef5f244b0fce52c5b32786c Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:40:35 -0600 Subject: [PATCH 06/18] feat: thread index_only through OrasPlugin.execute Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/builtin/oras/__init__.py | 3 ++- .../plugins/builtin/oras/test_oras_plugin.py | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py index 414338ef..c7b92588 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py @@ -102,6 +102,7 @@ def execute( targets: list[ImageTarget], *, dry_run: bool = False, + index_only: bool = False, **kwargs, ) -> list[ToolCallResult]: """Execute ORAS merge workflow against the given image targets.""" @@ -131,7 +132,7 @@ def execute( log.info(f"Merging sources for image UID '{target.uid}'") workflow = OrasMergeWorkflow.from_image_target(target) - workflow_result = workflow.run(dry_run=dry_run) + workflow_result = workflow.run(dry_run=dry_run, index_only=index_only) results.append( ToolCallResult( diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py b/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py index 87bb1c8f..60ef9e4e 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py @@ -44,6 +44,7 @@ def mock_target_with_sources(): mock_tag.suffix = "1.0.0" mock_tag.__str__ = lambda self: "ghcr.io/posit-dev/test-image:1.0.0" mock_target.tags = StringableList([mock_tag]) + mock_target.temp_tag_name = "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" return mock_target @@ -141,6 +142,26 @@ def test_execute_dry_run(self, plugin, mock_target_with_sources): assert results[0].exit_code == 0 assert results[0].artifacts["workflow_result"].success is True + def test_execute_index_only(self, plugin, mock_target_with_sources): + with ( + patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + results = plugin.execute( + Path("/project"), + [mock_target_with_sources], + index_only=True, + ) + + assert len(results) == 1 + assert results[0].exit_code == 0 + wf = results[0].artifacts["workflow_result"] + assert wf.success is True + assert wf.destinations == [] + # Only the index-create subprocess call ran (no copy-to-final). + assert mock_run.call_count == 1 + def test_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_target_without_sources): with ( patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), @@ -191,7 +212,7 @@ def make_target(name, sort_key): call_order = [] - def fake_run(self_workflow, dry_run=False): + def fake_run(self_workflow, dry_run=False, index_only=False): call_order.append(self_workflow.image_target.image_name) return OrasMergeWorkflowResult(success=True, destinations=[]) From 26a390d1f52ad3173647d710c12eb758a74576fc Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:47:28 -0600 Subject: [PATCH 07/18] feat: add --index-only to bakery ci merge Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/ci.py | 22 ++++++++-- posit-bakery/test/cli/test_ci.py | 68 ++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index eae6af4c..241b06aa 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -169,6 +169,13 @@ def merge( dry_run: Annotated[ bool, typer.Option(help="If set, the merged images will not be pushed to the registry.") ] = False, + index_only: Annotated[ + bool, + typer.Option( + help="Create and push the multi-arch index to the temp registry but do not copy it to " + "the final destination tags. Useful for publishing debug artifacts on non-release builds.", + ), + ] = False, dev_stream: Annotated[ Optional[ReleaseStreamEnum], typer.Option( @@ -235,15 +242,22 @@ def merge( from posit_bakery.plugins.registry import get_plugin oras = get_plugin("oras") - results = oras.execute(config.base_path, config.targets, dry_run=dry_run) + results = oras.execute(config.base_path, config.targets, dry_run=dry_run, index_only=index_only) - # CI-specific: verify final manifests with imagetools inspect + # CI-specific: verify the pushed manifest with imagetools inspect if not dry_run: for result in results: if result.exit_code == 0 and result.artifacts: workflow_result = result.artifacts.get("workflow_result") - if workflow_result and workflow_result.destinations: - manifest = python_on_whales.docker.buildx.imagetools.inspect(workflow_result.destinations[0]) + if not workflow_result: + continue + ref = ( + workflow_result.temp_index_ref + if index_only + else (workflow_result.destinations[0] if workflow_result.destinations else None) + ) + if ref: + manifest = python_on_whales.docker.buildx.imagetools.inspect(ref) stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True)) oras.results(results) diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 4c2992ea..375fc224 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -52,7 +52,8 @@ def patched_execute(base_path, targets, platform=None, **kwargs): if not sources: continue dry_run = kwargs.get("dry_run", False) - calls.append((sources, dry_run)) + index_only = kwargs.get("index_only", False) + calls.append((sources, dry_run, index_only)) results.append( ToolCallResult( exit_code=0, @@ -60,7 +61,7 @@ def patched_execute(base_path, targets, platform=None, **kwargs): target=target, stdout="", stderr="", - artifacts={"workflow_result": MagicMock(success=True, destinations=[])}, + artifacts={"workflow_result": MagicMock(success=True, destinations=[], temp_index_ref=None)}, ) ) return results @@ -100,6 +101,69 @@ def check_log_metadata_targets(bakery_command, datatable, ci_patched_merge_metho assert expected in calls +class TestMergeIndexOnly: + def test_merge_index_only_passes_flag(self, mocker, tmp_path): + """--index-only CLI flag should thread through to oras.execute as index_only=True.""" + import shutil + from pathlib import Path + from unittest.mock import MagicMock + + from posit_bakery.plugins.protocol import ToolCallResult + from test.cli.bakery_command import BakeryCommand + + calls = [] + + def patched_execute(base_path, targets, platform=None, **kwargs): + results = [] + for target in targets: + try: + sources = target.get_merge_sources() + except Exception: + continue + if not sources: + continue + dry_run = kwargs.get("dry_run", False) + index_only = kwargs.get("index_only", False) + calls.append((sources, dry_run, index_only)) + results.append( + ToolCallResult( + exit_code=0, + tool_name="oras", + target=target, + stdout="", + stderr="", + artifacts={"workflow_result": MagicMock(success=True, destinations=[], temp_index_ref=None)}, + ) + ) + return results + + mock_plugin = MagicMock() + mock_plugin.execute = patched_execute + mock_plugin.results = MagicMock() + mocker.patch("posit_bakery.plugins.registry.get_plugin", return_value=mock_plugin) + + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + # Copy the existing testdata metadata files so we get real targets loaded + testdata = Path(__file__).parent / "testdata" / "ci" / "merge" / "multiplatform" + for f in testdata.glob("*.json"): + shutil.copy(f, tmp_path / f.name) + + cmd = BakeryCommand() + cmd.context = resource + cmd.set_subcommand(["ci", "merge"]) + cmd.add_args(["--temp-registry", "ghcr.io/posit-dev", "--index-only"]) + cmd.add_args([str(p) for p in sorted(tmp_path.glob("*.json"))]) + cmd.run() + + assert cmd.result.exit_code == 0, cmd.result.output + # The fixture must have recorded at least one call (the testdata has 4 targets) + assert calls, "Expected at least one merge call to be recorded" + # Every recorded call must carry index_only=True + assert all(index_only for (_sources, _dry_run, index_only) in calls), ( + f"Some calls did not have index_only=True: {calls}" + ) + + class TestVersionMatches: @pytest.mark.parametrize( "ver_name,filter_version", From 58e0c73392f690809dc2316f4d44a8dcb027adfc Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:53:47 -0600 Subject: [PATCH 08/18] refactor: hoist test imports and panel the --index-only option Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/ci.py | 1 + posit-bakery/test/cli/test_ci.py | 10 +++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index 241b06aa..9afdc0e6 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -174,6 +174,7 @@ def merge( typer.Option( help="Create and push the multi-arch index to the temp registry but do not copy it to " "the final destination tags. Useful for publishing debug artifacts on non-release builds.", + rich_help_panel="Build Configuration & Outputs", ), ] = False, dev_stream: Annotated[ diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 375fc224..23669271 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -1,5 +1,7 @@ import json import re +import shutil +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -8,6 +10,7 @@ from posit_bakery.config.config import version_matches from posit_bakery.plugins.protocol import ToolCallResult +from test.cli.bakery_command import BakeryCommand scenarios( "cli/ci/matrix.feature", @@ -104,13 +107,6 @@ def check_log_metadata_targets(bakery_command, datatable, ci_patched_merge_metho class TestMergeIndexOnly: def test_merge_index_only_passes_flag(self, mocker, tmp_path): """--index-only CLI flag should thread through to oras.execute as index_only=True.""" - import shutil - from pathlib import Path - from unittest.mock import MagicMock - - from posit_bakery.plugins.protocol import ToolCallResult - from test.cli.bakery_command import BakeryCommand - calls = [] def patched_execute(base_path, targets, platform=None, **kwargs): From 02804ef99ba91f50712d3c23e87d0129efecf1d9 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 12:56:52 -0600 Subject: [PATCH 09/18] feat: add bakery ci summary command for CI job summaries Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/ci.py | 96 +++++++++++++++++++++++++++++ posit-bakery/test/cli/test_ci.py | 92 +++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index 9afdc0e6..e1bf6b51 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -21,6 +21,29 @@ log = logging.getLogger(__name__) +def render_artifact_summary(targets, mode: str) -> str: + """Render a GitHub-flavored markdown table of image references for the job summary. + + :param targets: Iterable of ImageTarget. + :param mode: ``temp`` to report the stable temp index ref (``temp_tag_name``), + ``final`` to report each final destination tag. + :return: Markdown string. + """ + title = "Build Artifacts (temporary registry)" if mode == "temp" else "Build Artifacts (pushed)" + lines = [f"## {title}", "", "| Image | Version | Reference | Pull |", "|---|---|---|---|"] + + for target in targets: + if mode == "temp": + refs = [target.temp_tag_name] if target.temp_tag_name else [] + else: + refs = target.tags.as_strings() + for ref in refs: + lines.append(f"| {target.image_name} | {target.image_version.name} | `{ref}` | `docker pull {ref}` |") + + lines.append("") + return "\n".join(lines) + + class RichHelpPanelEnum(str, Enum): """Enum for categorizing options into rich help panels.""" @@ -327,3 +350,76 @@ def readme( stderr_console.print(f"✅ Pushed {count} README(s) to Docker Hub", style="success") else: stderr_console.print("No READMEs pushed", style="dim") + + +@app.command() +@with_verbosity_flags +def summary( + mode: Annotated[ + str, + typer.Option(help="Which references to report: 'temp' (temp registry index) or 'final' (pushed tags)."), + ] = "temp", + context: Annotated[ + Path, typer.Option(help="The root path to use. Defaults to the current working directory where invoked.") + ] = auto_path(), + temp_registry: Annotated[ + Optional[str], + typer.Option(help="Temporary registry used for temp refs (required for --mode temp)."), + ] = None, + dev_versions: Annotated[ + Optional[DevVersionInclusionEnum], + typer.Option( + help="Include or exclude development versions defined in config.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = DevVersionInclusionEnum.EXCLUDE, + dev_stream: Annotated[ + Optional[ReleaseStreamEnum], + typer.Option( + help="Filter development versions to a specific release stream.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + matrix_versions: Annotated[ + Optional[MatrixVersionInclusionEnum], + typer.Option( + help="Include or exclude versions defined in image matrix.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = MatrixVersionInclusionEnum.EXCLUDE, + image_version: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image version to filter to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + output: Annotated[ + Optional[Path], + typer.Option(writable=True, help="Write the markdown summary to this path instead of stdout."), + ] = None, +) -> None: + """Render a markdown table of build artifact image references for a CI job summary.""" + if mode not in ("temp", "final"): + log.error(f"Invalid --mode '{mode}'; expected 'temp' or 'final'.") + raise typer.Exit(code=1) + if mode == "temp" and not temp_registry: + log.error("--temp-registry is required when --mode temp.") + raise typer.Exit(code=1) + + settings = BakerySettings( + filter=BakeryConfigFilter(image_version=image_version), + dev_versions=dev_versions, + dev_stream=dev_stream, + matrix_versions=matrix_versions, + temp_registry=temp_registry, + ) + config: BakeryConfig = BakeryConfig.from_context(context, settings) + + md = render_artifact_summary(config.targets, mode=mode) + + if output is not None: + output.write_text(md + "\n") + else: + stdout_console.print(md) diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 23669271..6b0bfd35 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -160,6 +160,98 @@ def patched_execute(base_path, targets, platform=None, **kwargs): ) +from posit_bakery.cli.ci import render_artifact_summary + + +def _summary_mock_target(image_name, temp_tag, tag_strings): + t = MagicMock() + t.image_name = image_name + t.image_version.name = "1.0.0" + t.temp_tag_name = temp_tag + tags = MagicMock() + tags.as_strings.return_value = tag_strings + t.tags = tags + return t + + +def test_render_artifact_summary_temp(): + target = _summary_mock_target( + "connect", + "ghcr.io/posit-dev/connect/tmp:connect-1-0-0", + ["ghcr.io/posit-dev/connect:1.0.0"], + ) + md = render_artifact_summary([target], mode="temp") + + assert "Build Artifacts" in md + assert "ghcr.io/posit-dev/connect/tmp:connect-1-0-0" in md + assert "docker pull ghcr.io/posit-dev/connect/tmp:connect-1-0-0" in md + # temp mode must not advertise the final destination tag + assert "ghcr.io/posit-dev/connect:1.0.0" not in md + + +def test_render_artifact_summary_final(): + target = _summary_mock_target( + "connect", + "ghcr.io/posit-dev/connect/tmp:connect-1-0-0", + ["ghcr.io/posit-dev/connect:1.0.0", "docker.io/posit/connect:1.0.0"], + ) + md = render_artifact_summary([target], mode="final") + + assert "ghcr.io/posit-dev/connect:1.0.0" in md + assert "docker.io/posit/connect:1.0.0" in md + # final mode must not advertise the temp ref + assert "/tmp:connect-1-0-0" not in md + + +def test_summary_cli_temp_mode(tmp_path): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + out = tmp_path / "summary.md" + + result = runner.invoke( + app, + [ + "ci", + "summary", + "--mode", + "temp", + "--temp-registry", + "ghcr.io/posit-dev", + "--matrix-versions", + "include", + "--context", + str(resource), + "--output", + str(out), + ], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + + assert result.exit_code == 0, result.output + body = out.read_text() + assert "Build Artifacts" in body + assert "test-multi" in body + assert "/tmp:" in body + assert "docker pull ghcr.io/posit-dev/test-multi/tmp:" in body + + +def test_summary_cli_temp_mode_requires_temp_registry(): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + result = runner.invoke( + app, + ["ci", "summary", "--mode", "temp", "--context", str(resource)], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + assert result.exit_code == 1 + + class TestVersionMatches: @pytest.mark.parametrize( "ver_name,filter_version", From ec0a2e5100eb7cf1c9cdbe35729f675e2adc138b Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:02:52 -0600 Subject: [PATCH 10/18] refactor: use enum for ci summary --mode, validate context, hoist import Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/ci.py | 27 ++++++++++++++++++--------- posit-bakery/test/cli/test_ci.py | 18 +++++++++++++++--- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index e1bf6b51..ab9d94f4 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -40,7 +40,6 @@ def render_artifact_summary(targets, mode: str) -> str: for ref in refs: lines.append(f"| {target.image_name} | {target.image_version.name} | `{ref}` | `docker pull {ref}` |") - lines.append("") return "\n".join(lines) @@ -50,6 +49,11 @@ class RichHelpPanelEnum(str, Enum): FILTERS = "Filters" +class SummaryModeEnum(str, Enum): + TEMP = "temp" + FINAL = "final" + + class BakeryCIMatrixFieldEnum(str, Enum): VERSION = "version" DEV = "dev" @@ -356,11 +360,19 @@ def readme( @with_verbosity_flags def summary( mode: Annotated[ - str, + SummaryModeEnum, typer.Option(help="Which references to report: 'temp' (temp registry index) or 'final' (pushed tags)."), - ] = "temp", + ] = SummaryModeEnum.TEMP, context: Annotated[ - Path, typer.Option(help="The root path to use. Defaults to the current working directory where invoked.") + Path, + typer.Option( + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help="The root path to use. Defaults to the current working directory where invoked.", + ), ] = auto_path(), temp_registry: Annotated[ Optional[str], @@ -401,10 +413,7 @@ def summary( ] = None, ) -> None: """Render a markdown table of build artifact image references for a CI job summary.""" - if mode not in ("temp", "final"): - log.error(f"Invalid --mode '{mode}'; expected 'temp' or 'final'.") - raise typer.Exit(code=1) - if mode == "temp" and not temp_registry: + if mode == SummaryModeEnum.TEMP and not temp_registry: log.error("--temp-registry is required when --mode temp.") raise typer.Exit(code=1) @@ -417,7 +426,7 @@ def summary( ) config: BakeryConfig = BakeryConfig.from_context(context, settings) - md = render_artifact_summary(config.targets, mode=mode) + md = render_artifact_summary(config.targets, mode=mode.value) if output is not None: output.write_text(md + "\n") diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 6b0bfd35..a425579a 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -7,6 +7,7 @@ import pytest from pytest_bdd import scenarios, then, parsers, given +from posit_bakery.cli.ci import render_artifact_summary from posit_bakery.config.config import version_matches from posit_bakery.plugins.protocol import ToolCallResult @@ -160,9 +161,6 @@ def patched_execute(base_path, targets, platform=None, **kwargs): ) -from posit_bakery.cli.ci import render_artifact_summary - - def _summary_mock_target(image_name, temp_tag, tag_strings): t = MagicMock() t.image_name = image_name @@ -252,6 +250,20 @@ def test_summary_cli_temp_mode_requires_temp_registry(): assert result.exit_code == 1 +def test_summary_cli_invalid_mode_exits_nonzero(): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + result = runner.invoke( + app, + ["ci", "summary", "--mode", "bogus", "--context", str(resource)], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + assert result.exit_code != 0 + + class TestVersionMatches: @pytest.mark.parametrize( "ver_name,filter_version", From 959fc89a8996e291cd417c2a9d731302c0cbe116 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:05:20 -0600 Subject: [PATCH 11/18] feat: add temp_tagged setting and --temp-tagged build flag Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/build.py | 9 +++++++++ posit-bakery/posit_bakery/config/config.py | 8 ++++++++ posit-bakery/test/config/test_config.py | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/posit-bakery/posit_bakery/cli/build.py b/posit-bakery/posit_bakery/cli/build.py index d8449e38..7ef1379f 100644 --- a/posit-bakery/posit_bakery/cli/build.py +++ b/posit-bakery/posit_bakery/cli/build.py @@ -133,6 +133,14 @@ def build( rich_help_panel="Build Configuration & Outputs", ), ] = None, + temp_tagged: Annotated[ + bool, + typer.Option( + help="Push a single multi-arch tag to the temp registry (tmp:{uid}) instead of by digest. " + "Requires --temp-registry and --push.", + rich_help_panel="Build Configuration & Outputs", + ), + ] = False, image_name: Annotated[ Optional[str], typer.Option( @@ -217,6 +225,7 @@ def build( clean_temporary=clean, cache_registry=cache_registry, temp_registry=temp_registry, + temp_tagged=temp_tagged, ) config: BakeryConfig = BakeryConfig.from_context(context, settings) diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index be58e5af..2801430f 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -326,6 +326,14 @@ class BakerySettings(BaseModel): ] cache_registry: Annotated[str | None, Field(description="Registry to use for image build cache.", default=None)] temp_registry: Annotated[str | None, Field(description="Registry to use for image build temp cache.", default=None)] + temp_tagged: Annotated[ + bool, + Field( + description="When pushing to the temp registry, push a single multi-arch tag " + "({temp_registry}/{image_name}/tmp:{uid}) instead of pushing by digest.", + default=False, + ), + ] class BakeryConfig: diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index 19268970..7121e96f 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -1979,3 +1979,11 @@ def test_clean_temporary_dry_run( mock_ghcr_client.assert_called_once() mock_ghcr_client_instance.get_package_versions.assert_called_once() mock_ghcr_client_instance.delete_package_version.assert_not_called() + + +class TestBakerySettings: + def test_bakery_settings_temp_tagged_default(self): + assert BakerySettings().temp_tagged is False + + def test_bakery_settings_temp_tagged_set(self): + assert BakerySettings(temp_tagged=True).temp_tagged is True From 7f2fdb637b2399c586006e373350b91b1d901b4a Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:10:18 -0600 Subject: [PATCH 12/18] feat: push tagged multi-arch temp ref when temp_tagged is set Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/config/config.py | 6 +++-- posit-bakery/posit_bakery/image/bake/bake.py | 4 +++- .../posit_bakery/image/image_target.py | 12 +++++++++- posit-bakery/test/image/bake/test_bake.py | 24 +++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index 2801430f..29f72d83 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -896,7 +896,9 @@ def generate_image_targets(self, settings: BakerySettings = BakerySettings()): image_variant=variant, image_os=_os, settings=ImageTargetSettings( - temp_registry=settings.temp_registry, cache_registry=settings.cache_registry + temp_registry=settings.temp_registry, + temp_tagged=settings.temp_tagged, + cache_registry=settings.cache_registry, ), ) ) @@ -1001,7 +1003,7 @@ def build_targets( context=self.base_path, image_targets=self.targets, platforms=platforms, push=push ) set_opts = None - if self.settings.temp_registry is not None and push: + if self.settings.temp_registry is not None and push and not self.settings.temp_tagged: set_opts = {"*.output": {"type": "image", "push-by-digest": True, "name-canonical": True, "push": True}} _retry_build( lambda: bake_plan.build( diff --git a/posit-bakery/posit_bakery/image/bake/bake.py b/posit-bakery/posit_bakery/image/bake/bake.py index 18c464e6..5bb277ec 100644 --- a/posit-bakery/posit_bakery/image/bake/bake.py +++ b/posit-bakery/posit_bakery/image/bake/bake.py @@ -135,7 +135,9 @@ def from_image_target( } ] - if image_target.temp_name is not None: + if image_target.settings.temp_tagged and image_target.temp_tag_name is not None: + kwargs["tags"] = [image_target.temp_tag_name] + elif image_target.temp_name is not None: kwargs["tags"] = [image_target.temp_name.rsplit(":", 1)[0]] secrets = [s.as_bake_json() for s in image_target.resolved_build_secrets] diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index 73e545cc..77455737 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -212,6 +212,13 @@ class ImageTargetSettings(BaseModel): str | None, Field(default=None, description="Temporary registry to use for multiplatform split/merge builds."), ] + temp_tagged: Annotated[ + bool, + Field( + default=False, + description="When True, push a single multi-arch tag to the temp registry instead of pushing by digest.", + ), + ] cache_registry: Annotated[ str | None, Field(default=None, description="Registry to use for build cache storage and retrieval."), @@ -652,7 +659,10 @@ def build( tags = self.tags.as_strings() output = {} - if self.temp_name is not None: + if self.settings.temp_tagged and self.temp_tag_name is not None: + # Push a single multi-arch tag to the temp registry instead of by digest. + tags = [self.temp_tag_name] + elif self.temp_name is not None: tags = [self.temp_name] # If push is true for a temporary image, override the output to push the image by digest. if push: diff --git a/posit-bakery/test/image/bake/test_bake.py b/posit-bakery/test/image/bake/test_bake.py index 351d57be..f70cb120 100644 --- a/posit-bakery/test/image/bake/test_bake.py +++ b/posit-bakery/test/image/bake/test_bake.py @@ -58,6 +58,30 @@ def _get_expected_plan(result_set: str, suite_name: str) -> Path: class TestBakeTarget: + def test_bake_target_uses_tagged_temp_ref_when_temp_tagged(self): + """Test that BakeTarget uses temp_tag_name when temp_tagged is set.""" + from posit_bakery.config import BakeryConfig + from posit_bakery.config.config import BakerySettings + from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum + + resources = Path(__file__).parent + while not (resources / "resources").is_dir(): + resources = resources.parent + settings = BakerySettings( + temp_registry="ghcr.io/posit-dev", + temp_tagged=True, + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + ) + config = BakeryConfig.from_context(resources / "resources" / "multiplatform", settings) + target = config.targets[0] + + bt = BakeTarget.from_image_target(target, push=True) + + assert bt.tags == [target.temp_tag_name] + # The tag must include a :uid suffix (not a bare /tmp ref). + assert ":" in bt.tags[0].rsplit("/", 1)[1] + def test_from_image_target(self, basic_standard_image_target): """Test that BakeTarget can be created from an ImageTarget.""" bake_target = BakeTarget.from_image_target(basic_standard_image_target) From 314a2bb36f1385a86e52199d6532a3ac5e90226b Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:21:14 -0600 Subject: [PATCH 13/18] test: tidy temp_tagged bake test (hoist imports, explicit fixture path) Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/test/image/bake/test_bake.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/posit-bakery/test/image/bake/test_bake.py b/posit-bakery/test/image/bake/test_bake.py index f70cb120..46398078 100644 --- a/posit-bakery/test/image/bake/test_bake.py +++ b/posit-bakery/test/image/bake/test_bake.py @@ -6,6 +6,9 @@ import python_on_whales from pytest_mock import MockFixture +from posit_bakery.config import BakeryConfig +from posit_bakery.config.config import BakerySettings +from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.image.bake import BakePlan from posit_bakery.image.bake.bake import BakeTarget, BakeGroup from posit_bakery.image.image_target import ImageTargetSettings @@ -60,20 +63,15 @@ def _get_expected_plan(result_set: str, suite_name: str) -> Path: class TestBakeTarget: def test_bake_target_uses_tagged_temp_ref_when_temp_tagged(self): """Test that BakeTarget uses temp_tag_name when temp_tagged is set.""" - from posit_bakery.config import BakeryConfig - from posit_bakery.config.config import BakerySettings - from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum - - resources = Path(__file__).parent - while not (resources / "resources").is_dir(): - resources = resources.parent + resources = Path(__file__).parents[2] / "resources" / "multiplatform" settings = BakerySettings( temp_registry="ghcr.io/posit-dev", temp_tagged=True, dev_versions=DevVersionInclusionEnum.INCLUDE, matrix_versions=MatrixVersionInclusionEnum.INCLUDE, ) - config = BakeryConfig.from_context(resources / "resources" / "multiplatform", settings) + config = BakeryConfig.from_context(resources, settings) + assert config.targets, "multiplatform fixture produced no targets" target = config.targets[0] bt = BakeTarget.from_image_target(target, push=True) From 109362acc77197b17f51b84eb3abbb04cd69c289 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:24:04 -0600 Subject: [PATCH 14/18] feat: tri-state push (off/temp/on) in bakery-build-native workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/bakery-build-native.yml | 55 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 0bdcff59..29e05aaf 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -38,10 +38,10 @@ on: required: false type: string push: - description: "Whether to push images to registries [default: false]" - default: false + description: "Push target [default: off] [options: off (no push), temp (multi-arch index to temp registry only), on (push to final registries)]" + default: "off" required: false - type: boolean + type: string retry: description: "Number of times to retry a failed build [default: 1]" default: 1 @@ -109,6 +109,15 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Validate push input + env: + PUSH: ${{ inputs.push }} + run: | + case "$PUSH" in + off|temp|on) ;; + *) echo "::error::Invalid push value '$PUSH'; expected off, temp, or on." ; exit 1 ;; + esac + - name: Install uses: "posit-dev/images-shared/setup-bakery@main" with: @@ -188,14 +197,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -203,7 +212,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Normalize platform @@ -320,14 +329,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -335,7 +344,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Setup docker buildx @@ -361,18 +370,42 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} PUSH: ${{ inputs.push }} run: | - if [ "$PUSH" = "true" ]; then PUSH_FLAG=""; else PUSH_FLAG="--dry-run"; fi + case "$PUSH" in + on) PUSH_FLAG="" ;; + temp) PUSH_FLAG="--index-only" ;; + *) PUSH_FLAG="--dry-run" ;; + esac bakery ci merge \ --context "$CONTEXT" \ --temp-registry "$REGISTRY" \ $PUSH_FLAG \ ./*-metadata.json + - name: Build Artifact Summary + if: ${{ inputs.push != 'off' }} + env: + CONTEXT: ${{ inputs.context }} + REGISTRY: ghcr.io/${{ github.repository_owner }} + PUSH: ${{ inputs.push }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + IMAGE_VERSION: ${{ inputs.image-version }} + DEV_STREAM: ${{ inputs.dev-stream }} + run: | + IMAGE_VERSION="${IMAGE_VERSION#v}" + if [ "$PUSH" = "on" ]; then MODE="final"; else MODE="temp"; fi + ARGS=(--mode "$MODE" --temp-registry "$REGISTRY" --context "$CONTEXT" + --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS") + [[ -n "$IMAGE_VERSION" ]] && ARGS+=(--image-version "$IMAGE_VERSION") + [[ -n "$DEV_STREAM" ]] && ARGS+=(--dev-stream "$DEV_STREAM") + bakery ci summary "${ARGS[@]}" --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" + readme: name: Push READMEs permissions: contents: read - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} needs: - merge runs-on: ubuntu-latest From 5f149d3c5808e43acfd6ddbff1499aa067e38975 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 13:29:50 -0600 Subject: [PATCH 15/18] feat: tri-state push (off/temp/on) in bakery-build emulation workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/bakery-build.yml | 76 +++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/.github/workflows/bakery-build.yml b/.github/workflows/bakery-build.yml index a0cf09fa..40112b8c 100644 --- a/.github/workflows/bakery-build.yml +++ b/.github/workflows/bakery-build.yml @@ -39,10 +39,10 @@ on: required: false type: string push: - description: "Whether to push images to registries [default: false]" - default: false + description: "Push target [default: off] [options: off (no push), temp (multi-arch tag to temp registry only), on (push to final registries)]" + default: "off" required: false - type: boolean + type: string retry: description: "Number of times to retry a failed build [default: 1]" default: 1 @@ -100,6 +100,15 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Validate push input + env: + PUSH: ${{ inputs.push }} + run: | + case "$PUSH" in + off|temp|on) ;; + *) echo "::error::Invalid push value '$PUSH'; expected off, temp, or on." ; exit 1 ;; + esac + - name: Install uses: "posit-dev/images-shared/setup-bakery@main" with: @@ -167,14 +176,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -182,7 +191,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Setup docker buildx @@ -199,22 +208,35 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} CONTEXT: ${{ inputs.context }} CACHE: ${{ inputs.cache }} + PUSH: ${{ inputs.push }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | CACHE_FLAGS=() if [ "$CACHE" = "true" ]; then CACHE_FLAGS=(--cache-registry "$REGISTRY") fi - bakery build --load --pull \ - --retry "$RETRY" \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --dev-versions "$DEV_VERSIONS" \ - --matrix-versions "$MATRIX_VERSIONS" \ - "${CACHE_FLAGS[@]}" \ - --context "$CONTEXT" + if [ "$PUSH" = "temp" ]; then + bakery build --push --temp-registry "$REGISTRY" --temp-tagged --pull \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$CONTEXT" + else + bakery build --load --pull \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$CONTEXT" + fi - name: Test + if: ${{ inputs.push != 'temp' }} env: IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} @@ -236,7 +258,7 @@ jobs: # Since this is a reusable workflow, we need to be very explicit about # when to push the images. We default to not pushing images, so the # calling workflow must explicitly request pushing images. - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} env: GIT_SHA: ${{ github.sha }} RETRY: ${{ inputs.retry }} @@ -255,11 +277,33 @@ jobs: --matrix-versions "$MATRIX_VERSIONS" \ --context "$CONTEXT" + - name: Build Artifact Summary + if: ${{ inputs.push != 'off' }} + env: + REGISTRY: ghcr.io/${{ github.repository_owner }} + PUSH: ${{ inputs.push }} + CONTEXT: ${{ inputs.context }} + IMAGE_NAME: ${{ matrix.img.image }} + IMAGE_VERSION: ${{ matrix.img.version }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + run: | + if [ "$PUSH" = "on" ]; then MODE="final"; else MODE="temp"; fi + bakery ci summary \ + --mode "$MODE" \ + --temp-registry "$REGISTRY" \ + --context "$CONTEXT" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" + readme: name: Push READMEs permissions: contents: read - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} needs: - build runs-on: ubuntu-latest From 88bf91e93dcb122d069fdff33e747000abda4979 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 15:04:13 -0600 Subject: [PATCH 16/18] feat: publish temp multi-arch artifacts for same-repo PRs in bakery-build-pr Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/bakery-build-pr.yml | 160 ++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 21 deletions(-) diff --git a/.github/workflows/bakery-build-pr.yml b/.github/workflows/bakery-build-pr.yml index 3810130a..27f72d69 100644 --- a/.github/workflows/bakery-build-pr.yml +++ b/.github/workflows/bakery-build-pr.yml @@ -1,8 +1,13 @@ # Fork-safe PR build workflow for Posit container images. # -# Unlike bakery-build-native.yml this workflow never pushes images. -# Fork PRs get a read-only GITHUB_TOKEN with no access to repo secrets, -# so registry logins and caching are conditional on the PR source. +# Fork PRs stay load-only and never push: they get a read-only GITHUB_TOKEN +# with no access to repo secrets, so registry logins and caching are skipped. +# Same-repo PRs push a temporary multi-arch index to ghcr.io/ (by +# digest per-platform, then assembled with `bakery ci merge --index-only`) so +# maintainers can pull and debug the built image when tests fail. PRs never +# push to the final/release registries. +# +# Registry logins and caching are conditional on the PR source. # # Security policy: No ${{ }} expressions in `run:` blocks. # All expression values are assigned to `env:` and referenced as @@ -192,40 +197,153 @@ jobs: MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} REGISTRY_OWNER: ${{ github.repository_owner }} + REGISTRY: ghcr.io/${{ github.repository_owner }} + NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} CACHE: ${{ inputs.cache }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CACHE_FLAGS=() - if [ "$IS_FORK" != "true" ] && [ "$CACHE" = "true" ]; then - CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}") + if [ "$IS_FORK" = "true" ]; then + # Fork PRs: load-only, no push, no temp registry. Cache only when + # explicitly enabled (forks have no registry credentials anyway). + CACHE_FLAGS=() + if [ "$CACHE" = "true" ]; then + CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}") + fi + bakery build \ + --strategy build --pull --load \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --image-platform "$IMAGE_PLATFORM" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$BAKERY_CONTEXT" + else + # Same-repo PRs: push by digest per-platform to the temp registry so + # the merge job can assemble a multi-arch index (no --temp-tagged). + bakery build \ + --strategy build --pull --push \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --image-platform "$IMAGE_PLATFORM" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + --cache-registry "ghcr.io/${REGISTRY_OWNER}" \ + --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ + --context "$BAKERY_CONTEXT" fi - bakery build \ - --strategy build --pull --load \ - --retry "$RETRY" \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --image-platform "$IMAGE_PLATFORM" \ - --dev-versions "$DEV_VERSIONS" \ - --matrix-versions "$MATRIX_VERSIONS" \ - "${CACHE_FLAGS[@]}" \ - --context "$BAKERY_CONTEXT" - name: Test env: + IS_FORK: ${{ needs.detect.outputs.is-fork }} IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} IMAGE_PLATFORM: ${{ matrix.img.platform }} DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} BAKERY_CONTEXT: ${{ inputs.context }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + DGOSS_ARGS=( + --image-name "^${IMAGE_NAME}$" + --image-version "$IMAGE_VERSION" + --image-platform "$IMAGE_PLATFORM" + --dev-versions "$DEV_VERSIONS" + --matrix-versions "$MATRIX_VERSIONS" + --context "$BAKERY_CONTEXT" + ) + if [ "$IS_FORK" != "true" ]; then + # Same-repo PRs pushed by digest; resolve via the build metadata. + DGOSS_ARGS+=(--metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json") + fi GOSS_PATH=${GITHUB_WORKSPACE}/tools/goss \ DGOSS_PATH=${GITHUB_WORKSPACE}/tools/dgoss \ - bakery run dgoss \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --image-platform "$IMAGE_PLATFORM" \ + bakery run dgoss "${DGOSS_ARGS[@]}" + + - name: Upload Metadata + if: needs.detect.outputs.is-fork != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: "${{ matrix.img.image }}-${{ matrix.img.version }}-${{ steps.normalize-platform.outputs.platform }}-metadata" + path: "./${{ matrix.img.image }}-${{ matrix.img.version }}-${{ steps.normalize-platform.outputs.platform }}-metadata.json" + overwrite: 'true' + + merge: + name: "Merge (temp index)" + if: ${{ needs.detect.outputs.is-fork != 'true' }} + permissions: + contents: read + packages: write + needs: + - detect + - build-test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup bakery + uses: "posit-dev/images-shared/setup-bakery@main" + with: + version: ${{ inputs.version }} + + - name: Set up Docker + uses: docker/setup-docker-action@b2189fbf2a6592b51fee7cdd93ee2bfaeba733db # v5.1.0 + with: + daemon-config: | + { + "features": { + "containerd-snapshotter": true + } + } + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Setup ORAS CLI + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + + - name: Download Metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*-metadata" + merge-multiple: true + + - name: Merge index (temp) + env: + BAKERY_CONTEXT: ${{ inputs.context }} + REGISTRY_OWNER: ${{ github.repository_owner }} + run: | + bakery ci merge \ + --context "$BAKERY_CONTEXT" \ + --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + --index-only \ + ./*-metadata.json + + - name: Build Artifact Summary + env: + BAKERY_CONTEXT: ${{ inputs.context }} + REGISTRY_OWNER: ${{ github.repository_owner }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + run: | + bakery ci summary \ + --mode temp \ + --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + --context "$BAKERY_CONTEXT" \ --dev-versions "$DEV_VERSIONS" \ --matrix-versions "$MATRIX_VERSIONS" \ - --context "$BAKERY_CONTEXT" + --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" From c3e90642144b04d96860256789f361cf6b87a1fe Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 15:08:40 -0600 Subject: [PATCH 17/18] fix: keep fork PR cache behavior unchanged in bakery-build-pr Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/bakery-build-pr.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bakery-build-pr.yml b/.github/workflows/bakery-build-pr.yml index 27f72d69..f960722f 100644 --- a/.github/workflows/bakery-build-pr.yml +++ b/.github/workflows/bakery-build-pr.yml @@ -196,19 +196,18 @@ jobs: DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} - REGISTRY_OWNER: ${{ github.repository_owner }} REGISTRY: ghcr.io/${{ github.repository_owner }} NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} CACHE: ${{ inputs.cache }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Cache only for same-repo PRs when enabled; fork PRs have no + # registry credentials, so they never receive cache flags. + CACHE_FLAGS=() + if [ "$IS_FORK" != "true" ] && [ "$CACHE" = "true" ]; then + CACHE_FLAGS=(--cache-registry "$REGISTRY") + fi if [ "$IS_FORK" = "true" ]; then - # Fork PRs: load-only, no push, no temp registry. Cache only when - # explicitly enabled (forks have no registry credentials anyway). - CACHE_FLAGS=() - if [ "$CACHE" = "true" ]; then - CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}") - fi bakery build \ --strategy build --pull --load \ --retry "$RETRY" \ @@ -220,8 +219,6 @@ jobs: "${CACHE_FLAGS[@]}" \ --context "$BAKERY_CONTEXT" else - # Same-repo PRs: push by digest per-platform to the temp registry so - # the merge job can assemble a multi-arch index (no --temp-tagged). bakery build \ --strategy build --pull --push \ --retry "$RETRY" \ @@ -230,8 +227,8 @@ jobs: --image-platform "$IMAGE_PLATFORM" \ --dev-versions "$DEV_VERSIONS" \ --matrix-versions "$MATRIX_VERSIONS" \ - --cache-registry "ghcr.io/${REGISTRY_OWNER}" \ - --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + "${CACHE_FLAGS[@]}" \ + --temp-registry "$REGISTRY" \ --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ --context "$BAKERY_CONTEXT" fi From a59edd85fbe4ead9b1e2d50feabfae4be3e2090e Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 29 May 2026 17:26:52 -0600 Subject: [PATCH 18/18] TEMP: retarget setup-bakery + version default to feat/dry-run-artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the internal setup-bakery action refs and the reusable-workflow `version` input defaults at this branch so PR CI (here and in the product repos) installs and runs this branch's bakery + workflows. REVERT THIS COMMIT before merging — on main these must reference @main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/bakery-build-native.yml | 10 +++++----- .github/workflows/bakery-build-pr.yml | 8 ++++---- .github/workflows/bakery-build.yml | 8 ++++---- .github/workflows/clean.yml | 4 ++-- .github/workflows/hadolint.yml | 2 +- .github/workflows/product-release.yml | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 29e05aaf..b77236b3 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -9,7 +9,7 @@ on: inputs: version: description: "The version of the Posit Bakery tool to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -119,7 +119,7 @@ jobs: esac - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -156,7 +156,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -296,7 +296,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -415,7 +415,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/bakery-build-pr.yml b/.github/workflows/bakery-build-pr.yml index f960722f..28c4c431 100644 --- a/.github/workflows/bakery-build-pr.yml +++ b/.github/workflows/bakery-build-pr.yml @@ -22,7 +22,7 @@ on: inputs: version: description: "Bakery version to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -97,7 +97,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -146,7 +146,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -285,7 +285,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/bakery-build.yml b/.github/workflows/bakery-build.yml index 40112b8c..e4fe7b44 100644 --- a/.github/workflows/bakery-build.yml +++ b/.github/workflows/bakery-build.yml @@ -10,7 +10,7 @@ on: inputs: version: description: "The version of the Posit Bakery tool to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -110,7 +110,7 @@ jobs: esac - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -147,7 +147,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -313,7 +313,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index ea47db70..2042855d 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -86,7 +86,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -131,7 +131,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 4d4295c2..4d64a9d2 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -40,7 +40,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/product-release.yml b/.github/workflows/product-release.yml index 21341e2e..a58f70cd 100644 --- a/.github/workflows/product-release.yml +++ b/.github/workflows/product-release.yml @@ -45,7 +45,7 @@ jobs: token: ${{ steps.app-token.outputs.token }} - name: Install bakery - uses: posit-dev/images-shared/setup-bakery@main + uses: posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts - name: Parse version id: parse