Bug Description
In the workflow expression evaluator (src/specify_cli/workflows/expressions.py), evaluate_expression() returns None when a template contains two or more {{ }} blocks with no surrounding literal text (e.g., "{{ context.run_id }} {{ inputs.issue }}").
Root cause: The regex _EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") uses non-greedy (.+?), but fullmatch() defeats non-greediness. For "{{ a }} {{ b }}", fullmatch succeeds by expanding (.+?) to capture everything between the first {{ and the last }}, producing "a }} {{ b" as the expression body. This garbage is passed to _evaluate_simple_expression() → dot-path lookup fails → returns None. Since fullmatch succeeded, the function returns None directly (line 389), bypassing the safe sub() multi-expression path that would have correctly interpolated each expression separately.
Impact: Any workflow step using a multi-expression template without surrounding text silently resolves to None. In our case, a finish step with args: "{{ context.run_id }} {{ inputs.issue }}" resolved to None, causing the finish command to receive the string "None" and fail with "no issue was provided" despite issue: "23" being in the run inputs.
Steps to Reproduce
-
Create a workflow with a command step using a two-expression template:
- id: finish
command: my.command
input:
args: "{{ context.run_id }} {{ inputs.issue }}"
-
Run the workflow with issue: "23" in inputs.
-
Observe that state.json shows step_results.finish.input.args: null instead of "47c5eb4b 23".
Direct reproduction:
import sys
sys.path.insert(0, "/path/to/specify-cli/lib/python3.12/site-packages")
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(
inputs={"issue": "23"},
run_id="47c5eb4b",
)
template = "{{ context.run_id }} {{ inputs.issue }}"
result = evaluate_expression(template, ctx)
print(f"Template: {template!r}")
print(f"Result: {result!r}")
print(f"Type: {type(result).__name__}")
Output:
Template: '{{ context.run_id }} {{ inputs.issue }}'
Result: None
Type: NoneType
Expected Behavior
evaluate_expression("{{ context.run_id }} {{ inputs.issue }}", ctx) should return "47c5eb4b 23" (a string with both values interpolated).
The sub() multi-expression path (lines 392-396) is correct and would produce the right result. The bug is that fullmatch incorrectly succeeds for two-expression templates, bypassing the safe path.
Actual Behavior
evaluate_expression returns None. The captured group from fullmatch is "context.run_id }} {{ inputs.issue" (garbage), which fails dot-path resolution and returns None. This None is then converted to the string "None" by CommandStep.execute() (line 57: args_str = str(resolved_input.get("args", ""))), causing downstream commands to receive the confusing string "None" instead of an empty string or error.
Specify CLI Version
0.11.9
AI Agent
opencode
Operating System
Ubuntu 22.04 (Linux x86_64)
Python Version
Python 3.12.3
Error Logs
$ python3 reproduce.py
Template: '{{ context.run_id }} {{ inputs.issue }}'
Result: None
Type: NoneType
BUG CONFIRMED: evaluate_expression returned None
Expected: '47c5eb4b 23' (string)
Additional Context
Why single-expression and shell-step templates work:
| Template type |
Example |
fullmatch result |
Why it works |
| Single expression |
{{ inputs.issue }} |
Succeeds, captures inputs.issue |
Correctly treated as single expression |
| Shell step (text prefix) |
bash script.sh "{{ inputs.issue }}" |
Fails (starts with bash) |
Falls to sub() path — correct |
| Two expressions, no prefix |
{{ context.run_id }} {{ inputs.issue }} |
Succeeds incorrectly — captures garbage |
BUG — returns None |
Proposed fix:
Add a count("{{") == 1 guard before fullmatch in expressions.py lines 386-389:
# Single expression: return typed value.
# Guard against multi-expression templates: fullmatch with non-greedy
# (.+?) treats "{{ a }} {{ b }}" as a single expression, capturing
# "a }} {{ b" as the body. Only use the single-expression fast path
# when there is exactly one {{ ... }} block, so two-expression
# templates fall through to the sub() interpolation path.
stripped = template.strip()
if stripped.count("{{") == 1:
match = _EXPR_PATTERN.fullmatch(stripped)
if match:
return _evaluate_simple_expression(match.group(1).strip(), namespace)
Verification after fix:
>>> evaluate_expression("{{ context.run_id }} {{ inputs.issue }}", ctx)
'47c5eb4b 23' # FIXED
This is a one-line behavioral guard that preserves typed return for single-expression templates while correctly routing multi-expression templates to the sub() path.
Related issue: #3073 (expression evaluator silently ignores unknown filters) — similar "silent failure" pattern in the expression engine, but different root cause.
Workaround: Change the workflow template to use a single expression (args: "{{ context.run_id }}") and have the command read issue from .specify/workflows/runs/<run_id>/inputs.json directly.
AI disclosure: This issue was investigated and written with AI assistance (opencode); I reproduced and verified the behavior against specify-cli 0.11.9 myself.
Bug Description
In the workflow expression evaluator (
src/specify_cli/workflows/expressions.py),evaluate_expression()returnsNonewhen a template contains two or more{{ }}blocks with no surrounding literal text (e.g.,"{{ context.run_id }} {{ inputs.issue }}").Root cause: The regex
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")uses non-greedy(.+?), butfullmatch()defeats non-greediness. For"{{ a }} {{ b }}",fullmatchsucceeds by expanding(.+?)to capture everything between the first{{and the last}}, producing"a }} {{ b"as the expression body. This garbage is passed to_evaluate_simple_expression()→ dot-path lookup fails → returnsNone. Sincefullmatchsucceeded, the function returnsNonedirectly (line 389), bypassing the safesub()multi-expression path that would have correctly interpolated each expression separately.Impact: Any workflow step using a multi-expression template without surrounding text silently resolves to
None. In our case, a finish step withargs: "{{ context.run_id }} {{ inputs.issue }}"resolved toNone, causing the finish command to receive the string"None"and fail with "no issue was provided" despiteissue: "23"being in the run inputs.Steps to Reproduce
Create a workflow with a command step using a two-expression template:
Run the workflow with
issue: "23"in inputs.Observe that
state.jsonshowsstep_results.finish.input.args: nullinstead of"47c5eb4b 23".Direct reproduction:
Output:
Expected Behavior
evaluate_expression("{{ context.run_id }} {{ inputs.issue }}", ctx)should return"47c5eb4b 23"(a string with both values interpolated).The
sub()multi-expression path (lines 392-396) is correct and would produce the right result. The bug is thatfullmatchincorrectly succeeds for two-expression templates, bypassing the safe path.Actual Behavior
evaluate_expressionreturnsNone. The captured group fromfullmatchis"context.run_id }} {{ inputs.issue"(garbage), which fails dot-path resolution and returnsNone. ThisNoneis then converted to the string"None"byCommandStep.execute()(line 57:args_str = str(resolved_input.get("args", ""))), causing downstream commands to receive the confusing string"None"instead of an empty string or error.Specify CLI Version
0.11.9
AI Agent
opencode
Operating System
Ubuntu 22.04 (Linux x86_64)
Python Version
Python 3.12.3
Error Logs
Additional Context
Why single-expression and shell-step templates work:
fullmatchresult{{ inputs.issue }}inputs.issuebash script.sh "{{ inputs.issue }}"bash)sub()path — correct{{ context.run_id }} {{ inputs.issue }}Proposed fix:
Add a
count("{{") == 1guard beforefullmatchinexpressions.pylines 386-389:Verification after fix:
This is a one-line behavioral guard that preserves typed return for single-expression templates while correctly routing multi-expression templates to the
sub()path.Related issue: #3073 (expression evaluator silently ignores unknown filters) — similar "silent failure" pattern in the expression engine, but different root cause.
Workaround: Change the workflow template to use a single expression (
args: "{{ context.run_id }}") and have the command readissuefrom.specify/workflows/runs/<run_id>/inputs.jsondirectly.AI disclosure: This issue was investigated and written with AI assistance (opencode); I reproduced and verified the behavior against specify-cli 0.11.9 myself.