Skip to content

Creating Your Own GitHub Action With TypeScript

In a recent JavaScript Marathon live training by Software Engineer Chris Trześniewski, viewers learned how to create their own GitHub Action using Typescript.

If you would like to watch this training for yourself, you can check out this JavaScript Marathon training on YouTube. I also invite you to follow along as I review the major points covered in this training with code examples that you can implement right now!

What is a GitHub Action?

A GitHub Action allows users to simply run a script, and automatically run jobs or workflows on a repository. These jobs and workflows can be triggered by events like merging to a branch, pull requests, push updates, and more.

Activities we can automate with GitHub Actions include linting, testing, building, and project deployment on every push to the main branch.

Below is a step-by-step process for creating your own GitHub Action using Typescript! Let's begin by setting up our project!

Project Setup

Setting up the project environment is an essential first step. Use this repository as a starter, which is what Chris used for his presentation.

First, run git clone https://github.com/ktrz/gh-action-js-marathon to clone the repository into a local directory.

Then, change into directory with cd gh-action-js-marathon, using npm to install dependencies, and run the build script. To initialize a node project in the directory, run the npm command:

npm init

At this point, you will accept the properties.

However, since the project is in TypeScript, it will have to be compiled into JavaScript before building the entry point, which will be dist/index.js.

After this, you will continue installing dev-dependencies like @types/node. To do this, run:

npm i -D @types/node

To set up the TypeScript project, include a tsconfig.json to let TypeScript know where the source files are, and where the output directory will be.

You can use the below configuration with a little extra configuration for this project.

tsconfig.json file

{
    "compilerOptions": {
      "target": "es2019",
      "module": "commonjs",
      "strict": true,
      "esModuleInterop": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true,
      "outDir": "dist",
      "lib": ["esnext"]
    },
    "references": [
      {
        "path": "./tsconfig.build.json"
      }
    ],
    "exclude": ["node_modules"]
  }

The line "outDir": "dist" tells the TypeScript compiler that the output files should go into the dist directory.

tsconfig.build.json file

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "types": ["node"]
    },
    "include": ["src/**/*.ts"],
    "exclude": ["src/**/*.spec.ts"]
  }

The lines "include": ["src/**/*.ts"], and "exclude": ["src/**/*.spec.ts"] tell the TypeScript compiler to ignore any file name that ends with spec.ts, and to include every file that ends with .ts in the src directory.

The next step is to edit the package.json file, replacing the test script with a build script "build": "tsc -p tsconfig.build.json".

To test the build script, create an src directory, and create an index.ts file with the following code inside it:

console.log(‘Hello World!’)

Then, run the script npm run build, which builds the TypeScript code, and generates a JavaScript file for the project.

To clean up, add a gitignore file. Use the below content for the file:

node_modules
dist
.idea
.vscode

GitHub Action Set Up

Continue by creating a GitHub Action file, which is a YML extension file named action.yml. This will define what the project, and the entry file for the GitHub Action configuration, are.

action.yml file

name: 'Consonant vowel ratio'
author: 'Chris Trzesniewski'
description: 'Custom GH action with TypeScript'

runs:
  using: 'node16'
  main: 'dist/index.js'

Details above properties name, author, and description include the metadata for the yml action file.

The run property is the most important part of the configuration. It informs developers of what to use to run the Action. For this project, it is Node 16.

The directory for the Action is the same as the build script directory, which is dist/index.js. In this case, if we build and deploy the Action to a separate branch on the repository, this should allow anyone to point to the repository and use the Action.

For the purposes of this training, we will set up all of this in one directory. This will be a .github/workflow directory in the repository, in which we will create a test Action with the below configuration:

.github/workflow/test.yml file

name: Test
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  run-action:
    name: Run action
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js 16.x
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
          cache: 'npm'
          cache-dependency-path: package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Run my action
        uses: ./

Using the properties here, we will first need to define a name for the Action, which is Test.

Then, we define what triggers the workflow, which is where the on property comes in. Following this, we will create either a push to main or a pull request that is created against the main branch.

On the jobs property, we define a map of jobs that we want to run. This will have the name property for displaying the name of what we are running. Here, we want to run the Linux Virtual Machine environment provided by GitHub.

Next, check out the repository: - uses: actions/checkout@v2. This is an out-of-the-box GitHub Action provided by GitHub.

Then, we set up a node environment to run the actual script for our project on the line uses: actions/setup-node@v2. This installs node-version 16, and then caches npm so it doesn’t take as long on a subsequent run.

Install the project dependencies using command npm ci, which is the same as the usual npm install. However, npm ci is used to install all exact version dependencies, or devDependencies from a package-lock.

Now it's time to build our project with the run property run: npm run build.

Finally, we will point to a directory in the project. For our case, it is the current working directory, and GitHub will find the action.yml file there.

GitHub will be able to identify the entry point as we defined it in the action.yml.

To test for a pull request trigger, you can create another branch and call it feat/setup-action. Publish the new branch, navigate to the remote repository, and create a pull request. This should trigger the workflow to run.

GitHub Actions Dependency

Now it's time to install some dependencies from GitHub. These dependencies allow you to retrieve information about the event that triggered the action from GitHub Runner.

Run the npm installation command npm install @actions/core @actions/github.

Edit the src/index.ts file to replace it with the code below:

import { getInput } from "@actions/core";

type GithubContext = typeof context;

const inputName = getInput("name");

greet(inputName);

function greet(name: string, repoUrl: string) {
  console.log(`'Hello ${name}! You are running a GH Action in ${repoUrl}'`);
}

Now, you should modify the action.yml file, and add the below code.

The final file should look like this:

name: 'Consonant vowel ratio'
author: 'Chris Trzesniewski'
description: 'Custom GH action with TypeScript'

inputs:
  name:
    description: 'Name to greet'
    required: false
    default: 'JS Marathon viewers'

runs:
  using: 'node16'
  main: 'dist/index.js'

Commit the changes and push to the remote repository. The workflow will trigger and run again. You should see ’Hello JS Marahon viewers!’ on the line Run my action.

This is for default values. But you can also provide values on the Actions by editing the .github/workflow/test.yml file.

The final file should look like this:


name: Test
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  run-action:
    name: Run action
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js 16.x
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
          cache: 'npm'
          cache-dependency-path: package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Run my action
        uses: ./
        with:
          name: 'This Dot Labs'

Commit the code, and push to the remote repository.

On line 4 of Run my action, you will see the new message to the sponsor.

Retrieve Data

To retrieve data from GitHub, we will use the second dependency. This time, modify src/index.ts. The file should look like this:

import { getInput } from "@actions/core";
import { context, getOctokit } from "@actions/github";
import dedent from 'dedent'

type GithubContext = typeof context;

const inputName = getInput("name");

greet(inputName, getRepoUrl(context));

function greet(name: string, repoUrl: string) {
  console.log(`'Hello ${name}! You are running a GH Action in ${repoUrl}'`);
}

function getRepoUrl({ repo, serverUrl }: GithubContext): string {
  return `${serverUrl}/${repo.owner}/${repo.repo}`;
}

Import context, which is the object that will be provided by the Action runner, to get basic data about the repository that we will run this workflow against, and the payload that triggered the action.

To learn more about this, check out the GitHub docs on Webhook events & payloads.

To get the repo URL, create a function called getRepoUrl which accepts the git Context. This destructures the properties repo and serverUrl, and the function will return the remote URL of the repository.

GitHub Diff

Using the GitHub API, we can access the diff between the current head, and the target branch which a pull request is referencing.

The dependency already installed comes with an OctoKit module. We also need the GitHub token for this to properly work. Go back to the src/index.ts file, and modify it to look like the file below.

import { getInput } from "@actions/core";
import { context, getOctokit } from "@actions/github";
import dedent from 'dedent'

type GithubContext = typeof context;

const inputName = getInput("name");
const ghToken = getInput("ghToken");

greet(inputName, getRepoUrl(context));

getDiff().then(files => {
    console.log(dedent(`
    Your PR diff:
    ${JSON.stringify(files, undefined, 2)}
    `))
})

function greet(name: string, repoUrl: string) {
  console.log(`'Hello ${name}! You are running a GH Action in ${repoUrl}'`);
}

function getRepoUrl({ repo, serverUrl }: GithubContext): string {
  return `${serverUrl}/${repo.owner}/${repo.repo}`;
}

async function getDiff() {
  if (ghToken && context.payload.pull_request) {
      const octokit = getOctokit(ghToken)

      const result = await octokit.rest.repos.compareCommits({
          repo: context.repo.repo,
          owner: context.repo.owner,
          head: context.payload.pull_request.head.sha,
          base: context.payload.pull_request.base.sha,
          per_page: 100
      })

      return result.data.files || []
  }

  return []
}

Let's walk through the getDiff function.

First, check to see if you have the GitHub token, and that the context payload exists.

Then, declare and assign the octokit for GitHub REST API access. Then, call the compareCommits method, and pass the properties from the GitHub context. Then, return result files or an empty array.

If you do not have either, the token or a pull request will return an empty array.

Modify the action.yml file to look like this in order to access the GitHub token:

name: 'Consonant vowel ratio'
author: 'Chris Trzesniewski'
description: 'Custom GH action with TypeScript'

inputs:
  name:
    description: 'Name to greet'
    required: false
    default: 'JS Marathon viewers'
  ghToken:
    description: 'Github access token'
    required: false

runs:
  using: 'node16'
  main: 'dist/index.js'

Finally, modify .github/workflow/test.yml to pass the GitHub token:

name: Test
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  run-action:
    name: Run action
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js 16.x
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
          cache: 'npm'
          cache-dependency-path: package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Run my action
        uses: ./
        with:
          name: 'This Dot Labs'
          ghToken: ${{ secrets.GITHUB_TOKEN }}

Now, commit the code and push to a remote repository to test the final result.

Conclusion

GitHub Actions are powerful tools for implementing CI/CD in your projects no matter the size, and can be used for testing code before deployment and staging.

If you need further support, I encourage you to watch Chris' training, Creating Your Own GitHub Action with TypeScript to follow along with him!