DEV Community

Cover image for Getting started with GitHub Actions and workflows
Benny Neugebauer for TypeScript TV

Posted on

Getting started with GitHub Actions and workflows

GitHub Actions are a great way to automate your own software development cycle. GitHub Actions are free of charge for public repositories and provide you with a whole CI/CD platform. It allows you to automate all parts of your software supply chain and run it in virtual environments or even your own environment using self-hosted runners.

Much of what used to be done with a Jenkins job can now be done with GitHub Actions. In this article, I will give you a quick start in GitHub Actions and explain what actions, workflows, events, jobs and steps are. As an example we take a JavaScript application for which we set up a test automation.

What are GitHub Actions?

GitHub Actions are reusable scripts that can be used on GitHub's platform for continuous integration and continuous delivery (CI/CD). You can write your own actions using JavaScript (and other languages) or use published actions from the GitHub Marketplace.

There are already actions for various tasks like sending a message to a Slack channel (slack-send), uploading code coverage reports (codecov) or deploying code to the Google Cloud (setup-gcloud). In this tutorial, we will use existing GitHub Actions and wire them together in a so-called "workflow".

What are workflows?

A workflow is a description for your CI/CD pipeline on GitHub Actions. A workflow always runs one or more jobs and each job consists of steps which can be calls to GitHub Actions or regular shell commands. A workflow is triggered by an event (e.g. a commit in your branch) and runs on a virtual environment on GitHub (called "hosted runner") or your own environment (called "self-hosted runner").

Test Automation with GitHub Actions

To ensure that pull requests are compatible with your code, you can setup a GitHub workflow to run a test automation pipeline. I will show you how to do this by using a JavaScript demo project for which we will run npm test when new code comes in.

Setting up a workflow

Setting up a workflow is done by creating a YAML file inside of the .github/workflows directory of your repository on GitHub. We will save our test automation in test.yml:

.github/workflows/test.yml

# Name of our workflow
name: 'Test'

# Events that will trigger our workflow
on: [ 'pull_request', 'push' ]

# List of custom jobs
jobs:
  # Job is called "test"
  test:
    # Using a "label" to assign job to a specific hosted runner
    runs-on: ubuntu-latest
    steps:
      # Checks-out our repository under "$GITHUB_WORKSPACE", so our job can access it
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      # Runs commands using the runners shell
      - name: 'Run tests'
        run: npm install && npm test
Enter fullscreen mode Exit fullscreen mode

Specify Node.js version

GitHub provides hosted runners which can run your workflow in different virtual environments. The "ubuntu-latest" environment already contains a recent version of Node.js which is ideal for testing JavaScript applications.

You can also use the setup-node action to configure any Node.js version you like to use:

name: 'Test'

on: [ 'pull_request', 'push' ]

jobs:
  test:
    # Using a build matrix to route workflow to hosted runner(s)
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      # Uses specific version of Node.js
      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: 'Run tests'
        run: npm install && npm test
Enter fullscreen mode Exit fullscreen mode

Define workflow triggers

Currently our workflow is executed on every git push event and every event in a Pull Request. Pushing commits in a PR triggers our action twice because it is a push event and an event in our PR. To prevent this, we can restrict the events that trigger our workflow. We will limit the push events to the "main" branch, which is useful when we squash and merge a PR into our "main" branch:

name: 'Test'

on:
  pull_request:
  # Limit push events to "main" branch
  push:
    branches: [ 'main' ]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: 'Run tests'
        run: npm install && npm test
Enter fullscreen mode Exit fullscreen mode

Note: Simply leave the value for pull_request empty to match any branch name.

Run workflow manually with workflow_dispatch

We can also define a workflow_dispatch trigger which will allow us to run a workflow manually from the "Actions" tab of our repository:

name: 'Test'

on:
  pull_request:
  push:
    branches: [ 'main' ]
  # The "workflow_dispatch" event gives us a button in GitHub's "Action" UI
  workflow_dispatch:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: 'Run tests'
        run: npm install && npm test
Enter fullscreen mode Exit fullscreen mode

Screenshot:

Workflow dispatch in action

Run multiline shell commands

When working with TypeScript, it is advisable to check the validity of your types before running tests. This way errors can be catched even before setting up the test runner. We will accomplish this by running tsc --noEmit just before executing our test script. In order to have a better overview of our commands, we will replace the && link with a multiline command using the pipe (|):

name: 'Test'

on:
  pull_request:
  push:
    branches: [ 'main' ]
  workflow_dispatch:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:      
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      # Runs multiple commands using the "|" operator
      - name: 'Run tests'
        run: |
          npm install
          npx tsc --noEmit
          npm test
Enter fullscreen mode Exit fullscreen mode

Skip workflow execution

We can prevent our full test setup from running when adding a specific text (like [skip ci] or [ci skip]) in our commit message:

name: 'Test'

on:
  pull_request:
  push:
    branches: [ 'main' ]
  workflow_dispatch:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    # Condition to run the job using GitHub's event API
    if: |
      contains(github.event.commits[0].message, '[skip ci]') == false &&
      contains(github.event.commits[0].message, '[ci skip]') == false
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:      
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: 'Run tests'
        run: |
          npm install
          npx tsc --noEmit
          npm test
Enter fullscreen mode Exit fullscreen mode

Note: By default GitHub skips checks for commits that have two empty lines followed by skip-checks: true within the commit message before the closing quotation:

git commit -m "Some commit message
>
>
skip-checks: true"
Enter fullscreen mode Exit fullscreen mode

Using expressions in workflows

The workflow syntax for GitHub Actions allows us to use expressions. There is a set of built-in functions, like success() and failure(), that can be used in expressions and are very handy to check the status of your workflow. We will use failure() to send a message to our Slack channel whenever our tests fail:

name: 'Test'

on:
  pull_request:
  push:
    branches: [ 'main' ]
  workflow_dispatch:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    if: |
      contains(github.event.commits[0].message, '[skip ci]') == false &&
      contains(github.event.commits[0].message, '[ci skip]') == false
    strategy:
      matrix:
        os: [ 'ubuntu-latest' ]
        node-version: [ '16.x' ]
    steps:
      - name: 'Checkout repository'
        uses: actions/checkout@v3

      - name: 'Use Node.js v${{ matrix.node-version }}'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: 'Run tests'
        run: |
          npm install
          npx tsc --noEmit
          npm test

      - name: 'Post error notification to Slack channel'
        uses: slackapi/slack-github-action@v1.18.0
        # Use built-in function in expression
        if: ${{ failure() }}
        with:
          channel-id: my-channel
          slack-message: 'Test run <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|${{ github.run_id }}> failed.'
        env:
          SLACK_BOT_TOKEN: ${{ secrets.MY_SLACK_BOT_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Note: To make use of the Slack Action, you have to create a Slack App for your Slack workspace with an OAuth scope of chat.write. Afterwards, you have to make your "Bot User OAuth Token" available as environment variable (e.g. MY_SLACK_BOT_TOKEN) in your GitHub repository. This can be done in Settings → Secrets → Actions. It will then be accessible in your workflow file using the ${{ secrets.MY_SLACK_BOT_TOKEN }} expression.

Branch protection rules

Once you have a testing workflow and sufficient tests covering your code, you can setup a branch protection rule. This can be done by navigating to Settings → Branches → Branch protection rules → Add rule in your GitHub repository.

The "Branch name pattern" supports fnmatch syntax but also allows to set a single branch name (like "main"). In order to protect your branch from incompatible dependency updates you have to activate "Require status checks to pass before merging". You can use GitHub Actions as status checks by searching for their job names (e.g. "test").

Screenshot:

Branch Protection Rule Setup

The branch protection rule will warn you when new code fails your testing pipeline. It will also prevent merging broken code into your "main" branch when you are not an administrator who can override such rules.

Running your GitHub Actions locally

If you want to have faster feedback loops, you can also run GitHub Actions locally using the act cli. It requires Docker and a local installation through your favorite package manager.

After installing "act", you can run it locally from your terminal by passing it the job name of your workflow, e.g. act -j test. It will then download the necessary Docker image. Depending on the complexity of your workflow, this image can be 20+ GB in size. For our small test setup a micro image (below 200 MB) that contains only Node.js is good enough when we remove our "skip ci" condition.

Screenshot:

act-cli in action

Where to go from here?

Congratulations! You have just learned the fundamentals of GitHub Actions and you are now able to create your own workflows. With your newly acquired skills, you can build great CI/CD pipelines. 🎊

If you want to learn more about GitHub Actions, then I recommend the following topics:

Discussion (3)

Collapse
tyler36 profile image
tyler36

Great article! You covered a range of configurations clearly and with good examples.

When I try to run this locally through act, I'm hitting an error:
Error: ❌ Error in if-expression: "if: contains(github.event.commits[0].message, '[skip ci]') == false" (Unable to index on non-slice value: invalid)

How do you handle this?

Collapse
bennycode profile image
Benny Neugebauer Author

Hi @tyler36, you can solve the issue in two ways: 1) remove the "if" condition (my suggestion) or 2) use the biggest Docker image from "act" which can emulate if conditions.

Collapse
bennycode profile image
Benny Neugebauer Author

I reported that Error in if-expression bug to the maintainers of "act" and it seems to be fixed now: github.com/nektos/act/issues/1115