My First Github Action
Tags: github, actions, devops
Published: 2021-02-17 04:33:06

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](https://github.com/ka7eh/no-weekend-merge) or on [Github Marketplace](https://github.com/marketplace/actions/no-weekend-merge).

According to the [docs](https://docs.github.com/en/actions/creating-actions/about-actions), 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](https://github.com/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](https://www.npmjs.com/package/@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](https://github.com/actions/toolkit).

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

The next step was updating [`action.yml`](https://github.com/ka7eh/no-weekend-merge/blob/main/action.yml) and adjusting the inputs and outputs. Here's the content of the updated file:

```{yaml}
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'
```

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](https://feathericons.com/) 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:

```{JS}
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()
```

As mentioned in [actions/typescript-action](https://github.com/actions/typescript-action#change-the-code) 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:

```{js}
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
}
```

I included some tests in [`__tests__/main.test.ts`](https://github.com/ka7eh/no-weekend-merge/blob/main/__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](https://github.com/ka7eh/no-weekend-merge/blob/main/.github/workflows/test.yml) 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:

```{yaml}
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
```

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](https://docs.github.com/en/actions/creating-actions/creating-a-javascript-action).

I also saw this thorough course on [Github Learning Lab](https://lab.github.com/githubtraining/github-actions:-writing-javascript-actions), but I haven't tried it yet.

Have fun creating your own Github Actions! :)