The Power of Automation with GitHub Action - How to Create your Action
Jean Carlos Taveras
Posted on May 29, 2020
In the past two to four months I started to manage a new project where thankfully I was able to apply a lot of the things that I've been learning from courses and readings while having in mind the experience of the team members that I'm working with to make things easy but at the same time a little bit challenging so I can encourage them to learn new things or reinforced the knowledge that they currently have.
In the first two weeks of the project, we had to deliver an MVP so we decide to host it in Heroku where I created a pipeline for multi-environment, which now that I think about it was an overkilled 😅 since it was just an MVP.
Moving on, I wanted to be able to push my Docker images to the Heroku Registry so every little piece of code that was merged I manually built the image and pushed it to Heroku.
So far so good, but I was getting tired of doing the same thing over and over again, so that's when I remember that I can use GitHub Actions to automate this process 💡. I search the GitHub Marketplace for something that allows me to build and push my docker images to Heroku, I found some things, but it wasn't what I wanted. So I did whatever an engineer would do, create its action 😎.
Read the Docs!
Since I've never worked with Action I have to go and read the documentation which I found out that it is a well-documented feature.
Something that caught my attention was that one can write their actions for some of the common programming languages such as JavaScript, Python, and Java. You can read more about the supported languages and framework here.
Now that I know that I can write an action for my project, I then went ahead and landed on the create actions page, here I noticed that you can write your Actions with JavaScript or Bash, which is cool 😉 for me.
Building the action
I decided to use JavaScript to write my action so as usual, create a folder for your project:
mkdir my-action && cd my-action
Add the action.yml
Open your project directory with your favorite IDE or Code Editor and create a new file called action.yml
. This file is where you are going to define your action metadata and should have the following structure:
name: # Name of your action
description: # Some Fancy description explaining what this does
inputs: # User input for you action
id_of_your_input:
description: # What is this input about
required: # Set this to true if the input is required or set it to fall if otherwise
default: # Some default value
outputs:
time: # id of output
description: 'The time we greeted you'
runs:
using: 'node12'
main: 'index.js'
So I created my action.yml
and it looks something like this:
name: 'Deploy Docker Image to Heroku App'
author: 'Jean Carlos Taveras'
description: 'A simple action to build, push and Deploy a Docker Image to your Heroku app.'
inputs:
email:
description: 'Email Linked to your Heroku Account'
required: true
api_key:
description: 'Your Heroku API Key'
required: true
app_name:
description: 'Your Heroku App Name'
required: true
dockerfile_path:
description: 'Dokerfile path'
required: true
options:
description: 'Optional build parameters'
required: false
runs:
using: 'node12'
main: 'dist/index.js'
Install Dependencies
Before you can start coding you need to install two dependencies
@actions/core
@actions/github
The @actions/core
is required for you to be able to pull the declared input and output variables and more from the action.yml
. On the other hand, the @actions/github
is used to get information about the Action Context and more.
npm install -s @actions/core @actions/github
Write the core of the action
Create an index.js
file and let's import the dependencies:
const core = require('@actions/core');
const github = require('@actions/github'); // In case you need it
Since I'm going to need to execute docker and Heroku commands I'll need to add the child_process
and the util
modules and get the promisify
function from the latter one.
...
const { promisify } = require('util');
const exec = promisify(require('child_process').exec);
Nice! Now I have to create a function to allow authentication to the Heroku Registry.
...
async function loginHeroku() {
const login = core.getInput('email');
const password = core.getInput('api_key');
try {
await exec(`echo ${password} | docker login --username=${login} registry.heroku.com --password-stdin`);
console.log('Logged in succefully ✅');
} catch (error) {
core.setFailed(`Authentication process faild. Error: ${error.message}`);
}
}
Good! Now I need to build the Docker image, push it to the Heroku Registry and deploy it to the Heroku App
...
async function buildPushAndDeploy() {
const appName = core.getInput('app_name');
const dockerFilePath = core.getInput('dockerfile_path');
const buildOptions = core.getInput('options') || '';
const herokuAction = herokuActionSetUp(appName);
try {
await exec(`cd ${dockerFilePath}`);
await exec(`docker build . --file Dockerfile ${buildOptions} --tag registry.heroku.com/${appName}/web`);
console.log('Image built 🛠');
await exec(herokuAction('push'));
console.log('Container pushed to Heroku Container Registry ⏫');
await exec(herokuAction('release'));
console.log('App Deployed successfully 🚀');
} catch (error) {
core.setFailed(`Something went wrong building your image. Error: ${error.message}`);
}
}
Now that I see this, I need to refactor this function 😅. I think I took it too seriously when I said let's Write the core of our action.
As you might notice there's a function called herokuActionSetUp
which is just a helper function that returns the Heroku action (push or release).
...
/**
*
* @param {string} appName - Heroku App Name
* @returns {function}
*/
function herokuActionSetUp(appName) {
/**
* @typedef {'push' | 'release'} Actions
* @param {Actions} action - Action to be performed
* @returns {string}
*/
return function herokuAction(action) {
const HEROKU_API_KEY = core.getInput('api_key');
const exportKey = `HEROKU_API_KEY=${HEROKU_API_KEY}`;
return `${exportKey} heroku container:${action} web --app ${appName}`
}
}
We are almost done. We just need to call our functions and since these functions are asynchronous then we can chain them together as follow:
...
loginHeroku()
.then(() => buildPushAndDeploy())
.catch((error) => {
console.log({ message: error.message });
core.setFailed(error.message);
})
Bundle your code
To prevent committing your node_modules/
folder you can run:
npx zeit/ncc build index.js
This will create a dist
folder with a bundle index.js
file bare in mind that you have to change the runs
section in your action.yml
file to point to the bundled JavaScript file:
runs:
using: 'node12'
main: 'dist/index.js'
Add a README
You should add a README.md
file to let users to how to use your action.
Testing your Action
You can follow the instructions in the GitHub documentation Testing out your action in a workflow. However, I found this method of testing really painful since you have to push your code every time you make a change. What you can do then is run your actions locally by using nektos/act is a well-documented tool and easy to use.
That's it, that's all you need to know to create an action with JavaScript. This post turned out to be a little bit longer than I thought it would since this is my first post.
Thanks and check this action in the GitHub Marketplace Deploy Docker Image to Heroku App as well as the repo at jctaveras/heroku-deploy.
Posted on May 29, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.