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:
- Imports the
@actions/core
and@actions/github
packages. These provide convenient methods for interacting with the GitHub Actions runtime. - Retrieves the
who-to-greet
input usingcore.getInput()
. - Prints a greeting message to the console.
- Generates a random number and sets it as the
random-number
output usingcore.setOutput()
. - Logs the event payload that triggered the workflow run. This is accessed via the
github.context.payload
object. - 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:
- Authenticates with the GitHub API using the provided
repo-token
- Fetches the pull request metadata and list of changed files using the
github.context.payload.pull_request
object - Defines a mapping of label names to file glob patterns in the
labelGlobs
object - Filters the
labelGlobs
to find labels that match the changed files - Adds the matching labels to the pull request using the GitHub API
- Sets the
labeled
output totrue
if any labels were added - 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()
andcore.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:
- Official GitHub Actions documentation
- GitHub Actions JavaScript toolkit
- awesome-actions – A curated list of awesome Actions resources
Happy automating!