Skip to content

Commit 40d832f

Browse files
authored
Allow specify workflow run to execute YAML files without a project (#2825)
* Initial plan * feat: add --workflow option to init command for post-init workflow execution * chore: remove unused import in test file * refactor: allow workflow run without project when given a YAML file path Instead of adding --workflow to init, make `specify workflow run ./file.yml` work without requiring a .specify/ project directory. When the source is a YAML file that exists on disk, cwd is used as the project root. When it's a workflow ID, the .specify/ project requirement is preserved. * Handle standalone workflow path edge cases * Fix USERPROFILE env var portability and docs notation * Fix workflow YAML path detection to require regular files * Harden workflow run against unsafe .specify paths --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 659a41a commit 40d832f

4 files changed

Lines changed: 259 additions & 5 deletions

File tree

docs/reference/workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Example:
2020
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
2121
```
2222

23-
> **Note:** All workflow commands require a project already initialized with `specify init`.
23+
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
2424
2525
## Resume a Workflow
2626

src/specify_cli/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2755,12 +2755,28 @@ def workflow_run(
27552755
"""Run a workflow from an installed ID or local YAML path."""
27562756
from .workflows.engine import WorkflowEngine
27572757

2758-
project_root = _require_specify_project()
2758+
source_path = Path(source).expanduser()
2759+
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
2760+
2761+
if is_file_source:
2762+
# When running a YAML file directly, use cwd as project root
2763+
# without requiring a .specify/ project directory.
2764+
project_root = Path.cwd()
2765+
specify_dir = project_root / ".specify"
2766+
if specify_dir.is_symlink():
2767+
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
2768+
raise typer.Exit(1)
2769+
if specify_dir.exists() and not specify_dir.is_dir():
2770+
console.print("[red]Error:[/red] .specify path exists but is not a directory")
2771+
raise typer.Exit(1)
2772+
else:
2773+
project_root = _require_specify_project()
2774+
27592775
engine = WorkflowEngine(project_root)
27602776
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27612777

27622778
try:
2763-
definition = engine.load_workflow(source)
2779+
definition = engine.load_workflow(source_path if is_file_source else source)
27642780
except FileNotFoundError:
27652781
console.print(f"[red]Error:[/red] Workflow not found: {source}")
27662782
raise typer.Exit(1)

src/specify_cli/workflows/engine.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,10 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition:
449449
ValueError:
450450
If the workflow YAML is invalid.
451451
"""
452-
path = Path(source)
452+
path = Path(source).expanduser()
453453

454454
# Try as a direct file path first
455-
if path.suffix in (".yml", ".yaml") and path.exists():
455+
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
456456
return WorkflowDefinition.from_yaml(path)
457457

458458
# Try as an installed workflow ID
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Tests for running workflow YAML files without a project."""
2+
3+
import os
4+
5+
import pytest
6+
import yaml
7+
8+
9+
class TestWorkflowRunWithoutProject:
10+
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
11+
12+
def test_workflow_run_yaml_without_project(self, tmp_path):
13+
"""Running a .yml file should work without a .specify/ directory."""
14+
from typer.testing import CliRunner
15+
from specify_cli import app
16+
17+
runner = CliRunner()
18+
19+
# Create a minimal workflow YAML with a shell step
20+
workflow_file = tmp_path / "test-workflow.yml"
21+
workflow_content = {
22+
"schema_version": "1.0",
23+
"workflow": {
24+
"id": "standalone-test",
25+
"name": "Standalone Test",
26+
"version": "1.0.0",
27+
"description": "A workflow that runs without a project",
28+
},
29+
"steps": [
30+
{
31+
"id": "create-marker",
32+
"type": "shell",
33+
"run": "echo done > marker.txt",
34+
},
35+
],
36+
}
37+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
38+
39+
old_cwd = os.getcwd()
40+
try:
41+
os.chdir(tmp_path)
42+
result = runner.invoke(app, [
43+
"workflow", "run", str(workflow_file),
44+
], catch_exceptions=False)
45+
finally:
46+
os.chdir(old_cwd)
47+
assert result.exit_code == 0, f"workflow run failed: {result.output}"
48+
assert "completed" in result.output
49+
assert (tmp_path / "marker.txt").exists()
50+
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
51+
52+
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
53+
"""Running ~/file.YML should work without a .specify/ directory."""
54+
from typer.testing import CliRunner
55+
from specify_cli import app
56+
57+
runner = CliRunner()
58+
59+
home_dir = tmp_path / "home"
60+
home_dir.mkdir()
61+
monkeypatch.setenv("HOME", str(home_dir))
62+
monkeypatch.setenv("USERPROFILE", str(home_dir))
63+
64+
workflow_file = home_dir / "test-workflow.YML"
65+
workflow_content = {
66+
"schema_version": "1.0",
67+
"workflow": {
68+
"id": "standalone-test-uppercase",
69+
"name": "Standalone Test Uppercase",
70+
"version": "1.0.0",
71+
"description": "A workflow that runs from ~/ with an uppercase suffix",
72+
},
73+
"steps": [
74+
{
75+
"id": "create-marker",
76+
"type": "shell",
77+
"run": "echo done > marker.txt",
78+
},
79+
],
80+
}
81+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
82+
83+
old_cwd = os.getcwd()
84+
try:
85+
os.chdir(tmp_path)
86+
result = runner.invoke(app, [
87+
"workflow", "run", "~/test-workflow.YML",
88+
], catch_exceptions=False)
89+
finally:
90+
os.chdir(old_cwd)
91+
assert result.exit_code == 0, f"workflow run failed: {result.output}"
92+
assert "Status: completed" in result.output
93+
assert (tmp_path / "marker.txt").exists()
94+
95+
def test_workflow_run_id_still_requires_project(self, tmp_path):
96+
"""Running a workflow by ID should still require a .specify/ directory."""
97+
from typer.testing import CliRunner
98+
from specify_cli import app
99+
100+
runner = CliRunner()
101+
102+
old_cwd = os.getcwd()
103+
try:
104+
os.chdir(tmp_path)
105+
result = runner.invoke(app, [
106+
"workflow", "run", "some-workflow-id",
107+
], catch_exceptions=False)
108+
finally:
109+
os.chdir(old_cwd)
110+
assert result.exit_code != 0
111+
assert "Not a spec-kit project" in result.output
112+
113+
def test_workflow_run_missing_yaml_file(self, tmp_path):
114+
"""Running a non-existent .yml file should still require a project."""
115+
from typer.testing import CliRunner
116+
from specify_cli import app
117+
118+
runner = CliRunner()
119+
120+
old_cwd = os.getcwd()
121+
try:
122+
os.chdir(tmp_path)
123+
result = runner.invoke(app, [
124+
"workflow", "run", "nonexistent.yml",
125+
], catch_exceptions=False)
126+
finally:
127+
os.chdir(old_cwd)
128+
# non-existent .yml files fall through to project check or file-not-found
129+
assert result.exit_code != 0
130+
131+
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
132+
"""A failing workflow YAML should report failure status."""
133+
from typer.testing import CliRunner
134+
from specify_cli import app
135+
136+
runner = CliRunner()
137+
138+
workflow_file = tmp_path / "fail-workflow.yml"
139+
workflow_content = {
140+
"schema_version": "1.0",
141+
"workflow": {
142+
"id": "fail-test",
143+
"name": "Fail Test",
144+
"version": "1.0.0",
145+
"description": "A workflow that fails",
146+
},
147+
"steps": [
148+
{
149+
"id": "fail-step",
150+
"type": "shell",
151+
"run": "exit 1",
152+
},
153+
],
154+
}
155+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
156+
157+
old_cwd = os.getcwd()
158+
try:
159+
os.chdir(tmp_path)
160+
result = runner.invoke(app, [
161+
"workflow", "run", str(workflow_file),
162+
], catch_exceptions=False)
163+
finally:
164+
os.chdir(old_cwd)
165+
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
166+
assert "Status: failed" in result.output
167+
168+
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
169+
"""Running local YAML should fail when .specify is a symlink."""
170+
from typer.testing import CliRunner
171+
from specify_cli import app
172+
173+
runner = CliRunner()
174+
175+
workflow_file = tmp_path / "test-workflow.yml"
176+
workflow_content = {
177+
"schema_version": "1.0",
178+
"workflow": {
179+
"id": "symlink-test",
180+
"name": "Symlink Test",
181+
"version": "1.0.0",
182+
"description": "A workflow for symlink guard testing",
183+
},
184+
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
185+
}
186+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
187+
188+
target_dir = tmp_path / "real-specify-dir"
189+
target_dir.mkdir()
190+
try:
191+
(tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True)
192+
except (OSError, NotImplementedError):
193+
pytest.skip("Symlinks are not available in this environment")
194+
195+
old_cwd = os.getcwd()
196+
try:
197+
os.chdir(tmp_path)
198+
result = runner.invoke(app, [
199+
"workflow", "run", str(workflow_file),
200+
], catch_exceptions=False)
201+
finally:
202+
os.chdir(old_cwd)
203+
204+
assert result.exit_code != 0
205+
assert "Refusing to use symlinked .specify path in current directory" in result.output
206+
207+
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
208+
"""Running local YAML should fail when .specify is not a directory."""
209+
from typer.testing import CliRunner
210+
from specify_cli import app
211+
212+
runner = CliRunner()
213+
214+
workflow_file = tmp_path / "test-workflow.yml"
215+
workflow_content = {
216+
"schema_version": "1.0",
217+
"workflow": {
218+
"id": "nondir-test",
219+
"name": "Non-directory Test",
220+
"version": "1.0.0",
221+
"description": "A workflow for non-directory guard testing",
222+
},
223+
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
224+
}
225+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
226+
(tmp_path / ".specify").write_text("not a directory", encoding="utf-8")
227+
228+
old_cwd = os.getcwd()
229+
try:
230+
os.chdir(tmp_path)
231+
result = runner.invoke(app, [
232+
"workflow", "run", str(workflow_file),
233+
], catch_exceptions=False)
234+
finally:
235+
os.chdir(old_cwd)
236+
237+
assert result.exit_code != 0
238+
assert ".specify path exists but is not a directory" in result.output

0 commit comments

Comments
 (0)