Github Actions

What are Github actions?

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline.

It can trigger workflows based on a good variety of events, such as pull requests created, labels added, releases published and so on.

Workflows

A workflow is a configurable automated process that will run one or more jobs. They are defined by YAML files defined in .github/workflows and each workflow/file can be used to a specific purpose.

A workflow must contain the following components:

  1. One or more events that will trigger the workflow.
  2. One or more jobs, each of which will execute on a runner machine and run a series of one or more steps.
  3. Each step can either run a script that you define or run an action, which is a reusable extension that can simplify your workflow.

Diagram of an event triggering Runner 1 to run Job 1, which triggers Runner 2 to run Job 2. Each of the jobs is broken into multiple steps.

Example of an action:

name: deploy-frontend
run-name: ${{ github.actor }} is deploying version ${{ github.run_number }}
on:
	release:
		types: [published]
jobs:
  deploy-new-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '14'
      - run: npm ci
      - run: npm run build
      - uses: aws-actions/configure-aws-credentials@v1
		with:
			aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
			aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
			aws-region: eu-central-1
	  - run: aws s3 sync dist/ s3://my-project/${{ github.run_number }} --recursive --quiet

Triggers

Workflow triggers are events that cause a workflow to run. There are more than 36 main types of triggers and each one has specific activities associated, so we are going to focus on the main ones.

name: deploy-frontend
run-name: ${{ github.actor }} is deploying version ${{ github.run_number }}
on:
	pull_request:
		types: [published, synchronize]
on:
  push:
    branches:
      - 'main'
      - 'releases/**'
run-name: Running Deployment on ${{ github.event.inputs.environment }}
on:
  workflow_dispatch:
    inputs:
      logLevel:
        description: 'Log level'
        required: true
        default: 'warning'
        type: choice
        options:
        - info
        - warning
        - debug
      tags:
        description: 'Test scenario tags'
        required: false
        type: boolean
      environment:
        description: 'Environment to run tests against'
        type: environment
        required: true
on:
  release:
    types: [published]
on:
  schedule:
    # * is a special character in YAML so you have to quote this string
    - cron: '30 5 * * 1,3'
    - cron: '30 5 * * 2,4'

Jobs

A workflow contains one or more jobs which execute a set of steps in a given machine.

jobs:
  my-job:
    runs-on: ubuntu-latest
    steps:
    - name: say_hello
      run: echo Hello world!
    - name: say_bye
      run: echo Bye!

If you use a github hosted runner each job will run using the image specified on runs-on property. The types available are:

In case you are not interested on having github hosted runners it is also possible to run github actions on self-hosted environments.

Creating dependent jobs

By default, the jobs in your workflow all run in parallel at the same time. If you have a job that must only run after another job has completed, you can use the needs keyword to create this dependency.

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      - run: echo "preparing environment"
  build:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - run: echo "building application"
  test:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - run: echo "testing application"
  deploy:
    needs: [build, test]
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying application"

Outputting values from jobs

Sometimes jobs that depend on each other might also require not only the order to be preserved, but also extra data, to handle this situation you can create outputs for a job and consume it on another.

jobs:
  job1:
    runs-on: ubuntu-latest
    outputs:
      greeting: ${{ steps.step1.outputs.test }}
      target: ${{ steps.step2.outputs.test }}
    steps:
      - id: step1
        run: echo "test=hello" >> "$GITHUB_OUTPUT"
      - id: step2
        run: echo "test=world" >> "$GITHUB_OUTPUT"
  job2:
    runs-on: ubuntu-latest
    needs: job1
    steps:
      - env:
          GREETING: ${{needs.job1.outputs.greeting}}
          TARGET: ${{needs.job1.outputs.target}}
        run: echo "$GREETING $TARGET"

Environment variables and secrets

You are able to set environment variables to keep your code flexible by not using fixed values. This environment variables can be set on Job, workflow, repository or even on organisation level.

name: Auto Deploy
run-name: Deploying
on:
	release:
		types: [published]
env:
	VERSION_TAG: GA_${{ github.run_number }}
jobs:
	name: deploy
	runs_on: ubuntu-latest
	env:
		BUCKET: s3://my-project
	steps:
		- run: echo "Deploying version $VERSION_TAG on $BUCKET"

And in case you are dealing with sensitive information you can create a secret to store it, this will prevent this value from being directly accessed/viewed by other developers and will obfuscate it on logs.

name: top_secret
on:
	workflow_dispatch: {}
env:
	GLOBAL_CONSPIRACY: ${{ secrets.GLOBAL_CONSPIRACY }}
jobs:
	name: deploy
	runs_on: ubuntu-latest
	steps:
		- run: echo "The government doesn't want you to know that $GLOBAL_CONSPIRACY"

Job permissions

In some cases you might want to use resources within Github, for example, to fetch packages or add a label to a pull request. You can use permissions to modify the permissions granted, adding or removing access as required, so that you only allow the minimum required access.

The permissions property can be defined either in the workflow level (which will set those permissions to all jobs) or on job level.

permissions:
  actions: read|write|none
  checks: read|write|none
  contents: read|write|none
  deployments: read|write|none
  id-token: read|write|none
  issues: read|write|none
  discussions: read|write|none
  packages: read|write|none
  pages: read|write|none
  pull-requests: read|write|none
  repository-projects: read|write|none
  security-events: read|write|none
  statuses: read|write|none

Details on one of the permissions here

Going deeper on workflows

Reusing workflows

Rather than copying and pasting from one workflow to another, you can make workflows reusable. A workflow that uses another workflow is referred to as a "caller" workflow. The reusable workflow is a "called" workflow.

If you reuse a workflow from a different repository, any actions in the called workflow run as if they were part of the caller workflow. For example, if the called workflow uses actions/checkout, the action checks out the contents of the repository that hosts the caller workflow, not the called workflow.

You can connect a maximum of four levels of workflows - that is, the top-level caller workflow and up to three levels of reusable workflows.

Creating a reusable workflow

Reusable workflows are created by adding the trigger workflow_call which also allows you to pass inputs to the workflow.

on:
  workflow_call:
    inputs:
      config-path:
        required: true
        type: string
    secrets:
      envPAT:
        required: true

And those inputs/secrets can be used by referencing inputs.[property] or secrets.[properties]:

jobs:
  reusable_workflow_job:
    runs-on: ubuntu-latest
    environment: production
    steps:
    - uses: actions/labeler@v4
      with:
        repo-token: ${{ secrets.envPAT }}
        configuration-path: ${{ inputs.config-path }}

Calling a reusable workflow

jobs:
  call-workflow-passing-data:
    uses: octo-org/example-repo/.github/workflows/reusable-workflow.yml@main
    with:
      config-path: .github/labeler.yml # inputs
    secrets:
      envPAT: ${{ secrets.envPAT }} # Passing secrets individually
jobs:
  call-workflow-passing-data:
    uses: octo-org/example-repo/.github/workflows/reusable-workflow.yml@main
    with:
      config-path: .github/labeler.yml
    secrets: inherit # Passing all secrets from caller

Caching Dependencies

To make your workflows faster and more efficient, you can create and use caches for dependencies and other commonly reused files. This can be achieved by using the cache action which will allow a workflow to cache data or fetch data as long as it is on the default branch (usually main) or in the workflow's branch.

YAML

name: Caching with npm
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Cache node modules
        id: cache-npm
        uses: actions/cache@v3
        env:
          cache-name: cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-

      - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
        name: Only install dependencies if there is no cache
        run: npm ci

Storing Artifacts

Artifacts allow you to share data between jobs in a workflow and store data once that workflow has completed. This can be used, for example, to share test results with another job.

The main difference between artefacts and cache is that artifacts are supposed to be values that can change between each workflow run, while cache tends to be more long lasting.

To handle artifacts there are 2 actions, upload-artifact and download-artifact

name: Node CI
on: [push]
jobs:
  build_and_test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: npm install, build, and test
        run: |
          npm install
          npm run build --if-present
          npm test
      - name: Archive code coverage results
        uses: actions/upload-artifact@v3
        with:
          name: code-coverage-report
          path: output/test/code-coverage.html
  sonar_cloud:
	runs_on: ubuntu-latest
	steps:
		- name: Download a single artifact
		  uses: actions/download-artifact@v3
		  with:
		    name: code-coverage-report

Testing Github Actions Locally

One of the biggest complains when developing workflows is that the feedback loop is too long. You need to do your changes, commit files, push them, run the workflow and if the change doesn't work as expected do the whole cycle again.

Fortunately there is a small program called act which allows you to run your github actions locally. It reads the workflows on .github/workflows/ uses Docker API to either pull or build the necessary and when you run the action it runs the container following your workflow. The environment variables and filesystem are all configured to match what GitHub provides.

Installing Act

brew install act
# OR
gh extension install https://github.com/nektos/gh-act

Listing actions

# List all actions for all events:
act -l

# List the actions for a specific event:
act workflow_dispatch -l

Running events

act pull_request

act -s MY_SECRET=somevalue --input NAME=somevalue

Demo