Selective test di CI monorepo adalah pendekatan untuk menjalankan pipeline hanya pada package yang benar-benar terdampak perubahan, bukan seluruh repository. Jika diterapkan dengan benar, waktu CI bisa turun signifikan karena test, lint, dan build hanya berjalan pada affected scope yang dihitung dari perubahan file dan relasi antar-package.
Masalah utamanya bukan sekadar membandingkan file yang berubah. Di monorepo, perubahan pada satu package bisa memengaruhi package lain yang bergantung padanya. Karena itu, pendekatan yang aman membutuhkan dua hal: pemetaan file ke package dan dependency graph untuk menghitung dampak transitif. Artikel ini membahas alur praktisnya, contoh struktur monorepo, pseudo-config GitHub Actions, skrip sederhana, fallback penuh saat graph tidak valid, serta jebakan umum yang sering menyebabkan false negative.
Mengapa selective test penting di monorepo
Pada monorepo kecil, menjalankan semua test untuk setiap pull request masih bisa diterima. Namun ketika jumlah package, aplikasi, atau service bertambah, biaya CI akan naik cepat:
- Waktu feedback pull request menjadi lambat.
- Runner CI lebih mahal karena lebih banyak job berjalan.
- Developer cenderung menunda merge karena pipeline terlalu lama.
- Queue CI menumpuk saat banyak branch aktif.
Selective test mencoba menjawab masalah itu dengan prinsip berikut:
- Deteksi file yang berubah antara branch dan target branch.
- Petakan file tersebut ke package yang relevan.
- Gunakan dependency graph untuk mencari semua package yang terdampak secara transitif.
- Jalankan test, lint, dan build hanya pada package affected.
- Jika perhitungan tidak dapat dipercaya, lakukan fallback ke full run.
Poin terakhir penting. CI yang cepat tetapi salah lebih berbahaya daripada CI yang lambat. Desain selective test harus bias ke keamanan: jika ragu, jalankan semua.
Konsep inti: affected scope dan dependency graph
Apa itu affected scope
Affected scope adalah himpunan package yang harus diproses oleh CI berdasarkan perubahan terkini. Scope ini biasanya mencakup:
- Package yang filenya berubah langsung.
- Package yang bergantung pada package yang berubah.
- Terkadang package yang terpengaruh oleh perubahan file global seperti config root, lockfile, atau shared tooling.
Contoh sederhana:
packages/uiberubah.apps/webbergantung pada@repo/ui.- Maka minimal
packages/uidanapps/webmasuk affected scope.
Mengapa dependency graph diperlukan
Tanpa dependency graph, CI hanya tahu file apa yang berubah, tetapi tidak tahu siapa yang terdampak. Dependency graph menyimpan relasi antar-package, misalnya:
apps/web -> packages/uipackages/ui -> packages/themeapps/admin -> packages/ui
Jika packages/theme berubah, maka package affected bisa mencakup:
packages/themepackages/uiapps/webapps/admin
Inilah yang disebut dampak transitif. Perubahan pada node bawah dapat merambat ke semua consumer di atasnya.
Contoh struktur monorepo
Struktur berikut cukup umum untuk monorepo JavaScript/TypeScript, tetapi prinsipnya berlaku umum:
repo/
apps/
web/
package.json
src/
admin/
package.json
src/
packages/
ui/
package.json
src/
config-eslint/
package.json
test-utils/
package.json
.changeset/
package.json
pnpm-workspace.yaml
tsconfig.base.json
eslint.config.js
scripts/
detect-affected.mjs
Contoh relasi dependensi:
apps/webbergantung pada@repo/uidan@repo/test-utils.apps/adminbergantung pada@repo/ui.@repo/uibergantung pada package internal lain atau config bersama.
Folder .changeset di sini tidak dipakai untuk semantic release, tetapi berguna sebagai sinyal bahwa perubahan package memang tercatat dan bisa membantu review. Namun selective test tetap harus ditentukan dari file changed dan dependency graph, bukan dari changeset saja.
Alur deteksi affected package
1. Ambil daftar file yang berubah
Langkah pertama adalah membandingkan commit saat ini dengan basis yang benar. Pada pull request GitHub, basis umum adalah branch target, misalnya origin/main.
git fetch origin main --depth=1
BASE=$(git merge-base HEAD origin/main)
git diff --name-only "$BASE" HEAD
Kenapa memakai merge-base? Karena membandingkan langsung terhadap head branch target kadang menghasilkan cakupan perubahan yang tidak stabil jika branch target bergerak. merge-base memberi titik referensi yang lebih aman untuk PR.
Jika repository di-checkout dengan
fetch-depthterlalu dangkal,merge-basebisa gagal atau menghasilkan hasil tidak lengkap. Dalam selective test, ini alasan umum mengapa workflow harus memakai history yang cukup atau fallback ke full run.
2. Petakan file ke package
Setelah daftar file berubah didapat, setiap file dipetakan ke package workspace terdekat. Cara paling sederhana adalah membaca semua package.json di workspace lalu mencari parent directory terdekat dari file.
Contoh hasil pemetaan:
packages/ui/src/Button.tsx->@repo/uiapps/web/src/pages/Home.tsx->@repo/web
Namun tidak semua file berada di dalam package. Ada file global yang perlu aturan khusus, misalnya:
pnpm-lock.yamltsconfig.base.jsoneslint.config.js- script CI atau tooling bersama
Untuk file semacam ini, pendekatan aman adalah menandai semua package affected atau minimal semua package yang relevan terhadap jenis perubahan tersebut.
3. Bangun dependency graph internal
Graph dibentuk dari dependensi internal antar workspace. Biasanya Anda membaca field dependency dari tiap package.json, lalu hanya mengambil dependency yang menunjuk ke package internal monorepo.
Misalnya:
{
"name": "@repo/web",
"dependencies": {
"@repo/ui": "workspace:*"
}
}
Dari sini dibuat edge @repo/web -> @repo/ui. Untuk perhitungan affected, biasanya Anda juga perlu graph terbalik (reverse graph) agar mudah mencari siapa saja yang bergantung pada package yang berubah.
4. Hitung dampak transitif
Mulai dari package yang berubah langsung, lalu telusuri reverse graph untuk menemukan semua dependent secara transitif. Ini bisa dilakukan dengan BFS atau DFS sederhana.
Hasil akhirnya adalah daftar package yang harus menjalankan target tertentu, misalnya:
- lint: package berubah langsung + dependent yang relevan jika lint rule tergantung shared config.
- test: package berubah langsung + semua dependent transitif.
- build: package berubah langsung + consumer yang build-nya menggabungkan dependency internal.
Anda tidak harus memakai scope yang sama untuk semua task. Build dan test sering membutuhkan propagasi lebih luas daripada lint file lokal.
Skrip sederhana untuk menghitung affected scope
Contoh berikut sengaja dibuat generik dan ringkas. Tujuannya menunjukkan alur, bukan menggantikan tooling lengkap.
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
const ROOT = process.cwd();
const WORKSPACE_DIRS = ['apps', 'packages'];
const GLOBAL_TRIGGER_FILES = [
'pnpm-lock.yaml',
'package.json',
'tsconfig.base.json',
'eslint.config.js'
];
function findPackages() {
const packages = [];
for (const dir of WORKSPACE_DIRS) {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) continue;
for (const entry of fs.readdirSync(abs)) {
const pkgDir = path.join(abs, entry);
const pkgJson = path.join(pkgDir, 'package.json');
if (!fs.existsSync(pkgJson)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
packages.push({
name: pkg.name,
dir: path.relative(ROOT, pkgDir),
dependencies: {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies
}
});
}
}
return packages;
}
function getChangedFiles(baseRef = 'origin/main') {
const mergeBase = execSync(`git merge-base HEAD ${baseRef}`, { encoding: 'utf8' }).trim();
const out = execSync(`git diff --name-only ${mergeBase} HEAD`, { encoding: 'utf8' }).trim();
return out ? out.split('\n') : [];
}
function fileToPackage(file, packages) {
const normalized = file.replace(/\\/g, '/');
let best = null;
for (const pkg of packages) {
if (normalized === pkg.dir || normalized.startsWith(pkg.dir + '/')) {
if (!best || pkg.dir.length > best.dir.length) best = pkg;
}
}
return best?.name ?? null;
}
function buildGraphs(packages) {
const names = new Set(packages.map(p => p.name));
const graph = new Map();
const reverse = new Map();
for (const pkg of packages) {
graph.set(pkg.name, new Set());
reverse.set(pkg.name, new Set());
}
for (const pkg of packages) {
for (const depName of Object.keys(pkg.dependencies || {})) {
if (!names.has(depName)) continue;
graph.get(pkg.name).add(depName);
reverse.get(depName).add(pkg.name);
}
}
return { graph, reverse };
}
function collectAffected(initial, reverseGraph) {
const affected = new Set(initial);
const queue = [...initial];
while (queue.length) {
const current = queue.shift();
for (const dependent of reverseGraph.get(current) || []) {
if (affected.has(dependent)) continue;
affected.add(dependent);
queue.push(dependent);
}
}
return [...affected].sort();
}
function main() {
const packages = findPackages();
const changedFiles = getChangedFiles(process.argv[2] || 'origin/main');
const hasGlobalChange = changedFiles.some(f => GLOBAL_TRIGGER_FILES.includes(f));
if (hasGlobalChange) {
console.log(JSON.stringify({ mode: 'full', affected: packages.map(p => p.name).sort() }));
return;
}
const changedPackages = new Set();
for (const file of changedFiles) {
const pkgName = fileToPackage(file, packages);
if (pkgName) changedPackages.add(pkgName);
}
const { reverse } = buildGraphs(packages);
const affected = collectAffected(changedPackages, reverse);
console.log(JSON.stringify({ mode: 'selective', affected }));
}
try {
main();
} catch (err) {
console.log(JSON.stringify({ mode: 'full', reason: 'graph_error' }));
process.exit(0);
}
Skrip di atas memperlihatkan beberapa prinsip penting:
- Jika file global berubah, mode berpindah ke full.
- Jika graph gagal dibangun atau
merge-basebermasalah, hasil aman adalah full. - Perhitungan affected dilakukan lewat reverse dependency graph.
Pada implementasi nyata, Anda biasanya menambahkan:
- Daftar file global yang lebih lengkap.
- Aturan khusus untuk test-only change, docs-only change, atau config tertentu.
- Output berbeda per task: affected untuk test, build, dan lint.
- Logging yang mudah dibaca agar debugging lebih sederhana.
Peran changeset dalam alur ini
Changeset bukan pengganti dependency graph. Dalam konteks selective test di CI monorepo, changeset lebih cocok dipakai sebagai pelengkap:
- Menjadi sinyal bahwa perubahan package memang disengaja.
- Membantu reviewer melihat package mana yang diakui berubah.
- Dapat dipakai sebagai validasi tambahan, misalnya PR yang mengubah package produksi tetapi tidak menambahkan changeset.
Namun jangan menjadikan file changeset sebagai satu-satunya sumber affected scope. Alasannya:
- Changeset dibuat manual, sehingga bisa lupa ditambahkan.
- Changeset tidak selalu mencerminkan dampak transitif ke dependent package.
- Perubahan file global atau tooling bersama sering tidak tercermin dengan baik di changeset.
Praktik yang lebih aman adalah:
- Hitung affected dari git diff + dependency graph.
- Gunakan changeset sebagai validasi atau metadata tambahan.
- Jika ada mismatch mencurigakan, tandai untuk review atau fallback.
Integrasi dengan GitHub Actions
Pola workflow yang umum
Biasanya workflow dibagi dua tahap:
- Job detect untuk menghitung affected scope dan menentukan mode selective atau full.
- Job eksekusi untuk menjalankan lint, test, dan build berdasarkan output job detect.
Pseudo-config berikut menunjukkan bentuk alurnya:
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.detect.outputs.mode }}
affected: ${{ steps.detect.outputs.affected }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- name: Install deps
run: npm ci
- name: Detect affected
id: detect
run: |
result=$(node scripts/detect-affected.mjs origin/main)
echo "$result"
echo "mode=$(echo $result | jq -r '.mode')" >> $GITHUB_OUTPUT
echo "affected=$(echo $result | jq -c '.affected // []')" >> $GITHUB_OUTPUT
test:
needs: detect
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Run selective tests
if: ${{ needs.detect.outputs.mode == 'selective' }}
run: node scripts/run-task.mjs test '${{ needs.detect.outputs.affected }}'
- name: Run full tests
if: ${{ needs.detect.outputs.mode == 'full' }}
run: npm run test:all
lint:
needs: detect
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Run selective lint
if: ${{ needs.detect.outputs.mode == 'selective' }}
run: node scripts/run-task.mjs lint '${{ needs.detect.outputs.affected }}'
- name: Run full lint
if: ${{ needs.detect.outputs.mode == 'full' }}
run: npm run lint:all
build:
needs: detect
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Run selective build
if: ${{ needs.detect.outputs.mode == 'selective' }}
run: node scripts/run-task.mjs build '${{ needs.detect.outputs.affected }}'
- name: Run full build
if: ${{ needs.detect.outputs.mode == 'full' }}
run: npm run build:all
Ada beberapa detail penting pada workflow seperti ini:
fetch-depth: 0mengurangi risikomerge-basegagal.- Job detect dipisahkan agar hasil perhitungan dapat dipakai ulang oleh job lain.
- Mode
fullharus tersedia sebagai fallback yang jelas.
Skrip runner task
Agar workflow tetap sederhana, eksekusi task bisa didelegasikan ke skrip kecil yang menerima daftar package affected. Bentuk implementasinya bergantung pada package manager dan task runner yang dipakai, tetapi idenya sama: filter hanya package terkait.
#!/usr/bin/env node
const task = process.argv[2];
const affected = JSON.parse(process.argv[3] || '[]');
if (!task) process.exit(1);
if (!affected.length) {
console.log(`No affected package for ${task}`);
process.exit(0);
}
for (const pkg of affected) {
console.log(`Running ${task} for ${pkg}`);
// Ganti dengan mekanisme workspace runner yang Anda pakai.
// Contoh: pnpm --filter "${pkg}" run ${task}
}
Jika memakai task runner yang sudah mendukung filtering dan graph-aware execution, skrip ini bisa sangat tipis. Tetapi tetap berguna untuk menyatukan kebijakan fallback, logging, dan normalisasi input dari CI.
Kapan harus fallback ke full run
Selective test tidak boleh dipaksakan ketika data dasarnya tidak valid. Berikut kondisi yang sebaiknya memicu fallback:
- Dependency graph gagal dibangun, misalnya ada
package.jsonrusak atau workspace tidak sinkron. - Git history tidak cukup untuk menentukan
merge-base. - File global kritis berubah, seperti lockfile, config TypeScript root, Babel, ESLint, atau tooling build bersama.
- Perubahan pada generator kode, shared test setup, script internal, atau infrastructure package yang memengaruhi banyak target.
- Mismatch antara graph dan hasil instalasi, misalnya package internal tidak terdeteksi karena nama package berubah tetapi referensi belum diperbarui.
Fallback penuh bukan kegagalan desain. Itu bagian dari desain yang aman. Targetnya bukan selective 100% setiap saat, tetapi selective ketika sinyalnya cukup kuat dan dapat dipercaya.
Strategi pemetaan perubahan yang lebih aman
Kategorikan file berdasarkan radius dampak
Daripada semua file diperlakukan sama, buat kategori:
- Local package files: hanya package tersebut + dependent transitif.
- Shared config files: bisa memengaruhi banyak package, sering perlu full run.
- Docs-only files: bisa melewati test/build tertentu.
- CI/workflow files: sering perlu validasi lebih luas karena perilaku pipeline berubah.
Pendekatan ini membuat selective test lebih prediktif dan lebih mudah dijelaskan ke tim.
Bedakan affected per task
Kesalahan umum adalah memakai satu daftar affected untuk semua task. Padahal kebutuhan tiap task berbeda:
- Lint kadang cukup pada package berubah langsung, kecuali config lint bersama ikut berubah.
- Unit test sering perlu dependent transitif jika kontrak package bisa berubah.
- Build perlu mempertimbangkan bundling, code generation, dan type dependency.
- Integration/E2E test sering lebih cocok dipicu berdasarkan app level, bukan package level.
Dengan memisahkan scope per task, Anda bisa menjaga CI tetap cepat tanpa mengorbankan akurasi.
Metrik yang perlu dipantau
Selective test sebaiknya dievaluasi dengan data, bukan asumsi. Beberapa metrik yang berguna:
- Median durasi CI per PR sebelum dan sesudah selective test.
- Persentase package affected per PR.
- Rasio selective vs full fallback.
- Jumlah rerun karena false negative, misalnya PR lolos selective tetapi gagal setelah merge atau pada full pipeline malam hari.
- Cache hit rate untuk dependency install dan build artifact.
- Distribusi waktu per stage: install, detect, lint, test, build.
Jika durasi deteksi terlalu mahal atau fallback terlalu sering, berarti aturan selective perlu disederhanakan atau graph perlu distabilkan.
Jebakan umum dan cara menghindarinya
1. False negative karena graph tidak lengkap
Ini risiko terbesar. Penyebab umumnya:
- Dependensi internal tidak dideklarasikan eksplisit.
- Package saling terhubung lewat code generation atau import dinamis yang tidak tercermin di
package.json. - Shared config atau helper digunakan luas tetapi tidak dimodelkan sebagai dependency.
Solusinya:
- Disiplinkan deklarasi dependency internal.
- Masukkan package tooling bersama ke graph atau tandai sebagai global trigger.
- Lakukan full run berkala, misalnya di branch utama atau jadwal harian, untuk mendeteksi blind spot.
2. Cache usang
Cache CI bisa mempercepat, tetapi juga menutupi bug. Misalnya build terlihat hijau karena artifact lama masih dipakai, padahal package affected tidak dibangun ulang dengan benar.
Praktik yang lebih aman:
- Sertakan lockfile dan input task dalam key cache.
- Jangan gunakan cache untuk menghindari validasi yang seharusnya dijalankan ulang.
- Bedakan cache dependency install dengan cache output build/test.
3. Perbedaan hasil lokal vs CI
Di lokal, developer mungkin menjalankan test dari state workspace yang belum bersih, sedangkan CI mulai dari checkout baru. Ini bisa menimbulkan hasil berbeda karena:
- File generated belum di-commit.
- Dependency lokal masih tersisa dari instalasi lama.
- Environment variable lokal berbeda.
- Script detect memakai referensi git yang tidak sama dengan CI.
Untuk mengurangi selisih:
- Sediakan skrip lokal yang sama dengan CI untuk menghitung affected.
- Dokumentasikan base ref yang digunakan.
- Jalankan validasi dari workspace bersih saat debugging.
4. Menganggap lockfile selalu aman untuk selective
Perubahan lockfile bisa memengaruhi dependency tree lint, test, atau build lintas package. Jika Anda tidak memiliki model dampak yang sangat jelas, anggap lockfile sebagai trigger full run. Ini lebih konservatif, tetapi jauh lebih aman.
5. Mengabaikan perubahan pada root config
File seperti tsconfig.base.json, config test runner, dan config bundler root sering mengubah perilaku banyak package sekaligus. Jika file seperti ini tidak masuk daftar global trigger, selective test bisa melewatkan regresi penting.
Pola rollout yang realistis
Jika tim belum pernah menerapkan selective test, jangan langsung mengganti seluruh CI. Rollout bertahap biasanya lebih aman:
- Mulai dari mode observasi: hitung affected, tetapi tetap jalankan full CI.
- Bandingkan hasil selective dengan full run selama beberapa minggu.
- Aktifkan selective untuk lint lebih dulu.
- Lanjutkan ke unit test, lalu build.
- Pertahankan full run di main branch atau schedule sebagai jaring pengaman.
Pendekatan ini membantu menemukan false negative sebelum selective test menjadi jalur utama merge gate.
Kesimpulan
Selective test di CI monorepo dengan changeset dan dependency graph bekerja baik jika Anda memisahkan tiga tanggung jawab utama: deteksi file berubah, pemetaan ke package, dan propagasi dampak melalui dependency graph. Changeset berguna sebagai pelengkap, tetapi sumber utama affected scope tetap harus berasal dari perubahan aktual dan relasi dependensi internal.
Kunci implementasi yang tahan di produksi adalah sikap konservatif: modelkan file global dengan benar, hitung dampak transitif, dan selalu sediakan fallback full run saat graph atau git state tidak dapat dipercaya. Jika Anda juga memantau metrik durasi, rasio fallback, dan false negative, selective test bisa menjadi optimasi CI yang benar-benar aman, bukan sekadar cepat di atas kertas.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!