CI matrix build yang efisien bukan berarti menguji lebih sedikit secara serampangan, melainkan memilih kombinasi yang benar-benar memberi sinyal kualitas. Jika pipeline Anda menguji banyak versi runtime, sistem operasi, atau database, pemborosan biasanya terjadi karena semua kombinasi diperlakukan sama, padahal risikonya berbeda.

Untuk tim web atau backend, tujuan praktisnya adalah menurunkan waktu tunggu merge tanpa mengorbankan verifikasi penting. Caranya biasanya kombinasi dari: memisahkan job wajib dan opsional, mengurangi redundansi pada matrix, memakai fail-fast secara tepat, menyusun cache dependency yang aman, membatasi paralelisme sesuai kapasitas runner, dan menjalankan subset job berdasarkan perubahan file.

Kapan matrix build benar-benar perlu dipakai

Matrix build berguna ketika aplikasi Anda harus kompatibel terhadap lebih dari satu dimensi lingkungan eksekusi. Contoh yang umum:

  • Runtime: Node.js, PHP, Python, Java, atau versi runtime lain yang masih didukung.
  • OS: Linux sebagai target utama, plus Windows atau macOS jika produk, tooling, atau skrip build memang harus berjalan di sana.
  • Database: PostgreSQL dan MySQL, atau beberapa versi engine yang masih aktif dipakai pelanggan.
  • Mode integrasi: unit test di semua runtime, tetapi integration test hanya pada kombinasi representatif.

Matrix tidak selalu dibutuhkan. Jika produksi Anda hanya berjalan di Linux dengan satu versi runtime dan satu database, menguji semua OS hanya karena tersedia di CI hampir pasti membuang waktu. Matrix sebaiknya dipakai jika memang ada risiko kompatibilitas yang nyata, bukan sekadar “biar lengkap”.

Tanda bahwa matrix Anda terlalu besar

  • Satu perubahan dokumentasi memicu puluhan job mahal.
  • Semua kombinasi menjalankan test suite yang identik, padahal sebagian hanya memverifikasi hal yang sama.
  • Job lint, static analysis, dan unit test diulang di setiap kombinasi runtime/OS tanpa alasan teknis.
  • Waktu tunggu pull request lebih lama daripada waktu review.
  • Biaya runner meningkat, tetapi bug yang tertangkap tidak bertambah signifikan.

Penyebab utama pipeline membengkak

1. Perkalian kombinasi tanpa prioritas risiko

Masalah paling umum adalah menggabungkan semua dimensi sekaligus: 3 versi runtime × 3 OS × 2 database = 18 job. Jika tiap job memasang dependency, menyalakan service container, dan menjalankan integration test penuh, total waktu dan biaya naik sangat cepat.

Pendekatan yang lebih sehat adalah membedakan antara:

  • cakupan kompatibilitas — memastikan aplikasi masih berjalan di beberapa versi runtime atau OS,
  • cakupan perilaku — memastikan fitur benar lewat test yang mahal seperti integration atau end-to-end.

Keduanya tidak harus dijalankan di semua kombinasi.

2. Semua job dianggap sama penting

Pipeline sering lambat karena job yang sebenarnya bersifat informasional diperlakukan sebagai gerbang merge. Misalnya, nightly test pada runtime lama atau database sekunder tidak perlu memblokir pull request jika sudah ada job wajib yang mewakili jalur produksi utama.

3. Cache yang salah desain

Cache bisa mempercepat pipeline, tetapi jika key terlalu longgar, Anda berisiko memakai dependency yang salah antar versi runtime atau OS. Sebaliknya, key yang terlalu spesifik dapat membuat cache hampir tidak pernah terpakai. Desain cache harus menyeimbangkan keamanan dan hit rate.

4. Tidak ada pemicu berbasis perubahan file

Perubahan pada README, asset statis, atau folder yang tidak memengaruhi backend sering kali tetap memicu matrix penuh. Ini salah satu sumber pemborosan yang paling mudah diperbaiki.

Prinsip desain CI matrix build yang efisien

Pisahkan job wajib dan job opsional

Buat kategori yang jelas:

  • Job wajib untuk pull request: harus cepat, stabil, dan mewakili jalur produksi utama.
  • Job opsional atau informasional: tetap berguna untuk deteksi kompatibilitas, tetapi tidak harus memblokir merge.
  • Job periodik: cocok untuk kombinasi mahal yang tidak perlu dijalankan setiap commit, misalnya nightly atau scheduled.

Contoh pembagian yang masuk akal untuk aplikasi backend:

  • PR wajib: Linux + runtime utama + database utama, lint, unit test, integration test inti.
  • PR opsional: runtime lama atau terbaru, database sekunder.
  • Nightly: semua kombinasi penting, termasuk versi eksperimental atau OS sekunder.

Kurangi redundansi di level jenis verifikasi

Jangan menjalankan seluruh stack verifikasi di semua sel matrix. Pilih berdasarkan tujuan:

  • Lint dan static analysis biasanya cukup sekali di environment utama.
  • Unit test cocok dijalankan pada beberapa versi runtime karena relatif murah.
  • Integration test cukup pada kombinasi representatif yang paling berisiko.
  • End-to-end test sebaiknya sangat selektif karena paling mahal dan sering paling flakey.

Dengan begitu, matrix tidak lagi berarti “semua hal di semua kombinasi”.

Gunakan baseline dan edge coverage

Strategi yang praktis adalah memilih:

  • Baseline: kombinasi yang paling mirip produksi, wajib lulus.
  • Edge coverage: kombinasi untuk menangkap masalah kompatibilitas di batas bawah/atas, dijalankan terbatas.

Contoh:

  • Baseline: Linux + runtime utama + PostgreSQL.
  • Edge runtime: runtime terendah yang masih didukung, runtime terbaru yang ingin dipantau.
  • Edge OS: Windows hanya untuk memastikan skrip/tooling lintas platform tidak rusak.

Terapkan fail-fast dengan sadar

Fail-fast membatalkan job matrix lain ketika satu sel gagal. Ini menghemat waktu dan runner jika kegagalan awal menunjukkan masalah sistemik, misalnya dependency rusak atau test dasar gagal di semua kombinasi.

Namun, ada trade-off: Anda kehilangan visibilitas penuh terhadap kombinasi mana saja yang juga gagal. Untuk pull request biasa, fail-fast sering tepat. Untuk investigasi kompatibilitas atau scheduled run, mematikan fail-fast kadang lebih berguna agar semua hasil terkumpul.

Contoh struktur workflow GitHub Actions

Berikut contoh yang realistis untuk tim backend/web: lint dijalankan sekali, unit test memakai matrix runtime, dan integration test dibatasi ke kombinasi representatif. Ada juga contoh conditional run berbasis perubahan file.

name: ci

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      docs: ${{ steps.filter.outputs.docs }}
    steps:
      - uses: actions/checkout@v4
      - id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            backend:
              - 'src/**'
              - 'tests/**'
              - 'package.json'
              - 'package-lock.json'
              - '.github/workflows/**'
            docs:
              - 'docs/**'
              - '*.md'

  lint:
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  unit:
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    strategy:
      fail-fast: true
      matrix:
        node: ['18', '20', '22']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --runInBand

  integration:
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    strategy:
      fail-fast: false
      matrix:
        include:
          - node: '20'
            db: postgres
          - node: '20'
            db: mysql
    services:
      postgres:
        image: postgres:latest
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
      mysql:
        image: mysql:latest
        env:
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - name: Run integration tests for PostgreSQL
        if: matrix.db == 'postgres'
        run: npm run test:integration:postgres
      - name: Run integration tests for MySQL
        if: matrix.db == 'mysql'
        run: npm run test:integration:mysql

  compatibility:
    runs-on: ${{ matrix.os }}
    needs: changes
    if: github.event_name == 'push' || github.ref == 'refs/heads/main'
    continue-on-error: true
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            node: '18'
          - os: windows-latest
            node: '20'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

Mengapa struktur di atas lebih efisien

  • Lint tidak dimatrix-kan karena hasilnya umumnya tidak bergantung pada banyak kombinasi runtime/OS.
  • Unit test memakai matrix runtime karena murah dan bagus untuk mendeteksi kompatibilitas bahasa/runtime.
  • Integration test dibatasi ke runtime utama dan hanya bervariasi di database, karena itu dimensi risiko yang ingin diverifikasi.
  • Compatibility job tidak memblokir merge dan bisa dibatasi ke push/main, bukan setiap pull request.
  • Conditional run mencegah matrix penuh berjalan saat perubahan hanya di dokumentasi.

Contoh di atas menunjukkan pola desain, bukan template universal. Sesuaikan runtime, test command, dan service database dengan stack Anda.

Strategi reduce redundancy yang paling berdampak

1. Jadikan satu kombinasi sebagai sumber kebenaran utama

Tentukan satu kombinasi yang paling mendekati produksi. Semua pemeriksaan berat dijalankan di sana. Kombinasi lain cukup menjalankan subset yang relevan. Ini memberi sinyal cepat untuk reviewer sekaligus tetap menjaga cakupan kompatibilitas.

2. Gunakan matrix include, bukan produk kartesius penuh

Sering kali Anda tidak butuh semua pasangan runtime × OS × database. Dengan include, Anda bisa memilih kombinasi eksplisit yang benar-benar penting. Ini hampir selalu lebih efisien dibanding membuat seluruh kombinasi lalu mencoba mematikannya satu per satu.

3. Pindahkan verifikasi mahal ke jalur terpisah

Contoh verifikasi mahal:

  • test end-to-end browser penuh,
  • migrasi database besar,
  • contract test lintas layanan,
  • security scan yang memakan waktu.

Jika tidak semua perubahan memerlukan itu, pisahkan ke workflow khusus, scheduled run, atau trigger manual saat dibutuhkan.

4. Hindari duplikasi setup

Jika beberapa job berbagi langkah yang sama, pertimbangkan reusable workflow atau composite action. Tujuannya bukan sekadar merapikan YAML, tetapi mengurangi kesalahan konfigurasi antar job yang sering membuat cache tidak konsisten atau command berbeda tanpa sengaja.

Cache dependency yang aman dan efektif

Cache dapat menurunkan durasi instalasi dependency, tetapi harus dirancang agar tidak mencampur environment yang berbeda. Prinsip umumnya:

  • Masukkan OS dan versi runtime ke identitas cache jika hasil instalasi bergantung pada keduanya.
  • Gunakan lockfile sebagai komponen utama key agar cache invalid saat dependency berubah.
  • Jangan mengandalkan cache untuk menjamin reproduksibilitas; install tetap harus deterministik.
  • Lebih aman meng-cache artefak dependency manager daripada folder build yang rapuh, kecuali Anda memahami implikasinya.

Pada GitHub Actions, ekosistem populer seperti Node sering sudah didukung oleh setup-* action dengan opsi cache bawaan. Itu biasanya pilihan yang lebih aman dibanding membuat cache manual tanpa kebutuhan khusus.

Kesalahan umum pada cache

  • Key terlalu umum: cache Linux dipakai di Windows, atau dependency runtime lama dipakai untuk runtime baru.
  • Meng-cache output yang tidak portable: binary hasil kompilasi native sering tidak aman dipakai lintas environment.
  • Menganggap cache sebagai sumber state: ketika cache hilang atau rusak, pipeline mendadak gagal.

Paralelisme: percepat tanpa membuat runner kewalahan

Menambah paralelisme tidak selalu membuat pipeline lebih cepat secara nyata. Jika runner terbatas, terlalu banyak job paralel justru menciptakan antrean. Jika service container berat dijalankan serentak, bottleneck bisa pindah ke jaringan, disk, atau startup database.

Prinsip praktisnya:

  • Paralelkan job yang independen dan murah lebih dulu.
  • Batasi job berat agar tidak saling berebut resource.
  • Prioritaskan hasil cepat untuk pull request: lint dan baseline test harus selesai paling awal.
  • Jika platform CI mendukung pembatasan konkruensi, gunakan untuk mencegah branch lama menghabiskan runner saat ada commit baru.

Tips debugging saat paralelisme justru memperlambat

  • Bandingkan waktu antrean versus waktu eksekusi nyata.
  • Lihat apakah startup service container lebih mahal daripada test-nya sendiri.
  • Periksa apakah test gagal flakey hanya saat banyak job berjalan bersamaan.
  • Pastikan test tidak berebut port, direktori sementara, atau resource eksternal yang sama.

Conditional run berdasarkan perubahan file

Ini salah satu teknik paling hemat biaya untuk tim kecil-menengah. Intinya, hanya jalankan matrix relevan jika area yang memengaruhi job tersebut berubah.

Contoh kebijakan yang umum:

  • Perubahan docs/** atau file Markdown: lewati unit/integration test.
  • Perubahan frontend/**: jalankan workflow frontend, jangan memicu integration test backend jika tidak ada kontrak bersama yang berubah.
  • Perubahan migration atau layer database: jalankan integration test database penuh.
  • Perubahan file CI atau lockfile: jalankan lebih banyak pemeriksaan karena risiko sistemik lebih tinggi.

Namun, conditional run juga punya risiko. Aturan yang terlalu agresif dapat melewatkan bug karena dependensi antarmodul tidak dipetakan dengan baik. Jika arsitektur Anda monorepo atau banyak modul saling terkait, definisi path harus ditinjau berkala.

Trade-off biaya vs cakupan

Tidak ada matrix build yang “sempurna” untuk semua tim. Desain terbaik adalah yang memberi sinyal kuat pada risiko tertinggi dengan biaya yang masih masuk akal.

Kapan memilih cakupan lebih sempit

  • Tim kecil dengan budget runner terbatas.
  • Mayoritas trafik dan deploy hanya ke satu OS/runtime.
  • Banyak kombinasi historis jarang sekali menangkap bug nyata.
  • Waktu feedback pull request menjadi masalah utama produktivitas.

Kapan memperluas cakupan masih layak

  • Produk SDK, library, atau tool CLI yang memang dipakai lintas environment.
  • Ada kewajiban mendukung beberapa versi runtime untuk pelanggan.
  • Riwayat insiden menunjukkan bug kompatibilitas sering terjadi.
  • Perubahan dependency atau platform sedang aktif dan berisiko tinggi.

Pendekatan yang sering efektif adalah cakupan penuh secara periodik dan cakupan prioritas pada pull request. Dengan begitu Anda tidak kehilangan visibilitas, tetapi juga tidak membebani setiap perubahan kecil.

Anti-pattern yang sering merusak efisiensi CI matrix build

  • Menjalankan lint, format check, dan static analysis di semua sel matrix. Biasanya tidak perlu.
  • Menguji semua kombinasi runtime × OS × database setiap PR. Ini jarang sebanding dengan sinyal yang didapat.
  • Semua job wajib lulus untuk merge, termasuk job eksperimental. Akibatnya developer menunggu hal yang tidak kritis.
  • Cache global tanpa pemisahan environment. Cepat sesaat, tetapi sulit di-debug saat rusak.
  • Conditional run terlalu agresif. Pipeline memang cepat, tetapi ada blind spot besar.
  • Memakai fail-fast untuk investigasi kompatibilitas. Anda hemat waktu, tetapi kehilangan peta kegagalan lengkap.
  • Menganggap durasi total sebagai satu-satunya metrik. Yang sering lebih penting adalah waktu sampai job wajib selesai.

Checklist audit pipeline yang bisa langsung dipakai

Gunakan daftar ini untuk meninjau CI matrix build Anda:

  1. Apakah setiap dimensi matrix punya alasan bisnis atau teknis yang jelas?
    Jika tidak, hapus.
  2. Kombinasi mana yang paling merepresentasikan produksi?
    Pastikan itu menjadi baseline wajib.
  3. Job mana yang sebenarnya cukup dijalankan sekali?
    Biasanya lint, format, dan static analysis.
  4. Apakah integration test dijalankan hanya pada kombinasi representatif?
    Jika belum, kecilkan ruang matrix.
  5. Apakah ada pemisahan job wajib, opsional, dan periodik?
    Jika belum, reviewer kemungkinan menunggu terlalu lama.
  6. Apakah fail-fast digunakan hanya di tempat yang tepat?
    Aktifkan untuk feedback cepat, nonaktifkan untuk investigasi penuh.
  7. Apakah cache memasukkan lockfile, OS, dan runtime bila perlu?
    Pastikan aman, bukan sekadar cepat.
  8. Apakah perubahan dokumentasi atau area non-kritis masih memicu matrix penuh?
    Tambahkan conditional run berbasis path.
  9. Apakah job berat bersaing memperebutkan resource runner?
    Tinjau paralelisme dan service container.
  10. Apakah ada kombinasi yang selama beberapa bulan tidak pernah menangkap bug?
    Pertimbangkan pindahkan ke scheduled run.
  11. Apakah durasi yang diukur adalah total pipeline atau waktu sampai status wajib tersedia?
    Optimalkan metrik yang benar.
  12. Apakah konfigurasi matrix mudah dipahami tim?
    Matrix yang terlalu pintar tetapi sulit dirawat akan kembali membengkak.

Penutup

CI matrix build yang efisien lahir dari keputusan selektif, bukan dari menghapus verifikasi secara membabi buta. Fokuskan pull request pada kombinasi baseline dan edge yang paling bernilai, pindahkan verifikasi mahal ke jalur yang sesuai, gunakan cache dengan hati-hati, dan jalankan job hanya saat perubahan memang relevan.

Jika Anda memulai dari pipeline yang sudah terlanjur besar, jangan langsung mengubah semuanya sekaligus. Audit dulu job mana yang wajib, mana yang redundan, dan mana yang bisa dipindah ke scheduled run. Perbaikan kecil seperti mematikan matrix untuk lint atau menambahkan path-based trigger sering memberi dampak paling cepat dengan risiko paling rendah.