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:
- Bump the
minor
version - Add commit message under the category:
🚀 Features
A branch merged in with a patch/
prefix will:
- Bump the
patch
version - 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);
};