Skip to content

Custom Actions and Reusable Workflows

When writing workflows, there are many times where we many want to reuse specific functionality. To help do this, GitHub provides us with custom actions and reusable workflows, both of which can be written to accomplish certain tasks and be reused in other workflows.

Custom Actions

Sometimes you may find that you cannot find an action that already exists in the GitHub marketplace that covers your use case. When this happens creating your own action could be your solution. There are 3 different types of custom actions:

  1. Docker container action
  2. This method you will provide your own Dockerfile that creates a container on the runner and executes your action inside the container.
  3. Requires OS Linux runner
  4. Can use any Docker image. In the example below we are using a Python image to run main.py as our action.
  5. "For actions that must run in a specific environment configuration, Docker is an ideal option because you can customize the operating system and tools" GitHub Docker Container Actions
  6. JavaScript action
  7. This method you will create a nodeJS project that will run your code directly on the runner.
  8. Runs faster than Docker containers
  9. "To ensure your JavaScript actions are compatible with all GitHub-hosted runners (Ubuntu, Windows, and macOS), the packaged JavaScript code you write should be pure JavaScript and not rely on other binaries" GitHub JavaScript Actions
  10. Composite Action
  11. You can look at this as more of a method to wrap multiple actions together into one. For example if you found yourself calling the same actions over and over again and wanted to group them all together under one action this would be the type of custom action to create.

When you have decided on what approach best fits your use case the next step would be to know where it needs to be stored. If you plan to create an action that could be used by others, then placing an action in its own repository would be what you should do. This also allows you to control actions versioning from versioning of other code that may exist on the repository. If you have no intentions of others using your action then you can store the file anywhere in your repository. However, "If you plan to combine action, workflow, and application code in a single repository, we recommend storing actions in the .github directory. For example, .github/actions/action-a and .github/actions/action-b" GitHub Action Location

We briefly metioned actions versioning earlier, but it is a very important part of creating your action especially when you expect others to use it. There are a few different ways to control versioning of your action:

  1. Using tags (Recommended)
  2. Tag and create a release using semantic versioning i.e. v1, v1.3.2
  3. Using branches
  4. Use a Branch name i.e. release/v1.0.1
  5. Using a commit's SHA
  6. Point to a particular commit using it's full SHA i.e. 172239021f7ba04fe7327647b213799853a9eb89

Example repository directory structures:

Docker container action

ascii-action
├── Dockerfile
├── README.md
├── action.yml
└── main.py

JavaScript container action

validate-schema-js
├── README.md
├── action.yml
├── index.js
├── node_modules/*
├── package-lock.json
└── package.json

Composite action

my-composite-action
├── README.md
└── action.yml

NOTE: When setting up your JavaScript action if you do not want to source control your node_modules, then you can use the tool @vercel/ncc to compile your code and modules into a single file.

Now that we can see how our repositories are setup let's look at the main file that makes up your custom action.

action.yml - This is the metadata file for your action. It describes inputs, outputs, and runs configuration for your action. We will only cover a few key terms in the metadata file, but a full list can be found here

    inputs: Optional input parameters allow you to take values from a workflow calling your action using the with keyword.

    outputs: Optional output parameters allow you to declare values that an action will set. This is usually needed when another action in your workflow would need some value derived from your action's logic.

    runs: Defines whether this is a Docker, JavaScript, or Composite action

Docker example

name: 'ASCII Text' 
description: 'Display users text as ASCII art'
inputs: 
  my-text:
    description: 'Text to convert to ASCII art'
    required: true
    default: 'WOW'
runs:
  using: 'docker' 
  image: 'Dockerfile' # The Dockerfile in this action's repository
  args: # Note args overrides Docker CMD. 
    - ${{ inputs.my-text }}
Javascript example
name: 'Validate Schema Object'
description: 'Validate a schema object in JS'
inputs:
  user-name:
    description: 'Name of User'
    required: true
    default: 'Tim'
  user-age:
    description: 'Age of User'
    required: true
    default: '35'
  user-email:
    description: 'Email of User'
    required: true
    default: 'admin@example.com'
outputs:
  is-valid:
    description: 'Are inputs valid for schema object'
runs:
  using: 'node16'
  main: 'index.js'
Composite example
# This composite action combines the Docker and JavaScript action above
name: 'Composite'
description: 'Demonstrate combining actions'
inputs:
  my-text:
    description: 'Text to convert to ASCII art'
    required: true
    default: 'WOW'
  user-name:
    description: 'Name of User'
    required: true
    default: 'Tim'
  user-age:
    description: 'Age of User'
    required: true
    default: '35'
  user-email:
    description: 'Email of User'
    required: true
    default: 'admin@example.com'
outputs:
  is-valid:
    description: 'Are inputs valid for schema object'
runs:
  using: "composite"
  steps: # steps is where you would group together actions. Everything listed under steps will run when calling this composite action
    - name: ASCII art action step
      id: art
      uses: test-user/ascii-action@v1 
      with:
        my-text: ${{ inputs.my-text }}
    - name: Validate inputs against schema
      id: validate
      uses: test-user/validate-schema-js@v1
      with:
        user-name: ${{ inputs.user-name }}
        user-age: ${{ inputs.user-age }}
        user-email: ${{ inputs.user-email }}
    - name: Get the validation output
      run: echo "${{ steps.validate.outputs.is-valid }}"
      shell: bash

How do you access input parameters in your Docker container and JavaScript scripts?

In Docker all input parameters are passed as Environment Variables with the prefix INPUT_ followed by the input name. i.e based on the Docker input example INPUT_MY-TEXT

main.py

user_input = os.environ["INPUT_MY-TEXT"]

In JavaScript you need to use @actions/core. You would then create a variable for this require to access it's getInput() function.

index.js

const core = require('@actions/core');

const userName = core.getInput('user-name');

How to you set outputs in your Docker container and JavaScript scripts?

For Docker you must use this syntax for GitHub to be able to recognize it as an output parameter echo "<output name>=<value>" >> $GITHUB_OUTPUT

For JavaScript you would again use the @actions/core package.

core.setOuput("is-valid", "someValue")

NOTE: In JavaScript if you would be using an Object as an argument for setOutput() use JSON.stringify(myObject) to see it's string representation. Otherwise you will see [object] [Object].

Reusable Workflows

A helpful feature of GitHub Actions is reusing workflows. Reusable workflows are similar to actions in that they run code specified in other repositories to complete tasks like push Docker images, build npm projects, etc., but reusable workflows is an entire pipeline that can be used again and again. This repo holds the reusable workflows that client name has developed. Let's run through an exmaple of using a reusable workflow since it's slightly different than using an action.

The reusable workflow that we're going to be using for this example is the build-scan-push workflow. Let's start with a generic workflow setup:

name: Reusable Workflow Example

on: 
  push:
    branches:
      - "main"
  pull_request:

jobs:
  ...

For this example, we only want to build the Docker image on a push to main and a PR. You might be asking yourself that this doesn't look different from a regular pipeline so far, and you would be right! The syntax only changes on the jobs level. See below how we would reference the build-scan-push workflow.

...
jobs:
  build:
    uses: wwg-internal/github-workflows/.github/workflows/build-scan-push.yaml@main
    with:
      publish: ${{ github.ref == 'refs/heads/main' }} # we will only publish if we're on the main branch. This stops a publish from happening on PRs
      repository: ghcr.io # We're pushing to GitHub Container Registry
      tag: latest
      image-name: ${{ github.event.repository.name }}
      nofail: true
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GITHUB_TOKEN }}

This syntax is special to reusable workflows because one doesn't need to provide steps or runs-on to the job. But how did I know how to include publish, repository, tag, and all the other inputs? That information can be found in the workflow file we're referencing under the workflow_call event trigger similar to how an action.yaml defines the input to an action.

We can also pass secrets as well as inputs by using the secrets keyword along with the with keyword. Be careful not to pass sensitive data to a reusable workflow's inputs.

An example use of reusable workflows used in client name is the builder-images repositories. These repos started as one repostiory that got split into multiple which you can find on the org's repository list. Reusable workflows were therefore very helpful because we could develop a workflow in one repo and then propigate it out into the other repositories. Image-Atlantis is one of those repositories.

i