Skip to content

Creating Custom GitHub Actions

Since its generally available release in Nov 2019, Github Actions has seen an incredible increase in adoptions.

Github Actions allows you to automate, customize and execute your software development workflows.

In this article, we will learn how to create our first custom Github Actions using Typescript. We will also show some of the best practices, suggested by Github, for publishing and versioning our actions.

Types of Actions

There two types of publishable actions: Javascript and Docker actions.

Docker containers provide a more consistent and reliable work unit than Javascript actions because they package the environment with the Github Actions code. They are ideal for actions that must run in a specific configuration.

On the other hand, JavaScript actions are faster than Docker actions since they run directly on a runner machine and do not have to worry about building the Docker image every time. Javascript actions can run in Windows, Mac, and Linux, while Docker actions can just run in Linux. But most importantly (for the purpose of this article), Javascript actions are easier to write.

There is a third kind of Action: the Composite run steps Actions. These help you reuse code inside your project workflows, and hide complexity when you do not want to publish the Action to the marketplace.

You can quickly learn how to create Composite run step Actions in this video, or by reading through the docs.

The Action

For this article, we will be creating a simple Javascript Action.

We will use the Typescript Action template to simplify our setup, and use TypeScript out of the box. The objective is to walk over the whole lifecycle of creating and publishing a custom GitHub Action.

We will be creating a simple action that counts the Lines of Code (LOC) of a given type of file, and throws if the sum of LOC exceeds a given threshold.

Keep in mind that the source code is not production-ready and should only be used for learning.

The Action will receive three params:

  • fileOrFolderToProcess (optional): The file or folder to process
  • filesAndFoldersToIgnore (optional): A list of directories to ignore. It supports glob patterns.
  • maxCount (required): The maximum number of LOC for the sum of files.

The Action recursively iterates over all files under the folder to calculate the total amount of Lines of Code for our project.

During the process, the Actions will skip the files and folders marked to ignore, and at the end, if the max count is reached, we throw an error.

Additionally, we will set the total LOC in an Action output no matter the result of the Action.

Setting up the Environment

JavaScript Github Actions are not significantly different from any other Javascript project. We will set up some minimal configuration, but you should feel free to add your favorite workflow.

Let us start by creating the repository. As mentioned, we will use the Typescript Github Actions template, which will provide some basic configuration for us.

We start by visiting https://github.com/actions/typescript-action. We should see something like this:

typesscript-template-gh-actions

The first thing we need to do is add a start to the repo :). Once that is completed, we will then click on the "Use this template" button.

We are now in a regular "create new repository" page that we must fill.

Create a new repository page of GitHub

We can then create our new repository by clicking the "Create repository from template" button.

Excellent, now our repository is created. Let us take a look at what this template has provided for us.

Freshly Created Repository using the TypeScript Template

The first thing to notice is that Github recognizes that we are in a GitHub Actions source code. Because of that, GitHub provides a contextual button to start releasing our Action.

Publish to marketplace button

The file that allows this integration is the action.yml file. That is the action metadata container, including the name, description, inputs, and outputs. It is also where we will reference the entry point .js for our Action.

Action.yml with default data

The mentioned entry point will be located in the dist folder, and the files contained there is the result of building our Typescript files.

Important! Github uses the dist folder to run the Actions. Unlike other repositories, this build bundle MUST be included in the repository, and should not be ignored.

Our source code lives in the source folder. The main.ts is what would be compiled to our Action entry point index.js. There is where most of our work will be focused.

Additional files and configurations

In addition to the main files, the TypeScript template also adds configuration files for Jest, TypeScript, Prettier and ESLint.

A Readme template and a CODEOWNERS file are included, along with a LICENSE.

Lastly, it will also provide us with a GitHub CI YAML file with everything we need to e2e test our Action.

ci yaml file with pipeline steps

Final steps

To conclude our setup walkthrough, let us clone the repository. I will be using mine, but you should replace the repository with yours.

git clone https://github.com/NachoVazquez/loc-alarm.git

Navigate to the cloned project folder, and install the dependencies.

npm i

Now we are ready to start implementing our Action.

The implementation

First we must configure our action.yml file and define our API.

The metadata

The first three properties are mostly visual metadata for the Workspace, and the Actions tab.

name: 'Lines Of Code Alert'
description: 'Github Action that throws an error when the specified maximum LOC count is reached by any file'
author: 'Nacho Vazquez -- This Dot, Inc'

The name property is the name of your Action. GitHub displays the name in the Actions tab to help visually identify actions in each job.

GitHub will also use the name, the description, and the author of the Action to inform users about the Action goal in the Actions Marketplace.

Ensure a short and precise description; Doing so will help the users of the Action quickly identify the problem that the Action is solving.

Next, we define our inputs. Like we did with the Action, we should write a short and precise description to avoid confusion about the usage of each input variable.

inputs:
  fileOrFolderToProcess:
    required: false
    description: 'The file or folder to process'
    default: '.'
  filesAndFoldersToIgnore:
    required: false
    description: 'A list of directories to ignore. Supports glob patterns.'
    default: '["node_modules", ".git", "dist", ".github"]'
  maxCount:
    required: true
    description: 'The maximum number of LOC for the sum of files'

We will mark our inputs as required or optional, according to what we already specified when describing our plans for the Action.

The default values help provide pre-configured data to our Action.

As with the inputs, we must define the outputs.

outputs:
  locs:
    description: 'The amount of LOC of your project without comments and empty lines'

Actions that run later in a workflow can use the output data set in our Action run.

If you don't declare an output in your action metadata file, you can still set outputs and use them in a workflow.

However, it would not be evident for a user searching for the Action in the Marketplace since GitHub cannot detect outputs that are not defined in the metadata file.

Finally, we define the application running the Action and the entry point for the Action itself.

runs:
  using: 'node12'
  main: 'dist/index.js'

Now, let's see everything together so we can appreciate the big picture of our Action metadata.

name: 'Lines Of Code Alert'
description: 'Github Action that throws an error when the specified maximum LOC count is reached by any file'
author: 'Nacho Vazquez -- This Dot, Inc'
inputs:
  fileOrFolderToProcess:
    required: false
    description: 'The file or folder to process'
    default: '.'
  filesAndFoldersToIgnore:
    required: false
    description: 'A list of directories to ignore. Supports glob patterns.'
    default: '["node_modules", ".git", "dist", ".github"]'
  maxCount:
    required: true
    description: 'The maximum number of LOC for the sum of files'
outputs:
  locs:
    description: 'The amount of LOC of your project without comments and empty lines'
runs:
  using: 'node12'
  main: 'dist/index.js'

The Code

Now that we have defined all our metadata and made GitHub happy, we can start coding our Action.

Our code entry point is located at src/maint.ts. Let's open the file in our favorite IDE and start coding.

Let's clean all the unnecessary code that the template created for us. We will, however, keep the core tools import.

import * as core from '@actions/core'

The core library will give us all the tools we need to interact with the inputs and outputs, force the step to fail, add debugging information, and much more. Discover all the tools provided by the Github Actions Toolkit.

After cleaning up all of the example code, the initial step would be extracting and transforming our inputs to a proper form.

// extract inputs
const filesAndFoldersToIgnore = JSON.parse(
  core.getInput('filesAndFoldersToIgnore')
)
const maxCount: number = +core.getInput('maxCount')
const fileOrFolderToProcess: string = core.getInput('fileOrFolderToProcess')

With our inputs ready, we need to start thinking about counting our LOC while enforcing the input restrictions.

Luckily there is a couple of libraries that can do this for us. For this example, we will be using node-sloc, but feel free to use any other.

Go on and install the dependency using npm or any package manager that you prefer.

npm install --save node-sloc

Import the library.

import sloc from 'node-sloc'

And the rest of the implementation is straightforward.

// calculate loc stats
const stats = await sloc({
  path: fileOrFolderToProcess,
  extensions: ['ts', 'html', 'css', 'scss'],
  ignorePaths: filesAndFoldersToIgnore,
  ignoreDefault: true
})

Great! We have our LOC information ready. Let's use it to set the output defined in the metadata before doing anything else.

// set the output of the action
core.setOutput('locs', stats?.sloc)

Additionally, we will also provide debuggable data. Notice that debug information is only available if the repository owner activated debug logging capabilities.

// debug information is only available when enabling debug logging https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging
core.debug(`LOC ${stats?.sloc?.toString() || ''}`)
core.debug(`Max Count ${maxCount.toString() || ''}`)

Here is the link if you are interested in debugging the Action yourself.

Finally, verify that the count of the LOC is not exceeding the threshold.

// verify that locs threshold is not exceeded
if ((stats?.sloc || 0) > maxCount) {
  core.debug('Threshold exceeded')
  throw new Error(
    `The total amount of lines exceeds the maximum allowed.
     Total Amount: ${stats?.sloc}
     Max Count: ${maxCount}`
  )
}

If the threshold is exceeded, we use the core.setFailed, to make this action step fail and, therefore, the entire pipeline fails.

catch (error) {
  core.setFailed(error.message)
}

Excellent! We just finished our Action. Now we have to make it available for everyone.

But first, lets configure our CI to perform an e2e test of our Action.

Go to the file .github/workflows/*.yml. I called mine ci.yml but you can use whatever name makes sense to you.

name: 'loc-alarm CI'

on: # rebuild any PRs and main branch changes
  pull_request:
  push:
    branches:
      - main

jobs:
  build: # make sure build/ci work properly
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: npm install
      - run: npm run build

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: ./
        id: e2e
        with:
          maxCount: '1000'
      # Use the output from the `e2e` step
      - name: Get the LOC
        run: echo "The project has ${{ steps.e2e.outputs.locs }} lines of code"

Here, we are triggering the pipeline whenever a pull request is created with base branch main or the main branch itself is pushed.

Then, we run the base setup steps, like installing the packages, and building the action to verify that everything works as it should.

Finally, we run e2e jobs that will test the actions as we were running it in an external application.

That's it! Now we can publish our Action with confidence.

Publish and versioning

Something you must not forget before any release is to build and package your Action.

npm run build && npm run package

These commands will compile your TypeScript and JavaScript into a single file bundle on the dist folder.

With that ready, we can commit our changes to the main branch, and push to origin.

Go back to your browser and navigate to the Action repository.

First, go to the Actions tab and verify that our pipeline is green and the Action is working as expected.

Action Pipeline green

After that check, go back to the "Code" tab, the home route of our repository.

Code tab loc-alarm repo

Remember the "Draft a release" button? Well, it is time to click it.

We are now on the releases page. This is where our first release will be created.

release action page

Click on the terms and conditions link, and agree with the terms to publish your actions.

Term and conditions
terms and conditions

Check the "Publish this Action to the Github Marketplace" input, and fill in the rest of the information.

fill release information

You can mark this as pre-release if you want to experiment with the Action before inviting users to use it.

pre release check

And that's it! Just click the "Publish release" button.

First release

Tada! Click in the marketplace button to see how your Action looks!

marketplace

After the first release is out, you will probably start adding features or fixing bugs. There are some best practices that you should follow while maintaining your versioning.

Use this guide to keep your version under control. But the main idea is that the major tag- v1 for instance- should always be referencing the latest tag with the same major version.

This means that if we release v1.9.3 we should update v1 to the same commit as v1.9.3.

Our Action is ready. The obvious next step is to test it with a real application.

Using the Action

Now it is time to test our Action, and see how it works in the wild.

We are going to use our Plugin Architecture example application. If you have read that article yet, here is the link.

The first thing we need to do is create a new git branch.

After that, we create our ci.yml file under .github/workflows.

And we add the following pipeline code.

name: Plugin Architecture CI

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  loc:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: NachoVazquez/loc-alarm@v1
        id: loc-alert
        with:
          maxCount: 1000
      - name: Print the LOC
        run: echo "The project has ${{ steps.loc-alert.outputs.locs }} lines of code."

Basically, we are just triggering this Action when a PR is created using main as the base branch, or if we push directly to main.

Then, we add a single job that will checkout the PR branch and use our Action with a max count of 200.

Finally, we print the value of our output variable.

Save, commit, and push.

Create your PR, go to the check tab, and see the result of your effort.

create pr

Great! We have our first failing custom GitHub action.

failing ci

Now, 200 is a bit strict. Maybe 1000 lines of code are more appropriate.

Adjust your step, commit, and push to see your pipeline green and passing.

successful pipeline

How great is that!?

Conclusion

Writing Custom GitHub Actions using JavaScript and TypeScript is really easy, but it can seem challenging when we are not familiar with the basics.

We covered an end-to-end tutorial about creating, implementing, publishing, and testing your Custom GitHub Action.

This is really just the beginning. There are unlimited possibilities to what you can create using GitHub Actions.

Use what you learned today to make the community a better place with the tools you can create for everyone.