Late last year GitHub Actions became generally available, providing a convenient way to automate development tasks. In this post, we will compare and contrast GitHub Actions with Atomist Skills, showing how Atomist Skills are a quicker, easier, and more convenient way to automate a larger variety of development tasks.

The problem

Recently I needed to publish an npm package created from a repository hosted on GitHub.com to the npmjs.com package registry. Taking a GitOps-style approach to the package publication process, I wanted to manage the release cycle using git. Specifically, I wanted to be able to push a semantic version tag to the repository to do the following:

  1. Trigger publication of the npm package
  2. Create a GitHub release for the tag
  3. Increment the version patch level of the package

As a practitioner of DevOps long before there was a name for it, I have used a lot of different continuous integration and automation solutions over the years. Since I had only played around with GitHub Actions previously, I decided to try to accomplish these tasks using Actions. After going through that somewhat unpleasant experience, I decided to switch implementations to one leveraging Atomist Skills.

Below, I’ll first detail the GitHub Actions implementation of the solution and then the Atomist Skills implementation. While both provide a platform for running automated tasks triggered by development activity, it’s clear the Atomist Skills focus on simplicity wins out. The re-usable units in GitHub Actions are like modules that must be composed together in YAML workflows to create your desired behavior. Each Atomist Skill, in contrast, provides a fully-realized, configurable, automated solution. In other words, rather than a piecemeal approach of component parts that must be fit together in specific ways like GitHub Actions, Atomist Skills provides a higher-level approach, providing both polished, ready-to-use automations and the extensibility to build your own custom skills. Plus, the multi-repository story for Atomist Skills is a huge advantage over the per-repository configuration of GitHub Actions.

GitHub Actions

My plan when implementing my delivery flow with GitHub Actions was to take action on both release, e.g., 1.2.3, and prerelease, e.g., 1.2.3-0, semantic version tags. Briefly, I would publish the npm package and create a GitHub “prerelease” release when a prerelease version tag was pushed. If a release version tag was pushed, I would perform all three actions: publish, create a release, and increment the version. My initial attempt to implement this using Actions was to split the solution into two Actions, one that triggered off tags that looked like semantic versions and another that triggered off releases, but not “prerelease” releases. Unfortunately, there did not seem to be a way to only trigger off releases and not prereleases. While the GitHub Actions documentation about triggering off releases indicates there are different “activity types” under releases, including prereleased, in practice only a subset of those activity types ever fire, and prereleased is not one of them.

Unable to trigger off releases appropriately, I settled on triggering everything off tags that looked like semantic versions. The next step was to determine how I wanted things to fail. In other words, if the first task, i.e., package build and publication, failed, did I want to terminate processing or did I want all tasks to proceed in parallel and let the chips fall where they may? I chose the latter approach, each task firing independently, so if anything failed I’d only have to fix that one thing manually rather than having to fix it and all subsequent tasks. So how do we map this strategy into GitHub Actions? Each GitHub Action in a repository is defined in a single YAML file under the .github/workflows directory. Each GitHub Action is triggered by one or more GitHub events, i.e., activities in GitHub which trigger a webhook firing, e.g., a push or a new comment on an issue. Each GitHub Action can have one or more jobs, which are executed in parallel by default, and each job can have one or more steps. To achieve our strategy, it seems we can use a single action, since all our tasks trigger off the same event, and make each of the three tasks a distinct job within the action, so they run independently and in parallel.

After some experimenting, I settled on the following name and trigger for the GitHub Action.

name: Publish, Release, and Increment
on:
  push:
    tags:
      # https://semver.org/ proper release tags, more or less
      - v[0-9]+.[0-9]+.[0-9]+
      # prerelease tags, more or less
      - v[0-9]+.[0-9]+.[0-9]+-*

The name can be any string, or omitted entirely. We trigger on pushes of tags, specifically tags that look like semantic versions, more or less. Why more or less? Well the filter pattern syntax supported by GitHub Actions appears to be some strange extension of the gitignore pattern syntax, I guess. It would have been great if they chose something standard that was more powerful, like shell-style globs or regular expressions, but they didn’t. Other than the limited functionality, creating their own pattern matching syntax has two drawbacks. First, it is one more pattern matching syntax to remember, or more importantly, remember how it differs from the the similar gitignore and glob pattern syntaxes. Second, its implementation is specific to GitHub Actions, as are the bugs in that implementation. How do these shortcomings manifest themselves when using GitHub Actions? In this example, there is no way to write a good pattern for a semantic version. Without grouping and alternation, writing a proper matching pattern just for release semantic versions requires eight separate patterns instead of just one. To avoid that mess, we just ignore that fact that 1.01.1 is an invalid semantic version and allow our pattern to match it. Prerelease semantic versions, among other limitations, only allow alphanumeric, “.”, and “-” characters in the prerelease descriptors, or [A-Za-z0-9.-] when expressed as a character class. The GitHub Action filter pattern syntax supports character classes with ranges, but the ranges can only include A-Z, a-z, and 0-9. Fortunately, those are the exact ranges we need. Unfortunately, putting the additional . and - in the character class confuses their pattern parser and causes parsing of the GitHub Actions definition, and hence every execution of the Action itself, to fail. So, as you can see above, we gave up on matching something that is a prerelease semantic version to matching something that somewhat, kind, sorta looks like a prerelease semantic version. If you are not much of a stickler for detail, this probably does not bother you.

The first GitHub Actions job we’ll define is the publication of our npm package. Here’s what the job YAML looks like.

jobs:
  # publish npm package
  publish:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 12.x
          registry-url: https://registry.npmjs.org
      - name: install dependencies
        run: npm ci
      - name: set package version
        env:
          # when triggering on a tag, github.ref is the tag reference
          TAG_REF: ${{ github.ref }}
        run: npm version --allow-same-version --no-git-tag-version "${TAG_REF#refs/tags/v}"
      - name: publish package
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPMJS_AUTH_TOKEN }}
          TAG_REF: ${{ github.ref }}
        run: >-
          npm publish --access public --tag
            "$(if [[ $TAG_REF == *-* ]]; then echo next; else echo latest; fi)"

The first line creates the jobs section of the YAML. The value of the jobs section is an object whose keys are the job names and whose values are the job definition objects. Above we define the publish job, which is executed in, or runs-on, a container using the GitHub Actions standard ubuntu-20.04 Docker image. This job has several steps, which are executed sequentially. First, we use standard actions available from the GitHub Marketplace to checkout the code associated with the tag that triggered the action, actions/checkout@v2, and set up Node.js in the container, actions/setup-node@v1. The registry-url: https://registry.npmjs.com line configures npm to publish packages to the npmjs.com package registry. After the code is checked out and Node.js installed, we run a simple command, npm ci, which installs our package’s dependencies. Next we use the tag that triggered the action to set the package version. This involves using the github context, specifically its ref property, to set an environment variable. When an action is triggered off a push of a tag, the github.ref contains the reference to the tag, e.g., refs/tags/v1.2.3. We then use the npm version command, along with some Bash variable replacement magic, to set the package version in the package.json file. Finally, we run the npm publish command, setting the NODE_AUTH_TOKEN environment variable to the NPMJS_AUTH_TOKEN secret value. We created the repository secret for our npmjs.com registry authentication token following the GitHub Actions secrets documentation. We also use Bash command substitution to set the npm distribution tag appropriately, i.e., prereleases get the next tag and releases get the latest tag. To do this, we use the fact that all prerelease semantic versions contain the hyphen character, -, and proper release semantic versions never do.

(Note: The GitHub Actions secrets documentation states:

Command-line processes may be visible to other users (using the ps command) or captured by security audit events. To help protect secrets, consider using environment variables, STDIN, or other mechanisms supported by the target process.

As I have noted previously, a process’ environment is also stored in the kernel process table, so making secrets available in environment variables does not prevent leaking them to other processes.)

The next job will create a GitHub release. Here is the YAML:

jobs:
  # …
  # create GitHub Release
  release:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v2
      - uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: Release ${{ github.ref }}
          draft: false
          # true for prerelease tags
          prerelease: ${{ contains(github.ref, '-') }}

Above we define the release job, which has two steps. The first step, as with the previous job, checks out the code associated with the tag triggering the skill. The second step uses the available actions/create-release@v1 action to create the release. We have to use the secrets.GITHUB_TOKEN to set the GITHUB_TOKEN environment variable so the action can authenticate against the GitHub API and create the release. The with section provides the standard properties required when creating a GitHub release. We indicate the release should be created on the tag that triggered the action, i.e., the github.ref. We provide a name for the release and indicate it is not a draft release. Finally, we set the prerelease property using the the contains function available within the GitHub Actions expression syntax. As above, we use the presence of the hyphen to discern prerelease and release semantic versions. If the tag contains a -, the function returns true and a prerelease release is created. If the tag does not contain a -, the function returns false and a regular release is created.

The final job increments the npm package’s version, but only after a regular release tag is pushed. It does not make sense to increment the package version after a prerelease.

jobs:
  # …
  # increment NPM package version patch level
  increment-version:
    # not for prerelease tags
    if: ${{ !contains(github.ref, '-') }}
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v2
        with:
          # clone deeply
          fetch-depth: 0
          # checkout default branch, not tag
          ref: main
      - name: increment package version
        env:
          TAG_REF: ${{ github.ref }}
        run: >-
          npm version --allow-same-version --no-git-tag-version "${TAG_REF#refs/tags/v}" &&
          npm version --no-git-tag-version patch
      - uses: stefanzweifel/git-auto-commit-action@v4
        with:
          commit_message: Increment patch level after release
          branch: main
          push_options: --no-tags
      - name: retry push on failure
        if: ${{ failure() }}
        run: >-
          git -c user.name="GitHub Actions" -c user.email="actions@github.com" pull --rebase &&
          git push origin main

The definition of the increment-version job introduces conditional execution using the if property. Since we only want this job to execute for release semantic version tags, we use the same test as on the previous job, but negate its value. So if the tag is a release version, it does not contain a -, the contains function returns false, which is negated to true by the !, the conditional evaluates to true, and the step executes. The first step of the job once again checks out the code, but here we have to include some configuration to avoid problems in future steps. First, we set fetch-depth: 0 to ensure we do not end up in a detached head state such that we are unable to push our changes back to the repository. Second, rather than check out the tag, we checkout the tip of the repository default branch, main. Since you can tag any commit, the tag that triggered this action may not be the tip of the default branch, but we want to change the version in the current state of the repository, not create a branch off some past version. Next, we actually increment the version patch level in the package.json file. We take some pains, running npm version twice, to ensure we end up with the patch level one higher than the tag that triggered the action. The first execution ensures the current value is equivalent to the tag and the second increments the patch level. This allows us to avoid double incrementing if someone has already incremented the version manually. We then use the stefanzweifel/git-auto-commit-action@v4 action to commit and push the changes back to the repository. Here again we need to provide some configuration under the with property. We set the commit message, what we want to push (branch: main), and, since we did not create any tags and this action pushes tags by default, add --no-tags to the git push options. The last step is a hacky way to retry the push if it fails. Since we have a few automated processes operating around tags and releases, we have seen cases where in the time it takes for this job to checkout the code, make the change, and try to push it, another automated process has pushed a new commit to the default branch, so the push fails. This last step only runs if the previous step fails, performing a pull with rebase and then tries to push again.

With all that done, it’s just a matter of pushing a semantic version tag.

You can find the complete GitHub Actions workflow in the repository.

Atomist Skills

Much like GitHub Actions, Atomist Skills provides a catalog of available skills at https://go.atomist.com/catalog. Unlike GitHub Actions, these skills are complete units of automation, not building blocks that need to be composed together to accomplish your desired task. We do not need to worry about the proper way to clone the repository or retry a push if it fails. All of that, and more, happens automatically. We simply need to pick the skills we want to use. The skills we need to achieve our desired delivery flow are npm Build, GitHub Release, and npm Version.

npm Build

The npm Build skill provides an adaptable means for building and optionally publishing npm packages. The configuration is similar to that for the publish GitHub Action step above, albeit graphically driven. Before enabling the npm Build skill, we first configure the npmjs registry integration. The npmjs registry integration allows our skills to authenticate with the npmjs.com package registry so they can publish packages and read private packages. This step is similar to creating the NPMJS_AUTH_TOKEN secret value for GitHub Actions. Go to the npmjs registry integration page and click the Add button. The configuration page will look like this:

We accept the default name, leave the scope empty, enter our authentication token, and click the Add button.

With the npmjs registry integration created, we can proceed with the npm Build skill. To configure and enable the skill, go to the npm Build skill page and click the “Turn on this skill” button. The skill configuration page will look something like this:

To configure the npm Build skill, we first select the npmjs registry we just created. Since we want to build only when tags are pushed, we select the “GitHub > tag” trigger. We enter the Node.js version 12. Since we are building tags, we select “All branches” and publish a public package. We do not add any distribution tags, yielding to the default behavior: apply the latest tag to releases and the next tag to prereleases. Finally, we select the repository we want the skill to run on.

With the configuration complete, we click the “Enable skill” button to save the configuration and activate the skill.

GitHub Release

The GitHub Release skill, perhaps unsurprisingly, creates a GitHub release when a semantic version tag is pushed to a repository. To enable it, we go to the GitHub Release skill page and click the “Turn on this skill” button.

On the skill configuration page, check the “Create Prereleases” box, select the repository as we did above, and click the “Enable skill” button. That's it. The logic to trigger off tags that look like semantic versions, real semantic versions, is built in.

npm Version

The npm Version skill increments the patch level of the package version after a GitHub release is created. Unlike GitHub Actions, this skill only runs when a proper GitHub Release is created. No hacky conditionals are necessary to avoid incrementing on prereleases. All the logic to checkout the code, safely increment the patch level, and push the change back with retrying is built into the skill. We just need to go to the npm Version skill page, click the “Turn on this skill” button, select our repository, and click the “Enable skill” button.

With our three skills configured and activated, we simply push a semantic version tag.

Moving on

Looking at the effort required to enable the desired workflow using GitHub Actions and Atomist Skills, it seems clear the Atomist Skills provides a quicker, easier enablement, unless you really like YAML. Beyond what you see above, configuring the GitHub Actions took many iterations—edit, commit, and push cycles—to get right. Atomist Skills’ focus on providing out-of-the-box usable automated tasks requires less cognitive effort to design the solution and less configuration effort to get it running.

Feature Comparison: GitHub Actions & Atomist Skills
  GitHub Actions Atomist Skills
Configuration YAML Web UI
Architecture Modular components Pre-packaged solutions
Operation Logs & commit checks Logs & commit checks
Extensibility Containers & Code Containers & Code
Scope Repository GitHub-wide

In addition to being easier for one repository, the GitHub-wide purview of Atomist Skills makes moving beyond one repository much simpler. With GitHub Actions, you have to copy your workflows from repository to repository, repeating whenever you need to update your workflow. With Atomist Skills, you just select more repos, or be bold and select them all! The same, consistent configuration is used across all your repositories with the click of a button. No more copying files around. In other words, it is just as easy to automate with Atomist Skills across a thousand repositories as it is to automate one repository.

What are you waiting for? Sign up for Atomist and enable your first skill! You can try the skills mentioned in this blog post or try the Secret Scanner, npm Vulnerability Scanner, Keep a Changelog, GitHub Slash Commands, or Copyright License skill. Or create your own custom skill using the Docker Container Runner skill.