Skip to content

fix(bundles): verify pins of independently installed components - #4789

Merged
KSchlobohm merged 2 commits into
github:mainfrom
kartsan03:fix/bundle-unowned-component-pin
Sep 29, 2026
Merged

KSchlobohm merged 2 commits into
github:mainfrom
kartsan03:fix/bundle-unowned-component-pin

Conversation

@kartsan03

Copy link
Copy Markdown
Contributor

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_bundle skips it by ID and never refreshes it (FR-022), but never compares its installed version with the manifest pin either. So:

  • specify bundle install succeeds and records the bundle while the project keeps running the other version;
  • --refresh and bundle update can'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:

  1. Create a project with specify init proj --integration codex.
  2. Copy the bundled bug extension (1.0.0), relabel the copy 0.9.0, and install it on its own with specify extension add ../bug-0.9.0 --dev.
  3. Install this bundle.yml:
schema_version: "1.0"
bundle:
  id: program-kit
  name: Program Kit
  version: 1.0.0
  role: developer
  description: Pins the bundled bug extension.
  author: repro
  license: MIT
requires:
  speckit_version: ">=0.1.0"
provides:
  extensions:
    - id: bug
      version: 1.0.0
$ specify bundle install ../bundle.yml --offline
✓ Installed 'program-kit' (0 added, 1 already present).

.specify/extensions/.registry still has bug at 0.9.0. .specify/bundle-records.json records program-kit 1.0.0 with contributed_components: [].

The same manifest with the bundle version bumped to 1.1.0 (still pinning bug 1.0.0) goes through --refresh:

$ specify bundle install ../bundle-v2.yml --refresh --offline
✓ Installed 'program-kit' (0 added, 1 already present, 0 refreshed, 0 removed).

The record now says 1.1.0, and bug is still 0.9.0.

With this change, both commands exit 1 before changing anything:

Error: Bundle 'program-kit' pins extension 'bug' to 1.0.0, but 0.9.0 is installed. Bundles leave components installed outside any bundle unchanged, so remove the installed version or install the pinned one yourself, then re-run.

After specify extension remove bug --force, the same install adds bug 1.0.0 and records it as owned by the bundle.

Not changed:

  • An independently installed component at the pinned version is still not adopted into contributed_components. Adopting it would change what bundle remove deletes, so that would need an explicit opt-in.
  • Components shared through another bundle's record are handled as before.

Testing

  • Tested locally with uv run specify --help

  • Ran existing tests with uv sync && uv run pytest

  • Tested with a sample project (if applicable)

  • New tests:

    • install_bundle refuses, on both install and refresh, an independently installed component at another version, without installing anything or touching the record.
    • A matching version (v1.0.0 for a 1.0.0 pin) is still skipped and left unowned.
    • installed_version reads each of the four primitive registries.
    • A CLI test with the real bundled bug extension.

    All except the matching-version test fail on main and pass with the change.

  • Full suite: 8716 passed, 251 skipped (Linux, Python 3.13)

  • uvx ruff@0.15.0 check src tests: clean

  • Sample project: the repro above against main and this branch.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (fill in the disclosure below)

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.

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)
@kartsan03
kartsan03 requested a review from mnriem as a code owner September 29, 2026 12:40
@mnriem mnriem added the triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate label Sep 29, 2026
@kartsan03

Copy link
Copy Markdown
Contributor Author

I think it's worth mentioning that this doesn't need a hand-edited version to happen. A project that added the bundled assess extension before #4394 bumped it to 1.0.1 hits it with the first-party assess bundle after upgrading, using released versions only:

$ uvx --from specify-cli==1.0.9 specify init proj --integration codex --ignore-agent-tools --non-interactive
$ uvx --from specify-cli==1.0.9 specify extension add assess     # bundled assess extension 1.0.0
$ uvx --from specify-cli==1.0.12 specify bundle add assess        # first-party bundle, pins assess 1.0.1
✓ Workflow 'Idea Assessment Pipeline' (assess) installed
✓ Installed 'assess' (1 added, 1 already present).

The assess extension stays at 1.0.0. .specify/bundle-records.json records the bundle with only the workflow in contributed_components, so bundle update won't refresh the extension later either. The same project on this branch stops before changing anything:

Error: Bundle 'assess' pins extension 'assess' to 1.0.1, but 1.0.0 is installed. Bundles leave components installed outside any bundle unchanged, so remove the installed version or install the pinned one yourself, then re-run.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

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.

Comment thread src/specify_cli/bundles/installer.py Outdated
Comment thread src/specify_cli/bundles/installer.py Outdated
@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The implementation is scoped, fail-fast, and supported by positive and negative regression coverage.

Review effort: Balanced
Findings: None

Resolved since last review (2)

@kartsan03

Copy link
Copy Markdown
Contributor Author

Addressed both Copilot findings in e519d6b:

  • The pin check now runs inside the same try as the install loop, so an unreadable registry (for example _WorkflowKindManager failing closed at construction) surfaces as the usual Failed to install bundle ... BundlerError instead of a raw exception.
  • An independently installed component whose version can't be read now fails the check (... but its installed version is unknown) instead of being skipped. Every install path writes version to its registry, so this only triggers on a damaged or hand-edited registry. Two existing tests that pre-install ext-a without a version now give it the pinned one.

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.

@KSchlobohm KSchlobohm self-assigned this Sep 29, 2026
@KSchlobohm
KSchlobohm merged commit 59ab543 into github:main Sep 29, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants