fix(workflows): reject a condition that is spliced into text, not evaluated - #4292
Open
ntdatt812 wants to merge 1 commit into
Open
fix(workflows): reject a condition that is spliced into text, not evaluated#4292ntdatt812 wants to merge 1 commit into
ntdatt812 wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #4182 and #4230, one layer out from both.
The defect
evaluate_expressiontakes 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 — andevaluate_conditionthen coerces it withbool().So a condition whose braces do not cover the whole expression is always true. Measured on
main(27f50f7) withinputs.ready: false,inputs.count: 0:evaluate_expressionevaluate_condition{{ inputs.ready }} and {{ inputs.count > 100 }}'False and False'not {{ inputs.ready }}'not False'{{ inputs.count }} > 100'0 > 100'ready: {{ inputs.ready }}'ready: False'{{ inputs.ready }}(control)FalseEvery one of those reads as a real comparison, validates clean, and takes
thenon every run. Awhile/do-whilewritten that way spins tomax_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_loopanddo_while:Nothing checked it.
condition_is_never_evaluatedasks only whether some{{exists and whether every block closes: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_textderives 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.
Identical 22 failures on both sides,
diffclean — 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:
The third mutation is there because
evaluate_expressionstrips 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.