Skip to content

Commit 6688b44

Browse files
authored
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
1 parent fb076a3 commit 6688b44

7 files changed

Lines changed: 322 additions & 1 deletion

File tree

docs/reference/workflows.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
305305
| `inputs.spec` | Workflow input values |
306306
| `steps.specify.output.file` | Output from a previous step |
307307
| `item` | Current item in a fan-out iteration |
308+
| `context.run_id` | Current workflow run ID |
309+
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
308310

309311
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
310312

@@ -316,6 +318,14 @@ args: "{{ inputs.spec }}"
316318
message: "{{ status | default('pending') }}"
317319
```
318320
321+
## Shell Step Environment Variables
322+
323+
Shell steps automatically receive the following environment variables:
324+
325+
| Variable | Description |
326+
| -------- | ----------- |
327+
| `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. |
328+
319329
## Input Types
320330

321331
| Type | Coercion |

src/specify_cli/workflows/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ class StepContext:
7474
#: Current run ID.
7575
run_id: str | None = None
7676

77+
#: Source directory of the workflow definition file.
78+
workflow_dir: str | None = None
79+
7780

7881
@dataclass
7982
class StepResult:

src/specify_cli/workflows/engine.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ def __init__(
508508
# append_log is never called while _lock is held, the two never nest.
509509
self._log_lock = threading.Lock()
510510
self.inputs: dict[str, Any] = {}
511+
self.workflow_dir: str | None = None
511512
self.created_at = datetime.now(timezone.utc).isoformat()
512513
self.updated_at = self.created_at
513514
self.log_entries: list[dict[str, Any]] = []
@@ -562,6 +563,7 @@ def save(self) -> None:
562563
"current_step_index": self.current_step_index,
563564
"current_step_id": self.current_step_id,
564565
"step_results": self.step_results,
566+
"workflow_dir": self.workflow_dir,
565567
"created_at": self.created_at,
566568
"updated_at": self.updated_at,
567569
}
@@ -654,6 +656,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState:
654656
state.current_step_index = state_data.get("current_step_index", 0)
655657
state.current_step_id = state_data.get("current_step_id")
656658
state.step_results = state_data.get("step_results", {})
659+
state.workflow_dir = state_data.get("workflow_dir")
657660
state.created_at = state_data.get("created_at", "")
658661
state.updated_at = state_data.get("updated_at", "")
659662

@@ -810,6 +813,12 @@ def execute(
810813
# Resolve inputs
811814
resolved_inputs = self._resolve_inputs(definition, inputs or {})
812815
state.inputs = resolved_inputs
816+
workflow_dir = (
817+
str(definition.source_path.resolve().parent)
818+
if definition.source_path is not None
819+
else None
820+
)
821+
state.workflow_dir = workflow_dir
813822
state.status = RunStatus.RUNNING
814823
state.save()
815824

@@ -820,6 +829,7 @@ def execute(
820829
default_options=definition.default_options,
821830
project_root=str(self.project_root),
822831
run_id=state.run_id,
832+
workflow_dir=workflow_dir,
823833
)
824834

825835
# Execute steps
@@ -885,6 +895,7 @@ def resume(
885895
default_options=definition.default_options,
886896
project_root=str(self.project_root),
887897
run_id=state.run_id,
898+
workflow_dir=state.workflow_dir,
888899
)
889900

890901
from . import STEP_REGISTRY

src/specify_cli/workflows/expressions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ def _build_namespace(context: Any) -> dict[str, Any]:
142142
# runs use an 8-character uuid4 hex; operator-supplied ids may be
143143
# any alphanumeric string with hyphens or underscores.
144144
run_id = getattr(context, "run_id", None) or ""
145-
ns["context"] = {"run_id": run_id}
145+
workflow_dir = getattr(context, "workflow_dir", None) or ""
146+
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
146147
return ns
147148

148149

src/specify_cli/workflows/steps/shell/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import math
7+
import os
78
import subprocess
89
from typing import Any
910

@@ -40,6 +41,13 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4041
error=timeout_error,
4142
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
4243
)
44+
45+
env = {**os.environ}
46+
if context.workflow_dir:
47+
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
48+
else:
49+
env.pop("SPECKIT_WORKFLOW_DIR", None)
50+
4351
# NOTE: shell=True is required to support pipes, redirects, and
4452
# multi-command expressions in workflow YAML. Workflow authors
4553
# control commands; catalog-installed workflows should be reviewed
@@ -51,6 +59,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
5159
capture_output=True,
5260
text=True,
5361
cwd=cwd,
62+
env=env,
5463
timeout=timeout,
5564
)
5665
output = {

0 commit comments

Comments
 (0)