Cache dependency CI di GitHub Actions bisa memangkas waktu pipeline monorepo secara signifikan, tetapi hasilnya hanya bagus jika strategi cache disusun dengan benar. Cache yang terlalu longgar berisiko memakai dependency lama, sementara cache yang terlalu sensitif membuat miss rate tinggi dan manfaatnya hilang.

Untuk monorepo JavaScript/TypeScript, pendekatan yang paling aman biasanya adalah memisahkan cache dependency dari cache build output, membuat cache key berdasarkan file lock dan konteks OS/toolchain, lalu memantau apakah biaya restore dan save cache benar-benar lebih kecil daripada waktu instalasi atau build ulang. Artikel ini fokus pada implementasi praktis di GitHub Actions, termasuk contoh workflow YAML, jebakan umum, dan trade-off untuk tim.

Memahami masalah: monorepo membuat CI mudah lambat

Dalam monorepo, satu pipeline CI sering menangani banyak paket atau aplikasi sekaligus, misalnya frontend, backend, shared package, dan tool internal. Tanpa cache, setiap job bisa mengulang langkah yang sama:

  • mengunduh dependency dari registry,
  • melakukan ekstraksi package,
  • membangun ulang output yang sebenarnya belum berubah.

Biaya ini cepat membesar ketika:

  • jumlah package banyak,
  • workflow memakai matrix untuk beberapa versi runtime atau OS,
  • runner bersifat ephemeral sehingga tidak menyimpan state lokal antar-job.

GitHub Actions menyediakan mekanisme cache yang berguna, tetapi cache bukan selalu solusi otomatis. Ia efektif jika data yang disimpan:

  • cukup mahal untuk dibuat ulang,
  • cukup stabil untuk sering dipakai kembali,
  • cukup kecil sehingga biaya kompres, upload, dan download tidak menghapus manfaatnya.

Struktur monorepo JavaScript/TypeScript yang umum

Contoh struktur berikut cukup umum untuk workspace berbasis npm, pnpm, atau Yarn:

repo/
├─ apps/
│  ├─ web/
│  │  ├─ package.json
│  │  └─ src/
│  └─ api/
│     ├─ package.json
│     └─ src/
├─ packages/
│  ├─ ui/
│  │  ├─ package.json
│  │  └─ src/
│  └─ config/
│     └─ package.json
├─ package.json
├─ tsconfig.json
├─ pnpm-lock.yaml
└─ .github/
   └─ workflows/
      └─ ci.yml

Pada struktur seperti ini, biasanya ada satu file lock di root dan beberapa package di bawah workspace. Itu berarti perubahan pada dependency sering lebih tepat dideteksi dari file lock utama, bukan dari setiap package.json secara terpisah.

Perbedaan cache dependency dan cache build output

1. Cache dependency

Cache dependency menyimpan data yang dipakai untuk mempercepat instalasi package. Bentuk pastinya bergantung pada package manager, tetapi konsepnya sama: menyimpan artefak download atau cache lokal package manager agar proses install berikutnya tidak mulai dari nol.

Karakteristiknya:

  • umumnya relatif aman jika key berbasis lock file,
  • sering berguna lintas package dalam monorepo,
  • biasanya memberi manfaat konsisten jika dependency besar atau registry lambat.

Yang perlu dihindari: meng-cache node_modules secara membabi buta untuk semua skenario. Walau kadang terlihat cepat, pendekatan ini sering bermasalah karena:

  • ukuran cache besar,
  • bergantung pada OS dan arsitektur,
  • rentan konflik jika ada native module,
  • lebih sulit dijaga reproduktibilitasnya.

2. Cache build output

Cache build output menyimpan hasil kompilasi atau artefak antara, misalnya folder build tool atau cache incremental compiler. Ini berbeda dari dependency karena nilainya sangat dipengaruhi source code, konfigurasi build, dan kadang environment.

Karakteristiknya:

  • bisa memberi penghematan besar pada project besar,
  • lebih mudah invalid jika source berubah,
  • lebih berisiko menghasilkan output tidak relevan jika key terlalu longgar.

Contoh target cache build output yang sering masuk akal:

  • cache incremental TypeScript atau bundler,
  • cache task runner monorepo,
  • folder cache tool, bukan hasil distribusi final yang akan dipakai sebagai release artifact.

Catatan: Cache dan artifact bukan hal yang sama. Cache dipakai untuk mempercepat job berikutnya. Artifact dipakai untuk memindahkan hasil build dari satu job ke job lain atau untuk diunduh setelah workflow selesai. Jangan menukar fungsi keduanya.

Strategi cache key yang stabil namun aman

Tujuan cache key adalah menyeimbangkan dua hal yang saling menarik:

  • stabil, supaya cache sering kena hit,
  • aman, supaya cache tidak dipakai saat input penting sudah berubah.

Komponen cache key yang biasanya penting

  • sistem operasi runner,
  • versi runtime utama bila relevan,
  • package manager atau toolchain,
  • hash lock file untuk dependency,
  • hash file konfigurasi build untuk build cache tertentu.

Untuk dependency monorepo JavaScript/TypeScript, pola yang umum dan aman adalah:

${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}

Jika monorepo memakai beberapa lock file atau tool tambahan, masukkan semua file yang memang memengaruhi hasil install. Jangan menambahkan terlalu banyak file yang sering berubah tetapi tidak memengaruhi dependency, karena ini akan menaikkan miss rate.

Kapan perlu memasukkan versi Node.js ke key?

Jika dependency atau cache package manager dapat berbeda secara material antarversi Node.js, memasukkan versi Node ke key adalah langkah aman. Ini terutama penting jika ada native dependency, binary prebuild, atau perilaku install yang sensitif terhadap runtime.

Jika ingin konservatif, gunakan key yang memasukkan OS dan versi Node. Jika ingin lebih agresif mengejar hit rate, Anda bisa mengevaluasi apakah package manager cache tertentu benar-benar perlu dipisah per versi runtime.

Gunakan restore key sebagai fallback, bukan jalan pintas invalidation

Restore key berguna ketika key utama tidak ditemukan. Ia memungkinkan GitHub Actions mencari cache yang paling dekat. Contohnya:

restore-keys: |
  ${{ runner.os }}-node-
  ${{ runner.os }}-

Ini berguna untuk dependency cache karena package manager masih bisa melengkapi item yang kurang dari registry. Tetapi untuk build output, fallback terlalu longgar bisa berbahaya atau tidak berguna. Jika tool build tidak punya mekanisme validasi internal yang kuat, lebih baik key dibuat lebih ketat.

Contoh workflow GitHub Actions yang praktis

Berikut contoh workflow CI untuk monorepo JavaScript/TypeScript yang memisahkan cache dependency, build cache, dan artifact hasil build. Contoh ini sengaja generik agar tetap relevan untuk banyak setup workspace.

name: ci

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

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

      - name: Restore package manager cache
        uses: actions/cache@v4
        with:
          path: |
            ~/.pnpm-store
          key: ${{ runner.os }}-node20-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-node20-pnpm-
            ${{ runner.os }}-pnpm-

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Restore build cache
        uses: actions/cache@v4
        with:
          path: |
            .turbo
            apps/web/.next/cache
          key: ${{ runner.os }}-build-node20-${{ hashFiles('pnpm-lock.yaml', 'turbo.json', 'tsconfig.json', 'apps/**/package.json', 'packages/**/package.json') }}
          restore-keys: |
            ${{ runner.os }}-build-node20-

      - name: Lint
        run: pnpm lint

      - name: Test
        run: pnpm test

      - name: Build
        run: pnpm build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: web-dist
          path: |
            apps/web/dist
            apps/api/dist

Mengapa workflow di atas disusun seperti itu?

  • Cache dependency dipulihkan sebelum install agar package manager dapat memanfaatkan data yang sudah ada.
  • Key dependency memakai lock file sebagai sumber invalidation utama, karena perubahan lock file adalah sinyal paling akurat bahwa tree dependency berubah.
  • Cache build dipisahkan dari dependency karena siklus invalidation-nya berbeda.
  • Artifact diunggah setelah build untuk dipakai job lain atau sebagai hasil workflow, bukan sebagai cache jangka menengah.

Jika memakai beberapa job

Pada pipeline yang memisahkan job install, test, dan build, artifact sering lebih tepat dipakai untuk memindahkan hasil tertentu antar-job dalam workflow yang sama. Cache lebih cocok untuk reuse antar-run workflow, bukan semata antar-step atau antar-job pada run yang sama.

Invalidation: kapan cache harus dibuang

Invalidation adalah inti dari semua strategi cache. Aturan sederhananya: cache harus berubah ketika input yang memengaruhi hasil juga berubah.

Invalidation untuk dependency

Gunakan file lock sebagai sumber utama. Jika install juga dipengaruhi file lain seperti konfigurasi registry, patch package, atau setup toolchain tertentu, file-file itu perlu dipertimbangkan.

Contoh kasus yang sering terlewat:

  • file lock berubah tetapi key masih memakai package.json saja,
  • konfigurasi package manager berubah namun tidak masuk hash,
  • runner berganti OS tetapi key tidak memisahkannya.

Invalidation untuk build output

Untuk build cache, sumber invalidation biasanya lebih luas:

  • source code terkait,
  • konfigurasi bundler,
  • konfigurasi TypeScript,
  • environment yang memengaruhi hasil build.

Karena itu, cache build output lebih rumit dijaga. Jika tool yang Anda pakai sudah punya mekanisme hashing input sendiri, lebih baik manfaatkan cache folder tool tersebut daripada mencoba merancang key sangat kompleks secara manual.

Kapan cache justru memperlambat pipeline?

Ini bagian yang sering diabaikan. Cache tidak selalu mempercepat. Dalam beberapa kondisi, ia malah menambah waktu.

1. Ukuran cache terlalu besar

Jika cache berukuran ratusan megabyte atau lebih, waktu kompres, upload, dan download bisa lebih lama daripada mengunduh dependency ulang dari registry atau membangun ulang sebagian output.

Gejalanya:

  • step restore cache lama,
  • step save cache lama di akhir job,
  • pipeline terasa lambat walau hit rate tinggi.

2. Miss rate tinggi

Jika key terlalu sensitif, cache hampir selalu miss. Akibatnya Anda membayar overhead pencarian cache tanpa benar-benar mendapat reuse. Ini sering terjadi ketika key memasukkan file yang sering berubah tetapi tidak relevan, misalnya source file untuk dependency cache.

3. Data cache tidak benar-benar mahal untuk dibuat ulang

Jika install dependency sudah cepat atau build output kecil, cache bisa menjadi optimasi yang tidak sepadan. Selalu ukur, jangan berasumsi.

4. Runner atau jaringan membuat akses cache tidak efisien

Pada beberapa lingkungan, akses ke storage cache tidak cukup cepat. Dalam kondisi ini, pendekatan artifact atau optimasi dependency lain bisa lebih efektif.

Metrik yang perlu dipantau

Supaya strategi cache dependency CI di GitHub Actions tidak didasarkan pada tebakan, pantau metrik berikut:

  • cache hit rate: seberapa sering key utama atau restore key berhasil menemukan cache,
  • waktu restore cache: berapa lama langkah pengambilan cache,
  • waktu save cache: apakah penyimpanan cache menghabiskan waktu besar,
  • waktu install dependency: bandingkan dengan dan tanpa cache,
  • waktu build: apakah build cache benar-benar mengurangi durasi,
  • ukuran cache: cache besar biasanya sulit efisien,
  • stabilitas pipeline: apakah cache memicu hasil flaky atau tidak konsisten.

Praktiknya, Anda bisa memulai dengan mencatat durasi step utama selama beberapa pull request dan push ke branch utama. Jika setelah menambahkan cache total durasi tidak turun secara konsisten, strategi perlu dievaluasi ulang.

Jebakan umum dan cara menghindarinya

Cache poisoning

Cache poisoning terjadi ketika cache berisi data yang tidak semestinya dipercaya lalu dipakai oleh run lain. Risiko ini meningkat jika cache dapat ditulis dari konteks yang tidak sepenuhnya dipercaya, atau jika key terlalu umum sehingga data dari konteks berbeda bercampur.

Langkah mitigasi:

  • hindari key yang terlalu generik,
  • batasi apa yang disimpan dalam cache,
  • jangan mengandalkan cache sebagai sumber kebenaran untuk artefak rilis,
  • pastikan proses build tetap deterministik walau cache ada atau tidak ada.

Meng-cache node_modules tanpa alasan kuat

Ini godaan paling umum. Kadang berhasil, tetapi sering membawa biaya besar dan hasil tidak stabil, terutama di monorepo dengan native dependency. Biasanya lebih aman meng-cache store package manager atau cache tool yang memang dirancang untuk dipulihkan lintas-run.

Restore key terlalu longgar

Fallback memang berguna, tetapi untuk build cache ia bisa membuat cache lama dipakai terlalu sering. Hasilnya bisa berupa build lambat, cache yang tidak relevan, atau debugging yang membingungkan.

Mencampur cache dan artifact

Jika tujuan Anda adalah meneruskan hasil build dari job build ke job deploy dalam workflow yang sama, gunakan artifact. Jangan memaksa cache untuk kasus ini karena semantiknya berbeda.

Trade-off untuk tim: maintainability, biaya runner, reproducibility

Maintainability

Semakin kompleks strategi cache, semakin besar biaya perawatannya. Key yang terlalu rumit, banyak path cache, dan banyak pengecualian akan sulit dipahami tim baru. Mulailah dari desain sederhana:

  1. cache dependency berdasarkan lock file,
  2. tambahkan build cache hanya untuk tool yang benar-benar mahal,
  3. ukur hasilnya sebelum menambah kompleksitas.

Biaya runner

Jika runner dibayar berdasarkan durasi, cache yang tepat bisa mengurangi biaya. Tetapi cache yang salah justru menaikkan durasi total melalui restore/save yang berat. Karena itu, ukuran cache dan hit rate lebih penting daripada sekadar “memiliki cache”.

Reproducibility build

Build yang reproducible tetap harus berhasil meski cache kosong. Cache hanya akselerator, bukan fondasi korektness. Pastikan:

  • instalasi memakai lock file,
  • build tidak bergantung pada file sisa run sebelumnya,
  • artifact rilis dihasilkan dari proses yang dapat diulang tanpa cache.

Rekomendasi implementasi yang realistis

Jika Anda ingin mulai tanpa membuat pipeline terlalu rumit, gunakan urutan keputusan berikut:

  1. Mulai dari cache dependency dengan key berbasis OS, versi runtime, dan lock file.
  2. Jangan langsung cache node_modules kecuali Anda sudah mengukur dan tahu itu menguntungkan di lingkungan Anda.
  3. Tambahkan build cache hanya untuk tool yang memang menyimpan cache incremental dengan baik.
  4. Gunakan artifact untuk memindahkan hasil build antar-job, bukan sebagai pengganti cache.
  5. Pantau hit rate dan durasi step selama beberapa minggu, lalu sederhanakan atau perketat key sesuai data.

Untuk banyak tim, strategi yang paling sehat adalah strategi yang cukup konservatif: cache dependency yang aman, build cache yang terbatas dan terukur, serta artifact yang jelas perannya. Pendekatan ini biasanya memberi peningkatan kecepatan tanpa mengorbankan keandalan pipeline.

Penutup

Cache dependency CI di GitHub Actions untuk monorepo bukan sekadar menyalakan fitur cache, melainkan merancang batas invalidation yang tepat. Kunci yang baik harus cukup stabil untuk sering dipakai kembali, tetapi cukup ketat untuk menjaga hasil tetap benar.

Jika Anda memisahkan cache dependency dari build output, memakai lock file sebagai dasar invalidation, menggunakan restore key dengan hati-hati, dan memantau metrik nyata seperti hit rate serta durasi restore/save, pipeline CI monorepo bisa menjadi lebih cepat tanpa mengorbankan reproducibility dan maintainability tim.