GitHub Actions: Publishing Bundled Packages With TypeScript

Paul Lessing
The Startup
Published in
9 min readJul 31, 2020

--

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:

  1. Check out our code
  2. Run the build and package the build outputs into a .tgz file
  3. Create a release for the latest version
  4. 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 called
steps.<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.

--

--