The migration of large-scale enterprise software projects from legacy continuous integration platforms like Jenkins to GitHub Actions presents a unique set of architectural challenges, particularly when the codebase is structured as a monorepo. A monorepo, by definition, houses multiple services, libraries, and applications within a single repository, often spanning diverse programming languages and deployment targets. While the community has generated numerous guides on implementing monorepos with GitHub Actions, many rely heavily on third-party actions, complex build systems, or "hacks" that introduce additional dependencies and potential supply-chain vulnerabilities. The pursuit of a "vanilla" GitHub Actions setup—defined as a configuration that relies minimally on external tools and maximizes native platform capabilities—offers a pathway to improved maintainability, security, and consistency between development and continuous integration environments. This analysis examines the strategies, patterns, and pitfalls of configuring GitHub Actions for monorepos, drawing from empirical enterprise migrations and modern orchestration tools like Turborepo and Nx.
The Case for Vanilla GitHub Actions
In an enterprise context, the decision to migrate from Jenkins to GitHub Actions is often driven by the desire for tighter integration with source control, improved developer experience, and reduced operational overhead. However, the transition introduces a critical requirement: the need to support a monorepo containing multiple programming languages, such as JavaScript and Python, and deploying to cloud-native environments like AWS using CloudFormation or AWS CDK, rather than Kubernetes.
The primary motivation for adopting a "vanilla" approach to GitHub Actions is the mitigation of dependency risk. By limiting the use of third-party actions, organizations decrease their exposure to supply-chain attacks and reduce the complexity of the CI/CD pipeline. This strategy enhances maintainability, as fewer external components mean fewer potential points of failure and easier debugging. Furthermore, ensuring that the development environment shares the same tool versions as the CI environment is crucial for preventing "works on my machine" scenarios, a common issue in large organizations, particularly within the Python ecosystem where version management can be notoriously difficult.
The goal is not to eliminate all third-party tools, but to use them only when they provide significant convenience without compromising security. For instance, actions from trusted vendors like AWS and Docker may be used for specific deployment tasks, but the core orchestration logic should remain within native GitHub Actions features. This approach demonstrates that even large monorepos with tens of components can be effectively managed without resorting to complex build systems or extensive external dependencies.
Monorepo Structure and Path-Based Triggers
A typical monorepo structure organizes code into distinct packages or components. A representative layout might include:
- Root directory (
my-monorepo/) containing global configuration files likepackage.jsonandturbo.json. - A
packages/directory housing individual applications and services, such asapi,web,mobile, andshared. - A
libs/directory containing shared libraries and utilities, such asutils,ui-components,tools, andcli.
The efficiency of a monorepo CI/CD pipeline hinges on its ability to trigger workflows only when relevant files change. Running full builds and tests for every commit, regardless of the affected components, is resource-intensive and slow. GitHub Actions provides native paths filtering to address this.
By defining specific file paths in the workflow file, the pipeline can be configured to run only when those paths are modified. For example, a workflow dedicated to the API component should trigger only when files within the packages/api/ directory change. However, because monorepo components often have interdependencies, the trigger must also account for changes in dependent libraries. If the api package depends on libs/utils and libs/shared, changes in those directories should also trigger the API workflow. Additionally, changes to root-level files such as package.json, pnpm-lock.yaml, or the workflow file itself should trigger the job, as they may affect the global build environment.
The following YAML configuration illustrates a path-based trigger for an API CI workflow:
yaml
name: API CI
on:
push:
branches: [main]
paths:
- 'packages/api/**'
- 'libs/utils/**'
- 'libs/shared/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/api.yml'
pull_request:
paths:
- 'packages/api/**'
- 'libs/utils/**'
- 'libs/shared/**'
This configuration ensures that the build-api job, which includes steps for checking out code, setting up Node.js and pnpm, installing dependencies, and running build and test commands, executes only when relevant changes occur. This targeted approach significantly reduces CI/CD runtime and resource consumption.
Dynamic Change Detection and Orchestration Tools
While static path filtering is effective for simple scenarios, it can become cumbersome in complex monorepos with many interdependent components. Dynamic change detection allows the CI/CD pipeline to determine at runtime which components have changed and need to be built or tested. This can be achieved using third-party actions like dorny/paths-filter@v2 or by leveraging modern monorepo tools such as Turborepo and Nx.
The dorny/paths-filter action allows workflows to define filters for different components and then use conditional logic to run jobs based on which filters matched. For instance, a workflow can define filters for api, web, mobile, and shared components. The workflow then outputs boolean values indicating whether each component was affected. Subsequent jobs can depend on these outputs to determine whether to execute.
yaml
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
mobile: ${{ steps.filter.outputs.mobile }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
api:
- 'packages/api/**'
- 'libs/utils/**'
web:
- 'packages/web/**'
Alternatively, tools like Turborepo and Nx provide built-in capabilities for detecting affected packages and executing tasks only for those packages. Turborepo, for example, can be integrated into a GitHub Actions workflow by caching its internal state and using the --filter='...[origin/main]' flag to run build, test, and lint tasks only for packages that have changed relative to the main branch.
The following configuration demonstrates how to set up Turborepo in a GitHub Actions workflow:
yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ github.sha }}
restore-keys: |
turbo-
- run: pnpm install --frozen-lockfile
- name: Build affected packages
run: pnpm turbo run build --filter='...[origin/main]'
- name: Test affected packages
run: pnpm turbo run test --filter='...[origin/main]'
- name: Lint affected packages
run: pnpm turbo run lint --filter='...[origin/main]'
The Turborepo configuration file (turbo.json) defines the pipeline tasks and their dependencies. For instance, the build task depends on the build tasks of its dependencies (^build), and the test task depends on the build task. This ensures that the correct order of operations is maintained.
Similarly, Nx provides an affected command that can be used to run tasks only for changed packages. Nx also integrates with Nx Cloud for distributed caching, further improving build performance. The following configuration shows how to use Nx in a GitHub Actions workflow:
yaml
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Set up Nx Cloud
run: npx nx-cloud start-ci-run
- run: npm ci
- name: Build affected
run: npx nx affected
Deployment Environments and Status Reporting
One of the challenges in deploying monorepo components is managing environment status in GitHub Actions. GitHub Actions supports the concept of "environments" for deployments, which allow for deployment protection rules and status reporting. However, in a monorepo, multiple components may be deployed to the same environment, or a single component may be deployed to multiple regions within the same environment.
The default behavior of GitHub Actions is to mark a deployment as "inactive" when a new deployment is made to the same environment. This can lead to confusion when multiple components are deployed in parallel, as only one deployment will appear as "active," even if others are running successfully. For example, if an API and a web frontend are deployed to the same production environment, only one will show as active, while the other will appear inactive.
This issue is exacerbated when using matrix jobs to deploy a single component to multiple regions. If a component is deployed to both prod-us-west-2 and prod-eu-west-1, both deployments are part of the same "production" environment. However, GitHub Actions may mark one as active and the other as inactive, making it difficult to track the status of each region.
To address this, teams have suggested improvements to the GitHub Deployment Status APIs, such as the ability to name environments more granularly or to distinguish between different deployments within the same environment. Until such features are implemented, teams may need to adopt workarounds, such as using custom status checks or external monitoring tools to track deployment status.
Managing Workflow Complexity and Required Checks
As the number of components in a monorepo grows, so does the number of workflow files. While this may seem counterintuitive, having one workflow file per component is often the most understandable and maintainable approach. Each workflow file can be tailored to the specific needs of its component, including its dependencies, build commands, and deployment targets.
However, managing required checks in a monorepo can be complex. Required checks are used to ensure that certain jobs pass before a pull request can be merged. In a monorepo, not all jobs may be relevant to every pull request. For example, a pull request that only changes the web frontend should not require the API build job to pass.
To handle this, teams can define no-op jobs in common workflow files. These jobs are included in the required checks but do nothing if the relevant components have not changed. For example, a common deployment workflow might include no-op jobs for each component, which are triggered only if that component has changed. This ensures that the required checks are always satisfied, even if some components are not affected by the current pull request.
Another consideration is the use of third-party tools for dependency management and automated releases. Tools like Renovate can be integrated into a monorepo to automatically update dependencies. Renovate configurations can be shared across the monorepo, ensuring that all components are kept up to date. Automated releases can also be implemented using GitHub Actions, creating new minor releases when pull requests are merged into the main branch.
Conclusion
Implementing GitHub Actions for a monorepo requires a careful balance between native platform features and external tools. The "vanilla" approach, which prioritizes minimal dependency on third-party actions, offers significant benefits in terms of security, maintainability, and consistency. However, it also demands a deeper understanding of GitHub Actions capabilities, such as path-based triggers, dynamic change detection, and environment management.
For teams migrating from Jenkins to GitHub Actions, the journey involves rethinking how CI/CD pipelines are structured and orchestrated. By leveraging native path filtering, tools like Turborepo and Nx, and careful management of required checks, organizations can build efficient and scalable monorepo workflows. While challenges remain, particularly in deployment status reporting and environment management, the patterns and strategies outlined here provide a solid foundation for navigating the complexities of monorepo CI/CD in GitHub Actions.