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
163 changes: 163 additions & 0 deletions .github/scripts/check_extension_version_bump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/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) — 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
}
Comment on lines +90 to +97

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/**"
Comment on lines +12 to +15

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
7 changes: 7 additions & 0 deletions extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: 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 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
64 changes: 64 additions & 0 deletions tests/contract/test_bundled_extension_versions.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading