From 9a242d8fbffaa416ce119c088089ccb58a8a4bbb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:58:18 -0500 Subject: [PATCH 1/4] feat: support exact workflow catalog releases Keep current workflow metadata at the top level while validating and selecting optional historical releases from the winning catalog. Verify the selected digest, requirements, ID, and version before installation, and expose available versions in workflow info. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/workflows.md | 32 ++ src/specify_cli/workflows/_commands.py | 54 ++- src/specify_cli/workflows/catalog/_domain.py | 26 +- .../workflows/catalog/_versions.py | 120 +++++++ src/specify_cli/workflows/command_add.py | 19 +- src/specify_cli/workflows/command_info.py | 25 +- .../workflows/test_catalog_versions.py | 324 ++++++++++++++++++ 7 files changed, 585 insertions(+), 15 deletions(-) create mode 100644 src/specify_cli/workflows/catalog/_versions.py create mode 100644 tests/specify_cli/workflows/test_catalog_versions.py diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 58862ec41a..f465d9e64d 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -105,6 +105,7 @@ specify workflow add | --------------- | ------------------------------------------------------ | | `--dev` | Install from a local YAML file, package directory, or archive | | `--from ` | Install from a custom URL (`` names the expected workflow ID) | +| `--version ` | Install an exact advertised catalog release (`` must be a workflow ID) | Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz` @@ -115,6 +116,34 @@ Directory and archive installs preserve the complete workflow package, including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz` archives follow the same validation and installation behavior. +Catalog entries keep the current release's `version`, `url`, optional `sha256`, +and optional `requires` at the top level. An optional `releases` mapping +advertises historical versions without changing what unqualified `add`, +`search`, `info`, or `update` select: + +```json +{ + "id": "example", + "version": "2.0.0", + "url": "https://example.com/example-2.0.0.zip", + "releases": { + "1.0.0": { + "url": "https://example.com/example-1.0.0.zip", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requires": {"speckit_version": ">=1.0.0"} + } + } +} +``` + +Each historical release needs its own URL and SHA-256 digest; `requires` is +optional and, when present, must match the downloaded workflow definition. +The requested version must exist in the highest-priority catalog that provides +the workflow. A missing version does not fall back to another source, and +discovery-only catalogs cannot be installed from. The downloaded workflow ID, +version, and declared digest are verified before installation. `--version` does +not apply to local paths, direct URLs, or `--from` installations. + ## Workflow Overlays Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades. @@ -378,9 +407,12 @@ Searches all active catalogs for workflows matching the query. ```bash specify workflow info +specify workflow info --versions ``` Shows detailed information about a workflow, including its steps, inputs, and requirements. +`--versions` lists the current catalog version followed by available historical +versions; it also works when a different version is installed locally. ## Catalog Management diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index de01789d0c..297c34b10d 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -724,6 +724,7 @@ def _install_workflow_package( *, expected_id: str | None = None, expected_version: str | None = None, + expected_requires: dict[str, Any] | None = None, expected_installed_version: str | None = None, catalog_info: dict[str, Any] | None = None, ) -> None: @@ -768,6 +769,12 @@ def _install_workflow_package( f"version ({_escape_markup(expected_version)})." ) raise typer.Exit(1) + if expected_requires is not None and definition.requires != expected_requires: + console.print( + "[red]Error:[/red] Downloaded workflow requirements do not match " + "the selected catalog release." + ) + raise typer.Exit(1) dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id) staged_dir = Path( @@ -1067,6 +1074,7 @@ def _install_workflow_from_catalog( workflow_id: str, expected_version: str | None = None, expected_installed_version: str | None = None, + requested_version: str | None = None, ) -> None: """Download, validate, and register a catalog workflow. @@ -1094,15 +1102,28 @@ def versions_match(actual: object, expected: str) -> bool: catalog = WorkflowCatalog(project_root) try: - info = catalog.get_workflow_info(workflow_id) + info = ( + catalog.get_workflow_info(workflow_id, requested_version) + if requested_version is not None + else catalog.get_workflow_info(workflow_id) + ) except WorkflowCatalogError as exc: console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") raise typer.Exit(1) if not info: + if requested_version is not None: + console.print( + f"[red]Error:[/red] Workflow '{safe_wf_id}' version " + f"'{_escape_markup(requested_version)}' not found in the winning catalog." + ) + raise typer.Exit(1) console.print(f"[red]Error:[/red] Workflow '{safe_wf_id}' not found in catalog") raise typer.Exit(1) + if requested_version is not None: + expected_version = info["version"] + if not info.get("_install_allowed", True): console.print(f"[yellow]Warning:[/yellow] Workflow '{safe_wf_id}' is from a discovery-only catalog") console.print("Direct installation is not enabled for this catalog source.") @@ -1235,14 +1256,17 @@ def versions_match(actual: object, expected: str) -> bool: console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}") raise typer.Exit(1) + try: + verify_archive_sha256( + downloaded_content, info.get("sha256"), workflow_id, ValueError + ) + except ValueError as exc: + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) + if downloaded_archive_format is not None: try: - verify_archive_sha256( - downloaded_content, - info.get("sha256"), - workflow_id, - ValueError, - ) import tempfile from io import BytesIO @@ -1270,6 +1294,9 @@ def versions_match(actual: object, expected: str) -> bool: workflow_url, expected_id=workflow_id, expected_version=expected_version, + expected_requires=( + info.get("requires") if requested_version is not None else None + ), expected_installed_version=expected_installed_version, catalog_info={**info, "url": workflow_url}, ) @@ -1330,6 +1357,19 @@ def versions_match(actual: object, expected: str) -> bool: f"The catalog entry may be stale or misconfigured." ) raise typer.Exit(1) + if ( + requested_version is not None + and "requires" in info + and definition.requires != info["requires"] + ): + _safe_discard_staged_workflow_file( + staged_file, workflow_dir, existed_before + ) + console.print( + "[red]Error:[/red] Downloaded workflow requirements do not match " + "the selected catalog release." + ) + raise typer.Exit(1) try: transaction = _workflow_install_transaction(project_root) diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 93bc496f12..4200615e6a 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -692,13 +692,29 @@ def search( results.append(wf_data) return results - def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None: - """Get details for a specific workflow from the catalog.""" + def get_workflow_info( + self, workflow_id: str, version: str | None = None + ) -> dict[str, Any] | None: + """Get the current or an exact advertised release from the winning source.""" + from ._versions import select_release + + merged = self._get_merged_workflows() + wf = merged.get(workflow_id) + if wf is None: + return None + wf.setdefault("id", workflow_id) + return select_release(wf, version) + + def get_workflow_versions(self, workflow_id: str) -> list[str]: + """List versions advertised by the winning catalog entry.""" + from ._versions import available_versions + merged = self._get_merged_workflows() wf = merged.get(workflow_id) - if wf: - wf.setdefault("id", workflow_id) - return wf + if wf is None: + return [] + wf.setdefault("id", workflow_id) + return available_versions(wf) def get_catalog_configs(self) -> list[dict[str, Any]]: """Return current catalog configuration as a list of dicts.""" diff --git a/src/specify_cli/workflows/catalog/_versions.py b/src/specify_cli/workflows/catalog/_versions.py new file mode 100644 index 0000000000..dff11538d8 --- /dev/null +++ b/src/specify_cli/workflows/catalog/_versions.py @@ -0,0 +1,120 @@ +"""Version selection for workflow catalog entries.""" + +from __future__ import annotations + +import re +from typing import Any + +from packaging.version import InvalidVersion, Version + +from ..._download_security import is_https_or_localhost_http +from ._domain import WorkflowValidationError + +_SHA256 = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$", re.IGNORECASE) +_CURRENT_ONLY = frozenset( + {"version", "url", "sha256", "requires", "bundled", "releases"} +) +_RESERVED = frozenset( + {"id", "version", "releases", "_catalog_name", "_install_allowed"} +) + + +def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: + if "releases" not in entry: + return {} + releases = entry["releases"] + workflow_id = entry.get("id", "") + if not isinstance(releases, dict): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has an invalid releases mapping." + ) + + current = entry.get("version") + if not isinstance(current, str) or not current.strip(): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has releases but no current version." + ) + try: + seen = {Version(current)} + except InvalidVersion: + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has an invalid current version '{current}'." + ) from None + + for release_version, record in releases.items(): + if not isinstance(release_version, str) or not release_version.strip(): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has an invalid release version key." + ) + try: + normalized = Version(release_version) + except InvalidVersion: + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has invalid release version '{release_version}'." + ) from None + if normalized in seen: + raise WorkflowValidationError( + f"Workflow '{workflow_id}' repeats release version '{release_version}'." + ) + seen.add(normalized) + if not isinstance(record, dict): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' must be an object." + ) + if _RESERVED.intersection(record): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' contains reserved fields." + ) + if not isinstance(record.get("url"), str) or not record["url"].strip(): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' needs a url." + ) + if not is_https_or_localhost_http(record["url"]): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' has an invalid URL." + ) + if not isinstance(record.get("sha256"), str) or not _SHA256.fullmatch( + record["sha256"] + ): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' needs a SHA-256 digest." + ) + if "requires" in record and not isinstance(record["requires"], dict): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' release '{release_version}' has invalid requires." + ) + return releases + + +def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None: + """Select from the winning source, preserving the advertised version.""" + releases = _validated_releases(entry) + current = entry.get("version") + if version is None or version == current: + return entry + try: + requested = Version(version) + except InvalidVersion: + return None + if isinstance(current, str): + try: + if requested == Version(current): + return entry + except InvalidVersion: + pass # Legacy entries without history can use non-PEP-440 versions. + for advertised, record in releases.items(): + if requested == Version(advertised): + common = { + key: value for key, value in entry.items() if key not in _CURRENT_ONLY + } + return {**common, **record, "version": advertised} + return None + + +def available_versions(entry: dict[str, Any]) -> list[str]: + """Current first, followed by historical versions newest to oldest.""" + releases = _validated_releases(entry) + current = entry.get("version") + if not isinstance(current, str) or not current: + return [] + return [current, *sorted(releases, key=Version, reverse=True)] diff --git a/src/specify_cli/workflows/command_add.py b/src/specify_cli/workflows/command_add.py index f123a96459..0ea615817b 100644 --- a/src/specify_cli/workflows/command_add.py +++ b/src/specify_cli/workflows/command_add.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Annotated + from . import _commands as cli @@ -37,12 +39,25 @@ def workflow_add( from_url: str | None = cli.typer.Option( None, "--from", help="Install from a custom URL" ), + version: Annotated[ + str | None, cli.typer.Option(help="Install an exact catalog release") + ] = None, ): """Install a workflow from catalog, URL, or local path.""" from . import load_custom_steps from .engine import WorkflowDefinition project_root = cli._require_specify_project() + if version is not None and ( + dev or from_url is not None or source.startswith(("http://", "https://")) + or cli.Path(source).exists() + ): + cli.console.print( + "[red]Error:[/red] --version requires a workflow ID from a catalog." + ) + raise cli.typer.Exit(1) + if version is not None: + cli._validate_workflow_id_or_exit(source) load_custom_steps(project_root) cli._open_workflow_registry(project_root) workflows_dir = project_root / ".specify" / "workflows" @@ -504,4 +519,6 @@ def _validate_and_install_local( return # Try from catalog - cli._install_workflow_from_catalog(project_root, workflows_dir, source) + cli._install_workflow_from_catalog( + project_root, workflows_dir, source, requested_version=version + ) diff --git a/src/specify_cli/workflows/command_info.py b/src/specify_cli/workflows/command_info.py index ba3741f7b1..1bd4d80185 100644 --- a/src/specify_cli/workflows/command_info.py +++ b/src/specify_cli/workflows/command_info.py @@ -8,12 +8,32 @@ @cli.workflow_app.command("info") def workflow_info( workflow_id: str = cli.typer.Argument(..., help="Workflow ID"), + versions: bool = cli.typer.Option( + False, "--versions", help="Show versions available in workflow catalogs" + ), ): """Show workflow details and step graph.""" from .catalog import WorkflowCatalog, WorkflowCatalogError from .engine import WorkflowEngine project_root = cli._require_specify_project() + if versions: + catalog = WorkflowCatalog(project_root) + try: + available = catalog.get_workflow_versions(workflow_id) + except WorkflowCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + if not available: + cli.console.print( + f"[red]Error:[/red] Workflow '{cli._escape_markup(workflow_id)}' not found in catalog" + ) + raise cli.typer.Exit(1) + cli.console.print( + f"Available versions for {cli._escape_markup(workflow_id)}: " + + ", ".join(cli._escape_markup(version) for version in available) + ) + return # Check installed first registry = cli._open_workflow_registry(project_root) @@ -92,8 +112,9 @@ def workflow_info( catalog = WorkflowCatalog(project_root) try: info = catalog.get_workflow_info(workflow_id) - except WorkflowCatalogError: - info = None + except WorkflowCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) if info: # Catalog-derived fields are untrusted; escape them so bracketed content diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py new file mode 100644 index 0000000000..bdf0c67be9 --- /dev/null +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -0,0 +1,324 @@ +"""Exact-release lookup and installation for workflow catalogs (#4719).""" + +from __future__ import annotations + +import hashlib +import io +import zipfile + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.workflows.catalog import ( + WorkflowCatalog, + WorkflowCatalogEntry, + WorkflowRegistry, + WorkflowValidationError, +) + +runner = CliRunner() +CURRENT_URL = "https://example.com/current.zip" +OLD_URL = "https://example.com/old.zip" + + +def _archive(version: str, workflow_id: str = "history-wf", requires=None) -> bytes: + import yaml + + document = { + "schema_version": "1.0", + "workflow": { + "id": workflow_id, + "name": "History Workflow", + "version": version, + }, + "steps": [{"id": "first", "type": "gate", "message": "Continue?"}], + } + if requires is not None: + document["requires"] = requires + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("workflow.yml", yaml.safe_dump(document)) + return output.getvalue() + + +def _entry() -> dict: + old = _archive("1.0.0", requires={"integrations": ["copilot"]}) + current = _archive("2.0.0") + return { + "id": "history-wf", + "name": "History Workflow", + "version": "2.0.0", + "url": CURRENT_URL, + "sha256": hashlib.sha256(current).hexdigest(), + "bundled": True, + "releases": { + "1.0.0": { + "url": OLD_URL, + "sha256": hashlib.sha256(old).hexdigest(), + "requires": {"integrations": ["copilot"]}, + } + }, + } + + +def _catalog(monkeypatch, project_dir, high, low=None, *, install_allowed=True): + sources = [ + WorkflowCatalogEntry( + "https://example.com/high.json", "high", 1, install_allowed + ) + ] + if low is not None: + sources.append( + WorkflowCatalogEntry("https://example.com/low.json", "low", 2, True) + ) + monkeypatch.setattr(WorkflowCatalog, "get_active_catalogs", lambda self: sources) + monkeypatch.setattr( + WorkflowCatalog, + "_fetch_single_catalog", + lambda self, source, force_refresh=False: { + "workflows": {"history-wf": high if source.name == "high" else low} + }, + ) + return WorkflowCatalog(project_dir) + + +class _Response(io.BytesIO): + def __init__(self, content: bytes, url: str): + super().__init__(content) + self.url = url + + def geturl(self): + return self.url + + +def test_current_and_historical_metadata(monkeypatch, project_dir): + entry = _entry() + catalog = _catalog(monkeypatch, project_dir, entry) + assert catalog.get_workflow_info("history-wf")["url"] == CURRENT_URL + assert catalog.get_workflow_info("history-wf", "v2.0")["url"] == CURRENT_URL + old = catalog.get_workflow_info("history-wf", "1.0.0.0") + assert old["url"] == OLD_URL + assert old["version"] == "1.0.0" + assert old["sha256"] == entry["releases"]["1.0.0"]["sha256"] + assert old["requires"] == {"integrations": ["copilot"]} + assert "releases" not in old and "bundled" not in old + assert old["_catalog_name"] == "high" + assert catalog.get_workflow_versions("history-wf") == ["2.0.0", "1.0.0"] + assert catalog.get_workflow_info("history-wf", "invalid") is None + assert catalog.get_workflow_info("history-wf", "0.0.1") is None + + +def test_legacy_entry_and_winning_source(monkeypatch, project_dir): + entry = _entry() + high = {"id": "history-wf", "version": "2.0.0", "url": CURRENT_URL} + catalog = _catalog(monkeypatch, project_dir, high, entry) + assert catalog.get_workflow_info("history-wf", "2.0.0")["url"] == CURRENT_URL + assert catalog.get_workflow_info("history-wf", "1.0.0") is None + assert catalog.get_workflow_versions("history-wf") == ["2.0.0"] + assert catalog.search(query="history-wf")[0]["version"] == "2.0.0" + + +@pytest.mark.parametrize( + "history", + [ + None, + [], + {"bad": {"url": OLD_URL, "sha256": "a" * 64}}, + {"2.0.0": {"url": OLD_URL, "sha256": "a" * 64}}, + {"2.0.0.0": {"url": OLD_URL, "sha256": "a" * 64}}, + { + "1.0.0": {"url": OLD_URL, "sha256": "a" * 64}, + "v1.0": {"url": OLD_URL, "sha256": "b" * 64}, + }, + {"1.0.0": {"sha256": "a" * 64}}, + {"1.0.0": {"url": "http://example.com/old.zip", "sha256": "a" * 64}}, + {"1.0.0": {"url": "https://[::1", "sha256": "a" * 64}}, + {"1.0.0": {"url": OLD_URL}}, + {"1.0.0": {"url": OLD_URL, "sha256": "wrong"}}, + {"1.0.0": {"url": OLD_URL, "sha256": "a" * 64, "id": "other"}}, + {"1.0.0": {"url": OLD_URL, "sha256": "a" * 64, "requires": []}}, + {"1.0.0": None}, + ], +) +def test_malformed_history_rejected(monkeypatch, project_dir, history): + entry = _entry() + entry["releases"] = history + catalog = _catalog(monkeypatch, project_dir, entry) + with pytest.raises(WorkflowValidationError): + catalog.get_workflow_info("history-wf", "1.0.0") + with pytest.raises(WorkflowValidationError): + catalog.get_workflow_versions("history-wf") + + +def test_history_without_current_version_rejected(monkeypatch, project_dir): + entry = _entry() + entry.pop("version") + catalog = _catalog(monkeypatch, project_dir, entry) + with pytest.raises(WorkflowValidationError, match="no current version"): + catalog.get_workflow_info("history-wf") + + +def test_malformed_history_is_reported_by_cli(monkeypatch, project_dir): + entry = _entry() + entry["releases"] = {"1.0.0": {"url": OLD_URL}} + _catalog(monkeypatch, project_dir, entry) + monkeypatch.chdir(project_dir) + for args in ( + ["workflow", "info", "history-wf"], + ["workflow", "info", "history-wf", "--versions"], + ["workflow", "add", "history-wf", "--version", "1.0.0"], + ): + result = runner.invoke(app, args) + assert result.exit_code == 1 + assert "needs a SHA-256 digest" in result.output + assert "not found" not in result.output + + +def test_info_versions_uses_catalog_even_when_installed(monkeypatch, project_dir): + _catalog(monkeypatch, project_dir, _entry()) + WorkflowRegistry(project_dir).add( + "history-wf", {"version": "0.5.0", "source": "catalog"} + ) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "info", "history-wf", "--versions"]) + assert result.exit_code == 0, result.output + assert "2.0.0, 1.0.0" in result.output + result = runner.invoke(app, ["workflow", "info", "history-wf"]) + assert result.exit_code == 0, result.output + assert "Version: 2.0.0" in result.output + + +def test_exact_add_uses_historical_url_digest_and_requirements( + monkeypatch, project_dir +): + from specify_cli.authentication import http + + _catalog(monkeypatch, project_dir, _entry()) + requested = [] + + def open_url(url, **kwargs): + requested.append(url) + return _Response(_archive("1.0.0", requires={"integrations": ["copilot"]}), url) + + monkeypatch.setattr(http, "open_url", open_url) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "v1.0"]) + assert result.exit_code == 0, result.output + assert requested == [OLD_URL] + assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" + + +def test_exact_yaml_release_verifies_digest_and_version(monkeypatch, project_dir): + from specify_cli.authentication import http + + yaml_content = b"""schema_version: "1.0" +workflow: + id: history-wf + name: History Workflow + version: 1.0.0 +steps: + - id: first + type: gate + message: Continue? +""" + entry = _entry() + entry["releases"]["1.0.0"] = { + "url": "https://example.com/old.yml", + "sha256": hashlib.sha256(yaml_content).hexdigest(), + } + _catalog(monkeypatch, project_dir, entry) + monkeypatch.setattr( + http, + "open_url", + lambda url, **kw: _Response(yaml_content, url), + ) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "1.0"]) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" + + +@pytest.mark.parametrize( + ("download", "entry_change", "error"), + [ + (_archive("3.0.0"), None, "does not match the catalog version"), + ( + _archive("1.0.0", "wrong-wf"), + None, + "does not match the requested workflow ID", + ), + (_archive("1.0.0"), None, "requirements do not match"), + ( + _archive("1.0.0", requires={"integrations": ["copilot"]}), + "bad-digest", + "Integrity check failed", + ), + ], + ids=["version", "id", "requirements", "digest"], +) +def test_exact_add_rejects_inconsistent_archive( + monkeypatch, project_dir, download, entry_change, error +): + from specify_cli.authentication import http + + entry = _entry() + if entry_change == "bad-digest": + entry["releases"]["1.0.0"]["sha256"] = "0" * 64 + else: + entry["releases"]["1.0.0"]["sha256"] = hashlib.sha256(download).hexdigest() + _catalog(monkeypatch, project_dir, entry) + monkeypatch.setattr(http, "open_url", lambda url, **kw: _Response(download, url)) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "1.0.0"]) + assert result.exit_code == 1 + assert error in result.output + assert WorkflowRegistry(project_dir).get("history-wf") is None + assert not ( + project_dir / ".specify" / "workflows" / "history-wf" / "workflow.yml" + ).exists() + + +def test_no_fallthrough_for_missing_or_discovery_release(monkeypatch, project_dir): + high = _entry() + high["releases"] = {} + _catalog(monkeypatch, project_dir, high, _entry()) + monkeypatch.chdir(project_dir) + missing = runner.invoke( + app, ["workflow", "add", "history-wf", "--version", "1.0.0"] + ) + assert missing.exit_code == 1 + assert "not found in the winning catalog" in missing.output + _catalog(monkeypatch, project_dir, _entry(), _entry(), install_allowed=False) + discovery = runner.invoke( + app, ["workflow", "add", "history-wf", "--version", "1.0.0"] + ) + assert discovery.exit_code == 1 + assert "discovery-only" in discovery.output + + +def test_unqualified_add_still_uses_current_and_invalid_version_scope( + monkeypatch, project_dir +): + from specify_cli.authentication import http + + _catalog(monkeypatch, project_dir, _entry()) + requested = [] + + def open_url(url, **kw): + requested.append(url) + return _Response(_archive("2.0.0"), url) + + monkeypatch.setattr(http, "open_url", open_url) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "add", "history-wf"]) + assert result.exit_code == 0, result.output + assert requested == [CURRENT_URL] + assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "2.0.0" + invalid = runner.invoke( + app, + ["workflow", "add", "history-wf", "--from", OLD_URL, "--version", "1.0.0"], + ) + assert invalid.exit_code == 1 + assert "--version requires a workflow ID" in invalid.output + assert requested == [CURRENT_URL] From 8e83845f2f062d5c07dd403bb3506a44984450ff Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:07:45 -0500 Subject: [PATCH 2/4] fix: align catalog releases with workflow version rules Reject advertised versions that workflow definitions cannot declare, enforce exact artifact spelling for selected releases, and mark discovery-only versions as non-installable in workflow info. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/workflows.md | 9 ++- src/specify_cli/workflows/_commands.py | 21 +++--- src/specify_cli/workflows/catalog/_domain.py | 11 ++- .../workflows/catalog/_versions.py | 9 +++ src/specify_cli/workflows/command_info.py | 12 +++- src/specify_cli/workflows/engine.py | 10 ++- .../workflows/test_catalog_versions.py | 68 ++++++++++++++++++- 7 files changed, 122 insertions(+), 18 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index f465d9e64d..2ebe4ff5e6 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -138,6 +138,9 @@ advertises historical versions without changing what unqualified `add`, Each historical release needs its own URL and SHA-256 digest; `requires` is optional and, when present, must match the downloaded workflow definition. +Advertised versions use the workflow definition's `X.Y.Z` version format; +`--version` also accepts equivalent spellings such as `v1.0` when selecting an +advertised `1.0.0` release. The requested version must exist in the highest-priority catalog that provides the workflow. A missing version does not fall back to another source, and discovery-only catalogs cannot be installed from. The downloaded workflow ID, @@ -411,8 +414,10 @@ specify workflow info --versions ``` Shows detailed information about a workflow, including its steps, inputs, and requirements. -`--versions` lists the current catalog version followed by available historical -versions; it also works when a different version is installed locally. +`--versions` lists the current catalog version followed by advertised historical +versions and indicates whether the winning catalog is installable or +discovery-only (not installable). It also works when a different version is +installed locally. ## Catalog Management diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 297c34b10d..241bddb775 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1348,15 +1348,18 @@ def versions_match(actual: object, expected: str) -> bool: # A stale or misconfigured URL can serve a different version than the # catalog advertised; without this check `update` would report success # while leaving the old version installed (or even downgrading). - if expected_version is not None: - if not versions_match(definition.version, expected_version): - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print( - f"[red]Error:[/red] Downloaded workflow version ({_escape_markup(str(definition.version))}) " - f"does not match the catalog version ({_escape_markup(expected_version)}). " - f"The catalog entry may be stale or misconfigured." - ) - raise typer.Exit(1) + if expected_version is not None and not ( + str(definition.version) == expected_version + if requested_version is not None + else versions_match(definition.version, expected_version) + ): + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print( + f"[red]Error:[/red] Downloaded workflow version ({_escape_markup(str(definition.version))}) " + f"does not match the catalog version ({_escape_markup(expected_version)}). " + f"The catalog entry may be stale or misconfigured." + ) + raise typer.Exit(1) if ( requested_version is not None and "requires" in info diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 4200615e6a..352618fee3 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -707,14 +707,21 @@ def get_workflow_info( def get_workflow_versions(self, workflow_id: str) -> list[str]: """List versions advertised by the winning catalog entry.""" + details = self.get_workflow_version_details(workflow_id) + return details[0] if details is not None else [] + + def get_workflow_version_details( + self, workflow_id: str + ) -> tuple[list[str], bool] | None: + """Return advertised versions and whether their source allows installation.""" from ._versions import available_versions merged = self._get_merged_workflows() wf = merged.get(workflow_id) if wf is None: - return [] + return None wf.setdefault("id", workflow_id) - return available_versions(wf) + return available_versions(wf), bool(wf.get("_install_allowed", True)) def get_catalog_configs(self) -> list[dict[str, Any]]: """Return current catalog configuration as a list of dicts.""" diff --git a/src/specify_cli/workflows/catalog/_versions.py b/src/specify_cli/workflows/catalog/_versions.py index dff11538d8..f0d9c1779c 100644 --- a/src/specify_cli/workflows/catalog/_versions.py +++ b/src/specify_cli/workflows/catalog/_versions.py @@ -8,6 +8,7 @@ from packaging.version import InvalidVersion, Version from ..._download_security import is_https_or_localhost_http +from ..engine import _is_valid_workflow_version from ._domain import WorkflowValidationError _SHA256 = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$", re.IGNORECASE) @@ -34,6 +35,10 @@ def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: raise WorkflowValidationError( f"Workflow '{workflow_id}' has releases but no current version." ) + if not _is_valid_workflow_version(current): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has an invalid current version '{current}'." + ) try: seen = {Version(current)} except InvalidVersion: @@ -46,6 +51,10 @@ def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: raise WorkflowValidationError( f"Workflow '{workflow_id}' has an invalid release version key." ) + if not _is_valid_workflow_version(release_version): + raise WorkflowValidationError( + f"Workflow '{workflow_id}' has invalid release version '{release_version}'." + ) try: normalized = Version(release_version) except InvalidVersion: diff --git a/src/specify_cli/workflows/command_info.py b/src/specify_cli/workflows/command_info.py index 1bd4d80185..ff36a1c2a7 100644 --- a/src/specify_cli/workflows/command_info.py +++ b/src/specify_cli/workflows/command_info.py @@ -20,19 +20,25 @@ def workflow_info( if versions: catalog = WorkflowCatalog(project_root) try: - available = catalog.get_workflow_versions(workflow_id) + details = catalog.get_workflow_version_details(workflow_id) except WorkflowCatalogError as exc: cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") raise cli.typer.Exit(1) - if not available: + if details is None or not details[0]: cli.console.print( f"[red]Error:[/red] Workflow '{cli._escape_markup(workflow_id)}' not found in catalog" ) raise cli.typer.Exit(1) + available, install_allowed = details cli.console.print( - f"Available versions for {cli._escape_markup(workflow_id)}: " + f"Catalog versions for {cli._escape_markup(workflow_id)}: " + ", ".join(cli._escape_markup(version) for version in available) ) + cli.console.print( + " Install policy: installable" + if install_allowed + else " Install policy: discovery-only (not installable)" + ) return # Check installed first diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d81aae3212..d7ad0fb857 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -122,6 +122,14 @@ def from_string(cls, content: str) -> WorkflowDefinition: # ID format: lowercase alphanumeric with hyphens _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") +_WORKFLOW_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+") + + +def _is_valid_workflow_version(value: object) -> bool: + return ( + isinstance(value, str) + and _WORKFLOW_VERSION_PATTERN.fullmatch(value) is not None + ) # Keys accepted under a workflow's ``requires`` block: the advisory # pre-conditions documented for workflows (``speckit_version`` and @@ -229,7 +237,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"{type(definition.version).__name__} ({definition.version!r}) — " f'quote it in YAML (version: "1.0.0").' ) - elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version): + elif not _is_valid_workflow_version(definition.version): errors.append( f"Workflow version {definition.version!r} is not valid " f"semantic versioning (expected X.Y.Z)." diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index bdf0c67be9..17ccbd2588 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -125,11 +125,14 @@ def test_legacy_entry_and_winning_source(monkeypatch, project_dir): None, [], {"bad": {"url": OLD_URL, "sha256": "a" * 64}}, + {"\u0661.\u0660.\u0660": {"url": OLD_URL, "sha256": "a" * 64}}, + {"1.0": {"url": OLD_URL, "sha256": "a" * 64}}, + {"v1.0.0": {"url": OLD_URL, "sha256": "a" * 64}}, {"2.0.0": {"url": OLD_URL, "sha256": "a" * 64}}, {"2.0.0.0": {"url": OLD_URL, "sha256": "a" * 64}}, { "1.0.0": {"url": OLD_URL, "sha256": "a" * 64}, - "v1.0": {"url": OLD_URL, "sha256": "b" * 64}, + "01.0.0": {"url": OLD_URL, "sha256": "b" * 64}, }, {"1.0.0": {"sha256": "a" * 64}}, {"1.0.0": {"url": "http://example.com/old.zip", "sha256": "a" * 64}}, @@ -159,6 +162,19 @@ def test_history_without_current_version_rejected(monkeypatch, project_dir): catalog.get_workflow_info("history-wf") +@pytest.mark.parametrize( + "version", ["2.0", "v2.0.0", "2.0.0.0", "\u0662.\u0660.\u0660"] +) +def test_history_with_non_workflow_current_version_rejected( + monkeypatch, project_dir, version +): + entry = _entry() + entry["version"] = version + catalog = _catalog(monkeypatch, project_dir, entry) + with pytest.raises(WorkflowValidationError, match="invalid current version"): + catalog.get_workflow_info("history-wf") + + def test_malformed_history_is_reported_by_cli(monkeypatch, project_dir): entry = _entry() entry["releases"] = {"1.0.0": {"url": OLD_URL}} @@ -184,11 +200,22 @@ def test_info_versions_uses_catalog_even_when_installed(monkeypatch, project_dir result = runner.invoke(app, ["workflow", "info", "history-wf", "--versions"]) assert result.exit_code == 0, result.output assert "2.0.0, 1.0.0" in result.output + assert "installable" in result.output result = runner.invoke(app, ["workflow", "info", "history-wf"]) assert result.exit_code == 0, result.output assert "Version: 2.0.0" in result.output +def test_info_versions_labels_discovery_only_catalog(monkeypatch, project_dir): + _catalog(monkeypatch, project_dir, _entry(), _entry(), install_allowed=False) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "info", "history-wf", "--versions"]) + assert result.exit_code == 0, result.output + assert "2.0.0, 1.0.0" in result.output + assert "discovery-only" in result.output + assert "not installable" in result.output + + def test_exact_add_uses_historical_url_digest_and_requirements( monkeypatch, project_dir ): @@ -239,6 +266,45 @@ def test_exact_yaml_release_verifies_digest_and_version(monkeypatch, project_dir assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" +@pytest.mark.parametrize("source_type", ["archive", "yaml"]) +def test_exact_release_rejects_differently_spelled_artifact_version( + monkeypatch, project_dir, source_type +): + from specify_cli.authentication import http + + if source_type == "archive": + download = _archive("01.0.0") + url = OLD_URL + else: + download = b"""schema_version: "1.0" +workflow: + id: history-wf + name: History Workflow + version: "01.0.0" +steps: + - id: first + type: gate + message: Continue? +""" + url = "https://example.com/old.yml" + entry = _entry() + entry["releases"]["1.0.0"] = { + "url": url, + "sha256": hashlib.sha256(download).hexdigest(), + } + _catalog(monkeypatch, project_dir, entry) + monkeypatch.setattr( + http, "open_url", lambda requested, **kw: _Response(download, requested) + ) + monkeypatch.chdir(project_dir) + + result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "v1.0"]) + + assert result.exit_code == 1 + assert "does not match the catalog version" in result.output + assert WorkflowRegistry(project_dir).get("history-wf") is None + + @pytest.mark.parametrize( ("download", "entry_change", "error"), [ From 92f719b09b4a71cfc6e9aff4d2b1e53c1c5a02e6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:26:50 -0500 Subject: [PATCH 3/4] test: tolerate wrapped workflow install errors Reproduce narrow Windows Rich console output and compare the validation message after normalizing whitespace, without weakening the exit or rollback assertions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/workflows/test_catalog_versions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 17ccbd2588..b51529c447 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -327,7 +327,9 @@ def test_exact_add_rejects_inconsistent_archive( monkeypatch, project_dir, download, entry_change, error ): from specify_cli.authentication import http + from specify_cli.workflows import _commands as workflow_cli + monkeypatch.setattr(workflow_cli.console, "width", 68) entry = _entry() if entry_change == "bad-digest": entry["releases"]["1.0.0"]["sha256"] = "0" * 64 @@ -338,7 +340,7 @@ def test_exact_add_rejects_inconsistent_archive( monkeypatch.chdir(project_dir) result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "1.0.0"]) assert result.exit_code == 1 - assert error in result.output + assert error in " ".join(result.output.split()) assert WorkflowRegistry(project_dir).get("history-wf") is None assert not ( project_dir / ".specify" / "workflows" / "history-wf" / "workflow.yml" From 7644fbcc9963981d126136863e91e99dc6271646 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:46:12 -0500 Subject: [PATCH 4/4] test: cover workflow release rejection paths Exercise mismatched requirements in standalone YAML releases and reject exact-version selection for URL, local-file, local-directory, and dev sources before download or install. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/test_catalog_versions.py | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index b51529c447..f9828b3278 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -20,6 +20,17 @@ runner = CliRunner() CURRENT_URL = "https://example.com/current.zip" OLD_URL = "https://example.com/old.zip" +OLD_YAML_URL = "https://example.com/old.yml" +OLD_WORKFLOW_YAML = b"""schema_version: "1.0" +workflow: + id: history-wf + name: History Workflow + version: 1.0.0 +steps: + - id: first + type: gate + message: Continue? +""" def _archive(version: str, workflow_id: str = "history-wf", requires=None) -> bytes: @@ -239,26 +250,16 @@ def open_url(url, **kwargs): def test_exact_yaml_release_verifies_digest_and_version(monkeypatch, project_dir): from specify_cli.authentication import http - yaml_content = b"""schema_version: "1.0" -workflow: - id: history-wf - name: History Workflow - version: 1.0.0 -steps: - - id: first - type: gate - message: Continue? -""" entry = _entry() entry["releases"]["1.0.0"] = { - "url": "https://example.com/old.yml", - "sha256": hashlib.sha256(yaml_content).hexdigest(), + "url": OLD_YAML_URL, + "sha256": hashlib.sha256(OLD_WORKFLOW_YAML).hexdigest(), } _catalog(monkeypatch, project_dir, entry) monkeypatch.setattr( http, "open_url", - lambda url, **kw: _Response(yaml_content, url), + lambda url, **kw: _Response(OLD_WORKFLOW_YAML, url), ) monkeypatch.chdir(project_dir) result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "1.0"]) @@ -266,6 +267,35 @@ def test_exact_yaml_release_verifies_digest_and_version(monkeypatch, project_dir assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" +def test_exact_yaml_release_rejects_mismatched_requirements(monkeypatch, project_dir): + from specify_cli.authentication import http + + entry = _entry() + entry["releases"]["1.0.0"] = { + "url": OLD_YAML_URL, + "sha256": hashlib.sha256(OLD_WORKFLOW_YAML).hexdigest(), + "requires": {"integrations": ["copilot"]}, + } + _catalog(monkeypatch, project_dir, entry) + requested = [] + + def open_url(url, **kwargs): + requested.append(url) + return _Response(OLD_WORKFLOW_YAML, url) + + monkeypatch.setattr(http, "open_url", open_url) + monkeypatch.chdir(project_dir) + result = runner.invoke(app, ["workflow", "add", "history-wf", "--version", "1.0.0"]) + + assert result.exit_code == 1 + assert "requirements do not match" in " ".join(result.output.split()) + assert requested == [OLD_YAML_URL] + assert WorkflowRegistry(project_dir).get("history-wf") is None + assert not ( + project_dir / ".specify" / "workflows" / "history-wf" / "workflow.yml" + ).exists() + + @pytest.mark.parametrize("source_type", ["archive", "yaml"]) def test_exact_release_rejects_differently_spelled_artifact_version( monkeypatch, project_dir, source_type @@ -390,3 +420,41 @@ def open_url(url, **kw): assert invalid.exit_code == 1 assert "--version requires a workflow ID" in invalid.output assert requested == [CURRENT_URL] + + +@pytest.mark.parametrize( + "source_type", ["direct-url", "local-file", "local-dir", "dev"] +) +def test_exact_add_rejects_non_catalog_sources(monkeypatch, project_dir, source_type): + from specify_cli.authentication import http + + source = "history-wf" + options = [] + if source_type == "direct-url": + source = OLD_YAML_URL + elif source_type in ("local-file", "local-dir"): + path = project_dir / "local-workflow" + if source_type == "local-dir": + path.mkdir() + (path / "workflow.yml").write_bytes(OLD_WORKFLOW_YAML) + else: + path = path.with_suffix(".yml") + path.write_bytes(OLD_WORKFLOW_YAML) + source = str(path) + else: + options = ["--dev"] + + requested = [] + monkeypatch.setattr(http, "open_url", lambda url, **kwargs: requested.append(url)) + monkeypatch.chdir(project_dir) + result = runner.invoke( + app, ["workflow", "add", source, *options, "--version", "1.0.0"] + ) + + assert result.exit_code == 1 + assert "--version requires a workflow ID from a catalog" in result.output + assert requested == [] + assert WorkflowRegistry(project_dir).get("history-wf") is None + assert not ( + project_dir / ".specify" / "workflows" / "history-wf" / "workflow.yml" + ).exists()