ทางที่ใช้กันมาตลอดคือ ssh เข้า server แล้วพิมพ์ git pull, composer install, php artisan migrate ทีละบรรทัด บางคนเก็บเป็น script ไว้รันครั้งเดียว ก็ใช้ได้นะ จนวันที่ลืมรัน test ก่อน หรือ push โค้ดที่ test ยังแดงขึ้นไปตอนรีบ
CI/CD คือการให้เครื่องทำขั้นตอนพวกนี้แทนเราทุกครั้งที่ push
- CI (continuous integration) รัน test ให้ทุก push ทุก branch เราจะรู้ตั้งแต่ก่อน merge ว่าโค้ดพังหรือเปล่า
- CD (continuous deployment) deploy ขึ้น production ให้เองเมื่อ test ของ
mainผ่าน
โพสต์นี้ใช้ GitHub Actions เพราะอยู่ใน GitHub ที่เราเก็บโค้ดอยู่แล้ว ไม่ต้องสมัครบริการเพิ่ม
workflow คือไฟล์ YAML ไฟล์หนึ่งใน repo
GitHub อ่านทุกไฟล์ในโฟลเดอร์ .github/workflows ไฟล์หนึ่งคือ workflow หนึ่งตัว คำที่ต้องรู้มีสี่คำ
- workflow ไฟล์หนึ่งไฟล์ บอกว่ารันเมื่อไร (
on:) และมี job อะไรบ้าง - job กลุ่มของขั้นตอนที่รันบนเครื่องเดียวกัน job ต่าง ๆ รันพร้อมกันได้ ถ้าไม่ได้บอกให้รอกัน
- step ขั้นตอนหนึ่งใน job เป็นคำสั่ง shell (
run:) หรือ action สำเร็จรูป (uses:) เช่นactions/checkout - runner เครื่องที่รับ job ไปรัน จะเป็นเครื่องของ GitHub หรือเครื่องของเราเองก็ได้ เรื่องนี้มีรายละเอียดอยู่ในโพสต์เรื่อง self-hosted runner
repo สาธารณะใช้เครื่องของ GitHub ได้ไม่คิดเงิน ส่วน repo ส่วนตัวบนแพ็กเกจ Free ได้ 2,000 นาทีต่อเดือน (ราคาของ GitHub Actions) โปรเจกต์เล็กที่ push วันละไม่กี่ครั้งใช้ไม่หมด แต่ถ้า CI รันรอบละหลายนาทีและ push บ่อย ก็หมดก่อนสิ้นเดือนได้
แบ่งงานเป็นสาม job

job deploy รอ test ส่วน notify รอทั้งสองตัว แล้วรันเสมอ
name: CI
on:
push:
branches: ['**']
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps: # ...
deploy:
needs: [test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: # ...
notify:
needs: [test, deploy]
if: always()
runs-on: ubuntu-latest
steps: # ...
needs: [test]ทำให้ deploy รอจน test จบ ถ้า test ไม่ผ่าน GitHub จะข้าม deploy ไปเลยif:ของ deploy เช็กสองอย่าง push เท่านั้น และต้องเป็นmainเท่านั้น pull request กับการกดรันเองจากหน้าเว็บ (workflow_dispatch) จึงรัน test ได้แต่ไม่ deployif: always()ของ notify สำคัญกว่าที่เห็น ปกติ GitHub ข้าม job ที่needsไม่ผ่าน ซึ่งก็คือรอบที่เราอยากรู้ผลที่สุด รายละเอียดของ job นี้อยู่ในโพสต์เรื่องส่งผลเข้า Telegram
ส่วนขั้นตอนใน job deploy มีลำดับที่ต้องคิดหลายจุด จึงแยกไปอธิบายในโพสต์เรื่องลำดับการ deploy
ลำดับใน job test
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: app_testing
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- uses: actions/setup-node@v7
with:
node-version: '24'
- run: cp .env.testing .env
- run: composer install --no-interaction --prefer-dist --no-progress
- run: php artisan optimize:clear
- run: php artisan key:generate
- run: composer analyse
- run: npm ci && npm run build
- run: npm test
- run: php artisan snapshot:load testing --env=testing --force
- run: php artisan migrate --env=testing --force
- run: php artisan test --compact
services: เปิด PostgreSQL ใน container ให้ job นี้ใช้ ถ้าโปรเจกต์ใช้ MySQL ก็เปลี่ยนเป็น image mysql:8.4 แล้วตั้งตัวแปร MYSQL_* ตามเอกสารของ image นั้นแทน ส่วนลำดับของ step มีเหตุผลอยู่สามจุด
- static analysis ขึ้นก่อน
composer analyseคือ PHPStan ซึ่งไม่ต้องใช้ฐานข้อมูลและจบเร็ว ถ้าเจอบั๊กก็หยุดตรงนี้ ไม่ต้องรอ test ชุดใหญ่ (PHPStan ทำอะไรบ้าง) - build ก่อน test ฝั่ง PHP feature test ที่ render หน้าต้องอ่าน Vite manifest และถ้าใช้ Wayfinder ไฟล์ route ฝั่ง JavaScript ก็มาจากขั้น build เหมือนกัน ถ้าสลับลำดับ test จะแดงด้วยเรื่องที่ไม่เกี่ยวกับโค้ดเลย Vitest จึงรันต่อจาก build ทันที
- โหลดฐานข้อมูลตั้งต้นก่อน migrate ทุกรอบเริ่มจากไฟล์ SQL ชุดเดียวกัน เหตุผลอยู่ในโพสต์เรื่อง snapshot ถ้าโปรเจกต์ยังใช้
RefreshDatabaseก็ข้ามขั้นsnapshot:loadไปได้
ข้าม CI เมื่อแก้แค่เอกสาร
commit ที่แก้แค่ README หรือโน้ตใน repo ไม่ได้เปลี่ยนโค้ด แต่ CI ก็ยังรันเต็มชุดและ deploy ซ้ำ paths-ignore บอกให้ข้ามรอบที่ไฟล์ที่เปลี่ยนทุกไฟล์ตรงกับรายการ
on:
push:
branches: ['**']
paths-ignore: ['docs/**', '**/*.md']
pull_request:
branches: [main]
paths-ignore: ['docs/**', '**/*.md']
ถ้า commit แก้ทั้ง .md และไฟล์ PHP ก็ยังรันตามปกติ เพราะไม่ใช่ทุกไฟล์ที่ตรงกับรายการ
มีสองจุดที่ทำให้งงได้
- ถ้าตั้ง required status check ไว้ใน branch protection PR ที่แก้แค่เอกสารจะ merge ไม่ได้ เพราะ check ค้างเป็น Pending รอ workflow ที่ไม่ได้รัน (เอกสารของ GitHub) ทางแก้ที่ใช้กันคือไม่ใช้
paths-ignoreกับ workflow ที่เป็น required check หรือย้ายการเช็กไฟล์ไปไว้ใน step แทน branches: ['*']ไม่รวม branch ที่มี/เช่นfeature/loginเพราะ*ไม่ข้าม/ถ้าตั้งชื่อ branch แบบนี้ให้ใช้'**'แทน
ยกเลิกรอบเก่าเมื่อมี push ใหม่
ถ้า push สามครั้งติดกันใน branch เดียว GitHub จะรัน CI สามรอบ ทั้งที่เราอยากรู้แค่ผลของ commit ล่าสุด concurrency จัดให้รอบที่อยู่ group เดียวกันรันทีละรอบ และ cancel-in-progress ยกเลิกรอบเก่าเมื่อรอบใหม่มาถึง
concurrency:
group: ci-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
cancel-in-progress: true
นิพจน์นี้ให้ branch อื่นใช้ชื่อ branch เป็น group รอบใหม่จึงยกเลิกรอบเก่าได้ ส่วน main ได้ group ของตัวเองทุกรอบ (run_id ไม่ซ้ำกัน) เพราะถ้า GitHub ยกเลิกรอบของ main รอบนั้นก็ไม่ได้ deploy ไปด้วย ถ้า push สองครั้งติดกัน แล้วรอบที่สองพังเพราะเหตุอื่น commit แรกก็จะไม่ได้ขึ้น production เลย
job deploy มี group แยกของตัวเองอีกชั้น ตั้ง cancel-in-progress: false ให้รอคิวแทนการยกเลิก เพราะ deploy ที่หยุดกลางทางแย่กว่า deploy ที่ต้องรอ
deploy:
concurrency:
group: deploy-production
cancel-in-progress: false
ให้ AI agent อ่านผล CI แทนการนั่งรอ
พอ CI รันเองทุก push งานที่เหลือคือดูผล ถ้าใช้ Claude Code หรือ agent ตัวอื่นเขียนโค้ด ไม่ต้องให้ agent สั่ง gh run watch รอจนจบ job notify ส่งผลมาให้เราอยู่แล้ว ถ้ารอบไหนแดง ค่อยให้ agent อ่านเฉพาะ log ของ step ที่พัง
gh run view --log-failed
ได้ log เฉพาะ step ที่ไม่ผ่าน สั้นพอให้ agent อ่านแล้วแก้ต่อได้เลย ไม่ต้องไล่ log ทั้งรอบ






