mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
feat(workflows): expose workflow source directory to steps (#3469)
* feat(workflows): expose workflow source directory to steps (#3467) Propagate WorkflowDefinition.source_path to steps via {{ context.workflow_dir }} in template expressions and SPECKIT_WORKFLOW_DIR env var for shell steps. The original source directory is persisted in state.json so resume restores the correct value instead of the run-directory copy path. Closes #3467 Assisted-By: 🤖 Claude Code * fix: apply bot review suggestions (#2) Applied fixes from bot review comments: - Comment #3563319058: prevent stale SPECKIT_WORKFLOW_DIR leak from parent env - Comment #3563319094: use cross-platform Python one-liner instead of printenv - Comment #3563319103: add monkeypatch.delenv for deterministic env var test - Comment #3563319116: same env leak fix as #3563319058 Assisted-By: 🤖 Claude Code * fix: use YAML single-quotes and forward-slash paths for Windows CI (#2) sys.executable on Windows returns backslash paths (D:\a\...) which YAML double-quoted strings interpret as escape sequences. Switch to single-quoted YAML strings and normalize paths with replace("\\", "/"). Assisted-By: 🤖 Claude Code * fix: resolve workflow_dir to absolute path and add installed-by-ID test (#3469) Applied fixes from bot review comments: - Comment #3563382853: resolve source_path before taking parent to ensure absolute paths - Comment #3563382864: add test for installed-by-ID workflow_dir semantics Assisted-By: 🤖 Claude Code * docs: document context.workflow_dir and SPECKIT_WORKFLOW_DIR Add reference documentation for the new workflow_dir runtime value in both workflows/README.md and docs/reference/workflows.md so workflow authors can discover the feature and its semantics. Assisted-By: 🤖 Claude Code * fix: clarify installed workflow_dir is an absolute path (#3469) The documentation for context.workflow_dir described the installed-by-ID case as ".specify/workflows/<id>/" which appears relative, contradicting the "resolved absolute path" semantics. Clarified that it is the absolute path to the installation directory. Assisted-By: 🤖 Claude Code * fix: apply bot review suggestions (#3469) Applied fixes from bot review comments: - Comment #3580005128: Quote sys.executable in shell step env var test - Comment #3580005174: Quote sys.executable in no-env-var test Assisted-By: 🤖 Claude Code * fix: apply bot review suggestions (#3469) Applied fixes from bot review comments: - Comment #3587146944: Quote interpolated workflow_dir path in example Assisted-By: 🤖 Claude Code
This commit is contained in:
@@ -305,6 +305,8 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
|
||||
| `inputs.spec` | Workflow input values |
|
||||
| `steps.specify.output.file` | Output from a previous step |
|
||||
| `item` | Current item in a fan-out iteration |
|
||||
| `context.run_id` | Current workflow run ID |
|
||||
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
|
||||
|
||||
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
|
||||
|
||||
@@ -316,6 +318,14 @@ args: "{{ inputs.spec }}"
|
||||
message: "{{ status | default('pending') }}"
|
||||
```
|
||||
|
||||
## Shell Step Environment Variables
|
||||
|
||||
Shell steps automatically receive the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |
|
||||
|
||||
## Input Types
|
||||
|
||||
| Type | Coercion |
|
||||
|
||||
@@ -74,6 +74,9 @@ class StepContext:
|
||||
#: Current run ID.
|
||||
run_id: str | None = None
|
||||
|
||||
#: Source directory of the workflow definition file.
|
||||
workflow_dir: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
|
||||
@@ -508,6 +508,7 @@ class RunState:
|
||||
# append_log is never called while _lock is held, the two never nest.
|
||||
self._log_lock = threading.Lock()
|
||||
self.inputs: dict[str, Any] = {}
|
||||
self.workflow_dir: str | None = None
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
self.log_entries: list[dict[str, Any]] = []
|
||||
@@ -562,6 +563,7 @@ class RunState:
|
||||
"current_step_index": self.current_step_index,
|
||||
"current_step_id": self.current_step_id,
|
||||
"step_results": self.step_results,
|
||||
"workflow_dir": self.workflow_dir,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
@@ -654,6 +656,7 @@ class RunState:
|
||||
state.current_step_index = state_data.get("current_step_index", 0)
|
||||
state.current_step_id = state_data.get("current_step_id")
|
||||
state.step_results = state_data.get("step_results", {})
|
||||
state.workflow_dir = state_data.get("workflow_dir")
|
||||
state.created_at = state_data.get("created_at", "")
|
||||
state.updated_at = state_data.get("updated_at", "")
|
||||
|
||||
@@ -810,6 +813,12 @@ class WorkflowEngine:
|
||||
# Resolve inputs
|
||||
resolved_inputs = self._resolve_inputs(definition, inputs or {})
|
||||
state.inputs = resolved_inputs
|
||||
workflow_dir = (
|
||||
str(definition.source_path.resolve().parent)
|
||||
if definition.source_path is not None
|
||||
else None
|
||||
)
|
||||
state.workflow_dir = workflow_dir
|
||||
state.status = RunStatus.RUNNING
|
||||
state.save()
|
||||
|
||||
@@ -820,6 +829,7 @@ class WorkflowEngine:
|
||||
default_options=definition.default_options,
|
||||
project_root=str(self.project_root),
|
||||
run_id=state.run_id,
|
||||
workflow_dir=workflow_dir,
|
||||
)
|
||||
|
||||
# Execute steps
|
||||
@@ -885,6 +895,7 @@ class WorkflowEngine:
|
||||
default_options=definition.default_options,
|
||||
project_root=str(self.project_root),
|
||||
run_id=state.run_id,
|
||||
workflow_dir=state.workflow_dir,
|
||||
)
|
||||
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
@@ -142,7 +142,8 @@ def _build_namespace(context: Any) -> dict[str, Any]:
|
||||
# runs use an 8-character uuid4 hex; operator-supplied ids may be
|
||||
# any alphanumeric string with hyphens or underscores.
|
||||
run_id = getattr(context, "run_id", None) or ""
|
||||
ns["context"] = {"run_id": run_id}
|
||||
workflow_dir = getattr(context, "workflow_dir", None) or ""
|
||||
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
|
||||
return ns
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -40,6 +41,13 @@ class ShellStep(StepBase):
|
||||
error=timeout_error,
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
|
||||
)
|
||||
|
||||
env = {**os.environ}
|
||||
if context.workflow_dir:
|
||||
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
|
||||
else:
|
||||
env.pop("SPECKIT_WORKFLOW_DIR", None)
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
# control commands; catalog-installed workflows should be reviewed
|
||||
@@ -51,6 +59,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
|
||||
@@ -4516,6 +4516,286 @@ steps:
|
||||
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
|
||||
|
||||
|
||||
# ===== context.workflow_dir Tests =====
|
||||
|
||||
|
||||
class TestContextWorkflowDir:
|
||||
"""Tests for `{{ context.workflow_dir }}` and `SPECKIT_WORKFLOW_DIR`."""
|
||||
|
||||
def test_context_workflow_dir_resolves(self):
|
||||
"""``{{ context.workflow_dir }}`` resolves to ``StepContext.workflow_dir``."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(workflow_dir="/home/user/my-workflow")
|
||||
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == "/home/user/my-workflow"
|
||||
|
||||
def test_context_workflow_dir_defaults_to_empty_when_unset(self):
|
||||
"""``{{ context.workflow_dir }}`` resolves to ``""`` when no source
|
||||
path is available (string-loaded workflows, dry-run).
|
||||
"""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext()
|
||||
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == ""
|
||||
|
||||
def test_context_workflow_dir_string_interpolation(self):
|
||||
"""Workflow dir interpolates inside a larger template string."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(workflow_dir="/opt/workflows/setup")
|
||||
result = evaluate_expression("cp {{ context.workflow_dir }}/config.yml .", ctx)
|
||||
assert result == "cp /opt/workflows/setup/config.yml ."
|
||||
|
||||
def test_step_context_workflow_dir(self):
|
||||
"""StepContext accepts and stores workflow_dir."""
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(workflow_dir="/some/path")
|
||||
assert ctx.workflow_dir == "/some/path"
|
||||
|
||||
ctx_none = StepContext()
|
||||
assert ctx_none.workflow_dir is None
|
||||
|
||||
def test_from_yaml_sets_workflow_dir(self, project_dir):
|
||||
"""Workflow loaded from a YAML file has workflow_dir set to the
|
||||
file's parent directory.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
|
||||
wf_dir = project_dir / "my-workflows"
|
||||
wf_dir.mkdir()
|
||||
wf_file = wf_dir / "setup.yml"
|
||||
wf_file.write_text("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "from-yaml"
|
||||
name: "From YAML"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: check-dir
|
||||
type: shell
|
||||
run: "echo DIR={{ context.workflow_dir }}"
|
||||
""")
|
||||
definition = WorkflowDefinition.from_yaml(wf_file)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
stdout = state.step_results["check-dir"]["output"]["stdout"]
|
||||
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
|
||||
|
||||
def test_from_string_has_empty_workflow_dir(self, project_dir):
|
||||
"""String-loaded workflows have empty workflow_dir."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "from-string"
|
||||
name: "From String"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: check-dir
|
||||
type: shell
|
||||
run: "echo DIR={{ context.workflow_dir }}"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
stdout = state.step_results["check-dir"]["output"]["stdout"]
|
||||
assert stdout.strip() == "DIR="
|
||||
|
||||
def test_shell_step_receives_speckit_workflow_dir_env_var(self, project_dir):
|
||||
"""Shell steps receive SPECKIT_WORKFLOW_DIR in their environment."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
import sys
|
||||
|
||||
wf_dir = project_dir / "wf"
|
||||
wf_dir.mkdir()
|
||||
wf_file = wf_dir / "workflow.yml"
|
||||
python = sys.executable.replace("\\", "/")
|
||||
wf_file.write_text(f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "env-var-test"
|
||||
name: "Env Var Test"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: print-env
|
||||
type: shell
|
||||
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
|
||||
""")
|
||||
definition = WorkflowDefinition.from_yaml(wf_file)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
stdout = state.step_results["print-env"]["output"]["stdout"]
|
||||
assert stdout.strip() == str(wf_dir.resolve())
|
||||
|
||||
def test_shell_step_no_env_var_when_workflow_dir_unset(self, project_dir, monkeypatch):
|
||||
"""Shell steps do not set SPECKIT_WORKFLOW_DIR for string-loaded workflows."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
import sys
|
||||
|
||||
monkeypatch.delenv("SPECKIT_WORKFLOW_DIR", raising=False)
|
||||
|
||||
python = sys.executable.replace("\\", "/")
|
||||
definition = WorkflowDefinition.from_string(f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "no-env-var"
|
||||
name: "No Env Var"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: check-env
|
||||
type: shell
|
||||
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
stdout = state.step_results["check-env"]["output"]["stdout"]
|
||||
assert stdout.strip() == "UNSET"
|
||||
|
||||
def test_resume_preserves_original_workflow_dir(self, project_dir):
|
||||
"""Resumed workflow uses the original source directory, not the
|
||||
run-directory copy path.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
wf_dir = project_dir / "original-source"
|
||||
wf_dir.mkdir()
|
||||
wf_file = wf_dir / "resumable.yml"
|
||||
wf_file.write_text("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "resumable"
|
||||
name: "Resumable"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: gate-step
|
||||
type: gate
|
||||
message: "Approve?"
|
||||
- id: after-gate
|
||||
type: shell
|
||||
run: "echo DIR={{ context.workflow_dir }}"
|
||||
""")
|
||||
definition = WorkflowDefinition.from_yaml(wf_file)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
|
||||
# Execute -- gate pauses the workflow
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.PAUSED
|
||||
assert state.workflow_dir == str(wf_dir.resolve())
|
||||
|
||||
# Simulate gate approval by patching the gate step
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.base import StepResult
|
||||
|
||||
with patch(
|
||||
"specify_cli.workflows.steps.gate.GateStep.execute",
|
||||
return_value=StepResult(output={"approved": True}),
|
||||
):
|
||||
state = engine.resume(state.run_id)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
stdout = state.step_results["after-gate"]["output"]["stdout"]
|
||||
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
|
||||
|
||||
def test_workflow_dir_persisted_in_state(self, project_dir):
|
||||
"""workflow_dir is persisted in state.json and survives load/save."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, RunState
|
||||
|
||||
wf_dir = project_dir / "persist-test"
|
||||
wf_dir.mkdir()
|
||||
wf_file = wf_dir / "workflow.yml"
|
||||
wf_file.write_text("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "persist-wfdir"
|
||||
name: "Persist WfDir"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: noop
|
||||
type: shell
|
||||
run: "echo ok"
|
||||
""")
|
||||
definition = WorkflowDefinition.from_yaml(wf_file)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
# Reload state from disk and verify workflow_dir survived
|
||||
loaded = RunState.load(state.run_id, project_dir)
|
||||
assert loaded.workflow_dir == str(wf_dir.resolve())
|
||||
|
||||
def test_installed_workflow_has_workflow_dir(self, project_dir):
|
||||
"""Installed-by-ID workflows get workflow_dir pointing to the
|
||||
installation directory (.specify/workflows/<id>/).
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
wf_id = "installed-wfdir"
|
||||
install_dir = project_dir / ".specify" / "workflows" / wf_id
|
||||
install_dir.mkdir(parents=True)
|
||||
(install_dir / "workflow.yml").write_text("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "installed-wfdir"
|
||||
name: "Installed WfDir"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: check-dir
|
||||
type: shell
|
||||
run: "echo DIR={{ context.workflow_dir }}"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
definition = engine.load_workflow(wf_id)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
stdout = state.step_results["check-dir"]["output"]["stdout"]
|
||||
assert stdout.strip() == f"DIR={install_dir.resolve()}"
|
||||
|
||||
def test_workflow_dir_is_resolved_to_absolute(self, project_dir):
|
||||
"""workflow_dir is resolved to an absolute path even when the
|
||||
source path is relative.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
import os
|
||||
|
||||
wf_dir = project_dir / "rel-test"
|
||||
wf_dir.mkdir()
|
||||
wf_file = wf_dir / "workflow.yml"
|
||||
wf_file.write_text("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "rel-path"
|
||||
name: "Relative Path"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: check
|
||||
type: shell
|
||||
run: "echo ok"
|
||||
""")
|
||||
# Load via a relative path
|
||||
saved_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_dir)
|
||||
rel_path = Path("rel-test/workflow.yml")
|
||||
definition = WorkflowDefinition.from_yaml(rel_path)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
finally:
|
||||
os.chdir(saved_cwd)
|
||||
|
||||
assert Path(state.workflow_dir).is_absolute()
|
||||
assert state.workflow_dir == str(wf_dir.resolve())
|
||||
|
||||
|
||||
# ===== continue_on_error Tests =====
|
||||
#
|
||||
# Locks the contract documented in workflows/README.md "Error Handling"
|
||||
|
||||
@@ -348,6 +348,7 @@ current run:
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `context.run_id` | The current workflow run id (the same value Spec Kit prints as `Run ID:` at the end of `workflow run`). Auto-generated runs are 8-character hex from `uuid4`; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
|
||||
| `context.workflow_dir` | The resolved absolute path to the directory containing the workflow source file. For file-loaded workflows this is the parent directory of the YAML file; for installed-by-ID workflows it is the absolute path to the installation directory (e.g. `<project>/.specify/workflows/<id>/`); for string-loaded workflows it is an empty string. On resume the original source directory is preserved from the first execution. |
|
||||
|
||||
```yaml
|
||||
# Stamp telemetry events with the run id for cross-system join.
|
||||
@@ -365,6 +366,11 @@ current run:
|
||||
command: speckit.specify
|
||||
input:
|
||||
args: "{{ context.run_id }}"
|
||||
|
||||
# Reference a sibling file shipped alongside the workflow definition.
|
||||
- id: apply-config
|
||||
type: shell
|
||||
run: 'cp "{{ context.workflow_dir }}/defaults.yml" ./config.yml'
|
||||
```
|
||||
|
||||
## Input Types
|
||||
@@ -445,6 +451,7 @@ specify workflow catalog remove <index>
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
|
||||
| `SPECKIT_WORKFLOW_DIR` | Set automatically for shell steps; contains the resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path (string-loaded workflows). |
|
||||
|
||||
## Configuration Files
|
||||
|
||||
|
||||
Reference in New Issue
Block a user