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:
Roland Huss
2026-07-15 15:09:35 +02:00
committed by GitHub
parent fb076a38b8
commit 6688b447b7
7 changed files with 322 additions and 1 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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 = {