Packaging an Action

May 8, 2020

After having created several actions to use in GitHub Actions workflows, I've settled on a pattern that I really like for packaging them, and in my most recent action, I codified this in a CI process to automate it for me.

The reason that this is important is because GitHub Actions uses a git repository as the distribution mechanism for an action. When you specify an action to run, like:

steps:
- uses: actions/checkout@v2

That indicates that you want to run an action named actions/checkout at version v2. This literally maps to a reference v2 in the repository https://github.com/actions/checkout.

Since actions are actually just Node.js applications, that means that the reference v2 in that repository needs to contain the application, and its dependencies. It needs to actually contain the node_modules directory. But... you're not supposed to check that in, are you?

No! You're not. At least... not in your development branch. Now, of course you could technically do this and GitHub Actions would work just fine, but it's going to be messy. Instead, I recommend using a two branch approach:

  1. master is the branch that you work in, just like you would with any application. In this branch, you should not check in your node_modules directory, it should be added to your .gitignore just like any other Node.js application.

  2. dist is the branch that your application is distributed in. This contains the built and packed version of your action, along with any metadata files (your license, README and action.yml, for example).

This keeps the build output separate from your source directory, where it definitely doesn't belong. But it's a little annoying to have to build into a new branch and publish it yourself. And - whenever I see anything that's a manual annoyance, I try to automate it. So I created a GitHub Actions workflow to build my master branch and then publish it into dist.

Here's the simple summary (with comments to explain what's happening):

name: CI

# Run this whenever there's an update to the master branch.
on:
  push:
    branches: [ master ]

jobs:
  #
  # Build (if there's a build step) and run tests to ensure that the
  # new change in master is good.
  #
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Check out source
      uses: actions/checkout@v2

    - name: Build and Test
      run: |
        npm ci
        npm run build --if-present
        npm test
  #
  # Publish the action to the `dist` branch
  #
  publish_action:
    runs-on: ubuntu-latest
    needs: build

    steps:
    - name: Check out source
      uses: actions/checkout@v2

    # Check out the `dist` branch into the `dist` directory.
    - name: Check out distribution branch
      uses: actions/checkout@v2
      with:
        ref: 'dist'
        path: 'dist'

    # Run `npm run pack`, which uses @zeit/ncc to package the action
    # into a single file.  Copy things that we want to publish out of
    # the source directory and into the dist directory (which is where
    # the dist branch is checked out.)
    - name: Package
      run: |
        npm install
        npm run pack
        mkdir -p dist/documentation
        mkdir -p dist/examples
        cp action.yml dist/
        cp README.md dist/
        cp LICENSE.txt dist/
        cp documentation/* dist/documentation/
        cp examples/* dist/examples/

    # Check for changes; this avoids publishing a new change to the
    # dist branch when we made a change to (for example) a unit test.
    # If there were changes made in the publish step above, then this
    # will set the variable `has_changes` to `1` for subsequent steps.
    - name: Check for changes
      id: status
      run: |
        source ../.github/workflows/actions.sh
        if [ -n "$(git status --porcelain)" ]; then
          echo "::set-output name=has_changes::1"
        fi
      working-directory: dist

    # Commit the changes to the dist branch and push the changes up to
    # GitHub.  (Replace the name and email address with your own.)
    # This step only runs if the previous step set `has_changes` to `1`.
    - name: Publish action
      run: |
        git add --verbose .
        git config user.name 'CI User'
        git config user.email 'ci@example.com'
        git commit -m 'Update from CI'
        git push origin dist
      if: steps.status.outputs.has_changes == '1'
      working-directory: dist

This workflow will keep dist updated any time you make changes in the master branch. To reference your action, you can run your/repo@dist in a workflow, or better still, you can create a release off the dist branch. You can even publish your action to the marketplace from those releases.

Before you run this workflow, you'll need to create some commit in the dist branch in your GitHub repository.

You can create an empty one by:

commit_id=$(git commit-tree -m 'Distribution branch' 4b825dc642cb6eb9a060e54bf8d69288fbee4904)
git push origin ${commit_id}:refs/heads/dist

(What's going on here? That's another blog post.)

I think that this workflow is a great way to keep a build of your action up-to-date, but also while keeping it out of your source branch.