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:
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:
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
0 comments