fix(bundles): verify pins of independently installed components - #4789
Conversation
A component installed outside any bundle is skipped by bundle install and never refreshed (FR-022), but its version was never compared with the manifest pin. The bundle record advanced while the project kept running the other version, and --refresh or bundle update could not repair it because the component stays unowned. Read each primitive registry's recorded version and, before any primitive runs, refuse when such a component does not match its pin, naming the component, the pin and the installed version. Components a bundle owns are unchanged, and a component already at the pinned version is still skipped and left unowned. Refs github#4434 Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
|
I think it's worth mentioning that this doesn't need a hand-edited version to happen. A project that added the bundled The AI disclosure: drafted on behalf of @kartsan03 by Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode). The agent ran the commands above and drafted this comment. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The preflight can leak raw registry errors and silently accepts installed components whose versions cannot be determined.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Adds version-pin validation for independently installed bundle components before installation or refresh.
Changes:
- Reads installed versions from all primitive registries.
- Rejects mismatched unowned components before mutation.
- Adds regression tests and documentation.
Regression tests were reviewed but not executed in this environment.
| File | Description |
|---|---|
src/specify_cli/bundles/adapters.py |
Exposes installed-version lookup. |
src/specify_cli/bundles/installer.py |
Adds unowned-component pin validation. |
src/specify_cli/bundles/primitives.py |
Reads versions from primitive registries. |
src/specify_cli/bundles/versioning.py |
Adds normalized exact-version comparison. |
tests/specify_cli/bundles/helpers.py |
Extends the fake installer with versions. |
tests/specify_cli/bundles/test_command_install.py |
Adds CLI regression coverage. |
tests/specify_cli/bundles/test_installer.py |
Covers mismatch, refresh, and matching pins. |
tests/specify_cli/bundles/test_primitives.py |
Tests all registry readers. |
docs/reference/bundles.md |
Documents unowned-component pin enforcement. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Please address Copilot feedback |
…ersions 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 github#4434 Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
|
Addressed both Copilot findings in e519d6b:
New tests cover both and fail on the previous commit. Full suite: 8718 passed, 251 skipped. AI disclosure: drafted on behalf of @kartsan03 by Claude Code (model: Claude Opus 5.5, xhigh reasoning effort, autonomous agent mode). The agent made the changes, ran the tests and drafted this comment. |

Description
Refs #4434: the second case, which #4477 left open.
A component installed on its own before a bundle (
specify extension add,specify preset add, and so on) is tracked by no bundle record.install_bundleskips it by ID and never refreshes it (FR-022), but never compares its installed version with the manifest pin either. So:specify bundle installsucceeds and records the bundle while the project keeps running the other version;--refreshandbundle updatecan't repair it: the component is still unowned, so it is skipped again while the record advances to the new bundle version.This change reads the installed version from each primitive's registry (extensions, presets, workflows and steps all record
version). Before any primitive runs, the install stops if a component installed outside any bundle doesn't match its pin, and the error names the component, the pin and the installed version. Nothing changes for components a bundle owns. A component that already has the pinned version is still skipped and left unowned, so FR-022's no-collateral removal holds.Reproduction on current
main:specify init proj --integration codex.bugextension (1.0.0), relabel the copy 0.9.0, and install it on its own withspecify extension add ../bug-0.9.0 --dev.bundle.yml:.specify/extensions/.registrystill hasbugat 0.9.0..specify/bundle-records.jsonrecordsprogram-kit1.0.0 withcontributed_components: [].The same manifest with the bundle version bumped to 1.1.0 (still pinning
bug1.0.0) goes through--refresh:The record now says 1.1.0, and
bugis still 0.9.0.With this change, both commands exit 1 before changing anything:
After
specify extension remove bug --force, the same install addsbug1.0.0 and records it as owned by the bundle.Not changed:
contributed_components. Adopting it would change whatbundle removedeletes, so that would need an explicit opt-in.Testing
Tested locally with
uv run specify --helpRan existing tests with
uv sync && uv run pytestTested with a sample project (if applicable)
New tests:
install_bundlerefuses, on both install and refresh, an independently installed component at another version, without installing anything or touching the record.v1.0.0for a1.0.0pin) is still skipped and left unowned.installed_versionreads each of the four primitive registries.bugextension.All except the matching-version test fail on
mainand pass with the change.Full suite: 8716 passed, 251 skipped (Linux, Python 3.13)
uvx ruff@0.15.0 check src tests: cleanSample project: the repro above against
mainand this branch.AI Disclosure
AI disclosure: Claude Code (Claude Opus 5.5, max reasoning effort, autonomous agent mode) was used for drafting/refactoring the code change, the regression tests and this description.