Build, test and deploy only the projects a change affects. No config file needed.
dynamic-monorepo reads the git diff, finds the projects in your repository on its own (from package.json, go.mod, Dockerfile and similar files), follows the dependencies between them, and gives you JSON lists for a GitHub Actions matrix. Change a shared library and everything that uses it is rebuilt. Change a Dockerfile and that image is rebuilt. The job summary says why each project was picked.
In use: rajadilipkolli/spring-boot-microservices-series-v2 builds its services with it, and uni-helper/create-uni runs the path-filter audit. Gaps the audit found have been fixed in rhesis, GitWand and nagiyu-platform.
See it live: the demo monorepo (Node, Go and Docker, no config) has pull requests showing what runs for a shared-library change, a Dockerfile change and a docs-only change.
Contents
- Quick start
- Already using
on.paths? Audit it - What it detects
- What you get
- Customising
- Troubleshooting
- For AI agents
- FAQ
- Security
- Inputs
- How it works
- Performance
- Limitations
- License
Status: v1 is stable. Inputs and outputs won't change incompatibly within
v1.
Add this file as .github/workflows/ci.yml and open a pull request. That's the whole setup.
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.plan.outputs.build }}
docker: ${{ steps.plan.outputs.docker }}
paths: ${{ steps.plan.outputs.paths }}
dockerfiles: ${{ steps.plan.outputs.dockerfiles }}
has_build: ${{ steps.plan.outputs.has_build }}
has_docker: ${{ steps.plan.outputs.has_docker }}
steps:
- uses: actions/checkout@v7
- uses: continuous-actions/dynamic-monorepo@v1
id: plan
build:
needs: plan
if: needs.plan.outputs.has_build == 'true' # an empty matrix would fail the job
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.plan.outputs.build) }}
steps:
- uses: actions/checkout@v7
- name: Build
working-directory: ${{ fromJSON(needs.plan.outputs.paths)[matrix.project] }}
run: echo "replace with your build command"
docker:
needs: plan
if: needs.plan.outputs.has_docker == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.plan.outputs.docker) }}
steps:
- uses: actions/checkout@v7
- name: Build image
env:
DIR: ${{ fromJSON(needs.plan.outputs.paths)[matrix.project] }}
FILE: ${{ fromJSON(needs.plan.outputs.dockerfiles)[matrix.project] }}
run: docker build -f "$FILE" "$DIR"- The default
actions/checkout(shallow,fetch-depth: 1) is enough. The action fetches only the commits it needs. - It needs only
contents: read, never runs code from your repository, and doesn't call the GitHub API. See Security. - Add a
testordeployjob the same way, using thetest/has_testordeploy/has_deployoutputs.
Already have one workflow per service? Keep your reusable workflow and call it once per affected project: docs/examples/reusable.
To see what it finds before you push, run this in your repository:
npx github:continuous-actions/dynamic-monorepo projects # every project, its folder, targets and dependencies
npx github:continuous-actions/dynamic-monorepo --base origin/main # what CI would run for your branchYou don't have to change how your CI is triggered to get value. Add one step and it checks every workflow's paths: list against your real dependency graph:
name: Path filter audit
on: pull_request
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: continuous-actions/dynamic-monorepo@v1
with:
audit: warn # or "fail" to block the PRIt annotates the workflow file and lists in the job summary:
- a package a workflow builds depends on (directly or transitively), but its folder isn't in
paths:, so a change there silently skips that workflow - a directory listed without
/**, which matches nothing inside it - a
.github/workflows/...entry that no longer exists
Run it locally with npx github:continuous-actions/dynamic-monorepo audit. On a sample of active public monorepos, about a third had at least one of these problems.
Prefer not to add an action? npx github:continuous-actions/dynamic-monorepo audit --fix writes the missing folders into those paths: lists and adds /** to bare directories, then you commit the diff. It edits only the paths lists and keeps your comments and quoting. A list it can't edit safely (YAML anchors, multi-line entries) is reported instead, along with stale workflow references, which need a human.
A project is a folder that contains one of these files. A changed file belongs to the closest project folder above it.
| File | Kind | Dependencies come from |
|---|---|---|
package.json |
node | dependencies, devDependencies, peerDependencies, optionalDependencies naming another detected package |
go.mod |
go | require and replace lines naming another detected module. A module with several package main folders is split into one project per package, linked by its own imports |
Cargo.toml with [package] |
cargo | path dependencies, and workspace = true dependencies with a path |
*.csproj, *.fsproj, *.vbproj |
dotnet | <ProjectReference Include="..."> |
pyproject.toml, setup.py |
python | local path dependencies: [tool.uv.sources], Poetry path = dependencies, and @ file: requirements |
pom.xml, build.gradle, build.gradle.kts |
maven, gradle | Maven <parent> and sibling <dependency> artifacts, Gradle project(':a:b'). Aggregator poms build nothing |
Dockerfile, Containerfile, *.Dockerfile, Dockerfile.* |
docker | — |
Chart.yaml |
helm | — |
- Names: the package name from
package.jsonorCargo.tomlwhen it is unique, otherwise the folder path (services/api). A project at the repository root is calledroot. - Lists: every project is in
buildandtest. Projects with a Dockerfile or Containerfile are also indockeranddeploy. Projects with aChart.yamlare also indeploy. - Lockfiles at the root (
package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lock,go.work.sum,Cargo.lock,poetry.lock,uv.lock,Pipfile.lock) select every project of their ecosystem. Changingyarn.lockrebuilds the node projects, not the Go ones. - Not projects: a root
package.jsonthat declaresworkspaces(or sits next topnpm-workspace.yaml), aCargo.tomlwithout[package], and anything undernode_modules,vendor,dist,build,target,out,bin,obj,testdata,fixtures,__fixtures__,__tests__or a folder starting with.. - Only committed files count. Untracked and ignored files are never scanned.
- Files outside every project, such as
.github/or root scripts, select nothing, and the summary lists them. The exception is a project at the root (for example a rootpackage.jsonwithoutworkspaces): it owns every file that isn't inside another project. - No usable comparison (for example a manual run without a
baseinput) selects every project, with a warning. The action never reports "nothing changed" just because it couldn't compare.
build ["@acme/shared","@acme/web"]
test ["@acme/shared","@acme/web"]
docker ["@acme/web"]
deploy ["@acme/web"]
paths {"@acme/shared":"packages/shared","@acme/web":"apps/web"}
dockerfiles {"@acme/web":"apps/web/Dockerfile"}
has_build true has_test true has_docker true has_deploy true all false
Lists are in dependency order: dependencies before the projects that use them. The log and job summary explain each choice:
dynamic-monorepo: 2 affected / 5 projects
Compared: 09594672b857..63ef87f87cae (pull request merge commit vs its base parent)
Detected 5 projects: 2 node, 2 go, 2 docker (no dynamic-monorepo.config.json, so projects come from marker files).
yarn.lock changes select every node project (2).
Directly affected (1):
@acme/shared — 1 changed file: packages/shared/index.ts
Transitively affected (1):
@acme/web — depends on @acme/shared (@acme/shared → @acme/web)
All outputs: docs/outputs.md.
You only need a config file to change what was detected. Create dynamic-monorepo.config.json at the repository root:
{
"$schema": "https://raw.githubusercontent.com/continuous-actions/dynamic-monorepo/v1/schema.json",
"detect": true,
"projects": {
"web": { "path": "apps/web", "dependsOn": ["proto"], "targets": ["build", "test", "deploy"] },
"proto": { "path": "proto" }
},
"global": [".github/workflows/**"],
"ignore": ["**/*.md"]
}"detect": truekeeps auto-detection on. Once a config file exists, detection is off unless you set this. Without it, list every project underprojects.- An entry under
projectsoverrides the detected project at the same path. Detected dependencies are kept and added to yours. global: files whose changes select every project.ignore: files whose changes select nothing.- You can also read dependencies from workspace files (
"infer": ["node", "go", "cargo"]), import an Nx graph, or turn every sub-folder into a project ("discover": ["services/*"]).
The full reference, including per-target exclusions such as "test-only changes don't redeploy", is in docs/configuration.md.
The build job was skipped, or failed with "Matrix vector 'project' does not contain any values". Nothing that job builds was affected. Keep the if: needs.plan.outputs.has_build == 'true' line, and read the plan job's summary to see what was compared.
A project is missing, or has an odd name. Run npx github:continuous-actions/dynamic-monorepo projects. Check that its marker file is committed and not inside a skipped folder (see What it detects). To add or rename a project, use a config file with "detect": true and an entry under projects.
A change didn't select the project I expected. The file is probably outside every project folder. Run with verbose: true to list such files. Add the file to that project's include, or to global.
Every project was selected. The warning and the reason output say why. The usual causes:
- A manual or scheduled run: there is nothing to compare with. Set the
baseinput, for examplebase: main. - A tag push, or a push whose previous commit no longer exists (force push).
- A file listed in
globalchanged, orglobal,ignoreortargetschanged in the config file. - History couldn't be fetched: with
persist-credentials: falseon a private repository, usefetch-depth: 0. See docs/git.md.
Why was this project picked? The job summary has a reason for each project. For the full detail, read the plan_file output, or run the CLI with --json.
Making it a required check. Skipped matrix jobs count as passed, but a workflow that never runs leaves a required check pending forever. Run the workflow on every pull request and require one gate job: see docs/outputs.md and the realistic example.
More than 256 projects in one list. GitHub allows 256 jobs per matrix. Use build_batches (and test_batches, deploy_batches, docker_batches): each entry is a list of projects.
strategy:
matrix:
batch: ${{ fromJSON(needs.plan.outputs.build_batches) }}
steps:
- run: for p in $BATCH; do ./build.sh "$p"; done
env:
BATCH: ${{ join(matrix.batch, ' ') }}If you set up CI with an AI coding agent, this repository has machine-readable guidance for it:
llms.txt: what the action does, when to use it, the canonical workflow and every output.- An Agent Skill (
SKILL.md) that skill-aware agents, including Claude Code, can install to set up selective monorepo CI. - A JSON Schema for the optional config, and
--jsonoutput from the CLI to check the plan.
A prompt to try: "Set up GitHub Actions for this monorepo so that only changed projects and their dependents are built, using continuous-actions/dynamic-monorepo."
Does it need a token or secrets? No. It reads the checked-out repository and runs git; the default contents: read permission is enough.
Does it work with a shallow checkout? Yes. The default actions/checkout (depth 1) is enough: missing commits are fetched by SHA. See docs/git.md.
Pull requests, pushes, merge queues? All of them. A pull request is compared with its base, a push with the previous commit, and a merge queue entry with its base. Manual and scheduled runs select every project unless you set base.
Can I use it with Nx, Turborepo, pnpm, Go workspaces or Cargo workspaces? Yes. Detection works on any of them as-is; a config file can also read an Nx graph or workspace manifests directly. See docs/configuration.md.
I already have one workflow per service with on.paths. How do I switch? Follow docs/migrating-from-path-filters.md: preview the detected projects, map each paths entry, and replace the per-service workflows with one workflow and one required check.
What if it picks too much or too little? Every decision is explained in the job summary. Add a config file to override names, dependencies, targets or global files; nothing else changes.
-
Permissions:
contents: readonly. No secrets, no GitHub API calls, no third-party network access (it only fetches missing commits from your ownorigin). -
Nothing from your repository is executed. Manifests are parsed as data with size limits and a strict JSON parser;
gitruns without a shell and with repository diff drivers disabled. Project names and paths are limited to shell-safe characters, and file names can't inject workflow commands into logs. Details: docs/decisions.md. -
What runs is what you can read:
dist/is committed and CI fails if it differs from a fresh build ofsrc/. There is one bundled runtime dependency. -
Pin it: releases are immutable. For the strictest setup, pin a full commit SHA and let Dependabot update it:
- uses: continuous-actions/dynamic-monorepo@<commit-sha> # v1.0.0
Report vulnerabilities privately: SECURITY.md.
| Input | Default | Description |
|---|---|---|
config |
dynamic-monorepo.config.json |
Config file path. Optional: without it, projects are auto-detected. |
base |
— | Ref or SHA to compare against (merge-base with HEAD). Overrides event detection. |
head |
HEAD |
Revision to compare. |
fetch |
true |
Fetch missing commits by SHA in shallow clones. |
summary |
true |
Write the job summary. |
verbose |
false |
List skipped projects, unowned files, git commands and the full plan. |
audit |
off |
warn or fail: check every workflow's on.*.paths list against the dependency graph. |
max-jobs |
256 |
Maximum entries in each *_batches output. |
working-directory |
. |
Directory of the repository to analyse. |
- Pick the comparison from the event: a pull request's merge commit against its base,
before..afterfor a push,base_sha..head_shain a merge queue, or thebaseinput. Details: docs/git.md. - Diff with
git diff --name-status -M. A renamed file counts for both its old and new project. - Find projects in the config file and, when detection is on, in the committed files. This is done at both commits, so new and deleted projects are reported in
addedanddeleted. - Assign each changed file to the deepest project folder that contains it, then apply
include/exclude,globalandignore. - Walk the dependency graph from the changed projects to everything that depends on them, and sort the result in dependency order.
- Write outputs, a job summary and a plan file with a reason for every project.
A single bundled JavaScript file with nothing to install. Planning 1,000 projects with 1,000 changed files takes about 105 ms end to end on a hosted ubuntu-latest runner. Numbers: docs/benchmarks.md.
- Dependencies come from manifests, not from source imports. Docker and Helm projects get no dependency edges; add them with
dependsOn. - A Dockerfile in its own sub-folder (
services/api/docker/Dockerfile) makes that sub-folder a separate project. - Dependency edges are project-level, not per target.
MIT © continuous-actions
