Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion posit-bakery/posit_bakery/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 47 additions & 2 deletions posit-bakery/posit_bakery/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion posit-bakery/posit_bakery/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import logging
import re
import warnings
from enum import Enum
from pathlib import Path
from typing import Annotated, Optional

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
Expand Down Expand Up @@ -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],
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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=[],
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions posit-bakery/test/cli/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions posit-bakery/test/cli/test_dev_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down
3 changes: 2 additions & 1 deletion posit-bakery/test/cli/test_dev_stream_deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 24 additions & 1 deletion posit-bakery/test/cli/test_run_dgoss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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."""

Expand Down
49 changes: 48 additions & 1 deletion posit-bakery/test/plugins/builtin/dgoss/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()."""

Expand Down
Loading
Loading