feat(workflows): expose {{ context.run_id }} template variable (#2664)

* feat(workflows): expose `{{ context.run_id }}` template variable

Closes #2590.

Surfaces the engine-assigned run id (the same 8-character hex
string Spec Kit prints as `Run ID:` at the end of
`workflow run`) as a workflow template variable so YAML
authors can reference it from shell `run:`, command
`input.args:`, switch `expression:`, and any other field that
already evaluates `{{ ... }}` templates.

### Why

The run id is the natural join key between a Spec Kit workflow
run and downstream artifacts, telemetry, or per-run scratch
state. Today the operator sees it in stdout but workflows
themselves cannot reference it — there was no way to stamp a
log line, name a scratch directory, or tag an artifact with
the same id Spec Kit assigned.

The three motivating use cases from the issue:

1. Telemetry / observability — stamp logs and events with the
   run id so external systems can join workflow runs to
   downstream artifacts.
2. Per-run scratch / isolation — interactive operator commands
   that need their own state directory under
   `/tmp/run-<id>/`.
3. Run-id in artifact metadata — stable join key from artifact
   back to the producing run.

### Implementation

`StepContext.run_id` is already populated by `WorkflowEngine`
in both `execute()` and `resume()`. The only gap was the
template namespace builder.

`_build_namespace` (in `workflows/expressions.py`) now adds a
`context` key alongside the existing `inputs`, `steps`,
`item`, and `fan_in` namespaces:

```python
ns["context"] = {"run_id": run_id}
```

The value is always present (even outside a run) and falls
back to an empty string when no run is active. Workflows
referencing `{{ context.run_id }}` therefore never error — a
hard requirement from the issue's acceptance criteria for
dry-run, validation, and ad-hoc evaluator usage.

### Default behaviour preserved

Workflows that do not reference `{{ context.run_id }}` are
byte-equivalent to before this change. The `context`
namespace is added unconditionally to keep template
resolution branch-free, but its presence has no observable
effect when nothing references it.

### Tests

`TestExpressions` (unit-level) gains three tests:

- `test_context_run_id_resolves` — direct lookup against a
  `StepContext(run_id=...)`.
- `test_context_run_id_defaults_to_empty_when_unset` —
  graceful default outside a run context.
- `test_context_run_id_string_interpolation` — mixed
  template (e.g. `"RUN_ID={{ context.run_id }}"`).

`TestContextRunId` (end-to-end) covers the three step types
the acceptance criteria called out:

- `test_shell_run_resolves_run_id` — `run:` field
  substitution, verified via captured stdout.
- `test_command_input_args_resolves_run_id` — `input.args:`
  resolution, captured in step output even when CLI dispatch
  is unavailable (the artifact-metadata use case).
- `test_switch_expression_matches_on_run_id` — switch
  matches against the resolved value, proving the run id is a
  first-class value in the expression engine, not just an
  interpolation token.
- `test_workflow_without_context_reference_unchanged` —
  locks the byte-equivalent default required by the issue.

### Docs

`workflows/README.md` gains a "Runtime Context" subsection
under "Expressions" documenting the new namespace and the
three canonical use patterns (telemetry, per-run scratch,
artifact metadata).

* test(workflows): drop inline double-quotes in run_id shell tests

`test_shell_run_resolves_run_id` and
`test_switch_expression_matches_on_run_id` used
`run: 'echo "RUN_ID={{ context.run_id }}"'` with inner double-quotes
around the echo argument. Bash/sh strips those quotes before invoking
echo, but cmd.exe (used on Windows when `shell=True`) treats them
as literal characters and emits `"RUN_ID=abc12345"` — failing the
exact-match assertion. Linux passed; all three Windows-latest matrix
entries failed with `assert '"RUN_ID=abc12345"' == 'RUN_ID=abc12345'`.

Resolve by dropping the inner double-quotes (the value has no spaces
or shell metacharacters) and wrapping the YAML scalar in plain
double-quotes the same way other shell-step tests in this file do
(e.g. `run: "echo b-saw-..."`). Behaviour-equivalent on POSIX,
portable to cmd.exe.
This commit is contained in:
Huy Do
2026-05-28 01:00:58 +07:00
committed by GitHub
parent 66884db85b
commit c6afe4cde1
3 changed files with 215 additions and 0 deletions

View File

@@ -102,6 +102,15 @@ def _build_namespace(context: Any) -> dict[str, Any]:
ns["item"] = context.item
if hasattr(context, "fan_in"):
ns["fan_in"] = context.fan_in or {}
# Engine-managed runtime metadata. Always present (even outside a
# run) so templates referencing it never error: `run_id` falls back
# to an empty string when no run is active (dry-run, validation,
# ad-hoc evaluator usage). The value is the same one Spec Kit
# prints as `Run ID:` at the end of `workflow run` — auto-generated
# 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}
return ns

View File

@@ -333,6 +333,44 @@ class TestExpressions:
result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx)
assert result == "a.md"
def test_context_run_id_resolves(self):
"""``{{ context.run_id }}`` resolves to ``StepContext.run_id``.
Locks the contract from issue #2590: workflow templates can
reference the engine-assigned run id for telemetry, artifact
metadata, or per-run scratch isolation.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(run_id="a1b2c3d4")
assert evaluate_expression("{{ context.run_id }}", ctx) == "a1b2c3d4"
def test_context_run_id_defaults_to_empty_when_unset(self):
"""``{{ context.run_id }}`` resolves to ``""`` when no run is
active (dry-run, validation, ad-hoc evaluator usage) rather
than raising — workflows referencing the variable never error
outside a run context.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# No run_id set on the context.
ctx = StepContext()
assert evaluate_expression("{{ context.run_id }}", ctx) == ""
def test_context_run_id_string_interpolation(self):
"""Run id interpolates inside a larger template string — the
common pattern for stamping shell commands and artifact paths
with the run id.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(run_id="deadbeef")
result = evaluate_expression("RUN_ID={{ context.run_id }}", ctx)
assert result == "RUN_ID=deadbeef"
# ===== Integration Dispatch Tests =====
@@ -2154,6 +2192,147 @@ steps:
assert "retry-loop:step-b:2" in state.step_results
# ===== context.run_id Tests =====
#
# End-to-end coverage for the `{{ context.run_id }}` template
# variable introduced in issue #2590. Locks resolution inside the
# three step types the acceptance criteria called out — shell `run:`,
# command `input.args:`, and switch `expression:` — plus the
# "workflow doesn't reference it" backward-compat path.
class TestContextRunId:
"""End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
def test_shell_run_resolves_run_id(self, project_dir):
"""`run: "echo {{ context.run_id }}"` substitutes the
engine-assigned run id into the spawned shell, and the
same value appears on `state.run_id`.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "stamp-run-id"
name: "Stamp Run Id"
version: "1.0.0"
steps:
- id: stamp
type: shell
run: "echo RUN_ID={{ context.run_id }}"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition, run_id="abc12345")
assert state.run_id == "abc12345"
stdout = state.step_results["stamp"]["output"]["stdout"]
assert stdout.strip() == "RUN_ID=abc12345"
def test_command_input_args_resolves_run_id(self, project_dir):
"""`input.args: "{{ context.run_id }}"` is resolved by
`CommandStep` and recorded in step output, even when CLI
dispatch is unavailable (no integration installed). Covers
the artifact-metadata use case from the issue.
"""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "command-stamp"
name: "Command Stamp"
version: "1.0.0"
integration: claude
steps:
- id: tag-artifact
command: speckit.specify
input:
args: "{{ context.run_id }}"
""")
engine = WorkflowEngine(project_dir)
with patch(
"specify_cli.workflows.steps.command.shutil.which",
return_value=None,
):
state = engine.execute(definition, run_id="cafef00d")
# Even when dispatch fails (no CLI), the resolved input is
# recorded so downstream observers see the run id in artifact
# metadata.
assert state.step_results["tag-artifact"]["output"]["input"]["args"] == "cafef00d"
def test_switch_expression_matches_on_run_id(self, project_dir):
"""`switch` over `{{ context.run_id }}` matches against case
keys, and the nested branch can ALSO reference
`{{ context.run_id }}`. Demonstrates the run id is a
first-class value in the expression engine (not just a
string-interpolation token) AND that it propagates into
nested step execution via the recursive `_execute_steps`
traversal.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "switch-on-run-id"
name: "Switch On Run Id"
version: "1.0.0"
steps:
- id: route
type: switch
expression: "{{ context.run_id }}"
cases:
target-run:
- id: matched-branch
type: shell
run: "echo nested-run-id={{ context.run_id }}"
default:
- id: default-branch
type: shell
run: "echo defaulted"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition, run_id="target-run")
assert state.status == RunStatus.COMPLETED
assert state.step_results["route"]["output"]["matched_case"] == "target-run"
assert "matched-branch" in state.step_results
assert "default-branch" not in state.step_results
# The nested branch sees the same run id — propagation through
# recursive `_execute_steps` is intact.
nested_stdout = state.step_results["matched-branch"]["output"]["stdout"]
assert nested_stdout.strip() == "nested-run-id=target-run"
def test_workflow_without_context_reference_unchanged(self, project_dir):
"""Workflows that do not reference `{{ context.run_id }}`
continue to run exactly as before. Locks the byte-equivalent
default required by the issue's acceptance criteria.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "no-context-ref"
name: "No Context Ref"
version: "1.0.0"
steps:
- id: only-step
type: shell
run: "echo hello"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello"
# ===== State Persistence Tests =====
class TestRunState:

View File

@@ -239,6 +239,33 @@ message: "{{ status | default('pending') }}"
Supported filters: `default`, `join`, `contains`, `map`.
### Runtime Context
`{{ context.* }}` exposes engine-managed runtime metadata for the
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. |
```yaml
# Stamp telemetry events with the run id for cross-system join.
- id: emit-event
type: shell
run: 'echo "{\"run_id\":\"{{ context.run_id }}\",\"event\":\"started\"}" >> events.jsonl'
# Per-run scratch directory.
- id: prep-scratch
type: shell
run: 'mkdir -p /tmp/run-{{ context.run_id }}'
# Pass run id into a command for artifact metadata.
- id: tag-artifact
command: speckit.specify
input:
args: "{{ context.run_id }}"
```
## Input Types
Workflow inputs are type-checked and coerced from CLI string values: