Running GitHub Actions Only for Changed Packages in a Monorepo

Davide Cavaliere
Davide Cavaliere
View profile →

By Davide Cavaliere · Sep 19 '24 · 3 min read

Running GitHub Actions Only for Changed Packages in a Monorepo

In a monorepo setup, you’re probably (like us at Microgamma) using Lerna to bump package versions, followed by some GitHub Actions to test, build, and deploy them.

One challenge we faced was: how can we run a job only if a specific package has changed?

Let’s walk through a simple GitHub workflow that shows our approach.

on:
  pull_request:
    branch: [master]

  workflow_dispatch:

jobs:
  version:
    runs-on: buildjet-2vcpu-ubuntu-2204

    outputs:
      tag: ${{ steps.released-tag.outputs.tag }}
      next: ${{ steps.next-tag.outputs.next }}
      changed: ${{ steps.changed.outputs.changed }}

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          fetch-depth: 0

      - uses: buildjet/setup-node@v4
        with:
          node-version: '20.x'
          cache: 'npm'
          registry-url: 'https://registry.npmjs.org/'

      - name: Install dependencies
        run: npm ci

      - id: released-tag
        name: Grab Latest Release Tag
        run: |
          tag=`git describe --tags --abbrev=0`
          echo "tag=$tag" >> $GITHUB_OUTPUT

      - name: Get changed packages
        id: changed
        run: |
          CHANGED=$(npx lerna changed --json | jq 'map(.name)' -c)
          echo ${CHANGED}
          echo "changed=${CHANGED}" >> $GITHUB_OUTPUT

      - name: Lint
        run: npm run lint:ci

      - name: Version
        run: npm run version:ci

      - id: next-tag
        name: Grab Next Tag
        run: |
          tag=`git describe --tags --abbrev=0`
          echo "next=$tag" >> $GITHUB_OUTPUT

      - name: Check variable
        if: contains(env.changed, 'musicbox-web')
        run: echo ${{ env.changed }}


  should_run:
    runs-on: buildjet-2vcpu-ubuntu-2204
    needs: version
    if: contains(needs.version.outputs.changed, 'my-package-a')

    steps:
      - name: test
        run: echo ${{ needs.version.outputs.changed }}

  should_not_run:
    runs-on: buildjet-2vcpu-ubuntu-2204
    needs: version
    if: contains(needs.version.outputs.changed, 'my-package-b')

    steps:
      - name: test
        run: echo ${{ needs.version.outputs.changed }}

The key piece is this step:

      - name: Get changed packages
        id: changed
        run: |
          CHANGED=$(npx lerna changed --json | jq 'map(.name)' -c)
          echo ${CHANGED}
          echo "changed=${CHANGED}" >> $GITHUB_OUTPUT

Here, all changed package names are collected into a changed output variable, which can then be used in later jobs:

  should_run:
    runs-on: buildjet-2vcpu-ubuntu-2204
    needs: version
    if: contains(needs.version.outputs.changed, 'my-package-a')

Why we store both the previous and next release tags

You’ll notice we capture both the current release tag (before bumping) and the next release tag (after bumping). This is crucial because of how Lerna’s run command works.

By default, lerna run test executes the test script in every package that has changed since the latest tag. But after running lerna version, a new tag is created, which means the “latest tag” changes — and that breaks the logic for detecting what really changed.

To solve this:

  • We store the tag before bumping (so we can compare changes against it).
  • We store the new tag (so subsequent jobs can check it out).

For example:

  Build_UI:
    runs-on: buildjet-2vcpu-ubuntu-2204
    needs: Version

    if: needs.Version.outputs.next != needs.Version.outputs.tag

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.Version.outputs.next }}
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          fetch-depth: 0

This job runs only if a new tag was actually created.

From there, we can run builds and deploys only for packages changed since the previous release:

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build:ci -- --since ${{ needs.Version.outputs.tag }}

      - name: Deploy
        run: npm run deploy:ci -- --since ${{ needs.Version.outputs.tag }}

Why not just use lerna run?

Technically, we could just use lerna run ... and it would skip unchanged packages. But to even get to that point, the workflow still needs to:

Run the job

Checkout the repo

Install dependencies

…which wastes build time if no relevant packages actually changed.

By filtering earlier in the workflow, we avoid unnecessary jobs altogether. Hopefully this helps anyone else running into the same issue we did! 🚀

Originally published on dev.to