Creating custom Github Actions: A complete tutorial

13 December, 2023 |  Vladimir Djurovic 
img/custom-github-actions.jpg

In this tutorial, we’ll go through the complete process of creating a custom Github Action. We will cover the following steps:

Table Of Contents

What will we build?

In this tutorial, we will build a complete, working Github action. In the previous post about automatic semantic versioning, we’ve seen how we can use it in CI/CD pipelines. As a continuation, we will build upon that knowledge and embed automatic semantic versioning into CI/CD pipeline using Github Actions.

This action will calculate versions in the following way:

  • if it’s being run in the context of pull request, it will simply return short commit hash as version (eg. 1234abc)
  • if it’s run on push to main branch, it will calculate next semantic version based on commit message

Getting started with custom Github Actions

Github Actions can be public or private. You can think of this in the same vein as public or private repositories. Public actions reside in public repositories and can be shared, while private actions are confied to a single repository or organization.

In addition to this, Github Actions can also be separated into three separate categories:

  1. Docker container actions - entire runtime environment and action code is packaged as Docker container
  2. JavaScript actions - these are written in JavaScript (or any language that can be transpiled to JavaScript) and run directly on runner machine. They are a bit faster then Docker actions, because there is no need to build a Docker image for it
  3. Composeite actions - allow you to combine multple workflow steps in a single action

For this tutorial, we’ll build a Docker container action. This will allow us to reuse shell scripts used for versioning which we created in previous post.

Create repository and metadata

The first step in the process is to create a repository that will contain action code. Since this will be a public action, we will use public repository. Actual repository for this action is available as git-auto-semver. Creating a repository is standard Github workflow.

Now we need to specify that this repository contains Github Action. We do this by creating a YAML file called action.yml or action.yaml. This files contains action metadata, as well as description of expected inputs and output. For our action, file is available here.

We’ll start with the most basic content for this file:

1
2
3
4
5
6
name: 'Git Automatic Semantic Versioning'
description: 'Automatically create new semantci version string based on commit message'
author: 'Bitshift'
runs:
  using: 'docker'
  image: 'Dockerfile'

First three lines specify the name of the action, short description and name of the author. Section runs contains two entries:

  • using - tells us that action uses Docker image
  • image - which Docker image will be used. This can be URL of the existing image (like Docker Hub URL). In our case, we want to build the image as a part of the action, so we specify Dockerfile here. This will cause the action to build the image using supplied Dockerfile.

Creating Dockerfile for the action

Next step in the process is to create a Dockerfile that will be created for the action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
FROM ubuntu:22.04

LABEL "com.github.actions.name" = "Git Automatic Semantic Versioning"
LABEL "com.github.actions.description"="Automatically create new semantci version string based on commit message"


LABEL repository="https://github.com/bitshifted/git-auto-semver"
LABEL maintainer="Bitshift"

COPY scripts/* /usr/bin
RUN chmod 755 /usr/bin/entrypoint.sh && chmod 755 /usr/bin/version.sh && chmod 755 /usr/bin/semver-bump.sh
RUN apt update && apt install -y git

ENTRYPOINT ["/usr/bin/entrypoint.sh"]

This is pretty basic Dockerfile. We use ubuntu:22.04 as base image, then add some standard labels to help Github identify and manage the image. We then copy required scripts from scripts directory to /usr/bin, so they are availble in OS path. These scripts are available in the scripts directory in action repository.

We also need to install git command, so it is available in the image. This is done in line 12. Finally, we set container entry point in the last line. Actual contant of this file is what we will create next.

Creating entry point script

Entrypoint is the command which is being run when the container starts. For our purposes, we want the following sequence of events:

  1. if action is run in pull request, return commit short hash as output
  2. if action is run after push, first check if push is to main branch. If so, calculate and return next version. Otherwise, just return

Before we can create this script, we need to define expected inputs and output for our action. This is done in action.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
inputs:
  main_branch:
    description: 'Main branch for the repository'
    required: false
    default: 'main'
  initial_version:
    description: 'First version, defaults to 1.0.0'
    required: false
    default: '1.0.0'
outputs:
  version-string:
    description: 'Semantic version string'

Here, we define two inputs:

  • main_branch - this will be an optional input, with default value of main if nothing is specified. This tells our action which branch is considered to be main branch
  • initial_version - also an optional input with default value of 1.0.0. This will be used as initial version, if no tags are specified

Inside Github action, we refer to these inputs as INPUT_<input name> variables, where <input name> is capitalized version of the name. So, for our inputs, we can refer to them as INPUT_MAIN_BRANCH and INPUT_INITIAL_VERSION.

Section outputs defines outputs from the action. Here, we have only one output called version-string. We can refer to this output by this name in subsequent workflow steps.

After we have configured inputs and output, we can create the entrypoint script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#! /bin/bash

set -u # gives warning when using undeclared variables

# fix error 'fatal: detected dubious ownership in repository at '/github/workspace'' in Github Action
sh -c "git config --global --add safe.directory $PWD"

VERSION_STRING="$INPUT_INITIAL_VERSION"

if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
    VERSION_STRING=$(version.sh --pull-request)
elif [ "$GITHUB_EVENT_NAME" = "push" ]; then
    if [ "$GITHUB_REF" = "refs/heads/$INPUT_MAIN_BRANCH" ]; then
        VERSION_STRING=$(version.sh)
    else 
        echo "Push not on main branch"
    fi
fi

echo "version-string=$VERSION_STRING" >> "$GITHUB_OUTPUT"

exit 0

The script will infer event that triggered the action from environment variable GITHUB_EVENT_NAME. If event name is pull_request, it will set the value of VERSION_STRING variable to short commit hash. If event name is push, and affected branch is equal to what we specified as main_branch input, it will calculate next semantic version.

Finally, we need to output resulting version. We do it by writing into special environment variable called GITHUB_OUTPUT.

And, we’re done! This is all that wee need to have a working action. Next, we want ot automate building and versioning of the action itself.

Automate bulding the action

In order to build and test the action, we need to specify regular workflow. This is done with file .github/workflows/build-and-test.yml, available here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: Build and test
on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]
jobs:
  build-and-test-project:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
          fetch-tags: true
      - name: Generate version
        id: generate-version
        uses: ./
      - name: Print version information
        if: steps.generate-version.outputs.version-string != ''
        run: echo ${{ steps.generate-version.outputs.version-string }}
      - name: Tag version
        if: ${{ github.event_name == 'push' }}
        run: |
          echo "Tagging version: ${{ steps.generate-version.outputs.version-string }}"
          git tag  v${{ steps.generate-version.outputs.version-string }}
          git tag --force v1 v${{ steps.generate-version.outputs.version-string }}
          git push origin --tags          

This workflow will run on the following events:

  • a pull request to main branch is craeted or updated
  • a push is performed to main branch

Step Checkout code will simply checkout the repository. Here, we are most interested in steps Generate version and Print version information.

In Generate version, we run the action itself. Notice the uses: ./ line. It will cause the step to run with action defined with the repository. Output of action run will be available as variable steps.generate-version.outputs.version-string. We print this value in the line 21, using echo ${{ steps.generate-version.outputs.version-string }} command.

The last step Tag version will run only on push event. In this step we create a git tag with the value calculated by the action. Initial value will be 1.0.0, since it is the default version.

Tag created here will be in format v1.0.0. But, in line 28, we create another tag v1. Why do we need this? Well, this tag will always point to the latest version of the action.

Usually, Github Actions are used in workflows as uses: owner/action@v1. This way, each time we modify the action, but stay on same major version, all clients will pick up new update without requiring any change in workflow.

If you now run this workflow in your repository, you should get a tagged version 1.0.0.

Publishing action to Github Action marketplace

After all the hard work, it is now time to release our action into wild by publishing it to Github action marketplace. Before we can do that, we need to address two requirements:

  • documentation - action is no good if nobidy knows how to use it. Usually, it is sufficient to includebasic information about the usage i the README file. At a minimum, it should action description, expected inputs and outputs and example of usage
  • branding - Github allows us to specify color and icon for the action. This is specified as part is action.yml file
1
2
3
branding:
  color: blue
  icon: tag

Information about branding can be found here. Only available options are color and icon. Icon must be one of the Feathericons.

Once all of this is complete, it’s time to perform the actual release. To start got to Releases section and click Draft new release button:

Github Actionr release button

Draft a new release in Github

In the next page, you need to fill in information about the action. You need to select a primary category and secondary category. For tag, you can select semantic versioned tag we created (ie. 1.0.0) and fill in short description.

Form with information about Github action

Fill in the information form

And this is it! Once you click the “Publish release” button, your action will be available in the marketplace. You can checkout this action in Github Action marketplace here.

Using the action in workflow

Now that the action is published and available on Github Marketplace, it can be used in other workflows. For real-world usage of this action, checkout the ui-graphics-tools project.

In general, action can be used as follows:

1
2
3
4
5
6
- name: Calculate version
     id: calculate-version
     uses: bitshifted/git-auto-semver@v1
- name: Another step
  run: |
    echo "Version: ${{ steps.calculate-version.outputs.version-string }}"    

Another example of this action in practice is described in deploying artifacts to Maven Central repository blog post.

Conclusion

I hope you enjoyed this ride! I’m a great fan of automation and code reuse, and creating and publishing actions is a great way to improve both.

If you have any questions or comments, please feel free to post using the form bellow.