Building Your First JavaScript GitHub Action: A Step-by-Step Guide

GitHub Actions have rapidly become an essential tool for developers looking to automate their software development workflows. Since launching in 2018, Actions have been adopted by over 40% of GitHub‘s active repositories, with more than 500 million jobs run per month.

As a full-stack developer, I‘ve found GitHub Actions invaluable for streamlining repetitive tasks like linting, testing, and deploying code. The ability to create custom actions in JavaScript has been a game-changer, allowing me to encapsulate common processes and share them across projects.

In this in-depth tutorial, I‘ll walk you through building your first JavaScript GitHub Action from scratch. Whether you‘re new to Actions or looking to level up your automation skills, this guide will provide a solid foundation for creating, testing, and sharing your own actions.

What are GitHub Actions?

Before diving into the code, let‘s step back and define what GitHub Actions are and how they work.

At a high level, GitHub Actions is a continuous integration and delivery (CI/CD) platform that allows you to automate your software development workflows. With Actions, you can build, test, and deploy your code directly from GitHub.

The building blocks of GitHub Actions are:

  • Workflows – Automated processes that you can set up in your repository to run on certain events, like pushing code or creating a pull request. Workflows are defined in YAML files in the .github/workflows directory.
  • Jobs – A set of steps that execute on the same runner. You can define dependencies between jobs and run them in parallel or sequentially.
  • Steps – Individual tasks that can run commands or use pre-built actions. Steps can share data between each other.
  • Actions – Reusable units of code that perform a specific task, like setting up a particular programming language or publishing a package. You can create your own actions or use third-party ones from the GitHub Marketplace.

Here‘s a simple example of a workflow that uses a JavaScript action to greet someone:

name: Greeting Workflow

on:
  push:
    branches: [main]

jobs:

  greeting:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Hello world
      uses: ./hello-world-action
      with:
        who-to-greet: ‘Mona the Octocat‘

In this workflow, the "Hello world" step uses a custom hello-world-action that presumably prints a greeting message. The who-to-greet input specifies the name to include in the message.

Actions are the most powerful part of the GitHub Actions platform, as they allow you to create and share code that others can use in their workflows. Let‘s take a closer look at the anatomy of a JavaScript action.

Anatomy of a JavaScript Action

GitHub Actions can be written in any language, but JavaScript is a popular choice because of its familiarity and the wide availability of useful packages on npm. JavaScript actions run directly on the virtual machine specified by the runs-on key in the workflow file.

Here‘s the basic structure of a JavaScript action:

hello-world-action/
├── action.yml
├── index.js
├── node_modules/
│   └── ...
├── package.json
└── package-lock.json

The two most important files are action.yml and index.js.

action.yml is the metadata file that defines the action‘s interface. It specifies the inputs, outputs, and main entrypoint of the action. Here‘s an example:

name: ‘Hello World‘
description: ‘Greet someone‘
inputs:
  who-to-greet:  
    description: ‘Who to greet‘
    required: true
    default: ‘World‘
outputs:
  random-number: 
    description: "Random number"
runs:
  using: "node12"
  main: "index.js"

This action takes a who-to-greet input and produces a random-number output. The runs section defines the execution environment (node12) and the entrypoint file (index.js).

The index.js file contains the action‘s main logic. Here‘s a basic example:

const core = require(‘@actions/core‘);
const github = require(‘@actions/github‘);

try {
  const nameToGreet = core.getInput(‘who-to-greet‘);
  console.log(`Hello ${nameToGreet}!`);

  const randomNumber = Math.floor(Math.random() * 100);
  core.setOutput(‘random-number‘, randomNumber);

  const payload = JSON.stringify(github.context.payload, undefined, 2);
  console.log(`The event payload: ${payload}`);

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

This code does the following:

  1. Imports the @actions/core and @actions/github packages. These provide convenient methods for interacting with the GitHub Actions runtime.
  2. Retrieves the who-to-greet input using core.getInput().
  3. Prints a greeting message to the console.
  4. Generates a random number and sets it as the random-number output using core.setOutput().
  5. Logs the event payload that triggered the workflow run. This is accessed via the github.context.payload object.
  6. Wraps the code in a try/catch block to catch any errors and use core.setFailed() to signal a failure and halt the action‘s execution.

This is a simple example, but it demonstrates some of the key concepts of writing GitHub Actions in JavaScript:

  • Inputs are retrieved using core.getInput()
  • Outputs are set using core.setOutput()
  • The github context provides access to the event payload and other metadata about the workflow run
  • Uncaught errors will fail the build, so it‘s important to handle them gracefully

Now that we understand the basic structure, let‘s walk through creating a more advanced action step-by-step.

Step-by-Step Example: Pull Request Labeler

For this example, we‘ll create an action that automatically adds labels to new pull requests based on the files changed. This is a common use case for automating the triage process and marking PRs for further review.

Step 1: Set up the action‘s metadata

Create a new action.yml file with the following contents:

name: ‘Pull Request File Labeler‘
description: ‘Add labels to new pull requests based on file paths‘
inputs:
  repo-token:
    description: ‘Token for the repository. Can be passed in using secrets.GITHUB_TOKEN‘
    required: true
outputs:
  labeled:
    description: ‘Whether or not the pull request was labeled‘
runs:
  using: ‘node12‘
  main: ‘dist/index.js‘

This defines a repo-token input for authenticating with the GitHub API and a labeled output to indicate the action‘s result.

Step 2: Install dependencies

Next, initialize an npm project and install the @actions/core, @actions/github, and minimatch packages:

npm init -y
npm install @actions/core @actions/github minimatch

We‘ll use minimatch for matching file paths against glob patterns.

Step 3: Write the action‘s logic

Create an index.js file with the following code:

const core = require(‘@actions/core‘);
const github = require(‘@actions/github‘);
const minimatch = require(‘minimatch‘);

async function run() {
  try {
    const token = core.getInput(‘repo-token‘);
    const octokit = github.getOctokit(token);

    const { data: pullRequest } = await octokit.pulls.get({
      owner: github.context.repo.owner,
      repo: github.context.repo.repo,
      pull_number: github.context.payload.pull_request.number
    });

    const changedFiles = await octokit.paginate(
      octokit.pulls.listFiles,
      {
        owner: github.context.repo.owner,
        repo: github.context.repo.repo,
        pull_number: github.context.payload.pull_request.number
      },
      (response) => response.data.map((file) => file.filename)
    );

    const labelGlobs = {
      ‘bug‘: [‘**/*.js‘, ‘**/*.ts‘],
      ‘documentation‘: [‘**/*.md‘],
      ‘dependencies‘: [‘**/package.json‘, ‘**/yarn.lock‘] 
    }

    const labels = Object.keys(labelGlobs).filter((label) => {
      return changedFiles.some((file) => {
        return minimatch(file, labelGlobs[label]);
      });
    });

    await octokit.issues.addLabels({
      owner: github.context.repo.owner,
      repo: github.context.repo.repo,
      issue_number: github.context.payload.pull_request.number,
      labels
    });

    core.setOutput(‘labeled‘, labels.length > 0);

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

run();

Here‘s what this code does:

  1. Authenticates with the GitHub API using the provided repo-token
  2. Fetches the pull request metadata and list of changed files using the github.context.payload.pull_request object
  3. Defines a mapping of label names to file glob patterns in the labelGlobs object
  4. Filters the labelGlobs to find labels that match the changed files
  5. Adds the matching labels to the pull request using the GitHub API
  6. Sets the labeled output to true if any labels were added
  7. Catches any errors and fails the action with an appropriate message

This provides a flexible and reusable way to automatically label pull requests based on file paths.

Step 4: Compile and package the action

To package the action for distribution, we‘ll use the @vercel/ncc tool to compile the code and dependencies into a single file.

First, install ncc as a development dependency:

npm install -D @vercel/ncc

Then, add a build script to package.json:

{
  "scripts": {
    "build": "ncc build index.js"
  }
}

Running npm run build will create a dist/index.js file containing the compiled action.

Step 5: Commit and push the action

Finally, commit the action‘s code to a repository and create a release. Here‘s an example release workflow:

name: Release

on:
  release:
    types: [published]

jobs:

  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Install dependencies
      run: npm ci

    - name: Build
      run: npm run build

    - name: Upload artifact
      uses: actions/upload-artifact@v2
      with:
        name: dist
        path: dist

This workflow will trigger whenever a new release is published, build the action, and upload the dist directory as an artifact.

You can then reference the action in another repo‘s workflow using the release version:

uses: your-org/your-action@v1

And that‘s it! You‘ve now created a reusable, shareable GitHub Action in JavaScript.

Advanced Concepts and Best Practices

Building on the example above, let‘s explore some more advanced concepts and best practices for creating robust and maintainable actions.

Naming and Versioning

How you name and version your actions is important for clarity and reusability. Some tips:

  • Use a descriptive, concise name that clearly communicates the action‘s purpose
  • Follow semantic versioning for releases (e.g. v1.0.0, v1.0.1, etc.)
  • Include a major tag (e.g. v1) to allow users to receive latest updates within a major version
  • Use a latest tag for the most recent stable release

Documentation

Comprehensive documentation is crucial for helping others understand and use your action. At a minimum, your action should include:

  • A README.md file with:
    • A brief description of the action‘s purpose and functionality
    • Input and output details
    • Usage examples
    • Any requirements or prerequisites
  • Inline code comments explaining key logic
  • Release notes detailing changes between versions

Error Handling

Proper error handling is essential for providing a good user experience and surfacing issues early. Some best practices:

  • Validate inputs and fail fast if requirements aren‘t met
  • Use try/catch blocks to handle errors gracefully
  • Provide meaningful error messages using core.setFailed()
  • Use core.warning() and core.error() to surface non-fatal issues
  • Exit with appropriate status codes to indicate success or failure

Testing

As with any code, testing your action is important for catching bugs and ensuring reliability. Some strategies:

  • Write unit tests for individual functions using a testing framework like Jest
  • Use a tool like @actions/exec to run your action in a test environment
  • Test common input scenarios and edge cases
  • Set up continuous integration to run tests on each commit and PR

Security

When creating actions, it‘s important to follow security best practices to prevent vulnerabilities and protect sensitive data. Some tips:

  • Don‘t commit secrets or sensitive information to your repository
  • Use secrets contexts to pass sensitive values to your action
  • Avoid using third-party actions or libraries from untrusted sources
  • Pin dependencies to specific versions to avoid introducing breaking changes
  • Regularly update dependencies to include security patches

Performance

GitHub Actions usage is billed based on the time it takes to execute your workflows. To keep costs down and improve the user experience:

  • Minimize dependencies and use a tool like ncc to create a single bundled file
  • Use caching to avoid installing dependencies on each run
  • Run intensive tasks in parallel when possible
  • Use appropriate hardware resources for your needs (e.g. don‘t use a large machine for a simple task)

Limitations

While GitHub Actions are very flexible, there are some limitations to be aware of:

  • Each job in a workflow can run for up to 6 hours of execution time
  • Workflows are limited to 20 concurrent jobs per repository
  • Storage space is limited to 10 GB per repository
  • Network traffic is limited to 1 GB per month per repository

Keep these constraints in mind when designing your actions and workflows.

Conclusion

In this guide, we‘ve covered the fundamentals of building, testing, and sharing GitHub Actions in JavaScript. We walked through a real-world example of a pull request labeler action, and explored advanced concepts and best practices for creating reliable and maintainable actions.

GitHub Actions are a powerful tool for automating your development workflows and sharing reusable bits of functionality with the community. With the knowledge you‘ve gained here, you‘re well-equipped to start building your own actions and optimizing your workflows.

Here are some additional resources to continue learning:

Happy automating!

Similar Posts