Skip to content

Commit b21d936

Browse files
committed
feat(extensions): select exact catalog releases
Keep current release metadata compatible with existing catalogs while allowing trusted catalogs to publish historical release URLs and digests. Add exact version selection, archive identity and discovery-policy checks, tests, and documentation. Refs #4719; follows up #4712. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) Signed-off-by: 李永祺 <doribelove@gmail.com>
1 parent 25d43a9 commit b21d936

6 files changed

Lines changed: 664 additions & 8 deletions

File tree

‎docs/reference/extensions.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,19 @@ specify extension add <name>
2626
| --------------- | -------------------------------------------------------- |
2727
| `--dev` | Install from a local directory (for development) |
2828
| `--from <url>` | Install from a custom URL instead of the catalog |
29+
| `--version <v>` | Install an exact version advertised by a catalog |
2930
| `--force` | Overwrite if the extension is already installed |
3031
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
3132

3233
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
3334

35+
An unqualified catalog install still selects the advertised current version.
36+
`--version` uses only the winning catalog source for that extension ID; it does
37+
not fall back to a lower-priority source when the requested version is absent.
38+
Discovery-only catalogs remain non-installable. `--version` cannot be combined
39+
with `--dev` or the direct-URL `--from` option. The downloaded archive's extension
40+
ID and version are checked before installation.
41+
3442
> **Note:** All extension commands require a project already initialized with `specify init`.
3543
3644
## Remove an Extension
@@ -79,9 +87,43 @@ including for help, the existing human-readable behavior is unchanged.
7987

8088
```bash
8189
specify extension info <name>
90+
specify extension info <name> --versions
8291
```
8392

8493
Shows detailed information about an installed or available extension, including its description, version, commands, and configuration.
94+
`--versions` lists the current and historical versions advertised by the
95+
winning catalog source; it labels discovery-only sources as non-installable.
96+
97+
Catalogs may keep the current release in the existing top-level fields and add
98+
historical releases in a `releases` mapping. Older single-version catalogs
99+
continue to work unchanged. Each historical release needs its own download URL
100+
and SHA-256 digest; release-specific requirements or provided capabilities must
101+
be placed in that release's record rather than inherited from the current one.
102+
103+
```json
104+
{
105+
"extensions": {
106+
"my-extension": {
107+
"name": "My Extension",
108+
"version": "0.5.1",
109+
"download_url": "https://example.com/my-extension-0.5.1.zip",
110+
"sha256": "<64-character SHA-256 for 0.5.1>",
111+
"releases": {
112+
"0.4.12": {
113+
"download_url": "https://example.com/my-extension-0.4.12.zip",
114+
"sha256": "<64-character SHA-256 for 0.4.12>"
115+
}
116+
}
117+
}
118+
}
119+
}
120+
```
121+
122+
The example omits other catalog metadata for brevity. The current version must
123+
not be repeated in `releases`; malformed or duplicate release records are
124+
rejected. Bundle pins still use the current catalog resolution path until the
125+
separate bundle work described in [#4719](https://github.com/github/spec-kit/issues/4719)
126+
adds exact-version component lookup.
85127

86128
## Update Extensions
87129

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,6 +2722,8 @@ def install_from_archive(
27222722
source_name: str | None = None,
27232723
content_type: str | None = None,
27242724
catalog_name: str | None = None,
2725+
expected_id: str | None = None,
2726+
expected_version: str | None = None,
27252727
) -> ExtensionManifest:
27262728
"""Install an extension from a supported archive.
27272729
@@ -2771,6 +2773,23 @@ def install_from_archive(
27712773
if not manifest_path.exists():
27722774
raise ValidationError("No extension.yml found in archive")
27732775

2776+
if expected_id is not None or expected_version is not None:
2777+
archive_manifest = ExtensionManifest(manifest_path)
2778+
if expected_id is not None and archive_manifest.id != expected_id:
2779+
raise ValidationError(
2780+
f"Downloaded extension declares ID '{archive_manifest.id}', "
2781+
f"expected '{expected_id}'."
2782+
)
2783+
if (
2784+
expected_version is not None
2785+
and pkg_version.Version(archive_manifest.version)
2786+
!= pkg_version.Version(expected_version)
2787+
):
2788+
raise ValidationError(
2789+
f"Downloaded extension '{archive_manifest.id}' declares version "
2790+
f"{archive_manifest.version}, expected {expected_version}."
2791+
)
2792+
27742793
# Install from extracted directory
27752794
return self.install_from_directory(
27762795
extension_dir,
@@ -2934,6 +2953,8 @@ def install_from_zip(
29342953
source_name: str | None = None,
29352954
content_type: str | None = None,
29362955
catalog_name: str | None = None,
2956+
expected_id: str | None = None,
2957+
expected_version: str | None = None,
29372958
) -> ExtensionManifest:
29382959
"""Backward-compatible wrapper for archive installation."""
29392960
return self.install_from_archive(
@@ -2945,6 +2966,8 @@ def install_from_zip(
29452966
source_name=source_name,
29462967
content_type=content_type,
29472968
catalog_name=catalog_name,
2969+
expected_id=expected_id,
2970+
expected_version=expected_version,
29482971
)
29492972

29502973
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
@@ -4336,13 +4359,16 @@ def search(
43364359

43374360
return results
43384361

4339-
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
4362+
def get_extension_info(
4363+
self, extension_id: str, version: str | None = None
4364+
) -> Optional[Dict[str, Any]]:
43404365
"""Get detailed information about a specific extension.
43414366
43424367
Searches all active catalogs in priority order.
43434368
43444369
Args:
43454370
extension_id: ID of the extension
4371+
version: Exact catalog version, or ``None`` for the advertised current release
43464372
43474373
Returns:
43484374
Extension metadata (annotated with ``_catalog_name`` and
@@ -4351,9 +4377,20 @@ def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
43514377
all_extensions = self._get_merged_extensions()
43524378
for ext_data in all_extensions:
43534379
if ext_data["id"] == extension_id:
4354-
return ext_data
4380+
from ._catalog_versions import select_release
4381+
4382+
return select_release(ext_data, version)
43554383
return None
43564384

4385+
def get_extension_versions(self, extension_id: str) -> list[str]:
4386+
"""List versions advertised by the winning catalog source."""
4387+
from ._catalog_versions import available_versions
4388+
4389+
for ext_data in self._get_merged_extensions():
4390+
if ext_data["id"] == extension_id:
4391+
return available_versions(ext_data)
4392+
return []
4393+
43574394
def download_extension(
43584395
self, extension_id: str, target_dir: Optional[Path] = None
43594396
) -> Path:
@@ -4369,13 +4406,30 @@ def download_extension(
43694406
Raises:
43704407
ExtensionError: If extension not found or download fails
43714408
"""
4372-
import urllib.error
4373-
43744409
# Get extension info from catalog
43754410
ext_info = self.get_extension_info(extension_id)
43764411
if not ext_info:
43774412
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
43784413

4414+
return self.download_extension_info(ext_info, target_dir=target_dir)
4415+
4416+
def download_extension_info(
4417+
self, ext_info: Dict[str, Any], target_dir: Optional[Path] = None
4418+
) -> Path:
4419+
"""Download a selected release without looking up its ID again.
4420+
4421+
Exact-version callers pass the already-selected record so a catalog
4422+
change between lookup and download cannot substitute the current URL.
4423+
"""
4424+
import urllib.error
4425+
4426+
extension_id = ext_info["id"]
4427+
if not ext_info.get("_install_allowed", True):
4428+
raise ExtensionError(
4429+
f"Extension '{extension_id}' is from a discovery-only catalog; "
4430+
"installation is not allowed."
4431+
)
4432+
43794433
# Bundled extensions without a download URL must be installed locally
43804434
if ext_info.get("bundled") and not ext_info.get("download_url"):
43814435
raise ExtensionError(
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Exact-version selection for extension catalog entries.
2+
3+
Legacy entries advertise one release at the top level. A versioned entry keeps
4+
that current release unchanged for older clients and adds historical releases
5+
under ``releases``. Historical records must carry their own URL and digest; a
6+
new current release must never supply either for an older version by accident.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from typing import Any
13+
14+
from packaging.version import InvalidVersion, Version
15+
16+
from . import ExtensionError
17+
18+
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
19+
_CURRENT_ONLY = frozenset(
20+
{
21+
"version",
22+
"download_url",
23+
"sha256",
24+
"requires",
25+
"provides",
26+
"bundled",
27+
"verified",
28+
"releases",
29+
}
30+
)
31+
32+
33+
def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]:
34+
"""Return a checked history, or reject an ambiguous catalog entry."""
35+
if "releases" not in entry:
36+
return {}
37+
releases = entry["releases"]
38+
extension_id = entry.get("id", "<unknown>")
39+
if not isinstance(releases, dict):
40+
raise ExtensionError(
41+
f"Extension '{extension_id}' has an invalid releases mapping."
42+
)
43+
44+
current = entry.get("version")
45+
if not isinstance(current, str) or not current.strip():
46+
raise ExtensionError(
47+
f"Extension '{extension_id}' has releases but no current version."
48+
)
49+
try:
50+
normalized_current = Version(current)
51+
except InvalidVersion:
52+
raise ExtensionError(
53+
f"Extension '{extension_id}' has an invalid current version '{current}'."
54+
) from None
55+
56+
seen = {normalized_current}
57+
for release_version, record in releases.items():
58+
if not isinstance(release_version, str) or not release_version.strip():
59+
raise ExtensionError(
60+
f"Extension '{extension_id}' has an invalid release version key."
61+
)
62+
try:
63+
normalized = Version(release_version)
64+
except InvalidVersion:
65+
raise ExtensionError(
66+
f"Extension '{extension_id}' has invalid release version '{release_version}'."
67+
) from None
68+
if normalized in seen:
69+
raise ExtensionError(
70+
f"Extension '{extension_id}' repeats release version '{release_version}'."
71+
)
72+
seen.add(normalized)
73+
if not isinstance(record, dict):
74+
raise ExtensionError(
75+
f"Extension '{extension_id}' release '{release_version}' must be an object."
76+
)
77+
if any(
78+
key in record
79+
for key in (
80+
"id",
81+
"version",
82+
"releases",
83+
"_catalog_name",
84+
"_install_allowed",
85+
)
86+
):
87+
raise ExtensionError(
88+
f"Extension '{extension_id}' release '{release_version}' contains reserved fields."
89+
)
90+
if (
91+
not isinstance(record.get("download_url"), str)
92+
or not record["download_url"].strip()
93+
):
94+
raise ExtensionError(
95+
f"Extension '{extension_id}' release '{release_version}' needs a download_url."
96+
)
97+
if not isinstance(record.get("sha256"), str) or not _SHA256.fullmatch(
98+
record["sha256"]
99+
):
100+
raise ExtensionError(
101+
f"Extension '{extension_id}' release '{release_version}' needs a SHA-256 digest."
102+
)
103+
for field in ("requires", "provides"):
104+
if field in record and not isinstance(record[field], dict):
105+
raise ExtensionError(
106+
f"Extension '{extension_id}' release '{release_version}' has invalid {field}."
107+
)
108+
109+
return releases
110+
111+
112+
def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None:
113+
"""Select from the winning catalog entry without consulting lower sources.
114+
115+
``None`` is returned when the requested version is absent. Callers can then
116+
report a missing historical release without falling through to another
117+
catalog or silently substituting the current release.
118+
"""
119+
releases = _validated_releases(entry)
120+
if version is None or version == entry.get("version"):
121+
return entry
122+
record = releases.get(version)
123+
if record is None:
124+
return None
125+
common = {key: value for key, value in entry.items() if key not in _CURRENT_ONLY}
126+
return {**common, **record, "version": version}
127+
128+
129+
def available_versions(entry: dict[str, Any]) -> list[str]:
130+
"""Current version first, then historical versions in descending order."""
131+
releases = _validated_releases(entry)
132+
current = entry.get("version")
133+
if not isinstance(current, str) or not current:
134+
return []
135+
return [current, *sorted(releases, key=Version, reverse=True)]

0 commit comments

Comments
 (0)