diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index abc99c2b..92ab3915 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -401,8 +401,9 @@ def _apply_dev_spec(image: Image, settings: BakerySettings) -> None: Called before load_dev_versions() so the pinned version is set when resolution runs. """ - # Local import to avoid circular import. + # Local imports to avoid circular import. from posit_bakery.config.image.dev_version.channel import ImageDevelopmentVersionFromProductChannel + from posit_bakery.config.image.dev_version.dependency import ImageDevelopmentVersionFromDependency # Validate channel consistency: if both are set and differ, the pinned version # would be filtered out by --dev-channel. Fail loudly instead of silently skipping. @@ -421,8 +422,8 @@ def _apply_dev_spec(image: Image, settings: BakerySettings) -> None: candidates = [ dv for dv in image.devVersions - if isinstance(dv, ImageDevelopmentVersionFromProductChannel) - and (target_channel is None or dv.channel == target_channel) + if isinstance(dv, (ImageDevelopmentVersionFromProductChannel, ImageDevelopmentVersionFromDependency)) + and (target_channel is None or dv.get_release_channel() == target_channel) ] if not candidates: return @@ -432,13 +433,24 @@ def _apply_dev_spec(image: Image, settings: BakerySettings) -> None: f"channel '{target_channel}'. Specify 'channel' in --dev-spec or pass " f"--dev-channel to disambiguate." ) - candidates[0].version_override = settings.dev_spec.version # None for branch-only specs - if settings.dev_spec.version is not None: - # Extract YYYY.MM from the pinned version to target the correct branch URL. - # The dailies API supports YYYY.MM path segments (e.g. /rstudio/2026.06/index.json). - candidates[0].release_branch = _extract_calver_minor(settings.dev_spec.version) - elif settings.dev_spec.release_branch is not None: - candidates[0].release_branch = settings.dev_spec.release_branch + + candidate = candidates[0] + candidate.version_override = settings.dev_spec.version # None for branch-only specs + + if isinstance(candidate, ImageDevelopmentVersionFromProductChannel): + if settings.dev_spec.version is not None: + # Extract YYYY.MM from the pinned version to target the correct branch URL. + # The dailies API supports YYYY.MM path segments (e.g. /rstudio/2026.06/index.json). + candidate.release_branch = _extract_calver_minor(settings.dev_spec.version) + elif settings.dev_spec.release_branch is not None: + candidate.release_branch = settings.dev_spec.release_branch + elif settings.dev_spec.version is None: + # Dependency-sourced dev versions resolve from a fixed dependency endpoint + # with no release-branch concept, so a branch-only spec cannot pin them. + log.warning( + f"Image '{image.name}': --dev-spec without a version cannot pin a " + f"dependency-sourced dev version; building the latest resolved version." + ) class BakeryConfig: diff --git a/posit-bakery/posit_bakery/config/image/dev_version/base.py b/posit-bakery/posit_bakery/config/image/dev_version/base.py index 024b5935..7361f083 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/base.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/base.py @@ -57,6 +57,17 @@ class BaseImageDevelopmentVersion(BakeryYAMLModel, abc.ABC): description="Arbitrary key-value pairs used in template rendering.", ), ] + version_override: Annotated[ + str | None, + Field( + exclude=True, + default=None, + description="Version pinned by a workflow dispatch spec (--dev-spec). When set, " + "bypasses CDN/dependency discovery so the build targets exactly this version. " + "The stream model also forwards it to the channel resolver for URL construction; " + "the dependency model returns it directly from get_version().", + ), + ] @field_validator("extraRegistries", "overrideRegistries", mode="after") @classmethod diff --git a/posit-bakery/posit_bakery/config/image/dev_version/channel.py b/posit-bakery/posit_bakery/config/image/dev_version/channel.py index 9e9019a6..5fea79d3 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/channel.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/channel.py @@ -41,16 +41,6 @@ class ImageDevelopmentVersionFromProductChannel(BaseImageDevelopmentVersion): "for builds targeting older versions.", ), ] - version_override: Annotated[ - str | None, - Field( - exclude=True, - default=None, - description="Version pinned by a workflow dispatch spec. When set, bypasses CDN " - "discovery and is forwarded to the channel resolver for offline template rendering " - "(PPM) or manifest assertion (Connect, Workbench).", - ), - ] release_branch: Annotated[ str | None, Field( diff --git a/posit-bakery/posit_bakery/config/image/dev_version/dependency.py b/posit-bakery/posit_bakery/config/image/dev_version/dependency.py index 974b199d..556d3d2c 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/dependency.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/dependency.py @@ -44,6 +44,8 @@ def channel_not_release(cls, v: ReleaseChannelEnum | None) -> ReleaseChannelEnum return v def get_version(self) -> str: + if self.version_override is not None: + return self.version_override constraint_class = get_dependency_constraint_class(self.dependency) constraint = constraint_class( prerelease=self.prerelease, diff --git a/posit-bakery/test/config/image/dev_version/test_dependency.py b/posit-bakery/test/config/image/dev_version/test_dependency.py index 9704905a..fb9cf6b2 100644 --- a/posit-bakery/test/config/image/dev_version/test_dependency.py +++ b/posit-bakery/test/config/image/dev_version/test_dependency.py @@ -349,3 +349,24 @@ def test_repr_contains_key_fields(self): assert 'sourceType="dependency"' in r assert "positron" in r assert "prerelease=True" in r + + +class TestVersionOverride: + def test_version_override_defaults_none(self): + """The shared version_override field defaults to None.""" + dev = ImageDevelopmentVersionFromDependency( + dependency="positron", + os=[_UBUNTU_24_OS], + ) + assert dev.version_override is None + + def test_version_override_short_circuits_resolution(self): + """When version_override is set, get_version() returns it without a network call.""" + dev = ImageDevelopmentVersionFromDependency( + dependency="positron", + prerelease=True, + os=[_UBUNTU_24_OS], + ) + dev.version_override = "2026.06.0-99" + # No patch_requests_get fixture: a network call would error, proving none happens. + assert dev.get_version() == "2026.06.0-99" diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index edb967e1..f4847d13 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -1,4 +1,5 @@ import json +import logging import os import shutil import textwrap @@ -2329,3 +2330,82 @@ def test_version_takes_precedence_over_release_branch(self, tmp_path): def test_extract_calver_minor_rejects_trailing_garbage(self): with pytest.raises(ValueError, match="not a valid CalVer"): _extract_calver_minor("2026.06.0-daily+143 trailing garbage") + + +class TestApplyDevSpecDependency: + """--dev-spec pins dependency-sourced dev versions, matched by channel.""" + + def _make_image(self, tmp_path, *, channel="daily", extra_dev_versions=None): + """Return a minimal Image with one positron daily dependency dev version.""" + dev_version = { + "sourceType": "dependency", + "dependency": "positron", + "prerelease": True, + "channel": channel, + "os": [{"name": "Ubuntu 24.04", "primary": True}], + } + doc = BakeryConfigDocument( + base_path=tmp_path, + **{ + "repository": {"url": "https://github.com/posit-dev/test"}, + "images": [ + { + "name": "test-positron-init", + "devVersions": [dev_version, *(extra_dev_versions or [])], + } + ], + }, + ) + return doc.images[0] + + def test_version_pins_dependency_dev_version(self, tmp_path): + """version in DevBuildSpec sets version_override on the matching dependency dev version.""" + image = self._make_image(tmp_path) + spec = DevBuildSpec(version="2026.06.0-99", channel="daily") + settings = BakerySettings(dev_versions=DevVersionInclusionEnum.ONLY, dev_spec=spec) + _apply_dev_spec(image, settings) + assert image.devVersions[0].version_override == "2026.06.0-99" + + def test_channel_mismatch_is_noop(self, tmp_path): + """A dev-spec channel that matches no dev version leaves version_override unset.""" + image = self._make_image(tmp_path, channel="daily") + spec = DevBuildSpec(version="2026.06.0-99", channel="preview") + settings = BakerySettings(dev_versions=DevVersionInclusionEnum.ONLY, dev_spec=spec) + _apply_dev_spec(image, settings) + assert image.devVersions[0].version_override is None + + def test_release_branch_ignored_for_dependency(self, tmp_path): + """release_branch is not applicable to dependency dev versions and is not set.""" + image = self._make_image(tmp_path) + spec = DevBuildSpec(version="2026.06.0-99", channel="daily", release_branch="apple-blossom") + settings = BakerySettings(dev_versions=DevVersionInclusionEnum.ONLY, dev_spec=spec) + _apply_dev_spec(image, settings) + dv = image.devVersions[0] + assert dv.version_override == "2026.06.0-99" + assert not hasattr(dv, "release_branch") + + def test_branch_only_spec_skips_dependency_with_warning(self, tmp_path, caplog): + """A branch-only spec cannot pin a dependency dev version: warns, leaves override None.""" + image = self._make_image(tmp_path) + spec = DevBuildSpec(release_branch="apple-blossom") + settings = BakerySettings(dev_versions=DevVersionInclusionEnum.ONLY, dev_spec=spec) + with caplog.at_level(logging.WARNING): + _apply_dev_spec(image, settings) + assert image.devVersions[0].version_override is None + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "cannot pin a dependency-sourced dev version" in warnings[0].message + + def test_ambiguous_candidates_raise(self, tmp_path): + """Two dev versions matching the same channel raise a disambiguation error.""" + extra = { + "sourceType": "stream", + "product": "workbench", + "channel": "daily", + "os": [{"name": "Ubuntu 24.04", "primary": True}], + } + image = self._make_image(tmp_path, channel="daily", extra_dev_versions=[extra]) + spec = DevBuildSpec(version="2026.06.0-99", channel="daily") + settings = BakerySettings(dev_versions=DevVersionInclusionEnum.ONLY, dev_spec=spec) + with pytest.raises(ValueError, match="dev versions matching"): + _apply_dev_spec(image, settings)