diff --git a/examples/bundles/business-analyst/bundle.yml b/examples/bundles/business-analyst/bundle.yml index b03875a22e..90d35ce87d 100644 --- a/examples/bundles/business-analyst/bundle.yml +++ b/examples/bundles/business-analyst/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "requirements-elicitation" version: "1.0.0" diff --git a/examples/bundles/developer/bundle.yml b/examples/bundles/developer/bundle.yml index 3a365534e5..3f4dce5465 100644 --- a/examples/bundles/developer/bundle.yml +++ b/examples/bundles/developer/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "implementation-planning" version: "1.0.0" diff --git a/examples/bundles/product-manager/bundle.yml b/examples/bundles/product-manager/bundle.yml index 9abba40bd4..c5f96ab186 100644 --- a/examples/bundles/product-manager/bundle.yml +++ b/examples/bundles/product-manager/bundle.yml @@ -19,7 +19,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "product-discovery" version: "1.0.0" diff --git a/examples/bundles/security-researcher/bundle.yml b/examples/bundles/security-researcher/bundle.yml index d0b289e872..e017071e94 100644 --- a/examples/bundles/security-researcher/bundle.yml +++ b/examples/bundles/security-researcher/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "security-compliance" version: "1.0.0" diff --git a/extensions/agent-context/extension.yml b/extensions/agent-context/extension.yml index 191069e32c..2846b4d9a6 100644 --- a/extensions/agent-context/extension.yml +++ b/extensions/agent-context/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: agent-context name: "Coding Agent Context" - version: "1.0.0" + version: "1.1.0" description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/extensions/assess/extension.yml b/extensions/assess/extension.yml index 9161b268fb..42b281b8ee 100644 --- a/extensions/assess/extension.yml +++ b/extensions/assess/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: assess name: "Idea Assessment Pipeline" - version: "1.0.0" + version: "1.0.1" description: "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//" category: "process" effect: "read-write" diff --git a/extensions/catalog.json b/extensions/catalog.json index d05c48e0e5..af0aae7701 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,12 +1,12 @@ { "schema_version": "1.0", - "updated_at": "2026-07-17T00:00:00Z", + "updated_at": "2026-08-27T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "agent-context": { "name": "Coding Agent Context", "id": "agent-context", - "version": "1.0.0", + "version": "1.1.0", "description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -20,7 +20,7 @@ "assess": { "name": "Idea Assessment Pipeline", "id": "assess", - "version": "1.0.0", + "version": "1.0.1", "description": "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -51,7 +51,7 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.0", + "version": "1.1.0", "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index c92322d8b1..84e2dc35a5 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: git name: "Git Branching Workflow" - version: "1.0.0" + version: "1.1.0" description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/tests/bundler_helpers.py b/tests/bundler_helpers.py index 0ebaf2f1c7..2df11e03df 100644 --- a/tests/bundler_helpers.py +++ b/tests/bundler_helpers.py @@ -43,6 +43,24 @@ def valid_manifest_dict(**overrides) -> dict: return data +def bundled_extension_version(extension_id: str) -> str: + """Version declared by the bundled extension the primitives will install. + + Resolved through the same lookup ``BundleExtensionPrimitive`` uses, so + fixtures that pin a real bundled extension stay valid across legitimate + extension version bumps (#4345) instead of hardcoding a literal that + drifts out of sync and trips the exact-pin enforcement. + """ + from specify_cli._assets import _locate_bundled_extension + + bundled_dir = _locate_bundled_extension(extension_id) + assert bundled_dir is not None, f"bundled extension '{extension_id}' not found" + manifest = yaml.safe_load( + (bundled_dir / "extension.yml").read_text(encoding="utf-8") + ) + return manifest["extension"]["version"] + + def write_manifest(directory: Path, data: dict | None = None) -> Path: directory.mkdir(parents=True, exist_ok=True) manifest_path = directory / "bundle.yml" diff --git a/tests/contract/test_bundled_extension_update_delivery.py b/tests/contract/test_bundled_extension_update_delivery.py new file mode 100644 index 0000000000..f85e745829 --- /dev/null +++ b/tests/contract/test_bundled_extension_update_delivery.py @@ -0,0 +1,157 @@ +"""Contract tests: previously stale bundled installs are now offered an update. + +Every bundled extension shipped at 1.0.0 from its creation while its content +kept changing, so installed copies were reported "Up to date" forever +(#4345). The version bumps in extensions/*/extension.yml and the synced +extensions/catalog.json are what finally make `specify extension update` +offer those installs a newer version, and the local-package route installs +it from the copy bundled with the running spec-kit. + +Unit tests cover that route with synthetic extensions and versions, and the +version contract only checks catalog/manifest equality. These tests close +the gap with the real data: install each bundled extension's real source +at the pre-bump 1.0.0, run `extension update` against the real catalog +entry and the real bundled copy, and assert it reaches the catalog version. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from packaging.version import Version +from typer.testing import CliRunner + +REPO_ROOT = Path(__file__).parents[2] +EXTENSIONS_ROOT = REPO_ROOT / "extensions" +PRE_BUMP_VERSION = "1.0.0" +# Bundled extensions whose content had drifted while still declaring 1.0.0 +# when #4345 was filed. Their catalog version must stay above PRE_BUMP_VERSION +# so a copy installed before the fix is actually offered an update; a version +# that is not bumped past it here is a regression, not a skip. +DRIFTED_BEFORE_BUMP = frozenset({"agent-context", "assess", "git"}) + + +def _catalog_entries() -> dict[str, dict]: + catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) + return catalog["extensions"] + + +def _bundled_ids() -> list[str]: + return sorted( + ext_id + for ext_id, entry in _catalog_entries().items() + if entry.get("bundled") + and not entry.get("download_url") + and (EXTENSIONS_ROOT / ext_id / "extension.yml").is_file() + ) + + +def _make_project(tmp_path: Path) -> Path: + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + return project_dir + + +def _stale_copy(tmp_path: Path, ext_id: str, version: str) -> Path: + """The real bundled source with only its manifest version rewritten.""" + source = tmp_path / "stale" / ext_id + shutil.copytree(EXTENSIONS_ROOT / ext_id, source) + manifest_path = source / "extension.yml" + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + data["extension"]["version"] = version + manifest_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return source + + +def _run_update(project_dir: Path, ext_id: str): + from specify_cli import app + from specify_cli.extensions import ExtensionCatalog + + catalog_info = dict(_catalog_entries()[ext_id]) + catalog_info.setdefault("_install_allowed", True) + # Real catalog entry and real bundled copy; only the network fetch of the + # catalog is replaced, and downloading must never be attempted for a + # bundled extension. + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value=catalog_info), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("bundled update must not download"), + ): + return CliRunner().invoke( + app, ["extension", "update", ext_id], input="y\n", catch_exceptions=True + ) + + +def test_drifted_extensions_are_covered(): + """The parametrized tests below draw their cases from the live catalog, so + an extension that lost its ``bundled`` flag, its catalog entry, or its + in-repo manifest would silently drop out of them and the + ``DRIFTED_BEFORE_BUMP`` guard inside would never run for it. Pin the + coverage itself: every extension known to have drifted must still be a + bundled, in-repo catalog entry. De-bundling one of them is a deliberate + decision that should update this set, not a silent loss of coverage.""" + covered = set(_bundled_ids()) + missing = DRIFTED_BEFORE_BUMP - covered + assert not missing, ( + f"{sorted(missing)} drifted before #4345 but are no longer bundled, in-repo " + f"catalog entries, so the update-delivery tests no longer cover them" + ) + + +@pytest.mark.parametrize("ext_id", _bundled_ids()) +def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext_id: str): + from specify_cli._assets import get_speckit_version + from specify_cli.extensions import ExtensionManager + + catalog_version = Version(_catalog_entries()[ext_id]["version"]) + if catalog_version <= Version(PRE_BUMP_VERSION): + assert ext_id not in DRIFTED_BEFORE_BUMP, ( + f"'{ext_id}' drifted at {PRE_BUMP_VERSION} before #4345 but the catalog still " + f"advertises {catalog_version}; installs made before the fix would never be " + f"offered the shipped changes" + ) + pytest.skip(f"'{ext_id}' has not been bumped past {PRE_BUMP_VERSION}; nothing to deliver") + + project_dir = _make_project(tmp_path) + stale_source = _stale_copy(tmp_path, ext_id, PRE_BUMP_VERSION) + manager = ExtensionManager(project_dir) + manager.install_from_directory(stale_source, get_speckit_version()) + assert manager.registry.get(ext_id)["version"] == PRE_BUMP_VERSION + + result = _run_update(project_dir, ext_id) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert f"Updated to v{catalog_version}" in flat, flat + assert "Up to date" not in flat, flat + assert ExtensionManager(project_dir).registry.get(ext_id)["version"] == str(catalog_version) + + +@pytest.mark.parametrize("ext_id", _bundled_ids()) +def test_current_bundled_install_is_up_to_date(tmp_path: Path, ext_id: str): + """The bumped catalog must not re-offer an update to an install that + already carries the bundled version, or every fresh install would loop.""" + from specify_cli._assets import get_speckit_version + from specify_cli.extensions import ExtensionManager + + catalog_version = _catalog_entries()[ext_id]["version"] + project_dir = _make_project(tmp_path) + ExtensionManager(project_dir).install_from_directory( + EXTENSIONS_ROOT / ext_id, get_speckit_version() + ) + + result = _run_update(project_dir, ext_id) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert f"Up to date (v{catalog_version})" in flat, flat + assert ExtensionManager(project_dir).registry.get(ext_id)["version"] == catalog_version diff --git a/tests/contract/test_bundled_extension_versions.py b/tests/contract/test_bundled_extension_versions.py index 2d7f0c6c0e..f86f0afc72 100644 --- a/tests/contract/test_bundled_extension_versions.py +++ b/tests/contract/test_bundled_extension_versions.py @@ -25,6 +25,7 @@ REPO_ROOT = Path(__file__).parents[2] EXTENSIONS_ROOT = REPO_ROOT / "extensions" +EXAMPLE_BUNDLES_ROOT = REPO_ROOT / "examples" / "bundles" def _catalog_entries() -> dict[str, dict]: @@ -81,3 +82,39 @@ def test_catalog_version_is_valid_pep440(ext_id: str): f"extensions/catalog.json entry '{ext_id}' version {version!r} is not a valid " f"PEP 440 version ({exc}); `extension update` would skip it" ) + + +def _example_bundle_extension_pins() -> list[tuple[str, str, str]]: + """(bundle name, extension id, pinned version) for every example bundle.""" + pins = [] + for manifest_path in sorted(EXAMPLE_BUNDLES_ROOT.glob("*/bundle.yml")): + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + for entry in (data.get("provides") or {}).get("extensions") or []: + pins.append((manifest_path.parent.name, entry["id"], str(entry.get("version")))) + return pins + + +def test_example_bundles_pin_extensions(): + assert _example_bundle_extension_pins(), "expected at least one extension pin in examples/bundles" + + +@pytest.mark.parametrize( + "bundle_name,ext_id,pinned", + _example_bundle_extension_pins(), + ids=lambda v: v if isinstance(v, str) else str(v), +) +def test_example_bundle_pins_match_bundled_manifest(bundle_name: str, ext_id: str, pinned: str): + """``BundleExtensionPrimitive`` refuses to install when the resolved + extension version differs from the bundle's pin, so an example bundle that + still pins a bundled extension's previous version fails for every user who + installs it. Test fixtures derive their pins from the bundled manifest and + therefore cannot catch this; the shipped examples must be checked directly.""" + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + if not manifest_path.is_file(): + pytest.skip(f"'{ext_id}' is not a bundled extension; pin resolves via the catalog at install time") + manifest_version = _manifest_version(ext_id) + assert Version(pinned) == Version(manifest_version), ( + f"examples/bundles/{bundle_name}/bundle.yml pins '{ext_id}' to {pinned} but " + f"{manifest_path.relative_to(REPO_ROOT)} declares {manifest_version}; the bundler " + f"enforces exact pins, so installing this example would fail" + ) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index f6be51caf6..5bc25b9332 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -145,7 +145,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "git" - assert m.version == "1.0.0" + assert m.version == "1.1.0" def test_manifest_commands(self): """Manifest declares expected commands.""" diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py index a13def5ff8..291c67871f 100644 --- a/tests/integration/test_bundler_init_install.py +++ b/tests/integration/test_bundler_init_install.py @@ -18,7 +18,7 @@ from specify_cli.bundler.models.manifest import BundleManifest from specify_cli.commands.bundle import _resolve_init_integration from specify_cli.bundler.services.packager import build_bundle -from tests.bundler_helpers import valid_manifest_dict +from tests.bundler_helpers import bundled_extension_version, valid_manifest_dict runner = CliRunner() @@ -75,7 +75,14 @@ def _build_mini(tmp_path: Path) -> Path: "license": "MIT", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, + "provides": { + "extensions": [ + { + "id": "agent-context", + "version": bundled_extension_version("agent-context"), + } + ] + }, } ), encoding="utf-8", diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 7655543ebb..5760bfb4e1 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -18,7 +18,13 @@ from specify_cli import app from specify_cli.bundler import BundlerError from specify_cli.commands.bundle import _local_manifest_source -from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict, write_manifest +from tests.bundler_helpers import ( + FakeInstaller, + bundled_extension_version, + make_project, + valid_manifest_dict, + write_manifest, +) def test_local_source_none_for_non_path(): @@ -116,7 +122,12 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path): }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { - "extensions": [{"id": "agent-context", "version": "1.0.0"}] + "extensions": [ + { + "id": "agent-context", + "version": bundled_extension_version("agent-context"), + } + ] }, } ),