Writing My First Custom GitHub Action

Table Of Content

This is a part 3 in a 3 part blog series about GitHub Actions.

Part 1: Github Actions 2.0 Is Here!!! – A first look and simple walkthrough of GitHub Actions 2.0 for CI/CD

Part 2: Git Actions 2.0-Let’s do something a little more involved – A deeper dive using GitHub Actions 2.0 for a more involved CI/CD pipeline including provisioining infrastructure using IaC and deploy schema changes in a SqlServer Database using a dacpac.

Part 3: Writing My First Custom Github Action – Walkthrough writing a custom GitHub Action.

Introduction

Recently, I started playing with GitHub Actions 2.0 for CI/CD and so far it’s been surprisingly easy. It behaves pretty much the way I expect a yaml based task/action runner to behave. See my previous blog posts on doing a quick and dirty ci/cd pipeline for a .net core web app hosted in Azure App Service, and another where I do a much more involved CI/CD pipeline including provisioning infrastructure using IaC and also deploying database schema changes in my pipeline.

What I don’t know how to do is build my own Action to use in a workflow. But once I know how to do that, I’m pretty confident I’ll be able to do just about anything with Github Actions 2.0.

Getting started

As with any new tech I’m learning, getting started is the hardest part. I’m always super lost in where to get started. Google to the rescue. Some quick googling brought me to this page https://help.github.com/en/articles/about-actions and that gave me a nice overview about actions.  In a nutshell, actions can either be docker container based or just javascript which gets run on the GitHub vms.

For this blog, I figured I’d start with a javascript based action. But what should I build?

In my last blog, I deployed my database schema using sqlpackage.exe and a dacpac. However using sqlpackage.exe was kind of a pain. It was installed on the GitHub windows vm’s. But first I had to manually figure out the path to sqlpackage.exe, and then I had to figure out exactly how to use sqlpackage.exe (lots of googling!). I think I’ll build an action that will deploy my database schema changes via a dacpac. The action will hide all the nonsense involved so it’s super easy for a user to do a dacpac deploy. At the very least, this will make for a great learning experience!

Creating a new action

Github provides a nice template for both javascript based actions and docker container based actions. Since I’m building a javascript based action, I went to the javascript template and clicked the bright green Use this template button.

image

This brought me to a page where I filled in the name of the repository I wanted to create (dacpac-deploy), clicked the bright green Create repository from template button and…

image

Bam! It generated the repo in my account.

image

In the readme of this repo, there is a great walkthrough link that walks you through how to do everything.
image

All the parts of the repo

The repo has everything you need to start writing a JavaScript based action. Looking a little closer, it seems that everything is written using TypeScript. Cool. I sort of kind of know Node and sort of kind of know TypeScript… this will be fun Smile

Starting from the bottom of the template and working my way up:

  • tsconfig.json – configuration for TypeScript
  • package.json – node/npm configuration stuff
  • package-lock.json – auto generated npm config file that describes all the libraries installed (or something like that, again… not a node expert. barely functional in node really)
  • jest.config.js – unit test configuration
  • action.yml – Ooooh, this is the important file. This file describes the custom action
  • README.md – the readme
  • LICENSE – duh
  • .gitignore – all the files ignored by git
  • src – folder containing the source code for the action. This is the folder for our code.
  • docs – folder for your docs
  • _tests_ folder for all your unit tests
  • .github/workflows – folder for the workflows for this repository

The first thing I need to do is update the action.yml file. This file describes my action as well as defines what all my inputs are. Opening it up, I see

name: 'Node 12 Template Action'
description: 'Get started with Node actions'
author: 'GitHub'
inputs:
  myInput:
    description: 'Input to use'
    default: 'world'
runs:
  using: 'node12'
  main: 'lib/main.js'
action.yml

I update this with my action name, a description of my action and the author name. I will also need inputs for my connection string to my sql server database, the path to my dacpac file and another field to hold the optional command line arguments for sqlpackage.exe.

name: 'dacpac-deploy'
description: 'This action deploys a database schema using a dacpac'
author: 'abelsquidhead'
inputs:
  connectionString: 
    description: 'Connection string to the SQL Server database'
    required: true
  dacpac:
    description: 'Path to the dacpac file.'
    required: true
  additionalArguments:
    description: 'Additional SqlPackage.exe arguments that will be applied when deploying the SQL Database'
    required: false
runs:
  using: 'node12'
  main: 'lib/main.js'
action.yml

Next, I went into the package.json file and updated the name and description there.

{
  "name": "dacpac-deploy",
  "version": "0.0.0",
  "private": true,
  "description": "This action deploys a database schema using a dacpac",
  "main": "lib/main.js",
  "scripts": {
    "build": "tsc",
    "test": "jest"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/actions/javascript-template.git"
  },
  "keywords": [
    "actions",
    "node",
    "setup"
  ],
  "author": "GitHub",
  "license": "MIT",
  "dependencies": {
    "@actions/core": "^1.0.0"
  },
  "devDependencies": {
    "@types/jest": "^24.0.13",
    "@types/node": "^12.0.4",
    "jest": "^24.8.0",
    "jest-circus": "^24.7.1",
    "ts-jest": "^24.0.2",
    "typescript": "^3.5.1"
  }
}
package.json

And finally, it’s time to dive into code. The entry point for my code is the main.ts file under src. Right now it looks like this:

import * as core from '@actions/core';
async function run() {
  try {
    const myInput = core.getInput('myInput');
    core.debug(`Hello ${myInput}`);
  } catch (error) {
    core.setFailed(error.message);
  }
}
run();
main.ts

Github provides us with 5 libraries that help us do the things we need in an action.

image

In our sample code, we are using core to get the input variables. For my dacpac-deploy action, I need to get the input for my path to my dacpac file, my connection string to my sql server and any additional command line arguments. Right now, just to see if I can get things working, I’ll just get the input and then echo it to the screen. I updated main.ts to look like this:

import * as core from '@actions/core';
async function run() {
  try {
    const connectionString = core.getInput('connectionString');
    const dacpac = core.getInput('dacpac');
    const additionalArguments = core.getInput('additionalArguments');
    console.log("connection string: " + connectionString);
    console.log("dacpac: " + dacpac);
    console.log("additionalArguments: " + additionalArguments);
  } catch (error) {
    core.setFailed(error.message);
  }
}
run();
main.ts

Building and deploying my action

To “build” my action, the first thing I do is npm install from the command line at the root of my repo. This installs all the node libraries I need.

image

Next, I do a npm run build from the command line. This packages and “builds” .ts files into .js files and it puts them under the lib folder

image

And finally, I have to make sure my node_modules folder has production modules (not dev modules) so I delete the node_modules folder, and then run npm install –production from the command line. Now, I’m ready to check everything in, including the node_modules directory. The node_modules directory is in my .gitignore file so when I add this, I need to add a –f. From the command line, I issue the following commands:

git add *

git add node_modules –f

git commit –m “first take of my action”

git push

Aaaand…. uh…. wait… I think I just wrote and published my first action. Let’s test it out. I quickly create a workflow that uses this action:

name: Test dacpac deploy task
on: [push]
jobs:
  # run the dacpac-deploy action
  buildWeb:
    runs-on: windows-latest
    steps:
    - name: test dacpac deploy action
      uses: abelsquidhead/dacpac-deploy@master
      with:
        connectionString: "Server=tcp:abelmercuryhealthcoredbserverbeta.database.windows.net,1433;Initial Catalog=abelmercuryhealthcoredbbeta;Persist Security Info=False;User ID={your_username};Password={your_password};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
        dacpac: "db\MercuryHealthDB.dacpac"
        additionalArguments: "/p: BlockOnPossibleDataLoss=(BOOLEAN 'True')"
    

Check it in and my output is

image

SWEET!!!! I just created my first action!!!! Granted it doesn’t do much. It just takes 3 values as inputs and then echoes them out to the console. But its a start!

Time to write my action for real

I’m thinking this action should be pretty easy to write. I have the path to the dacpac, the connection string and additional command line parameters. I also know the path the sqplpackage.exe since I found where it was installed on the GitHub Windows VM in my last blog. All I need to do is call:

sqlpackage.exe /Action:Publish /SourceFile:<path to dacpac>  /TargetConnectionString: <connection string> <additional command line args>

Easy peasy! I think what I’m going to do is create an object named DacpacDeployer which does all the heavy lifting. This way, my main.ts doesn’t do anything except instantiate the DacpacDeployer and then calls the deploy() method. By isolating all my logic in the DacpacDeployer task, this should make writing unit tests easier.

I get that this action might be overkill for literally just calling sqlpackage.exe with some parameters. It makes more sense to create an install-sqlpackage.exe action where I add the path to sqlpackage.exe to the path and that’s it. But this is just a learning exercise so I’ll create the dacpac-deploy action.

Here is my DacpacDeployer.ts object:

import * as core from '@actions/core';
import * as exec from '@actions/exec';
export class DacpacDeployer {
    connectionString: string;
    dacpac: string;
    additionalArguments: string;
    workspacePath: string;
    constructor() {
        // get all the inputs
        this.connectionString = core.getInput('connectionString');
        this.dacpac = core.getInput('dacpac');
        this.additionalArguments = core.getInput('additionalArguments');
        // get workspace path from environment variable
        this.workspacePath = <string>process.env.GITHUB_WORKSPACE;
    }
    deploy(): void {
        // add sql package.exe to path
        core.addPath('C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Enterprise\\Common7\\IDE\\Extensions\\Microsoft\\SQLDB\\DAC\\150');
        // getting input variables and workspace path to create the command line command string
        console.log("updating database...");
        console.log("connectionString: " + this.connectionString);
        console.log("dacpac: " + this.dacpac);
        console.log("additionalArguments: " + this.additionalArguments);
        console.log("workspace: " + this.workspacePath);
        console.log("");
        // create command string from all the inputs and workspace path
        let commandString = "sqlpackage.exe /Action:Publish /SourceFile:\"" + this.workspacePath + "\\" + this.dacpac + "\" /TargetConnectionString:\"" + this.connectionString + "\" " + this.additionalArguments;
        console.log("command string: " + commandString);
        console.log("");
        // call sql package.exe
        exec.exec(commandString)
            .then(r => {
                console.log(r);
                console.log("done updating database");
            })
            .catch(e => {
                console.log("done updating database")  
                core.setFailed(`Action failed with ${e}`);
            });
    }
}
DacpacDeployer.ts

In my constructor I use the core library to get all my input values. I also get the workspace path from the environment variable GITHUB_WORKSPACE as I’ll need this to construct the full path to my dacpac later.

In my deploy method, I use the core library to add the path to sqlpackage.exe to the path. Then, I create my command line command string from all my variables. And finally, I use the exec library to call sqlpackage.exe with all the command line variables tacked on to it. Because I’m using the exec library, I need to add it to package.json

{
  "name": "dacpac-deploy",
  "version": "0.0.0",
  "private": true,
  "description": "This action deploys a database schema using a dacpac",
  "main": "lib/main.js",
  "scripts": {
    "build": "tsc",
    "test": "jest"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/actions/javascript-template.git"
  },
  "keywords": [
    "actions",
    "node",
    "setup"
  ],
  "author": "GitHub",
  "license": "MIT",
  "dependencies": {
    "@actions/core": "^1.0.0",
    "@actions/exec": "^1.0.0"
  },
  "devDependencies": {
    "@types/jest": "^24.0.13",
    "@types/node": "^12.0.4",
    "jest": "^24.8.0",
    "jest-circus": "^24.7.1",
    "ts-jest": "^24.0.2",
    "typescript": "^3.5.1"
  }
}

And here is my main.ts

import * as core from '@actions/core';
import Deployer = require("./DacpacDeployer");
async function run() {
  try {
    let myDeployer = new Deployer.DacpacDeployer();
    myDeployer.deploy();
  } catch (error) {
    core.setFailed(error.message);
  }
}
run();
main.ts

Very little magic here. I import my DacPacDeployer, instantiate it and then call the deploy method.

To “build and deploy” my action, I call from the command line at the root of my action

  • npm install
  • npm run build
  • delete my node_modules directory
  • npm install –production
  • git add *
  • git add node_modules –f
  • git commit –m “initial version of my dacpac deployer”
  • git push

Allright! Let’s test my action, see if it works. In my MercuryWebCore repo, I create a simple deployDatabaseWorkflow.yml

name: Deploy Database Workflow
on: [push]
jobs:
  # build database schema, build artifact is the dacpac
  buildDatabase:
    runs-on: windows-latest
    steps:
    # checkout code from repo
    - name: checkout repo
      uses: actions/checkout@v1
    # use msbuild to build VS solution which has the SSDT project
    - name: build solution
      run: |
        echo "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\bin\MSBuild.exe MercuryHealthCore.sln"
        "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe" MercuryHealthCore.sln
    # publish build artifact (dacpac) back to github
    - name: publish build artifacts back to GitHub
      uses: actions/upload-artifact@master
      with:
        name: db
        path: MercuryHealthDB\bin\Debug
  # deploy new database schema using dacpac
  deployDB:
      needs: buildDatabase
      runs-on: windows-latest
      steps:
      # download build artifacts
      - name: download build artifacts
        uses: actions/download-artifact@master
        with: 
          name: db
      # deploy dacpac calling my dacpac-deploy task
      - name: update database schema using dacpac deploy action
        uses: abelsquidhead/dacpac-deploy@master
        with:
          connectionString: ${{ secrets.DATABASE_CONNECTION_STRING }}
          dacpac: 'db\MercuryHealthDB.dacpac'
          additionalArguments: '/p:BlockOnPossibleDataLoss=False'
deployDatabaseWorkflow.yml

Running the build my output is now

image

Holy crap! I totally built my own github action. That wasn’t hard at all.

Wrapping up this action

There’s a couple of things I need to do before calling this action finished. I need to write unit tests and I also need to write a good readme. This isn’t a blog about unit testing so I won’t dive into that. For the readme, the documentation recommends this:

image

So…. I update my readme with…

image

And… that’s a wrap. I just wrote an Action!

Let’s really get crazy

Ok, so writing a github action is really easy. Starting from the template makes everything super easy and the walkthroughs are good too. This one was a little too easy. I was hoping to have more of a challenge. Just to see how it would work, I wanted to see how actions would work if the tool I wanted to use didn’t exist on the vm.

From the documentation, GitHub provides a tool-cache library where you can download and cache tools that aren’t on the vm’s. To test it out, I’m going to pretend sqlpackage.exe is not on the windows vm.

My code now looks like this:

import * as core from '@actions/core';
import * as exec from '@actions/exec';
const tc = require('@actions/tool-cache');
export class DacpacDeployer {
    connectionString: string;
    dacpac: string;
    additionalArguments: string;
    workspacePath: string;
    constructor() {
        // get all the inputs
        this.connectionString = core.getInput('connectionString');
        this.dacpac = core.getInput('dacpac');
        this.additionalArguments = core.getInput('additionalArguments');
        // get workspace path from environment variable
        this.workspacePath = <string>process.env.GITHUB_WORKSPACE;
    }
    async deploy() {
        // download sqlpackage.exe 
        const sqlPackageDownloadPath = await tc.downloadTool('https://abelsharedblob.blob.core.windows.net/abelblog/sqlpackage.exe.zip');
        // unzip it
        const sqlPackageExtractedFolder = await tc.extractZip(sqlPackageDownloadPath, this.workspacePath + "\\sqlpackageexe");
        // add sql package.exe to path
        core.addPath(sqlPackageExtractedFolder);
        // add sql package.exe to path
        //core.addPath('C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Enterprise\\Common7\\IDE\\Extensions\\Microsoft\\SQLDB\\DAC\\150');
        // getting input variables and workspace path to create the command line command string
        console.log("updating database...");
        console.log("connectionString: " + this.connectionString);
        console.log("dacpac: " + this.dacpac);
        console.log("additionalArguments: " + this.additionalArguments);
        console.log("workspace: " + this.workspacePath);
        console.log("");
        // create command string from all the inputs and workspace path
        // let commandString = "sqlpackage.exe /Action:Publish /SourceFile:\"" + this.workspacePath + "\\" + this.dacpac + "\" /TargetConnectionString:\"" + this.connectionString + "\" " + this.additionalArguments;
        let commandString = "sqlpackage.exe /Action:Publish /SourceFile:\"" + this.workspacePath + "\\" + this.dacpac + "\" /TargetConnectionString:\"" + this.connectionString + "\" " + this.additionalArguments;
        console.log("command string: " + commandString);
        console.log("");
        // call sql package.exe
        exec.exec(commandString)
            .then(r => {
                console.log(r);
                console.log("done updating database");
            })
            .catch(e => {
                console.log("done updating database")  
                core.setFailed(`Action failed with ${e}`);
            });
    }
}
DacpacDeployer.ts

I use the tool-cache library to download my version of sqlpackage.exe (I zipped up the sqlpackage folder and stuck it in azure storage). I then use the tool-cache library to unzip the zip file I just downloaded and then using the core library, I add the path to the downloaded sqlpackage.exe to the PATH and then call this version. Because I’m using the tool-cache library, I need to add it to package.json

{
  "name": "dacpac-deploy",
  "version": "0.0.0",
  "private": true,
  "description": "This action deploys a database schema using a dacpac",
  "main": "lib/main.js",
  "scripts": {
    "build": "tsc",
    "test": "jest"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/actions/javascript-template.git"
  },
  "keywords": [
    "actions",
    "node",
    "setup"
  ],
  "author": "GitHub",
  "license": "MIT",
  "dependencies": {
    "@actions/core": "^1.0.0",
    "@actions/exec": "^1.0.0",
    "@actions/tool-cache": "^1.0.0"
  },
  "devDependencies": {
    "@types/jest": "^24.0.13",
    "@types/node": "^12.0.4",
    "jest": "^24.8.0",
    "jest-circus": "^24.7.1",
    "ts-jest": "^24.0.2",
    "typescript": "^3.5.1"
  }
}
package.json

And now, when I run this Action I see

image

Bam!!!!! Everything just works!!!!

Conclusion

Github Actions 2.0 is pretty slick. It’s not complete and it’s definitely in beta but some things are already abundantly clear. It’s pretty simple to write CI/CD pipelines using actions. And creating custom actions is super easy as well. My example in this blog is kind of contrived, but it showed me that I can pretty much do whatever I need to in an Action. The power, flexibility and ease of writing custom actions is SICK!

Leave a Reply

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