Published on

Using GitHub Actions

Authors
  • avatar
    Name
    Linell Bonnette
    Twitter

GitHub Actions were announced late last year. I immediately signed up for the beta and, after a lot of waiting, finally got access. Before long I encountered a real-world use case that let me learn how to put them into action and, lo and behold, they're pretty great.

The use case I encountered is this: at SchoolStatus we wanted a better way of keeping our customer support team up to date on changes deployed during a sprint, to avoid the problem of them not seeing a change until the end of the sprint or when a customer encounters an issue with something we'd changed. We could just hook up GitHub's Slack integration to the support team's channel, but that would result in a lot of noise for them -- not everything we do is changelog worthy or even something they'd ever care to know about. So we decided that we could do something to tag certain commits as changelog worthy and then send only those commits to a special "these are the important daily changes to our app" channel.

What We're Going to Build

What we're going for here is really, really simple. We want something that looks at every push to the master branch and, if it's tagged for the changelog, sends a message to a Slack channel.

There are tons of pre-built actions for you to utilize, but I was having trouble stringing them together to do what I needed. For example, there's a pre-built filter action but it doesn't allow you to filter based off of commit message content. There's a pre-built Slack command, but it doesn't let you send information about the change that caused the action to run. Luckily, writing an action from scratch is pretty darn easy.

How to Build It

Actions live inside of a .github directory in your project's root. Once you've created that, go ahead and add a file named main.workflow. If you've played with their visual builder, then it's worth noting that all you're really doing via the visual builder is modifying this file.

In this file, put the following:

workflow "Release Notification" {
  on = "push"
  resolves = ["Commit Message Filter"]
}

action "Commit Message Filter" {
  uses = "./.github/release-notification"
  secrets = ["SLACK_BOT_TOKEN", "SLACK_ROOM_ID"]
}

This is defining a new workflow that fires on push events. The resolves keyword points to the action that we'll actually be running, "Commit Message Filter". Inside of the action, uses points to the code that's going to be run for this action. You can check out the documentation for creating an action here. In there, it gives us this as an example directory structure:

|-- hello-world (repository)
|   |__ .github
|       |__ main.workflow
|   |__ action-a
|       │__  Dockerfile
|       │__  README.md
|       |__  entrypoint.sh  
|

It's worth noting that I actually put my action inside of my .github directory. It ended up looking like this:

.github
├── main.workflow
└── release-notification
    ├── Dockerfile
    ├── README.md
    └── entrypoint.rb

You can go with whatever structure you want -- just make sure that you adjust your path in the main.workflow file from above.

While we're talking about how you can do whatever you want with the directory structure, let's stop and talk about the main takeaway from this whole experience. Guess what: you're just writing a Dockerfile, with which you can really do whatever you want. It's really that simple.

Easy example: you'll notice that the documentation on GitHub has an entrypoint.sh file while my example has entrypoint.rb. When I started on this process I was attempting to link all sorts of actions together and it was just a little too painful... but then I realized I could just do it all in one Ruby script. Why make something complicated when it can be simple? You're just writing a Dockerfile and a script.

So with that in mind, here's my Dockerfile:

FROM ruby:2.4.1

LABEL "com.github.actions.name"="Release Notifier"
LABEL "com.github.actions.description"="Ping Slack with changelog commits."
LABEL "com.github.actions.icon"="filter"
LABEL "com.github.actions.color"="gray-dark"

LABEL "repository"="http://github.com/octocat/hello-world"
LABEL "homepage"="http://github.com/actions"
LABEL "maintainer"="Linell <tlbonnette@gmail.com>"

ADD entrypoint.rb /entrypoint.rb
ENTRYPOINT ["/entrypoint.rb"]

All it's really doing is setting me up with Ruby, tacking on a few labels like GitHub tells us to do, and then setting our entrypoint.

Then, for our entrypoint, we can just write a script like we would for anything else:

#!/usr/bin/env ruby

require 'json'
require 'net/http'
require 'net/https'
require 'uri'

class Event
  attr_reader :raw_params, :repository, :commits, :branch

  def initialize(params)
    @raw_params = params

    @repository = params['repository']['name']
    @branch     = params['ref'].split('/').last
    @commits    = params['commits'].map {|c| Commit.new c, repository }
  end

  def changelog_commits
    commits.select {|c| c.message =~ /!changelog/ }
  end

  def to_slack_params
    {
      :channel     => ENV['SLACK_ROOM_ID'],
      :attachments => changelog_commits.map(&:to_attachment)
    }
  end
end

class Commit
  attr_reader :raw_params, :author, :author_username, :url, :message, :id, :repository

  def initialize(params, repository)
    @raw_params = params

    @author          = params['author']['name']
    @author_username = params['author']['username']
    @id              = params['id']
    @message         = params['message']
    @repository      = repository
    @url             = params['url']
  end

  def to_attachment
    {
      :title       => parsed_message,
      :title_link  => url,
      :footer      => "#{repository}: #{id}",
      :author_name => author,
      :author_link => "https://github.com/#{author_username}" # not sure this works
    }
  end

  private
  def parsed_message
    _msg = message.split("\n\n")

    if _msg.length > 1
      _msg.reject {|s| s == '!changelog' }.join("\n\n")
    else
      message
    end
  end
end

def check_setup
  [
    'GITHUB_EVENT_PATH',
    'SLACK_BOT_TOKEN',
    'SLACK_ROOM_ID'
  ].each do |var|
    if ENV[var].nil? || ENV[var].empty?
      puts "ENV VAR '#{var}' MUST BE SET"
      exit 1
    end
  end
end

def send_message_to_slack(event)
  uri                      = URI.parse("https://slack.com/api/chat.postMessage")
  request                  = Net::HTTP::Post.new(uri)
  request.content_type     = "application/json"
  request["Authorization"] = "Bearer #{ENV['SLACK_BOT_TOKEN']}"
  request.body             = JSON.dump(event.to_slack_params)

  req_options = {
    use_ssl: uri.scheme == "https",
  }

  response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
    http.request(request)
  end

  unless response.code == '200'
    puts response.code
    exit 1
  end
end

def get_event_information
  event_file = File.read  ENV['GITHUB_EVENT_PATH']
  data       = JSON.parse event_file

  Event.new data
end

def run
  event = get_event_information

  exit 78 unless event.branch == 'master'
  exit 78 unless event.changelog_commits.count > 0

  send_message_to_slack event
  exit
end

run

I'm still working on making this all work in real life, and nobody else on my team has looked at it, so criticism is more than welcome. The thing you really want to take note of are the environment variables like ENV['GITHUB_EVENT_PATH'] that holds all of the event information about the event that caused the action to run. This environment variable is one provided by GitHub to give us information about what's going on in the action. The other places you see me using env vars are actually me using secrets I've set on the action.

Note that secrets seem to be universal - once you add one to any action in a workflow, you're able to share it across other actions in the workflow but you have to click the action and mark a checkbox to give the action permission to use the secret.

The last thing to note are the exit 78 calls in my run method. There are a couple of exit codes you can use to return different statuses for your action.


Overall, my main takeaway was that, wow, we're just writing a Dockerfile with which we can pretty much do anything we want. Once you wrap your head around that, it's easy to let your imagination see a million neat things you can do with GitHub Actions. If you're short on ideas, check out this talk from re:Invent this talk by Jess Frazelle, Clare Liguori, and Abby Fuller in which they discuss a neat way to handle giving easy environments to preview pull requests. I've already thought of a couple of different ways I'm going to use this at work, and I'm really excited to see all of the ways they end up being used.