Don’t Make Conditional GitHub Actions Jobs
Tags: beginners, devops, productivity, githubactions
Published: 2024-01-09 13:00:00

_(cover image: midjourney)_

A conditional GitHub Actions job can look something like this:

```yaml
jobs:
  deploy-o-matic:
    if: github.ref == 'refs/heads/main'
```

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: 

```yaml
steps:
  - name: Run deploy script
    run: |
       if [ "${{ github.ref }}" = "refs/heads/main" ]; then 
        ./deploy.sh
       else
         ./deploy.sh --dryrun
       fi
```

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:
> 
> ```yaml
> 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:

```yaml
steps:
  - name: Prepare deploy
  - name: Deploy
    if: github.ref == 'refs/heads/main'
    run: ./deploy.sh
```

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:

```yaml
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 }}
```

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.