Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/bundles/business-analyst/bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/bundles/developer/bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/bundles/product-manager/bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/bundles/security-researcher/bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion extensions/agent-context/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion extensions/assess/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/"
category: "process"
effect: "read-write"
Expand Down
8 changes: 4 additions & 4 deletions extensions/catalog.json
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
CrazyBaran marked this conversation as resolved.
Comment thread
CrazyBaran marked this conversation as resolved.
"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",
Expand All @@ -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/<slug>/",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tests/bundler_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
CrazyBaran marked this conversation as resolved.
"""
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"
Expand Down
157 changes: 157 additions & 0 deletions tests/contract/test_bundled_extension_update_delivery.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions tests/contract/test_bundled_extension_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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"
)
2 changes: 1 addition & 1 deletion tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
11 changes: 9 additions & 2 deletions tests/integration/test_bundler_init_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions tests/integration/test_bundler_local_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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"),
}
]
},
}
),
Expand Down