Converting GitHub Actions from Docker to JavaScript

 ⋅ 4 min read

The full source for this Action is available at paambaati/codeclimate-action.

GitHub Actions are the latest big thing in the world of CI/CD. As GitHub is slowly inching towards an “eat the whole world” monopoly (see package registry), they’ve also launched a very compelling CI/CD automation feature called Actions that let you define your own custom workflows for your GitHub repositories. They’re currently in public beta and will be generally available by November 13.

The Actions documentation is pretty great, and I’d recommend reading it to understand how to use them.

There are 2 types of Actions - Docker based and JavaScript based. While each have their pros and cons (see "Types of actions"), the summary of it is — JavaScript Actions run on all platforms (Linux, macOS & Windows) and they’re faster than Docker-based Actions.

I’d recommend writing JavaScript Actions if your workflow doesn’t need specific versions of tools, dependencies or platforms. So without much further ado, here’s how I rewrote a Docker-based action to JavaScript/TypeScript.

From Docker to JavaScript

Recently, I published an action that uploads your code coverage results to Code Climate. The first version was based on Docker, and here's how it looked —

Dockerfile
FROM node:lts-alpine

LABEL version="1.0.0"
LABEL repository="http://github.com/paambaati/codeclimate-action"
LABEL homepage="http://github.com/paambaati/codeclimate-action"
LABEL maintainer="GP <me@httgp.com>"

LABEL com.github.actions.name="Code Climate Action"
LABEL com.github.actions.description="Publish code coverage to Code Climate"
LABEL com.github.actions.icon="code"
LABEL com.github.actions.color="gray-dark"

RUN apk add --no-cache python make g++ curl

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT [ "/entrypoint.sh" ]
CMD [ "yarn coverage" ]
entrypoint.sh
#!/bin/bash

set -eu

curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
chmod +x ./cc-test-reporter
./cc-test-reporter before-build

bash -c "$*"

./cc-test-reporter after-build --exit-code $?

How this works

The Dockerfile includes all the metadata for GitHub Actions with the LABEL directives, and includes an ENTRYPOINT script. The entrypoint script downloads the Code Climate reporter and then runs it before and after the actual coverage command.

Motivation for rewriting in JavaScript

I realized that the Docker-based Action could only be run on Linux. If I wanted my Action to be used on all platforms, I had to write this as a JavaScript action. As a bonus, running times would also reduce (YMMV, but after the rewrite, running time for a coverage task fell from 26 seconds to a whopping 9 seconds!)

To rewrite it in JavaScript, I mostly followed the official documentation. It also includes a handy template repository if you want to get started with a JS/TS action right away.

Every Action repository needs an action.yml file. You can read more about all the available metadata syntax.

action.yml
name: 'Code Climate Action'
description: 'Publish code coverage to Code Climate'
author: 'GP <me@httgp.com>'
branding:
  icon: 'code'
  color: 'gray-dark'
inputs:
  coverageCommand:
    description: 'Coverage command to execute'
    default: 'yarn coverage'
runs:
  using: 'node12'
  main: 'lib/main.js'

lib/main.js includes all the Action's logic. GitHub already maintains npm packages for most basic tasks like logging, executing commands, filesystem services & using the GitHub API.

Using these, here's my main code —

main.ts
import { platform } from 'os';
import { createWriteStream } from 'fs';
import fetch from 'node-fetch';
import { debug, getInput } from '@actions/core';
import { exec } from '@actions/exec';

const DOWNLOAD_URL = `https://codeclimate.com/downloads/test-reporter/test-reporter-latest-${platform()}-amd64`;
const EXECUTABLE = './cc-reporter';
const DEFAULT_COVERAGE_COMMAND = 'yarn coverage';

export function downloadToFile(url: string, file: string, mode: number = 0o755): Promise<void> {
    return new Promise(async (resolve, reject) => {
        try {
            const response = await fetch(url, { timeout: 2 * 60 * 1000 }); // Timeout in 2 minutes.
            const writer = createWriteStream(file, { mode });
            response.body.pipe(writer);
            writer.on('close', () => {
                return resolve();
            });
        } catch (err) {
            return reject(err);
        }
    });
}

export function run(downloadUrl = DOWNLOAD_URL, executable = EXECUTABLE, coverageCommand = DEFAULT_COVERAGE_COMMAND): Promise<void> {
    return new Promise(async (resolve, reject) => {
        await downloadToFile(downloadUrl, executable);
        await exec(executable, ['before-build']);
        await exec(coverageCommand);
        await exec(executable, ['after-build', '--exit-code', lastExitCode.toString()]);
        debug('Coverage uploaded!');
        return resolve();
    });
}

const coverageCommand = getInput('coverageCommand', { required: false });
run(DOWNLOAD_URL, EXECUTABLE, coverageCommand);

Publishing to the Actions Marketplace

To publish an Action, there are a few manual steps —

  1. Check in your built files (if any).
  2. Check in your node_modules (🚩if there are native modules in your dependency tree, you’d be better off writing a Docker-based action).
  3. Remove development dependencies.
  4. Version them via release branches or better yet, tags.

To make these steps easier, I’ve written a simple bash script —

release.sh
#!/bin/bash

set -e

# Check if we're on master first.
git_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$git_branch" == "master" ]; then
    echo "Cannot release from 'master' branch. Please checkout to a release branch!"
    echo "Example: git checkout -b v1-release"
    exit 1
fi

# Install dependencies and build & test.
npm install
npm test
npm run build

# Build & tests successful. Now keep only production deps.
npm prune --production

# Force add built files and deps.
git add --force lib/ node_modules/
git commit -a -m "Publishing $git_branch"
git push -u origin $git_branch

# Set up release tag.
read -p "Enter tag (example: v1.0.0) " git_tag
git push origin ":refs/tags/$git_tag"
git tag -fa "$git_tag" -m "Release $git_tag"
git push -u origin $git_tag
git push --tags

echo "Done!"
git_repo="$(git config --get remote.origin.url | cut -d ':' -f2 | sed "s/.git//")"
echo "You can now use this action with $git_repo@$git_tag"

It automates all of these steps with only a prompt for the release tag version. Once run, you will see that there's a new release in the repository under the Releases tab. When you edit it, you're presented with the option to publish your action to the Actions Marketplace

Publish release to GitHub Actions Marketplace

Using the Action

After the action is published, users can start using it like this —

your-own-workflow.yml
steps:
- name: Test & publish code coverage
  uses: paambaati/codeclimate-action@v2.1.0
  env:
    CC_TEST_REPORTER_ID: <code_climate_reporter_id>
  with:
    coverageCommand: npm run coverage