mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user