Building Your First GitHub Action

Avatar

Steven

This is a guest post from one of our friends at GitHub, John Bohannon. Welcome John!

Introduction

Hello from the friendly octocats at GitHub! By now, you might have used GitHub Actions for automation, CI/CD, and more – and you know it’s powered by thousands of modular, community-developed building blocks called actions. In this guide, you’ll use TypeScript to build a GitHub action to interact with an existing service, and then learn how to publish it to GitHub Marketplace for discovery by the larger GitHub community.

So why would you want to write your own action? Reasons include:

  • Facilitate developer interaction with an existing API, service, or product.
  • Contribute to the DevSecOps ecosystem, reaching the 56+ million developers on GitHub.
  • Use Azure DevOps and GitHub Actions together in a complementary fashion or while migrating step by step

The action we build in this guide will make it easy to upload files to Azure Blob Storage, a service for massively scalable and secure storage of files like images, videos, logs, and backups. Let’s begin!

Method

1️⃣ Design the user experience

Start by designing a good user experience (UX):

  • How do we want someone to consume the action?
  • How might someone already be used to interacting with Azure Blob Storage programmatically, whether by Azure CLI, popular SDKs, or existing actions?
  • What other actions or run steps might commonly precede or follow it?

From some quick research, it looks like accepting a set of files (maybe supporting globbing) and an upload destination (maybe a storage account and container) is a good set of functionality to begin with. And maybe the action could be used in conjunction with some of the existing Azure actions… πŸ€”

An action takes inputs and produces outputs, a model which lets actions be piped together with other actions, like these in the GitHub Marketplace, in a workflow. We can start with outputting the filename and url of the uploaded blob.

So, a user experience like this should do the trick. Here’s an imaginary workflow calling the yet-to-be-built upload-azure-blob action:

# GitHub Actions repository workflow file, e.g .github/workflows/upload.yml

# ...
# previous steps to choose a runner type, authenticate to Azure, prepare files, etc
# ...

# Upload `.png`s to Azure Blob Storage
name: Upload all PNGs to Azure Blob Storage
id: upload
uses: github-developer/upload-azure-blob@v1
with:
  account: octodex
  destination: octocats
  source: **/*.png

# Print out the urls to uploaded files
name: Print URLs
run: echo $URLS # { ["filename":"hulatocat.png","url":"https://octodex.blob.core.windows.net/octocats/hulatocat.png"] }
env:
  URLS: ${{ steps.upload.outputs.urls }}

Quick aside: an action is “just” a GitHub repository with a metadata file called action.yml. Workflows can call them using a pattern like uses: {owner}/{repo}/{path}@{ref} – in the example above, uses: github-developer/upload-azure-blob@v1.

To match the workflow above, the action.yml metadata file for upload-azure-blob can look something like this. Progress!

# GitHub action metadata file, action.yml

# ... name, description, branding

inputs:
  account:
    description: |-
      Storage account name, e.g. 'mystorageaccount'
    required: true

  destination:
    description: |-
      Name of container to upload blob to, e.g. '$web' to upload a static website.
    required: true

  source:
    description: |-
      Path to file(s) to upload to "destination", e.g. '.' to upload all files in the current directory.
      Supports globbing, e.g. 'images/**.png'.
      For more information, please refer to https://www.npmjs.com/package/glob.
    required: true

outputs:
  urls:
    description: |-
      URL(s) to the uploaded blob(s), if successful

# ...

With the metadata description complete, now we consider the action logic and come to a fork in the road.

There are advantages to building a container action, but arguably more for JavaScript actions. They load faster, work on all runner types, and build upon a thriving community of tooling like actions/toolkit! JavaScript also happens to be the most-used language across GitHub (read the Octoverse 2020 report), so chances are, choosing JavaScript will open doors to community contribution to our action, too.

And you know what? Anything that compiles to JavaScript is eligible, so let’s go with CoffeeScript errr nah, TypeScript, for some extra DX love πŸ˜ƒ. I’ve been wanting to try optional chaining anyway.

2️⃣ Get to coding

Start with a template

The GitHub Actions team provides a nice set of template repos, ready to help us get started! By generating a new repo from actions/typescript-action, we start with a functioning action. Note that the name of this repository forms part of the path to your action. I generated mine into github-developer/upload-azure-blob, so it can be referenced that way across GitHub. Not to leave out all you container action devs, there’s a template or two for you too.

And wow, to TypeScript eyes this action looks very…normal, except for that one stray metadata file, action.yml, most of which we designed before ☝️. Copy that in now.

.
β”œβ”€β”€ .github
β”‚   └── workflows
β”‚       └── test.yml
β”œβ”€β”€ .gitignore
β”œβ”€β”€ LICENSE
β”œβ”€β”€ README.md
β”œβ”€β”€ __tests__
β”‚   └── main.test.ts
β”œβ”€β”€ action.yml
β”œβ”€β”€ jest.config.js
β”œβ”€β”€ lib
β”‚   β”œβ”€β”€ main.js
β”‚   └── wait.js
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ main.ts
β”‚   └── wait.ts
└── tsconfig.json

This action isn’t very useful for uploading to Azure Storage though – it just waits the number of milliseconds we input. But it’s a great base, since it does include compilation support, tests, a validation workflow, publishing, and versioning guidance!

Leverage official libraries and actions

We won’t get very far without access to an Azure SDK of some sort, like @azure/storage-blob. Used together with @azure/identity, we should be able to get an authenticated client to start make function / API calls. But “authenticated”… with what tokens? Whose identity? Well, we’re covered there, too 😌.

This TypeScript code we’re writing will be compiled and then called from a GitHub Actions workflow, right? And we saw earlier that there is an extensive set of actions just for Azure. Well, one of these actions happens to facilitate secure, programmatic authentication with Azure on Actions runners!

So in our repository documentation (e.g. README), we can direct our users to call azure/login in their workflows before upload-azure-blob, like this:

# GitHub Actions repository workflow file, e.g .github/workflows/upload.yml

# ...
# previous steps to choose a runner type, authenticate to Azure, prepare files, etc
# ...

# Log into Azure using repository secret with service principal credentials
# https://github.com/Azure/login#configure-deployment-credentials
- uses: azure/login@v1
  with:
    creds: ${{ secrets.AZURE_CREDENTIALS }}

# Upload `.png`s to Azure Storage Blob
- uses: github-developer/upload-azure-blob@v1
  with:
    account: mystorageaccount
    destination: nameofstoragecontainer
    source: **/*.png

Is that cool or what? Our function calls like these will suddenly work:

// src/upload.ts
export const getBlobServiceClient = (account: string): BlobServiceClient => {
  // https://github.com/Azure/azure-sdk-for-js/blob/master/sdk/identity/identity/README.md#defaultazurecredential
  const defaultAzureCredential = new DefaultAzureCredential()
  const blobServiceClient = new BlobServiceClient(
    `https://${account}.blob.core.windows.net`,
    defaultAzureCredential
  )
  return blobServiceClient
}

Write the upload logic

Now we can write the logic to upload globbed blob (I just wanted to type that) paths like *.log, **/*.png. Yet again, we build on the shoulders of giants by pulling in a trusty npm package, glob.

To save us some time, and to spare you a very messy commit history (what, just me?!), here’s how the top-level main.ts shapes up, with the core functionality in upload.ts. Refer to the full code in the the open source repo.

// src/main.ts
import * as core from '@actions/core'
import { getInputs, getBlobServiceClient, uploadBlobs, Inputs, Output } from './upload'

async function run (): Promise<void> {
  try {
    // Retrieve user inputs to action
    const inputs: Inputs = getInputs()
    // Retrieve authenticated container client
    const containerClient = getBlobServiceClient(inputs.account).getContainerClient(inputs.destination)
    // Upload files to container
    const output: Output[] = await uploadBlobs(containerClient, inputs.source)

    core.setOutput('urls', JSON.stringify(output))
  } catch (error) {
    core.setFailed(error.message)
  }
}

// eslint-disable-next-line @typescript-eslint/no-floating-promises
run()

One distinction actions have with regular JavaScript projects is that dependent packages are committed alongside the code, usually in a minified form using a compiler / bundler like @vercel/ncc or Parcel. The typescript-action template already includes ncc, so by running npm run package, you get a nice, compiled dist/index.js file. This is the entry point to the action and should match main in action.yml.

Test and automate

Of course, we should add some Jest unit tests to raise confidence in future changes πŸ˜‡, and then we can use GitHub Actions as a test runner…to test our action! Let’s start with some quick sanity checks of uploadBlobs, just like you would with any other TypeScript project:

// __tests__/upload.test.ts

// ...
// test files message1.txt, message2.txt exist in repo
// ...

test('uploadBlobs doesn\'t call uploadBlob with zero files', async () => {
  await upload.uploadBlobs(client, 'bananas.txt')
  expect(mockedUploadBlob.mock.calls.length).toBe(0)
})

test('uploadBlobs calls uploadBlob with one file', async () => {
  await upload.uploadBlobs(client, 'message.txt')
  expect(mockedUploadBlob.mock.calls.length).toBe(1)
})

test('uploadBlobs calls uploadBlob with two files', async () => {
  await upload.uploadBlobs(client, 'message*.txt')
  expect(mockedUploadBlob.mock.calls.length).toBe(2)
})

// ...

You could try remember to run these tests locally or in GitHub Codespaces, but it’s far better for them to run automatically every time a push happens or a pull request is opened. Sounds like a job for GitHub Actions! A sample continuous integration (CI) workflow is included to build, test, and push compiled changes back to the repo.

To view all the source code and see how you can pull in the action to your very own workflow, refer to this open source repo πŸ€—.

3️⃣ The action in action

So far, we have:

  • Designed
  • Developed
  • Tested
  • And automated CI for upload-azure-blob πŸŽ‰

Now that we’ve built the engine, let’s see this thing go πŸš€! After creating an Azure storage account and container and releasing v1 of the action, here’s me uploading some favorite octocats to my very own Octodex:

upload

4️⃣ Release and publish to GitHub Marketplace

As it stands, upload-azure-blob is instructional but not necessarily production-ready. Here are just a few ideas of how it could be improved: – Automate release and maintenance (e.g. by following this guide) – Remove requirement to have to create the destination before uploading to it – Increase test coverage

When we’re happy with the way things look, we can start to distribute and promote. How can I get this in more hands? Well for starters, anyone can publish actions to GitHub Marketplace!

When drafting a new release, it’s as simple as accepting the terms and clicking a checkbox:

publish-to-github-marketplace

To further promote and partner with GitHub, consider joining the GitHub Technology Partner Program.

Wrapping up

Today, we started with an idea (“upload to Azure Blob Storage”) and ended with a working GitHub action published in GitHub Marketplace πŸ₯³, all while building upon open source tooling and libraries.

We encourage you to try it out by building and publishing actions that facilitate your own DevOps journey. See you around!

John from GitHub Partnerships

2 comments

Leave a comment