diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 58862ec41a..2ebe4ff5e6 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,37 @@ 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. +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, +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 +410,14 @@ 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 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 de01789d0c..241bddb775 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}, ) @@ -1321,15 +1348,31 @@ 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 + 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..352618fee3 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -692,13 +692,36 @@ 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.""" + 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: - wf.setdefault("id", workflow_id) - return wf + if wf is None: + return None + wf.setdefault("id", workflow_id) + 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 new file mode 100644 index 0000000000..f0d9c1779c --- /dev/null +++ b/src/specify_cli/workflows/catalog/_versions.py @@ -0,0 +1,129 @@ +"""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 ..engine import _is_valid_workflow_version +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." + ) + 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: + 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." + ) + 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: + 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..ff36a1c2a7 100644 --- a/src/specify_cli/workflows/command_info.py +++ b/src/specify_cli/workflows/command_info.py @@ -8,12 +8,38 @@ @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: + 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 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"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 registry = cli._open_workflow_registry(project_root) @@ -92,8 +118,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/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 new file mode 100644 index 0000000000..f9828b3278 --- /dev/null +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -0,0 +1,460 @@ +"""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" +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: + 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}}, + {"\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}, + "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}}, + {"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") + + +@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}} + _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 + 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 +): + 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 + + entry = _entry() + entry["releases"]["1.0.0"] = { + "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(OLD_WORKFLOW_YAML, 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" + + +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 +): + 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"), + [ + (_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 + 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 + 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 " ".join(result.output.split()) + 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] + + +@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()