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:
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 langkah | Contoh | Peran |
|---|---|---|
uses | actions/checkout@v4 | memanggil action siap pakai dari marketplace |
run | mvn -B -q package | mengeksekusi perintah shell di runner |
env | secrets.REGISTRY_TOKEN | menyuntikkan 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:
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:
== 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.0Mari kita bedah mesinnya:
record Pekerjaan(nama, needs): daftar job ditulis tidak berurut —publishlebih dulu — meniru YAML nyata yang urutan penulisannya bebas. Urutan eksekusi justru dihitung, bukan dibaca.urutanEksekusi(...): algoritma Kahn — hitung sisa prasyarat per job, antrekan yang nilainya nol, lalu setiap job yang selesai menurunkan hitungan tetangganya. Hasilnyabuild→test-jdk→publish, urutan yang dijamin memenuhi semuaneeds.KONTEKS: peta nilai ekspresi dengan nilai beku —github.refberisirefs/tags/v2.1.0karena pemicunya tag,runner.osdilaporkan runner Linux. Inilah bentuk data dari${{ github.ref }}di runner sungguhan.substitusi(...): pemindai teks yang mengganti tiap${{ path }}dengan nilai konteksnya — persis tahap evaluasi ekspresi yang dilakukan GitHub sebelum perintahrundiserahkan ke shell.turunan:env.IMAGE_TAGditurunkan darigithub.refdengan membuang awalanrefs/tags/— pola yang sama dengangithub.ref_namepada workflow nyata, menghasilkanv2.1.0untuk 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:
- name: Dorong ke registry internal
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: echo "${REGISTRY_TOKEN}" | docker login registry.internal -u bot --password-stdinPipeline 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 #
- GitHub: Workflow Syntax untuk GitHub Actions
- GitHub: Quickstart GitHub Actions
- GitHub: Understanding GitHub Actions
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
AlurKerja.java
Unduh berkasimport 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));
}
}
}