Skip to content
All articles
Engineering

How We Fixed the AI Coding CI Bottleneck: 45% Less CI Time

AI coding agents sped up pull requests and made CI the slow part of the day. The pipeline now follows the diff, and that cut the time CI consumed by 45%.

Rushit Jivani6 min read

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
A web-only pull request no longer pays for mobile lint or a Functions build. Untouched apps stay idle.

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:check

The 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
Runs are grouped by pull request number. A new push cancels the run that is already obsolete instead of letting two emulator suites fight over the same change.

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

  1. 1pnpm install --frozen-lockfilecheapThe lockfile is the resolution. CI does not re-resolve the tree an agent just edited.
  2. 2Build shared packagescheapWorkspace packages the functions depend on.
  3. 3ESLint on the PR diffcheapeslint-plugin-diff in CI mode: a 10-file change does not re-lint the whole functions tree.
  4. 4OpenAPI drift checkcheapIf a handler and the published spec disagree, the job fails here.
  5. 5Firebase emulator + Jestthe expensive stepJava, the emulator boot, and the suite — minus the long groups and activity paths.
A style error returns in the cheap steps and never pays for Java and the emulator boot.
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/functions

The 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 1

Each 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: 1

Later 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 groups and activity suites 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

No bigger runners, no pnpm store cache — the cut is the sum of work that stopped running.

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.

  • #CICD
  • #DevOps
  • #AIDevelopment
  • #pnpm
  • #GitHubActions
Rushit Jivani headshot

Written by

Rushit Jivani

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