Pipeline CI monorepo berbasis perubahan bertujuan mengurangi pekerjaan yang tidak perlu: jika perubahan hanya menyentuh packages/ui, pipeline tidak perlu selalu membangun semua service backend, worker, dan aplikasi lain. Pendekatan yang aman bukan sekadar memfilter path, tetapi menghitung proyek yang terdampak berdasarkan file yang berubah dan dependency graph internal.

Intinya, CI perlu menjawab tiga pertanyaan: file apa yang berubah, proyek mana yang memiliki file tersebut, dan proyek lain mana yang bergantung pada proyek tersebut. Dari jawaban itu, pipeline dapat membuat matrix dinamis untuk menjalankan lint, test, dan build hanya pada target yang relevan, dengan fallback full build untuk perubahan global atau branch utama.

Masalah yang Diselesaikan oleh CI Berbasis Perubahan

Monorepo sering berisi banyak aplikasi, paket shared, konfigurasi lint, skema API, dan pipeline deployment. Tanpa seleksi target, setiap pull request dapat memicu lint, test, dan build seluruh repository. Ini boros waktu, runner, cache, dan perhatian developer.

Namun seleksi yang terlalu agresif juga berbahaya. Risiko terbesarnya adalah false negative: CI melewatkan proyek yang sebenarnya terdampak, sehingga bug baru lolos karena tidak diuji. Karena itu, desain pipeline harus konservatif: jika tidak yakin, jalankan lebih banyak target atau fallback ke full build.

Prinsip aman: selective build boleh menghemat, tetapi tidak boleh menjadi satu-satunya jaring pengaman. Branch utama, perubahan konfigurasi global, dan jadwal berkala tetap sebaiknya menjalankan full build.

Model Dasar: Changed Files, Path Filter, dan Dependency Graph

Contoh struktur monorepo

Misalkan repository memiliki struktur seperti ini:

apps/api
apps/web
apps/worker
packages/ui
packages/config
packages/logger
ci/affected.mjs
package.json
package-lock.json

Relasi antarproyek dapat digambarkan sebagai dependency graph internal:

{
  "apps/api": ["packages/config", "packages/logger"],
  "apps/web": ["packages/ui", "packages/config"],
  "apps/worker": ["packages/config", "packages/logger"],
  "packages/ui": ["packages/config"],
  "packages/config": [],
  "packages/logger": []
}

Jika packages/logger berubah, proyek yang terdampak bukan hanya packages/logger, tetapi juga apps/api dan apps/worker. Jika apps/web berubah, biasanya cukup menjalankan target untuk apps/web, kecuali ada aturan lain seperti integrasi end-to-end.

Mengapa path filter saja tidak cukup

Path filter berguna untuk memetakan file ke proyek pemiliknya. Contoh: perubahan pada packages/ui/Button.tsx berarti packages/ui berubah. Tetapi path filter tidak tahu siapa yang menggunakan packages/ui. Tanpa graph, pipeline bisa hanya menguji package shared dan melewatkan aplikasi yang mengonsumsinya.

Dependency graph menyelesaikan masalah ini dengan menghitung reverse dependency closure: semua proyek yang secara langsung atau tidak langsung bergantung pada proyek yang berubah.

Strategi Perhitungan Affected Projects

Algoritme praktisnya adalah:

  1. Ambil daftar file yang berubah terhadap base commit.
  2. Cek apakah ada perubahan global, misalnya lockfile, konfigurasi CI, konfigurasi compiler, atau file build tooling.
  3. Jika ada perubahan global, tandai semua proyek sebagai affected.
  4. Jika tidak, petakan file ke proyek berdasarkan root path.
  5. Hitung semua proyek yang bergantung pada proyek yang berubah.
  6. Keluarkan hasil sebagai JSON matrix untuk CI.

Contoh script sederhana untuk menghitung affected projects

Contoh berikut memakai Node.js tanpa bergantung pada tool monorepo tertentu. Dalam sistem produksi, Anda bisa menggantinya dengan Nx, Turborepo, Bazel, Pants, atau script internal selama prinsipnya sama: hasil harus konservatif dan dapat diaudit.

// ci/affected.mjs
import fs from 'node:fs';

const changedFileList = process.env.CHANGED_FILES || 'changed-files.txt';
const changedFiles = fs.readFileSync(changedFileList, 'utf8')
  .split('\n')
  .map((line) => line.trim())
  .filter(Boolean);

const projects = {
  'apps/api': { root: 'apps/api', workspace: 'api' },
  'apps/web': { root: 'apps/web', workspace: 'web' },
  'apps/worker': { root: 'apps/worker', workspace: 'worker' },
  'packages/ui': { root: 'packages/ui', workspace: 'ui' },
  'packages/config': { root: 'packages/config', workspace: 'config' },
  'packages/logger': { root: 'packages/logger', workspace: 'logger' }
};

const deps = {
  'apps/api': ['packages/config', 'packages/logger'],
  'apps/web': ['packages/ui', 'packages/config'],
  'apps/worker': ['packages/config', 'packages/logger'],
  'packages/ui': ['packages/config'],
  'packages/config': [],
  'packages/logger': []
};

const globalPatterns = [
  'package-lock.json',
  'package.json',
  '.github/workflows/',
  'ci/',
  'tsconfig.base.json',
  'eslint.config.',
  'Dockerfile.base'
];

function isGlobalChange(file) {
  return globalPatterns.some((pattern) => file === pattern || file.startsWith(pattern));
}

function ownerOf(file) {
  return Object.entries(projects)
    .find(([, meta]) => file === meta.root || file.startsWith(meta.root + '/'))?.[0];
}

function reverseGraph(graph) {
  const reversed = Object.fromEntries(Object.keys(graph).map((name) => [name, []]));
  for (const [project, dependencies] of Object.entries(graph)) {
    for (const dependency of dependencies) {
      reversed[dependency] ??= [];
      reversed[dependency].push(project);
    }
  }
  return reversed;
}

const allProjects = Object.keys(projects);
let affected = new Set();

if (changedFiles.some(isGlobalChange)) {
  affected = new Set(allProjects);
} else {
  for (const file of changedFiles) {
    const owner = ownerOf(file);
    if (owner) affected.add(owner);
  }

  const reversed = reverseGraph(deps);
  const queue = [...affected];

  while (queue.length > 0) {
    const current = queue.shift();
    for (const dependent of reversed[current] || []) {
      if (!affected.has(dependent)) {
        affected.add(dependent);
        queue.push(dependent);
      }
    }
  }
}

const include = [...affected].sort().map((project) => ({
  project,
  workspace: projects[project].workspace
}));

const matrix = JSON.stringify({ include });
const hasChanges = include.length > 0 ? 'true' : 'false';

if (process.env.GITHUB_OUTPUT) {
  fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${matrix}${String.fromCharCode(10)}`);
  fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_changes=${hasChanges}${String.fromCharCode(10)}`);
}

console.log(JSON.stringify({ changedFiles, affected: include }, null, 2));

Script ini sengaja memperlakukan beberapa file sebagai perubahan global. Misalnya perubahan package-lock.json atau konfigurasi CI dapat memengaruhi banyak proyek, sehingga lebih aman menjalankan semua target.

Contoh Implementasi dengan GitHub Actions

Workflow berikut memiliki tiga bagian utama: job plan untuk menghitung affected projects, job quality untuk menjalankan lint/test/build secara matrix, dan job opsional untuk mengunggah artifact build. Sesuaikan perintah npm run ... --workspace dengan package manager dan script repository Anda.

name: monorepo-ci

on:
  pull_request:
  push:
    branches: [main]

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.affected.outputs.matrix }}
      has_changes: ${{ steps.affected.outputs.has_changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Determine changed files
        shell: bash
        run: |
          if [ '${{ github.event_name }}' = 'pull_request' ]; then
            BASE_SHA='${{ github.event.pull_request.base.sha }}'
          else
            BASE_SHA='${{ github.event.before }}'
          fi

          git diff --name-only $BASE_SHA ${{ github.sha }} > changed-files.txt
          cat changed-files.txt

      - name: Compute affected projects
        id: affected
        env:
          CHANGED_FILES: changed-files.txt
        run: node ci/affected.mjs

  quality:
    needs: plan
    if: needs.plan.outputs.has_changes == 'true'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint --workspace ${{ matrix.workspace }}

      - name: Test
        run: npm test --workspace ${{ matrix.workspace }}

      - name: Build
        run: npm run build --workspace ${{ matrix.workspace }}

      - name: Upload build artifact
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.workspace }}
          path: |
            apps/${{ matrix.workspace }}/dist
            packages/${{ matrix.workspace }}/dist
          if-no-files-found: ignore

Ada beberapa detail penting pada contoh ini:

  • fetch-depth: 0 membantu memastikan base commit tersedia untuk git diff. Jika clone terlalu dangkal, daftar changed files bisa salah atau gagal dihitung.
  • Job plan memisahkan perhitungan dari eksekusi. Ini membuat hasil affected projects mudah dibaca di log dan dapat dipakai oleh job lain.
  • fail-fast: false membuat semua proyek dalam matrix tetap berjalan meskipun salah satu gagal, sehingga developer mendapat daftar kegagalan yang lebih lengkap.
  • Workflow menggunakan has_changes agar job matrix tidak perlu berjalan ketika perubahan tidak menyentuh proyek apa pun, misalnya hanya dokumentasi tertentu jika memang dikecualikan.

Catatan: struktur output artifact pada contoh belum tentu cocok untuk semua repository. Untuk monorepo besar, lebih baik setiap project memiliki metadata lokasi artifact sendiri daripada menebak path dari nama workspace.

Cache Dependency dan Artifact Reuse

Cache dependency

Cache dependency mengurangi waktu instalasi paket, tetapi tidak boleh dianggap sebagai sumber kebenaran. Cache harus bisa dihapus kapan saja tanpa mengubah hasil build. Kunci cache sebaiknya berasal dari lockfile dan platform runner, bukan hanya nama branch.

Untuk npm, actions/setup-node dapat menangani cache package manager berdasarkan lockfile. Untuk pnpm, Yarn, Gradle, Maven, atau Go, prinsipnya sama: cache direktori unduhan atau build cache yang aman, bukan output final yang belum divalidasi.

Kesalahan umum adalah cache node_modules lintas OS atau lintas versi runtime tanpa invalidasi yang jelas. Ini sering memunculkan error aneh karena native dependency, symlink workspace, atau perbedaan versi package manager.

Artifact reuse

Artifact berbeda dari cache. Cache bersifat oportunistik dan bisa hilang; artifact adalah output build dari sebuah pipeline run yang dapat dikonsumsi job berikutnya, misalnya deployment, image packaging, atau integration test.

Pola yang umum:

  1. Job matrix membangun proyek terdampak.
  2. Setiap proyek mengunggah artifact dengan nama unik, misalnya build-web atau jar-api.
  3. Job downstream mengunduh artifact hanya untuk service yang akan diuji atau dirilis.

Artifact reuse efektif ketika build mahal tetapi output-nya stabil. Namun jangan memakai artifact lama dari pipeline sebelumnya untuk menggantikan build PR kecuali ada mekanisme validasi yang kuat, seperti content hash dan provenance yang jelas.

Fallback Full Build: Kapan Harus Menjalankan Semua Target

Selective CI sebaiknya memiliki mode fallback full build. Beberapa perubahan terlalu luas untuk diprediksi secara aman melalui path filter sederhana.

Jalankan full build untuk kondisi berikut:

  • Branch utama, misalnya main atau master, agar integrasi akhir tetap tervalidasi.
  • Perubahan lockfile, package manager config, versi runtime, base image, atau build tool.
  • Perubahan workflow CI, script internal, atau konfigurasi test runner.
  • Perubahan konfigurasi global, seperti TypeScript base config, ESLint config, Babel config, Jest/Vitest config, atau file schema bersama.
  • Merge besar atau rebase kompleks ketika base commit tidak tersedia atau diff tidak dapat dihitung dengan andal.
  • Jadwal berkala, misalnya nightly full build untuk mendeteksi masalah yang tidak tertangkap selective CI.

Untuk push ke branch utama, Anda bisa memilih dua pendekatan. Pertama, selalu full build di workflow terpisah. Kedua, buat script affected menerima variabel seperti FORCE_FULL_BUILD=true dan mengeluarkan semua proyek. Pendekatan kedua menjaga logika tetap terpusat.

Risiko False Negative dan Cara Menguranginya

False negative terjadi ketika pipeline menyimpulkan sebuah proyek tidak terdampak, padahal seharusnya diuji atau dibangun. Ini risiko utama dalam pipeline CI monorepo berbasis perubahan.

Penyebab umum false negative

  • Dependency graph tidak lengkap: service menggunakan package shared, tetapi relasi itu tidak tercatat.
  • Dependency runtime tidak terlihat: konfigurasi, environment template, migration, OpenAPI schema, protobuf, atau SQL file dipakai lintas proyek tetapi tidak dimodelkan.
  • Generated code: perubahan pada sumber generator tidak memicu proyek yang memakai output generated.
  • Path ownership ambigu: file di folder shared tidak dipetakan ke proyek mana pun.
  • Diff base salah: CI membandingkan commit yang salah karena shallow clone, force push, atau event pipeline yang berbeda.
  • Script affected fail-open: script gagal, tetapi pipeline menganggap tidak ada perubahan dan melewati semua job.

Strategi mitigasi

  • Fail closed: jika perhitungan affected gagal, jalankan full build, bukan skip build.
  • Audit log: tampilkan changed files dan affected projects di log CI agar mudah diperiksa saat review.
  • Modelkan file global: lebih baik terlalu banyak full build daripada melewatkan perubahan konfigurasi penting.
  • Review graph: perubahan dependency internal harus memperbarui graph atau metadata project.
  • Bandingkan berkala: jalankan selective build dan full build pada jadwal tertentu untuk mendeteksi gap.
  • Tambahkan test integrasi strategis: terutama untuk kontrak API, skema event, dan package shared yang dipakai banyak service.

Checklist Guardrail untuk Pipeline yang Aman

Gunakan checklist berikut sebelum menjadikan selective CI sebagai status check utama:

  • Daftar changed files dicetak di log CI.
  • Hasil affected projects dicetak dan, jika perlu, disimpan sebagai artifact kecil untuk audit.
  • Dependency graph mencakup dependency internal langsung dan transitive dihitung melalui reverse graph.
  • Perubahan global memicu full build.
  • Branch utama menjalankan full build atau validasi tambahan yang setara.
  • Script affected bersifat fail closed: error berarti full build atau pipeline gagal, bukan skip diam-diam.
  • Cache dependency dikunci oleh lockfile dan tidak menjadi satu-satunya sumber output build.
  • Artifact build diberi nama unik per proyek dan per pipeline run.
  • Status check tidak dianggap sukses jika job matrix tidak berjalan akibat error perhitungan.
  • Ada jadwal full build berkala untuk menemukan gap pada graph atau path filter.
  • Perubahan pada graph, konfigurasi CI, dan file global direview oleh owner yang memahami pipeline.
  • Dokumentasi developer menjelaskan cara menambah proyek baru ke metadata path dan dependency graph.

Debugging Saat Pipeline Melewatkan atau Menjalankan Terlalu Banyak Job

Jika pipeline melewatkan proyek yang seharusnya berjalan, mulai dari tiga hal: apakah file muncul di changed-files.txt, apakah path tersebut memiliki owner, dan apakah reverse dependency-nya tercatat. Jika salah satu tidak ada, masalahnya bukan pada job matrix, melainkan pada fase planning.

Jika pipeline menjalankan terlalu banyak proyek, cek daftar global patterns. Mungkin pola terlalu luas, misalnya config/ dianggap global padahal hanya dipakai satu service. Namun jangan langsung mempersempit pola tanpa memahami dampaknya. Untuk file konfigurasi lintas proyek, full build sering merupakan keputusan yang benar.

Masalah lain yang sering muncul adalah base SHA tidak valid. Pada pull request dari fork, merge queue, atau workflow khusus, event payload dapat berbeda. Pastikan workflow mengambil base commit yang tepat dan checkout memiliki history yang cukup untuk melakukan diff.

Penutup

Pipeline CI monorepo berbasis perubahan yang baik tidak hanya memakai path filter, tetapi menggabungkan changed files, dependency graph internal, dynamic matrix, cache dependency, artifact reuse, dan fallback full build. Kombinasi ini mengurangi build yang tidak perlu tanpa mengorbankan keandalan validasi.

Mulailah dari implementasi konservatif: path ownership jelas, graph sederhana, full build untuk perubahan global, dan log yang mudah diaudit. Setelah stabil, optimalkan cache, artifact, dan pembagian job. Dalam monorepo, pipeline yang aman biasanya bukan yang paling sedikit menjalankan job, melainkan yang hanya menghemat ketika dampaknya benar-benar dapat dihitung.