Optimize Your CI/CD Pipeline
Get instant insights into your CI/CD performance and costs. Reduce build times by up to 45% and save on infrastructure costs.
Introduction
Conditional execution of jobs is one of those powerful features you find in GitHub Actions, underutilized at times. It enables you to block the execution of jobs unless certain conditions are met-something which is super useful if you want to control a workflow flow based on outputs or external factors.
Whether for multiple environments, complex build pipelines, or job dependencies, conditions enable you to further define your automation.
Steps we will cover in this article:
- Using Conditions to Control Job Execution
- Combining Job Dependencies and Conditions
- Handling of Skipped Jobs and Conditional Logic
- Lessons Learned: Always Be Careful with Conditional Logic
Using Conditions to Control Job Execution
GitHub Actions similar to jobs.<job_id>.if
syntax, you define conditions based on a wide range of inputs where a job would run. Amongst these are job outputs, GitHub contexts-like repository names or branches, environment variables amongst others. Adding a condition makes your workflow more efficient while skipping jobs by marking them as "success" if skipped.
Here's a simple example - we only want to run a deployment job if the repository is a production repository:
jobs:
production-deploy:
if: github.repository == 'my-org/prod-repo'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Deploying to production"
In this case, the production-deploy
job will only execute if the workflow is triggered in the repository my-org/prod-repo
, and be skipped for any other cases.
Combining Job Dependencies and Conditions
This is often the case when you want to make a job execute depending on the result of the execution of another job, deployment job after a build job. You can easily define job dependencies using the keyword needs in these cases, or you could combine the usage of needs with conditions to fine-tune this behavior.
Consider an example where we have the three jobs: build
, test
, and deploy
. Here, we want deploy
to run only if build
succeeds and the test
job either succeeds or is skipped.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Building project"
test:
runs-on: ubuntu-latest
needs: build
steps:
- run: echo "Running tests"
- id: set_test_output
run: echo "::set-output name=should_deploy::yes"
outputs:
should_deploy: ${{ steps.set_test_output.outputs.should_deploy }}
deploy:
runs-on: ubuntu-latest
needs: [build, test]
if: needs.test.outputs.should_deploy == 'yes' && (needs.test.result == 'success' || needs.test.result == 'skipped')
steps:
- run: echo "Deploying project"
Overview: How It Works:
- In it, the job
build
is executed and upon success of this job, thetest
job is triggered. - The
test
job outputs ashould_deploy
, which will be used in thedeploy
job to decide if it should be executed or not. - Here, the
deploy
job is dependent on success of thebuild
job and either success or skip of thetest
job. The deployment proceeds when the output of thetest
job is set to'yes'
.
This will be an elastic way of dealing with job dependencies, which come with complementary conditions.
Handling of Skipped Jobs and Conditional Logic
One common challenge within workflows, is how to deal with skipped jobs. By default a job may be "skipped", yet its status can still be reported out as "success", yet you may wish for other jobs to behave differently based on this status. That's where using conditions like success()
, failure()
, cancelled()
or always()
can help.
Suppose you then have a test
job which, under some circumstances is going to skip. Since the following job, notify
should-only execute if prior test
job succeed or is skipped.
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "Running tests"
- id: set_skip_condition
run: echo "::set-output name=should_skip::no"
outputs:
should_skip: ${{ steps.set_skip_condition.outputs.should_skip }}
notify:
runs-on: ubuntu-latest
needs: test
if: needs.test.result == 'success' || needs.test.result == 'skipped'
steps:
- run: echo "Sending notification"
Breakdown:
- The
test
job sets an output calledshould_skip
. - The
notify
job will only run if thetest
job succeeds or is skipped. - This way, it would still generally be expected that the workflow proceeds, even if a job was skipped.
Lessons Learned: Always Be Careful with Conditional Logic
When I was investigating the feature of conditional execution of jobs, that is when all those unexpected behaviors kicked in. For example, I used some combined conditions with if
. Sometimes, the workflow didn't behave the way it was expected.
Sometimes you might want to make sure a job runs under several conditions. You can use always()
as a way of forcing an evaluation alongside other conditions. Here's such an example where we wish the finalize
job to run even if previous jobs were skipped, provided another condition is met:
jobs:
finalize:
runs-on: ubuntu-latest
needs: [build, test]
if: always() && (needs.build.result == 'success' || needs.test.result == 'skipped')
steps:
- run: echo "Finalizing workflow"
This will ensure that finalize
is definitely executed regardless of the result of all the other jobs, but only of course, if at least one of the jobs succeeded or was skipped.
Conclusion
Conditional execution of jobs in GitHub Actions allows you to get much more efficient and maintainable continuous integration/continuous deployment pipelines. Because you're going to have some context, like outputs or external factors determining whether it's worth running the job, you will save not just time but also resources. You would need to consider how you will handle skipped jobs. Know about status checks like success()
, failure()
or always()
.
You'll be able to make your workflows more intelligent and responsive with conditional logic so that the need for redundant jobs or manual intervention will be reduced when different scenarios pop up.