Konsep CI/CD dari chapter sebelumnya butuh mesin nyata — dan bila kode Anda sudah berada di GitHub, mesin itu sudah tersedia. GitHub Actions menjalankan pipeline dari berkas YAML yang hidup di dalam repository sendiri, dikerjakan oleh runner yang disediakan GitHub tanpa server tambahan. Tidak ada aplikasi terpisah untuk dipasang, tidak ada antrean untuk dirawat: workflow menyala saat Anda push, dan hasilnya terlampir tepat pada commit yang memicunya. Chapter ini membangun workflow rilis lengkap untuk aplikasi kasir — dari pemicu tag, graf dependensi antar job, caching Maven, hingga publikasi image.

Workflow sebagai Berkas di Repository #

Workflow disimpan di .github/workflows/ — satu berkas YAML berarti satu workflow. Bagian on menentukan pemicunya; workflow rilis kita menyala hanya saat tag berawalan v didorong:

yaml .github/workflows/release.yml — pemicu dan job
name: Rilis Kasirapp

on:
  push:
    tags: ["v*"]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "25"
          cache: maven
      - run: mvn -B -q package

  test-jdk:
    needs: build
    strategy:
      matrix:
        java: [21, 25]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: ${{ matrix.java }}
          cache: maven
      - run: mvn -B test

  publish:
    needs: test-jdk
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Bangun dan dorong image
        env:
          IMAGE_NAME: registry.internal/kasirapp
        run: |
          docker build -t ${{ env.IMAGE_NAME }}:${{ github.ref_name }} .
          docker push ${{ env.IMAGE_NAME }}:${{ github.ref_name }}

Tiga jenis langkah bekerja bergandengan dalam satu job:

Jenis langkahContohPeran
usesactions/checkout@v4memanggil action siap pakai dari marketplace
runmvn -B -q packagemengeksekusi perintah shell di runner
envsecrets.REGISTRY_TOKENmenyuntikkan variabel lingkungan

Action checkout menarik kode ke runner, setup-java memasang JDK distribusi Temurin versi 25 sekaligus mengaktifkan cache Maven, dan env bertemu rapi dengan konfigurasi environment dari chapter sebelumnya.

Graf Dependensi antar Job #

Kunci needs menyatakan job tidak boleh jalan sebelum yang ditunjuk selesai — publish menunggu test-jdk, test-jdk menunggu build. Runner mengeksekusi graf ini dalam urutan topologis, dan job yang kehilangan prasyarat (karena gagal) ditandai skipped persis seperti perilaku fail-fast yang kita modelkan di chapter CI/CD. Matriks matrix.java: [21, 25] melipatgandakan job test-jdk menjadi dua salinan — satu per versi JDK — untuk menangkap regressi kompatibilitas. Mesin pengurutan dan substitusinya bisa kita lihat utuh di sini:

java AlurKerja.java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Workflow GitHub Actions mini: urutan pekerjaan ber-graf needs dan substitusi ekspresi. */
public class AlurKerja {

    /** Satu pekerjaan: nama dan daftar pekerjaan yang harus selesai lebih dulu. */
    record Pekerjaan(String nama, List<String> needs) {}

    /** Konteks ekspresi ${{ ... }} dengan nilai beku ala runner nyata. */
    static final Map<String, String> KONTEKS = Map.of(
            "github.ref", "refs/tags/v2.1.0",
            "github.sha", "9f2ab41c08d7",
            "runner.os", "Linux",
            "env.IMAGE_NAME", "registry.internal/kasirapp");

    /** Urutan eksekusi = urutan topologis graf needs (Kahn). */
    static List<String> urutanEksekusi(List<Pekerjaan> pekerjaan) {
        Map<String, Integer> sisa = new LinkedHashMap<>();
        Map<String, List<String>> pemenuh = new LinkedHashMap<>();
        for (Pekerjaan p : pekerjaan) {
            sisa.put(p.nama(), p.needs().size());
            for (String n : p.needs()) {
                pemenuh.computeIfAbsent(n, k -> new java.util.ArrayList<>()).add(p.nama());
            }
        }
        Deque<String> siap = new ArrayDeque<>();
        for (var e : sisa.entrySet()) {
            if (e.getValue() == 0) {
                siap.add(e.getKey());
            }
        }
        List<String> urutan = new java.util.ArrayList<>();
        while (!siap.isEmpty()) {
            String j = siap.poll();
            urutan.add(j);
            for (String b : pemenuh.getOrDefault(j, List.of())) {
                if (sisa.merge(b, -1, Integer::sum) == 0) {
                    siap.add(b);
                }
            }
        }
        return urutan;
    }

    /** Ganti tiap ${{ path }} dengan nilai dari konteks. */
    static String substitusi(String teks, Map<String, String> konteksTambahan) {
        Map<String, String> semua = new LinkedHashMap<>(KONTEKS);
        semua.putAll(konteksTambahan);
        StringBuilder hasil = new StringBuilder();
        int i = 0;
        while (i < teks.length()) {
            int mulai = teks.indexOf("${{ ", i);
            if (mulai < 0) {
                hasil.append(teks, i, teks.length());
                break;
            }
            int selesai = teks.indexOf(" }}", mulai);
            String path = teks.substring(mulai + 4, selesai);
            hasil.append(teks, i, mulai);
            hasil.append(semua.getOrDefault(path, "<tidak ada: " + path + ">"));
            i = selesai + 3;
        }
        return hasil.toString();
    }

    public static void main(String[] args) {
        System.out.println("== Pemicu ==");
        System.out.println("push ke " + KONTEKS.get("github.ref")
                + " -> workflow release.yml aktif di " + KONTEKS.get("runner.os"));
        System.out.println();

        List<Pekerjaan> pekerjaan = List.of(
                new Pekerjaan("publish", List.of("test-jdk")),
                new Pekerjaan("build", List.of()),
                new Pekerjaan("test-jdk", List.of("build")));

        System.out.println("== Urutan eksekusi (urutan topologis graf needs) ==");
        int nomor = 1;
        for (String j : urutanEksekusi(pekerjaan)) {
            Pekerjaan p = pekerjaan.stream().filter(x -> x.nama().equals(j)).findFirst().orElseThrow();
            String needs = p.needs().isEmpty() ? "-" : String.join(", ", p.needs());
            System.out.printf("  %d. %-9s (needs: %s)%n", nomor++, p.nama(), needs);
        }
        System.out.println();

        System.out.println("== Substitusi ekspresi pada langkah publish ==");
        Map<String, String> turunan = Map.of(
                "env.IMAGE_TAG", KONTEKS.get("github.ref").substring("refs/tags/".length()));
        String[] langkah = {
                "docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .",
                "docker push ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}"
        };
        for (String l : langkah) {
            System.out.println("  sebelum : " + l);
            System.out.println("  sesudah : " + substitusi(l, turunan));
        }
    }
}

Output yang Dihasilkan:

text
== Pemicu ==
push ke refs/tags/v2.1.0 -> workflow release.yml aktif di Linux

== Urutan eksekusi (urutan topologis graf needs) ==
  1. build     (needs: -)
  2. test-jdk  (needs: build)
  3. publish   (needs: test-jdk)

== Substitusi ekspresi pada langkah publish ==
  sebelum : docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .
  sesudah : docker build -t registry.internal/kasirapp:v2.1.0 .
  sebelum : docker push ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
  sesudah : docker push registry.internal/kasirapp:v2.1.0

Mari kita bedah mesinnya:

  1. record Pekerjaan(nama, needs): daftar job ditulis tidak berurut — publish lebih dulu — meniru YAML nyata yang urutan penulisannya bebas. Urutan eksekusi justru dihitung, bukan dibaca.
  2. urutanEksekusi(...): algoritma Kahn — hitung sisa prasyarat per job, antrekan yang nilainya nol, lalu setiap job yang selesai menurunkan hitungan tetangganya. Hasilnya build → test-jdk → publish, urutan yang dijamin memenuhi semua needs.
  3. KONTEKS: peta nilai ekspresi dengan nilai beku — github.ref berisi refs/tags/v2.1.0 karena pemicunya tag, runner.os dilaporkan runner Linux. Inilah bentuk data dari ${{ github.ref }} di runner sungguhan.
  4. substitusi(...): pemindai teks yang mengganti tiap ${{ path }} dengan nilai konteksnya — persis tahap evaluasi ekspresi yang dilakukan GitHub sebelum perintah run diserahkan ke shell.
  5. turunan: env.IMAGE_TAG diturunkan dari github.ref dengan membuang awalan refs/tags/ — pola yang sama dengan github.ref_name pada workflow nyata, menghasilkan v2.1.0 untuk tag image.

Ekspresi, Konteks, dan Secrets #

Ekspresi ${{ ... }} membaca konteks bernama: github (ref, sha, actor), env (variabel langkah), matrix, dan secrets. Kredensial tidak pernah ditulis di YAML — GitHub menyimpannya terenkripsi sebagai secret repository dan hanya menyalin nilainya ke variabel lingkungan saat langkah berjalan:

yaml Langkah dengan kredensial dari GitHub Secrets
- name: Dorong ke registry internal
  env:
    REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
  run: echo "${REGISTRY_TOKEN}" | docker login registry.internal -u bot --password-stdin

Pipeline kini hidup di dalam repository: setiap tag v* memicu build, pengujian dua versi JDK, dan publikasi image — tanpa satu pun langkah manual. Namun setelah aplikasi berjalan di produksi, alat kerja berganti: yang kita butuhkan bukan lagi build tool, melainkan jejak peristiwa yang bisa diinterogasi. Chapter berikutnya membahas logging aplikasi — fondasi diagnosis produksi.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

AlurKerja.java

Unduh berkas
java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Workflow GitHub Actions mini: urutan pekerjaan ber-graf needs dan substitusi ekspresi. */
public class AlurKerja {

    /** Satu pekerjaan: nama dan daftar pekerjaan yang harus selesai lebih dulu. */
    record Pekerjaan(String nama, List<String> needs) {}

    /** Konteks ekspresi ${{ ... }} dengan nilai beku ala runner nyata. */
    static final Map<String, String> KONTEKS = Map.of(
            "github.ref", "refs/tags/v2.1.0",
            "github.sha", "9f2ab41c08d7",
            "runner.os", "Linux",
            "env.IMAGE_NAME", "registry.internal/kasirapp");

    /** Urutan eksekusi = urutan topologis graf needs (Kahn). */
    static List<String> urutanEksekusi(List<Pekerjaan> pekerjaan) {
        Map<String, Integer> sisa = new LinkedHashMap<>();
        Map<String, List<String>> pemenuh = new LinkedHashMap<>();
        for (Pekerjaan p : pekerjaan) {
            sisa.put(p.nama(), p.needs().size());
            for (String n : p.needs()) {
                pemenuh.computeIfAbsent(n, k -> new java.util.ArrayList<>()).add(p.nama());
            }
        }
        Deque<String> siap = new ArrayDeque<>();
        for (var e : sisa.entrySet()) {
            if (e.getValue() == 0) {
                siap.add(e.getKey());
            }
        }
        List<String> urutan = new java.util.ArrayList<>();
        while (!siap.isEmpty()) {
            String j = siap.poll();
            urutan.add(j);
            for (String b : pemenuh.getOrDefault(j, List.of())) {
                if (sisa.merge(b, -1, Integer::sum) == 0) {
                    siap.add(b);
                }
            }
        }
        return urutan;
    }

    /** Ganti tiap ${{ path }} dengan nilai dari konteks. */
    static String substitusi(String teks, Map<String, String> konteksTambahan) {
        Map<String, String> semua = new LinkedHashMap<>(KONTEKS);
        semua.putAll(konteksTambahan);
        StringBuilder hasil = new StringBuilder();
        int i = 0;
        while (i < teks.length()) {
            int mulai = teks.indexOf("${{ ", i);
            if (mulai < 0) {
                hasil.append(teks, i, teks.length());
                break;
            }
            int selesai = teks.indexOf(" }}", mulai);
            String path = teks.substring(mulai + 4, selesai);
            hasil.append(teks, i, mulai);
            hasil.append(semua.getOrDefault(path, "<tidak ada: " + path + ">"));
            i = selesai + 3;
        }
        return hasil.toString();
    }

    public static void main(String[] args) {
        System.out.println("== Pemicu ==");
        System.out.println("push ke " + KONTEKS.get("github.ref")
                + " -> workflow release.yml aktif di " + KONTEKS.get("runner.os"));
        System.out.println();

        List<Pekerjaan> pekerjaan = List.of(
                new Pekerjaan("publish", List.of("test-jdk")),
                new Pekerjaan("build", List.of()),
                new Pekerjaan("test-jdk", List.of("build")));

        System.out.println("== Urutan eksekusi (urutan topologis graf needs) ==");
        int nomor = 1;
        for (String j : urutanEksekusi(pekerjaan)) {
            Pekerjaan p = pekerjaan.stream().filter(x -> x.nama().equals(j)).findFirst().orElseThrow();
            String needs = p.needs().isEmpty() ? "-" : String.join(", ", p.needs());
            System.out.printf("  %d. %-9s (needs: %s)%n", nomor++, p.nama(), needs);
        }
        System.out.println();

        System.out.println("== Substitusi ekspresi pada langkah publish ==");
        Map<String, String> turunan = Map.of(
                "env.IMAGE_TAG", KONTEKS.get("github.ref").substring("refs/tags/".length()));
        String[] langkah = {
                "docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .",
                "docker push ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}"
        };
        for (String l : langkah) {
            System.out.println("  sebelum : " + l);
            System.out.println("  sesudah : " + substitusi(l, turunan));
        }
    }
}