Take some action

GitHub Actions is a great tool to automate development pipeline. One of the biggest benefits it the ability to cusomize and reuse Custom Actions. This blog post will guide you step by step on how to create your own Custom Action for GitHub Actions using JavaScript.

Elon Salfati profile picture
Elon Salfati
@elonsalfati

GitHub Actions is one of my favorite CI/CD tools. The fact that it's bundled directly to GitHub makes it extremely easy to use within their ecosystem. GitHub Actions aims to provide a simpler way to automate your software workflows, test your code, deploy your application, or even manually run a simple one-time flow. In addition, with GitHub Actions, you get live logs, built-in secret management solutions for your organization or specific repository, parallel execution, and more than anything else - a vibrant community of open-source creators that publish new actions daily.

If you've used GitHub Actions before, you might have seen a step similar to:

- uses: actions/upload-artifact@v2  with:    name: version-before    path: version-before

Specifically, this example uses a pre-built action that uploads an artifact to GitHub. It's an action that someone (in this case - GitHub) has built in a way that any engineer can reuse for their own needs. The ability to reuse Actions enables excellent growth in the engineering community. Each Action describes a reusable task with a set of predefined inputs and outputs.

Types of Actions

There are three types of Customer Actions you can build:

  1. Docker container Actions - allows you to leverage any docker images that can run any piece of code to run the task at hand. Using a Docker container, you can get a fully customized environment to run a much more complex logic while having complete control over the job. However, Docker containers are slower than JavaScript Actions which, ideally, we would want to avoid when we can.
  2. JavaScript Actions is probably the best choice for most cases. Using JavaScript Actions, you can run a set of JavaScript scripts that execute your logic. When using JavaScript Actions, you can get full access to the data in the runner. GitHub even provides a tool kit to develop these actions.
  3. Composition Action is a way to bundle a set of tasks into one. It's pretty much like writing a pipeline for your project. You can run and reuse Custom Actions one after the other to combine more complex use cases.

Pricing

GitHub Actions is free for public repository and open-source projects (thank you, GitHub!). For private repositories, it provides 2,000 minutes of running actions; beyond that, it's a transparent pay-as-you-go pricing.


Let's build & share Custom Actions that you can use anywhere

This blog post will guide you through the process of building a Custom Action that scans the repository for NPM packages and publishes them to the GitHub artifact store. We will create a Custom Action that engineers will be able to use to publish all their NPM packages to the GitHub artifacts store using JavaScript Custom Actions. The Custom Action usage will look like this:

uses: <YOUR_REPOSITORY>/publish-all-npm-packages-to-github@v1with:    # the scope for the NPM packages    scope: "@<YOUR_SCOPE>"    # Enforce a specific version to publish    tag: "v1.0.0"env:    AUTH_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

The basics of building your own Custom Action

When adding a use: <REPOSITORY>@<BRANCH> line in your pipeline, GitHub looks for public repositories that contain the action.yml file. The actions.yml file tells GitHub Actions that this repository contains a relevant execution file that can be used in a pipeline in addition to the inputs and outputs provided by this Action.

Create a Custom Action Repository

First of all, the GitHub Actions team provides a template repository for building GitHub Action using any method you want - you can find the JavaScript template repository here or the rest of the examples in their organization here.

Or, you can just create an empty repository and your way through it - which is what we will do :-)

To create the basic project structure, we will run the commands below. It will help us set the following project structure:

+ src/
|---- + index.js
\---- + utils.js
|
+ action.yml
+ .gitignore
+ package.json
  • Create a new local folder that will contain the project source code
mkdir publish-all-npm-packages-to-github
  • In the created folder, we'll create all the required files to develop the Custom Action
# go to the project foldercd publish-all-npm-packages-to-github/# create the source filemkdir src/touch src/index.jstouch src/utils.js# create the main aciton.yml manifesttouch action.yml# create a new javascript package.json fileyarn init -y# create a gitignore file and add ignored filestouch .gitignoreecho "node_modules/" >> .gitignore
  • Install all the development dependencies for the Custom Action
yarn add @actions/core @actions/github globyarn add -D @vercel/ncc

Define the inputs and outputs for the Custom Action - action.yml

The action.yml file is the manifest GitHub Actions uses to execute your Custom Action. It defines the inputs, outputs, name, and execution code used by the Custom Action.

name: Detect and Publish any NPM package to GitHub Artifact Storeauthor: <YOUR_NAME :-)>inputs:    scope:        description: "Required. The scope of the NPM packages to publish to. For example @salfatigroup"        required: true    tag:        description: "Required. Enforce the version of the NPM packages before publishing."        required: trueoutputs:    modules:        description: The modules published by this Custom Action. For example, @salfatigroup/foo@1.0.0, @salfatigroup/bar@1.0.0runs:    using: node16    main: dist/index.js

This action.yml manifest tells GitHub Actions that this Custom Action receives two required inputs - the scope of the NPM package and the version that needs to be published. It returns a list of the published packages. Finally, it uses Node 16 to run the JavaScript file in src/index.js

Implementing our JavaScript logic

index.js contains the main flow of our logic. It includes a single function we will call to get all the required inputs, scan for packages, publish these packages, and finally return the list of published modules.

File: src/index.js

const core = require("@actions/core")const { getInputs, scanPackages, publish } = require("./utils.js")/** * Root function of the custom actions. Contains the main logic flow. */async function run() {    try {        // get the inputs form the user        const userInputs = getInputs()        // scan for NPM packages        const packages = await scanPackages()        if (packages.length === 0) {            core.debug(`No NPM modules detected in codebase`)            return        }        // publish the NPM packages to GitHub        const publications = await publish(packages, userInputs.scope, userInputs.tag, userInputs.token)        // return the outputs        core.setOutput("modules", Array.from(publications).join(", "))    } catch (error) {        core.setFailed(error.message)    }}// run the custom action logicrun()

An interesting note for this snippet is the use of the @actions/core packages, which allows us to print debug data to the pipeline logs by using core.debug, stop the pipeline when an error occurs by using core.setFailed or define the outputs of the module by using core.setOutput

Now, to the utility file:

File: src/utils.js

const fs = require("fs")const path = require("path")const glob = require("glob")const core = require("@actions/core")const { execSync, exec } = require("child_process")/** * Return the inputs from the GitHub Actions - These are defined in the action.yml manifest and are pulled here into the source code. */module.exports.getInputs = () => {    const inputs = {        token: process.env.AUTH_TOKEN,        scope: core.getInput("scope"),        tag: core.getInput("tag"),    }    core.debug(`Scope: ${inputs.scope}`)    core.debug(`Tag: ${inputs.tag}`)    return inputs}/** * Scan the repository for NPM packages */module.exports.scanPackages = () => {    return glob.sync("**/package.json", {        ignore: [            "**/node_modules/**,        ]    })}/** * Publish the detected packages to NPM while enforcing the required tag */module.exports.publish = (packages, scope, tag, token) => {    setGlobaNpmrc(scope, token)    setPackageNpmrc(process.env.GITHUB_WORKSPACE, scope)    const publishPromises = packages.map((package) => {        const packageDir = path.dirname(package)        setPackageNpmrc(packageDir, scope)        if (isPrivatePackage(package)) {            core.debug("Skipping private package at: ", packageDir)            return        }        return publishPackage(packageDir, tag)    })}

Now let's add some additional utility functions to support the publish function

function setGlobaNpmrc(scope, token, registry="https://npm.pkg.github.com") {    const npmrcPath = path.join(process.env.HOME, '.npmrc')    if (!fs.existsSync(npmrcPath)) {        fs.writeFileSync(npmrcPath, `//${register}:_authToken=${token}\nalways-auth=true`)    }}function setPackageNpmrc(packageDir, scope, registry="https://npm.pkg.github.com") {    if (!fs.existsSync(npmrcPath)) {        fs.writeFileSync(npmrcPath, `${scope}:registry=https://${registry}`)    }}function isPrivatePackage(package) {    return require(package).private}function publishPackage(packageDir, tag) {    return new Promise((resolve, reject) => {        const packageVersionCmd = `npm version ${tag} --no-git-tag-version`        execSync(packageVersionCmd, { cwd: packageDir })        const cmd = `npm publish ${packageDir}`        exec(cmd, (err, stdout, stderr) => {            if (err) {                reject(err)            } else {                resolve(packagePath)            }        })    })}

Building the package

Now that we have written our Custom Action, we can use @vercel/ncc to build our Custom Action into a single JavaScript file.

Add the following script to your package.json under the "scripts" section:

"scripts": {    // ...    "build": "ncc build index.js -o dist --source-map"}

Publishing to GitHub

We're almost done. All that is left is to create our v1 tag and publish the source code to GitHub.

If you haven't initialized the git repository yet:

git init .git remote add origin https://github.com/<YOUR_USERNAME>/publish-all-npm-packages-to-github.git

Add all the changes, commit them to the main branch, and create a new tag v1.

git add .git commit -am "Woohoo... We created a Custom GitHub Action together with Salfati Group"git tag -a v1 -m "First version of my Custom Action"git push

🎉 You're done!

Salfati Group

Salfati Group is a holding company that provides products and services in the software architecture, cloud, and cybersecurity space. We've adopted go workspace completely, allowing us to decouple and reuse our go modules elegantly.

Nopeus

By enabling a cloud application layer™, Nopeus can help manage your cloud infrastructure with 10x less resource and time. In addition, Nopeus, our latest project, pushed us to adopt Go and Go workspace even further. Check our GitHub project at https://github.com/salfatigroup/nopeus. You can also check nopeus cloud for more advanced features. Follow me on Twitter (@elonsalfati) to follow my journey #buildinginpublic Salfati Group and Nopeus


Get all of our updates directly to your inbox.
Sign up for our newsletter.

Salfati Group, Limited.

Our mission is to empower your
product security and velocity.

Products