Luke the Web Dev Logo

Luke the Web Dev

What Is GitHub Actions?

Luke Twomey
What Is GitHub Actions?

GitHub Actions is a continuous integration/continuous delivery (CI/CD) platform which allows you to build, test and deploy your applications.

It was originally released in 2018, and provides developers with a way to create pipelines within GitHub itself, rather than relying on external tools (Team City, Azure Pipelines etc) to do so.

For example, you can use GitHub Actions to push your code to Azure Container Registry, or Docker Hub, or another similar provider.

What Is CI/CD?

I’ll go into more detail on this in a future post, but briefly, CI/CD is a modern approach to releasing software, where smaller, incremental code changes are made and released to production more frequently.

Changes are still tested by automated processes, to ensure no breaking changes are released. This automation is one of the great advantages of having a CI/CD pipeline in place.

For example, you can set things up so that as soon as you commit a code change and push to GitHub, a process is started which installs your dependencies, builds your app, tests it and deploys it automatically. Pretty cool stuff.

What Are Some Examples of Things GitHub Actions Can Do?

They are so customisable that really, the sky is the limit, but here are some examples of the type of thing you can do with GitHub Actions:

  • Starting with the obvious, test and release your code whenever a new PR is merged
  • Set up a scheduled workflow to optimise your images and assets if needed
  • Check the contents of your repository Issues and assign appropriate labels
  • Generate release notes with every new release
  • Check for stale Issues in your repository, and close them if necessary

These just scratch the surface, but you get the idea. If you dream it up, you can probably do it!

Pipeline

How Do GitHub Actions Work Exactly?

There are some key concepts to familiarise yourself with which make things so much easier to understand when working with GitHub Actions.

Workflows

A workflow is a YAML file which will run one or more jobs. It will trigger automatically on an event of your choosing. Each workflow is stored as a separate YAML file in your code repository, in a directory named .github/workflows.

Events

An event is an activity in a repository which triggers your workflow to run. This can be anything from creating a PR to opening an issue.

You can have GitHub Actions run on loads of different events of your choosing.

You could have it run whenever a commit to a branch is made. I found this very useful recently at work because it allowed me to make a commit to my feature branch while I was testing and see the result immediately, without the extra hassle of creating and merging PR's.

You can set a GitHub Actions schedule to ensure your workflow runs at a time of your choosing. This could be useful for closing stale issues in your repository in the middle of the night, for example.

You can even set workflows to run when something happens outside of GitHub, using the repository_dispatch event.

You can also trigger your workflow to run manually, from within the GitHub UI.

Jobs

A job is a set of steps inside a workflow. Each step either executes a shell script, or runs an action. You can share data between steps, as they run in order. For example, you can build your app in one step, then test the result of that build in another.

Actions

An action is a custom application for GitHub Actions that performs a complex but frequently repeated task. You should use this to enforce the DRY principle.

Runners

A runner is a server that runs your workflow when it's triggered. Each runner can run a single job at a time. GitHub provides Ubuntu Linux, Microsoft Windows, and macOS runners to run your workflows; each workflow run executes in a fresh, newly-provisioned virtual machine.

GitHub Mascot

Real-World Example

Let's run through a real-world example which I've just been working on at ASOS. Obviously I can't go into any specific detail about the project, but we have a repo with two packages, both of which we need to be able to deploy to Azure Artifacts.

To avoid any code repetition, we need to utilise the actions described above. In our case, we combine that with a matrix, which basically stores the details of each of our packages in an array, and runs our action for each of those entries.

Inside Our Workflow YAML File

We'll start off in the workflow file, which as you'll remember is the file which will be triggered on an event of your choosing and orchestrate all of your chosen jobs. The filepath to this file is: ./.github/workflows/publishPackages.yaml

1on: 2 pull_request: 3 types: 4 - closed

For this particular workflow, we want to trigger in the event that a pull request is closed.

1jobs: 2 get-package-details: 3 runs-on: ubuntu-latest 4 outputs: 5 name: ${{ steps.details.outputs.name }} 6 version: ${{ steps.details.outputs.version_bump }} 7 steps: 8 - name: Determine package version from Pr body 9 id: details 10 shell: bash 11 run: | 12 NAME="fantasticPackage" 13 VERSION_BUMP="NA" 14 15 if [[ "$BODY" == *"[x] [Patch]"* ]]; then 16 VERSION_BUMP="patch" 17 fi 18 19 if [[ "$BODY" == *"[x] [Minor]"* ]]; then 20 VERSION_BUMP="minor" 21 fi 22 23 if [[ "$BODY" == *"[x] [Major]"* ]]; then 24 VERSION_BUMP="major" 25 fi 26 27 echo "version_bump=$VERSION_BUMP" >> $GITHUB_OUTPUT 28 echo "name=$NAME" >> $GITHUB_OUTPUT 29 env: 30 BODY: ${{ github.event.pull_request.body }}

This is a really cool part. We have added checkboxes to our PR template, allowing you to select whether this PR is releasing a Patch, Minor or Major version of the package. When the workflow gets triggered by our PR merge, it checks the PR body contents (passed in by the env: BODY: ${{ github.event.pull_request.body }} environment variable) and determines which of the checkboxes you've selected. It then passes that version on to the next job in the workflow using >> $GITHUB_OUTPUT.

1publish: 2 needs: [get-package-details, get-other-package-details] 3 if: ${{ github.event.pull_request.merged == true }} 4 runs-on: ubuntu-latest 5 strategy: 6 fail-fast: false 7 matrix: 8 package: 9 [ 10 { 11 name: "fantasticPackage", 12 version: "${{needs.get-package-details.outputs.version}}", 13 }, 14 { name: "otherFantasticPackage", 15 version: "${{needs.get-other-package-details.outputs.version}}", 16 }, 17 ] 18 steps: 19 ... 20 - name: Publish package 21 uses: ./.github/actions/publish 22 with: 23 name: ${{ matrix.package.name }} 24 version: ${{ matrix.package.version }}

The next job in the workflow,publish, needs the previous job, where we retrieved our package version. It checks to see if the PR has actually been merged, as opposed to just closed. If it hasn't, the job will not run.

We then create the matrix, which is an array of two objects. One for each package we need to release. The following steps will then be run for each entry we have in our matrix, in our case twice, once for each package.

There are then interim steps which I've removed to keep things succinct, but they basically do our authentication with Azure, to allow us to be able to publish our packages if required (otherwise you would get an unauthorised error).

The Publish package step then runs twice. Once for each entry in our matrix. The runs will be as follows:

  • Run 1 - name will be fantasticPackage, version will be the version output from the get-package-details job.
  • Run 2 - name will be otherFantasticPackage, version will be the version output from the get-other-package-details job.

It uses with to pass in our matrix name and version values to the action.

Inside Our "Publish" Actions File

You'll notice that the Publish package step in the previous bit of code uses a particular GitHub Actions file. We will look at this now. The filepath to this file is: ./.github/actions/publish/actions.yaml

1name: Publish 2inputs: 3 name: 4 description: "Package name" 5 required: true 6 version: 7 description: "New version of package" 8 required: true

The action is named, and below that we specify the inputs that we are expecting (the values passed in using with in the workflow file). This will be the name and version for the first entry in our matrix.

1runs: 2 steps: 3 - name: Reject Any NA Packages 4 if: ${{ inputs.version == 'NA' }} 5 run: | 6 echo "### Do not release ${{ inputs.name }} package. Version is set to:" >> $GITHUB_STEP_SUMMARY 7 echo '```' >> $GITHUB_STEP_SUMMARY 8 echo ${{ inputs.version }} >> $GITHUB_STEP_SUMMARY 9 echo '```' >> $GITHUB_STEP_SUMMARY 10 shell: bash

If we have not selected any checkboxes in our PR i.e. we do not want to release our package, our version will have defaulted to NA. To save time and effort, if this is the case then this step will just not run at all. We use >> $GITHUB_STEP_SUMMARY to provide a nice output in GitHub informing us of this.

1- name: Set up Node 2 if: ${{ inputs.version != 'NA' }} 3 uses: actions/setup-node@v3 4 with: 5 node-version: 18 6 cache: yarn 7 8- name: Install dependencies 9 if: ${{ inputs.version != 'NA' }} 10 run: yarn --cwd packages/${{ inputs.name }} install --frozen-lockfile 11 shell: bash 12 13- name: Run Tests 14 if: ${{ inputs.version != 'NA' }} 15 run: yarn --cwd packages/${{ inputs.name }} test 16 shell: bash

With the following steps we first set up node, then install our dependencies for the specific package we are working on, then run the tests.

1- name: Build Package 2 if: ${{ inputs.version != 'NA' }} 3 run: yarn --cwd packages/${{ inputs.name }} build 4 shell: bash 5 6- name: Set Version 7 if: ${{ inputs.version != 'NA' }} 8 run: | 9 OLD_VERSION=$(cat packages/${{ inputs.name }}/package.json | grep version) 10 OLD_VERSION=${OLD_VERSION%\"*} 11 OLD_VERSION=${OLD_VERSION##*\"} 12 git config --global user.email "asos-ciagent@asos.com" 13 git config --global user.name "asos-ciagent" 14 yarn config set version-tag-prefix "v" 15 yarn config set version-git-message "chore: $OLD_VERSION -> %s" 16 yarn --cwd packages/${{ inputs.name }} version --${{ inputs.version }} 17 shell: bash 18 19- name: Publish Package 20 if: ${{ inputs.version != 'NA' }} 21 run: yarn --cwd packages/${{ inputs.name }} publish 2> publish_stderr_digest.log 22 id: publish 23 shell: bash 24- name: Report Publish Failure 25 run: | 26 echo "### 😞 ${{ inputs.name }} package publish failed:" >> $GITHUB_STEP_SUMMARY 27 echo '```' >> $GITHUB_STEP_SUMMARY 28 echo "$(cat packages/${{ inputs.name }}/publish_stderr_digest.log)" >> $GITHUB_STEP_SUMMARY 29 echo '```' >> $GITHUB_STEP_SUMMARY 30 if: failure() 31 shell: bash

We then build our package, and then use the version number currently specified in the package.json file to give a useful commit message for our package update.

Then finally we publish our package! If there is an error we save it to a logfile which we read in the Report Publish Failure step. This will only run if failure() is true for the preceding step. We use this logfile to again give useful information in the GitHub ui as to what might have gone wrong.

Summary

GitHub Actions provides a really convenient way of creating a CI/CD pipeline for your development process. You can configure your workflow in an almost limitless number of ways. The customisability is a real advantage, and you should be able to tailor this solution to meet your needs.

Give it a try and let me know what you think!