The short version
- AI agents raised the commit rate and most of their pull requests touch one app, but CI treated every PR as if it touched all three.
- The pipeline now follows the diff: untouched apps stay idle, lint looks at the PR, formatting is settled pre-commit, and the emulator runs only after cheap checks pass.
- Together those skips cut the time CI consumed by 45%, with no bigger runners and no pnpm store cache.
AI coding agents sped up pull requests. They also made CI the slow part of the day. A small change in one app still installed the whole monorepo, linted trees the agent never touched, and, on the backend, booted a Firebase emulator before anyone knew the diff was valid.
The pipeline now follows the diff. Untouched apps stay idle. Lint looks at the pull request. Formatting is settled before GitHub Actions starts. The emulator job runs only after the cheap checks pass. Together, that cut the time CI consumed by 45%.
The mess: three apps, one blunt pipeline
The repo is a pnpm workspace with three apps that fail in different ways: an Expo app, a Vite web app, and Firebase Cloud Functions. Agents raise the commit rate, and a lot of those commits touch one workspace. Every pull request was still treated as if it touched all three.
That showed up as four kinds of waste:
- Untouched work. A web-only change still paid for mobile lint and for a full Functions build plus emulator tests.
- Full-repo lint. Backend ESLint walked the whole functions tree, including files the agent did not change.
- Formatting noise. Agents emit slightly different Prettier output on reruns. CI was acting as a second formatter and failing on style.
- Stale runs. A new push on the same pull request left the previous run going, so two copies of the emulator suite fought over the same change.
Bigger runners would have hidden this. The 45% came from deleting work the diff did not need.
Three rules that shipped
Skip untouched workspaces
A label job diffs the PR against base and tags frontend, backend or website. App jobs run only for their label.
Lint and format the diff
Backend ESLint uses eslint-plugin-diff in CI mode. Prettier runs pre-commit through lint-staged, so Actions sees one style.
Fail before the emulator
Lint first, Firebase emulator after. The long Jest suites stay off the pull-request path.
Skip workspaces the pull request did not touch. The workflow only starts when files under apps/frontend, apps/backend, or apps/website change. A short first job diffs the pull request against the base branch and adds a frontend, backend, or website label. Each app job runs when that label is already on the pull request.
Lint and format the diff. Backend ESLint extends eslint-plugin-diff in CI mode, and the job points it at the base branch. The pre-commit hook runs Prettier through lint-staged in each workspace, so Actions sees one style. The web job still runs Prettier, and only on the JS, JSX, TS, TSX, and JSON files in that pull request.
Fail before the emulator. Backend lint runs first. The Firebase emulator starts only after that. The pull-request test script ignores the groups and activity Jest paths, which are the long suites. Those stay off the path that has to finish before a review.
The pipeline follows the diff
pull request
3 files changed, all in apps/website
label job
git diff against base → adds label: website. No pnpm, no Node.
- skippedmobile — lint + testnot in the diff
- runsweb — changed files onlylint → prettier on the PR's files → test → ts:check
- skippedfunctions — emulatornot in the diff
The pull-request workflow
This is the shape of the CI workflow, with secrets and status callbacks removed.
name: CI
on:
pull_request:
types: [opened, reopened, synchronize]
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "apps/website/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Fetch base branch
run: git fetch origin ${{ github.event.pull_request.base.ref }}
- name: Label the workspaces this PR touches
id: changes
run: |
files=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} ${{ github.sha }})
labels=""
echo "$files" | grep -E '^apps/frontend/' && labels="${labels}frontend,"
echo "$files" | grep -E '^apps/backend/' && labels="${labels}backend,"
echo "$files" | grep -E '^apps/website/' && labels="${labels}website,"
echo "labels=${labels%,}" >> "$GITHUB_OUTPUT"
- name: Add labels
if: steps.changes.outputs.labels != ''
uses: actions/github-script@v9
with:
script: |
const labels = '${{ steps.changes.outputs.labels }}'.split(',');
await github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels
});
web:
needs: label
if: |
github.event.pull_request.labels[0] == null ||
contains(github.event.pull_request.labels.*.name, 'website')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22.23.1"
- run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm --filter <web> run lint:check
- name: Prettier on changed web files only
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}
FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} ${{ github.sha }} \
| grep '^apps/website/.*\.\(js\|jsx\|ts\|tsx\|json\)$' || true)
if [ -n "$FILES" ]; then
pnpm exec prettier --check $FILES
fi
- run: pnpm --filter <web> run ci_test
- run: pnpm --filter <web> run ts:checkThe label job checks out the repo and diffs it. It does not install pnpm or Node. That job used to pay for a full install so it could add a label.
concurrency is grouped by pull request number, with cancel-in-progress: true. A new push cancels the run that is already obsolete. The staging deploy workflow does the same thing, grouped by branch, so two deploys of main do not run together.
cancel-in-progress — try it
- CI run #1running
Backend: lint the diff, then boot the emulator
The functions job is the expensive one. It installs Java for the emulator, builds the shared packages, lints, checks that the OpenAPI spec still matches the generated client, and only then starts Firebase.
Fail before the emulator
- 1pnpm install --frozen-lockfilecheapThe lockfile is the resolution. CI does not re-resolve the tree an agent just edited.
- 2Build shared packagescheapWorkspace packages the functions depend on.
- 3ESLint on the PR diffcheapeslint-plugin-diff in CI mode: a 10-file change does not re-lint the whole functions tree.
- 4OpenAPI drift checkcheapIf a handler and the published spec disagree, the job fails here.
- 5Firebase emulator + Jestthe expensive stepJava, the emulator boot, and the suite — minus the long groups and activity paths.
backend:
needs: label
if: |
github.event.pull_request.labels[0] == null ||
contains(github.event.pull_request.labels.*.name, 'backend')
runs-on: ubuntu-latest
env:
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22.23.1"
- uses: actions/setup-java@v5
with:
java-version: "21"
distribution: temurin
- run: pnpm install --frozen-lockfile
- name: Build shared packages
run: pnpm run build:packages
working-directory: apps/backend/functions
- name: Fetch base branch for diff lint
run: git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }}
- name: ESLint on the PR diff
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event.pull_request.base.ref }}
run: pnpm --filter <functions> run lint
working-directory: apps/backend/functions
- name: OpenAPI drift check
run: pnpm --filter <api-client> run openapi:check
- name: Emulator tests, without the long suites
run: firebase emulators:exec --project=<test-project> "pnpm run test:no-groups-activity"
working-directory: apps/backend/functionsThe ESLint config extends plugin:diff/ci. With ESLINT_PLUGIN_DIFF_COMMIT set to the base branch, a 10-file agent change does not re-lint the rest of the functions tree. A local --fix config extends plugin:diff/staged for the same idea on staged files. The hook itself runs Prettier:
pnpm --filter <mobile> run lint-frontend || exit 1
pnpm --filter <web> run lint-webapp || exit 1
pnpm --filter <functions> run lint-backend || exit 1Each of those scripts is lint-staged, and lint-staged runs prettier --write on the staged JS and TS. Agents can be sloppy about commas and quotes. The commit normalizes that before CI sees it.
test:no-groups-activity is Jest with --testPathIgnorePatterns=groups and --testPathIgnorePatterns=activity, under --max-old-space-size=4096. The pull request still runs the functions suite against the emulator. It leaves the two slow suites off that command.
Installs use pnpm install --frozen-lockfile on Node 22.23.1. The lockfile is the resolution. CI does not re-resolve the tree an agent just edited.
Deploy: build the shared packages once
Prod and staging used to rebuild workspace packages inside every deploy job. One job installs, runs pnpm run build, and uploads the result:
- uses: actions/upload-artifact@v7
with:
name: built-packages
path: |
packages/*/dist
packages/*/package.json
packages/*/tsconfig.json
retention-days: 1Later jobs download that artifact. The web deploy and the functions deploy consume the same build.
These workflows leave the pnpm store uncached. The 45% comes from skipping jobs, skipping files, and cancelling stale runs. A store cache is a separate change.
What went wrong first
Prettier checks sat on the mobile job and the functions job in CI, on top of the pre-commit hook. Agents reformatted the same file in ways that were equivalent and still red. Those two checks came out of CI. The web job kept a check, limited to files the pull request changed, so a missed hook still fails there.
The label job also installed Node and the workspace when it only needed git diff and the GitHub API. That job now checks out the repo, fetches the base branch, and adds labels.
Every integration suite on every push was the other miss. The groups and activity suites stay off the pull-request command. A lint failure returns before emulators:exec, so a style error does not pay for Java and the emulator.
A few smaller cuts
- Cancel in progress on the pull request workflow and on the staging deploy workflow. A follow-up push replaces the run.
- Order inside the functions job: install, build shared packages, diff lint, OpenAPI check, then the emulator. A lint failure never reaches the slow step.
- A fixed Node version so an engine bump on a laptop does not silently move CI.
Where the 45% comes from
The cut is the sum of work that stopped running:
- After the first push, a one-app pull request no longer waits on the other two app jobs.
- Backend ESLint covers the diff, so a small agent change does not re-lint the functions tree.
- Stale runs die when a new commit lands on the same pull request.
- The long
groupsandactivitysuites stay off the pull-request path. - Deploy builds the shared packages once and reuses the artifact.
Where the 45% comes from
before — every PR treated as if it touched all three apps
after — the pipeline follows the diff-45% CI time
That is 45% less CI time on this pipeline. The number is the result of those skips, measured on the runs this workflow actually executes.
FAQ
Prettier runs in the pre-commit hook on each workspace. CI lints behavior and, on the web app, confirms the changed files are already formatted. The mobile app and the functions package keep formatting on the hook.
Agents will keep opening small, frequent pull requests. The pipeline has to get cheaper per diff. Skip the apps that did not change, lint the lines that did, and start the emulator only when the cheap checks have already passed.

Written by
Rushit Jivani
Senior Software Architect & AI Application Engineer. Architecture that shows up in crash rate, performance and how fast the team ships.