Package GitHub Actions automatically with GitHub Actions
Actions are individual tasks that you can combine to create jobs and customize your workflow. You can create your own actions, or use and customize actions shared by the GitHub community.
A GitHub Action shared by the GitHub community is distributed through a GitHub repository.
GitHub downloads each action run in a workflow during runtime⌝ and executes it as a complete package of code [that] must include any package dependencies required to run the JavaScript code¹
The typical process to meet this requirement is a dist
directory that each commit author is responsible for keeping up to date with their changes — as seen in first-party actions⌝ and third-party actions⌝ — a fragile, manual process.
Fortunately, we can automate the process of packaging GitHub Actions for distribution using GitHub Actions itself — following a few simple steps. Read on to learn how and why, or skip straight to the complete example.
Why automate the process of packaging GitHub Actions?
Ease of Contribution
The barrier to entry for contributing to a GitHub project should be as low as possible; this means enabling contributions created on the GitHub website — without access to build tooling.
Vulnerability to Malicious Changes
Packaged code is difficult to review; it obscures changes by transpiling, compiling and minifying, while Code Review interfaces often hide differences in generated files.
How can we use GitHub and GitHub Actions to build a robust packaging process?
- A
main
branch protected from pushes⌝— changes come from Pull Requests - The
main
branch does not contain any packaged code, thedist
directory in.gitignore
prevents any unintentional push of packaged code - A single
release
branch offmain
which a Workflow keeps synchronised by merging new changes frommain
intorelease
- Any push to the
release
branch triggers the Workflow, this Workflow automatically packages the action and pushes the resultingdist
onto the release branch - A Workflow prevents tags from being added to commits that do not contain a
dist
(i.e., not on therelease
branch)
1. Create a Release Branch
You can create branches directly on GitHub, as described in Creating and deleting branches within your repository⌝ — create a branch called release
from the main
branch. We need to keep release
up to date with any changes merged into main
which we can do from a Workflow.
Pushing new commits from a Workflow
GitHub executes each Workflow with a token — ${{ github.token }}
— that has permissions to push commits back to a repository; however, to prevent infinite Workflow loops, the tasks performed with this token do not trigger further Workflow runs.
GitHub recommends a Personal Access Token for workflows that need the ability to trigger further workflows. Unfortunately, Personal Access Tokens cannot be scoped to a repository, violating the principle of least privilege. Fortunately, there is an alternative: a Deploy Key.
A complete step by step guide to using a Deploy Key instead of a Personal Access Token is available on this blog, in Trigger another GitHub Workflow — without using a Personal Access Token.
2. Package Action on Push
Synchronise release
with main
A push to the main
branch triggers our Workflow. The job checks out the release
branch using the commit key and then merges main
into the job workspace.
Package changes
All dependencies are installed, the action is packaged.
Push Packaged Action
The source changes and dist changes are pushed to the release branch.
3. Run integration tests against the package
Tests run against every commit, with both unit tests and integration tests. The additional step required is determining if the action should be packaged or not: on unpackaged branches, we need to package; on packaged branches, we want to use dist
as is — this is important as it tests that our packaged releases work.
4. Protect commits without the package from being tagged
An unpackaged Action will cause the Workflow to fail with an unhelpful error that Action consumers may not understand.
Error: File not found: '/home/runner/work/_actions/organisation/repository/main/dist/index.js'
Avoiding this error is straight-forward: do not create tags against commits that aren’t on the release
branch, but human error is inevitable, so we can implement a Workflow to protect from this mistake.
💡 GitHub warns that “you will not receive a webhook for this event when you push more than three tags at once⌝”, which means there is an edge-case that this Workflow cannot catch, and so some diligence is required.
5. Limitations and caveats
Branch references are unsupported
GitHub recommends using tags for release management⌝and only list tags in the Marketplace, which this solution supports — along with commit pinning. However, a user may expect that they can continue to reference an action by branch— e.g.: pr-mpt/examples-action-packaging@main
— which is not possible because main
is no longer packaged.
Release a patch
The main
and release
branching strategy requires an additional branch for any change to a previous major or minor version.
- Locate the commit on
main
which represents the source for the major or minor version - Create a new fix branch using the
main
commit as the base (e.g:git checkout -b fix-a-bug 3ad5247
) - Push your changes to the new branch
- Create a new release branch from the fix branch (e.g:
git checkout -b release/fix-a-bug
) - After the
package-action
job is complete, tag the packaged Action commit with the version
Automatic packaging in action
A complete example is available on GitHub in pr-mpt/examples-action-packaging⌝. Fork it, make changes and see automatic packaging in action.
For a real-world example, many of the actions in the pr-mpt organisation on GitHub⌝ use this approach, including:
- pr-mpt/actions-commit-hash⌝ output commit hash (short and long) with an optional prefix