# GitHub Actions if condition always runs, and the shape that causes it

> A GitHub Actions if condition always runs when the value is a string holding an expression. Quoting the whole condition is not what breaks it.

Source: https://latchkey.dev/learn/github-actions/github-actions-if-condition-string-always-runs  
Updated: 2026-09-20

A GitHub Actions if condition always runs when what you wrote produces a non-empty string instead of a boolean, and there is one shape that does this reliably: an expression embedded inside a larger string. Quoting the condition, which is the usual suspect, is harmless, because the value is compiled as an expression either way.

## What this error means

A step or job that should be conditional runs on every event, every branch and every pull request. There is no error and no warning; the run is green and the guarded work simply happens when it should not. Inverting the condition does not help, because the inverse is also a non-empty string. The give-away is that the condition contains both an expression and some text around it, which is easy to miss because the line usually looks tidier than the version that works.

```The shape that always evaluates true, not an error message: this raises none
if: ${{ github.ref }} == 'refs/heads/main'
```

## Common causes

### An expression embedded in a longer string

The dominant cause. Writing `${{ value }}` and then comparing it to something outside the markers turns the whole condition into a `format` call. The result is text, the text is never empty, and the step runs every time. It reads naturally, which is exactly the problem.

### Each half of a compound condition wrapped separately

Putting markers round both sides of an `&&` or an `||` produces a multi-argument `format` call for the same reason. It carries a second consequence: if either half contains a status function, the runner sees a status function in the tree and skips the implicit success check, so the step also stops respecting earlier failures.

### A value that is a string rather than a boolean

A bare `if: ${{ steps.probe.outputs.ok }}` is a single expression, so no `format` call is built, but a step output is always a string and the string "false" is truthy. The condition is well formed and still always true. Compare it against the exact text instead.

### A condition whose contexts differ between job and step

A job condition may use fewer contexts than a step condition, so a line moved between the two can start failing to compile rather than quietly always running. Worth mentioning because it is the failure people expect when they meet this page, and it is a different one.

## How to fix it

### Put the whole condition inside one set of markers

Move `${{` to the very start of the value and `}}` to the very end, so the value is a single expression rather than a string containing one. Nothing else needs to change, and the compiled tree becomes the comparison you meant.

```.github/workflows/ci.yml (illustrative)
if: ${{ github.ref == 'refs/heads/main' }}
```

### Or leave the markers out entirely

1. Write the condition as a bare expression, which the schema treats as an expression by definition.
2. Quote it if YAML needs it, for example when it begins with a character YAML would misread; the quotes are not significant to the expression.
3. Pick one of the two styles for the whole file, so a mixed value is easy to spot in review.

```.github/workflows/ci.yml (illustrative)
if: github.ref == 'refs/heads/main'
```

### Compare strings explicitly instead of relying on truthiness

When the value comes from a step output, an input read through the event payload or a variable, it is text. Compare it with the exact string you expect so the expression returns a real boolean, rather than depending on whether the text happens to be empty.

```.github/workflows/ci.yml (illustrative)
if: steps.probe.outputs.ok == 'true'
```

### Turn on step debug logging when a condition surprises you

Set the `ACTIONS_STEP_DEBUG` secret or variable to true and re-run. The runner then prints the condition it evaluated and the result, which distinguishes a comparison that returned false from a `format` call that returned text. That difference is invisible in a normal log.

```Terminal
gh secret set ACTIONS_STEP_DEBUG --body true
```

## How to prevent it

- Keep every `if` value either wholly inside markers or wholly outside them.
- Never place a comparison operator outside a `${{ }}` pair.
- Treat any step output or event-payload value in a condition as text and compare it.
- Run actionlint, which flags a condition that is a string rather than a boolean.

## A workflow that never skips

This file is written for this page and has never been run. It is the smallest shape that reaches the behavior: a deploy step guarded by a branch check, written with the expression markers around the value rather than around the whole comparison.

It deploys from every branch. The condition is not ignored and is not malformed; it evaluates successfully, to a string, and a non-empty string is true here. That is why nothing is red and why re-running changes nothing.

```.github/workflows/ci.yml (illustrative)
name: ci
on: push

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Deploy
        if: ${{ github.ref }} == 'refs/heads/main'
        run: ./scripts/deploy.sh
```

## Quoting the condition is not the problem

The workflow schema types an `if` value as a string with `is-expression` set, and its own description says so: "Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression."

The runner does exactly that. `ConvertToIfCondition` takes the inner text when the whole value was one expression, and otherwise asserts a string and takes its value; either way it hands the result to `expressionParser.CreateTree`. So `if: "github.ref == 'refs/heads/main'"` compiles to the same tree as the unquoted form. YAML quoting decides how the scalar is read, and the scalar is compiled as an expression afterwards regardless.

```actions/runner, PipelineTemplateConverter.cs, two branches of ConvertToIfCondition
else if (token is BasicExpressionToken expressionToken)
{
    condition = expressionToken.Expression;
}
else
{
    var stringToken = token.AssertString($"{(isJob ? "job" : "step")} {PipelineTemplateConstants.If}");
    condition = stringToken.Value;
}

// ...

var expressionParser = new ExpressionParser();
```

## What actually happens to a mixed value

When a scalar contains `${{` but is not entirely one expression, the template reader splits it into segments and rebuilds it as a call to `format`. The code is explicit about it: it escapes the literal parts, appends a placeholder per expression, and produces `format('{format}'{args})`. So `${{ github.ref }} == 'refs/heads/main'` becomes a format call whose result is a piece of text such as `refs/heads/feature == 'refs/heads/main'`.

What comes out is a `BasicExpressionToken` holding a `format` call, so the condition is still a perfectly valid expression. It simply evaluates to a piece of text rather than to a boolean, and nothing anywhere reports that as a problem.

```actions/runner, src/Sdk/WorkflowParser/ObjectTemplating/TemplateReader.cs
var finalExpression = $"format('{format}'{args})";
if (!ExpressionToken.IsValidExpression(finalExpression, allowedContext, out Exception ex2))
{
    m_context.Error(token, ex2);
    return token;
}
return new BasicExpressionToken(m_fileId, token.Line, token.Column, finalExpression);
```

## And why a piece of text counts as true

Truthiness decides the rest, and the rule is narrower than most languages. `EvaluationResult.IsFalsy` in the runner returns true for a string only when it equals the empty string, so every non-empty result is true, including the word "false" and including a comparison that was flattened into text. There is no comparison left to perform; the comparison became part of the string.

```actions/runner, src/Sdk/DTExpressions2/Expressions2/EvaluationResult.cs, from IsFalsy
case ValueKind.String:
    var str = (String)Value;
    return String.Equals(str, String.Empty, StringComparison.Ordinal);
```

## Five ways to write one condition

The first three rows are all correct and all compile to the same tree. The last two are the ones that always run, and they differ only in where the expression markers fall.

The fifth row deserves attention because it looks the most careful. Wrapping each half of an `and` in its own markers produces a two-argument format call, so the condition becomes a string such as `true && false`, which is non-empty and therefore true. It also contains a status function, which suppresses the implicit `success()` wrapper the runner would otherwise add, so the step runs even after an earlier step has failed.

| What you wrote | What the parser builds | Result |
| --- | --- | --- |
| `if: github.ref == 'refs/heads/main'` | one comparison expression | correct |
| `if: "github.ref == 'refs/heads/main'"` | the same expression; the quotes are YAML only | correct |
| `if: ${{ github.ref == 'refs/heads/main' }}` | the same expression | correct |
| `if: ${{ github.ref }} == 'refs/heads/main'` | a `format` call returning text | always true |
| `if: ${{ success() }} && ${{ github.ref == 'refs/heads/main' }}` | a two-argument `format` call returning text | always true, and skips the implicit success check |

> Turning on step debug logging prints the condition the runner evaluated and its result, which is the quickest way to see a `format` call where you expected a comparison.

## The corrected file

The fix is to move the markers outward so the whole condition is one expression, or to drop them entirely. Both produce the same compiled tree, so pick one convention and keep it; a file that mixes the two is where the mixed values get written.

Note the second step in the corrected file. When a condition needs to run on failure or cancellation, the status function belongs inside the single expression with everything else, not welded on outside it.

```.github/workflows/ci.yml, corrected (illustrative)
- name: Deploy
        if: ${{ github.ref == 'refs/heads/main' }}
        run: ./scripts/deploy.sh
      - name: Report
        if: ${{ always() && github.ref == 'refs/heads/main' }}
        run: ./scripts/report.sh
```

## Why there is no recorded run on this page

The defect here is a step that runs, so a recorded run would be a green log of a step doing its job correctly, with nothing in it to point at. The evidence is in what the parser built from the text, and the compiled condition is not printed in an ordinary log at all; it appears only when step debug logging is switched on, which is a setting on the reader's repository rather than a capture we can hand over. What is stable is the transformation itself, quoted above from the three files that perform it, and those hold for every runner rather than for one job of ours. A page about a silent condition is better served by the code that makes it silent.

## FAQ

### Does quoting an if condition make it always true?

No. The schema types an `if` value as an expression, and the runner compiles the string it receives with the expression parser either way. Quoting only changes how YAML reads the scalar, so a quoted comparison behaves exactly like an unquoted one.

### Why does ${{ x }} == 'y' always run?

Because the value is a string with an expression inside it, not a single expression. The template reader rebuilds it as a call to `format`, the call returns text such as `a == 'y'`, and a non-empty string is truthy. The comparison became part of the text instead of being evaluated.

### Should I wrap each side of an && in its own markers?

No. That produces a multi-argument `format` call whose result is text, so the condition is always true. It can also suppress the implicit success check, because a status function inside the call counts as one being present, leaving the step running after earlier failures.

### How can I see the condition the runner actually evaluated?

Enable step debug logging by setting `ACTIONS_STEP_DEBUG` to true and re-running. The runner logs the condition it evaluated for each step along with the result, which makes a `format` call visible where you expected a comparison.

## References

- [GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].if](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Actions: expressions, literals and operators](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions)
- [actions/runner: PipelineTemplateConverter.cs, how an if value is compiled](https://github.com/actions/runner/blob/main/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs)
- [GitHub Actions: enabling debug logging](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging)
- [actions/runner: TemplateReader.cs, where a mixed value becomes a format call](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/ObjectTemplating/TemplateReader.cs)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
