Ternary Operators in GitHub Actions
About a year ago I wrote a post on how to mimic ternary operators in Azure DevOps, and since I've been using a lot of GitHub Actions lately, I thought I'd do a similar post now. It turns out although a fake ternary operator is easy to construct in GitHub Actions, using it safely requires some caution.
The syntax #
In a GitHub Actions expression, you can use the syntax foo && bar || baz
to simulate a ternary operator (with several caveats).
A classic example is where you want to include an optional parameter --production
on a command line, but only if a checkbox Production
is checked on a manual workflow. Here's how you could express that:
name: Adhoc Run
on:
workflow_dispatch:
inputs:
production:
type: boolean
description: 'Production?'
default: false
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm run build ${{ inputs.production && '--production' || '' }}
# => adds '--production' if box is checked, '' if not
However, don't let this example fool you -- this syntax has many hidden dangers, especially when you combine it with some of the confusing comparison behaviors that GitHub Action expressions have.
Navigating boolean values #
In the example expression above, it works as expected because the second operand is truthy. The reverse does not work:
steps:
- uses: actions/checkout@v3
- run: npm run build ${{ inputs.test && '' || '--production' }}
# => WRONG: produces '--production' no matter what
There's no good way to evaluate an && ||
expression when the middle operand is not truthy, so the best way to handle this situation is to reverse the truthiness of the first operand, and then flip the second and third operands.
steps:
- uses: actions/checkout@v3
- run: npm run build ${{ !inputs.test && '--production' || '' }}
# => RIGHT: adds '' if box is checked, '--production' if not
You can use the operand-flipping trick above for any situation in which the second operand is falsy: the boolean false
, number 0
, string ''
and the value null
(no value) are all falsy in GitHub Actions.
Note that the strings 'false'
and 'true'
are both truthy, so in the special case where the result is a boolean, you can also just use strings instead of booleans. A good example where you might do this is when passing an input into a reusable action; for example:
steps:
- uses: ./.github/actions/build
with:
test_mode: ${{ inputs.production && 'false' || 'true' }}
Dealing with null inputs #
In a GitHub Actions workflow, the inputs are part of the workflow_dispatch
event. This means all your inputs are undefined (null) if the workflow is triggered by a pull request, a push, or a scheduled trigger. This can be very confusing, especially if you've specified default:
values for your inputs, because they won't be honored when the workflow is triggered by one of these events.
This situation is made worse because of GitHub's conversion logic. In the situation where the input is undefined, all of the following comparisons return true:
steps:
- run: |
echo ${{ inputs.production == false }}
# => true
echo ${{ inputs.production == '' }}
# => true
echo ${{ inputs.production == 0 }}
# => true
So, if you've defined the input production
as a checkbox that defaults to true
, how can we tell the difference between someone who is running the workflow and manually unchecked the box, and a scheduled trigger, where (presumably) we want the default true
value to apply? The answer is that it's simply not possible using the input value itself. The only way to properly handle this situation is to add special logic for the workflow_dispatch
event.
Here's a full example of what this might like look:
name: Push Release
on:
workflow_dispatch:
inputs:
production:
type: boolean
description: 'Production?'
default: true
push:
branches:
- 'release/*'
env:
PRODUCTION: ${{ github.event_name != 'workflow_dispatch' && 'true' || inputs.production }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm run build ${{ env.PRODUCTION == 'true' && '--production' || '' }}
# => if check box is checked, '--production'
# => if user unchecks box, ''
# => if run on push, defaults to '--production'
Here we've used an intermediate environment value to make the workflow easier to read (although this isn't required). First, we check whether this is a manual run or not: if it's not, we specify the same "default value" specified in the input
. If it is a manual run, then we use the value of inputs.production
(which, in the case of a checkbox, is guaranteed to be true or false).
In the job step, we use our env.PRODUCTION
value, which is a string -- env
values are always strings, so we take care to do an explicit string comparison instead of treating it as just a truthy or falsy value.
Reusing expressions with environment variables #
If you find yourself copying and pasting complex ternary expressions to multiple places in your workflow, you might want to consolidate that into a single place. The simplest way to do that is with environment variables, just like the example above.
It's worth noting that although you can set environment variables at the workflow level, they only exist in the context of a running job. So if you want to use an expression in an if:
clause of a job, or as an input into a job matrix, you cannot refer to an env
value -- you'll get a workflow error. In those cases, you have no choice but to copy and paste the desired expression.
Passing inputs to reusable workflows #
When you create a reusable workflow, you can define inputs for these workflows. The boolean
type of input is special in that what you pass must be a boolean, so if you pass a manual input in directly, you can get in trouble.
jobs:
build:
uses: ./.github/workflows/_build.yaml
with:
production: ${{ inputs.production }}
# WARNING: OK for manual workflows, blows up for other triggers
To avoid an error in this case, use the same event_name
check.
jobs:
build:
uses: ./.github/workflows/_build.yaml
with:
production: ${{ github.event_name != 'workflow_dispatch' && true || inputs.production }}
If you need your boolean input to default to false
, just remember to use the operand flipping trick.
jobs:
build:
uses: ./.github/workflows/_build.yaml
with:
production: ${{ github.event_name == 'workflow_dispatch' && inputs.production || false }}
Technically, you can simplify the above to
${{ inputs.production || false }}
, and it will work fine. I have started avoiding this syntax, however, because it's too easy to replace thatfalse
with atrue
or a"BLUE"
or a5
or any other default, truthy value, and expect it to work; leaving a landmine like that for other developers who aren't as familiar with the nuances of GitHub Actions can result in disaster.
Passing inputs to reusable actions #
A reusable action has some special caveats when compared to a workflow.
Although the inputs block of a composite reusable action looks similar to the inputs block of a workflow, an action only accepts strings -- every value you pass to an action will be converted to a string before your action receives it.
If you're copy-pasting some expressions from a workflow to an action, triple-check your first operand and make sure it is an explicit string comparison and not a truthy/falsy check. It'll look similar to the env
example from up above.
inputs:
production:
description: 'Production?'
required: true
runs:
using: composite
steps:
- run: npm run build ${{ inputs.production == 'true' && '--production' || '' }}
Final thoughts #
Hopefully, this rundown of safe ways to use "ternary operators" in GitHub Actions is helpful to you.
A last caveat... all of the above is accurate as of April 2023, and seems unlikely to change, due to the potential blast radius in existing workflows (classic Hyrum's Law). But, if you think it has changed, drop me a line and I'll update this post!
- Previous: The "PR Ready" Pattern
- Next: Robust remote caching with Rush