mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* refactor: move workflow command handlers to workflows/_commands.py (PR-8/8) Final PR of the __init__.py split. Moves the workflow command group out of __init__.py into the existing workflows/ package, completing the domain-dir layout established in PR-5 (integrations), PR-6 (presets) and PR-7 (extensions). - New workflows/_commands.py holds the four Typer apps (workflow / catalog / step / step-catalog), all 25 command handlers, the six workflow-only helpers (_parse_input_values, _workflow_run_payload, _emit_workflow_json, _stdout_to_stderr_when, _validate_step_id_or_exit, _resolve_steps_base_dir_or_exit), and a register(app) entry point. - workflows is already a package, so no rename is needed; intra-package imports change from `.workflows.x` to `.x`. The only root-helper dep (_require_specify_project) is reached through a call-time shim so test monkeypatching of specify_cli._require_specify_project keeps working. - __init__.py drops ~1445 lines (2066 -> 621); the workflow group is re-attached via register(app). Dead `contextlib` import removed. - tests/test_workflows.py: import the now-relocated _stdout_to_stderr_when helper from its new home (workflows._commands) instead of the package root. No behavior change. Full suite green (3847 passed), ruff clean. * Prevent workflow state writes through symlinked storage Workflow commands persist run state under .specify/workflows/runs, so the command-local project shim now rejects symlinked workflow storage before any workflow command proceeds. The standalone YAML path uses the same guard because it intentionally bypasses the normal project requirement while still creating workflow state under the current directory. Constraint: Local YAML workflow runs do not require an existing .specify project directory but still create .specify/workflows/runs state Rejected: Guard only .specify in the file-source path | .specify/workflows and runs can independently redirect writes Confidence: high Scope-risk: narrow Directive: Keep workflow storage symlink checks centralized before constructing WorkflowEngine Tested: .venv/bin/python -m pytest tests/test_workflow_run_without_project.py tests/test_workflows.py::TestWorkflowAddSymlinkGuard -v Tested: .venv/bin/python -m py_compile src/specify_cli/workflows/_commands.py tests/test_workflow_run_without_project.py tests/test_workflows.py Not-tested: Ruff lint; ruff is not installed in the repo virtualenv Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * fix(workflows): pass github_hosts allowlist to GHES release asset resolver workflow add resolved GitHub release download URLs without forwarding the github_provider_hosts() allowlist, so resolve_github_release_asset_api_url never treated any host as GHES. This regressed GitHub Enterprise Server release asset resolution and diverged from presets/extensions, which already pass github_hosts. Forward github_provider_hosts() at both the direct-URL and catalog install call sites. The allowlist remains the anti-SSRF gate. * fix(workflows): reject symlinked/traversal <id> dir on workflow install Local/URL and catalog installs wrote to .specify/workflows/<id>/workflow.yml without guarding the <id> segment. A pre-planted symlink at <id> or <id>/workflow.yml let mkdir+copy/download follow it and write outside the project root; a non-directory <id> made mkdir raise unhandled. Add _safe_workflow_id_dir() to reject path traversal, symlinked or non-directory <id>, and a symlinked workflow.yml leaf before any write. Fold the catalog branch's existing traversal check into the helper. * fix(workflows): harden _safe_workflow_id_dir output and leaf checks - Reorder symlink/non-directory check before resolve() so a symlinked <id> reports as symlinked instead of misleading "Invalid workflow ID" - Reject a pre-existing <id>/workflow.yml that is not a file, avoiding an unhandled IsADirectoryError on later write/copy2 - Escape workflow_id in Rich output to prevent markup injection; escape the repr (not the raw id) so repr-added backslashes cannot re-expose brackets, matching extensions/_commands.py hardening - Add tests for workflow.yml-as-directory and markup-escaped invalid id * Avoid stale lint failures from config helper imports Move PyYAML loading into the helpers that read and write agent-context configuration, and replace the broad Any annotation with object. The runtime behavior stays the same while the module no longer exposes top-level imports that can be flagged as unused when CI analyzes a narrower code shape. * Prevent workflow commands from targeting reserved storage Workflow install and removal paths are derived from workflow IDs before any catalog download, local copy, or directory deletion. Validate that IDs are single workflow-id path segments and reject names reserved for workflow runtime storage so commands cannot target .specify/workflows/runs or .specify/workflows/steps.
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""Tests for running workflow YAML files without a project."""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
class TestWorkflowRunWithoutProject:
|
|
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
|
|
|
|
def test_workflow_run_yaml_without_project(self, tmp_path):
|
|
"""Running a .yml file should work without a .specify/ directory."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Create a minimal workflow YAML with a shell step
|
|
workflow_file = tmp_path / "test-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "standalone-test",
|
|
"name": "Standalone Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow that runs without a project",
|
|
},
|
|
"steps": [
|
|
{
|
|
"id": "create-marker",
|
|
"type": "shell",
|
|
"run": "echo done > marker.txt",
|
|
},
|
|
],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"workflow run failed: {result.output}"
|
|
assert "completed" in result.output
|
|
assert (tmp_path / "marker.txt").exists()
|
|
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
|
|
|
|
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
|
|
"""Running ~/file.YML should work without a .specify/ directory."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
home_dir = tmp_path / "home"
|
|
home_dir.mkdir()
|
|
monkeypatch.setenv("HOME", str(home_dir))
|
|
monkeypatch.setenv("USERPROFILE", str(home_dir))
|
|
|
|
workflow_file = home_dir / "test-workflow.YML"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "standalone-test-uppercase",
|
|
"name": "Standalone Test Uppercase",
|
|
"version": "1.0.0",
|
|
"description": "A workflow that runs from ~/ with an uppercase suffix",
|
|
},
|
|
"steps": [
|
|
{
|
|
"id": "create-marker",
|
|
"type": "shell",
|
|
"run": "echo done > marker.txt",
|
|
},
|
|
],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", "~/test-workflow.YML",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"workflow run failed: {result.output}"
|
|
assert "Status: completed" in result.output
|
|
assert (tmp_path / "marker.txt").exists()
|
|
|
|
def test_workflow_run_id_still_requires_project(self, tmp_path):
|
|
"""Running a workflow by ID should still require a .specify/ directory."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", "some-workflow-id",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Not a spec-kit project" in result.output
|
|
|
|
def test_workflow_run_missing_yaml_file(self, tmp_path):
|
|
"""Running a non-existent .yml file should still require a project."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", "nonexistent.yml",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
# non-existent .yml files fall through to project check or file-not-found
|
|
assert result.exit_code != 0
|
|
|
|
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
|
|
"""A failing workflow YAML should report failure status."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
workflow_file = tmp_path / "fail-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "fail-test",
|
|
"name": "Fail Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow that fails",
|
|
},
|
|
"steps": [
|
|
{
|
|
"id": "fail-step",
|
|
"type": "shell",
|
|
"run": "exit 1",
|
|
},
|
|
],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
# A failed workflow now maps to a non-zero process exit code so
|
|
# scripts and CI can rely on $? (the CLI itself still ran fine).
|
|
assert result.exit_code == 1, f"expected exit 1 on failed run: {result.output}"
|
|
assert "Status: failed" in result.output
|
|
|
|
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
|
|
"""Running local YAML should fail when .specify is a symlink."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
workflow_file = tmp_path / "test-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "symlink-test",
|
|
"name": "Symlink Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow for symlink guard testing",
|
|
},
|
|
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
target_dir = tmp_path / "real-specify-dir"
|
|
target_dir.mkdir()
|
|
try:
|
|
(tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Refusing to use symlinked .specify path" in result.output
|
|
|
|
def test_workflow_run_yaml_rejects_symlinked_workflows_dir(self, tmp_path):
|
|
"""Running local YAML should fail when .specify/workflows is a symlink."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
workflow_file = tmp_path / "test-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "symlink-workflows-test",
|
|
"name": "Symlink Workflows Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow for symlink guard testing",
|
|
},
|
|
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
(tmp_path / ".specify").mkdir()
|
|
target_dir = tmp_path / "real-workflows-dir"
|
|
target_dir.mkdir()
|
|
try:
|
|
(tmp_path / ".specify" / "workflows").symlink_to(
|
|
target_dir, target_is_directory=True
|
|
)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Refusing to use symlinked .specify/workflows path" in result.output
|
|
|
|
def test_workflow_run_yaml_rejects_symlinked_runs_dir(self, tmp_path):
|
|
"""Running local YAML should fail when .specify/workflows/runs is a symlink."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
workflow_file = tmp_path / "test-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "symlink-runs-test",
|
|
"name": "Symlink Runs Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow for symlink guard testing",
|
|
},
|
|
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
|
|
(tmp_path / ".specify" / "workflows").mkdir(parents=True)
|
|
target_dir = tmp_path / "real-runs-dir"
|
|
target_dir.mkdir()
|
|
try:
|
|
(tmp_path / ".specify" / "workflows" / "runs").symlink_to(
|
|
target_dir, target_is_directory=True
|
|
)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Refusing to use symlinked .specify/workflows/runs path" in result.output
|
|
|
|
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
|
|
"""Running local YAML should fail when .specify is not a directory."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
workflow_file = tmp_path / "test-workflow.yml"
|
|
workflow_content = {
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": "nondir-test",
|
|
"name": "Non-directory Test",
|
|
"version": "1.0.0",
|
|
"description": "A workflow for non-directory guard testing",
|
|
},
|
|
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
|
}
|
|
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
|
(tmp_path / ".specify").write_text("not a directory", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"workflow", "run", str(workflow_file),
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
assert ".specify path exists but is not a directory" in result.output
|