Deploy to GitHub Packages With GitHub Actions

Friday, Nov 6, 2020 6 minute read Tags: devops javascript dotnet

You’ve started a new project in which you’re creating a package to release on a package registry and you want to simplify the workflow in which you push some changes to be tested in an app, without a lot of hassle of copying local packages around.

The simplest solution to this is to push to npm, but that can be a bit cluttering, especially if you’re iterating quickly.

This is a predicament that I found myself in recently, and decided it was finally time to check out GitHub Packages. GitHub Package supports a number of different package repository formats such as npm, NuGet, Maven and Docker, and integrates directly with the existing package management tool chain. For this post, we’ll use a npm package, but the concept the same for all registry types.

Creating a Workflow

To do this workflow, we’ll use GitHub Actions as our workflow engine. I’ve blogged in the past on getting started with GitHub Actions, so if you’re new to them I’d suggest using that to brush up on the terminology and structure of a workflow file.

Start by created a workflow file in .github/workflows and call it build.yml. We want this workflow to run every time someone pushes to the main branch, or when a PR is opened against it, so we’ll set that as our trigger:

1
2
3
4
5
6
7
name: Node.js CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

Next, we’ll create a job that does your normal build process. Remember that this is a Node package, so it’s written for that, but swap it out for npm calls, or whatever platform you’re targeting:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
jobs:
  build:
    runs-on: ubuntu-18.04
    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js 14.x
      uses: actions/setup-node@v1
      with:
        node-version: 14.x
    - run: npm ci
    - run: npm run lint
    - run: npm test

Building a Package

With the workflow running our standard verification checks, the next job will generate the package. Personally, I like to extract it out to a separate job so it’s clear which phase of our workflow a failure has happened. This new job will be called package and it’ll need the build job to complete first, which we specify with the needs property:

1
2
3
4
5
6
7
8
9
  package:
    needs: [build]
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js 14.x
        uses: actions/setup-node@v1
        with:
          node-version: 14.x

One down-side of doing this as a separate job is that we’ll need to prepare the artifacts for the package to be created again, as they aren’t available from the build job (unless you upload them, but that might be really slow if you have a lot of dependencies), so we’ll have to get them again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  package:
    needs: [build]
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js 14.x
        uses: actions/setup-node@v1
        with:
          node-version: 14.x

      - run: npm ci

For this example, we’re only installing the npm packages, but if it was a TypeScript project you’d want to run the tsc compilation, .NET projects would need to compile, etc.

With dependencies installed, it’s time to generate the package:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
  package:
    needs: [build]
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js 14.x
        uses: actions/setup-node@v1
        with:
          node-version: 14.x

      - run: npm ci
      - run: npm version prerelease --preid=ci-$GITHUB_RUN_ID --no-git-tag-version
      - run: npm pack
      - name: Upload
        uses: actions/upload-artifact@v2
        with:
            name: package
            path: "*.tgz"

With npm we have a version command that can be used to bump the version that the package is going to be created, and you can use it to bump each part of the semver string (check out the docs for all options). Since this is happening as part of a CI build, we’ll just tag it as a pre-release package bump, and use the ID of the build as the version suffix, making it unique and auto-incrementing across builds. We’ll also give it the --no-git-tag-version flag since we don’t need to tag the commit in Git, as that tag isn’t getting pushed (but obviously you can do that if you prefer, I just wouldn’t recommend it as part of a CI build as you’d get a lot of tags!).

If you’re using .NET, here’s the run step I use:

1
run: dotnet pack --configuration Release --no-build --version-suffix "-ci-$GITHUB_RUN_ID" --output .output

Finally, we’ll use the upload Action to push the package to the workflow so we can download it from the workflow to do local installs, or use it in our final job to publish to GitHub Packages.

Publishing a Package

With our package created and appropriately versioned it’s time to put it in GitHub Packages. Again, we’ll use a dedicated job for this, and it’s going to depend on the package job completion:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
  publish:
    name: "Publish to GitHub Packages"
    needs: [package]
    runs-on: ubuntu-18.04
    if: github.repository_owner == 'aaronpowell'
    steps:
    - name: Upload
      uses: actions/download-artifact@v2
      with:
          name: package
    - uses: actions/setup-node@v1
      with:
        node-version: 14.x
        registry-url: https://npm.pkg.github.com/
        scope: "@aaronpowell"
    - run: echo "registry=https://npm.pkg.github.com/@aaronpowell" >> .npmrc
    - run: npm publish $(ls *.tgz)
      env:
        NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

You’ll notice that here we have an if condition on the job and that it’s checking the GitHub context object to ensure that the owner is the organisation that this repo belongs to. The primary reason for this is to reduce the chance of a failed build if someone pushes a PR from a fork, it won’t have access to secrets.GITHUB_TOKEN, and as such the job would fail to publish, resulting in a failed job. You may want to tweak this condition, or remove it, depending on your exact scenario.

This job also doesn’t use the actions/checkout Action, since we don’t need the source code. Instead, we use actions/download-artifact to get the package file created in the package job.

To publish with npm, we’ll setup node, but configure it to use the GitHub Packages registry, which is https://npm.pkg.github.com/ and define the current organisation as the scope (@aaronpowell).

We’ll then setup the .npmrc file, specifying the registry again. This ensures that the publishing of the package will go through to the GitHub Packages endpoint, rather than the public npm registry.

Lastly, we run npm publish and since we’re publishing the package from an existing tgz, not from a folder with a package.json, we have to give it the file path. Since we don’t know what the version number is we can use ls *.tgz to get it and inline that to the command.

Quick note, GitHub Packages only supports scoped npm packages (ref), so your package name will need to be scoped like @aaronpowell/react-foldable.

Conclusion

With this done, each build will create a GitHub Package that you can use. You’ll find a full workflow example on my react-foldable project.

The requirement for npm packages to be scoped caught me out initially, but it’s an easy change to make, especially early on in a project.

Ultimately though, this helps give a quicker feedback loop between making a change to a package and being able to integrate it into a project, using the standard infrastructure to consume packages.