DEV Community

Kaveh Karimi
Kaveh Karimi

Posted on

My First Github Action

I started using GitHub Actions a while back to handle workflows in some projects, mostly for building docker images or running tests. They are easy to set up and Github Marketplace has a great number of them for different purposes, from managing tests and deployment pipelines to adding cat memes to your PRs.

I've been wanting to look under the hood for a while and see how I can create my own custom actions and finally last weekend I managed to go through their documentation and look at some samples and put something together. This is a summary of my first Github Action.

You can find the final action here or on Github Marketplace.

According to the docs, there are 3 types of actions: docker (uses docker containers to run the action operations in), JS (actions written in JS/TS), and composite run steps (can have multiple steps).

I had a simple idea for my first action:

I needed something to block changes (merge and push) into the given branches during the periods of time specified as inputs.

JS actions sounded like a good and straightforward way of doing this.

I started with creating a repo from actions/typescript-action template. This template contains an action that waits for a few milliseconds (passed as input) and passes the end time of its run as output. If anything goes wrong, it throws an error and stops the workflow.

I went through the files to see what I needed to modify for my own action. Here's the list of main changes:

  • package.json (of course!): updated the attributes and dependencies.
  • action.yml: the action definition, including its inputs and outputs, goes here.
  • src/**: the main logic of the action goes here.
  • __tests__: only if you need to test your code!

The main dependency for a JS action is @actions/core. It provides functions that enable the main interactions through a workflow: e.g. getting inputs, setting outputs, and logging.

There are other JS packages provided by Github for use in your actions, though I didn't need any of them for my purpose. You can see the list here.

My changes to package.json were trivial, so I skip them.

The next step was updating action.yml and adjusting the inputs and outputs. Here's the content of the updated file:

name: 'No Weekend Merge'
description: 'An action to prevent merges on weekends, or whenever that a merge should not happen'
author: 'ka7eh'
branding:
  icon: git-merge
  color: green
inputs:
  tz:
    required: true
    description: 'Timezone of the downtime periods'
    default: '0'
  mon:
    required: false
    description: 'When to block on Mondays'
  tue:
    required: false
    description: 'When to block on Tuesday'
  wed:
    required: false
    description: 'When to block on Wednesday'
  thu:
    required: false
    description: 'When to block on Thursday'
  fri:
    required: false
    description: 'When to block on Friday'
  sat:
    required: false
    description: 'When to block on Saturday'
  sun:
    required: false
    description: 'When to block on Sunday'
runs:
  using: 'node12'
  main: 'dist/index.js'
Enter fullscreen mode Exit fullscreen mode

The first 3 attributes are obvious (name, description, and author).

Branding is optional. It allows you to create a badge for your action. You can pick an icon name from Feather and set its color.

In the runs section of action.yml, I specify node 12 as my action runner. I also point to the final build of the code, which goes in dist (the build is handled by tsc and @vercel/ncc; check out the build and package scripts in package.json). Using a bundle that includes all the required dependencies should reduce complexity and sources of errors when running the action in a workflow.

Note: currently, only node 12 is supported for JS actions.

My action doesn't provide any outputs, so I didn't need to include anything for that section.

The last part to update in action.yml is the inputs section. I want to allow users to pass in periods of time for each day of the week that they want to block changes to some branches. So I add one input for each day of the week (mon, 'tue', ..., sun), but make them all optional.

Currently, Github actions only allow string inputs. So a good way of getting the time periods is a comma-separated list of time intervals in a format like this: 00:00-07:00,16:30-23:59. This example means block changes from midnight to 7 am and from 4:30 pm to midnight.

Users should also be able to specify the time zone for the time periods, so I add tz as an input, which accepts the time difference from UTC.

Now I'm done with defining my action. The next step is to implement the logic. The entrypoint for the action is src/main.ts. Here's the annotated content of the file:

import * as core from '@actions/core'
import {isInDowntime, getUTCAdjustments} from './check'

const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']

async function run(): Promise<void> {
  // Get the time that the action is called.
  const currentDate = new Date()

  try {
    // Parse `tz` (time zone) from the inputs.
    const tz = parseFloat(core.getInput('tz'))

    // Make adjustments to the current time to make sure we are checking for the correct time zone.
    const utcAdjustments = getUTCAdjustments(tz)
    currentDate.setUTCHours(currentDate.getUTCHours() + utcAdjustments.hours)
    currentDate.setUTCMinutes(
      currentDate.getUTCMinutes() + utcAdjustments.minutes
    )

    // We can set debugging info with `core.debug`. It helped me fix an issue I encountered when parsing the inputs.
    core.debug(`TZ: ${tz} - Day: ${currentDate.getUTCDay()}`)

    // Get the input for the current day of the week. If the input is not specified, it returns `undefined`. In such cases, fallback to an empty string.
    const downtimes = core.getInput(days[currentDate.getUTCDay()]) || ''
    core.debug(`Downtimes: ${downtimes}`)

    // We expect a comma-separated list of time periods, so split them by comma and check each time period with `isInDowntime` function. If the current time is in any of the time periods, throw an error and stop the workflow by calling `core.setFailed`.
    for (const downtime of downtimes.split(',')) {
      if (isInDowntime(currentDate, utcAdjustments, downtime)) {
        core.setFailed(
          `The PR cannot be merged at this time (${currentDate}) with the current settings (${downtime}).`
        )
      }
    }
  } catch (error) {
    // If any other errors happen, stop the workflow by calling `core.setFailed`. This also logs the error message in the output of the workflow.
    core.setFailed(`Error: ${error.message}. Run date: ${currentDate}.`)
  }
}

run()
Enter fullscreen mode Exit fullscreen mode

As mentioned in actions/typescript-action template, most CI/CD operations involve async processes and that's why run is an async function.

isInDowntime is a function that accepts a date, UTC adjustments, and a time period, and checks if the given time is in the time period. Its content is not the concern of this post, but I added an annotated version here:

export const isInDowntime = (
  date: Date,
  utcAdjustments: UTCAdjustments,
  downtime?: string
): boolean => {
  if (!downtime) {
    return false
  }

  // The regex pattern that captures the start and end hours and minutes of the time period.
  const result = /(?<fromHour>\d{2}):(?<fromMinute>\d{2})-(?<toHour>\d{2}):(?<toMinute>\d{2})/.exec(
    downtime
  )

  // If the input time period does not match the pattern and returns null, throw an error.
  if (!result) {
    throw new Error('Invalid downtime')
  }

  const groups = result.groups as DowntimePattern

  // Parse the captured groups in the regex match results as integer.
  const fromHour = parseInt(groups.fromHour, 10)
  const fromMinute = parseInt(groups.fromMinute, 10)
  const toHour = parseInt(groups.toHour, 10)
  const toMinute = parseInt(groups.toMinute, 10)

  // Double check the sections of the time period and make sure they are parsed as valid numbers and not NaN.
  for (const num of [fromHour, fromMinute, toHour, toMinute]) {
    if (Number.isNaN(num)) {
      throw new Error('Invalid downtime')
    }
  }

  // The next three blocks make sure hours and minutes are valid (between 0 and 23 for hours and 0 and 59 for minutes), and the start time is before the end time.
  for (const hour of [fromHour, toHour]) {
    if (hour < 0 || hour > 23) {
      throw new Error('Invalid downtime')
    }
  }

  for (const minute of [fromMinute, toMinute]) {
    if (minute < 0 || minute > 59) {
      throw new Error('Invalid downtime')
    }
  }

  if (fromHour > toHour || (fromHour === toHour && fromMinute > toMinute)) {
    throw new Error('Invalid downtime')
  }

  // Create two date objects, one for the start time and the other for the end time.
  // Both objects must be in the current working date, with their hours and minutes adjusted for UTC.
  const start = Date.UTC(
    date.getUTCFullYear(),
    date.getUTCMonth(),
    date.getUTCDate(),
    fromHour - utcAdjustments.hours,
    fromMinute - utcAdjustments.minutes
  )

  const end = Date.UTC(
    date.getUTCFullYear(),
    date.getUTCMonth(),
    date.getUTCDate(),
    toHour - utcAdjustments.hours,
    toMinute - utcAdjustments.minutes
  )

  // Finally, check if the given date is between the start and end times.
  return date.getTime() >= start && date.getTime() <= end
}
Enter fullscreen mode Exit fullscreen mode

I included some tests in __tests__/main.test.ts to check the logic, though I probably need more scenarios there to cover edge cases, mostly around time zones.

The final step is to update the workflow included in the template so it works with the updated code. This workflow runs two jobs: the first one tests the code and compiles and bundles it. The second job uses the code itself as part of the workflow with the following inputs:

with:
  tz: -5
  mon: 00:00-07:00,16:30-23:59
  tue: 00:00-07:00,16:30-23:59
  wed: 00:00-07:00,16:30-23:59
  thu: 00:00-07:00,16:30-23:59
  fri: 00:00-07:00,16:30-23:59
  sat: 00:00-23:59
  sun: 00:00-23:59
Enter fullscreen mode Exit fullscreen mode

These inputs set the time zone to Central Time (-5) and block changes before 7 am and after 16:30 on weekdays and all days on weekends.

I had to play with the config a few times and make changes to the code to fix bugs that popped up when I pushed my changes and ended up with a broken workflow. In the end, I got it working when I saw my workflow wasn't failing because of weird errors but because I was pushing changes on the weekend, which I'd specified as downtime in the workflow!

Before publishing the action on Github Marketplace, I put together a short README describing what's going on, and then it was release time!

The publishing process is pretty easy. You start by drafting a new release for your repo, similar to how you do it for any project. When Github sees action.yml in your project root, it knows it's an action and includes a relevant checklist in the release page and tells you whether there are things you can do to improve your action, e.g. include a branding icon or add a README! After completing the release draft, click on publish and the action shows up in the Marketplace.

Check out Github Actions documentation for more info and see their quick tutorial to JS actions here.

I also saw this thorough course on Github Learning Lab, but I haven't tried it yet.

Have fun creating your own Github Actions! :)

Top comments (2)

Collapse
 
marcellothearcane profile image
marcellothearcane

Neat! However sometimes you might need to merge on a weekend 😉

Collapse
 
ka7eh profile image
Kaveh Karimi

Definitely! :) I guess in a real-world scenario inputs shouldn't be hard-coded and it's better to pass them as variables so they can be easily changed.