Pada monorepo, masalah CI yang paling sering muncul bukan sekadar durasi build yang lama, tetapi pekerjaan yang tidak perlu: setiap commit memicu lint, test, dan build untuk seluruh aplikasi dan library, padahal perubahan sering hanya menyentuh sebagian kecil proyek. CI monorepo dengan Nx menyelesaikan ini dengan memetakan dependensi antarproyek, menghitung proyek yang terdampak perubahan, lalu menjalankan task yang relevan saja.

Jika diterapkan dengan benar, pendekatan ini membuat pipeline lebih cepat tanpa mengorbankan akurasi. Kuncinya ada pada empat hal: project graph yang benar, penggunaan affected commands, cache yang deterministik, dan parallel execution yang terukur. Artikel ini fokus pada implementasi praktis yang bisa langsung diadopsi tim.

Mengapa CI monorepo sering lambat

Monorepo memberi keuntungan pada konsistensi dependency, shared library, dan refactor lintas proyek. Namun di sisi CI, monorepo juga memperbesar ruang lingkup eksekusi jika pipeline tidak cerdas.

Gejala umum

  • Setiap pull request menjalankan lint/test/build untuk semua aplikasi.
  • Waktu tunggu merge meningkat meskipun perubahan hanya pada satu library kecil.
  • Runner CI sering habis karena banyak job redundan.
  • Cache ada, tetapi hit rate rendah karena input task tidak stabil.

Penyebab utamanya

  • Tidak ada pemetaan dependensi antarproyek, sehingga pipeline tidak tahu proyek mana yang benar-benar terdampak.
  • Task dijalankan dari root secara global, misalnya satu script lint atau test untuk seluruh repo.
  • Cache tidak deterministik, misalnya output bergantung pada waktu, environment acak, atau file yang tidak seharusnya ikut menjadi input.
  • Base branch atau commit range salah, sehingga perhitungan affected menjadi tidak akurat.

Nx mengatasi masalah ini dengan pendekatan berbasis graph: perubahan file dipetakan ke proyek yang memiliki file tersebut, lalu graph dependensi dipakai untuk menghitung proyek turunan yang ikut terdampak.

Konsep inti Nx untuk CI monorepo

1. Project graph

Project graph adalah representasi relasi antar aplikasi dan library di dalam monorepo. Contoh sederhana:

apps/web -> libs/ui, libs/auth, libs/api-client
apps/admin -> libs/ui, libs/api-client
libs/auth -> libs/shared-types
libs/api-client -> libs/shared-types

Jika ada perubahan di libs/shared-types, maka libs/auth, libs/api-client, apps/web, dan apps/admin bisa ikut terdampak. Jika perubahan hanya di apps/web, maka apps/admin tidak perlu disentuh.

Inilah alasan graph harus akurat. Jika dependensi internal tidak terbaca oleh Nx, task affected bisa terlalu sedikit atau terlalu banyak.

2. Affected commands

Nx menyediakan perintah seperti:

nx affected -t lint
nx affected -t test
nx affected -t build

Perintah ini membandingkan perubahan antara dua titik Git, biasanya base dan head, lalu hanya menjalankan target pada proyek yang terpengaruh. Dalam CI, ini jauh lebih efisien dibanding:

nx run-many -t lint --all
nx run-many -t test --all
nx run-many -t build --all

Kapan pakai affected? Saat ingin mengoptimalkan pull request, branch feature, atau validasi sebelum merge. Untuk workflow tertentu seperti nightly build, release, atau validasi penuh sebelum deploy besar, menjalankan seluruh proyek masih masuk akal.

3. Cache lokal dan remote

Nx menyimpan hasil task berdasarkan input yang relevan. Jika input sama, task tidak perlu dieksekusi ulang; Nx cukup mengambil hasil dari cache.

  • Local cache: cepat untuk pengembangan lokal dan kadang berguna di runner yang persisten.
  • Remote cache: penting untuk CI karena hasil task dari satu runner bisa dipakai runner lain atau workflow berikutnya.

Cache efektif bila task bersifat deterministik: input yang sama menghasilkan output yang sama. Jika tidak, cache justru menjadi sumber bug yang sulit dilacak.

4. Parallel execution

Nx bisa menjalankan task secara paralel selama dependency graph mengizinkan. Ini mempercepat CI tanpa harus memecah pipeline menjadi terlalu banyak job kecil. Namun paralelisme harus disesuaikan dengan kapasitas CPU/memori runner dan karakter task. Test end-to-end yang berat, misalnya, tidak selalu cocok dijalankan terlalu paralel.

Contoh struktur monorepo yang cocok untuk Nx

Struktur berikut cukup umum dan mudah dipetakan ke graph yang jelas:

repo/
├─ apps/
│  ├─ web/
│  └─ admin/
├─ libs/
│  ├─ ui/
│  ├─ auth/
│  ├─ api-client/
│  └─ shared-types/
├─ tools/
├─ nx.json
├─ package.json
├─ tsconfig.base.json
└─ .github/
   └─ workflows/
      └─ ci.yml

Pemisahan apps dan libs membantu tim membedakan entry point aplikasi dari komponen yang bisa dipakai ulang. Dari sisi CI, ini membuat dependensi internal lebih mudah dianalisis.

Konfigurasi dasar Nx untuk pipeline cepat

Konfigurasi Nx bisa berbeda sesuai plugin dan stack yang dipakai. Yang penting untuk CI adalah memastikan target seperti lint, test, dan build terdefinisi konsisten, serta input cache disusun dengan benar.

Contoh nx.json sederhana

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.*",
      "!{projectRoot}/**/*.test.*",
      "!{projectRoot}/**/*.md"
    ],
    "sharedGlobals": [
      "{workspaceRoot}/package.json",
      "{workspaceRoot}/nx.json",
      "{workspaceRoot}/tsconfig.base.json"
    ]
  },
  "targetDefaults": {
    "lint": {
      "cache": true,
      "inputs": ["default"]
    },
    "test": {
      "cache": true,
      "inputs": ["default"]
    },
    "build": {
      "cache": true,
      "inputs": ["production", "^production"]
    }
  }
}

Intinya:

  • namedInputs mendefinisikan file apa saja yang memengaruhi hasil task.
  • build biasanya memakai input yang lebih ketat daripada lint/test agar file non-produksi tidak sering menginvalidasi cache.
  • ^production berarti target build juga dipengaruhi input produksi dari dependency proyeknya.

Contoh package.json scripts

{
  "scripts": {
    "ci:lint": "nx affected -t lint --base=origin/main --head=HEAD --parallel=3",
    "ci:test": "nx affected -t test --base=origin/main --head=HEAD --parallel=3",
    "ci:build": "nx affected -t build --base=origin/main --head=HEAD --parallel=2"
  }
}

Angka paralel tidak harus tinggi. Mulailah dari nilai konservatif, lalu naikkan sambil memantau CPU, memori, dan stabilitas runner.

Menggunakan affected build dengan benar di CI

Perintah affected bergantung pada dua referensi Git: base dan head. Jika range salah, hasilnya juga salah.

Praktik yang aman

  • Untuk pull request, pakai base branch target PR, misalnya origin/main.
  • Pastikan checkout Git mengambil history yang cukup; shallow clone terlalu dangkal bisa membuat base commit tidak tersedia.
  • Jangan hardcode asumsi bahwa semua event punya konteks branch yang sama.

Contoh perintah eksplisit

nx affected -t lint --base=origin/main --head=HEAD
nx affected -t test --base=origin/main --head=HEAD
nx affected -t build --base=origin/main --head=HEAD

Jika Anda ingin mengecek proyek apa saja yang terdampak saat debugging, jalankan:

nx show projects --affected --base=origin/main --head=HEAD

Perintah ini berguna untuk memastikan graph dan range Git bekerja seperti yang diharapkan sebelum menyalahkan cache atau plugin.

Contoh pipeline GitHub Actions untuk Nx

Contoh berikut menunjukkan pipeline yang:

  • melakukan checkout dengan history yang cukup,
  • menginstal dependency,
  • memanfaatkan cache package manager,
  • menjalankan lint, test, dan build hanya untuk proyek yang terdampak.
name: ci

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  affected:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Set Nx base/head
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            echo "NX_BASE=origin/${{ github.base_ref }}" >> $GITHUB_ENV
            echo "NX_HEAD=HEAD" >> $GITHUB_ENV
          else
            echo "NX_BASE=HEAD~1" >> $GITHUB_ENV
            echo "NX_HEAD=HEAD" >> $GITHUB_ENV
          fi

      - name: Print affected projects
        run: npx nx show projects --affected --base=$NX_BASE --head=$NX_HEAD

      - name: Lint affected
        run: npx nx affected -t lint --base=$NX_BASE --head=$NX_HEAD --parallel=3

      - name: Test affected
        run: npx nx affected -t test --base=$NX_BASE --head=$NX_HEAD --parallel=3

      - name: Build affected
        run: npx nx affected -t build --base=$NX_BASE --head=$NX_HEAD --parallel=2

Pipeline ini sengaja sederhana. Untuk banyak tim, satu job seperti ini sudah cukup memberi perbaikan signifikan dibanding pipeline yang selalu membangun seluruh monorepo.

Memisahkan job lint, test, dan build

Jika ingin hasil lebih cepat terbaca atau ingin memanfaatkan runner terpisah, Anda bisa memecah job. Trade-off-nya, dependency install dan setup bisa berulang. Solusi yang umum adalah tetap memakai remote cache Nx agar hasil task yang sudah selesai bisa digunakan job lain.

Menambahkan remote cache

Local cache membantu di mesin developer, tetapi untuk CI manfaat terbesar datang dari remote cache. Dengan remote cache, hasil lint/test/build yang pernah dikerjakan bisa diambil ulang oleh workflow berikutnya selama input tidak berubah.

Pemilihan backend remote cache bergantung pada kebijakan tim. Yang penting adalah:

  • cache dibagikan antar runner CI,
  • akses aman,
  • retensi dan ukuran cache dipantau,
  • mudah dinonaktifkan saat troubleshooting.

Catatan: Cache package manager dan cache Nx adalah dua hal berbeda. Cache npm/pnpm/yarn mempercepat instalasi dependency, sedangkan cache Nx menyimpan hasil task seperti lint, test, atau build. Keduanya saling melengkapi, bukan saling menggantikan.

Strategi invalidasi cache yang masuk akal

Cache yang terlalu agresif bisa menghasilkan hasil usang. Cache yang terlalu sensitif justru jarang hit. Tujuannya adalah menentukan input yang cukup untuk menjamin kebenaran, tetapi tidak berlebihan.

Yang biasanya perlu menjadi input

  • File sumber proyek.
  • Konfigurasi workspace seperti nx.json dan tsconfig.base.json.
  • Lockfile atau konfigurasi package manager bila dependency memengaruhi hasil.
  • Konfigurasi tool seperti ESLint, Jest, TypeScript, bundler, jika dipakai oleh target terkait.

Yang sering membuat cache terlalu sering miss

  • Memasukkan file dokumentasi atau changelog ke input build produksi.
  • Memasukkan file yang berubah setiap pipeline, seperti artefak generated yang tidak stabil.
  • Mengikutkan environment variable yang tidak relevan.

Yang berbahaya jika tidak ikut menjadi input

  • Perubahan konfigurasi compiler atau linter.
  • Perubahan dependency versi pada lockfile.
  • Script custom di folder tools/ yang memengaruhi output build.

Prinsip sederhananya: setiap faktor yang bisa mengubah hasil task harus tercermin dalam input cache.

Fallback saat cache miss

Cache miss bukan error. Cache miss hanya berarti task harus benar-benar dijalankan. Pipeline yang sehat harus tetap benar dan selesai walaupun tidak mendapat cache sama sekali.

Praktik fallback yang baik

  • Pastikan semua task bisa dieksekusi penuh tanpa asumsi cache tersedia.
  • Jangan jadikan remote cache sebagai satu-satunya sumber artefak yang wajib ada.
  • Jika layanan cache eksternal gagal, pipeline sebaiknya tetap lanjut dengan eksekusi normal.

Untuk debugging, langkah awal yang aman adalah menjalankan ulang task terkait tanpa mengandalkan cache atau dengan logging lebih detail, lalu membandingkan input dan output yang dihasilkan.

Metrik yang perlu dipantau

Optimasi CI tidak cukup dinilai dari “terasa lebih cepat”. Tim perlu memantau metrik yang menunjukkan apakah pendekatan affected dan cache benar-benar bekerja.

Metrik inti

  • Total durasi workflow: waktu dari job mulai hingga selesai.
  • Durasi per target: lint, test, build.
  • Jumlah proyek affected: apakah masuk akal untuk tipe perubahan tertentu.
  • Cache hit rate: berapa banyak task yang diambil dari cache.
  • Frekuensi fallback/full rebuild: seberapa sering pipeline terpaksa menjalankan semua task.
  • Kegagalan flakey: terutama pada test yang paralel atau task dengan output tidak stabil.

Interpretasi cepat

  • Jika proyek affected selalu hampir seluruh repo, kemungkinan graph terlalu rapat atau boundary antarlib terlalu buruk.
  • Jika cache hit rate rendah padahal perubahan kecil, kemungkinan input cache terlalu luas atau ada faktor nondeterministik.
  • Jika build sering berbeda antar runner, periksa environment, timezone, locale, path, dan file generated.

Jebakan umum pada CI monorepo dengan Nx

1. Dependency graph keliru

Ini masalah paling mahal. Jika library memakai import path yang tidak dikenali, script custom mengakses proyek lain diam-diam, atau generator kode membuat dependensi tak terlihat, maka affected calculation bisa salah.

Tips:

  • Gunakan import boundary yang konsisten.
  • Hindari referensi lintas folder secara acak tanpa alias atau konfigurasi yang dikenali toolchain.
  • Audit graph saat menambah plugin, code generation, atau script di tools/.

2. Cache tidak deterministik

Contohnya build menulis timestamp ke file output, test bergantung urutan acak, atau task membaca environment variable yang berubah setiap run. Hasilnya bisa cache hit yang menyesatkan atau miss yang terlalu sering.

Tips:

  • Hilangkan timestamp atau metadata runtime dari artefak jika tidak diperlukan.
  • Stabilkan urutan input file.
  • Dokumentasikan environment variable yang memang memengaruhi output.

3. Salah menentukan base/head

Di GitHub Actions, event pull_request dan push punya konteks berbeda. Jika base salah, daftar proyek affected bisa kosong atau terlalu luas.

Tips:

  • Selalu cetak nilai NX_BASE dan NX_HEAD saat debugging.
  • Gunakan fetch-depth: 0 jika sering mengalami masalah commit range.

4. Paralelisme terlalu agresif

Menaikkan --parallel tidak selalu mempercepat. Runner kecil bisa kehabisan memori, test jadi flakey, dan total waktu malah memburuk.

Tips:

  • Sesuaikan paralelisme per target.
  • Mulai dari rendah, ukur, lalu naikkan bertahap.

5. Mengandalkan affected untuk semua skenario

Affected sangat cocok untuk PR. Tetapi untuk release validation, migrasi dependency besar, perubahan tooling global, atau audit keamanan, validasi penuh tetap relevan.

Langkah implementasi yang bisa langsung diadopsi tim

  1. Rapikan struktur repo menjadi apps/ dan libs/ dengan boundary yang jelas.
  2. Pastikan target lint, test, build konsisten di seluruh proyek.
  3. Definisikan named inputs agar cache build tidak terlalu sensitif terhadap file non-produksi.
  4. Ganti job global dari run all ke nx affected untuk pull request.
  5. Aktifkan remote cache agar hasil task bisa dipakai lintas runner.
  6. Tentukan nilai parallel yang realistis berdasarkan kapasitas runner.
  7. Tambahkan logging proyek affected di pipeline agar mudah diaudit.
  8. Pantau cache hit rate dan durasi workflow selama beberapa minggu pertama.
  9. Jadwalkan full run berkala untuk memverifikasi tidak ada dependensi tersembunyi yang lolos dari affected calculation.

Penutup

CI monorepo dengan Nx efektif bukan karena sekadar menambahkan cache, tetapi karena Nx menggabungkan dependency graph, affected build, dan eksekusi paralel menjadi workflow yang lebih selektif. Hasil terbaik datang saat tim memperlakukan graph sebagai sumber kebenaran, menyusun input cache dengan hati-hati, dan tetap menyiapkan fallback yang aman saat cache miss.

Jika Anda ingin perbaikan yang langsung terasa, mulai dari hal ini: pastikan nx affected berjalan dengan base/head yang benar, aktifkan cache untuk target utama, lalu ukur berapa banyak proyek yang benar-benar terdampak pada setiap PR. Dari situ, optimasi berikutnya akan jauh lebih terarah.