GitHub Actions: An introductory look and first impressions

GitHub Actions: An introductory look and first impressions

I recently had the benefit of gaining access to GitHub’s Actions beta. What is GitHub Actions? According to the site it says, “With GitHub Actions you can automate your workflow from idea to production”. Essentially we get to run code that responds to GitHub webhooks by spinning up a Docker container and running it. This means we can automate pretty much anything you can think of:

  • When you change the version number in a certain file tag the release in GitHub, build static binaries, and push them to the tag
  • When someone has their PR merged, and the branch is on your repo, delete the branch
  • If you push a new commit to master, create a Docker container, and tag it as latest, then push it to a container registry.

These are just a few things you could do. In fact with a little know how with the GitHub API, their webhooks, and your own server you could already do this. However, not everyone has infrastructure to do this or can afford it (which it should be noted you can only use Actions on private repos, which costs money, as of December 4th, 2018 so who knows if that will change). I’m personally hoping GitHub will make this free for open source code to lower barriers of entry and automation with DevOps.

What does this mean though for GitHub Actions currently? In your code you’ll have access to the webhooks, details about the repo, an API token scoped to the repo to interact with the API, and the ability to link multiple actions together with access to the state change between linked actions. Meaning you don’t need to setup a whole server just to run actions. GitHub does it for you and provides you the information you need to be able to actually automate decisions for your repo. That way you can spend less time manually doing things and more time actually coding.

I’m gonna go over a small thing I did and then go over my experience with it, which keep in mind this is Beta software, and then some final conclusions. Hopefully this will give you a starting point of something beyond the Hello World Example. Let’s get started!

Rusty Containers

Anyone who follows what I do knows I use Rust extensively and so I’m also writing my repo workflows with it, but fret not a lot of what I’m covering is still relevant to whatever language you decide to use with GitHub Actions! Really, I want you to pick up some of the following from this section:

  • Understanding the GitHub API
  • Understanding how containers are setup with Actions (i.e. ENV Vars)
  • Understanding limitations of actions

Let’s take a look at what I got working. I’ve been wanting to make an automated management bot like Rust’s bors for forever. I called mine Thearesia and in fact this was originally why I started to write github-rs. Admittedly I’ve let the project languish because I don’t enjoy maintenance and like making new stuff that  does weird things. I created a repo called Operator21O named after the character of the same name from NieR: Automata (easily one of my top 3 games this year and top 10 all time).

What it does so far is that if an Owner, Member of an Org, or a Collaborator says, “r+” on a PR it will automatically merge it. Some caveats to this; it has not been implemented to handle if the merge isn’t green. If Travis is still running or a collaborator blocked it with their PR review it’ll be red. That’s fine though! This was a great first step and this alone gives us a lot to work with and learn from!

Let’s first take a look at the workflow file that goes in the .github folder at the root of the repo:

workflow "PR Issue Comment" {
  resolves = "IssueComment"
  on = "issue_comment"
}

action "IssueComment" {
  uses = "./operator_issue_comment"
  secrets = ["GITHUB_TOKEN"]
}

What is this saying? We’ve created this file called main.workflow and in it is the text above. We have some kind of workflow called PR Issue Comment that I named as such. We say that it resolves IssueComment. This can be an array of actions we want to trigger actually, but we only use the one here. We then specify what GitHub webhook to trigger this workflow on. In this case we say, “Run this workflow if it’s an issue_comment event.” Neat! Workflows are our triggers and actions are what comes after. We only have one action here but you can chain multiple together so building a container could be one action and the next action then pushes it to a container registry if it had built successfully.

Let’s take a look at the action I wrote here. I’ve named it IssueComment. The uses section can be all sorts of things; containers on a registry or, in my case since I wanted to Dogfood my workflows, a path to a Dockerfile in the repo. The secrets field can contain, well, secrets. There’s docs on how to handle this but currently not using it for production is the current state it is in. I figured this out way later in the docs (which I’ll put in my review overall later), but if you want access to the GITHUB_TOKEN for API requests as an ENV var you have to specify it in the action first.

Great so we have our workflow file so let’s actually look at the code I wrote. Here’s the whole file:

#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
extern crate reqwest;
extern crate http;

use std::{fs, env, process::exit, error::Error};
use reqwest::header;
use http::StatusCode;
fn main() {
    if let Err(e) = run() {
        eprintln!("{}", e);
        exit(1);
    }
}

fn run() -> Result<(), Box<Error>> {
    let event = serde_json::from_str::<IssueCommentEvent>(
        &fs::read_to_string(env::var("GITHUB_EVENT_PATH")?)?
    )?;

    let mut headers = header::HeaderMap::new();
    headers.insert(header::ACCEPT, header::HeaderValue::from_static("application/vnd.github.v3+json"));
    let client = reqwest::Client::builder()
        .build()?;

    let aa = &*event.issue.author_association;
    if aa == "COLLABORATOR" || aa == "OWNER" || aa == "MEMBER" {
        let cb = &*event.comment.body;
        if cb.contains("r+") {
            let hurl = &*event.issue.html_url;
            if hurl.contains("pull") {
                let rurl = &*event.issue.repository_url;
                let num = event.issue.number;
                let result = client
                    .put(&format!("{}/pulls/{}/merge", rurl,num))
                    .bearer_auth(&env::var("GITHUB_TOKEN")?)
                    .send()?
                    .status();

                if result == StatusCode::OK {
                    println!("PR Merged!");
                } else {
                    eprintln!("Unable to merge PR!");
                    exit(1);
                }
            } else {
                eprintln!("Can't r+ on an issue only a PR");
            }
        } else if cb.contains("r-") {
        }
    }
    Ok(())
}

#[derive(Serialize, Deserialize, Debug)]
struct Comment {
  author_association: String,
  body: String,
  created_at: String,
  html_url: String,
  id: i64,
  issue_url: String,
  node_id: String,
  updated_at: String,
  url: String,
  user: User,
}

#[derive(Serialize, Deserialize, Debug)]
struct Issue {
  assignee: Option<String>,
  assignees: Vec<String>,
  author_association: String,
  body: String,
  closed_at: Option<String>,
  comments: i64,
  comments_url: String,
  created_at: String,
  events_url: String,
  html_url: String,
  id: i64,
  labels: Vec<String>,
  labels_url: String,
  locked: bool,
  milestone: Option<String>,
  node_id: String,
  number: i64,
  repository_url: String,
  state: String,
  title: String,
  updated_at: String,
  url: String,
  user: User,
}

#[derive(Serialize, Deserialize, Debug)]
struct Repository {
  archive_url: String,
  archived: bool,
  assignees_url: String,
  blobs_url: String,
  branches_url: String,
  clone_url: String,
  collaborators_url: String,
  comments_url: String,
  commits_url: String,
  compare_url: String,
  contents_url: String,
  contributors_url: String,
  created_at: String,
  default_branch: String,
  deployments_url: String,
  description: String,
  downloads_url: String,
  events_url: String,
  fork: bool,
  forks: i64,
  forks_count: i64,
  forks_url: String,
  full_name: String,
  git_commits_url: String,
  git_refs_url: String,
  git_tags_url: String,
  git_url: String,
  has_downloads: bool,
  has_issues: bool,
  has_pages: bool,
  has_projects: bool,
  has_wiki: bool,
  homepage: Option<String>,
  hooks_url: String,
  html_url: String,
  id: i64,
  issue_comment_url: String,
  issue_events_url: String,
  issues_url: String,
  keys_url: String,
  labels_url: String,
  language: String,
  languages_url: String,
  license: Option<String>,
  merges_url: String,
  milestones_url: String,
  mirror_url: Option<String>,
  name: String,
  node_id: String,
  notifications_url: String,
  open_issues: i64,
  open_issues_count: i64,
  owner: User,
  private: bool,
  pulls_url: String,
  pushed_at: String,
  releases_url: String,
  size: i64,
  ssh_url: String,
  stargazers_count: i64,
  stargazers_url: String,
  statuses_url: String,
  subscribers_url: String,
  subscription_url: String,
  svn_url: String,
  tags_url: String,
  teams_url: String,
  trees_url: String,
  updated_at: String,
  url: String,
  watchers: i64,
  watchers_count: i64,
}

#[derive(Serialize, Deserialize, Debug)]
struct IssueCommentEvent {
  action: String,
  comment: Comment,
  issue: Issue,
  repository: Repository,
  sender: User,
}

#[derive(Serialize, Deserialize, Debug)]
struct User {
  avatar_url: String,
  events_url: String,
  followers_url: String,
  following_url: String,
  gists_url: String,
  gravatar_id: String,
  html_url: String,
  id: i64,
  login: String,
  node_id: String,
  organizations_url: String,
  received_events_url: String,
  repos_url: String,
  site_admin: bool,
  starred_url: String,
  subscriptions_url: String,
  #[serde(rename = "type")]
  _type: String,
  url: String,
}

Most of this is just struct definitions for deserialization code. What we care about with these structs is that IssueCommentEvent represents the webhook for issue comments. As a side note if you’re unfamiliar with GitHub’s API under the hood every PR and Issue is an Issue. That’s why if, say, in a new repo you open an issue, the issue is numbered 1. If you then open a PR its number is 2. This will be important in a little bit!

Let’s look at the opening bit of code:

#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
extern crate reqwest;
extern crate http;

use std::{fs, env, process::exit, error::Error};
use reqwest::header;
use http::StatusCode;
fn main() {
    if let Err(e) = run() {
        eprintln!("{}", e);
        exit(1);
    }
}

This is probably the last bit of non Rust 2018 edition code I’ll write since I wanted to make sure this worked on stable and the newest version comes out this week which makes it the default. Here we’re importing some external crates. serde for our deserialization of JSON, reqwest to make simple http requests and http for all the types used amongst various http libs in Rust.

We make a few imports of types and modules with our use statement and then have our main function. We say that when we get a Result type from our run function, that if it was an error, print it out on stderr then exit with a non zero status code! Most of this code is standard boilerplate we would want to write to make sure we can handle errors and print out the result for our Rust programs, but I’m making no assumptions about who’s reading the article here.

Let’s take a look at the actual logic that does stuff now that we have gone through the setup and type code.

fn run() -> Result<(), Box<Error>> {
    let event = serde_json::from_str::<IssueCommentEvent>(
        &fs::read_to_string(env::var("GITHUB_EVENT_PATH")?)?
    )?;

    let mut headers = header::HeaderMap::new();
    headers.insert(header::ACCEPT, header::HeaderValue::from_static("application/vnd.github.v3+json"));
    let client = reqwest::Client::builder()
        .build()?;

    let aa = &*event.issue.author_association;
    if aa == "COLLABORATOR" || aa == "OWNER" || aa == "MEMBER" {
        let cb = &*event.comment.body;
        if cb.contains("r+") {
            let hurl = &*event.issue.html_url;
            if hurl.contains("pull") {
                let rurl = &*event.issue.repository_url;
                let num = event.issue.number;
                let result = client
                    .put(&format!("{}/pulls/{}/merge", rurl,num))
                    .bearer_auth(&env::var("GITHUB_TOKEN")?)
                    .send()?
                    .status();

                if result == StatusCode::OK {
                    println!("PR Merged!");
                } else {
                    eprintln!("Unable to merge PR!");
                    exit(1);
                }
            } else {
                eprintln!("Can't r+ on an issue only a PR");
            }
        } else if cb.contains("r-") {
        }
    }
    Ok(())
}

run has a function signature that says “I’ll do things, but in order to signal that it all went well return a unit type (()) but it if goes wrong send me back any type that implements the standard library’s Error trait”. This gives us flexibility with handling errors from various crates. Tf they did it right they’ll have implemented the trait!

Okay so next is actually reading the webhook file. GitHub stores this in an ENV var called GITHUB_EVENT_PATH. So we’re querying for it here. The ? is basically saying, “If the Result of this was an error return the error for this function, otherwise get me the value you got out of the Result.” We then use that to read the file from the disk and again use ? saying that, “If you can’t read throw an error, otherwise give me the contents of the file.” We then use from_str to deserialize it into the IssueCommentEvent type we made earlier. If that works then ? unwraps the value and stores it in event!

Next up we set up some default headers for any request we’ll make to the API in this program to create our Client which will make the request. We’re setting a header that tells the GitHub API that we only want to use v3  REST API and not the GraphQL version at all.

Next we get a reference to a field in the type called author_association and store it in aa this is mostly for a shorter variable name for the comparison here, but also so I don’t need to also stick & in front of each of the strings with " around them, thus the &*, but that’s a whole other blog post on it’s own. Suffice to say we’ll be doing this to grab values we need.

We then want to check if they’re an OWNER, COLLABORATOR, or MEMBER. We don’t just want someone who wouldn’t have commit access to merge things, though with MEMBER the granularity could have it that they do not have write access. We then check if the comment contained r+ in it. We then check the html_url (yes I know I shortened it to hurl that’s how I feel about my code sometimes) to see if it has pull in it. Remember when I was talking about how they’re all issues under the hood? Well we need to use the html_url to determine if it was a comment on a pull request or a comment on an issue in the issue tracker. issue_url does not make that distinction. If it is a common on a PR then we setup a request to do a merge on the PR with the token we added as a secret. If it works we print out it did, if it did not we print out it that it did not and exit with a non zero exit code. Future work will make an error type for this and send that up to the top level instead.

So what does this look like when it actually runs?

And from the log files:

It works! Hopefully, this gave you a bit of a primer on how to get it working!

The Good, The Bad, and The Ugly

Let’s start with the bad/ugly cause I like to keep negativity to a minimum and I overall really like actions. This is a beta so I only expect it to get better over time:

  • Slow startup times: It can take half a minute to startup a container. I think GitHub would benefit immensely from using something like firecracker
  • The build, push, run, debug cycle is long and painful. If there was some way to mock this locally to reduce that I would absolutely love it
  • No streaming logs: I can only see the logs after it runs
  • The Actions tab seems a bit wonky and feels like it could have 3 separate sub tabs. This one is kind of hard to describe unless you use it.
  • Docs are hit and miss. Overall they’re good, other packages/apis I’ve used have not had docs this good. However, I’d prefer schema definitions of JSON payloads with the examples. I had three things have null fields that did not show up in the example for the event type. That and I feel like Actions docs still have some ways to go. It’s not so bad that you can’t get anything done, but you’ll be skipping around trying to figure stuff out. For example, the GITHUB_TOKEN was made to seem like it’s part of the environment if you look at the storing secrets docs, but it’s not until you go to the docs on what environment variables are available that you find out that you have to add it to the action definition to get access to it. This is fine if you read the docs in order, but I did not so I ran into this issue. Having it be reaffirmed is good, albeit redundant.
  • Being able to test out workflows on feature branches so that I’m not pushing to master all the time would be dope. I’ll need to experiment if this is the case already, but if it is the docs do not mention it at all.
  • I’d love to see this be available to open source for free, but that costs money so I don’t really know if we’ll see that at all. Again it’s still limited to private repos for now.

That’s all I really had issue with and these can get better with time I believe. Let’s cover the good because Actions is overall impressive:

  • Arbitrary code execution in a serverless environment. Just typing those words gives me nightmares, but it’s impressive what GitHub has managed to pull off bringing this to their product
  • I don’t have to run my own infrastructure which is so nice
  • Visually being able to see how actions connect is neat
  • Being able to store secrets into a repo itself and access them from Actions is great, it’s got a GitOps vibe that I’m very here for
  • Whatever lang you want you can use, just bring a Dockerfile or container
  • Uses cached versions of your containers to reduce the need to rebuild, only rebuilds if it needs too
  • Sharing workflows is as easy as unzipping files into your directory. Would love to see this be as easy as pulling releases from like an Actions App Store to use in your repo
  • Arbitrary Code Execution. Seriously it’s so damn cool and you can now manage everything in one spot.

Color me impressed. I’m excited to see what more can be done with this.

Conclusion

We covered how to get started with GitHub Actions in a more advanced way, as well as my thoughts on the matter. I’d like to see this get rolled out to more people because it’s really cool. To those who already have it, happy hacking!