GitHub Actions: build your own JavaScript action — part 1

An intro to GitHub JavaScript Actions, with an example of how to automatically add labels to newly opened issues. This is part 1 of a series of posts on the topic.

If you work on GitHub, you’ll know GitHub Actions are powerful; they’re a great way to automate some of the tasks you regularly do in your repository.

GitHub introduced a marketplace where you can find actions for just about everything. This is a great way to get started with actions, see how useful they can be for your own projects.

In this series of posts, I’d like to go a bit further. We’ll be creating our own action to fit our exact needs. We’ll develop it within our existing repository, using JavaScript and GitHub’s own Actions Toolkit.

This post is part of the GitHub Actions: build your own JavaScript action series.

If you’re not following this blog yet, sign up here to get an email as soon as part 4 comes out:

Building your own action can seem a bit daunting at first, but it’s the best way to build something that fits your exact needs. It’s also going to be a tool you can iterate on; you’ll fully understand how it works and you can update your existing automations or add new ones.

Before we get started, let’s talk a bit about what we’ll be using:

  • GitHub’s Actions Toolkit is a series of npm packages that we’ll be using as dependencies. In this post, I’ll focus on 2 of those packages:
    • @actions/core is a package with simple tools to interact with a GitHub action: get some parameters defined in your action, set an action outcome as failed, display some debug information, …
    • @actions/github gives you access to the Octokit client. You will be able to make authenticated requests to the GitHub API right from your GitHub action.
  • @vercel/ncc is a simple CLI tool that will build our action for us.

That’s all we’ll need for now!

Build your action’s infrastructure

1. Create a workflow file

If you’ve worked with GitHub actions, you’ll know that you can enable any action in your own repository by adding a YAML file in .github/workflows. That’s what we’ll do here; we’ll create a new GitHub action workflow, that will do a few things:

  1. Be triggered every time a new issue is opened.
  2. Checkout a copy of our repository.
  3. Setup Node, since our action will rely on that.
  4. Build our action.
  5. Run it, by passing a GITHUB_TOKEN secret that GitHub provides us out of the box for all actions. It will allow us to authenticate ourselves.

Here is how my .github/workflows/triage.yml workflow file looks like:

name: Triage
  issues: # For auto-triage of issues.
    types: [opened]

    name: Apply some labels on newly opened issues
    runs-on: ubuntu-latest
     - name: Checkout
       uses: actions/checkout@v3

     - name: Setup Node
       uses: actions/setup-node@v3
          node-version: lts/*

     - name: Wait for prior instances of the workflow to finish
       uses: softprops/turnstyle@v1
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

     - name: Build Action
       run: |
          npm install && npm run build
       working-directory: ./.github/actions/triage/

     - name: Run action
       uses: ./.github/actions/triage/
        github_token: ${{ secrets.GITHUB_TOKEN }}

You can read more about the syntax of workflow files here.

Later on, if we want to do more with our automation, there are 2 things we may have to edit in that file:

  • We may have to add more events that will trigger our workflow. Right now our action will only happen when issues are opened. In future iterations, we could have our action run when issues are closed, or when Pull Requests are opened for example.
  • We may have to pass secrets to our action, in addition to github_token. This can be useful to add private information (access keys, tokens, …) to your action without actually having that data visible in your codebase.

2. Create a directory where our action will live

We’ll develop our action in a subdirectory of an existing project hosted on GitHub. This way, it won’t interfere with our existing codebase. I chose to have this live in .github/actions/, as you’ve seen in the workflow file above.

It’s now time to open our terminal and create the first elements of our action. We’ll start by creating a package.json file:

$ npm init

Once you’ve answered the few prompts, you should end up with a result a bit like this one:

  "name": "triage",
  "version": "1.0.0",
  "description": "A GitHub action to automatically add labels on newly opened issues.",
  "main": "src/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  "repository": {
    "type": "git",
    "url": "git+"
  "keywords": [
  "author": "Jeremy Herve",
  "license": "GPL-2.0-or-later",
  "bugs": {
    "url": ""
  "homepage": ""

Let’s now add in the dependencies I mentioned above:

$ npm install @vercel/ncc --save-dev
$ npm install @actions/core
$ npm install @actions/github

That’s all we’ll need for now.

Since we’ve installed ncc, we can create a build script that we’ll use to build the action:

$ npm set-script build "ncc build src/index.js -o dist --source-map"

We now need one last thing: an action.yml file that will inform GitHub about how our action works. Create the file and add the following to it:

name: "Issue Triage"
description: "Automatically add labels on newly opened issues"
    description: "GitHub access token"
    required: true
    default: ${{ github.token }}
  using: node16
  main: "dist/index.js"

Let’s examine this a bit:

  • The list of inputs currently only includes the default github_token, which I’ve mentioned above. It’s provided by GitHub, and will help us make authenticated requests. When you iterate on that action and want to pass more parameters to your action, this is where you’ll add them.
  • Notice the main entry using the dist/ directory? I’ve specified that directory in my build script earlier. This is where the built action lives.

Clean up

Before we go any further, we want to make sure we won’t be committing built files to our repo. You’ll consequently need to add the following to your repository’s .gitignore file (if that file doesn’t exist yet, now is the time to create it):


Write our action

1. Set up the main action file

We can now get into the meat of this post: writing the automation. It will all live in a .github/actions/triage/src/index.js file, as we’ve seen earlier.

At the top of that file, we import the tools we need from our dependencies. Then we create the function that will run everything. For now, I’ll only add a debug statement in there:

const { setFailed, getInput, debug } = require( '@actions/core' );
const { context, getOctokit } = require( '@actions/github' );

( async function main() {
	debug( 'Our action is running' );
} )();

Let’s stop right here, save everything. Now would be a good time to test if everything works!

If you run git status in your local checkout, you should see something like this:

Let’s check everything in, and push our changes to the repository’s main branch.

It’s now time to test things out: create a new issue!

How do we know our action works? The only thing we’ve added in there was a debug statement. It’s time to head to the “Actions” tab in our repository. It will list all the workflows that run, and we can then click on a specific run to see more information about it. Let’s locate our newly opened issue:

You should recognize some of those things: those are the steps we specified in our workflow file earlier.

Let’s look at the “Run action” step; if everything is alright, it should have run:

That’s a bit disappointing, isn’t it? Not much in there, not even our debug statement. That’s because debugging is not activated yet in our repository. We can do that by going to Settings > Secrets > Actions, and creating a new secret named ACTIONS_STEP_DEBUG, with the value of true.

Once you’ve done that, and when you open a new issue, you’ll see more things in the “Run action” details:

Much better! We can see our debug statement in there, “Our action is running”. That means we’re in business.

2. Use the Octokit client to make GitHub API requests

Let’s start doing some things inside our main function. The Octokit client allows us to do all sorts of fun things, all documented here.

Before we start using it, however, we’ll need to make sure we have the token that was provided to us by GitHub. Let’s check for it. (Note getInput and setFailed here; those are provided to us by @actions/core).

const token = getInput( 'github_token' );
if ( ! token ) {
	setFailed( 'Input `github_token` is required' );

Then, let’s get Octokit, and authenticate using the token from above.

const octokit = new getOctokit( token );

Once we have that, we can start using it just like in the documentation. It requires more information about the event though. Lucky for us, this is all accessible to us in the context provided by @actions/github:

const { payload } = context;
const { issue: { number }, repository: { owner, name } } = payload;

It’s worth noting that the context, the payload of the event, will be different depending on the event. We’re looking at an “issue” event here, but the structure of the returned data would be different if we were looking at a “pull request” event or a “push” event. You can find a list of all the available events and their matching payloads on this page.

This allows us to create a label, for example “Issue triaged”, using all that information.

await {
	owner: owner.login,
	repo: name,
	issue_number: number,
	labels: [ 'Issue triaged' ],
} );

Let’s commit this and give it a try by creating a new issue:

That’s a bingo!

3. Safeguards, comments

Our code works, but before we can call it done, there are a few extra things we could do to future-proof our work:

  • Add some inline comments.
  • Extract the label to add into its own variable. We’ll need this when we want our label to be dynamic (spoiler alert!).
  • Ensure we only try to add a label when the event is an issue being opened. When we iterate on our action, we may start listening to different types of events to do different things. Let’s clearly separate each event in our code.

Here is the end result:

const { setFailed, getInput, debug } = require( '@actions/core' );
const { context, getOctokit } = require( '@actions/github' );

( async function main() {
	debug( 'Our action is running' );

	const token = getInput( 'github_token' );
	if ( ! token ) {
		setFailed( 'Input `github_token` is required' );

	// Get the Octokit client.
	const octokit = new getOctokit( token );

	// Get info about the event.
	const { payload, eventName } = context;

	debug( `Received event = '${ eventName }', action = '${ payload.action }'` );

	// We only want to proceed if this is a newly opened issue.
	if ( eventName === 'issues' && payload.action === 'opened' ) {
		// Extra data from the event, to use in API requests.
		const { issue: { number }, repository: { owner, name } } = payload;

		// List of labels to add to the issue.
		const labels = [ 'Issue triaged' ];

			`Add the following labels to issue #${ number }: ${ labels
				.map( ( label ) => `"${ label }"` )
				.join( ', ' ) }`

		// Finally make the API request.
		await {
			owner: owner.login,
			repo: name,
			issue_number: number,
		} );
} )();

This is looking pretty good. We now have a working GitHub action, within our existing project repository, that automatically adds a label to every new issue being opened.

Let’s stop here for today, In a future post, we’ll iterate on this action to add some conditions before we add a label to issues.

I hope you enjoyed that first post; let me know in the comments if you have any questions or feedback!

This post is part of the GitHub Actions: build your own JavaScript action series.

If you’re not following this blog yet, sign up here to get an email as soon as part 4 comes out:

Leave a Reply

Your email address will not be published. Required fields are marked *