Node.js CLI Release Pipeline for Github Actions

ci/cd github actions node.js

Recently I was tasked with creating a release pipeline that:

  • Is executed via CLI from the root of our repo
  • Runs our full CD pipeline
  • Provides input to select which project in the monorepo is released (they are deployed separately)
  • Publishes release notes on the GitHub release

Release drafting

The release process begins as pull requests are merged into the main branch. Upon the first PR merge after a release is deployed, a new draft release is created by release-drafter. The release draft is updated with release notes after each merge. You basically create a mapping of branch prefixes and map them to categories that will appear in the release notes. For example, in the config file:

categories:
  - title: '🚀 Features'
    labels:
      - 'feature'
      - 'enhancement'
  - title: '🧰 Maintenance'
    labels:
      - 'chore'
      - 'patch'
autolabeler:
  - label: 'major'
    branch:
      - '/major\/.+/'
  - label: 'minor'
    branch:
      - '/minor\/.+/'
  - label: 'feature'
    branch:
      - '/feature\/.+/'
      - '/feat\/.+/'
  - label: 'patch'
    branch:
      - '/task\/.+/'
      - '.+'
version-resolver:
  major:
    labels:
      - 'major'
  minor:
    labels:
      - 'minor'
      - 'feature'
  patch:
    labels:
      - 'patch'
      - 'fix'

Any branches merged in with a feature/ prefix will:

  1. Bump the minor version
  2. Add commit message under the category: 🚀 Features

A branch merged in with a patch/ prefix will:

  1. Bump the patch version
  2. Add commit message under the category: 🧰 Maintenance

CLI script for starting the release pipeline

Even though all our devs are on macOS, I wrote the command line script in node.js just so I wouldn’t need to write something for both Powershell and bash. It also meant I could use these two node.js tools:

This method requires that the user has access to a GitHub PAT that has read/write permissions on both the repository and the releases. And since it is a monorepo, we needed a list of projects. Our project amount doesn’t change often so these were hardcoded:

const {Confirm, MultiSelect} = require('enquirer');
const {Octokit} = require("@octokit/core");

const repoOwner = 'owner_or_org_of_repo';
const repo = 'repo-name';

// Which projects are available to release
const projects = [
    {
        projectPrefix: "project-one",
        projectName: "Project 1"
    },
    ...
];
var args = process.argv.slice(2); // Extract the args (first two are node and the node script)
if (typeof args[0] === 'undefined') {
    // You can also store your PAT in an ENV var
    console.log("No GH PAT specified. Pass it indirectly using: node release.js $(npm config get gh_pat)");
    return;
}

// GitHub PAT
let token = args[0];

// Begin the interactive release prompt to the user
startInteraciveRelease(token);

The full script is below, but let’s break it into parts. First, you need to get all the releases for the repo, which will include the releases drafted above. In our case, there was a separate release for each project, whose release name was prefixed with the same projectPrefix defined above.

const octokit = new Octokit({auth: token});

// Get all releases for the repo
let releases = [];
let currentPage = 1;
let response = await octokit.request('GET /repos/{owner}/{repo}/releases', {
    owner: repoOwner,
    repo: repo,
    per_page: 100,
    page: currentPage
});
while (response.data.length > 0) {
    releases = releases.concat(response.data.map(_ => ({
        releaseId: _.id,
        tagName: _.tag_name,
        isReleaseDraft: _.draft
    })));

    currentPage++;
    response = // Get the next page
}

With all the releases, get the most recent and previous (if there is one) for each repo. You can then build a collection projectReleases that has all the possible release options for the user:

let projectReleases = [];
for (const project of projects) {
    const releasesForProject = releases.filter((_) => _.tagName.startsWith(project.projectPrefix));
    // Sorts the releases by version pattern v[major].[minor].[patch]
    const releasesForProjectSortedByMostRecent = sortReleasesByMostRecent(releasesForProject);
    // Current release
    const currentReleaseForProject = releasesForProjectSortedByMostRecent[0];
    // Previous release
    const previousReleaseForProject = releasesForProjectSortedByMostRecent[1];

    let desc = `${project.projectName}: Up-to-date`;
    // There is a previous and current release
    if (previousReleaseForProject != undefined && previousReleaseForProject.hasOwnProperty('tagName')
        && currentReleaseForProject != undefined && currentReleaseForProject.hasOwnProperty('tagName')
        && currentReleaseForProject.isReleaseDraft == true) {
        desc = `${project.projectName}: (${previousReleaseForProject.tagName.replace(project.projectPrefix + '_', '')}) -> (${currentReleaseForProject.tagName.replace(project.projectPrefix + '_', '')})`;
    }

    // This is the first release
    else if (currentReleaseForProject != undefined && currentReleaseForProject.hasOwnProperty('tagName')
        && currentReleaseForProject.isReleaseDraft == true) {
        desc = `${project.projectName}: (n/a) -> (${currentReleaseForProject.tagName.replace(project.projectPrefix + '_', '')})`;
    }
    projectReleases.push({
        ...project,
        ...currentReleaseForProject,
        description: desc
    });
}

You then pass this collection of release options to MultiSelect which generates a prompt for the user. For example:

1. Project 1 (v1.2.9) -> (v1.3.0)
2. Project 2 (v2.5.5) -> (v2.5.6)
3. Project 3 (n/a) -> (v0.1.0) // First release

The prompt will release any projects that the user selects when interacting with the prompt, by invoking the corresponding release workflow of the project:

const prompt = new MultiSelect({
    name: 'value',
    message: 'Choose the projects to release',
    choices: projectReleases.map(_ => ({name: _.description, value: _})),
    result(names) {
        return this.map(names);
    }
});

// Releases a project
async function releaseProject(projectToRelease) {
    // Already released
    if (projectToRelease.isReleaseDraft == false) {
        console.log(`${projectToRelease.projectName} has already been released: ${projectToRelease.tagName}`);
        return;
    }

    // No valid release or tag
    if (projectToRelease.hasOwnProperty('isReleaseDraft') == false || projectToRelease.isReleaseDraft === undefined) {
        console.log(`${projectToRelease.projectName} has no tags or release drafts`);
        return;
    }

    // Release is valid, so start it
    console.log(`${projectToRelease.projectName} releasing: ${projectToRelease.tagName}`);
    await octokit.request('POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', {
        owner: repoOwner,
        repo: repo,
        workflow_id: `${projectToRelease.projectPrefix}_release.yml`,
        ref: 'main',
        inputs: {
            'release-tag': projectToRelease.tagName,
            'release-id': projectToRelease.releaseId.toString()
        }
    })
}

// Show the prompt to the user
prompt.run()
    .then(async answer => {
        for (const projectName in answer) {
            const projectToRelease = answer[projectName];
            await releaseProject(projectToRelease);
        }
    })
    .catch(console.error);

Project release workflow

The request POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches in the release script invokes a project’s release pipeline with the following parameters:

  • Release tag (the git tag that is being release)
  • Release ID (the ID of the GitHub release)
  • The project to release, which again reuses the projectPrefix: workflow_id: `${projectToRelease.projectPrefix}_release.yml`
name: Project 1 CD

on:
  push:
    tags:
      - "project-one_*"
  workflow_dispatch:
    inputs:
      release-tag:
        description: 'Release Tag'
        required: true
        default: 'project-one_'
      release-id:
        description: 'Release ID'
        required: false

You can then use both release-tag and release-id to publish the release and perform a deployment.

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      # Run unit tests

  vulnerability-scan:
    runs-on: ubuntu-latest
    steps:
      # Run scans

  publish-and-tag-release:
    needs: [ unit-test, vulnerability-scan ]
    runs-on: ubuntu-latest
    outputs:
      tag-output: ${{ steps.release-tag.outputs.tag }}
    steps:
      # Set the tagged release as "Published"
      - name: Publish release
        id: release-tag
        uses: actions/github-script@v6
        with:
          script: |
            const inputReleaseId = '${{ github.event.inputs.release-id }}';
            const inputReleaseTag = '${{ github.event.inputs.release-tag }}';
            const githubRef = '${{github.ref_name}}';

            if (inputReleaseId !== undefined && inputReleaseId !== '' && inputReleaseTag !== undefined && inputReleaseTag !== '') {
              const repoOwner = '${{ github.repository_owner }}';
              const repo = '${{ github.event.repository.name }}';

              let response = await github.request('PATCH /repos/{owner}/{repo}/releases/{release_id}', {
                  owner: repoOwner,
                  repo: repo,
                  release_id: inputReleaseId,
                  tag_name: inputReleaseTag,
                  draft: false
              });
              core.setOutput('tag', inputReleaseTag);
            } else if (githubRef !== 'main') {
              core.setOutput('tag', githubRef);
            }

  build-images:
    needs: [ publish-and-tag-release ]
    runs-on: ubuntu-latest
    steps:
      # Build Docker images

  deploy-image:
    runs-on: ubuntu-latest
    needs: build-images
    steps:
      # Deploy new Docker containers

Once the release draft is published, it means it has been released and on the next PR merge, a new release draft is created, starting the whole process over again.

release.js

const {Confirm, MultiSelect} = require('enquirer');
const {Octokit} = require("@octokit/core");

const repoOwner = 'owner_or_org_of_repo';
const repo = 'repo-name';

// Which projects are available to release
const projects = [
    {
        projectPrefix: "project-one",
        projectName: "Project 1"
    },
    {
        projectPrefix: "project-two",
        projectName: "Project 2"
    },
    {
        projectPrefix: "project-three",
        projectName: "Project 3"
    },
];
var args = process.argv.slice(2); // Extract the args (first two are node and the node script)
if (typeof args[0] === 'undefined') {
    // You can also store your PAT in an ENV var
    console.log("No GH PAT specified. Pass it indirectly using: node release.js $(npm config get gh_pat)");
    return;
}

// GitHub PAT
let token = args[0];

// Begin the interactive release prompt to the user
startInteraciveRelease(token);

// Gets a release page
async function getReleasePage(octokit, repoOwner, repo, currentPage) {
    return await octokit.request('GET /repos/{owner}/{repo}/releases', {
        owner: repoOwner,
        repo: repo,
        per_page: 100,
        page: currentPage
    });
}

// Gets all releases
async function getAllReleases(octokit, repoOwner, repo) {
    let releases = [];
    let currentPage = 1;
    let response = await getReleasePage(octokit, repoOwner, repo, currentPage);
    while (response.data.length > 0) {
        releases = releases.concat(response.data.map(_ => ({
            releaseId: _.id,
            releaseName: _.name,
            tagName: _.tag_name,
            isReleaseDraft: _.draft,
            createdAt: _.created_at,
            publishedAt: _.published_at
        })));

        currentPage++;
        response = await getReleasePage(octokit, repoOwner, repo, currentPage);
    }
    return releases;
}

// Sorts the releases by version pattern v[major].[minor].[patch]
async function sortReleasesByMostRecent(releasesForProject) {
    return releasesForProject.sort(function (a, b) {
        let a1 = a.tagName.split('.');
        let b1 = b.tagName.split('.');

        const len = Math.min(a1.length, b1.length);
        for (let i = 0; i < len; i++) {
            const a2 = +a1[i] || 0;
            const b2 = +b1[i] || 0;

            if (a2 !== b2) {
                return a2 > b2 ? 1 : -1;
            }
        }

        // Sort tag names by descending (using sem ver, so most recent will be the first)
        return b1.length - a1.length;
    }).reverse();
}

// Get release options
function getAllReleaseOptions(projects) {
    let projectReleases = [];
    for (const project of projects) {
        const releasesForProject = releases.filter((_) => _.tagName.startsWith(project.projectPrefix));
        // Sorts the releases by version pattern v[major].[minor].[patch]
        const releasesForProjectSortedByMostRecent = sortReleasesByMostRecent(releasesForProject);
        // Current release
        const currentReleaseForProject = releasesForProjectSortedByMostRecent[0];
        // Previous release
        const previousReleaseForProject = releasesForProjectSortedByMostRecent[1];

        let desc = `${project.projectName}: Up-to-date`;
        // There is a previous and current release
        if (previousReleaseForProject != undefined && previousReleaseForProject.hasOwnProperty('tagName')
            && currentReleaseForProject != undefined && currentReleaseForProject.hasOwnProperty('tagName')
            && currentReleaseForProject.isReleaseDraft == true) {
            desc = `${project.projectName}: (${previousReleaseForProject.tagName.replace(project.projectPrefix + '_', '')}) -> (${currentReleaseForProject.tagName.replace(project.projectPrefix + '_', '')})`;
        }

        // This is the first release
        else if (currentReleaseForProject != undefined && currentReleaseForProject.hasOwnProperty('tagName')
            && currentReleaseForProject.isReleaseDraft == true) {
            desc = `${project.projectName}: (n/a) -> (${currentReleaseForProject.tagName.replace(project.projectPrefix + '_', '')})`;
        }
        projectReleases.push({
            ...project,
            ...currentReleaseForProject,
            description: desc
        });
    }
    return projectReleases;
}

// Releases a project
async function releaseProject(projectToRelease) {
    // Already released
    if (projectToRelease.isReleaseDraft == false) {
        console.log(`${projectToRelease.projectName} has already been released: ${projectToRelease.tagName}`);
        return;
    }

    // No valid release or tag
    if (projectToRelease.hasOwnProperty('isReleaseDraft') == false || projectToRelease.isReleaseDraft === undefined) {
        console.log(`${projectToRelease.projectName} has no tags or release drafts`);
        return;
    }

    // Release is valid, so start it
    console.log(`${projectToRelease.projectName} releasing: ${projectToRelease.tagName}`);
    await octokit.request('POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', {
        owner: repoOwner,
        repo: repo,
        workflow_id: `${projectToRelease.projectPrefix}_release.yml`,
        ref: 'main',
        inputs: {
            'release-tag': projectToRelease.tagName,
            'release-id': projectToRelease.releaseId.toString()
        }
    })
}

async function startInteraciveRelease(token) {
    const octokit = new Octokit({auth: token});

    // Get all releases
    let releases = await getAllReleases(octokit, repoOwner, repo);

    // Get release options
    let projectReleases = getAllReleaseOptions(projects);

    // Define the prompt to the user with the release options
    const prompt = new MultiSelect({
        name: 'value',
        message: 'Choose the projects to release',
        choices: projectReleases.map(_ => ({name: _.description, value: _})),
        result(names) {
            return this.map(names);
        }
    });

    // Show the prompt to the user
    prompt.run()
        .then(async answer => {
            for (const projectName in answer) {
                const projectToRelease = answer[projectName];
                await releaseProject(projectToRelease);
            }
        })
        .catch(console.error);
};