Skip to content

fix(workflows): reject a condition that is spliced into text, not evaluated - #4292

Open
ntdatt812 wants to merge 1 commit into
github:mainfrom
ntdatt812:fix/condition-interpolated-to-text
Open

fix(workflows): reject a condition that is spliced into text, not evaluated#4292
ntdatt812 wants to merge 1 commit into
github:mainfrom
ntdatt812:fix/condition-interpolated-to-text

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

Follow-up to #4182 and #4230, one layer out from both.

The defect

evaluate_expression takes its typed fast path only when the whole string is exactly one {{ ... }} block (_is_single_expression). Anything else goes to _interpolate_expressions, which substitutes each block into the surrounding text and returns a string — and evaluate_condition then coerces it with bool().

So a condition whose braces do not cover the whole expression is always true. Measured on main (27f50f7) with inputs.ready: false, inputs.count: 0:

condition evaluate_expression evaluate_condition validator errors
{{ inputs.ready }} and {{ inputs.count > 100 }} 'False and False' True 0
not {{ inputs.ready }} 'not False' True 0
{{ inputs.count }} > 100 '0 > 100' True 0
ready: {{ inputs.ready }} 'ready: False' True 0
{{ inputs.ready }} (control) False False 0

Every one of those reads as a real comparison, validates clean, and takes then on every run. A while/do-while written that way spins to max_iterations.

The first form is the one I would expect authors to reach for most, because it is the habit both Jinja and GitHub Actions teach.

Why it slipped through

The three validators already assert the property — verbatim, in all of if_then, while_loop and do_while:

f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
"it is never evaluated as an expression and is always true. "

Nothing checked it. condition_is_never_evaluated asks only whether some {{ exists and whether every block closes:

    if "{{" not in stripped:
        return True
    ...
    return _first_unclosable_block(stripped) == "verbatim"

That is the same shape as #4182 — a rule the runtime has that the gate does not derive — with the braces present instead of missing.

The fix

condition_is_interpolated_to_text derives the answer from _is_single_expression, the very predicate the fast path branches on, rather than restating it. Given #4274, restating it was the one thing I did not want to do.

It yields to both existing faults, so each keeps the message and advice written for it, and the three validators gain it as a third elif. No paste-ready correction is offered — there is no single right rewrite of {{ a }} and {{ b }}, since only the author knows which grouping was meant, and that is the refusal policy #4230 established rather than a new one.

Verification

Windows, Python 3.11.

tests/unit/test_condition_expression_block.py            335 passed   (286 before)
tests/unit/test_condition_expression_block.py
  + tests/test_workflows.py + tests/test_extensions.py   22 failed, 1777 passed
  main, same three files                                 22 failed, 1728 passed

Identical 22 failures on both sides, diff clean — the pre-existing symlink and bash-parity classes on unelevated Windows. 1728 → 1777 is exactly the 49 cases added. ruff: 0 net new findings across all five changed files (13 before, 13 after, same rules, same files).

Mutation-checked, after confirming each edit actually applied:

predicate never flags                    -> 24 failed
predicate does not yield to the two
  existing faults                        ->  5 failed
predicate tests the unstripped string    ->  3 failed

The third mutation is there because evaluate_expression strips before testing the fast path — I measured that rather than assuming it, so " {{ inputs.ready }} " stays accepted and is pinned as such.

One thing I could not measure

Whether a multi-block condition is ever legitimate. I could not construct one — a mixed string is truthy unless it renders exactly "", "true" or "false" — but that is an argument, not a measurement. If you know of a shape that should stay accepted, say so and I will narrow the check to exclude it.

…luated

`evaluate_expression` takes its typed fast path only when the whole string is
exactly one `{{ ... }}` block. Anything else goes to `_interpolate_expressions`,
which substitutes each block into the surrounding text and returns a *string*;
`evaluate_condition` then coerces that with `bool()`. So a condition whose
braces do not cover the whole expression is always true:

  {{ inputs.ready }} and {{ inputs.count > 100 }}  ->  "False and False"  ->  True
  not {{ inputs.ready }}                           ->  "not False"        ->  True
  {{ inputs.count }} > 100                         ->  "0 > 100"          ->  True

Each reads as a real comparison, each validates clean today, and each takes
`then` on every run — a `while`/`do-while` written that way spins to
`max_iterations`.

The three validators already told authors the condition "is not a single
complete '{{ }}' block", and nothing checked that property:
`condition_is_never_evaluated` asks only whether *some* `{{` exists and closes.

`condition_is_interpolated_to_text` derives the answer from
`_is_single_expression` — the same predicate the fast path uses — rather than
restating it, so the check cannot drift from the behaviour it predicts. It
yields to both existing faults, which keep their own message and advice, and it
offers no paste-ready correction: there is no single right rewrite of
`{{ a }} and {{ b }}`, because only the author knows the grouping meant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant