DEV Community

Davide Cavaliere
Davide Cavaliere

Posted on • Edited on

Running GitHub Actions Only for Changed Packages in a Monorepo

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 }}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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! 🚀

Top comments (0)