What are GitHub Workflows?

β€’

I've written about GitHub Actions a couple of times (because they're rad), but I haven't talked much about workflows. They're a vital part of actually using Actions, so let's take a look at what they are, how to write them, and some πŸ”₯ tips I've picked up from lurking in the Actions team's Slack channel.

The heck is a workflow?

Hopefully you're familiar with GitHub Actions; if not, check out this great article by @sarah_edo. Workflows are what define when and how a series of Actions should be run. You can think of it as the plan of Actions (pun 1000% intended).

GitHub will look for files with the .workflow extension in your repository's .github folder. Let's take a look at an example .github/main.workflow file:

workflow "Test my code" {
on = "push"
resolves = ["npm test"]
}

action "npm install" {
uses = "actions/npm@v1"
args = ["install"]
}

action "npm test" {
uses = "actions/npm@v1"
args = ["test"]
needs = ["npm install"]
}

In non-code, this workflow says:

When someone pushes, it will trigger the npm test actionβ€”which "needs" the npm install action.

GitHub doesn't just enact workflows in the repo's default branchβ€”if you're working on a .workflow file on a branch, be aware that GitHub will run the workflow.

The workflow object

workflow "Test my code" {
on = "push"
resolves = ["npm test"]
}

Here's where we define the workflow's metadata. This only takes two fields; on and resolves.

The on field is the webhook event name that will trigger your workflow. Note that not all webhook events you might be used to using are available to GitHub Actions; you'll want to double check in the docs.

The resolves field is the name of the action that should be run first. An important note is that actions can have "dependencies" by defining a needs property. GitHub will consider those needs properties before going to run your actions.

The action objects

Each action object defines what action is run, what arguments you can pass, even what command is run inside of the action. Some key notes:

  • You can have up to 100 action objects in a workflow
  • The needs property will define a dependency tree. Actions will be run according to whatever actions it needs
  • Actions can write to directories that are shared with every subsequent action. Things like npm install, which adds a whole node_modules directory, will persist across the actions in your workflow.

The individual fields here are a lot more complicated because there are different ways to use each one. Let's dig in!

uses

Arguably the most important field, uses defines what action you want to run. This can be any of the following:

  • owner/repo@ref
  • ./<filepath>
  • docker://image

The first option is pretty straightforward. It points to a repository on GitHub at the given ref. This can be a SHA, a tag or a branch; a common one might be JasonEtco/lights-camera-action@v1.0.0. A handy trick to know with this method is that it's actually owner/repo[/path]@ref; so you can point to a subdirectory of your repository.

This can be handy if you have one repo with a bunch of actions in it. However, I wouldn't recommend doing that. It's sort of like the whole "are monorepos good" debate, but GitHub has all kinds of discoverability hints for actions that are their own repos.

Now, you can also point your workflow to a folder in the same repository; so given a file tree like this:

β”œβ”€β”€ .github
β”‚   └── main.workflow
└── my-action
    β”œβ”€β”€ Dockerfile
    └── entrypoint

You can point your workflow to the my-action directory. Your uses key must start with a ./, to specify that you want to use a file in this repo:

action "My action" {
uses = "./my-action"
}

One important note: any path must have a valid Dockerfile; otherwise, the action will fail.

Lastly, and this is a really awesome feature, you can just use an arbitrary Docker image. This is amazing, because if all you want to do is run a command you don't even need a whole action:

action "My action" {
uses = "docker://alpine"
run = "echo"
args = ["Hello", "World"]
}

Like with most things I talk about, you should check out the GitHub Action docs for more details!

args

This property allows you to pass information to your workflow via command line arguments. So, given a workflow like this:

action "npm install" {
uses = "actions/npm@v1"
args = "install"
}

This will use the actions/npm action, whose entrypoint command runs npm $*. We're passing the argument install; so at the end of the day, it'll be npm install.

It's a fairly straightforward field, but let's take a look at some practical examples and how you might design an action that depends on user-set args.

My action JasonEtco/create-an-issue creates a new issue from a given template. By default, it will read from the .github/ISSUE_TEMPLATE.md fileβ€”but you can pass an argument to specify a different file:

action "Create issue" {
uses = "JasonEtco/create-an-issue@v2"
secrets = ["GITHUB_TOKEN"]
args = ".github/some-other-template.md"
}

Doing this makes the action way more extensible; you can have multiple workflows that use this functionality to open different issues.

Another thing I want to call out is that the args field is, by design, very barebones. In a Node.js action, for example, the arguments are passed as an array through process.argsv. That's totally standard, but it'd be amazing to give users an even more targeted way of configuring your action. You can use tools like actions-toolkit (which uses minimist under the hood) to parse arguments into something more declarative, by using --flags.

secrets

Need to interact with a third party API? Want to make requests directly back to GitHub's API? Well, those things often require secret credentials to be passed. Fortunately, GitHub has a method for passing secrets to actions via the secrets field. These can be set in the GitHub UI, and are stored per-repo, which means that no other repository will be able to read those secrets.

Let's take a look at a practical example. Here's a workflow that compiles a TypeScript project, then publishes the compiled version to NPM:

workflow "Publish" {
on = "release"
resolves = ["npm publish"]
}

action "npm ci" {
uses = "actions/npm@v1"
args = "ci"
}

action "npm run build" {
needs = ["npm ci"]
uses = "actions/npm@v1"
args = "run build"
}

action "npm publish" {
needs = ["npm run build"]
uses = "actions/npm@v1"
args = "publish"
secrets = ["NPM_TOKEN"]
}

You'll see that we're passing an NPM_TOKEN to authenticate our publishing step. Without it, we wouldn't have permission to publish our library.

One handy thing to note is that the GITHUB_TOKEN is special. Its always set in your repository. You still have to decide if you want to pass it to secrets, but it'll let you make API requests and authenticate with GitHub.


There are some more fields you can pass, but these should be all you need for most workflows. Let me know what nifty workflows you build ✨