Skip to content

Commit e519d6b

Browse files
committed
fix(bundles): keep the pin check's errors clean and fail on unknown versions
Run the unowned-component pin check inside the install try, so a raw error from an unreadable registry becomes the usual BundlerError, and treat an installed version that can't be read as a mismatch instead of skipping the component. Refs #4434 Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
1 parent 4d76a9f commit e519d6b

3 files changed

Lines changed: 53 additions & 11 deletions

File tree

‎docs/reference/bundles.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ specify bundle install <bundle_id | path>
6969

7070
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.
7171

72-
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 — components already present are skipped. Components installed outside any bundle are skipped and never adopted, so their installed version must match the manifest pin; if it doesn't, install and refresh stop before changing anything and name the component, so you can remove it or install the pinned version yourself. 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.
72+
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 — components already present are skipped. Components installed outside any bundle are skipped and never adopted, so their installed version must match the manifest pin; if it doesn't, or can't be read, install and refresh stop before changing anything and name the component, so you can remove it or install the pinned version yourself. 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.
7373

7474
A normal install rejects a change to an already-recorded bundle's version or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`:
7575

‎src/specify_cli/bundles/installer.py‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,10 @@ def install_bundle(
132132
if r.bundle_id != plan.bundle_id
133133
for c in r.contributed_components
134134
}
135-
_check_unowned_pins(project_root, plan, installer, prior_ours | other_tracked)
136-
137135
contributed: list[ComponentRef] = []
138136
done: list[ComponentRef] = []
139137
try:
138+
_check_unowned_pins(project_root, plan, installer, prior_ours | other_tracked)
140139
for component in plan.components:
141140
key = (component.kind, component.id)
142141
if installer.is_installed(project_root, component):
@@ -264,9 +263,9 @@ def _check_unowned_pins(
264263
A component tracked by no bundle is skipped and never refreshed (FR-022), so
265264
skipping it is only correct when it already has the pinned version.
266265
Otherwise the bundle record would advance while the project keeps running a
267-
different version (#4434). Runs before any primitive is touched. Installers
268-
without an ``installed_version`` hook, and components whose installed
269-
version is unknown, are not checked.
266+
different version (#4434). Runs before any primitive is touched. A component
267+
whose installed version can't be read fails too, since it can't be shown to
268+
match. Installers without an ``installed_version`` hook are not checked.
270269
"""
271270
installed_version = getattr(installer, "installed_version", None)
272271
if not callable(installed_version):
@@ -278,11 +277,11 @@ def _check_unowned_pins(
278277
if not installer.is_installed(project_root, component):
279278
continue
280279
actual = installed_version(project_root, component)
281-
if actual is not None and not same_version(actual, component.version):
282-
mismatches.append(
283-
f"{component.kind[:-1]} '{component.id}' to {component.version}, "
284-
f"but {actual} is installed"
285-
)
280+
pinned = f"{component.kind[:-1]} '{component.id}' to {component.version}"
281+
if actual is None:
282+
mismatches.append(f"{pinned}, but its installed version is unknown")
283+
elif not same_version(actual, component.version):
284+
mismatches.append(f"{pinned}, but {actual} is installed")
286285
if mismatches:
287286
raise BundlerError(
288287
f"Bundle '{plan.bundle_id}' pins {'; '.join(mismatches)}. Bundles "

‎tests/specify_cli/bundles/test_installer.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path
492492
manifest = BundleManifest.from_dict(valid_manifest_dict())
493493
installer = FakeInstaller()
494494
installer.installed.add(("extensions", "ext-a"))
495+
installer.versions[("extensions", "ext-a")] = "1.0.0"
495496

496497
result = install_bundle(
497498
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
@@ -516,6 +517,7 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
516517
installer = FakeInstaller()
517518
# Pre-install ext-a independently — no bundle record references it yet.
518519
installer.installed.add(("extensions", "ext-a"))
520+
installer.versions[("extensions", "ext-a")] = "1.0.0"
519521

520522
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
521523

@@ -572,6 +574,47 @@ def test_refresh_rejects_independently_installed_component_at_other_version(
572574
assert records_path(tmp_path).read_bytes() == original_record
573575

574576

577+
def test_install_rejects_independently_installed_component_of_unknown_version(
578+
tmp_path: Path,
579+
):
580+
# A registry entry without a readable version can't be shown to meet the
581+
# pin, and skipping it would recreate the #4434 mismatch.
582+
make_project(tmp_path)
583+
manifest = BundleManifest.from_dict(valid_manifest_dict())
584+
installer = FakeInstaller()
585+
installer.installed.add(("extensions", "ext-a"))
586+
587+
with pytest.raises(
588+
BundlerError, match=r"ext-a' to 1\.0\.0, but its installed version is unknown"
589+
):
590+
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
591+
592+
assert installer.install_calls == []
593+
assert not records_path(tmp_path).exists()
594+
595+
596+
def test_install_converts_raw_pin_check_exception_to_bundler_error(
597+
tmp_path: Path,
598+
):
599+
# The pin check runs before anything is installed, but an unreadable
600+
# registry (e.g. _WorkflowKindManager failing closed at construction) must
601+
# still surface as the clean BundlerError the CLI catches.
602+
make_project(tmp_path)
603+
manifest = BundleManifest.from_dict(valid_manifest_dict())
604+
installer = FakeInstaller()
605+
installer.installed.add(("extensions", "ext-a"))
606+
607+
def boom(project_root, component):
608+
raise OSError("workflow registry unreadable")
609+
610+
installer.installed_version = boom
611+
with pytest.raises(BundlerError, match="workflow registry unreadable"):
612+
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
613+
614+
assert installer.install_calls == []
615+
assert not records_path(tmp_path).exists()
616+
617+
575618
def test_independently_installed_component_at_pinned_version_stays_unowned(
576619
tmp_path: Path,
577620
):

0 commit comments

Comments
 (0)