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
165 changes: 165 additions & 0 deletions .github/scripts/check_extension_version_bump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Fail a PR that changes bundled extension content without a version bump.

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`:

1. Any change to a file under `extensions/<id>/` 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
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
from packaging.version import InvalidVersion, Version

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

# 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 did not increase ({base_version} -> {head_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))
43 changes: 43 additions & 0 deletions .github/workflows/extension-version-guard.yml
Original file line number Diff line number Diff line change
@@ -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 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
# 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
10 changes: 10 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ specify extension update [<name>]

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 <name> --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
Expand Down
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
11 changes: 11 additions & 0 deletions extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +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**: 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/<id>/` must also bump that extension's `extension.yml`
version and keep `extensions/catalog.json` in sync.

### Security

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"
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 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",
"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
Loading