ให้ GitHub Actions ตรวจ Pint, Rector, ESLint และ Prettier เฉพาะไฟล์ที่ pull request แก้

โปรเจกต์เก่าที่ไม่เคยรัน Pint ถ้าตรวจทั้ง repo ใน CI จะแดงทุก PR ด้วยไฟล์ที่ไม่มีใครแตะ workflow นี้หาไฟล์ที่ PR แก้ด้วย git diff แล้วส่งเฉพาะไฟล์เหล่านั้นให้ Pint, Rector, ESLint และ Prettier ในโหมดตรวจ พร้อมวิธีส่งชื่อไฟล์เข้า shell ให้ปลอดภัย

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 9 หัวข้อ
  1. ตรวจทั้ง repo แล้วแดงทุก PR
  2. workflow นี้รันเฉพาะ pull request
  3. หาไฟล์ที่ PR แก้ด้วย git diff
  4. ส่งรายชื่อไฟล์ผ่าน env ไม่ใช่ ${{ }}
  5. ข้ามเครื่องมือที่ไม่มีไฟล์ให้ตรวจ
  6. ให้ผลไปขึ้นที่บรรทัดใน PR
  7. ไฟล์เก่าที่ PR แตะเป็นครั้งแรก
  8. PHPStan ยังรันทั้งโปรเจกต์
  9. ให้ AI agent ตรวจชุดเดียวกันก่อน push

โพสต์ภาพรวมของ lint และ formatter เล่าไว้ว่าเครื่องมือแต่ละตัวทำอะไร และเราให้ Claude Code จัดไฟล์ทันทีหลังแก้ด้วย hook โพสต์นี้เป็นอีกชั้นหนึ่ง คือให้ CI ตรวจซ้ำตอนเปิด PR เผื่อไฟล์ไหนหลุดมา

ตรวจทั้ง repo แล้วแดงทุก PR

โปรเจกต์ที่เขียนมาหลายปีแล้วเพิ่งเริ่มใช้ Pint ถ้าสั่ง pint --test ทั้ง repo ใน CI จะเจอไฟล์ที่ไม่ผ่านเป็นร้อย PR ที่แก้แค่สามไฟล์ก็แดงด้วยไฟล์ที่คนเปิด PR ไม่ได้แตะเลย สุดท้ายทีมก็เลิกดูผล

จะสั่ง Pint จัดทั้ง repo ใน commit เดียวก็ได้ แต่ diff ก้อนใหญ่ทำให้ git blame อ่านยาก และชนกับ branch ที่คนอื่นทำค้างอยู่ อีกทางคือตรวจเฉพาะไฟล์ที่ PR แก้ ไฟล์ไหนมีคนแตะก็ต้องผ่าน ไฟล์ที่เหลือค่อย ๆ สะอาดขึ้นเองตามงานที่เข้ามา

workflow นี้รันเฉพาะ pull request

name: pr-lint

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: pr-lint-${{ github.event.pull_request.number }}
  cancel-in-progress: true
  • ไม่รันตอน push เพราะ CI หลักรัน static analysis ทั้งโปรเจกต์อยู่แล้ว (GitHub Actions สำหรับ Laravel)
  • permissions: contents: read ให้ token ของ job อ่าน repo ได้อย่างเดียว workflow นี้ไม่ต้องเขียนอะไรกลับ
  • concurrency ต่อ PR push เพิ่มเข้า PR เดิมแล้วรอบเก่ายกเลิกเอง

หาไฟล์ที่ PR แก้ด้วย git diff

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: List changed files
        id: changed
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          php_files=() js_files=()
          while IFS= read -r file; do
            case "$file" in
              _ide_helper*.php|.phpstorm.meta.php) ;;
              *.php) php_files+=("$file") ;;
              resources/js/*.js|resources/js/*.jsx) js_files+=("$file") ;;
            esac
          done < <(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA")
          echo "php=${php_files[*]}" >> "$GITHUB_OUTPUT"
          echo "js=${js_files[*]}" >> "$GITHUB_OUTPUT"
  • fetch-depth: 0 ดึงประวัติทั้งหมด ค่าเริ่มต้นของ actions/checkout ดึงมา commit เดียว git diff จะหา base ไม่เจอ
  • --diff-filter=ACMR เอาเฉพาะไฟล์ที่เพิ่ม คัดลอก แก้ หรือเปลี่ยนชื่อ ไม่เอาไฟล์ที่ลบ ถ้าส่งชื่อไฟล์ที่ไม่มีแล้วให้ Pint ก็จะ error
  • ข้ามไฟล์ที่ generate เอง อย่าง _ide_helper.php เครื่องมือสร้างไฟล์นี้ใหม่ทุกครั้งอยู่แล้ว ไม่มีประโยชน์ที่จะตรวจรูปแบบ
  • อ่าน SHA ผ่าน env: แทนการเขียน ${{ }} ไว้ในคำสั่งตรง ๆ เหตุผลอยู่ในหัวข้อถัดไป

ส่งรายชื่อไฟล์ผ่าน env ไม่ใช่ ${{ }}

GitHub แทนค่า ${{ ... }} ลงในข้อความของ script ก่อนส่งให้ bash รัน ค่าที่แทนลงไปจึงกลายเป็นโค้ด shell ส่วนชื่อไฟล์มาจากคนเปิด PR ไฟล์ชื่อ $(curl …).php ก็ commit ได้ ถ้าเขียนแบบนี้

# อย่าเขียนแบบนี้ ชื่อไฟล์จะกลายเป็นคำสั่ง shell
run: vendor/bin/pint --test ${{ steps.changed.outputs.php }}

ส่งผ่าน environment variable แทน bash จะเห็นเป็นข้อความธรรมดา ไม่รันสิ่งที่อยู่ในนั้น

      - name: Pint
        if: steps.changed.outputs.php != ''
        env:
          PHP_FILES: ${{ steps.changed.outputs.php }}
        run: vendor/bin/pint --test $PHP_FILES

$PHP_FILES ไม่ใส่เครื่องหมายคำพูด เพราะอยากให้ bash แยกเป็นหลายไฟล์ตามช่องว่าง ข้อเสียคือชื่อไฟล์ที่มีช่องว่างจะแตกเป็นสองชื่อ ในโปรเจกต์ Laravel แทบไม่มีไฟล์แบบนั้น ถ้ามีก็ใช้ xargs -0 กับรายชื่อที่คั่นด้วย null แทน

เอกสารของ GitHub เรียกปัญหานี้ว่า script injection และแนะนำวิธีเดียวกันกับทุกค่าที่คนนอกกำหนดได้ เช่น ชื่อ branch และชื่อ PR

ข้ามเครื่องมือที่ไม่มีไฟล์ให้ตรวจ

PR ที่แก้แค่ไฟล์ PHP ไม่ต้องติดตั้ง Node และ PR ที่แก้แค่ JavaScript ก็ไม่ต้องรัน composer install เราจึงใส่ if: ไว้ทุกขั้น

      - uses: shivammathur/setup-php@v2
        if: steps.changed.outputs.php != ''
        with:
          php-version: '8.5'

      - name: Install PHP dependencies
        if: steps.changed.outputs.php != ''
        run: composer install --no-interaction --prefer-dist --no-progress --no-scripts

      - name: Rector
        if: steps.changed.outputs.php != ''
        env:
          PHP_FILES: ${{ steps.changed.outputs.php }}
        run: vendor/bin/rector process --dry-run --no-progress-bar --output-format=github $PHP_FILES

      - name: ESLint
        if: steps.changed.outputs.js != ''
        env:
          JS_FILES: ${{ steps.changed.outputs.js }}
        run: npx eslint $JS_FILES

      - name: Prettier
        if: steps.changed.outputs.js != ''
        env:
          JS_FILES: ${{ steps.changed.outputs.js }}
        run: npx prettier --check $JS_FILES

--no-scripts ข้าม script ใน composer.json อย่าง package:discover workflow นี้ไม่ต้องบูตแอป จึงไม่ต้องมี .env หรือฐานข้อมูล ส่วนขั้นติดตั้ง Node กับ npm ci ก็ใส่ if: steps.changed.outputs.js != '' แบบเดียวกัน

ทุกตัวรันในโหมดตรวจอย่างเดียว ไม่แก้ไฟล์ให้

เครื่องมือ โหมดตรวจ
Pint --test
Rector process --dry-run
ESLint ค่าเริ่มต้น ไม่ใส่ --fix
Prettier --check

ถ้าไม่ผ่าน คนเปิด PR รันคำสั่งเดียวกันโดยไม่ใส่ flag โหมดตรวจในเครื่องตัวเอง แล้ว push ไฟล์ที่จัดแล้วขึ้นไป

ให้ผลไปขึ้นที่บรรทัดใน PR

ถ้า lint ไม่ผ่าน คนเปิด PR ต้องเปิด log ของ job มาไล่หาว่าไฟล์ไหนพัง ถ้าให้ผลไปขึ้นเป็น annotation ในหน้า Files changed ของ PR ตรงบรรทัดที่มีปัญหาเลย จะเห็นง่ายกว่ามาก

  • Rector มี --output-format=github ในตัว สั่ง rector process --dry-run --output-format=github แล้ว GitHub จะแสดงผลตรงไฟล์ที่ Rector อยากแก้
  • ESLint ไม่ต้องตั้งอะไรเพิ่ม actions/setup-node ลงทะเบียน problem matcher ของ ESLint ไว้แล้ว ผลแบบปกติของ ESLint จึงขึ้นเป็น annotation ให้เอง
  • Pint ยังไม่มีรูปแบบผลของ GitHub (--format=github ขึ้นว่า not supported) แต่ log ก็บอกชื่อไฟล์และกฎที่ไม่ผ่านไว้ชัดอยู่แล้ว

ไฟล์เก่าที่ PR แตะเป็นครั้งแรก

PR ที่แก้บรรทัดเดียวในไฟล์เก่าก็ต้องจัดทั้งไฟล์ให้ผ่าน diff จึงมีบรรทัดที่เปลี่ยนแค่ช่องว่างปนอยู่กับงานจริง ทางที่ทำให้รีวิวง่ายคือแยกเป็นสอง commit ใน PR เดียวกัน commit แรกจัดรูปแบบอย่างเดียว commit ที่สองค่อยเป็นงานจริง คนรีวิวจะข้าม commit แรกไปได้เลย

ถ้าวันหนึ่งตัดสินใจจัดทั้ง repo ใน commit เดียว ให้ใส่ hash ของ commit นั้นไว้ในไฟล์ .git-blame-ignore-revs ที่ root ของ repo หน้า blame ของ GitHub จะข้าม commit นั้นให้ ส่วนในเครื่องตั้ง git config blame.ignoreRevsFile .git-blame-ignore-revs ครั้งเดียว git blame ก็จะยังชี้ไปที่คนที่เขียนบรรทัดนั้นจริง

PHPStan ยังรันทั้งโปรเจกต์

PHPStan ไม่อยู่ใน workflow นี้ เพราะไฟล์ที่ PR ไม่ได้แตะอาจพังจากไฟล์ที่แตะก็ได้ เช่น เปลี่ยนชนิดที่เมธอดคืนค่า แล้วไฟล์ที่เรียกเมธอดนั้นใช้ค่าผิดชนิด ถ้าตรวจแค่ไฟล์ที่แก้ก็จะไม่เห็น PHPStan จึงรันทั้งโปรเจกต์ใน CI หลัก และใช้ baseline รับ error เก่าที่มีอยู่แล้ว แทนการตรวจเฉพาะไฟล์

ให้ AI agent ตรวจชุดเดียวกันก่อน push

ถ้าให้ Claude Code หรือ agent ตัวอื่นทำงานใน branch แล้วเปิด PR ก็ให้ agent ตรวจไฟล์ชุดเดียวกับที่ CI จะตรวจก่อน push ได้ Pint มี flag สำหรับเรื่องนี้อยู่แล้ว

vendor/bin/pint --test --diff=main

--diff=main ตรวจเฉพาะไฟล์ที่เปลี่ยนตั้งแต่แยกจาก main ส่วน --dirty ตรวจเฉพาะไฟล์ที่ยังไม่ commit Rector, ESLint และ Prettier ไม่มี flag แบบนี้ ก็ส่งรายชื่อจาก git diff --name-only main... ให้เหมือนใน workflow

เขียนคำสั่งนี้ไว้ใน CLAUDE.md หรือ AGENTS.md ว่าให้รันก่อน push agent จะเจอปัญหาเองตั้งแต่ในเครื่อง ไม่ต้องรอ CI แดงแล้วค่อยกลับมาแก้ครับ

ตอนถัดไปของซีรีส์ CI/CD ใน Laravel

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →