You might probably have heard about GitHub Actions, the CI/CD service provided by GitHub, which is in beta at the time of writing. With which you can create custom software development life cycle (SDLC) workflows directly in your GitHub repository.
An action is an individual task or step in workflows. Besides existed actions available on the Marketplace, which you can adopt right away, in this article, I share the approach of building custom actions in order to fit your unique workflow.
As an example, we’re going to build an action broadcasting CI/CD results via Telegram messages.
What we’re going to build is a JavaScript action, which is supposed to be lighter, faster than a Docker container action (see Types of actions).
First, create a repo for your action by applying the TypeScript action template, name it “action-telegram” or whatever you like.
Before diving into the code, it’s better to make a plan about how to organize source files & releases in the repo to keep it maintainable in the future.
For now, we have to check in the dependencies (the node_modules
folder) to make the action functioning, GitHub is NOT going to install them for us.
For this reason, it may be helpful to think that each action repo has two phases or sections, one for development and another for distribution: we don’t check in dependencies alongside source files, but install them during deployment (distribution). In this case, we use branches to separate the two phases/sections:
Branch/Tag | Description | Development | Distribution |
---|---|---|---|
master |
where we commit changes | ✅ | |
releases/v{version} |
release candidates e.g. releases/v1.0 |
✅ | |
v{version} |
tags referred from workflows e.g. v1.0 |
✅ |
Development
1. Preparing
Now we can start developing our action. As mentioned in the subtitle, we prefer TypeScript & Yarn:
- put TypeScript source files under the
src
folder, compiling into thelib
folder - keep
yarn.lock
rather thanpackage-lock.json
We should have the following .gitignore
rules on the master branch:
node_modules/
__tests__/runner/*
package-lock.json
lib/
2. Managing metadata
Add an action.yml
file to define the inputs & outputs of your action. You can find detailed documentation here.
# action.yml
name: 'Telegram Action'
description: 'Telegram notification for workflow set up with GitHub Actions'
author: <name>
inputs:
botToken:
description: 'The Telegram Bot token'
required: true
chatId:
description: 'The target to which the message will be sent, can be a Telegram Channel or Group'
required: true
jobStatus:
description: "The current status of the job: job.status, defaults to 'success'"
default: 'success'
skipSuccess:
description: "Only non-success notifications will be sent if true, include success notifications otherwise, defaults to 'true'"
default: 'true'
runs:
using: 'node12'
main: 'lib/main.js'
3. Coding
Install dependencies first:
- add actions toolkit:
yarn add @actions/github
so that we can access properties of the workflow itself - and also
yarn add request-promise-native
andyarn add -D @types/request-promise-native synp
And now the code, finally! 🤣
// src/main.ts
import * as core from '@actions/core';
import { context } from '@actions/github';
import * as request from 'request-promise-native';
(async function run() {
try {
const botToken = core.getInput('botToken');
const chatId = core.getInput('chatId');
const jobStatus = core.getInput('jobStatus');
const skipSuccess = (core.getInput('skipSuccess') || 'true') === 'true';
core.debug(`sending message, status=${jobStatus} skipSuccess=${skipSuccess} payload=${JSON.stringify(context.payload)}`);
await _sendMessage(botToken, chatId, jobStatus, skipSuccess);
core.debug('message sent');
} catch (error) {
core.setFailed(error.message);
}
})()
/**
* Send a Telegram message.
* @param botToken the Telegram bot token to send the message
* @param chatId id of targeted channel or group, to which the message will be sent
* @param jobStatus status of the job
*/
async function _sendMessage(
botToken: String,
chatId: String,
jobStatus: String = 'success',
skipSuccess: Boolean = true,
) {
const status = (jobStatus || '').toLowerCase();
if (status === 'success' && skipSuccess) {
core.debug('skipping successful job');
return;
}
const { repo, ref, sha, workflow, actor } = context;
const repoFullname = `${repo.owner}/${repo.repo}`;
const repoUrl = `https://github.com/${repoFullname}`;
let icon: String;
switch (status) {
case 'success': icon = '✅'; break;
case 'failure': icon = '🔴'; break;
default: icon = '⚠️'; break;
}
const uri = `https://api.telegram.org/bot${botToken}/sendMessage`;
const text = `${icon} [${repoFullname}](${repoUrl}/actions) ${workflow} *${jobStatus}*
\`${ref}\` \`${sha.substr(0, 7)}\` by *${actor}*
[View details](${repoUrl}/commit/${sha}/checks)`;
return request.post(uri, {
body: {
text,
chat_id: chatId,
parse_mode: 'Markdown',
},
json: true,
});
}
Distribution
As previously explained, the word distribution here means the process of publishing your action. You don’t have to deploy it anywhere, GitHub will handle it behind the scenes.
1. Preparing
Create a branch named releases/v1.0
assuming it’s your first release, and you can find detailed versioning guides here.
Append the following rules to .gitignore
, as we have to commit all the runtime stuff (dependencies & compiled code) to release branches.
# for release branches only
!lib/
!package-lock.json
!node_modules/
2. Build & Publish
Before launching the first release, we have many works to do, including compiling the code and packaging production modules. Let’s put down the procedures to scripts, making them reusable for future releases.
{
"scripts": {
"build": "tsc",
"clean": "rm -rf lib package-lock.json",
"rebuild": "yarn clean && yarn build",
"release": "yarn clean && yarn && yarn build && yarn npm-lock && mv -f package-lock.json npm.lock && rm -rf node_modules && yarn --prod && mv npm.lock package-lock.json",
"dev": "yarn clean && yarn",
"npm-lock": "synp -f -s yarn.lock"
}
}
It’s simpler than what it looks like, explained as followings:
yarn release
: run it before committing to release branches:- It compiles the code using
tsc
and generates apackage-lock.json
file viasynp
(both are dev dependencies), - and then cleans up to keep production packages only by
yarn install --prod
- It compiles the code using
yarn dev
: run it before making changes on the master branch
Now tag and push your commits to your remote repo (see versioning), and your action is published! 🎉
Validation & Usage
To make sure your first action is functioning properly, add a step to any workflow of any repo (the same action repo will also work). To get your own Telegram Bot token & chatId
, you may find these instructions helpful (ignore the local.ts part).
# .github/workflows/check.yml
- name: notification
uses: <user>/<repo>@v1 # use relative path if this's the same repo of your action
with:
botToken: <TelegramBotToken>
chatId: <TelegramChatId>
jobStatus: ${{ job.status }}
Trigger the workflow by making a few commits to the repo, to find out if everything works fine.
Tips & Best Practices (in my opinion)
- Make release branches read-only: Make changes on master branch only, and then merge them into release branches, to avoid conflicts
- Use a dedicated branch, e.g.,
release-template
, to keep shared configurations ascroll all release branches, and use it as a starting point for all your future releases (see my real-world action repo) - Enable diagnostic logging by setting the secret
ACTIONS_STEP_DEBUG
totrue
in your repo (see Enabling debug logging)
In this article, I shared my practices of building & distributing a custom action for GitHub Actions. Please let me know if you have any thoughts.
Happy coding!
This article is originally posted on my Medium publication.