GitHub Actions: Publishing Bundled Packages With TypeScript
GitHub Actions are a recent addition to GitHub which allow you to do a lot of CI (Continuous Integration) work directly on GitHub, without having to go through the effort of setting up a CI system like Travis CI, CircleCI or Drone (which is what we mainly use at my workplace, City Pantry).
Using GitHub Actions, you can automatically create builds, run tests, and even deploy, using a (relatively) simple YAML syntax. As a private developer on the free plan, you get 2000 Action minutes per month, which should be plenty to give them a try — and if your repository is public, you get unlimited minutes!
I had a project which was written in TypeScript, but it needed to be consumed by Node-RED, which requires compiled JavaScript bundles. It’s easy enough to point Node-RED at a GitHub repository as a package source and it will work things out using the configuriation in package.json
; but I didn’t want to have to commit compiled code to the repository.
Actions to the rescue! In this post, we will walk through how to automatically compile and package a TypeScript project, and attach the compiled tar file to the release, so it can be downloaded from a URL like https://github.com/paullessing/proj/releases/download/v1.0.0/package.tgz
Preconditions
I will assume you already have a working TypeScript repository up and running, and that in package.json
, there is a scripts
section with a build
script (n.b. this is an example; your build might be a lot more complicated than this):
// package.json
{
"name": "My project",
"scripts": {
"build": "tsc"
}
}
I use yarn
for all my packaging needs. This article works fine with npm
, all you need to do is replace the yarn
commands with the equivalent npm
commands and skip the setup-node
step in the build.
Bundling the code
We will be using the yarn pack
command to create our bundle. To prevent us having to manually run yarn build
before we pack the files, we can add another script to our list:
// package.json
{
"name": "My project",
"scripts": {
"build": "tsc",
"prepack": "yarn build"
}
}
The prepack
script is a built-in one to npm
and yarn
. Whenever the pack
command executes, this script (if found) executes first; in this case, this means that our build will run and create all our output artifacts before they are packaged up.
Creating the Workflow
On GitHub, go to the “Actions” tab in your project, and click the “Set up a workflow yourself” button in the top right corner. You will get a visual editor for a new main.yml
file in your project, under .github/workflows
. This is where all workflows have to be located. Any yml
file inside that directory will be treated as a workflow, and you can have at most one workflow per file.
There are three default properties in a workflow:
name
is the displayed name when your workflow runs;
on
defines triggers for this workflow;
jobs
sets up the steps that your workflow will execute.
For the full description of how to set this up, see Configuring a workflow in the GitHub actions doc.
Setting up triggers
First of all, we’re going to make the build run on every push to master
(full CD). To do this, we set the on
property to branches: [ master ]
.
See below for instructions on how to set the trigger up to run only when you create a release.
Configuring the build environment
We only need a single job in this workflow. It will:
- Check out our code
- Run the build and package the build outputs into a
.tgz
file - Create a release for the latest version
- Attach the file to our release.
Each of these things is done by a Step in the job. Steps are configurable scripts that each run an Action. You can find Actions in the GitHub Marketplace where there are many custom-written actions that may match your exact criteria, saving you from having to write them yourself!
To run these steps, we need to decide on a runner: the virtual machine that is going to run the scripts. There are a number to choose from; GitHub has a list of predefined runners that are hosted by GitHub (Windows, Linux, macOS), but you can use your own if you want. We will use the default ubuntu-latest
build environment.
1. Check out the code
Github’s actions/checkout
Action will run a checkout of our branch and allow us to execute commands on it in subsequent steps.
After this action has run, we have a copy of our repository in the working directory.
steps:
- uses: actions/checkout@v2
By defining the step with uses
, we tell the Workflow to run that action, passing in any configuration we may give it via the with
parameter.
2. Run the build and package it into a .tgz file
Since we use Yarn, we have to make sure that it is installed before we can proceed. To do this, we can use the official actions/setup-node
Action which lets us define specifically which version of Node we want to use. As a side effect, it also installs Yarn.
We’re going to use node v12, so we define a config object with
which contains the node-version
property, and we pass in 12.x
to allow any version of node v12.
steps:
...
- uses: actions/setup-node@v1
with:
node-version: '12.x'
Now, we can install our node_modules. The run
property on a step allows us to execute a command in the current working directory. We are going to run two commands: First, we yarn install
:
steps:
...
- run: yarn install --frozen-lockfile
Using --frozen-lockfile
is good practice to ensure that you only install versions defined in your lockfile; the install will fail if there are any packages in package.json
that are missing specific versions from your lockfile. This can happen if you add something manually to package.json
but forget to run yarn install
or commit the yarn.lock
file.
Finally, we can run the build. yarn pack
will run whatever is defined in your build
script in package.json
, and then bundle it up into a .tgz
file.
steps:
...
- run: yarn pack --filename=package.tgz
We rename this file because the default is <package-name>-v0.0.0.tgz
(with the full version number), which makes it a bit tricky to deal with in later steps (we would have to make sure we generate the right file name in the upload step to pass as an argument for the upload). Instead, we simply call this package.tgz
so we know exactly what it is called and where it is located.
That’s it! Because we set up the prepack
script in our package.json
, the pack
step already runs the build for us.
3. Create a release for the current version
To create a release, we are going to take the version from the package.json
file and create a tag for it. There is a helper action available on the GitHub marketplace for this, action-autotag
:
steps:
...
- uses: Klemensas/action-autotag@stable
id: update_tag
with:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
tag_prefix: "v"
The action-autotag
package does the tagging for us; by setting the tag_prefix
to “v” we are going to get versions that look like “v0.0.0”.
If a step produces output, it will be available via a local variable calledsteps.<step_id>.outputs.<property>
. In this case, we gave the step an ID of update_tag
so that we will be able to use the automatically generated tag name in a later step.
The secrets.GITHUB_TOKEN
property is provided for us by Actions; we don’t need to do any setup for it at all, but it is necessary for authenticating a lot of actions that deal with modifying our repository.
Now we’ve got the tag, we can create the release using the official create-release
action:
steps:
...
- name: Create Release
if: steps.update_tag.outputs.tagname
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.update_tag.outputs.tagname }}
release_name: Release ${{ steps.update_tag.outputs.tagname }}
We’ve given the step a nice name
so that it’ll show up nicely in the logs.
The autotag
action leaves the tagname
output empty if the tag already existed; this is useful because it allows us to skip the build if we’ve pushed to master without changing the version
property. The if
attribute will cause the step to skip if the value is falsy; we will have to add this to all future steps, too.
Again, we’ve assigned an id
to the step so we can read its outputs later; we need the upload_url
output that this step produces so we can upload the asset.
For the tag_name
and the release_name
of the release, we use the steps.update_tag.outputs.tagname
value that the previous step created. Note that we can interpolate strings very easily, if you want to use a different name.
That’s it for the release; if we left it at this, we would already have a workflow that automatically created a release whenever we pushed to master
with a new version in package.json
. (It would build and pack the source, then not do anything with it yet; but at least if the build failed, the job would terminate, so we have some kind of basic CI here!)
4. Attach the built file to our release
For our final step, we’re going to upload the file we built in step 2 and add it as an attachment to the GitHub release. This will make it show up as a downloadable asset on the release, and allow us to access it via an easy-to-generate URL.
We use the official upload-release-asset action to do this step.
steps:
...
- name: Upload Release Asset
if: steps.update_tag.outputs.tagname
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./package.tgz
asset_name: package.tgz
asset_content_type: application/tgz
Like the previous step, we have to skip this if the update_tag
did not update a tag, otherwise this would attempt to update the file even if the previous step hadn’t run.
We use the upload_url
produced by the previous create_release
step, which will attach the file we upload to that specific release.
The asset_path
refers to the file we created in the pack
step; it’s the location (on disk) of the output of yarn pack
, and we renamed it in that step so that here we can use the name without worrying about the version or the package name.
The asset_name
is the file name that will show up on the release page, and in our download URL. You can set it to whatever you like, including doing things like interpolating with ${{ steps.update_tag.outputs.tagname }}
, but I prefer to keep the filename simple.
The asset_content_type
tells GitHub what kind of file we are uploading; because our file is a .tgz
file, the correct content type is application/tgz
.
That’s it! Now whenever you push to master with a new version
, this workflow will run and automatically upload the asset for you.
Check the links at the bottom of this article for the full source of release.yml
.
Building only when we explicitly create a tag
If you don’t want to automatically build on every push to master, for example because you want to have a little more control over what the releases are, we can instead set it up so that we only build when you explicitly create a tag.
First, we need to change the trigger. GitHub provides a push
trigger for creating a tag, so we change the existing push.branches
configuration to use tags
instead:
on:
push:
tags:
- 'v*' # Push events to matching v*, e.g. v1.0, v20.15.10
This configuration does a pattern match on the tag name: If your release tag starts with v
, this job will run. This means that you can still create tags that do not automatically build, but any release you tag with a v0.0.0 format will run automatically.
Next, we can drop the autotag
step entirely. We are manually creating tags, so we don’t need the generated ones. Instead, we will use a context variable GitHub Actions provides us with called {{ github.ref }}
. This contains the branch or tag name that triggered this build. Because our trigger is a tag, the ref
property will contain the tag name.
Now we update the create-release
action to use github.ref
and drop the if
condition (we no longer need to check if there was a tag created):
steps:
...
- name: Create Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
That’s all! Now you can just create a new tag whenever you want a new release. Bear in mind that you still have to update the version
property in package.json
before doing this if you want the release numbers to match the installed version.
You can find the full files for these configurations on Gist: