Skip to content

fix(workflows): strip a resolved condition before the true/false check - #3883

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/condition-strip-resolved-string
Open

fix(workflows): strip a resolved condition before the true/false check#3883
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/condition-strip-resolved-string

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

evaluate_condition() special-cases the strings "false"/"true" so that a condition resolving to text behaves as a boolean — its own comment says so:

# Treat plain "false"/"true" strings as booleans so that
# condition: "false" (without {{ }}) behaves as expected.
if isinstance(result, str):
    lower = result.lower()

It matches with result.lower() and never strips. But the most common way a string reaches a condition is captured command output, and the shell step stores stdout verbatim (steps/shell/__init__.py:67):

"stdout": proc.stdout,

So run: echo false resolves to "false\n" — which matches neither branch and falls through to bool("false\n")True.

Reproduction on current main (81bf741)

shell stored stdout      : 'false\n'
evaluate_condition       : True     <-- expected False

  'false'      -> False
  'false\n'    -> True     <-- bug
  'false\r\n'  -> True     <-- bug
  ' false'     -> True     <-- bug
  'FALSE\n'    -> True     <-- bug

An if step takes its then branch on a step that printed false, and while/do-while keep dispatching their body — engine.py uses this same function as the loop-continuation predicate.

There is no workaround

_REGISTERED_FILTERS is ("default", "join", "map", "contains", "from_json")there is no trim filter, and docs/reference/workflows.md:510 lists the same five. A workflow author has no way to strip stdout before a condition, so this cannot be worked around in YAML.

Fix

One line — result.strip().lower().

Existing precedent for stripping before matching boolean text:

Site Code
steps/init/__init__.py:241 resolved.strip().lower() in ("true", "1", "yes")
workflows/catalog.py:410 raw_install.strip().lower() in (...)
workflows/catalog.py:1099 same

To be precise about the precedent: those return False for anything unmatched, whereas evaluate_condition falls through to bool(result). They justify the strip, not the surrounding semantics.

Breaking risk

Only strings whose stripped form is exactly false/true but whose raw form is not. Whitespace-padded false/FALSE now evaluate to False instead of True — that is the bug being fixed. Padded true was already True via bool() and stays True.

bool(result) below still sees the raw string, so nothing else changes: a whitespace-only string is still truthy, and "falsey" is still truthy. Both are pinned by a new test.

Verification

  • Fail-before / pass-after: reverting only the changed line makes test_condition_strips_captured_command_output fail (True = evaluate_condition('{{ steps.check.output.stdout }}', ...)); restoring it gives 2 passed.
  • tests/test_workflows.py → 839 passed, 20 failed; the failure set is byte-identical to the clean-main baseline I captured on 81bf741 (20 in this file, all Windows symlink-privilege).
  • uvx ruff@0.15.0 check src tests → clean

Tests go into the existing TestExpressions, beside test_condition_evaluation.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

evaluate_condition() special-cases the strings "false"/"true" so that
`condition: "false"` behaves as a boolean, but it matches with
`result.lower()` and never strips.

The most common way a *string* reaches a condition is captured command
output, and the shell step stores stdout verbatim
(steps/shell/__init__.py:67 `"stdout": proc.stdout`). So `run: echo false`
resolves to "false\n", which matches neither branch and falls through to
`bool("false\n")` -> True:

  'false'     -> False
  'false\n'   -> True    <-- bug
  'false\r\n' -> True    <-- bug
  ' false'    -> True    <-- bug

An `if` step therefore takes its `then` branch on a step that printed
"false", and `while`/`do-while` keep dispatching their body.

A workflow author cannot work around it: the registered filters are
default/join/map/contains/from_json — there is no `trim`.

`InitStep._resolve_bool` and both catalog readers already strip before
matching boolean text. `bool(result)` still sees the raw string, so no
non-boolean text changes truthiness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner July 31, 2026 06:28
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