Skip to content

Commit 6f00b1f

Browse files
markuswondrakMarkus WondrakCopilotMarkus
authored
feat(bundles): select exact bundle catalog releases (#4849)
* feat(bundles): select exact bundle catalog releases Add the bundle catalog slice of #4719. A bundle catalog entry may now advertise historical releases in an optional `releases` mapping, following the extension, preset, workflow and step slices. - `bundle install/add <id> --version <v>` installs an exact catalog release from the winning source; the historical download URL, digest and manifest ID/version checks flow through the existing download path. - `bundle info <id> --versions` lists the current and historical releases (text and JSON) without downloading a manifest. - Release history is validated lazily; `requires`, `provides` and `verified` are never inherited by historical releases. - Lookup never falls through to a lower-priority source, and discovery-only sources stay non-installable. Assisted-by: GitHub Copilot CLI (model: GPT-5.6 Terra, autonomous) Assisted-by: GitHub Copilot CLI (model: Claude Opus 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundles): validate catalog releases on load Reject duplicate JSON keys before decoding bundle catalogs, validate release history as catalog entries load, and compare catalog and manifest versions semantically. Assisted-by: OpenCode (model: github-copilot/gpt-5.6-terra, autonomous) * fix(bundles): align semver and download URL checks with shared policy - is_semver() now uses fullmatch(), so a trailing newline in a release key or version is rejected instead of passing the `$`-anchored regex. - Bundle _require_https() delegates to is_https_or_localhost_http(), so catalog validation and download enforce the same URL policy (e.g. http://127.0.0.2 and expanded IPv6 loopback are accepted consistently). Assisted-by: OpenCode (model: github-copilot/claude-sonnet-5.5, autonomous) --------- Co-authored-by: Markus Wondrak <markuswondrak@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Markus <markus@example.com>
1 parent d3ab820 commit 6f00b1f

19 files changed

Lines changed: 1331 additions & 37 deletions

‎docs/reference/bundles.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,19 @@ Searches all active catalogs for bundles matching the query. Without a query, li
4646

4747
```bash
4848
specify bundle info <bundle_id>
49+
specify bundle info <bundle_id> --versions
4950
```
5051

5152
| Option | Description |
5253
| ------------ | --------------------------------- |
5354
| `--offline` | Do not access the network |
5455
| `--json` | Emit machine-readable JSON |
56+
| `--versions` | List the current and historical catalog releases |
5557

5658
Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps with components already provided by installed bundles are surfaced here as well.
5759

60+
Use `specify bundle info <bundle_id> --versions` to inspect the catalog history without downloading a bundle manifest. With `--json`, it emits the bundle ID, available versions, current version, source, and install policy.
61+
5862
## Install a Bundle
5963

6064
```bash
@@ -66,6 +70,7 @@ specify bundle install <bundle_id | path>
6670
| `--integration` | Override the integration used when initializing/installing |
6771
| `--offline` | Do not access the network |
6872
| `--refresh` | Refresh owned components from the supplied bundle source |
73+
| `--version` | Install an exact historical or current catalog release |
6974

7075
Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack.
7176

@@ -83,6 +88,33 @@ A local bundle source supplies the manifest, not its component payloads. Compone
8388

8489
> **Step payloads resolve through the step catalog only.** A bundle's `provides.steps` entries still resolve exclusively through the active step catalogs. Bundle-local `steps/<id>/` payloads and relative `provides.steps[].source` overrides are **not** resolved in this release, so a step declared that way cannot be installed offline. To ship a step with a bundle today, publish it to a step catalog the bundle's users can reach.
8590
91+
## Catalog Release History
92+
93+
A catalog entry can advertise a current release and approved historical releases. Older single-version catalogs continue to work unchanged.
94+
95+
```json
96+
{
97+
"id": "team-setup",
98+
"version": "1.2.0",
99+
"download_url": "https://example.com/team-setup-1.2.0.zip",
100+
"sha256": "<sha256 for 1.2.0>",
101+
"releases": {
102+
"1.1.0": {
103+
"download_url": "https://example.com/team-setup-1.1.0.zip",
104+
"sha256": "<sha256 for 1.1.0>"
105+
}
106+
}
107+
}
108+
```
109+
110+
Each historical release needs its own `download_url` and a 64-character SHA-256 digest; as with current releases, a digest may use a case-insensitive `sha256:` prefix and surrounding whitespace, and other algorithm prefixes are rejected. A release's `requires`, `provides`, and `verified` fields are not inherited from the current release: put them in that release's record, or the release has no requirements, no provided counts, and is shown as `community`. Other shared metadata, such as the name, description, and author, is inherited, and a release record may override it. A release record cannot contain `id`, `version`, or `releases`.
111+
112+
`specify bundle install team-setup --version 1.1.0` selects the bundle release, downloads that release's artifact, verifies its digest, and checks that its manifest declares the catalog ID and the same version as the selected release. Equivalent spellings such as `v1.1.0` and `1.1.0` match; the catalog key spelling is display-only. It never overrides component pins inside `bundle.yml`. `--version` is catalog-only, so it cannot be blank, used with a local path, or combined with `--refresh`.
113+
114+
Only the highest-precedence catalog that lists a bundle is consulted; a missing version does not fall through to a lower-priority catalog. Discovery-only sources remain non-installable, including exact historical releases. Duplicate JSON keys anywhere in a catalog are rejected while it is read. Historical `download_url` values must use HTTPS (or HTTP on localhost), and release keys must be strict SemVer values that are PEP 440-comparable. Version keys must be distinct, including PEP 440-equivalent spellings such as `1.0.0-rc.1` and `1.0.0-rc1`, and cannot repeat the current version. `bundle update` always selects the current release. The first-party and community catalogs currently advertise one release per bundle.
115+
116+
Release history is validated when its catalog source loads. A malformed `releases` mapping makes `install`, `info`, `update`, and `search` fail while that source is consulted. Resolution stops after the first source that lists the bundle, so a malformed lower-precedence source remains unobserved when a higher-precedence source supplies that ID; `search` loads every source, so a shadowed malformed entry still causes it to fail.
117+
86118
## Update Bundles
87119

88120
```bash

‎src/specify_cli/bundles/catalog_stack.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,26 @@ def _entries_for(self, source: CatalogSource) -> dict[str, CatalogEntry]:
7070
self._payloads[source.id] = load_catalog_payload(raw)
7171
return self._payloads[source.id]
7272

73-
def resolve(self, bundle_id: str) -> ResolvedBundle:
73+
def resolve(self, bundle_id: str, version: str | None = None) -> ResolvedBundle:
7474
"""Return the highest-precedence entry for *bundle_id* or raise."""
75+
from .catalog_versions import available_versions, select_release
76+
7577
for source in self._sources:
7678
entries = self._entries_for(source)
7779
entry = entries.get(bundle_id)
7880
if entry is not None:
79-
return ResolvedBundle(entry=entry.with_provenance(source), source=source)
81+
selected = select_release(entry, version)
82+
if selected is None:
83+
advertised = ", ".join(available_versions(entry))
84+
raise BundlerError(
85+
f"Bundle '{bundle_id}' has no release {version} in catalog source "
86+
f"'{source.id}' (advertised: {advertised}). Only the "
87+
"highest-precedence source that lists a bundle is consulted."
88+
)
89+
return ResolvedBundle(
90+
entry=selected.with_provenance(source),
91+
source=source,
92+
)
8093
raise BundlerError(
8194
f"Bundle '{bundle_id}' was not found in any configured catalog."
8295
)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Exact-release validation and selection for bundle catalog entries."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from typing import Any
7+
8+
from packaging.version import InvalidVersion, Version
9+
10+
from .._download_security import is_https_or_localhost_http
11+
from . import BundlerError
12+
from .catalogs import CatalogEntry
13+
from .versioning import is_semver
14+
15+
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
16+
_CURRENT_ONLY = frozenset(
17+
{
18+
"version",
19+
"download_url",
20+
"sha256",
21+
"requires",
22+
"provides",
23+
"verified",
24+
"releases",
25+
}
26+
)
27+
_RESERVED = frozenset({"id", "version", "releases"})
28+
29+
30+
def _raw(entry: CatalogEntry) -> dict[str, Any]:
31+
return entry.raw if entry.raw is not None else {}
32+
33+
34+
def _release_error(entry: CatalogEntry, version: str, error: BundlerError) -> BundlerError:
35+
return BundlerError(f"Bundle '{entry.id}' release '{version}' has {error}")
36+
37+
38+
def _validated_releases(entry: CatalogEntry) -> dict[str, dict[str, Any]]:
39+
"""Return checked historical releases or reject an ambiguous entry."""
40+
raw = _raw(entry)
41+
if "releases" not in raw:
42+
return {}
43+
releases = raw["releases"]
44+
if not isinstance(releases, dict):
45+
raise BundlerError(f"Bundle '{entry.id}' has an invalid releases mapping.")
46+
47+
current = raw.get("version")
48+
if not isinstance(current, str) or not current.strip():
49+
raise BundlerError(f"Bundle '{entry.id}' has releases but no current version.")
50+
if not is_semver(current):
51+
raise BundlerError(
52+
f"Bundle '{entry.id}' has an invalid current version '{current}'."
53+
)
54+
try:
55+
seen = {Version(current)}
56+
except InvalidVersion:
57+
raise BundlerError(
58+
f"Bundle '{entry.id}' has an invalid current version '{current}'."
59+
) from None
60+
61+
for advertised, record in releases.items():
62+
if not isinstance(advertised, str) or not advertised.strip():
63+
raise BundlerError(
64+
f"Bundle '{entry.id}' has an invalid release version key."
65+
)
66+
if not is_semver(advertised):
67+
raise BundlerError(
68+
f"Bundle '{entry.id}' has invalid release version '{advertised}'."
69+
)
70+
try:
71+
normalized = Version(advertised)
72+
except InvalidVersion:
73+
raise BundlerError(
74+
f"Bundle '{entry.id}' has invalid release version '{advertised}'."
75+
) from None
76+
if normalized in seen:
77+
raise BundlerError(
78+
f"Bundle '{entry.id}' repeats release version '{advertised}'."
79+
)
80+
seen.add(normalized)
81+
if not isinstance(record, dict):
82+
raise BundlerError(
83+
f"Bundle '{entry.id}' release '{advertised}' must be an object."
84+
)
85+
if _RESERVED.intersection(record):
86+
raise BundlerError(
87+
f"Bundle '{entry.id}' release '{advertised}' contains reserved fields."
88+
)
89+
if (
90+
not isinstance(record.get("download_url"), str)
91+
or not record["download_url"].strip()
92+
):
93+
raise BundlerError(
94+
f"Bundle '{entry.id}' release '{advertised}' needs a download_url."
95+
)
96+
if not is_https_or_localhost_http(record["download_url"]):
97+
raise BundlerError(
98+
f"Bundle '{entry.id}' release '{advertised}' has an invalid "
99+
"download_url."
100+
)
101+
digest = record.get("sha256")
102+
if isinstance(digest, str):
103+
digest = digest.strip()
104+
if digest[:7].lower() == "sha256:":
105+
digest = digest[7:].strip()
106+
if not isinstance(digest, str) or not _SHA256.fullmatch(digest):
107+
raise BundlerError(
108+
f"Bundle '{entry.id}' release '{advertised}' needs a SHA-256 digest."
109+
)
110+
for field in ("requires", "provides"):
111+
if field in record and not isinstance(record[field], dict):
112+
raise BundlerError(
113+
f"Bundle '{entry.id}' release '{advertised}' has invalid {field}."
114+
)
115+
common = {key: value for key, value in raw.items() if key not in _CURRENT_ONLY}
116+
try:
117+
CatalogEntry.from_dict({**common, **record, "version": advertised})
118+
except BundlerError as exc:
119+
raise _release_error(entry, advertised, exc) from exc
120+
return releases
121+
122+
123+
def select_release(entry: CatalogEntry, version: str | None) -> CatalogEntry | None:
124+
"""Select an exact release from one winning catalog entry."""
125+
releases = _validated_releases(entry)
126+
current = entry.version
127+
if version is None or version == current:
128+
return entry
129+
try:
130+
requested = Version(version)
131+
except InvalidVersion:
132+
return None
133+
try:
134+
if requested == Version(current):
135+
return entry
136+
except InvalidVersion:
137+
# Legacy entries without history may retain older non-PEP-440 spellings.
138+
pass
139+
raw = _raw(entry)
140+
for advertised, record in releases.items():
141+
if requested == Version(advertised):
142+
common = {
143+
key: value for key, value in raw.items() if key not in _CURRENT_ONLY
144+
}
145+
return CatalogEntry.from_dict({**common, **record, "version": advertised})
146+
return None
147+
148+
149+
def available_versions(entry: CatalogEntry) -> list[str]:
150+
"""Return the current version, then historical releases newest first."""
151+
releases = _validated_releases(entry)
152+
if not entry.version:
153+
return []
154+
return [entry.version, *sorted(releases, key=Version, reverse=True)]

‎src/specify_cli/bundles/catalogs.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77
from __future__ import annotations
88

9-
from dataclasses import dataclass, field
9+
from dataclasses import dataclass, field, replace
1010
from enum import Enum
1111
from pathlib import Path
1212
from typing import Any
@@ -165,6 +165,9 @@ class CatalogEntry:
165165
# Resolution provenance (filled in by the catalog stack at lookup time):
166166
source_id: str | None = None
167167
source_policy: InstallPolicy | None = None
168+
# Preserve unknown additive catalog fields for release-history materialization
169+
# without changing current-entry parsing.
170+
raw: dict[str, Any] | None = field(default=None, compare=False, repr=False)
168171

169172
@classmethod
170173
def from_dict(cls, data: Any) -> "CatalogEntry":
@@ -215,17 +218,13 @@ def from_dict(cls, data: Any) -> "CatalogEntry":
215218
repository=(str(data["repository"]) if data.get("repository") else None),
216219
tags=_parse_tags(data.get("tags"), entry_id),
217220
verified=_parse_verified(data.get("verified", False), entry_id),
221+
raw=dict(data),
218222
)
219223

220224
def with_provenance(self, source: CatalogSource) -> "CatalogEntry":
221-
return CatalogEntry(
222-
id=self.id, name=self.name, version=self.version, role=self.role,
223-
description=self.description, author=self.author, license=self.license,
224-
download_url=self.download_url,
225-
requires_speckit_version=self.requires_speckit_version,
226-
sha256=self.sha256,
227-
provides=self.provides, repository=self.repository, tags=self.tags,
228-
verified=self.verified, source_id=source.id,
225+
return replace(
226+
self,
227+
source_id=source.id,
229228
source_policy=source.install_policy,
230229
)
231230

@@ -248,6 +247,9 @@ def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]:
248247
if not isinstance(bundles_raw, dict):
249248
raise BundlerError("Catalog payload is missing a 'bundles' object.")
250249
entries: dict[str, CatalogEntry] = {}
250+
# Function-local import avoids the catalogs -> catalog_versions import cycle.
251+
from .catalog_versions import _validated_releases
252+
251253
for bundle_id, entry_raw in bundles_raw.items():
252254
key = str(bundle_id)
253255
entry = CatalogEntry.from_dict(entry_raw)
@@ -264,6 +266,7 @@ def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]:
264266
f"Catalog entry id mismatch: key '{key}' != entry id "
265267
f"'{entry.id}'."
266268
)
269+
_validated_releases(entry)
267270
entries[key] = entry
268271
return entries
269272

‎src/specify_cli/bundles/command_add.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ def bundle_add(
2222
"--refresh",
2323
help="Refresh owned components from this bundle source",
2424
),
25+
version: str | None = typer.Option(
26+
None,
27+
"--version",
28+
help="Install an exact bundle release from the catalog",
29+
),
2530
) -> None:
2631
"""Install a bundle's full component set (alias for install)."""
2732
bundle_install(
2833
bundle_id=bundle_id,
2934
integration=integration,
3035
offline=offline,
3136
refresh=refresh,
37+
version=version,
3238
)

‎src/specify_cli/bundles/command_info.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,46 @@ def bundle_info(
2727
bundle_id: str = typer.Argument(..., help="Bundle id to inspect"),
2828
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
2929
as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"),
30+
versions: bool = typer.Option(False, "--versions", help="List catalog versions"),
3031
) -> None:
3132
"""Show full metadata and the fully expanded component set (== what install adds)."""
3233
try:
3334
project_root = find_project_root() or Path.cwd()
3435
stack = _build_stack(project_root, offline=offline)
3536
resolved = stack.resolve(bundle_id)
37+
if versions is True:
38+
from .catalog_versions import available_versions
39+
40+
available = available_versions(resolved.entry)
41+
if as_json:
42+
print(
43+
_json.dumps(
44+
{
45+
"id": resolved.entry.id,
46+
"versions": available,
47+
"current": resolved.entry.version,
48+
"source": resolved.source.id,
49+
"install_policy": resolved.source.install_policy.value,
50+
},
51+
indent=2,
52+
)
53+
)
54+
return
55+
console.print(
56+
f"Catalog versions for {_escape_markup(str(resolved.entry.id))}:"
57+
)
58+
for index, available_version in enumerate(available):
59+
current = " (current)" if index == 0 else ""
60+
console.print(f" {_escape_markup(available_version)}{current}")
61+
console.print(
62+
f" Source: {_escape_markup(str(resolved.source.id))} "
63+
f"({resolved.source.install_policy.value})"
64+
)
65+
if not resolved.install_allowed:
66+
console.print(
67+
"[yellow]Discovery only; catalog installation is disabled.[/yellow]"
68+
)
69+
return
3670
# `info` must show the fully expanded component set that `install` would
3771
# apply (contracts/cli-commands.md). Expansion happens regardless of
3872
# install policy — discovery-only bundles stay inspectable; only

0 commit comments

Comments
 (0)