Private GitHub actions to install a private binary tool


GitHub Actions is a native CI/CD system. In this tutorial, we going to cover how to create a private Github Action that deploys a binary released as part of another private repository.

The usage of actions from a private repositories differs from a regular action in a public repo in 2 points:

  1. Need to git checkout the action code from the private repo, which requires a decidate Personal Access Token (PAT) with repo permissions. GITHUB_TOKEN is not enough since it only has permissions to the current repo where the workflow is being executed
  2. Run the action using the local path where it was checked out instead of repository url.

Implementation

A Github action (javascript type) needs to declare at least the 2 following files:

  • action.yml: Contains the metadata for the action (e.g: description, input and output parameters, the .js file to be run, etc)
  • The .js to be invoked (needs to match with the value in action.yml). Usually index.js or main.js

Tips to understand the code blocks below:

  • Anything between angular brackets (e.g <owner>, <repo> and <tool>) should be replaced by actual values if the code where to be used
  • PRIVATE_REPO_ACCESS_TOKEN needs to be declared as a secret in the github repo where the workflow invokes the private action

action.yml

The action.yml below declares the 2 input parameters required by this action:

  • version (optional): Value to determine which tool version to install. If not specified, defaults to latest
  • token (required): PAT to authenticate against the private repository where the
name: "Github Action for tool"
description: "tool description"
author: "author name"
branding:
  icon: "droplet"
  color: "blue"
inputs:
  version:
    description: "Version of tool to install"
    default: "latest"
    required: false
  token:
    description: "PAT with permission to download from private repos"
    default: ""
    required: true
runs:
  using: "node16"
  main: "index.js"

index.js script

The index.js contains the logic of the action to execute. Its implementation can be divided in 3 components:

  • Resolve the version of the tool to install using octokit/repo.js library.
  • Download the tool from the private repo using @actions/tool-cache library.
  • Make the tool available locally in path using @actions/core and @actions/exec libraries.

Want to directly to the complete index.js script? Go here

Resolve version of tool to install

The resolveReleaseData function takes care of obtaining the release information through the GitHub API for the version to be installed (a specific version or the latest one). For that, it leverages the octokit/repo.js library as follows:

  • If no version or version parameter is set to latest, invoke getLatestRelease function to get the data about the latest release
  • If version is set to something other than latest, use the getReleaseByTag function to find the information about that particular release.

To execute the above functions, the parameters needs to include:

  • owner and repo to establish from which repo to get the data.
  • headers containing the authorization field with the PAT token.
async function resolveReleaseData() {
    let version = core.getInput('version');
    let token = core.getInput('token');
    let releaseData = null
    let params = {
        owner: '<owner>',
        repo: '<repo>',
        headers: {
            accept: 'application/vnd.github.v3+json',
            authorization: `token ${token}`
        }
    }

    if ((!version) || (version.toLowerCase() === 'latest')) {
        core.info("Get release info for latest version")
        releaseData = await octokit.repos.getLatestRelease(params).then(result => {
            return result.data;
        })
    } else {
        core.info(`Get release info for release ${version}`)
        params["tag"] = version
        releaseData = await octokit.repos.getReleaseByTag(params).then(result => {
            return result.data;
        })
    }

    return releaseData
}

Download tool

Using the release information obtained from the resolveReleaseData, the downloadTool function takes care of downloading the correct binary according to the platform version where it is being installed (linux or darwin).

  • In this example, all the tools version in a given release are named following the pattern: <tool>_<platform>_amd64.
  • tc.downloadTool takes care of downloading the particular binary from the release, using the url associated with the asset. Since it is a private project, it requires the PAT to authenticate and the header accept: 'application/octet-stream'.
async function downloadTool(releaseData) {
    core.info(`Downloading <tool> from ${releaseData.html_url}`)
    let token = core.getInput('token');

    let platform = 'linux';
    if (process.platform === 'darwin') {
        platform = 'darwin';
    }

    let assetName = `<tool>_${platform}_amd64`
    let asset = releaseData.assets.find(obj => {
        return obj.name == assetName
    })

    const toolDownload = await tc.downloadTool(
        asset.url,
        undefined,
        `token ${token}`,
        {
            accept: 'application/octet-stream'
        }
    );
    return toolDownload;
}

Making the tool available in path

After downloading the binary, using downloadTool function, it is recommend to make it available on path, so that users can invoke it without having to specify the full path to the binary.

  • tc.cacheFile copies the file to the caching directory and can rename the binary (which the code below does). That allows users to use the tool as tool ... instead of /path/to/tool ...
  • exec.exec runs the chmod command to make sure that the binary is executable
  • core.addPath adds the path to the binary to the $PATH environment variable to allow binary usage without having to specify the full path.
async function makeAvailableInPath(download, version) {
    let name = '<tool>'
    core.info(`Cache file ${download} and rename to generic name`);
    const cachedPath = await tc.cacheFile(download, name, name, version);
    const filePath = path.join(cachedPath, name)

    core.info(`Making <tool> binary executable`);
    await exec.exec("chmod", ["+x", filePath]);

    core.info(`Make ${cachedPath} available in path`);
    core.addPath(cachedPath);
}

Complete index.js

The complete index.js file combining the code blocks from the 3 previous sections, plus the run function which orchestrate the functions usage.

const core = require('@actions/core');
const exec = require('@actions/exec');
const tc = require('@actions/tool-cache');
const { Octokit } = require("@octokit/rest");
const path = require("path");

const octokit = new Octokit();

async function resolveReleaseData() {
    let version = core.getInput('version');
    let token = core.getInput('token');
    let releaseData = null
    let params = {
        owner: '<owner>',
        repo: '<repo>',
        headers: {
            accept: 'application/vnd.github.v3+json',
            authorization: `token ${token}`
        }
    }

    if ((!version) || (version.toLowerCase() === 'latest')) {
        core.info("Get release info for latest version")
        releaseData = await octokit.repos.getLatestRelease(params).then(result => {
            return result.data;
        })
    } else {
        core.info(`Get release info for release ${version}`)
        params["tag"] = version
        releaseData = await octokit.repos.getReleaseByTag(params).then(result => {
            return result.data;
        })
    }

    return releaseData
}

async function downloadTool(releaseData) {
    core.info(`Downloading <tool> from ${releaseData.html_url}`)
    let token = core.getInput('token');

    let platform = 'linux';
    if (process.platform === 'darwin') {
        platform = 'darwin';
    }

    let assetName = `<tool>_${platform}_amd64`
    let asset = releaseData.assets.find(obj => {
        return obj.name == assetName
    })

    const toolDownload = await tc.downloadTool(
        asset.url,
        undefined,
        `token ${token}`,
        {
            accept: 'application/octet-stream'
        }
    );
    return toolDownload;
}

async function makeAvailableInPath(download, version) {
    let name = '<tool>'
    core.info(`Cache file ${download} and rename to generic name`);
    const cachedPath = await tc.cacheFile(download, name, name, version);
    const filePath = path.join(cachedPath, name)

    core.info(`Making <tool> binary executable`);
    await exec.exec("chmod", ["+x", filePath]);

    core.info(`Make ${cachedPath} available in path`);
    core.addPath(cachedPath);
}

async function run() {
    try {
        let releaseData = await resolveReleaseData()
        let version = releaseData.tag_name
        core.info(`>>> Version to set up: ${version}`);

        let path = tc.find("<tool>", version);
        if (!path) {
            let download = await downloadTool(releaseData)
            await makeAvailableInPath(download, version);
            core.info(`>>> <tool> version ${version} installed successfully`);
        } else {
            core.info(`>> <tool> version ${version} already installed`)
        }

        await exec.exec('<tool> --help');
        core.info('>>> Successfully invoked <tool> help');
    }
    catch (error) {
        core.setFailed(error.message);
    }
}

run();

Action usage

Since Github Actions doesn’t support invoking actions from private repos directly, we need the following workaround to use the action (named setup-tool in this example) in our workflows:

  1. First checkout the private repo that defines the setup-tool action into your current repo, using a token with repo permissions (secrets.PRIVATE_REPO_ACCESS_TOKEN in the example below)
  2. Run the setup-tool action from the local path, which is a supported usage
    - uses: actions/checkout@v2
      with:
        repository: <owner>/setup-tool
        token: ${{ secrets.PRIVATE_REPO_ACCESS_TOKEN }}
        persist-credentials: false
        path: ./.github/actions/setup-tool
    - uses: ./.github/actions/setup-tool
      with:
        token: ${{ secrets.PRIVATE_REPO_ACCESS_TOKEN }}

Notes

  • persist-credentials: false avoids saving the access token into the current git configuration. This means that subsequent git operations will still rely on the default GitHub Action setup (able to pull the current project through GITHUB_TOKEN and only public repos in addition)

Future steps

  • Make the action generic so that it can install a published binary tool from any repository (public or private)

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer’s view in any way.