GitHub Actions if condition always runs, and the shape that causes it
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.
if: ${{ github.ref }} == 'refs/heads/main'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.
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.shCommon 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.
if: ${{ github.ref == 'refs/heads/main' }}Or leave the markers out entirely
- Write the condition as a bare expression, which the schema treats as an expression by definition.
- 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.
- Pick one of the two styles for the whole file, so a mixed value is easy to spot in review.
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.
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.
gh secret set ACTIONS_STEP_DEBUG --body trueQuoting 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.
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.
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.
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 |
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.
- name: Deploy
if: ${{ github.ref == 'refs/heads/main' }}
run: ./scripts/deploy.sh
- name: Report
if: ${{ always() && github.ref == 'refs/heads/main' }}
run: ./scripts/report.shWhy 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.
How to prevent it
- Keep every
ifvalue 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.
Frequently asked questions
Does quoting an if condition make it always true?
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?
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?
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?
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.Related guides
References
- GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].if
- GitHub Actions: expressions, literals and operators
- actions/runner: PipelineTemplateConverter.cs, how an if value is compiled
- GitHub Actions: enabling debug logging
- actions/runner: TemplateReader.cs, where a mixed value becomes a format call
- GitHub Actions documentation