Seluruh perangkat yang kita bangun di Section ini — PHPUnit, PHPStan, PHP-CS-Fixer — masih bergantung pada satu hal yang rapuh: seseorang harus ingat menjalankannya. Continuous integration (CI) menghapus ketergantungan itu: sebuah server menonton repositori, dan setiap push atau pull request otomatis memicu eksekusi rangkaian perintah yang sama di lingkungan bersih. Hasil akhirnya satu kata: hijau atau merah. Chapter penutup Section ini menyatukan lint, static analysis, dan test menjadi pipeline utuh di GitHub Actions, lengkap dengan matriks versi PHP, artifact, proteksi branch, dan cermin lokalnya di laptop setiap anggota tim.

Konsep Pipeline #

Pipeline adalah daftar tahapan yang dijalankan berurutan pada setiap perubahan, berhenti pada kegagalan pertama. Karena berjalan di lingkungan segar tanpa sisa-sisa komputer siapa pun, pipeline mematikan alasan klasik “di komputer saya jalan”: bila perubahan lolos di sana, ia lolos di kondisi awal yang sama dengan siapa pun yang meng-clone repositori. Untuk proyek PHP yang telah mengikuti chapter-chapter sebelumnya, tahapan pipeline beserta perannya terpetakan begini:

TahapPerintah contohYang dijaga
installcomposer installdependensi terpasang persis seperti composer.lock
lintvendor/bin/php-cs-fixer fix --dry-run --diffkonsistensi gaya kode
static analysisvendor/bin/phpstan analyse --no-progresskontrak tipe dan simbol
testvendor/bin/phpunitperilaku aplikasi

Setiap tahap berakhir dengan exit code: nol berarti lanjut ke tahap berikutnya, bukan nol berarti seluruh pipeline merah. Prinsipnya sesederhana itu — dan kita bisa memegangnya dengan tangan sendiri.

Runner Stage Mini #

Mari kita buat mesin pipeline dalam ukuran mainan: sebuah fungsi yang menerima daftar tahap dan sebuah executor yang mengembalikan exit code tiap tahap. Fungsi ini menjalankan tahap satu per satu dan berhenti pada kegagalan pertama — persis kontrak yang dipegang server CI:

php
<?php

function jalankanPipeline(array $tahapan, callable $eksekusi): int
{
    foreach ($tahapan as $tahap) {
        echo "== $tahap: mulai\n";

        if ($eksekusi($tahap) !== 0) {
            echo "== $tahap: GAGAL - pipeline berhenti di tahap ini\n";

            return 1;
        }

        echo "== $tahap: lulus\n";
    }

    echo "Pipeline hijau: seluruh tahap lulus\n";

    return 0;
}

$simulasiMerah = [
    'lint' => 0,
    'static-analysis' => 0,
    'test' => 1,
];

echo "-- Simulasi pull request MERAH --\n";
$kodeKeluar = jalankanPipeline(
    array_keys($simulasiMerah),
    fn (string $tahap): int => $simulasiMerah[$tahap],
);
echo "Exit code: $kodeKeluar\n";

echo "\n";

$simulasiHijau = [
    'lint' => 0,
    'static-analysis' => 0,
    'test' => 0,
];

echo "-- Simulasi pull request HIJAU --\n";
$kodeKeluar = jalankanPipeline(
    array_keys($simulasiHijau),
    fn (string $tahap): int => $simulasiHijau[$tahap],
);
echo "Exit code: $kodeKeluar\n";

Output yang Dihasilkan:

text
-- Simulasi pull request MERAH --
== lint: mulai
== lint: lulus
== static-analysis: mulai
== static-analysis: lulus
== test: mulai
== test: GAGAL - pipeline berhenti di tahap ini
Exit code: 1

-- Simulasi pull request HIJAU --
== lint: mulai
== lint: lulus
== static-analysis: mulai
== static-analysis: lulus
== test: mulai
== test: lulus
Pipeline hijau: seluruh tahap lulus
Exit code: 0

Mari kita bedah bagiannya:

  1. jalankanPipeline() mengiterasi tahap secara berurutan; exit code dari executor diperiksa untuk tiap tahap, dan nilai selain nol langsung menghentikan seluruh pipeline dengan nilai balik 1.
  2. $simulasiMerah memetakan tiap tahap ke exit code mainan: lint dan static analysis lulus, test gagal.
  3. Closure fn (string $tahap): int => ... berperan sebagai executor — dalam CI sungguhan, peran ini diisi proses nyata seperti vendor/bin/phpunit.
  4. Simulasi kedua memakai peta yang sama dengan seluruh nilai nol, membuktikan jalur hijau sampai ke baris terakhir.

Perhatikan bahwa urutan bekerja seperti dirancang pada chapter-chapter sebelumnya: kegagalan di tahap test baru terasa setelah lint dan static analysis lulus. Pentingnya urutan terlihat jelas kalau kita membayangkan kebalikannya — test yang berjalan menit-menit lebih dulu akan terbuang sia-sia hanya untuk menemukan pelanggaran spasi yang bisa terdeteksi dalam dua detik.

Workflow GitHub Actions #

GitHub Actions membaca berkas YAML di .github/workflows/. Workflow berikut menjalankan ketiga tahap kita pada tiga versi PHP sekaligus:

yaml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php-version: ['8.2', '8.3', '8.4']
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: none

      - uses: ramsey/composer-install@v3

      - name: Lint
        run: vendor/bin/php-cs-fixer fix --dry-run --diff

      - name: Static analysis
        run: vendor/bin/phpstan analyse --no-progress

      - name: Test
        run: vendor/bin/phpunit

Bagian-bagian kuncinya:

  1. on: menentukan pemicu: setiap push ke main dan setiap pull request — termasuk yang belum dibuka reviewer mana pun.
  2. strategy.matrix.php-version menggandakan job menjadi tiga salinan (PHP 8.2, 8.3, dan 8.4) dengan ${{ matrix.php-version }} menyuntikkan nilai tiap salinan. Pemutakhiran versi menjadi aman di sini: deprecation baru di 8.4 tertangkap jauh sebelum tim produksi benar-benar pindah versi.
  3. fail-fast: false membuat ketiga salinan tetap berjalan sampai selesai walau satu sudah merah, sehingga kita melihat gambaran penuh, bukan hanya kegagalan pertama.
  4. setup-php@v2 adalah action standar ekosistem PHP untuk memasang PHP versi mana pun; coverage: none melewatkan driver coverage yang tidak dibutuhkan tahap-tahap ini.
  5. ramsey/composer-install@v3 menjalankan composer install dengan cache unduhan yang dikunci pada hash composer.lock — eksekusi kedua dan seterusnya nyaris tanpa unduhan.
  6. Tiga langkah terakhir adalah pipeline kita sendiri: lint, static analysis, test — masing-masing membuat job merah begitu exit code-nya bukan nol.

Artifact untuk Otopsi #

Pipeline yang merah meninggalkan satu pertanyaan: “persisnya apa yang gagal?” Log mentah menjawab sebagian, tetapi laporan terstruktur jauh lebih enak diotopsi. Artifact adalah berkas yang diunggah oleh sebuah eksekusi dan dapat diunduh dari halaman run — misalnya laporan JUnit dari PHPUnit. Tambahkan dua langkah berikut menggantikan langkah test sebelumnya:

yaml
      - name: Test
        run: vendor/bin/phpunit --log-junit phpunit-junit.xml

      - name: Unggah laporan JUnit
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: phpunit-${{ matrix.php-version }}
          path: phpunit-junit.xml

Flag --log-junit membuat PHPUnit menulis laporan terstruktur — tiap test beserta statusnya — ke berkas phpunit-junit.xml. Kondisi if: always() memastikan laporan tetap diunggah justru ketika test gagal, saat laporan paling dibutuhkan. Nama artifact menyertakan versi PHP dari matriks agar ketiga salinan tidak saling menimpa.

Proteksi Branch dan Disiplin Hijau #

Pipeline hanya menjaga kalau hasilnya tidak bisa dilewati. GitHub menyediakan branch protection untuk itu: pada branch main, jadikan ketiga pemeriksaan (lint, static analysis, test) sebagai required status checks, larang force push, dan wajibkan pull request selalu mutakhir sebelum merge. Setelah pengaturan itu aktif, tombol merge berubah menjadi gerbang — merah berarti terkunci.

Aturan di atas baru benar-benar bekerja kalau disertai disiplin never-green-merge: tidak pernah merge saat pipeline merah atau masih berjalan, dan tidak pernah “re-run sampai hijau” tanpa membaca penyebab kegagalannya. Kegagalan flaky boleh dijalankan ulang, tetapi kegagalan yang tidak dipahami harus dibaca dulu; kalau tidak, yang dijalankan ulang bukan pipeline, melainkan keberuntungan.

Paritas Lokal #

Pipeline yang paling rapi sekalipun menimbulkan gesekan kalau perintah di CI berbeda dengan perintah di laptop. Solusinya adalah satu sumber kebenaran: definisikan seluruh tahap sebagai Composer script, lalu biarkan CI dan manusia memanggil hal yang sama:

json
{
    "scripts": {
        "lint": "php-cs-fixer fix --dry-run --diff",
        "sa": "phpstan analyse --no-progress",
        "test": "phpunit",
        "ci": [
            "@lint",
            "@sa",
            "@test"
        ]
    }
}

Composer otomatis menempatkan vendor/bin ke PATH saat menjalankan script, sehingga nama pendek tanpa path sudah cukup. Kini composer ci di laptop menjalankan urutan yang persis sama dengan yang dijalankan server, dan workflow di atas bisa diringkas menjadi satu langkah run: composer ci. Bila tim lebih nyaman dengan make, bentuk Makefile yang setara terlihat begini:

makefile
.PHONY: ci lint sa test

ci: lint sa test

lint:
	vendor/bin/php-cs-fixer fix --dry-run --diff

sa:
	vendor/bin/phpstan analyse --no-progress

test:
	vendor/bin/phpunit

Target ci bergantung pada ketiga tahap sehingga make ci menjalankan semuanya dalam urutan yang benar. Command runner seperti just menawarkan kemampuan serupa dengan sintaks berbeda — pilihan alat mana pun sah, selama satu perintah lokal bermakna sama dengan satu eksekusi CI.

Ringkasan #

Continuous integration menyatukan seluruh perangkat QA Section ini menjadi satu kontrak: setiap push menjalankan install, lint, static analysis, dan test di lingkungan bersih; matriks versi PHP mengawal pemutakhiran; artifact menyimpan laporan untuk otopsi; proteksi branch memastikan hasil tidak bisa dilewati; dan Composer script menjaga paritas lokal. Runner mini kita menunjukkan inti mekanismenya: tahap berurutan, berhenti pada kegagalan pertama, satu exit code sebagai vonis. Dengan ini rangkaian Testing dan Quality Assurance tertutup — kualitas bukan lagi kegiatan manual, melainkan properti yang terpasang di alur kerja. Tentu saja pipeline yang merah hanya memberi tahu bahwa ada yang salah, bukan di mana akar masalahnya bersembunyi; mencarinya adalah pekerjaan Section berikutnya: Debugging dan Observability.

Referensi #