diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad008720..a1a4c11d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,7 @@ jobs: packages: write uses: "./.github/workflows/bakery-build-pr.yml" with: + version: ${{ github.head_ref || github.ref_name }} context: "./posit-bakery/test/resources/multiplatform/" dev-versions: include diff --git a/posit-bakery/posit_bakery/cli/build.py b/posit-bakery/posit_bakery/cli/build.py index 9357ddce..3445d77e 100644 --- a/posit-bakery/posit_bakery/cli/build.py +++ b/posit-bakery/posit_bakery/cli/build.py @@ -6,7 +6,7 @@ import python_on_whales import typer -from posit_bakery.cli.common import with_verbosity_flags, with_temporary_storage, parse_dev_spec +from posit_bakery.cli.common import with_verbosity_flags, with_temporary_storage, parse_dev_spec, exit_if_no_targets from posit_bakery.config import BakeryConfig from posit_bakery.config.config import BakeryConfigFilter, BakerySettings from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum @@ -263,6 +263,8 @@ def build( stderr_console.print(f"❌ {e}", style="error") raise typer.Exit(code=1) + exit_if_no_targets(config, settings) + if plan: if strategy == ImageBuildStrategy.BUILD: # TODO: This should turn into dry-run behavior eventually. diff --git a/posit-bakery/posit_bakery/cli/common.py b/posit-bakery/posit_bakery/cli/common.py index eb724ecc..f36c5e1c 100644 --- a/posit-bakery/posit_bakery/cli/common.py +++ b/posit-bakery/posit_bakery/cli/common.py @@ -4,7 +4,7 @@ import logging import tempfile from pathlib import Path -from typing import Annotated, Optional, Any +from typing import Annotated, Optional, Any, TYPE_CHECKING import typer from pydantic import ValidationError @@ -16,12 +16,57 @@ DependencyVersions, ) from posit_bakery.config.image.dev_version.spec import DevBuildSpec -from posit_bakery.log import init_logging +from posit_bakery.log import init_logging, stderr_console from posit_bakery.settings import SETTINGS +# Runtime import would cycle (config.config indirectly imports the CLI package), +# and these are only needed for type hints. +if TYPE_CHECKING: + from posit_bakery.config.config import BakeryConfig, BakerySettings + log = logging.getLogger(__name__) +def exit_if_no_targets(config: "BakeryConfig", settings: "BakerySettings") -> None: + """Abort the command when the active filters resolved to zero image targets. + + A ``build`` or ``dgoss run`` that matches no targets is almost always a + mistake — a typo'd or non-existent ``--image-version``, an over-narrow + combination of filters, or a ``--dev-versions``/``--matrix-versions`` + selection that excludes everything. Exiting 0 in that case let broken CI + jobs pass while building/testing nothing, so fail loudly and echo the + active filters back to aid debugging. + """ + if config.targets: + return + active = _describe_active_filters(settings) + detail = f" matching {active}" if active else "" + stderr_console.print( + f"❌ No image targets{detail}. Check the --image-name, --image-version, " + "--image-variant, --image-os, and --image-platform filters along with the " + "--dev-versions/--matrix-versions selection.", + style="error", + ) + raise typer.Exit(code=1) + + +def _describe_active_filters(settings: "BakerySettings") -> str: + """Render the set filters as a human-readable ``--flag value`` list.""" + f = settings.filter + parts = [ + f"--{name} {value!r}" + for name, value in ( + ("image-name", f.image_name), + ("image-version", f.image_version), + ("image-variant", f.image_variant), + ("image-os", f.image_os), + ("image-platform", f.image_platform), + ) + if value + ] + return ", ".join(parts) + + def parse_dev_spec(ctx: typer.Context, param: typer.CallbackParam, value: str | None) -> DevBuildSpec | None: if value is None: return None diff --git a/posit-bakery/posit_bakery/cli/run.py b/posit-bakery/posit_bakery/cli/run.py index c1f64411..f2cd1cad 100644 --- a/posit-bakery/posit_bakery/cli/run.py +++ b/posit-bakery/posit_bakery/cli/run.py @@ -6,7 +6,7 @@ import typer -from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec +from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec, exit_if_no_targets from posit_bakery.config import BakeryConfig from posit_bakery.config.config import BakeryConfigFilter, BakerySettings from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum @@ -197,6 +197,8 @@ def dgoss( ) c = BakeryConfig.from_context(context, settings) + exit_if_no_targets(c, settings) + if metadata_file: c.load_build_metadata_from_file(metadata_file) diff --git a/posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py index 35fd424b..6c80a326 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py @@ -1,5 +1,4 @@ import logging -import re import warnings from enum import Enum from pathlib import Path @@ -7,7 +6,7 @@ import typer -from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec +from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec, exit_if_no_targets from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum from posit_bakery.config.config import BakeryConfig, BakeryConfigFilter, BakerySettings from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum @@ -197,7 +196,7 @@ def run( settings = BakerySettings( filter=BakeryConfigFilter( image_name=image_name, - image_version=re.escape(image_version) if image_version else None, + image_version=image_version, image_variant=image_variant, image_os=image_os, image_platform=[platform], @@ -211,6 +210,8 @@ def run( ) c = BakeryConfig.from_context(context, settings) + exit_if_no_targets(c, settings) + if metadata_file: c.load_build_metadata_from_file(metadata_file) diff --git a/posit-bakery/posit_bakery/plugins/builtin/hadolint/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/hadolint/__init__.py index 3704b115..559f497f 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/hadolint/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/hadolint/__init__.py @@ -1,12 +1,11 @@ import logging -import re from enum import Enum from pathlib import Path from typing import Annotated, Optional import typer -from posit_bakery.cli.common import with_verbosity_flags +from posit_bakery.cli.common import with_verbosity_flags, exit_if_no_targets from posit_bakery.config.config import BakeryConfig, BakeryConfigFilter, BakerySettings from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.error import BakeryToolRuntimeErrorGroup @@ -212,7 +211,7 @@ def run( settings = BakerySettings( filter=BakeryConfigFilter( image_name=image_name, - image_version=re.escape(image_version) if image_version else None, + image_version=image_version, image_variant=image_variant, image_os=image_os, image_platform=[], @@ -223,6 +222,8 @@ def run( ) c = BakeryConfig.from_context(context, settings) + exit_if_no_targets(c, settings) + # Build options override from CLI flags override_dict = {} if error or warning or info or style: diff --git a/posit-bakery/posit_bakery/plugins/builtin/wizcli/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/wizcli/__init__.py index bbe06f8f..cd1c22b3 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/wizcli/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/wizcli/__init__.py @@ -1,12 +1,11 @@ import logging -import re from enum import Enum from pathlib import Path from typing import Annotated, Optional import typer -from posit_bakery.cli.common import with_verbosity_flags +from posit_bakery.cli.common import with_verbosity_flags, exit_if_no_targets from posit_bakery.config.config import BakeryConfig, BakeryConfigFilter, BakerySettings from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.error import BakeryToolRuntimeErrorGroup @@ -224,7 +223,7 @@ def scan( settings = BakerySettings( filter=BakeryConfigFilter( image_name=image_name, - image_version=re.escape(image_version) if image_version else None, + image_version=image_version, image_variant=image_variant, image_os=image_os, image_platform=[platform], @@ -235,6 +234,8 @@ def scan( ) c = BakeryConfig.from_context(context, settings) + exit_if_no_targets(c, settings) + if metadata_file: c.load_build_metadata_from_file(metadata_file) diff --git a/posit-bakery/test/cli/test_build.py b/posit-bakery/test/cli/test_build.py index b51eb952..25be299c 100644 --- a/posit-bakery/test/cli/test_build.py +++ b/posit-bakery/test/cli/test_build.py @@ -59,6 +59,40 @@ def test_version_substitution_error_exits_with_clean_message(self): assert "Traceback" not in result.output +class TestBuildZeroMatchGuard: + """A filter that matches no targets must fail the build, not silently pass.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.cli.build.BakeryConfig") as mock: + instance = MagicMock() + instance.targets = [] + mock.from_context.return_value = instance + result = runner.invoke( + app, + ["build", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + assert "9999.99.99" in result.output + instance.build_targets.assert_not_called() + + def test_no_targets_blocks_plan_output(self): + """--plan must also fail rather than emit an empty bake plan.""" + with patch("posit_bakery.cli.build.BakeryConfig") as mock: + instance = MagicMock() + instance.targets = [] + mock.from_context.return_value = instance + result = runner.invoke( + app, + ["build", "--plan", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + instance.bake_plan_targets.assert_not_called() + + class TestBuildLatestFlag: def test_latest_passed_to_settings(self, mock_build_config): result = runner.invoke( diff --git a/posit-bakery/test/cli/test_dev_spec.py b/posit-bakery/test/cli/test_dev_spec.py index 9216736d..2c4983d9 100644 --- a/posit-bakery/test/cli/test_dev_spec.py +++ b/posit-bakery/test/cli/test_dev_spec.py @@ -196,7 +196,8 @@ def _invoke(self, extra_args: list[str], env: dict | None = None): ): instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance result = runner.invoke( app, @@ -264,7 +265,8 @@ def _invoke(self, extra_args: list[str], env: dict | None = None): ): instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance mock_plugin = MagicMock() mock_plugin.execute.return_value = [] diff --git a/posit-bakery/test/cli/test_dev_stream_deprecated.py b/posit-bakery/test/cli/test_dev_stream_deprecated.py index 5c5753c7..8e6d023e 100644 --- a/posit-bakery/test/cli/test_dev_stream_deprecated.py +++ b/posit-bakery/test/cli/test_dev_stream_deprecated.py @@ -195,7 +195,8 @@ def mock_run(self): with patch("posit_bakery.cli.run.BakeryConfig") as mock_config: instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance with patch("posit_bakery.cli.run.get_plugin") as mock_plugin: mock_dgoss = MagicMock() diff --git a/posit-bakery/test/cli/test_run_dgoss.py b/posit-bakery/test/cli/test_run_dgoss.py index 2baaeb27..33bd75e5 100644 --- a/posit-bakery/test/cli/test_run_dgoss.py +++ b/posit-bakery/test/cli/test_run_dgoss.py @@ -25,7 +25,8 @@ def mocked_bakery_run_dgoss(): with patch("posit_bakery.cli.run.BakeryConfig") as mock_config: instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance with patch("posit_bakery.cli.run.get_plugin") as mock_get_plugin: mock_plugin = MagicMock() @@ -77,6 +78,28 @@ def test_emits_deprecation_warning(self, mocked_bakery_run_dgoss): assert "removed" in combined.lower() +class TestRunDgossZeroMatchGuard: + """The deprecated path must also fail loudly when no targets match.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.cli.run.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [] + mock_config.from_context.return_value = instance + with patch("posit_bakery.cli.run.get_plugin") as mock_get_plugin: + mock_plugin = MagicMock() + mock_get_plugin.return_value = mock_plugin + result = runner.invoke( + app, + ["run", "dgoss", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + mock_plugin.execute.assert_not_called() + + class TestRunDgossLatestFlag: """The --latest flag is passed through to settings and warns with dev inclusion.""" diff --git a/posit-bakery/test/plugins/builtin/dgoss/test_init.py b/posit-bakery/test/plugins/builtin/dgoss/test_init.py index 959afa9e..302983c6 100644 --- a/posit-bakery/test/plugins/builtin/dgoss/test_init.py +++ b/posit-bakery/test/plugins/builtin/dgoss/test_init.py @@ -28,7 +28,8 @@ def mocked_dgoss_run(): with patch("posit_bakery.plugins.builtin.dgoss.BakeryConfig") as mock_config: instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance with ( patch("posit_bakery.plugins.builtin.dgoss.DGossPlugin.execute") as mock_execute, @@ -90,6 +91,52 @@ def test_latest_default_false(self, mocked_dgoss_run): assert settings.latest is False +class TestDgossRunImageVersionFilter: + """Regression coverage: `--image-version` must reach the filter verbatim. + + ``BakeryConfigFilter.image_version`` is consumed by ``version_matches()``, + which does segment-aware (not regex) matching, so the value must NOT be + regex-escaped. Escaping a calver build string like ``2026.01.2+418.pro1`` + into ``2026\\.01\\.2\\+418\\.pro1`` made the filter match no versions, so + `dgoss run` silently tested nothing and the CI workflow passed.""" + + def test_image_version_passed_verbatim(self, mocked_dgoss_run): + mock_config, _ = mocked_dgoss_run + version = "2026.01.2+418.pro1" + result = runner.invoke( + app, + ["dgoss", "run", "--context", BASIC_CONTEXT, "--image-version", version], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.stdout + settings = mock_config.from_context.call_args[0][1] + assert settings.filter.image_version == version + + +class TestDgossRunZeroMatchGuard: + """A filter that matches no targets must fail loudly, not silently pass. + + Before the guard, `dgoss run` with a non-matching filter exited 0 having + tested nothing, hiding broken CI jobs.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.plugins.builtin.dgoss.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [] + mock_config.from_context.return_value = instance + with patch("posit_bakery.plugins.builtin.dgoss.DGossPlugin.execute") as mock_execute: + result = runner.invoke( + app, + ["dgoss", "run", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + assert "9999.99.99" in result.output + mock_execute.assert_not_called() + + class TestDgossRunJobsFlag: """The --jobs flag is forwarded to DGossPlugin.execute().""" diff --git a/posit-bakery/test/plugins/builtin/hadolint/test_init.py b/posit-bakery/test/plugins/builtin/hadolint/test_init.py index 8dff947f..2f723ffb 100644 --- a/posit-bakery/test/plugins/builtin/hadolint/test_init.py +++ b/posit-bakery/test/plugins/builtin/hadolint/test_init.py @@ -30,7 +30,8 @@ def mocked_hadolint_run(): with patch("posit_bakery.plugins.builtin.hadolint.BakeryConfig") as mock_config: instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance with ( patch("posit_bakery.plugins.builtin.hadolint.HadolintPlugin.execute") as mock_execute, @@ -40,6 +41,27 @@ def mocked_hadolint_run(): yield mock_config, mock_execute +class TestHadolintRunZeroMatchGuard: + """A filter that matches no targets must fail loudly, not silently pass.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.plugins.builtin.hadolint.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [] + mock_config.from_context.return_value = instance + with patch("posit_bakery.plugins.builtin.hadolint.HadolintPlugin.execute") as mock_execute: + result = runner.invoke( + app, + ["hadolint", "run", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + assert "9999.99.99" in result.output + mock_execute.assert_not_called() + + class TestHadolintRunLatestFlag: """The --latest flag is passed through to settings and warns with dev inclusion.""" diff --git a/posit-bakery/test/plugins/builtin/wizcli/test_init.py b/posit-bakery/test/plugins/builtin/wizcli/test_init.py index 628a8499..b6c67f80 100644 --- a/posit-bakery/test/plugins/builtin/wizcli/test_init.py +++ b/posit-bakery/test/plugins/builtin/wizcli/test_init.py @@ -30,7 +30,8 @@ def mocked_wizcli_scan(): with patch("posit_bakery.plugins.builtin.wizcli.BakeryConfig") as mock_config: instance = MagicMock() instance.base_path = Path(BASIC_CONTEXT) - instance.targets = [] + # Non-empty so the zero-match guard does not abort the happy-path runs. + instance.targets = [MagicMock()] mock_config.from_context.return_value = instance with ( patch("posit_bakery.plugins.builtin.wizcli.WizCLIPlugin.execute") as mock_execute, @@ -40,6 +41,27 @@ def mocked_wizcli_scan(): yield mock_config, mock_execute +class TestWizcliScanZeroMatchGuard: + """A filter that matches no targets must fail loudly, not silently pass.""" + + def test_no_targets_exits_nonzero(self): + with patch("posit_bakery.plugins.builtin.wizcli.BakeryConfig") as mock_config: + instance = MagicMock() + instance.base_path = Path(BASIC_CONTEXT) + instance.targets = [] + mock_config.from_context.return_value = instance + with patch("posit_bakery.plugins.builtin.wizcli.WizCLIPlugin.execute") as mock_execute: + result = runner.invoke( + app, + ["wizcli", "scan", "--context", BASIC_CONTEXT, "--image-version", "9999.99.99"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert "No image targets" in result.output + assert "9999.99.99" in result.output + mock_execute.assert_not_called() + + class TestWizcliScanLatestFlag: """The --latest flag is passed through to settings and warns with dev inclusion."""