GitHub Actions: 7 obvious things I missed

simpikkle

Elena de Graaf

Posted on July 24, 2023

GitHub Actions: 7 obvious things I missed

GitHub actions are amazing. They are free (ish), fast, powerful — and super frustrating when you’re trying to build them quickly for the first time.

For a long time, my experience with GitHub actions was just about playing around. Recently I had to write a specific workflow, with quickly changing requirements, and that’s where things got tricky.

That experience created some awkward — and sometimes funny — moments that I wanted to share. Some of them are straight up silly, but some may help others to avoid the oops moment of “oh. I just spent 30 minutes on something that is that obvious”.

AI generated duck Even AI generated ducks are less confusing than GitHub context

1. Debugging
Before we dig into anything, the most useful thing that you can do when you are starting to work with GitHub actions is knowing how to see the context of your workflow.

There are different types of context, and you may need different ones depending on your tasks. For example, if you are building retries or conditional steps, it might make sense to have a small output step that will show the outcomes. Might save you a few moments of confusion (see the retries sections for details about said confusion):

- name: Context for steps
       env:
         STEPS_CONTEXT: ${{ toJson(steps) }}
       run: echo "$STEPS_CONTEXT"
Enter fullscreen mode Exit fullscreen mode

2. Mixing conditions
Let’s start with something very simple. The task I had was so straightforward that probably every engineer had had it in their career: to send notifications in case the pipeline fails. To do that, I used a slack GitHub action, and I wanted to utilise it in case the whole workflow fails. There is a very convenient GitHub function for it:

if: ${{ failure() }}
Enter fullscreen mode Exit fullscreen mode

I used the same workflow for pull requests and push to the branch, and that would have been a bit annoying, so I modified the condition to only trigger notifications if the failure is not triggered by a pull request:

if: ${{ failure() }} && {{ github.event != 'pull_request' }}
Enter fullscreen mode Exit fullscreen mode

Some of you may already see the issue, but I was quite surprised to see way more Slack notifications then I anticipated.

Figuring out what was the problem took a minute. Of course, I suspected the condition, but was not sure what was the problem: there were two valid expressions and a logical operator, what could go wrong?

Well… here’s the corrected version:

if: ${{ failure() && github.event != 'pull_request' }}
Enter fullscreen mode Exit fullscreen mode

To use the logical operator, both sides need to be a part of one expression. Another way to avoid that kind of error is to simplify it to:

if: failure() && github.event != 'pull_request'
Enter fullscreen mode Exit fullscreen mode

From GitHub documentation:

When you use expressions in an if conditional, you may omit the expression syntax (${{ }}) because GitHub automatically evaluates the if conditional as an expression.

Overall it was a good reminder to never underestimate the brackets. Ever.

3. Jobs are parallel, steps are not
That sounds obvious to anybody who actually read the whole documentation (anybody else having documentation fatigue? seems like we are using way too many technologies). That was a confusion that you see in practice quickly, so at worst you have one test workflow trying to deploy your code that is not compiled yet.

You can make jobs sequential by references one job from another using needs keyword. You can make steps parallel by placing them into two jobs. Just don’t forget to give all of that decent names or comments to avoid an existential dread of having to make changes to your workflows.

4. Code and names
Why do you need both id and a name for a step? How to set an id for a job? How to reference them? What happens if you don’t?

You will need an id to reference a step. It is a very convenient, but not necessary property. If you don't specify an id, a random id will be assigned to a step.

"1416cd4eabb2812fba6aa416b1a7f858": { <-- Example of random id
    "outputs": {},
    "outcome": "success",
    "conclusion": "success"
  },
Enter fullscreen mode Exit fullscreen mode

To find the step in the UI, you’ll need to use the name property.

steps:
 - name: Step 1 <-- Will be shown on UI
   id: step1    <-- Will be used to reference the step
Enter fullscreen mode Exit fullscreen mode

For jobs, however, the only way to create a job is to give it an id. The purpose of the name is still the same, to display it on UI.

jobs:
  jobId1:         <-- This is an id
  jobId2:
    name: Job 2   <-- You'll see this on UI
    needs: jobId1 <-- Now is it referenced
Enter fullscreen mode Exit fullscreen mode

The-least-pro tip possible: use only underscores _ (or only dashes -) in your workflows for ids and variables. Run edit and replace on your workflows to make sure the other one is not present. Will save you probably a few minutes of staring at a perfectly correct workflow, figuring out what is not working.

Confused catIt gets worse

5. Sharing is caring, or output from jobs/steps/other workflows
Now that I have a working workflow using all of this, I have no idea why it was so confusing before. But that’s how the brain works, I guess.

To use outputs in steps, your step needs to have an id and be referenced in the context of steps inside the job. Of course, the name of the key should match the key referenced by another step, but at this point it’s pretty obvious:

jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - id: step1
        run: echo "stepOutput=value" >> "$GITHUB_OUTPUT"
    steps:
      - id: step2
        run: echo "${{ steps.step1.stepOutput }}"
Enter fullscreen mode Exit fullscreen mode

If you want to reference the step from another job, you have to define the outputs for the job and then use needs context. Now, the name of the key in the step needs to be the same as in the job, but the job output key may be different… another not-so-pro tip: do not make them different unless you absolutely have to. The confusion only grows later:

jobs:
  job1:
    runs-on: ubuntu-latest
    outputs:
      jobOutput: ${{ steps.step1.stepOutput }}
    steps:
      - id: step1
        run: echo "stepOutput=value" >> "$GITHUB_OUTPUT"
  job2:
    runs-on: ubuntu-latest
    needs: job1
    steps:
        run: echo "${{ needs.job1.outputs.jobOutput }}"
Enter fullscreen mode Exit fullscreen mode

And the peak of the sharing of the outputs — reusable workflows. They are especially sweet if you have pretty long workflows, complex file structure and deadlines because the mess of the output names is becoming rather amusing. If you want to use the output from another workflow, you need to define outputs from both the job and the workflow:

name: Reusable workflow

on:
  workflow_call:
    outputs:
      workflowOutput:
        value: ${{ jobs.example_job.outputs.jobOutput }}

jobs:
  example_job:
    runs-on: ubuntu-latest
    outputs:
      jobOutput: ${{ steps.step1.outputs.stepOutput }}
    steps:
      - id: step1
        run: echo "stepOutput=value" >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode

So they can later be used in the main workflow:

name: Call a reusable workflow and use its outputs

on:
  workflow_dispatch:

jobs:
  job1:
    uses: <path>/reusable-workflow-file.yml

  job2:
    runs-on: ubuntu-latest
    needs: job1
    steps:
      - run: echo ${{ needs.job1.outputs.workflowOutput }}
Enter fullscreen mode Exit fullscreen mode

Now imaging combining a few results, add a pinch of - vs _ problems I explained earlier, and that will explain at least a couple of new grey hair.

Hedgehog in a hard hatOh shoot

6. Third time’s a charm, or retries
Before sharing my journey to this void, let me begin this section with a very nice article explaining different ways to retry a step — if you need a solution, you’ll most likely find it there.

My problem was very specific: there were a number of very unstable approaches, and I had to try each, hoping that at least one succeeds. Not the best way to solve the problems with your software — but good enough for some internal tool.

Each approach was written as a step, each was given a proud if: failure(), and, of course, each didn’t work. If the first approach (let’s call it step1) failed, the whole workflow would fail. Why?

Quick googling gave me continue-on-error: true property, that I added to the first n-1 steps. Without it job is considered failed after any one step has failed. Now the workflow continued working — but step2 and others were still not executed. Why?

Another googling minute, another finding: steps have different parameters for indicating a result of the execution. Using continue-on-error: true results in conclusion being a success in any case, while the outcome can differ. And, as you already guessed, if: failure() looks at conclusion.

"step1": {
    "outputs": {
      "custom_outcome": "value"
    },
    "outcome": "failure",
    "conclusion": "success"
  },
Enter fullscreen mode Exit fullscreen mode

A bit confusing, but ok. Let’s make the check more verbose.

if: ${{ steps.step1.outcome == 'failure' && steps.step2.outcome == 'failure'}}
Enter fullscreen mode Exit fullscreen mode

And yet, there was one problem. Steps can not only fail or succeed, but also be skipped, which was immediately evident when some additional inputs check we added. So the condition for the step3 became this:

if: ${{ steps.step1.outcome != 'success' && steps.step2.outcome != 'success'}}
Enter fullscreen mode Exit fullscreen mode

That was one of the cases where checking the context produced by GitHub was necessary. Without it I was quite confused by all the conclusions, outcomes, outputs, and whatever else I had a misfortune of naming in similar fashion.

7. Default value for the input
There are two ways to define the default value, and of course, I learned the wrong one when I needed it. So, here are the two of them together.

The most obvious way is you can use the default option for input itself:

on:
  workflow_dispatch:
    inputs:
      actor:
        type: string
        default: 'system'

jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - run: echo ${{ github.event.inputs.actor }}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can use the env to define a default value:

on:
  workflow_dispatch:
    inputs:
      actor:
        type: string
        required: false

jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - env: ACTOR: ${{ github.event.inputs.actor || 'system' }}
        run: echo $ACTOR
Enter fullscreen mode Exit fullscreen mode

The second option looks more complex, but there are a lot of benefits to it. If you have to get the default value from, say, secrets (encrypted values GitHub stores for you), you will have to use it:

on:
  workflow_dispatch:
    inputs:
      actor:
        type: string
        required: false

jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - env: ACTOR: ${{ github.event.inputs.actor || secrets.defaultActor }}
        run: echo $ACTOR
Enter fullscreen mode Exit fullscreen mode

Also, using env is the only way to have a default value if the workflow is used by other triggers than a manual. So even though the first version is simpler, it may make sense to go with the second one anyway, even though it will require to define the default value further from the declaration of the input.

Conclusion

So, those are my notable moments of maybe two or three accumulated days of GitHub actions. I’m quite happy with the tool, I could customise a lot of things to work exactly as I needed, and it gave me so many “oh that’s how it works” moments.

Maybe someday I’ll do a deep dive into them instead of floating on the air mattress on the surface because they definitely deserve it. Or maybe I’ll continue collecting mistakes — at least that make it fun to write about.

💖 💪 🙅 🚩
simpikkle
Elena de Graaf

Posted on July 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related