the/experts. Blog

Cover image for Writing Your First GitHub Action In JavaScript
BasvanGrinsven
BasvanGrinsven

Posted on

Writing Your First GitHub Action In JavaScript

I just started working with GitHub Actions. We had the need to create a custom GitHub Action, which could be used by multiple internal teams.

GitHub Actions is easy to use and very flexible. I would like to take you with me on my journey in creating my first custom GitHub Action written in JavaScript.

What is GitHub Actions

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository or deploy merged pull requests to production.

GitHub Actions goes beyond just DevOps and lets you run workflows when other events happen in your repository. For example, you can run a workflow to automatically add the appropriate labels whenever someone creates a new issue in your repository.

GitHub actions in JavaScript vs. bash

It's also possible to write your GitHub Actions in bash. Personally, I didn't like to work with a Dockerfile. Especially when I needed to add the aws-sdk. Writing it in JavaScript and using a package.json had my preference.

What was our goal

Our goal was to create an action that internal teams could use to publish a validated federated subgraph to a schema registry that runs in a Lambda on AWS.

Let's start!

Creating our action

We start our journey by creating an internal repository. We'll call it actions for now. Within that repository, we define several files in which we are going to dive deeper. The structure will look as follows:

actions
│
├──dist
│   │   index.js
├──node_modules
│   │   ...
│   │ 
├──action.yml
├──index.js
├──package.json
├──package-lock.json
Enter fullscreen mode Exit fullscreen mode

action.yml

This file is our action metadata file. It will define the interface of our action. In our case, it consists of inputs and runs.

  • inputs: the parameters containing data that the action expects to use during runtime
  • runs: specifies the execution runtime of the action, which will be node16 in this case
name: 'Publish federated subgraph'
description: 'Publish the federated subgraph to the schema registry'
inputs:
  target-account:
    description: 'Target account ID'
    required: true
  target-environment:
    description: 'Target environment'
    required: true
  target-team:
    description: 'Target team name'
    required: true
  path-to-schema:
    description: 'Absolute path to schema'
    required: true
  name-of-subgraph:
    description: 'Name of the subgraph'
    required: true
  url-of-subgraph:
    description: 'URL of the subgraph'
    required: true
runs:
  using: 'node16'
  main: 'dist/index.js'
Enter fullscreen mode Exit fullscreen mode

Now that we have defined the interface of the action. It's time to write the actual action.

index.js

Our action consists of several steps:

  • Get all the inputs that we defined in our actions.yml.
  • Fetch the federated subgraph schema and prepare it for lambda invocation.
  • Escape the subgraphUrl so we can use it in our payload.
  • Assume the configured role for the team so we can invoke the schema registry.
  • Invoke the schema registry and push the federated subgraph to it.
  • Based on the result we will throw an error or let it pass.

Since our action is written in JavaScript, we are able to include dependencies. GitHub Actions provides two dependencies that are very useful when creating your own custom actions. To include them in your action you should run:

npm install @actions/core and npm install @actions/github

These dependencies will be added to your package.json and will be available for use in your index.js. These packages provide core functionality to, for instance, fetch the inputs from your interface. It also provides the context which contains information about the current run.

Besides these dependencies, we can also add other packages that we need to interact with AWS.

const core = require('@actions/core');
const github = require('@actions/github');
const clientSTS = require('@aws-sdk/client-sts')
const AWS = require('aws-sdk');
const fs = require('fs')

let stsClient = new clientSTS.STSClient({region: 'eu-west-1'});
let schemaRegistry;

const main = async () => {
    try {
        /**
         * We need to fetch all the inputs that were provided to our action
         * and store them in variables for us to use.
         **/
        const targetAccount = core.getInput('target-account', { required: true });
        const targetEnvironment = core.getInput('target-environment', { required: true });
        const team = core.getInput('target-team', { required: true });
        const pathToSchema = core.getInput('path-to-schema', { required: true });
        const nameOfSubgraph = core.getInput('name-of-subgraph', { required: true });
        const urlOfSubgraph = core.getInput('url-of-subgraph', { required: true });

        /**
         * We need to retrieve the subgraph schema and prepare it for the lambda invocation
         */
        let subgraphSchema = fs.readFileSync(pathToSchema, 'utf-8')
            .replace(/\n|\r/g, "")
            .replace(/"/g, '\\\\\\"')
            .replace(/\t/g, ' ');

        /**
         * We also need to escape the url of the subgraph
         */
        let subgraphUrl = urlOfSubgraph.replace(/\//g, '\\/')

        /**
         * We need to assume the role to communicate with the schema-registry.
         * These roles are created per team/environment
         */
        const credentials = await assumeRoleInAccount(targetAccount, team, targetEnvironment);

        /**
         * Publish the schema to the schema registry with the given payload.
         */
        const payload = `{"path":"/schema/push", "httpMethod":"POST", "body": \"{\\"name\\": \\"${nameOfSubgraph}\\", \\"version\\": \\"${github.context.sha}\\", \\"type_defs\\": \\"${subgraphSchema}\\", \\"url\\": \\"${subgraphUrl}\\"}\", "multiValueHeaders": {"Content-Type": "application/json"}, "requestContext": {}}`
        const isSchemaValid = await publishSchema(credentials, payload)

        if(!isSchemaValid) {
            throw new Error("schema is not valid");
        }
    } catch (error) {
        console.error(error);
        core.setFailed(error.message);
    }
}

async function assumeRoleInAccount(targetAccount, team, targetEnvironment) {
    const command = new clientSTS.AssumeRoleCommand({
        RoleArn: `arn:aws:iam::${targetAccount}:role/schema-registry-${team}-${targetEnvironment}-role`,
        RoleSessionName: `schema-registry-${targetAccount}-${targetEnvironment}`
    });

    const assumedRole = await stsClient.send(command)
    return {
        accessKeyId: assumedRole.Credentials.AccessKeyId,
        secretAccessKey: assumedRole.Credentials.SecretAccessKey,
        sessionToken: assumedRole.Credentials.SessionToken
    }
}

async function publishSchema(credentials, payload) {
    schemaRegistry = new AWS.Lambda({
        region: 'eu-west-1',
        accessKeyId: credentials.accessKeyId,
        secretAccessKey: credentials.secretAccessKey,
        sessionToken: credentials.sessionToken
    });

    const result = await schemaRegistry.invoke({
        FunctionName: 'schema-registry',
        Payload: payload
    }).promise();

    const resultPayload = JSON.parse(result.Payload.toString());
    const is2xxResponse = resultPayload.statusCode.toString().startsWith("2");

    if(!is2xxResponse) {
        console.error(JSON.parse(resultPayload.body).message)
    }

    return is2xxResponse
}

// Call the main function to run the action
main();
Enter fullscreen mode Exit fullscreen mode

dist/index.js

Since we don't want to push the node_modules to the repository, we will build our action using ncc.

ncc build index.js -o dist
Enter fullscreen mode Exit fullscreen mode

This will compile our JavaScript and the packages we need from node_modules and create a dist/index.js which we will reference in our actions.yml

runs:
  using: 'node16'
  main: 'dist/index.js'
Enter fullscreen mode Exit fullscreen mode

Using the action in our workflow

Because this action is for internal use only, we don't publish it to the GitHub Marketplace. If we want to use it in our own workflow we have to check out the code first.

- name: Checkout private-actions
        uses: actions/checkout@v2
        with:
          repository: <repository-that-we-just-created>
          ref: <tag-or-branch-name>
          path: .github/actions
          token: ${{secrets.private-token-to-checkout-code}}
Enter fullscreen mode Exit fullscreen mode

This action will check out the internal action that we just created using the token which is configured in the GitHub repository. It pulls the code from the repository and makes it available under .github/actions

To use the action that we just created we should add the action to our workflow as follows:

- name: Publish subgraph
        uses: ./.github/actions/publish-subgraph
        with:
          target-account: ${{inputs.target_account}}
          target-environment: ${{inputs.target_environment}}
          target-team: ${{inputs.target_team}}
          path-to-schema: 'path/to/schema.graphql'
          name-of-subgraph: ${{inputs.name_of_subgraph}}
          url-of-subgraph: ${{inputs.url_of_subgraph}}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this journey, we created a custom JavaScript GitHub Action which publishes a federated subgraph to a schema registry running on an AWS Lambda.

You should be able to write your own GitHub Action and perhaps even publish it to the marketplace!

Discussion (0)