CI matrix build untuk monorepo multi-target berguna ketika satu repository harus memvalidasi banyak aplikasi, package, service, atau platform sekaligus. Masalahnya, jika semua target selalu dijalankan pada setiap perubahan, durasi pipeline, biaya runner, dan waktu tunggu feedback akan naik cepat.
Solusi praktisnya bukan sekadar menambah paralelisme. Pipeline harus dirancang seperti rute perjalanan lintas banyak negara: bukan semua orang harus transit ke semua kota, melainkan tiap penumpang hanya melewati jalur yang relevan. Dalam CI, artinya job hanya berjalan untuk target yang terdampak, dependensi dievaluasi dengan jelas, hasil build dimanfaatkan ulang, dan konkurensi dibatasi agar cepat tanpa membanjiri runner.
Mengapa pipeline monorepo sering melambat
Pada monorepo, satu commit bisa menyentuh beberapa area sekaligus: library bersama, backend, frontend, mobile, infrastruktur, dan dokumentasi. Pipeline linear biasanya tumbuh seperti ini:
- install dependency untuk seluruh repo,
- lint semua package,
- test semua package,
- build semua target,
- deploy atau publish artifact.
Pendekatan ini sederhana, tetapi buruk untuk skala. Perubahan kecil di satu folder tetap memicu validasi penuh. Hasilnya:
- feedback lambat untuk developer,
- biaya runner meningkat,
- queue menumpuk saat banyak pull request aktif,
- failure noise dari job yang sebenarnya tidak relevan.
Target utamanya bukan membuat semua job paralel, tetapi membuat jumlah job yang benar berjalan di waktu yang tepat.
Prinsip desain CI matrix build yang efisien
1. Bedakan unit validasi: target, task, dan platform
Sebelum membuat matrix, definisikan dulu apa yang sebenarnya divariasikan:
- Target: app web, service API, package library, worker, mobile module.
- Task: lint, unit test, integration test, build.
- Platform: OS, runtime, arsitektur, atau environment tertentu bila memang dibutuhkan.
Kesalahan umum adalah mencampur semua dimensi ke satu matrix besar. Misalnya 10 package × 4 task × 3 runtime × 2 OS akan menghasilkan 240 kombinasi. Secara teoritis rapi, tetapi secara operasional sering berlebihan.
Praktik yang lebih aman adalah:
- pisahkan job berdasarkan tahap logis,
- gunakan matrix hanya pada dimensi yang benar-benar perlu divariasikan,
- hindari pengujian platform ganda jika target tidak sensitif terhadap platform.
2. Bangun dependency graph, bukan daftar folder statis
Selective run berbasis path change lebih akurat jika Anda tahu relasi antar target. Jika packages/shared-ui berubah, mungkin yang terdampak bukan hanya package itu, tetapi juga apps/web dan apps/admin yang bergantung padanya.
Karena itu, pipeline yang baik memakai dependency graph sederhana:
- target A bergantung pada library X,
- target B bergantung pada library X dan Y,
- perubahan pada X memicu A dan B,
- perubahan pada file global seperti lockfile atau config lint dapat memicu lebih banyak target.
Graph ini bisa dibangun dari:
- workspace manifest,
- tool monorepo yang sudah punya graph internal,
- mapping JSON buatan sendiri jika struktur repo masih sederhana.
Mengapa ini penting? Karena path filtering murni berdasarkan folder sering gagal menangkap efek perubahan transitif.
3. Gunakan selective run sebagai gerbang utama
Selective run berarti hanya membuat matrix untuk target yang berubah atau terdampak. Ini biasanya tahap paling berdampak terhadap durasi pipeline.
Contoh kategori perubahan:
- Perubahan lokal: hanya
apps/apiberubah, maka lint/test/build untuk API saja. - Perubahan shared library: jalankan semua consumer yang terdampak.
- Perubahan global: lockfile, base image, config TypeScript, config lint, atau script CI; jalankan set yang lebih luas, kadang seluruh target.
- Perubahan non-eksekusi: dokumentasi; cukup validasi ringan atau lewati pipeline berat.
Selective run yang baik biasanya memberi penghematan lebih besar daripada optimasi cache semata. Cache membantu pekerjaan yang tetap harus dijalankan; selective run mengurangi pekerjaan sejak awal.
Arsitektur pipeline: dari deteksi perubahan ke matrix dinamis
Tahap 1: job deteksi perubahan
Buat satu job awal untuk:
- mengambil daftar file berubah dari base branch,
- memetakan file ke target terdampak,
- menghasilkan JSON matrix,
- menandai apakah lint, test, atau build perlu dijalankan.
Output job ini lalu dipakai oleh job berikutnya melalui needs. Dengan pola ini, Anda tidak perlu menulis matrix statis yang selalu menjalankan semua kombinasi.
Tahap 2: matrix per task atau per target
Ada dua pola umum:
- Matrix per target: satu job matrix, tiap entry menjalankan lint/test/build untuk satu target.
- Matrix per task: job lint matrix, test matrix, build matrix secara terpisah.
Untuk kebanyakan tim, matrix per task lebih mudah dipantau dan lebih fleksibel. Alasannya:
- lint bisa fail cepat tanpa menunggu build,
- build bisa bergantung pada test untuk target tertentu,
- cache dan artifact lebih mudah dikelola per tahap.
Tahap 3: artifact reuse
Jika hasil dari satu tahap dibutuhkan tahap lain, simpan sebagai artifact. Contohnya:
- hasil kompilasi shared library dipakai integration test,
- bundle frontend dipakai smoke test,
- binary atau package hasil build dipakai langkah publish.
Tanpa artifact reuse, job berikutnya cenderung mengulang kompilasi dari nol.
Contoh workflow GitHub Actions yang mudah diadaptasi
Contoh berikut menunjukkan pola umum: job awal menghitung target terdampak, lalu lint, test, dan build berjalan sebagai matrix dinamis. Script deteksi dapat Anda sesuaikan dengan struktur monorepo sendiri.
name: monorepo-ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.set-matrix.outputs.targets }}
has_targets: ${{ steps.set-matrix.outputs.has_targets }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute affected targets
id: set-matrix
shell: bash
run: |
node .github/scripts/affected-targets.js > affected.json
TARGETS=$(cat affected.json)
echo "targets=${TARGETS}" >> "$GITHUB_OUTPUT"
if [ "${TARGETS}" = "[]" ]; then
echo "has_targets=false" >> "$GITHUB_OUTPUT"
else
echo "has_targets=true" >> "$GITHUB_OUTPUT"
fi
lint:
needs: detect-changes
if: needs.detect-changes.outputs.has_targets == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 4
matrix:
target: ${{ fromJson(needs.detect-changes.outputs.targets) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Lint target
run: npm run lint -- ${{ matrix.target.name }}
test:
needs: detect-changes
if: needs.detect-changes.outputs.has_targets == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 4
matrix:
target: ${{ fromJson(needs.detect-changes.outputs.targets) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Test target
run: npm run test:ci -- ${{ matrix.target.name }}
build:
needs: [detect-changes, test]
if: needs.detect-changes.outputs.has_targets == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
target: ${{ fromJson(needs.detect-changes.outputs.targets) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Build target
run: npm run build -- ${{ matrix.target.name }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.target.name }}
path: dist/
Script affected-targets.js bisa menghasilkan JSON seperti berikut:
[
{"name":"apps-web"},
{"name":"packages-shared-ui"}
]
Poin penting dari contoh di atas:
- detect-changes adalah sumber kebenaran matrix,
- max-parallel membatasi ledakan job,
- fail-fast: false menjaga hasil target lain tetap terlihat saat satu target gagal,
- concurrency membatalkan pipeline lama pada branch yang sama agar runner tidak habis untuk commit yang sudah usang.
Selective run berbasis path change dan dependency graph
Strategi mapping yang praktis
Jika belum memakai tool graph khusus, mulai dari mapping sederhana:
{
"apps/api": ["apps-api"],
"apps/web": ["apps-web"],
"packages/shared": ["apps-api", "apps-web", "packages-shared"],
"package-lock.json": ["*"],
".github/workflows": ["*"]
}
Maknanya:
- perubahan di
apps/apihanya memicu target API, - perubahan di
packages/sharedmemicu semua consumer terkait, - perubahan lockfile atau workflow memicu semua target.
Kesalahan umum selective run
- Terlalu sempit: library berubah tetapi consumer tidak diuji.
- Terlalu luas: hampir semua perubahan dianggap global, sehingga selective run kehilangan manfaat.
- Tidak membedakan jenis task: perubahan README tidak perlu build semua target.
- Tidak memperhitungkan generated code: perubahan schema atau codegen sering berdampak ke banyak area.
Tips debugging selective run
- log daftar file berubah ke summary CI,
- log target yang dianggap terdampak,
- sediakan mode lokal untuk menjalankan script deteksi terhadap dua commit,
- audit mismatch saat ada bug “target penting tidak ikut diuji”.
Cache dependency dan cache hasil build
Cache dependency
Cache dependency mempercepat langkah instalasi, tetapi harus dipakai hati-hati. Simpan cache berdasarkan sinyal yang cukup stabil, misalnya lockfile dan environment utama. Jika key terlalu longgar, cache menjadi tidak valid; jika terlalu spesifik, hit rate turun.
Prinsip aman:
- cache dependency terpisah dari artifact build,
- ikat cache ke lockfile, OS, dan runtime utama bila relevan,
- jangan menganggap cache selalu tersedia.
Cache hasil build
Build cache berguna untuk kompilasi incremental atau output antara yang bisa dipakai ulang antar job atau antar run. Namun, hasil build sering lebih sensitif terhadap perubahan source, flags, dan environment.
Gunakan build cache bila:
- kompilasi mahal,
- tooling mendukung cache deterministik,
- invalidasi cache bisa dipahami tim.
Hindari cache build agresif bila tim belum siap men-debug hasil stale. Dalam banyak kasus, artifact reuse per workflow lebih mudah dipahami daripada cache lintas-run yang kompleks.
Kapan memilih artifact reuse daripada cache
- Artifact reuse: ketika output dari job A pasti dipakai job B pada workflow yang sama.
- Cache: ketika output atau dependency kemungkinan berguna kembali pada run berikutnya.
Cache mengejar kecepatan jangka menengah. Artifact mengejar pengurangan kerja berulang dalam satu pipeline.
Parallelism, fail-fast, dan batas konkurensi
Parallelism bukan berarti tanpa batas
Menjalankan semua job bersamaan terdengar cepat, tetapi sering menciptakan bottleneck baru:
- runner terbatas,
- throttling dari layanan eksternal,
- perebutan CPU dan I/O,
- biaya eksekusi naik tajam.
Karena itu, max-parallel penting. Seperti rute perjalanan internasional, lebih banyak penerbangan transit tidak selalu membuat total perjalanan lebih cepat jika bandara penghubung justru macet.
Kapan pakai fail-fast
Fail-fast berguna jika satu kegagalan membuat hasil target lain tidak terlalu bernilai. Contoh: tahap setup global benar-benar rusak.
Tetapi pada matrix monorepo, fail-fast: false sering lebih berguna agar tim melihat seluruh target yang bermasalah dalam satu run. Ini mengurangi siklus “perbaiki satu, push lagi, baru muncul error lain”.
Batas konkurensi per branch atau workflow
Gunakan kontrol konkurensi untuk membatalkan run lama saat commit baru masuk ke branch yang sama. Ini sangat efektif untuk pull request yang aktif direbase atau sering diperbarui.
Pilih pembatalan otomatis jika:
- feedback terbaru lebih penting daripada run lama,
- resource runner terbatas,
- pipeline berat dan mahal.
Jangan terlalu agresif membatalkan job jika ada tahap yang menghasilkan artifact penting untuk investigasi atau audit.
Trade-off biaya vs feedback cepat
Tidak ada konfigurasi tunggal yang optimal untuk semua tim. Anda perlu memilih titik seimbang antara waktu feedback, akurasi validasi, dan biaya runner.
Pendekatan yang umum dipakai
- PR ringan, main branch lebih lengkap: pada pull request jalankan selective run; pada merge ke main jalankan validasi lebih luas.
- Tiered CI: lint dan unit test cepat sebagai gerbang awal, integration test dan build penuh berjalan setelahnya atau hanya pada target tertentu.
- Nightly full validation: semua target diuji penuh secara terjadwal untuk menangkap gap selective run.
Trade-off yang harus disadari
- Selective run mengurangi durasi, tetapi butuh maintenance mapping/graph.
- Matrix besar mempercepat wall-clock time, tetapi bisa menaikkan biaya total compute.
- Cache mempercepat run, tetapi bisa menambah kompleksitas invalidasi.
- Artifact reuse mengurangi rebuild, tetapi menambah overhead upload/download dan pengelolaan storage.
Metrik yang perlu dipantau
Tanpa metrik, optimasi CI mudah berubah jadi asumsi. Pantau setidaknya:
- total wall-clock duration per workflow,
- queue time sebelum job mulai,
- total compute minutes atau biaya runner,
- cache hit rate untuk dependency dan build cache,
- jumlah target per run setelah selective run,
- rerun rate akibat flaky test atau cache bermasalah,
- failure distribution per tahap: lint, test, build.
Metrik penting lain adalah time-to-first-feedback: berapa lama developer menunggu sampai error pertama yang relevan muncul. Kadang ini lebih bernilai daripada hanya mengejar waktu pipeline selesai total.
Checklist migrasi dari pipeline linear ke matrix
- Inventaris target: daftar app, package, service, dan task yang perlu divalidasi.
- Kelompokkan task: pisahkan lint, test, build, dan integration test.
- Identifikasi target mahal: build mobile, integration test, atau image container besar.
- Definisikan dependency graph: minimal untuk shared library dan config global.
- Buat job deteksi perubahan: hasilkan target terdampak dalam JSON.
- Mulai dari selective run konservatif: lebih baik sedikit terlalu luas daripada terlalu sempit.
- Tambahkan matrix dinamis: gunakan output job deteksi sebagai input matrix.
- Batasi paralelisme: set
max-parallelsesuai kapasitas runner dan bottleneck eksternal. - Terapkan cache dependency dulu sebelum build cache kompleks.
- Tambah artifact reuse untuk tahap yang jelas mengulang hasil build.
- Atur concurrency cancellation untuk branch aktif.
- Validasi dengan full run berkala agar selective run tidak menutupi gap dependensi.
- Monitor metrik selama beberapa minggu sebelum menyempurnakan aturan.
Pola implementasi yang aman untuk tim
Jika tim Anda sedang bermigrasi, jangan langsung mengejar pipeline paling canggih. Urutan yang biasanya aman:
- pecah pipeline linear menjadi job terpisah,
- tambahkan matrix untuk target yang sudah jelas,
- batasi paralelisme,
- tambahkan selective run berbasis path change,
- tingkatkan ke dependency graph yang lebih akurat,
- optimalkan cache dan artifact reuse setelah pola eksekusi stabil.
Urutan ini penting karena bottleneck terbesar biasanya bukan kurangnya optimasi teknis, melainkan salah desain alur validasi. Pipeline yang dapat dipahami tim akan lebih mudah dipelihara daripada konfigurasi sangat pintar tetapi sulit di-debug.
Penutup
CI matrix build untuk monorepo multi-target tanpa pipeline lambat dicapai dengan kombinasi beberapa hal: matrix yang tidak berlebihan, selective run berbasis perubahan dan dependency graph, cache yang tepat guna, artifact reuse, serta pembatasan paralelisme dan konkurensi. Fokus utamanya adalah memastikan tiap perubahan hanya menempuh “rute perjalanan” yang memang perlu.
Jika Anda baru mulai, prioritaskan tiga langkah ini: deteksi target terdampak, matrix dinamis per task, dan batas paralelisme yang realistis. Tiga hal tersebut biasanya sudah memberi pengurangan durasi yang nyata tanpa membuat pipeline menjadi rapuh.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!