From 60dc320e5dbccaa090645dd64e3f8d8228ec85d3 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:33:03 +0200 Subject: [PATCH 01/10] fix(extensions): bump drifted bundled extension versions (agent-context, git 1.1.0; assess 1.0.1) The bundled agent-context and git extensions have carried version 1.0.0 since they were created while their content kept changing - including fixes for failures that made them unusable on some platforms (agent-context: 15 commits, +1,120/-169 across 7 of its 8 files; git: 23 commits, +2,191/-567 across all 21 files). Because `specify extension update` compares semver only, every installed copy is reported "Up to date (v1.0.0)" forever and never receives those fixes (#4345). Bump both manifests to 1.1.0 and sync extensions/catalog.json so existing installs finally see an available update. assess also drifted (one docs-only change to a shipped command file since its version was set), so it gets a patch bump to 1.0.1; bug has no drift and stays at 1.0.0. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- extensions/agent-context/extension.yml | 2 +- extensions/assess/extension.yml | 2 +- extensions/catalog.json | 8 ++++---- extensions/git/extension.yml | 2 +- tests/extensions/git/test_git_extension.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) 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/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.""" From dfa1fa6e8b638e748564c76395db0cc1d5ffa391 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:35:52 +0200 Subject: [PATCH 02/10] ci(extensions): guard bundled extension changes behind a version bump The version staleness fixed in the previous commit regressed silently because nothing enforced bumping: extensions/agent-context and extensions/git each absorbed months of content changes (including platform-breaking bug fixes) while extension.yml stayed at 1.0.0, so `specify extension update` kept telling every installed copy it was up to date (#4345). Add a PR-gating workflow (extension-version-guard.yml) that runs .github/scripts/check_extension_version_bump.py whenever a PR touches extensions/**. The script enforces two invariants for extensions listed in extensions/catalog.json: 1. Any change under extensions// must increase extension.yml's version (base vs head of the PR). 2. The catalog.json version must equal the manifest version - the catalog drives the update check, and the update preflight rejects a manifest whose version differs from it. Extensions not in the catalog (the selftest fixture and the template scaffold) are exempt: no update flow is driven by their versions. The sync invariant (2) is additionally enforced from a plain working tree by tests/contract/test_bundled_extension_versions.py, so it also holds on platforms and forks that do not run the workflow. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- .../scripts/check_extension_version_bump.py | 163 ++++++++++++++++++ .github/workflows/extension-version-guard.yml | 43 +++++ .../test_bundled_extension_versions.py | 64 +++++++ 3 files changed, 270 insertions(+) create mode 100644 .github/scripts/check_extension_version_bump.py create mode 100644 .github/workflows/extension-version-guard.yml create mode 100644 tests/contract/test_bundled_extension_versions.py diff --git a/.github/scripts/check_extension_version_bump.py b/.github/scripts/check_extension_version_bump.py new file mode 100644 index 0000000000..8a2cd980dc --- /dev/null +++ b/.github/scripts/check_extension_version_bump.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Fail a PR that changes bundled extension content without a version bump. + +`specify extension update` decides whether an installed extension needs +updating purely by comparing the semver in `extensions/catalog.json` +against the installed copy's registered version. Content changes that +ship without a version bump therefore never reach existing installs: +every one of them reports "Up to date" forever (#4345). + +This check enforces two invariants on the extensions listed in +`extensions/catalog.json`: + +1. Any change to a file under `extensions//` must be accompanied by + a `version:` change in that extension's `extension.yml`. +2. The `version` in `extensions/catalog.json` must equal the manifest's + `extension.version` (the catalog is what update checks compare + against, and the update preflight rejects a manifest whose version + differs from the catalog's). + +Usage: + check_extension_version_bump.py BASE_REF [HEAD_REF] + +BASE_REF is a git ref/SHA for the PR base (must be fetchable with +`git show`). HEAD_REF defaults to the working tree's HEAD. Exits 0 when +all invariants hold, 1 otherwise, printing one line per violation. + +Extensions under `extensions/` that are not in the catalog (the +`selftest` fixture and the `template` scaffold) are exempt: no update +flow is driven by their versions. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +EXTENSIONS_ROOT = "extensions" +CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json" + + +def _git(*args: str) -> str: + return subprocess.run( + ["git", *args], check=True, capture_output=True, text=True + ).stdout + + +def _show(ref: str, path: str) -> str | None: + """Return the file's content at *ref*, or None when absent there.""" + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], capture_output=True, text=True + ) + return result.stdout if result.returncode == 0 else None + + +def _manifest_version(manifest_text: str, origin: str) -> str: + data = yaml.safe_load(manifest_text) + if not isinstance(data, dict) or not isinstance(data.get("extension"), dict): + raise ValueError(f"{origin}: manifest is not a mapping with an 'extension' block") + version = data["extension"].get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{origin}: extension.version is missing or not a string") + return version.strip() + + +def _version_tuple(version: str) -> tuple[int, ...] | None: + """Parse a dotted-numeric version, or None when any part is non-numeric.""" + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return None + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or len(argv) > 3: + print(__doc__, file=sys.stderr) + return 2 + base_ref = argv[1] + head_ref = argv[2] if len(argv) == 3 else "HEAD" + + catalog_text = _show(head_ref, CATALOG_PATH) + if catalog_text is None: + print(f"::error::{CATALOG_PATH} is missing at {head_ref}") + return 1 + catalog = json.loads(catalog_text) + catalog_entries = catalog.get("extensions", {}) + + errors: list[str] = [] + + # -- Invariant 1: content change requires a version bump --------------- + changed = _git( + "diff", "--name-only", "--no-renames", base_ref, head_ref, "--", EXTENSIONS_ROOT + ).splitlines() + changed_ids = { + parts[1] + for line in changed + if len(parts := Path(line.strip()).parts) >= 3 and parts[0] == EXTENSIONS_ROOT + } + + for ext_id in sorted(changed_ids): + if ext_id not in catalog_entries: + continue # not driven by `extension update` (selftest, template) + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # extension removed in this PR + base_manifest = _show(base_ref, manifest_path) + if base_manifest is None: + continue # new extension; any initial version is fine + try: + base_version = _manifest_version(base_manifest, f"{base_ref}:{manifest_path}") + head_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + + base_parsed = _version_tuple(base_version) + head_parsed = _version_tuple(head_version) + if base_parsed is not None and head_parsed is not None: + if head_parsed <= base_parsed: + errors.append( + f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " + f"extension.version did not increase ({base_version} -> {head_version}). " + f"Installed copies only receive changes when the version is bumped." + ) + elif head_version == base_version: + errors.append( + f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " + f"extension.version is still {base_version}. " + f"Installed copies only receive changes when the version is bumped." + ) + + # -- Invariant 2: catalog.json version matches the manifest ------------ + for ext_id, entry in sorted(catalog_entries.items()): + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # catalog-only entry (e.g. hosted elsewhere) + try: + manifest_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + catalog_version = entry.get("version") + if catalog_version != manifest_version: + errors.append( + f"{CATALOG_PATH}: entry '{ext_id}' has version {catalog_version!r} but " + f"{manifest_path} declares {manifest_version!r}. `extension update` " + f"compares against the catalog, so the two must move together." + ) + + for error in errors: + print(f"::error::{error}") + if not errors: + print("Extension version guard: all invariants hold.") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/extension-version-guard.yml b/.github/workflows/extension-version-guard.yml new file mode 100644 index 0000000000..2c25ffa420 --- /dev/null +++ b/.github/workflows/extension-version-guard.yml @@ -0,0 +1,43 @@ +name: Extension Version Guard + +permissions: + contents: read + +# Bundled extensions only reach existing installs through a version bump: +# `specify extension update` compares the semver in extensions/catalog.json +# against the installed copy and reports "Up to date" whenever they match. +# Content changes shipped without a bump go silently stale on every +# project that already installed the extension (#4345). This guard turns +# "please remember to bump" into a merge requirement. +on: + pull_request: + paths: + - "extensions/**" + +jobs: + version-bump: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install PyYAML + run: python -m pip install --quiet pyyaml + + # For pull_request events the checkout is the merge of the PR head + # into the base tip, so diffing base.sha against HEAD yields exactly + # the PR's changes (same fetch pattern as lint.yml). + - name: Check bundled extension version bumps + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base" + python .github/scripts/check_extension_version_bump.py refs/checks/pr-base diff --git a/tests/contract/test_bundled_extension_versions.py b/tests/contract/test_bundled_extension_versions.py new file mode 100644 index 0000000000..f34a31fcfa --- /dev/null +++ b/tests/contract/test_bundled_extension_versions.py @@ -0,0 +1,64 @@ +"""Contract tests: bundled extension versions must stay in sync with the catalog. + +``specify extension update`` decides whether an installed extension needs +updating by comparing the semver in ``extensions/catalog.json`` against the +installed copy's registered version, and its preflight rejects a manifest +whose version differs from the catalog's. A catalog entry that drifts from +its ``extension.yml`` therefore either hides updates from every installed +copy or makes every offered update fail validation (#4345). + +The companion "content change requires a version bump" rule needs the git +diff of a PR and lives in CI +(``.github/scripts/check_extension_version_bump.py`` via the +``extension-version-guard.yml`` workflow); this test enforces the half that +is checkable from a plain working tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).parents[2] +EXTENSIONS_ROOT = REPO_ROOT / "extensions" + + +def _catalog_entries() -> dict[str, dict]: + catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) + return catalog["extensions"] + + +def _manifest_version(ext_id: str) -> str: + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + return data["extension"]["version"] + + +def test_catalog_lists_extensions(): + assert _catalog_entries(), "expected at least one extension in extensions/catalog.json" + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_catalog_version_matches_manifest(ext_id: str): + entry = _catalog_entries()[ext_id] + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + if not manifest_path.is_file(): + pytest.skip(f"'{ext_id}' has no in-repo extension directory") + assert entry.get("version") == _manifest_version(ext_id), ( + f"extensions/catalog.json entry '{ext_id}' and {manifest_path.relative_to(REPO_ROOT)} " + f"declare different versions - `specify extension update` compares against the " + f"catalog, so the two must move together" + ) + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_bundled_entries_ship_an_extension_directory(ext_id: str): + entry = _catalog_entries()[ext_id] + if not entry.get("bundled"): + pytest.skip(f"'{ext_id}' is not marked bundled") + assert (EXTENSIONS_ROOT / ext_id / "extension.yml").is_file(), ( + f"catalog marks '{ext_id}' as bundled but extensions/{ext_id}/extension.yml is missing" + ) From 1683f27833df7ee3e1649ad8ba4c98e00fd71259 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:43:13 +0200 Subject: [PATCH 03/10] feat(extensions): detect content-stale bundled extensions in `extension update` Belt-and-braces for #4345: even with the CI guard from the previous commit, any bundled-extension content change that ever ships without a version bump is invisible to `specify extension update` - the registry's manifest_hash covers only extension.yml, and the update check compares semver alone, so installed copies report "Up to date" while silently missing shipped fixes (in our case a hook that failed on every run for two months). Add compute_extension_content_hash(), a SHA256 over an extension directory's shipped files (sorted relative paths + bytes), excluding exactly what installs treat as user-owned or skip: *-config.yml / *-config.local.yml and .extensionignore plus whatever it ignores. The same function therefore yields comparable hashes for a bundled source directory and an installation made from it. - install_from_directory() now records the source's content_hash in the registry (all install routes funnel through it); a hash failure never fails an install. - When `extension update` finds a bundled extension (no download URL) whose catalog version equals the installed version, it compares the recorded hash - falling back to hashing the installed directory for registry entries that predate content_hash, which covers every existing install - against the copy bundled with the running spec-kit version. A mismatch is reported as stale content with the exact refresh command (`specify extension add --force`) instead of "Up to date", and suppresses the green "All extensions are up to date!" all-clear. Exit code stays 0: the report is advisory, and the version comparison's verdict is unchanged. User config edits are not flagged (config files are excluded from the hash), and non-bundled/downloadable extensions are untouched - their updates are served by the normal version flow. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- src/specify_cli/extensions/__init__.py | 81 ++++++-- src/specify_cli/extensions/_commands.py | 56 ++++- tests/test_extension_content_staleness.py | 240 ++++++++++++++++++++++ 3 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 tests/test_extension_content_staleness.py diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..30369d16e6 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -734,6 +734,55 @@ def get_hash(self) -> str: return f"sha256:{h.hexdigest()}" +def compute_extension_content_hash(ext_dir: Path) -> str: + """Calculate a SHA256 hash over an extension directory's shipped files. + + The registry's ``manifest_hash`` only covers ``extension.yml``, so + content changes shipped without a manifest edit are invisible to it + (#4345). This hash covers every regular file an install would copy: + it folds in the sorted POSIX-style relative path and raw bytes of each + file, excluding exactly what installs treat as user-owned or skip — + ``*-config.yml`` / ``*-config.local.yml`` (preserved across installs) + and ``.extensionignore`` plus whatever it ignores. The same function + therefore yields comparable hashes for a bundled source directory and + an installation made from it. + + Raises OSError when the directory cannot be read. + """ + ignore_fn = ExtensionManager._load_extensionignore(ext_dir) + h = hashlib.sha256() + visited: Set[Path] = set() + + def walk(directory: Path) -> None: + # copytree follows directory symlinks (symlinks=False default), so + # follow them too — but only once each, to terminate on cycles. + real_dir = directory.resolve() + if real_dir in visited: + return + visited.add(real_dir) + + entries = sorted(directory.iterdir(), key=lambda p: p.name) + ignored = ignore_fn(str(directory), [e.name for e in entries]) if ignore_fn else set() + for entry in entries: + if entry.name in ignored or entry.name == ".extensionignore": + continue + if entry.is_dir(): + walk(entry) + elif entry.is_file(): + if entry.name.endswith("-config.yml") or entry.name.endswith( + "-config.local.yml" + ): + continue + data = entry.read_bytes() + h.update(entry.relative_to(ext_dir).as_posix().encode("utf-8")) + h.update(b"\x00") + h.update(len(data).to_bytes(8, "big")) + h.update(data) + + walk(ext_dir) + return f"sha256:{h.hexdigest()}" + + class ExtensionRegistry: """Manages the registry of installed extensions.""" @@ -2611,19 +2660,25 @@ def _restore_stranded_config_file( elif backup_config_dir.exists(): backup_config_dir.unlink() - # Update registry - self.registry.add( - manifest.id, - { - "version": manifest.version, - "source": "local", - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - }, - ) + # Update registry. content_hash records what was shipped at install + # time so `extension update` can detect content that changed without + # a version bump (#4345); a hash failure must not fail the install. + try: + content_hash = compute_extension_content_hash(source_dir) + except OSError: + content_hash = None + registry_entry = { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + } + if content_hash is not None: + registry_entry["content_hash"] = content_hash + self.registry.add(manifest.id, registry_entry) # Post-commit cleanup: the registry now records this extension as # installed, so the rescue guard (`not self.registry.is_installed`) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7f7933e934..5b6af8ad87 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -106,6 +106,41 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str return placeholder +def _bundled_content_is_stale(ext_id, metadata, ext_info, manager) -> bool: + """Report whether a bundled extension's installed content is stale. + + A bundled extension whose catalog version equals the installed version + can still be out of date: its content may have changed upstream without + a version bump, and the semver comparison alone then reports "Up to + date" forever (#4345). Compare the content hash recorded at install + time (falling back to hashing the installed directory for registry + entries that predate content_hash) against the copy bundled with the + running spec-kit version. Only meaningful for bundled extensions with + no download URL — anything downloadable is served by the normal + version flow. + """ + from . import compute_extension_content_hash + + if not ext_info.get("bundled") or ext_info.get("download_url"): + return False + bundled_path = _locate_bundled_extension(ext_id) + if bundled_path is None: + return False + try: + bundled_hash = compute_extension_content_hash(bundled_path) + installed_hash = metadata.get("content_hash") + if not isinstance(installed_hash, str) or not installed_hash: + installed_dir = manager.extensions_dir / ext_id + if not installed_dir.is_dir(): + return False + installed_hash = compute_extension_content_hash(installed_dir) + except OSError: + # Unreadable content must not break the update check; the version + # comparison already ran, so fall back to its verdict. + return False + return installed_hash != bundled_hash + + def _refresh_events_and_warn(project_root: Path) -> None: """Refresh native event config and surface failures (R3). @@ -1622,6 +1657,7 @@ def extension_update( console.print("🔄 Checking for updates...\n") updates_available = [] + stale_content = [] for ext_id in extensions_to_update: safe_ext_id = _escape_markup(str(ext_id)) @@ -1667,11 +1703,29 @@ def extension_update( "download_url": ext_info.get("download_url"), } ) + elif _bundled_content_is_stale(ext_id, metadata, ext_info, manager): + # Bundled content changed without a version bump (#4345): + # the semver comparison alone would report "Up to date" + # while the installed copy keeps missing shipped fixes. + stale_content.append(ext_id) + console.print( + f"⚠ {safe_ext_id}: v{installed_version} matches the catalog, but the " + f"installed files differ from the copy bundled with this spec-kit version" + ) + console.print( + f" Refresh with: specify extension add {_command_safe_id(ext_id)} --force" + ) else: console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") + if stale_content: + console.print( + "\n[yellow]No version updates available, but the extension(s) " + "flagged above have stale content.[/yellow]" + ) + else: + console.print("\n[green]All extensions are up to date![/green]") raise typer.Exit(0) # Show available updates diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py new file mode 100644 index 0000000000..fccc42dfe6 --- /dev/null +++ b/tests/test_extension_content_staleness.py @@ -0,0 +1,240 @@ +"""Tests for bundled-extension content staleness detection (#4345). + +The registry's manifest_hash only covers extension.yml, so bundled +extension content that changed upstream without a version bump used to be +undetectable: `specify extension update` compared semver only and reported +"Up to date" forever. These tests cover the content hash that closes that +gap and the update command's stale-content reporting. +""" + +import yaml +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionCatalog, + ExtensionManager, + compute_extension_content_hash, +) + + +def _create_extension_source(base_dir: Path, name: str = "test-ext") -> Path: + """Create a minimal installable extension source directory.""" + ext_dir = base_dir / name + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test-ext.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + scripts_dir = ext_dir / "scripts" + scripts_dir.mkdir(exist_ok=True) + (scripts_dir / "run.sh").write_text("#!/bin/sh\necho hello\n") + (ext_dir / "test-ext-config.yml").write_text("setting: default\n") + return ext_dir + + +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 + + +BUNDLED_CATALOG_INFO = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, +} + + +class TestComputeExtensionContentHash: + def test_deterministic(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + assert compute_extension_content_hash(ext_dir) == compute_extension_content_hash( + ext_dir + ) + + def test_identical_copies_hash_equal(self, tmp_path): + a = _create_extension_source(tmp_path / "a") + b = _create_extension_source(tmp_path / "b") + assert compute_extension_content_hash(a) == compute_extension_content_hash(b) + + def test_content_change_changes_hash(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + assert compute_extension_content_hash(ext_dir) != before + + def test_new_file_changes_hash(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "scripts" / "extra.sh").write_text("#!/bin/sh\n") + assert compute_extension_content_hash(ext_dir) != before + + def test_user_config_files_excluded(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "test-ext-config.yml").write_text("setting: user-edited\n") + (ext_dir / "test-ext-config.local.yml").write_text("local: override\n") + assert compute_extension_content_hash(ext_dir) == before + + def test_extensionignore_and_ignored_files_excluded(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / ".extensionignore").write_text("*.log\n") + (ext_dir / "debug.log").write_text("noise\n") + assert compute_extension_content_hash(ext_dir) == before + + def test_matches_between_source_and_installation(self, tmp_path): + """An install made from a source dir hashes identically to it.""" + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path) + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + + installed_dir = project_dir / ".specify" / "extensions" / "test-ext" + # User edits to the preserved config file must not affect parity. + (installed_dir / "test-ext-config.yml").write_text("setting: user-edited\n") + assert compute_extension_content_hash( + installed_dir + ) == compute_extension_content_hash(source) + + +class TestInstallStoresContentHash: + def test_registry_entry_records_source_content_hash(self, tmp_path): + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path) + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + + entry = manager.registry.get("test-ext") + assert entry["content_hash"] == compute_extension_content_hash(source) + + +class TestUpdateStaleContentDetection: + def _install(self, tmp_path): + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path / "bundled") + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + return project_dir, source + + @staticmethod + def _flat(result) -> str: + """Console output with Rich's line wrapping collapsed.""" + return " ".join(result.output.split()) + + def _run_update(self, project_dir, bundled_path): + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, + "get_extension_info", + return_value=dict(BUNDLED_CATALOG_INFO), + ), \ + patch( + "specify_cli._locate_bundled_extension", + return_value=bundled_path, + ): + return runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + def test_reports_stale_content_when_bundled_copy_changed(self, tmp_path): + project_dir, source = self._install(tmp_path) + # Upstream ships a fix without bumping the version. + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "differ from the copy bundled" in self._flat(result) + assert "extension add test-ext --force" in self._flat(result) + assert "All extensions are up to date!" not in self._flat(result) + + def test_up_to_date_when_bundled_copy_matches(self, tmp_path): + project_dir, source = self._install(tmp_path) + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "Up to date (v1.0.0)" in self._flat(result) + assert "All extensions are up to date!" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + + def test_stale_check_covers_registry_entries_without_content_hash(self, tmp_path): + """Installs that predate content_hash fall back to hashing the installed dir.""" + project_dir, source = self._install(tmp_path) + manager = ExtensionManager(project_dir) + entry = manager.registry.get("test-ext") + del entry["content_hash"] + manager.registry.data["extensions"]["test-ext"] = entry + manager.registry._save() + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "differ from the copy bundled" in self._flat(result) + + def test_user_config_edits_are_not_reported_as_stale(self, tmp_path): + project_dir, source = self._install(tmp_path) + installed_config = ( + project_dir / ".specify" / "extensions" / "test-ext" / "test-ext-config.yml" + ) + installed_config.write_text("setting: user-edited\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "All extensions are up to date!" in self._flat(result) + + def test_non_bundled_extensions_skip_the_content_check(self, tmp_path): + project_dir, source = self._install(tmp_path) + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + catalog_info = dict(BUNDLED_CATALOG_INFO) + catalog_info["bundled"] = False + catalog_info["download_url"] = "https://example.com/test-ext-1.0.0.zip" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, "get_extension_info", return_value=catalog_info + ), \ + patch( + "specify_cli._locate_bundled_extension", + return_value=source, + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + assert "All extensions are up to date!" in self._flat(result) From 720484801a8c8117ec9ee378c70c38382c659dc5 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:46:24 +0200 Subject: [PATCH 04/10] fix(extensions): install bundled extension updates from the local package With the version bumps earlier in this series, `specify extension update` now offers agent-context/git/assess updates for the first time - and then failed to deliver them: the update pipeline is download-based, and ExtensionCatalog.download_extension refuses bundled extensions that have no download URL. The user confirmed an offered update only to get "Failed: ... Try reinstalling: uv tool install specify-cli ..." - a hint that upgrades the CLI but never refreshes the project-installed extension. This path was previously unreachable precisely because the bundled versions never moved (#4345). Route bundled updates through the local package instead: - At offer time, a bundled catalog entry (no download URL) resolves the copy shipped with the running spec-kit release via _locate_bundled_extension. When that local copy is newer than the installed version, it becomes the offered update; when it lags the catalog (older CLI) or is absent, the update is reported as requiring a spec-kit upgrade, with the exact next step, instead of being offered and then failing at the download step. - At install time, the resolved bundled directory is packaged as a ZIP and fed through the unchanged update pipeline, so bundled updates get the identical bounded extraction, manifest preflight, ID/version checks, and backup/rollback as downloaded ones - no second install code path. - When every checked extension is blocked on a newer spec-kit release, the summary says so instead of "All extensions are up to date!". Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- src/specify_cli/extensions/_commands.py | 93 +++++++++++++++++- tests/test_extensions.py | 122 ++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 5b6af8ad87..a099179571 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -141,6 +141,52 @@ def _bundled_content_is_stale(ext_id, metadata, ext_info, manager) -> bool: return installed_hash != bundled_hash +def _bundled_update_source(ext_id): + """Locate the local bundled copy of *ext_id* and its parsed version. + + Bundled extensions have no download URL, so an update can only come + from the copy shipped with the running spec-kit release — which may + lag the version the catalog on main advertises. Returns + ``(path, Version)`` when a valid local copy exists, ``(None, None)`` + otherwise. + """ + from . import ExtensionManifest, ValidationError + from packaging import version as pkg_version + + bundled_dir = _locate_bundled_extension(ext_id) + if bundled_dir is None: + return None, None + try: + manifest = ExtensionManifest(bundled_dir / "extension.yml") + return bundled_dir, pkg_version.Version(manifest.version) + except (ValidationError, pkg_version.InvalidVersion, OSError): + return None, None + + +def _archive_extension_directory(source_dir: Path) -> Path: + """Package an extension directory as a ZIP archive for the update flow. + + The update pipeline validates and installs archives (bounded + extraction, manifest preflight, ID/version checks, backup/rollback), + so a locally bundled extension is fed through that identical hardened + path rather than growing a second install code path. The caller + deletes the archive after the update, the same as a downloaded one. + """ + import zipfile + + fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip") + try: + with os.fdopen(fd, "wb") as archive_file: + with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(source_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(source_dir).as_posix()) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return Path(tmp_name) + + def _refresh_events_and_warn(project_root: Path) -> None: """Refresh native event config and surface failures (R3). @@ -1658,6 +1704,7 @@ def extension_update( updates_available = [] stale_content = [] + blocked_updates = [] for ext_id in extensions_to_update: safe_ext_id = _escape_markup(str(ext_id)) @@ -1694,13 +1741,36 @@ def extension_update( continue if catalog_version > installed_version: + download_url = ext_info.get("download_url") + bundled_dir = None + available_version = catalog_version + if ext_info.get("bundled") and not download_url: + # Bundled extensions cannot be downloaded; the update has + # to come from the copy shipped with the running spec-kit + # release, which may lag the catalog on main (#4345). + bundled_dir, bundled_version = _bundled_update_source(ext_id) + if bundled_dir is None or bundled_version <= installed_version: + local_desc = ( + f"only ships v{bundled_version}" + if bundled_dir is not None + else "does not ship a local copy" + ) + console.print( + f"⚠ {safe_ext_id}: v{catalog_version} is available, but this " + f"spec-kit release {local_desc} — upgrade spec-kit, then rerun " + f"'specify extension update'" + ) + blocked_updates.append(ext_id) + continue + available_version = bundled_version updates_available.append( { "id": ext_id, "name": ext_info.get("name", ext_id), # Display name for status messages "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), + "available": str(available_version), + "download_url": download_url, + "bundled_dir": bundled_dir, } ) elif _bundled_content_is_stale(ext_id, metadata, ext_info, manager): @@ -1719,7 +1789,13 @@ def extension_update( console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") if not updates_available: - if stale_content: + if blocked_updates: + console.print( + "\n[yellow]Update(s) exist but require a newer spec-kit " + "release — upgrade spec-kit, then rerun " + "'specify extension update'.[/yellow]" + ) + elif stale_content: console.print( "\n[yellow]No version updates available, but the extension(s) " "flagged above have stale content.[/yellow]" @@ -2022,8 +2098,15 @@ def backup_extension_skills(skill_names, *, skills_dir=None): if ext_hooks: backup_hooks[hook_name] = ext_hooks - # 5. Download new version - archive_path = catalog.download_extension(extension_id) + # 5. Acquire the new version. Bundled extensions install from + # the copy shipped with the running spec-kit release (they + # have no download URL); everything else downloads. Both are + # packaged as archives so the identical validation, + # backup/rollback, and install pipeline below applies. + if update.get("bundled_dir") is not None: + archive_path = _archive_extension_directory(update["bundled_dir"]) + else: + archive_path = catalog.download_extension(extension_id) try: # 6. Validate the archive and extension ID before modifying # the existing installation. The shared extractor applies diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..843314631c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9190,6 +9190,128 @@ def fake_install_from_zip(self_obj, _zip_path, speckit_version): ).read_text() assert restored_config_content == original_config_content + def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): + """A bundled extension (no download URL) updates from the copy shipped + with the running spec-kit release instead of failing at download (#4345).""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v2.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0" + + def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): + """When the catalog advertises a newer version than the running release + bundles, the update is reported as requiring a spec-kit upgrade instead + of being offered and then failing.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v1_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v1.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert "All extensions are up to date!" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): + """A bundled catalog entry with no locally shipped copy points at a + spec-kit upgrade instead of failing the update at download time.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=None + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "does not ship a local copy" in flat + assert "upgrade spec-kit" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): """Failed update should restore original registry, hooks, and command files.""" from typer.testing import CliRunner From cbe4b319d00129e14fd273c10de37619c5ad2360 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:55:21 +0200 Subject: [PATCH 05/10] refactor(extensions): align #4345 series with constitution typing and symlink rules Compliance pass of this branch against .specify/memory/constitution.md (v1.0.0) surfaced three code-level deviations in the new #4345 code: - Principle IV ("never follow symlinks out of the project root"): compute_extension_content_hash() followed directory symlinks (with a cycle guard) and file symlinks, so a symlink inside an installed extension directory could pull bytes from outside the project into the hash. Skip symlinks entirely instead - installs dereference file symlinks during copytree and bundled extensions ship none, so both sides of a comparison stay symmetric. The cycle-guard visited set is no longer needed. Covered by a new test that skips (not fails) where symlink creation needs privileges, per Principle II's guarding rule. - Principle I ("legacy Dict/List/Optional forms are rejected"): the visited set used typing.Set; gone with the visited set. The new _commands.py helpers (_bundled_content_is_stale, _bundled_update_source) were missing parameter/return annotations - added with modern syntax via TYPE_CHECKING-only imports. - Principle I ("every new module begins with from __future__ import annotations"): added to tests/test_extension_content_staleness.py. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- src/specify_cli/extensions/__init__.py | 14 ++++++-------- src/specify_cli/extensions/_commands.py | 16 +++++++++++++--- tests/test_extension_content_staleness.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 30369d16e6..ed94ca840f 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -747,25 +747,23 @@ def compute_extension_content_hash(ext_dir: Path) -> str: therefore yields comparable hashes for a bundled source directory and an installation made from it. + Symlinks are never followed: an entry linking outside the extension + directory must not pull external bytes into the hash. Bundled + extensions ship none, so both sides of a comparison stay symmetric. + Raises OSError when the directory cannot be read. """ ignore_fn = ExtensionManager._load_extensionignore(ext_dir) h = hashlib.sha256() - visited: Set[Path] = set() def walk(directory: Path) -> None: - # copytree follows directory symlinks (symlinks=False default), so - # follow them too — but only once each, to terminate on cycles. - real_dir = directory.resolve() - if real_dir in visited: - return - visited.add(real_dir) - entries = sorted(directory.iterdir(), key=lambda p: p.name) ignored = ignore_fn(str(directory), [e.name for e in entries]) if ignore_fn else set() for entry in entries: if entry.name in ignored or entry.name == ".extensionignore": continue + if entry.is_symlink(): + continue if entry.is_dir(): walk(entry) elif entry.is_file(): diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index a099179571..bc493a266b 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -15,9 +15,14 @@ import stat import tempfile from pathlib import Path -from typing import Optional +from typing import Any, Optional, TYPE_CHECKING from uuid import uuid4 +if TYPE_CHECKING: + from packaging.version import Version + + from . import ExtensionManager + import typer import yaml from rich.markup import escape as _escape_markup @@ -106,7 +111,12 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str return placeholder -def _bundled_content_is_stale(ext_id, metadata, ext_info, manager) -> bool: +def _bundled_content_is_stale( + ext_id: str, + metadata: dict[str, Any], + ext_info: dict[str, Any], + manager: ExtensionManager, +) -> bool: """Report whether a bundled extension's installed content is stale. A bundled extension whose catalog version equals the installed version @@ -141,7 +151,7 @@ def _bundled_content_is_stale(ext_id, metadata, ext_info, manager) -> bool: return installed_hash != bundled_hash -def _bundled_update_source(ext_id): +def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]: """Locate the local bundled copy of *ext_id* and its parsed version. Bundled extensions have no download URL, so an update can only come diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py index fccc42dfe6..fdd9a5bfac 100644 --- a/tests/test_extension_content_staleness.py +++ b/tests/test_extension_content_staleness.py @@ -7,6 +7,9 @@ gap and the update command's stale-content reporting. """ +from __future__ import annotations + +import pytest import yaml from pathlib import Path from unittest.mock import patch @@ -112,6 +115,19 @@ def test_extensionignore_and_ignored_files_excluded(self, tmp_path): (ext_dir / "debug.log").write_text("noise\n") assert compute_extension_content_hash(ext_dir) == before + def test_symlinks_are_never_followed(self, tmp_path): + """A symlink inside the extension dir must not pull external bytes + into the hash (never follow symlinks out of the project root).""" + ext_dir = _create_extension_source(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("external bytes\n") + before = compute_extension_content_hash(ext_dir) + try: + (ext_dir / "scripts" / "link.txt").symlink_to(outside) + except OSError: + pytest.skip("symlink creation requires privileges on this platform") + assert compute_extension_content_hash(ext_dir) == before + def test_matches_between_source_and_installation(self, tmp_path): """An install made from a source dir hashes identically to it.""" project_dir = _make_project(tmp_path) From 08246e5b05be2bc6678a6ffc344154279698dcd3 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 08:55:38 +0200 Subject: [PATCH 06/10] docs(extensions): document bundled update behavior and the version-bump rule The constitution's Development Workflow gate requires user-facing docs to move with behavior changes, and Principle III requires command groups to stay documented under docs/reference/. The #4345 series changed `specify extension update` behavior without touching either: - docs/reference/extensions.md (Update Extensions): bundled extensions now update from the copy shipped with the running spec-kit release; a catalog version newer than the release ships is reported as requiring a spec-kit upgrade; matching-version installs whose files differ from the shipped copy are flagged as stale content with the `specify extension add --force` refresh command; config files are preserved and never counted as stale. - extensions/EXTENSION-DEVELOPMENT-GUIDE.md (Versioning): record that a content change without a version bump never reaches installed copies, and that CI (extension-version-guard.yml) enforces the bump plus catalog.json sync for the bundled extensions in this repository. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- docs/reference/extensions.md | 10 ++++++++++ extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..f5bbacf438 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -75,6 +75,16 @@ specify extension update [] Updates a specific extension, or all installed extensions if no name is given. +Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first. + +When an installed bundled extension's files differ from the copy shipped with your spec-kit release even though the versions match (content that shipped without a version bump), the check flags it as stale content and points to the refresh command: + +```bash +specify extension add --force +``` + +Extension config files (`*-config.yml`, `*-config.local.yml`) are preserved across updates and forced reinstalls, and user edits to them are never counted as stale content. + ## Enable / Disable an Extension ```bash diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..a73a796a19 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -620,6 +620,13 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed - **MAJOR**: Breaking changes - **MINOR**: New features - **PATCH**: Bug fixes +- **Bump on every content change**: `specify extension update` compares + versions only, so a content change shipped without a version bump never + reaches already-installed copies. For the bundled extensions in this + repository this is enforced by CI (`extension-version-guard.yml`): a PR + that changes files under `extensions//` must also bump that + extension's `extension.yml` version and keep `extensions/catalog.json` + in sync. ### Security From 18a12b91aa7c11a07f68509d2142ab4925ea0f28 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 10:02:47 +0200 Subject: [PATCH 07/10] fix(extensions): address Copilot review round 1 on #4351 All four review findings were valid; each is applied with regression tests: 1. compute_extension_content_hash() excluded *-config.yml / *-config.local.yml at every depth, but the remove/backup/restore machinery only preserves top-level config files (_target_follows_preserved_convention), so a changed nested shipped file like templates/foo-config.yml was overwritten by installation yet invisible to staleness detection. The exclusion now applies only to direct children of the extension directory. 2. The stale-content check ran on every catalog_version <= installed_version outcome. When the installed copy is newer than the catalog or the running release's bundled copy (e.g. written by a newer CLI), a hash difference is version skew, not unbumped drift - and the suggested `extension add --force` would downgrade the installation. The check is now gated on catalog_version == installed_version at the call site AND on the bundled copy declaring the same version as the installed one inside the helper (the second guard also covers an older CLI run against an up-to-date project, which the call-site gate alone would miss). 3. _archive_extension_directory() followed file symlinks (is_file() + ZipFile.write() dereference), so a symlink in a source directory could turn out-of-tree bytes into a regular archive member before the hardened extractor sees it. Symlinks are now skipped, matching the rule in compute_extension_content_hash(). 4. The CI guard's fallback comparison only required inequality for non-dotted-numeric versions, so a PEP 440 prerelease downgrade like 2.0.0 -> 1.0.0rc1 passed. The script now compares with packaging.version.Version - the same semantics extension update/install use - and fails closed on unparseable versions; the workflow installs packaging alongside pyyaml. Verified locally that 1.1.0 -> 1.0.0rc1 with content changes is now rejected. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- .../scripts/check_extension_version_bump.py | 40 ++++---- .github/workflows/extension-version-guard.yml | 4 +- src/specify_cli/extensions/__init__.py | 16 ++-- src/specify_cli/extensions/_commands.py | 25 ++++- tests/test_extension_content_staleness.py | 95 ++++++++++++++++++- 5 files changed, 144 insertions(+), 36 deletions(-) diff --git a/.github/scripts/check_extension_version_bump.py b/.github/scripts/check_extension_version_bump.py index 8a2cd980dc..bfcc768a87 100644 --- a/.github/scripts/check_extension_version_bump.py +++ b/.github/scripts/check_extension_version_bump.py @@ -10,8 +10,9 @@ This check enforces two invariants on the extensions listed in `extensions/catalog.json`: -1. Any change to a file under `extensions//` must be accompanied by - a `version:` change in that extension's `extension.yml`. +1. Any change to a file under `extensions//` must increase the + `version:` in that extension's `extension.yml` (PEP 440 comparison, + the same semantics `extension update` uses). 2. The `version` in `extensions/catalog.json` must equal the manifest's `extension.version` (the catalog is what update checks compare against, and the update preflight rejects a manifest whose version @@ -37,6 +38,7 @@ from pathlib import Path import yaml +from packaging.version import InvalidVersion, Version EXTENSIONS_ROOT = "extensions" CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json" @@ -66,14 +68,6 @@ def _manifest_version(manifest_text: str, origin: str) -> str: return version.strip() -def _version_tuple(version: str) -> tuple[int, ...] | None: - """Parse a dotted-numeric version, or None when any part is non-numeric.""" - try: - return tuple(int(part) for part in version.split(".")) - except ValueError: - return None - - def main(argv: list[str]) -> int: if len(argv) < 2 or len(argv) > 3: print(__doc__, file=sys.stderr) @@ -117,19 +111,23 @@ def main(argv: list[str]) -> int: errors.append(str(exc)) continue - base_parsed = _version_tuple(base_version) - head_parsed = _version_tuple(head_version) - if base_parsed is not None and head_parsed is not None: - if head_parsed <= base_parsed: - errors.append( - f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " - f"extension.version did not increase ({base_version} -> {head_version}). " - f"Installed copies only receive changes when the version is bumped." - ) - elif head_version == base_version: + # Compare with the same PEP 440 semantics the extension update and + # install code use (packaging.version), so prereleases and other + # accepted forms cannot bypass the guard (e.g. 2.0.0 -> 1.0.0rc1 is + # a downgrade). Unparseable versions fail closed. + try: + base_parsed = Version(base_version) + head_parsed = Version(head_version) + except InvalidVersion as exc: + errors.append( + f"{manifest_path}: could not compare versions " + f"{base_version!r} -> {head_version!r}: {exc}" + ) + continue + if head_parsed <= base_parsed: errors.append( f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " - f"extension.version is still {base_version}. " + f"extension.version did not increase ({base_version} -> {head_version}). " f"Installed copies only receive changes when the version is bumped." ) diff --git a/.github/workflows/extension-version-guard.yml b/.github/workflows/extension-version-guard.yml index 2c25ffa420..ec9426304e 100644 --- a/.github/workflows/extension-version-guard.yml +++ b/.github/workflows/extension-version-guard.yml @@ -28,8 +28,8 @@ jobs: with: python-version: "3.14" - - name: Install PyYAML - run: python -m pip install --quiet pyyaml + - name: Install check dependencies + run: python -m pip install --quiet pyyaml packaging # For pull_request events the checkout is the merge of the PR head # into the base tip, so diffing base.sha against HEAD yields exactly diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index ed94ca840f..4b5bfd91f1 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -742,10 +742,13 @@ def compute_extension_content_hash(ext_dir: Path) -> str: (#4345). This hash covers every regular file an install would copy: it folds in the sorted POSIX-style relative path and raw bytes of each file, excluding exactly what installs treat as user-owned or skip — - ``*-config.yml`` / ``*-config.local.yml`` (preserved across installs) - and ``.extensionignore`` plus whatever it ignores. The same function - therefore yields comparable hashes for a bundled source directory and - an installation made from it. + top-level ``*-config.yml`` / ``*-config.local.yml`` (the only config + paths the remove/backup/restore machinery preserves, see + ``_target_follows_preserved_convention``) and ``.extensionignore`` + plus whatever it ignores. Nested config-suffixed files are hashed: + installs overwrite them, so their changes are real staleness. The + same function therefore yields comparable hashes for a bundled source + directory and an installation made from it. Symlinks are never followed: an entry linking outside the extension directory must not pull external bytes into the hash. Bundled @@ -767,8 +770,9 @@ def walk(directory: Path) -> None: if entry.is_dir(): walk(entry) elif entry.is_file(): - if entry.name.endswith("-config.yml") or entry.name.endswith( - "-config.local.yml" + if entry.parent == ext_dir and ( + entry.name.endswith("-config.yml") + or entry.name.endswith("-config.local.yml") ): continue data = entry.read_bytes() diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index bc493a266b..c28cb6e44c 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -116,6 +116,7 @@ def _bundled_content_is_stale( metadata: dict[str, Any], ext_info: dict[str, Any], manager: ExtensionManager, + installed_version: Version, ) -> bool: """Report whether a bundled extension's installed content is stale. @@ -128,13 +129,19 @@ def _bundled_content_is_stale( running spec-kit version. Only meaningful for bundled extensions with no download URL — anything downloadable is served by the normal version flow. + + The comparison requires the local bundled copy to declare the same + version as the installed one: a hash difference against an older or + newer local copy is version skew, not unbumped content drift, and a + `--force` refresh recommendation against an older copy would downgrade + the installation. """ from . import compute_extension_content_hash if not ext_info.get("bundled") or ext_info.get("download_url"): return False - bundled_path = _locate_bundled_extension(ext_id) - if bundled_path is None: + bundled_path, bundled_version = _bundled_update_source(ext_id) + if bundled_path is None or bundled_version != installed_version: return False try: bundled_hash = compute_extension_content_hash(bundled_path) @@ -189,6 +196,13 @@ def _archive_extension_directory(source_dir: Path) -> Path: with os.fdopen(fd, "wb") as archive_file: with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf: for path in sorted(source_dir.rglob("*")): + # Never follow symlinks: is_file() follows the target + # and ZipFile.write() reads its bytes, which would turn + # an out-of-tree target into a regular archive member + # before the hardened extractor ever sees it. Matches + # the symlink rule in compute_extension_content_hash. + if path.is_symlink(): + continue if path.is_file(): zf.write(path, path.relative_to(source_dir).as_posix()) except BaseException: @@ -1783,10 +1797,15 @@ def extension_update( "bundled_dir": bundled_dir, } ) - elif _bundled_content_is_stale(ext_id, metadata, ext_info, manager): + elif catalog_version == installed_version and _bundled_content_is_stale( + ext_id, metadata, ext_info, manager, installed_version + ): # Bundled content changed without a version bump (#4345): # the semver comparison alone would report "Up to date" # while the installed copy keeps missing shipped fixes. + # Guarded to equal versions: an installed copy newer than + # the catalog (e.g. written by a newer CLI) must not be + # steered into a downgrading --force refresh. stale_content.append(ext_id) console.print( f"⚠ {safe_ext_id}: v{installed_version} matches the catalog, but the " diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py index fdd9a5bfac..7d649b0bd3 100644 --- a/tests/test_extension_content_staleness.py +++ b/tests/test_extension_content_staleness.py @@ -24,7 +24,9 @@ ) -def _create_extension_source(base_dir: Path, name: str = "test-ext") -> Path: +def _create_extension_source( + base_dir: Path, name: str = "test-ext", version: str = "1.0.0" +) -> Path: """Create a minimal installable extension source directory.""" ext_dir = base_dir / name ext_dir.mkdir(parents=True, exist_ok=True) @@ -34,7 +36,7 @@ def _create_extension_source(base_dir: Path, name: str = "test-ext") -> Path: "extension": { "id": "test-ext", "name": "Test Extension", - "version": "1.0.0", + "version": version, "description": "A test extension", }, "requires": {"speckit_version": ">=0.1.0"}, @@ -108,6 +110,18 @@ def test_user_config_files_excluded(self, tmp_path): (ext_dir / "test-ext-config.local.yml").write_text("local: override\n") assert compute_extension_content_hash(ext_dir) == before + def test_nested_config_suffixed_files_are_hashed(self, tmp_path): + """Only top-level config files are preserved across installs + (_target_follows_preserved_convention); a nested *-config.yml is + overwritten by installation, so its changes are real staleness.""" + ext_dir = _create_extension_source(tmp_path) + templates = ext_dir / "templates" + templates.mkdir() + (templates / "scaffold-config.yml").write_text("shipped: v1\n") + before = compute_extension_content_hash(ext_dir) + (templates / "scaffold-config.yml").write_text("shipped: v2\n") + assert compute_extension_content_hash(ext_dir) != before + def test_extensionignore_and_ignored_files_excluded(self, tmp_path): ext_dir = _create_extension_source(tmp_path) before = compute_extension_content_hash(ext_dir) @@ -143,6 +157,46 @@ def test_matches_between_source_and_installation(self, tmp_path): ) == compute_extension_content_hash(source) +class TestArchiveExtensionDirectory: + def test_archive_contains_regular_files_only(self, tmp_path): + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "extension.yml" in names + assert "commands/hello.md" in names + finally: + archive_path.unlink() + + def test_archive_never_follows_symlinks(self, tmp_path): + """A symlink in the source must not pull out-of-tree bytes into the + archive before the hardened extractor sees it.""" + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("external bytes\n") + try: + (ext_dir / "scripts" / "link.txt").symlink_to(outside) + except OSError: + pytest.skip("symlink creation requires privileges on this platform") + + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "scripts/link.txt" not in names + finally: + archive_path.unlink() + + class TestInstallStoresContentHash: def test_registry_entry_records_source_content_hash(self, tmp_path): project_dir = _make_project(tmp_path) @@ -167,13 +221,13 @@ def _flat(result) -> str: """Console output with Rich's line wrapping collapsed.""" return " ".join(result.output.split()) - def _run_update(self, project_dir, bundled_path): + def _run_update(self, project_dir, bundled_path, catalog_info=None): runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch.object( ExtensionCatalog, "get_extension_info", - return_value=dict(BUNDLED_CATALOG_INFO), + return_value=dict(catalog_info or BUNDLED_CATALOG_INFO), ), \ patch( "specify_cli._locate_bundled_extension", @@ -220,6 +274,39 @@ def test_stale_check_covers_registry_entries_without_content_hash(self, tmp_path assert result.exit_code == 0, result.output assert "differ from the copy bundled" in self._flat(result) + def test_no_stale_flag_when_bundled_copy_is_older_version(self, tmp_path): + """An installed copy newer than the running release's bundled copy is + version skew, not content drift — flagging it would steer the user + into a downgrading --force refresh.""" + project_dir = _make_project(tmp_path) + v2_source = _create_extension_source(tmp_path / "installed-src", version="2.0.0") + ExtensionManager(project_dir).install_from_directory(v2_source, "0.1.0") + old_bundled = _create_extension_source(tmp_path / "bundled", version="1.0.0") + (old_bundled / "scripts" / "run.sh").write_text("#!/bin/sh\necho old\n") + catalog_info = dict(BUNDLED_CATALOG_INFO) + catalog_info["version"] = "2.0.0" + + result = self._run_update(project_dir, old_bundled, catalog_info) + + assert result.exit_code == 0, result.output + assert "Up to date (v2.0.0)" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + + def test_no_stale_flag_when_catalog_lags_installed_version(self, tmp_path): + """The stale check only runs when catalog and installed versions are + equal; a catalog behind the installed version must not trigger it.""" + project_dir = _make_project(tmp_path) + v2_source = _create_extension_source(tmp_path / "installed-src", version="2.0.0") + ExtensionManager(project_dir).install_from_directory(v2_source, "0.1.0") + old_bundled = _create_extension_source(tmp_path / "bundled", version="1.0.0") + (old_bundled / "scripts" / "run.sh").write_text("#!/bin/sh\necho old\n") + + result = self._run_update(project_dir, old_bundled) + + assert result.exit_code == 0, result.output + assert "Up to date (v2.0.0)" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + def test_user_config_edits_are_not_reported_as_stale(self, tmp_path): project_dir, source = self._install(tmp_path) installed_config = ( From 002443a5565dff4499653486c856660a95c28a22 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 10:12:30 +0200 Subject: [PATCH 08/10] fix(extensions): block bundled updates whenever the local copy lags the catalog Copilot review round 2 on #4351: the offer gate only blocked a bundled copy that was no newer than the installation. With installed v1, locally bundled v2, and catalog v3, the command offered and installed v2 and reported success - leaving the project lagging the catalog with no mention of it, contrary to the documented "the update is reported as requiring a spec-kit upgrade first" behavior (and re-nagging about the upgrade on every subsequent run). Compare the bundled version against the catalog version instead: any older local copy is blocked with the upgrade-spec-kit guidance. This subsumes the previous gate (inside the catalog > installed branch, bundled <= installed implies bundled < catalog). A local copy at or above the catalog version (dev/source checkouts) is still offered and installed. Tests added for the intermediate-version block and the newer-than-catalog install. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- src/specify_cli/extensions/_commands.py | 7 ++- tests/test_extensions.py | 84 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index c28cb6e44c..6f1b593ac4 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -1773,7 +1773,12 @@ def extension_update( # to come from the copy shipped with the running spec-kit # release, which may lag the catalog on main (#4345). bundled_dir, bundled_version = _bundled_update_source(ext_id) - if bundled_dir is None or bundled_version <= installed_version: + # Block whenever the local copy lags the catalog, not + # just when it lags the installation: installing an + # intermediate version would leave the project behind + # the catalog while reporting success, contrary to the + # documented "upgrade spec-kit first" behavior. + if bundled_dir is None or bundled_version < catalog_version: local_desc = ( f"only ships v{bundled_version}" if bundled_dir is not None diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 843314631c..aec32dc4ba 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9274,6 +9274,90 @@ def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): assert "All extensions are up to date!" not in flat assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path): + """A bundled copy newer than the installation but older than the + catalog must be blocked, not installed: an intermediate version would + leave the project lagging the catalog while reporting success.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "3.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("blocked bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v2.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path): + """A dev/source checkout can ship a copy newer than the fetched + catalog advertises; the local copy is offered and installed.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v3_dir = self._create_extension_source(tmp_path, "3.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v3_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v3.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0" + def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): """A bundled catalog entry with no locally shipped copy points at a spec-kit upgrade instead of failing the update at download time.""" From edb30a414e60f3f76a86086f5de4cd851f62258b Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 10:42:27 +0200 Subject: [PATCH 09/10] fix(bundles): move agent-context pins to 1.1.0 and stop hardcoding them in fixtures Copilot review round 3 on #4351: the agent-context bump left every checked-in bundle pinned to 1.0.0. BundleExtensionPrimitive enforces exact pins against the bundled manifest, so the offline installs in tests/integration/test_bundler_local_install.py and test_bundler_init_install.py failed, and all four examples/bundles/*/bundle.yml examples stopped being installable. - examples/bundles/{business-analyst,developer,product-manager, security-researcher}/bundle.yml: agent-context pin 1.0.0 -> 1.1.0 (exact pins are the point of the example format, so they stay literal). - The two integration-test fixtures now resolve the pin through a new tests/bundler_helpers.bundled_extension_version() helper, which reads the version via the same _locate_bundled_extension lookup the primitive enforces against - so the fixtures test the bundler's pin mechanics rather than a frozen version literal, and the next legitimate extension bump cannot silently break them again. The git and assess extensions are not pinned by any checked-in bundle; tests/contract/test_bundle_cli.py's 1.0.0 pin feeds `bundle validate`, which checks existence only, and keeps passing unchanged. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- examples/bundles/business-analyst/bundle.yml | 2 +- examples/bundles/developer/bundle.yml | 2 +- examples/bundles/product-manager/bundle.yml | 2 +- .../bundles/security-researcher/bundle.yml | 2 +- tests/bundler_helpers.py | 18 ++++++++++++++++++ tests/integration/test_bundler_init_install.py | 11 +++++++++-- .../integration/test_bundler_local_install.py | 14 ++++++++++++-- 7 files changed, 43 insertions(+), 8 deletions(-) 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/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/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 630c981a73..e9229e5c4d 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -18,7 +18,12 @@ 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 make_project, valid_manifest_dict, write_manifest +from tests.bundler_helpers import ( + bundled_extension_version, + make_project, + valid_manifest_dict, + write_manifest, +) def test_local_source_none_for_non_path(): @@ -116,7 +121,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"), + } + ] }, } ), From 16c56424188e76dbe894dbefe0868e979b4eecc9 Mon Sep 17 00:00:00 2001 From: Jakub Baranowski Date: Thu, 27 Aug 2026 10:54:49 +0200 Subject: [PATCH 10/10] docs(extensions): distinguish version-driven offers from advisory staleness Copilot review round 4 on #4351: two documentation spots still described `specify extension update` as comparing "versions only" / "purely" by semver, with unbumped content reporting "Up to date" forever - wording this PR itself made stale when it added the content-hash check. Clarify in extensions/EXTENSION-DEVELOPMENT-GUIDE.md (Versioning) and the .github/scripts/check_extension_version_bump.py module docstring that update offers remain version-driven - a bump is still required for automatic delivery - while the content-hash check on bundled extensions is only an advisory stale-content warning pointing at a manual --force reinstall. Wording only; no behavior change. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5, autonomous) Co-Authored-By: Claude Fable 5 --- .../scripts/check_extension_version_bump.py | 14 +++++++++----- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 18 +++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/scripts/check_extension_version_bump.py b/.github/scripts/check_extension_version_bump.py index bfcc768a87..3cb6e5371e 100644 --- a/.github/scripts/check_extension_version_bump.py +++ b/.github/scripts/check_extension_version_bump.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """Fail a PR that changes bundled extension content without a version bump. -`specify extension update` decides whether an installed extension needs -updating purely by comparing the semver in `extensions/catalog.json` -against the installed copy's registered version. Content changes that -ship without a version bump therefore never reach existing installs: -every one of them reports "Up to date" forever (#4345). +Update offers from `specify extension update` are version-driven: an +extension is offered (and installed) only when the semver in +`extensions/catalog.json` exceeds the installed copy's registered +version. A content change shipped without a version bump is therefore +never delivered automatically (#4345). The command's content-hash check +can detect such unbumped drift on bundled extensions, but only as an +advisory stale-content warning pointing at a manual `--force` reinstall — +a bump is what makes a change actually reach existing installs, and this +guard is what makes the bump non-optional. This check enforces two invariants on the extensions listed in `extensions/catalog.json`: diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index a73a796a19..1ddf37e774 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -620,13 +620,17 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed - **MAJOR**: Breaking changes - **MINOR**: New features - **PATCH**: Bug fixes -- **Bump on every content change**: `specify extension update` compares - versions only, so a content change shipped without a version bump never - reaches already-installed copies. For the bundled extensions in this - repository this is enforced by CI (`extension-version-guard.yml`): a PR - that changes files under `extensions//` must also bump that - extension's `extension.yml` version and keep `extensions/catalog.json` - in sync. +- **Bump on every content change**: update offers from `specify extension + update` are version-driven, so a content change shipped without a + version bump is never delivered automatically to already-installed + copies. For bundled extensions the command can detect such unbumped + drift and flag it as stale content, but that is only an advisory + warning pointing at a manual `--force` reinstall — a bump is still + required for the change to be offered and installed. For the bundled + extensions in this repository the bump is enforced by CI + (`extension-version-guard.yml`): a PR that changes files under + `extensions//` must also bump that extension's `extension.yml` + version and keep `extensions/catalog.json` in sync. ### Security