mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(workflow): centralize catalog-install cleanup across all failure branches
_install_workflow_from_catalog is new in this PR and has seven failure branches after the mkdir/download step, each independently rmtree'ing workflow_dir: redirect-to-non-HTTPS rejection, a generic download exception, invalid downloaded YAML, a validate_workflow failure, a workflow-id/catalog-key mismatch, a version mismatch, and (fixed in the prior commit) a registry.add() OSError. Only the last one had been special-cased to spare a prior working install on reinstall; the other six still unconditionally deleted the whole directory, so re-adding an already-installed catalog workflow and hitting any of those six earlier failures destroyed the working install even though nothing about it had actually changed. Replaced all seven ad hoc rmtree call sites with a single local _cleanup_failed_install() helper that closes over the existed_before / prior_workflow_bytes captured once at the top of the function: restore the prior workflow.yml for a reinstall, or rmtree only a directory that this attempt itself created. Every failure branch now calls this one helper, so the fix is structural rather than duplicated, and every existing error message/exit code is unchanged -- only the cleanup performed before each message is different. Added a parametrized regression test covering the four early-failure trigger points reachable from plain workflow add (redirect rejection, download exception, invalid YAML, ID mismatch): each installs a catalog workflow, re-adds it while forcing that specific failure, and asserts a clean error plus the original workflow.yml surviving byte-for-byte. Confirmed red against the unfixed code (all four raised FileNotFoundError reading the deleted file) before applying the helper, green after. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7847,6 +7847,80 @@ steps:
|
||||
assert registry.is_installed("align-wf")
|
||||
assert registry.get("align-wf")["version"] == "1.0.0"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode", ["redirect_rejected", "download_exception", "invalid_yaml", "id_mismatch"]
|
||||
)
|
||||
def test_add_catalog_reinstall_early_failure_restores_prior_file(
|
||||
self, project_dir, monkeypatch, mode
|
||||
):
|
||||
"""Every _install_workflow_from_catalog failure branch that runs after
|
||||
the mkdir/download step -- not just the registry.add() OSError case
|
||||
-- must route through the same existed-before/backup-aware cleanup:
|
||||
on a reinstall, a redirect rejection, a download exception, invalid
|
||||
YAML, or a workflow-id mismatch must restore the prior working
|
||||
workflow.yml rather than deleting the whole directory. One shared
|
||||
root cause (the cleanup helper), so parametrized over trigger point."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog,
|
||||
"get_workflow_info",
|
||||
lambda self, wid: {
|
||||
"id": wid,
|
||||
"name": "Align Workflow",
|
||||
"version": "1.0.0",
|
||||
"url": "https://example.com/workflow.yml",
|
||||
"_install_allowed": True,
|
||||
"_catalog_name": "test-catalog",
|
||||
},
|
||||
)
|
||||
original_data = self.WORKFLOW_YAML.format(version="1.0.0").encode()
|
||||
runner = CliRunner()
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(
|
||||
original_data, url
|
||||
),
|
||||
)
|
||||
result = runner.invoke(app, ["workflow", "add", "align-wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
dest_file = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml"
|
||||
assert dest_file.read_bytes() == original_data
|
||||
|
||||
if mode == "redirect_rejected":
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
return self._FakeResponse(b"irrelevant", "http://evil.example.com/workflow.yml")
|
||||
elif mode == "download_exception":
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
raise OSError("network down")
|
||||
elif mode == "invalid_yaml":
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
return self._FakeResponse(b": : not valid yaml: [", url)
|
||||
else: # id_mismatch
|
||||
mismatched_yaml = self.WORKFLOW_YAML.format(version="2.0.0").replace(
|
||||
'id: "align-wf"', 'id: "different-workflow"'
|
||||
)
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
return self._FakeResponse(mismatched_yaml.encode(), url)
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
result = runner.invoke(app, ["workflow", "add", "align-wf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert result.output.strip() != ""
|
||||
assert dest_file.read_bytes() == original_data
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
assert registry.is_installed("align-wf")
|
||||
assert registry.get("align-wf")["version"] == "1.0.0"
|
||||
|
||||
def test_download_redirect_validator_rejects_http_before_follow(self):
|
||||
import urllib.error
|
||||
|
||||
|
||||
Reference in New Issue
Block a user