Skip to content

Commit 93eb96a

Browse files
mnriemCopilot
andauthored
fix(bundles): allow newer packaged patch releases with warnings (#4942)
* fix(bundles): allow newer packaged patch releases with warnings Permit stable patch upgrades of CLI-packaged components while retaining strict catalog pins and recording actual installed versions alongside requested pins. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3dbacae3-752f-4608-a295-35d7c12fcee8 * fix(bundles): return structured compatibility warnings to CLI adapters Address review feedback by collecting packaged patch warnings in InstallResult without domain-layer rendering, and presenting them only at bundle CLI boundaries. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3dbacae3-752f-4608-a295-35d7c12fcee8 * test(bundles): cover packaged workflow validation and exact fallback Add online and offline packaged workflow patch-warning coverage and prove that incompatible packaged releases require the exact pinned historical catalog release, rejecting absent or different releases. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3dbacae3-752f-4608-a295-35d7c12fcee8 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3dbacae3-752f-4608-a295-35d7c12fcee8
1 parent 98127c5 commit 93eb96a

18 files changed

Lines changed: 711 additions & 74 deletions

‎docs/reference/bundles.md‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ specify bundle install <bundle_id | path>
7474

7575
Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack.
7676

77-
If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped. A missing or different installed version cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.
77+
If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped, as are bundle-owned compatible packaged patch releases with a warning (see below). Other differing or missing installed versions cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.
7878

7979
Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. An unpinned install or refresh cannot replace a component another bundle requires at a pinned version; sharing an already installed matching version without refreshing remains allowed. Shared bundles must also agree on component source and, for presets, priority and strategy before installing or refreshing the shared component. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements.
8080

@@ -88,6 +88,23 @@ The source may also be a bundle directory or `.zip` artifact. Refresh uses the s
8888

8989
A local bundle source supplies the manifest, not its component payloads. Components resolved through catalogs still require network access to refresh, even when already installed. Add `--offline` only when the components being installed or refreshed ship with Spec Kit; otherwise the command reports which component needs network access. Re-run without `--offline` to fetch that component through its catalog.
9090

91+
**Packaged patch compatibility.** When an extension, preset, or workflow shipped
92+
with the CLI is a newer stable patch release within the pinned major/minor
93+
version, installation and refresh warn and use that packaged release. For
94+
example, a bundle pinning `bug` to `1.0.0` can use packaged `bug` `1.0.1` without
95+
editing its manifest. Validation reports the same warning. Downgrades,
96+
major/minor mismatches, and differing prerelease versions remain errors; an
97+
explicit component `source` also retains exact-pin enforcement. Catalog-resolved
98+
components still require exact matches. This exception improves compatibility
99+
across CLI upgrades, but a patch release is not a guarantee of unchanged behavior.
100+
101+
Bundle records retain requested versions in `required_components` and
102+
`contributed_components` and
103+
store actual known installed versions under `installed_versions`, keyed by
104+
`kind:id` (for example, `"extensions:bug": "1.0.1"`). These are also visible in
105+
`specify bundle list --json`. Component version ranges, `latest`, and omitted
106+
extension versions are not supported.
107+
91108
> **Step payloads resolve through the step catalog only.** A bundle's `provides.steps` entries still resolve exclusively through the active step catalogs. Bundle-local `steps/<id>/` payloads and relative `provides.steps[].source` overrides are **not** resolved in this release, so a step declared that way cannot be installed offline. To ship a step with a bundle today, publish it to a step catalog the bundle's users can reach.
92109
93110
## Catalog Release History
@@ -129,11 +146,11 @@ specify bundle update [<bundle_id>]
129146
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
130147
| `--offline` | Do not access the network |
131148

132-
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
149+
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions (or compatible newer packaged patches, with a warning) while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
133150

134151
**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. Legacy extension and preset entries that advertise no version are an exception: the bundler uses the winning entry as-is and cannot enforce its pin or verify the installed archive's version. Publish a versioned entry to make that pin enforceable. The primitive installer uses the selected workflow or step record without re-reading the catalog, and verifies downloaded archive or workflow/step metadata against that release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release.
135152

136-
Without an explicit `source`, bundled extensions and presets take precedence over catalog releases. Validation rejects a pin that differs from the bundled version even when a matching catalog release exists; specify the winning catalog as `source` to opt into its release.
153+
Without an explicit `source`, bundled extensions and presets take precedence over catalog releases. Validation rejects an incompatible pin even when a matching catalog release exists; compatible newer stable packaged patches produce a warning instead. Specify the winning catalog as `source` to opt into its exact release.
137154

138155
> **One installed version per component ID.** Bundles sharing a component must agree on its pinned version. A different or unknown pin from another bundle is rejected before installation, including during `bundle update`; refreshing one bundle cannot replace a version required by another.
139156

‎src/specify_cli/bundles/_commands.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
from pathlib import Path
11+
from typing import TYPE_CHECKING
1112

1213
import typer
1314
from rich.markup import escape as _escape_markup
@@ -17,6 +18,9 @@
1718
from .project import active_integration
1819
from .records import load_records
1920

21+
if TYPE_CHECKING:
22+
from .installer import InstallResult
23+
2024
bundle_app = typer.Typer(
2125
name="bundle",
2226
help="Discover, install, and author Spec Kit bundles",
@@ -42,6 +46,11 @@ def _fail(message: str, *, as_json: bool = False) -> None:
4246
raise typer.Exit(code=1)
4347

4448

49+
def _render_install_warnings(result: InstallResult) -> None:
50+
for warning in result.warnings:
51+
err_console.print(f"[yellow]Warning:[/yellow] {_escape_markup(warning.message)}")
52+
53+
4554
def _user_config_dir() -> Path:
4655
# User-scope Spec Kit config lives under ~/.specify (same convention as
4756
# auth.json, extension/preset catalogs). Passing this through to the source

‎src/specify_cli/bundles/adapters.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import urllib.error
1717
import warnings
1818
from pathlib import Path
19+
from typing import TYPE_CHECKING
1920
from urllib.parse import ParseResult, urlparse
2021
from urllib.request import url2pathname
2122

@@ -26,6 +27,9 @@
2627
from .manifest import ComponentRef
2728
from .yamlio import loads_json
2829

30+
if TYPE_CHECKING:
31+
from .primitives import BundledPatchWarning
32+
2933
COMMUNITY_CATALOG_URL = (
3034
"https://raw.githubusercontent.com/github/spec-kit/main/"
3135
"bundles/catalog.community.json"
@@ -330,6 +334,22 @@ def installed_version(
330334
manager = self._manager_for(component, project_root)
331335
return manager.installed_version(component)
332336

337+
def bundled_patch_warning(
338+
self, component: ComponentRef, actual: str,
339+
) -> BundledPatchWarning | None:
340+
from .primitives import _bundled_component_version, _bundled_pin_warning
341+
from .versioning import is_newer_patch, same_version
342+
343+
packaged = _bundled_component_version(component)
344+
if (
345+
component.version
346+
and packaged
347+
and same_version(actual, packaged)
348+
and is_newer_patch(actual, component.version)
349+
):
350+
return _bundled_pin_warning(component.kind[:-1].capitalize(), component, actual)
351+
return None
352+
333353
def validate_source(self, project_root: Path, component: ComponentRef) -> None:
334354
if not self._allow_network:
335355
raise BundlerError(
@@ -354,13 +374,17 @@ def validate_source(self, project_root: Path, component: ComponentRef) -> None:
354374
f"'{component.source}': {detail}."
355375
)
356376

357-
def install(self, project_root: Path, component: ComponentRef) -> None:
377+
def install(
378+
self, project_root: Path, component: ComponentRef,
379+
) -> BundledPatchWarning | None:
358380
manager = self._manager_for(component, project_root)
359-
manager.install(component)
381+
return manager.install(component)
360382

361-
def refresh(self, project_root: Path, component: ComponentRef) -> None:
383+
def refresh(
384+
self, project_root: Path, component: ComponentRef,
385+
) -> BundledPatchWarning | None:
362386
manager = self._manager_for(component, project_root)
363-
manager.refresh(component)
387+
return manager.refresh(component)
364388

365389
def remove(self, project_root: Path, component: ComponentRef) -> None:
366390
manager = self._manager_for(component, project_root)

‎src/specify_cli/bundles/command_install.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
_bundle_overlaps,
1515
_default_script_type,
1616
_fail,
17+
_render_install_warnings,
1718
_resolve_init_integration,
1819
_run_init,
1920
_speckit_version,
@@ -143,6 +144,7 @@ def bundle_install(
143144
_fail(str(exc))
144145
return
145146

147+
_render_install_warnings(result)
146148
refresh_summary = (
147149
f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed"
148150
if refresh

‎src/specify_cli/bundles/command_update.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from .._console import console
99
from . import BundlerError
10-
from ._commands import _build_stack, _fail, _speckit_version, bundle_app
10+
from ._commands import (
11+
_build_stack,
12+
_fail,
13+
_render_install_warnings,
14+
_speckit_version,
15+
bundle_app,
16+
)
1117
from .project import active_integration, require_project_root
1218
from .records import load_records
1319
from .sources import _download_manifest
@@ -57,9 +63,10 @@ def bundle_update(
5763
active_integration=detected if detected is not None else integration,
5864
integration_explicit=bool(integration) and detected is None,
5965
)
60-
install_bundle(
66+
result = install_bundle(
6167
project_root, plan, installer, manifest=manifest, refresh=True
6268
)
69+
_render_install_warnings(result)
6370
console.print(
6471
f"[green]✓[/green] Updated '{_escape_markup(str(target))}' "
6572
f"to v{_escape_markup(str(plan.version))}."

0 commit comments

Comments
 (0)