DEV Community

Cover image for Don’t Make Conditional GitHub Actions Jobs
Jon Lauridsen
Jon Lauridsen

Posted on

Don’t Make Conditional GitHub Actions Jobs

(cover image: midjourney)

A conditional GitHub Actions job can look something like this:

jobs:
  deploy-o-matic:
    if: github.ref == 'refs/heads/main'
Enter fullscreen mode Exit fullscreen mode

But there’s a better way.

GitHub Actions what?

So, GitHub Actions are based around workflows which are yaml files with GitHub Actions syntax. And workflows consist of jobs which gets assigned to (virtual) GitHub Actions machines. And jobs consists of steps which that machine runs one after another.

And it is a very common pattern to see jobs that are conditional, meaning they are only run under certain conditions and are otherwise skipped. This is often done to have a single workflow that runs both in branches and on commits to the main branch, but only the main branch should deploy or create a release.

That is a sensible requirement, but it does not need to be implemented as a conditional job, because that has several negative implications:

  • All that conditional code isn't run as regularly, and so can accrue mistakes and rot until a deployment happens, and only then does everything explode.
  • They're inherently very difficult to debug, precisely because the code only gets run on main commits. That makes them difficult to maintain, and that causes them quickly become legacy code no-one dares touch. It's a real problem!

So let's not make jobs conditional.

Instead, here are three alternative patterns to consider:

Call Simple Scripts

This is a generally applicable tip for all pipelines, but it's quite important as much pipeline logic as possible is extracted into simple scripts that can also be run locally. I put this first because some pipelines are absolutely full of various nested logic, where complex inline scripts form a web of behavior that can be very difficult to change or even make sense of. That means no-one has confidence changing it.

So step one is to ensure simplicity: Workflows should ideally just be high-level orchestrators of scripts, not themselves expressions of complex logic. Take as much complexity into scripts, because those can be tested and debugged locally, which makes them much simpler to change and iterate on compared to pipelines that can only run on CI.

Conditionally dryrun

With a pipeline optimized for simplicity that calls out to a number of scripts, now we should identify the actual steps that must only be run on commits to the primary branch. Which exact step is the one that does the deploying? Can that script get invoked conditionally with something like a —dryrun argument? E.g. like this:

steps:
  - name: Run deploy script
    run: |

      if [ "${{ github.ref }}" = "refs/heads/main" ]; then

        ./deploy.sh

      else

        ./deploy.sh --dryrun

      fi
Enter fullscreen mode Exit fullscreen mode

This way all pipeline-code gets fully exercised even in branches, but with —dryrun passed when the commit isn’t on the main branch which the script could implement as outputting useful information about what it would’ve done but without causing any actual side-effects.

This is a lot better, because now there isn’t any code that lies dormant, all the code is always exercised!

ℹ️ I do find it can look a little “dangerous” to have a job named “deploy” that now shows up in the workflow overview, and then only by looking into its details can one see it actually dryruns. It can cause people who test in a branch to fear if they’re suddenly about to actually deploy 😱

To remedy this, and to more clearly express that the job is actually running in dryrun mode, I like to set the job name dynamically:

jobs:
 deploy:
   name: ${{ github.ref == 'refs/heads/main' && 'Deploy' || > 'Deploy (dryrun)' }}

It’s just a small quality-of-life improvement, but it takes away anxiety because now the job will clearly show up as Deploy (dryrun) in the overview.

Conditionally run a step

If —dryrun isn’t really an option, then at least the conditional check should only be applied to the specific steps that mustn’t get run rather than the whole job. Like this:

steps:
  - name: Prepare deploy
  - name: Deploy
    if: github.ref == 'refs/heads/main'
    run: ./deploy.sh
Enter fullscreen mode Exit fullscreen mode

This way at last most of the pipeline still runs all the time, even though yes it does leave the script itself vulnerable to rot. But certainly better than making the entire job conditional.

And if there are any complex data-transformations that prepares any data that a conditionally run step ingests, then strongly consider extracting those transformations into a separate step so the transformations can be easily tested in branches. And then pass the transformed data directly into the step that must be conditionally run. Like this:

steps:
  - name: Generate release description
    run: |
      echo "BODY=**Full Changelog**: …" >> $GITHUB_ENV
  - uses: softprops/action-gh-release@v1
    if: github.ref == 'refs/heads/main'
    with:
      body: ${{ env.BODY }}
Enter fullscreen mode Exit fullscreen mode

Here I have a separate step that calculates the “release description”, which makes it easily debuggable in branches because it always runs. And then its result is passed into the subsequent conditional step.

In conclusion

There are many good patterns to follow that minimizes risk of ending up with an unmaintainable and complex pipeline. If you have any to share I'd be happy to hear 'em in the comments.

Top comments (0)