ปลั๊กอิน WordPress ของ smallPict แปลงไฟล์ที่อัปโหลดเป็น WebP และ AVIF แล้วส่งให้ผู้เข้าชม เมื่อเราสร้าง tag สำหรับ release ใหม่ GitHub Actions จะ commit ขึ้นที่เก็บ SVN ของ WordPress.org และจากตรงนั้น release จะไปถึงทุกเว็บไซต์ที่ติดตั้งปลั๊กอินไว้ เว็บไซต์ที่เปิดอัปเดตอัตโนมัติจะติดตั้งทันทีโดยไม่มีใครตรวจการเปลี่ยนแปลง ปลั๊กอินประกาศ Requires PHP: 7.4 และ Requires at least: 5.9 ดังนั้น build เดียวต้องทำงานได้ทั้งบน shared hosting ที่ยังใช้ PHP 7.4 และบน container ที่รัน PHP 8.5
fatal error ในปลั๊กอินเพียงจุดเดียวอาจทำให้ wp-admin ใช้งานไม่ได้ ค่าที่ไม่ได้ escape บนหน้าตั้งค่าคือบั๊ก XSS บนทุกเว็บไซต์พร้อมกัน การตรวจ nonce ที่ขาดไปทำให้การกระทำของผู้ดูแลกลายเป็นเป้าของ CSRF ปัญหาเหล่านี้แก้แบบเงียบ ๆ ไม่ได้ การแก้ต้องออก release ใหม่อีกครั้ง และบางเว็บไซต์จะยังใช้เวอร์ชันที่มีปัญหาจนกว่า release นั้นจะไปถึง
บทความนี้อธิบาย pipeline CI/CD ของปลั๊กอิน WordPress ของเรา ได้แก่ WordPress Coding Standards (WPCS), PHPStan และเทสต์อัตโนมัติที่รันบน PHP เจ็ดเวอร์ชัน รวมถึงด่านตรวจก่อน deploy ที่วางไว้หน้า WordPress.org ทุกอย่างด้านล่างมาจาก repository smallpict-wp ที่ release 1.2.9
ประเด็นสำคัญ
- ทุก pull request ที่เข้า
masterจะรัน WPCS, PHPStan และสคริปต์เทสต์อัตโนมัติ 9 ตัวบน PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 และ 8.5 แบบขนานใน GitHub Actions- WPCS บังคับให้ sanitize อินพุต, escape เอาต์พุต, ใช้ nonce และ prepared query ส่วน PHPStan ที่ level 5 หา type error และ branch ที่ไม่มีวันทำงานจาก stub ของ WordPress โดยไม่ต้องรัน WordPress
- tag สำหรับ release จะรัน WPCS ซ้ำบน PHP ทั้งเจ็ดเวอร์ชัน และ job ที่ commit ขึ้น SVN ของ WordPress.org จะเริ่มหลังจากทั้งเจ็ดเวอร์ชันผ่านเท่านั้น
ภาพรวม pipeline CI/CD ของปลั๊กอิน WordPress
workflow ของ GitHub Actions สองตัวคอยป้องกันปลั๊กอิน
| Workflow | รันเมื่อ | Job | การตรวจ |
|---|---|---|---|
phpcs.yml (PHP Code Quality & Standards) | push ไปที่ master หรือ develop, pull request ที่เข้า master | หนึ่ง job ต่อหนึ่งเวอร์ชัน PHP: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 | WPCS, PHPStan, เทสต์อัตโนมัติ |
deploy-all.yml (Deploy to WordPress.org) | push tag v* | WPCS บน PHP แต่ละเวอร์ชันจากทั้ง 7 เวอร์ชัน ตามด้วย deploy | WPCS ตามด้วย build และ commit ขึ้น SVN |
หมายเหตุเรื่องเวอร์ชัน PHP ที่ครอบคลุม: ภาพหน้าจอในบทความนี้แสดงการรัน CI ก่อนหน้าของเราที่ครอบคลุม PHP 7.4 ถึง 8.3 ปัจจุบัน test matrix ใน GitHub Actions ของเราขยายให้ครอบคลุม PHP 7.4 ถึง PHP 8.5 แล้ว เพื่อให้เข้ากันได้กับ PHP runtime รุ่นใหม่อย่างต่อเนื่อง โดยไม่มี regression บนสภาพแวดล้อมรุ่นเก่า
เครื่องมือทั้งหมดเป็น dev dependency ของ Composer จึงไม่มีตัวใดถูกส่งไปถึงผู้ใช้:
"require-dev": { "wp-coding-standards/wpcs": "^3.4.1", "phpcsstandards/php_codesniffer": "^3.0", "phpstan/phpstan": "^2.1", "szepeviktor/phpstan-wordpress": "^2.0 || ^1.3.5", "phpstan/extension-installer": "^1.4"}การทดสอบแบบ matrix หลายเวอร์ชัน PHP ใน GitHub Actions
สิ่งที่เปลี่ยนไประหว่าง PHP 7.4 และ PHP 8.5
PHP 7.4 และ PHP 8.5 รันซอร์สโค้ดปลั๊กอินชุดเดียวกันได้ผลต่างกัน การเปลี่ยนแปลงบางส่วนที่กระทบโค้ดปลั๊กอิน WordPress มีดังนี้:
- PHP 8.0 ทำให้ฟังก์ชันภายในจำนวนมาก throw
TypeErrorเมื่อได้รับอาร์กิวเมนต์ผิดชนิด ขณะที่ 7.4 คืนค่าnullพร้อม warning โค้ดที่ส่ง array ให้strlen()ยังรันต่อได้บน 7.4 แต่หยุดทำงานบน 8.0 - PHP 8.0 เปลี่ยนวิธีเปรียบเทียบตัวเลขกับสตริงที่ไม่ใช่ตัวเลข
0 == "abc"ได้trueบน 7.4 และfalseบน 8.0 ทำให้การเปรียบเทียบแบบหลวมกับค่า option บางจุดให้ผลกลับด้านโดยไม่มีสัญญาณเตือน - PHP 8.0 เพิ่ม
str_starts_with()และฟังก์ชันที่เกี่ยวข้อง ตัวประมวลผลภาพของเราเรียกstr_starts_with()บน PHP 7.4 โค้ดนี้ทำงานได้เพียงเพราะ WordPress 5.9 ขึ้นไปมี polyfill มาให้ ซึ่งRequires at least: 5.9ของเรารับประกันไว้ - PHP 8.1 ประกาศ deprecated การส่ง
nullให้พารามิเตอร์แบบ non-nullable ของฟังก์ชันภายในstrtolower( wp_parse_url( $url, PHP_URL_HOST ) )ไม่มีปัญหาบน 7.4 แต่บน 8.1 จะบันทึก deprecation เมื่อ$urlเป็น path แบบสัมพัทธ์และ host เป็นnullโฮสต์บางรายพิมพ์ deprecation ออกมาบนหน้าเว็บ - PHP 8.2 ประกาศ deprecated พร็อพเพอร์ตีแบบไดนามิก การกำหนดค่าให้พร็อพเพอร์ตีที่ไม่ได้ประกาศไว้ในคลาสของคุณเองจะบันทึก deprecation ทุก request
ปัญหาแต่ละข้อจะปรากฏก็ต่อเมื่อโค้ดถูกวิเคราะห์หรือรันบนเวอร์ชัน PHP ที่มีพฤติกรรมต่างไป นี่คือเหตุผลที่ pipeline รันทุก pull request ที่เข้า master บนทั้งเจ็ดเวอร์ชันพร้อมกัน
Matrix ใน GitHub Actions

ภาพที่ 1: workflow PHP Code Quality & Standards ตอน merge งานของ 1.2.0 เข้า master PHP แต่ละเวอร์ชันเป็น job แยกกัน และ job ของ PHP 8.3 แสดงขั้นตอนที่ทุก job รัน ได้แก่ PHPCS, PHPStan แล้วตามด้วยเทสต์อัตโนมัติ
หมายเหตุเรื่องเวอร์ชัน PHP ที่ครอบคลุม: ภาพหน้าจอด้านบนแสดงการรัน CI ก่อนหน้าของเราที่ครอบคลุม PHP 7.4 ถึง 8.3 ปัจจุบัน test matrix ใน GitHub Actions ของเราขยายให้ครอบคลุม PHP 7.4 ถึง PHP 8.5 แล้ว เพื่อให้เข้ากันได้กับ PHP runtime รุ่นใหม่อย่างต่อเนื่อง โดยไม่มี regression บนสภาพแวดล้อมรุ่นเก่า
jobs: code-quality: name: WPCS & PHPStan (PHP ${{ matrix.php-version }}) runs-on: ubuntu-26.04 strategy: matrix: php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer:v2, cs2pr ini-values: memory_limit=2G coverage: none - run: composer install --prefer-dist --no-progress --no-interaction - name: Run PHPCS (WordPress Coding Standards) run: vendor/bin/phpcs -q --standard=phpcs.xml.dist --report=checkstyle | cs2pr - name: Run PHPStan (WordPress Static Analysis) run: vendor/bin/phpstan analyse --memory-limit=2G - name: Run the standalone tests run: | for t in tests/test_*.php; do echo "== $t" php "$t" || exit 1 donefail-fast ใช้ค่าเริ่มต้นคือ true เมื่อเวอร์ชันใดล้มเหลว GitHub จะยกเลิก job ที่ยังรันอยู่ pull request ที่มีปัญหาจึงใช้ทรัพยากรน้อย แลกกับการที่ไม่เห็นผลของทุกเวอร์ชันในการรันครั้งเดียวกัน
สิ่งที่ matrix พิสูจน์ได้ และสิ่งที่พิสูจน์ไม่ได้
การตรวจทั้งสามแบบได้ประโยชน์จาก matrix ไม่เท่ากัน PHPStan วิเคราะห์โค้ดตามเวอร์ชัน PHP ที่ตัวเองรันอยู่ และเทสต์รันโค้ดบนเวอร์ชันนั้น ทั้งสองจึงให้ผลต่างกันได้ในแต่ละ job ส่วน PHPCS ทำงานระดับ token และในทางปฏิบัติให้ผลเหมือนกันทั้งเจ็ดเวอร์ชัน ใน matrix มันจึงพิสูจน์เป็นหลักว่าชุดเครื่องมือติดตั้งและรันได้บนแต่ละเวอร์ชัน การตรวจ syntax และฟังก์ชันกับช่วงเวอร์ชัน PHP ในการรันครั้งเดียวเป็นหน้าที่ของ PHPCompatibility ซึ่ง ruleset ของเรายังไม่ได้ใส่ไว้
การทำ WordPress Coding Standards (WPCS) ให้เป็นอัตโนมัติ
sniff ด้านความปลอดภัยที่สำคัญสำหรับปลั๊กอิน
WPCS คือชุด sniff ของ PHP_CodeSniffer ที่เขียนมาสำหรับ WordPress กฎที่สำคัญที่สุดต่อความปลอดภัยของปลั๊กอินอยู่ใน ruleset WordPress เอง:
WordPress.Security.ValidatedSanitizedInputแจ้งทุกครั้งที่อ่าน$_POST,$_GET,$_REQUESTหรือ$_COOKIEโดยไม่ผ่านการ unslash และ sanitizeWordPress.Security.EscapeOutputแจ้งทุกครั้งที่echoหรือprintค่าที่ไม่ได้ผ่านฟังก์ชัน escape เช่นesc_html(),esc_attr()หรือesc_url()WordPress.Security.NonceVerificationแจ้ง handler ที่อ่านข้อมูลจาก request โดยไม่ตรวจสอบ nonceWordPress.DB.PreparedSQLและPreparedSQLPlaceholdersแจ้ง query ที่สร้างด้วยการต่อสตริงแทนการใช้$wpdb->prepare()และ placeholder ที่ไม่ตรงกับอาร์กิวเมนต์
ruleset WPCS ของเรา
phpcs.xml.dist เพิ่ม WordPress-Extra และ WordPress-Docs กำหนด text domain ให้ sniff ด้านการแปล และบังคับให้ฟังก์ชัน คลาส และค่าคงที่ระดับ global ทุกตัวมี prefix ของเรา เพื่อไม่ให้ชื่อในปลั๊กอินชนกับปลั๊กอินอื่นบนเว็บไซต์เดียวกัน:
<rule ref="WordPress"> <exclude name="Universal.Arrays.DisallowShortArraySyntax"/></rule><rule ref="WordPress-Extra"/><rule ref="WordPress-Docs"/>
<rule ref="WordPress.WP.I18n"> <properties> <property name="text_domain" type="array"> <element value="smallpict"/> </property> </properties></rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals"> <properties> <property name="prefixes" type="array"> <element value="smallpict"/> <element value="SmallPict"/> <element value="SMALLPICT"/> </property> </properties></rule>AJAX handler ที่ผ่าน WPCS
handler นี้ในปลั๊กอินกู้คืนสื่อที่ offload ไปแล้วเป็นชุด ๆ ขอบเขตอินพุตทุกจุดถูกจัดการในบรรทัดแรก ๆ ของฟังก์ชัน:
public function ajax_restore_offloaded() { if ( ! check_ajax_referer( 'smallpict_restore_offloaded', 'nonce', false ) || ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => 'forbidden' ), 403 ); } global $wpdb; $after = isset( $_POST['after'] ) ? absint( wp_unslash( $_POST['after'] ) ) : 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin action, the rows change on every call. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d", '_smallpict_offloaded', '1', $after, self::RESTORE_BATCH ) ); // ...}nonce และ capability ถูกตรวจพร้อมกัน เพราะ nonce ที่ถูกต้องพิสูจน์ได้แค่ว่า request มาจากหน้าผู้ดูแลของเรา ไม่ได้พิสูจน์ว่าผู้ใช้มีสิทธิ์ทำการกระทำนั้น $_POST['after'] ถูก unslash และแปลงด้วย absint() ก่อนไปถึง query และ query ยังผ่าน $wpdb->prepare() แม้ค่าจะเป็นจำนวนเต็มอยู่แล้วก็ตาม
การ suppress ต้องระบุชื่อ sniff และเหตุผล
บรรทัด phpcs:ignore ด้านบนตั้งใจใส่ไว้ WPCS เตือนเรื่อง query ฐานข้อมูลโดยตรงที่ไม่มี object cache ซึ่งเป็นคำแนะนำที่ดีสำหรับการอ่านข้อมูลฝั่ง front-end แต่ไม่เหมาะกับการกระทำของผู้ดูแลที่แถวข้อมูลเปลี่ยนทุกครั้งที่เรียก กฎของเราคือทุก ignore ต้องระบุชื่อ sniff ให้ตรงและบอกเหตุผลหลัง -- ส่วน // phpcs:ignore ที่ไม่มีอะไรต่อท้ายจะไม่ผ่าน review
ใน CI นั้น PHPCS เขียนรายงานแบบ checkstyle แล้ว cs2pr แปลงเป็น annotation บน diff ของ pull request ผู้รีวิวจึงเห็นการละเมิดแต่ละจุดบนบรรทัดที่ก่อให้เกิดปัญหา แทนที่จะต้องไปหาใน log
จับข้อบกพร่องเชิงตรรกะด้วย static analysis ของ PHPStan
WPCS อ่านโค้ดทีละบรรทัด มันรู้ว่า echo ขาดการเรียก escape แต่ไม่รู้ว่าตัวแปรเก็บค่าชนิดอะไร หรือ branch หนึ่งจะมีโอกาสทำงานหรือไม่ PHPStan สร้างแบบจำลองของชนิดข้อมูลที่ไหลผ่านแต่ละฟังก์ชัน แล้วรายงานโค้ดที่ขัดกับแบบจำลองนั้น เช่น การเรียก method ที่ไม่มีอยู่ การส่ง string|false ไปในที่ที่ต้องการ string การเปรียบเทียบค่าที่ไม่มีวันเท่ากัน และเงื่อนไขที่เป็นจริงเสมอหรือเท็จเสมอ
ใช้ PHPStan กับ WordPress โดยไม่ต้องรัน WordPress
szepeviktor/phpstan-wordpress ให้ signature ของฟังก์ชันและคลาสใน WordPress core แก่ PHPStan ผ่านไฟล์ stub พร้อม type extension เฉพาะของ WordPress อีกเล็กน้อย phpstan/extension-installer ลงทะเบียนให้อัตโนมัติ PHPStan อ่านการประกาศเหล่านั้นและไม่เคยโหลด WordPress หรือฐานข้อมูล
การตั้งค่า PHPStan ของเรา
parameters: level: 5 paths: - smallpict.php - includes/ - admin/ treatPhpDocTypesAsCertain: false bootstrapFiles: - phpstan-bootstrap.phpLevel 5 ตรวจชนิดของทุกอาร์กิวเมนต์ที่ส่งให้ฟังก์ชันหรือ method เพิ่มเติมจากการตรวจของ level ที่ต่ำกว่า ได้แก่ คลาส method และพร็อพเพอร์ตีที่ไม่รู้จัก ชนิดของค่าที่คืน และ dead code ส่วน treatPhpDocTypesAsCertain: false บอก PHPStan ไม่ให้ถือว่า docblock @param ที่เราเขียนเองเป็นข้อพิสูจน์ WordPress ส่งต่อค่าอะไรก็ตามที่ filter คืนมา พารามิเตอร์ที่ระบุใน docblock ว่าเป็น int จึงอาจได้รับสตริงจากปลั๊กอินอื่น และเราต้องการให้ PHPStan คงการตรวจเชิงป้องกันที่จัดการกรณีนี้ไว้ ไฟล์ bootstrap กำหนดค่าคงที่ของปลั๊กอิน (SMALLPICT_PATH, SMALLPICT_URL และอื่น ๆ) ซึ่งปกติจะถูกตั้งค่าตอน runtime
![log static analysis ของ PHPStan สำหรับ WordPress ใน GitHub Actions: ขั้นตอน WPCS ผ่านโดยไม่มีปัญหา PHPStan วิเคราะห์ไฟล์ปลั๊กอินครบ 11 จาก 11 ไฟล์และแสดง "[OK] No errors"](/blog-assets/02_phpcs_phpstan_log.png)
ภาพที่ 2: ขั้นตอน PHPCS และ PHPStan ของ job PHP 8.3 PHPCS ไม่พิมพ์อะไรออกมาเมื่อไม่มีอะไรต้องรายงาน PHPStan วิเคราะห์ไฟล์ปลั๊กอิน 11 ไฟล์ (ไฟล์หลักของปลั๊กอินและคลาส 10 ตัวใน includes/) และไม่พบข้อผิดพลาด
สิ่งที่ PHPStan พบจริง: บรรทัด 626 ของตัวประมวลผลภาพ
วันที่ 2 ตุลาคม 2026 pipeline ล้มเหลวที่ PHPStan ด้วยข้อผิดพลาดหนึ่งรายการ:

ภาพที่ 3: job ของ PHP 8.2 หยุดที่ขั้นตอน PHPStan ด้วย booleanAnd.rightAlwaysTrue ที่บรรทัด 626 ของ includes/class-smallpict-image-processor.php ขณะนั้นปลั๊กอินมี 9 ไฟล์ใน path ที่ถูกวิเคราะห์
โค้ดนี้คือขั้นตอนสุดท้ายของการแปลงไฟล์ เมื่อการตั้งค่า รูปแบบผลลัพธ์ ของปลั๊กอินเป็น WebP หรือ AVIF ไฟล์ที่แปลงแล้วจะกลายเป็น attachment และไฟล์ที่อัปโหลดต้นฉบับจะถูกลบเพื่อประหยัดพื้นที่ดิสก์ ตัวเลือกอื่นคือ "AVIF โดยมี WebP เป็นตัวสำรอง" และ "คงรูปแบบต้นฉบับไว้" จะเก็บไฟล์ JPEG และ PNG ต้นฉบับไว้ นี่คือ branch ที่ลบไฟล์:
// C. Pure 1-to-1 Mode: Delete raw uncompressed source file to free 100% hosting space.if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) { wp_delete_file( $main_file_path );}PHPStan ระบุว่า file_exists( $main_file_path ) เป็นจริงเสมอ ณ จุดนั้น เหตุผลอยู่ก่อนหน้านั้น 150 บรรทัด ที่ส่วนบนสุดของ method เดียวกัน:
$main_file_path = get_attached_file( $attachment_id );if ( ! $main_file_path || ! file_exists( $main_file_path ) ) { return new WP_Error( 'file_missing', 'File not found on disk.' );}method จะทำงานต่อเมื่อไฟล์มีอยู่เท่านั้น และ PHPStan นำข้อเท็จจริงนี้ไปจนถึงบรรทัด 626
เราไม่ได้เชื่อผลนี้ทันที file_exists() ถามระบบไฟล์ และคำตอบอาจเปลี่ยนได้ระหว่างการเรียกสองครั้ง โดยค่าเริ่มต้น PHPStan จะจำผลของการเรียกแบบนี้ไว้ (ตัวเลือก rememberPossiblyImpureFunctionValues) โดยสมมติว่าไม่มีอะไรระหว่างนั้นเปลี่ยนแปลงดิสก์ ก่อนลบการตรวจนี้ เราอ่านโค้ดและยืนยันสองเรื่อง:
- ไม่มีโค้ดใดระหว่าง guard กับบรรทัด 626 ที่ลบหรือย้ายไฟล์ต้นทาง การแปลงเขียนเอาต์พุตแต่ละไฟล์ลงไฟล์ชั่วคราวแล้วย้ายไปยัง path ใหม่ และการเรียก
wp_delete_file()บน path นั้นมีไว้ลบไฟล์ชั่วคราวเท่านั้น ส่วนขั้นตอน offload ซึ่งลบไฟล์ในเครื่องจริง ๆ รันหลังบรรทัด 626 wp_delete_file()รับมือกับไฟล์ที่ไม่มีอยู่ได้อยู่แล้ว ฟังก์ชันนี้ส่ง path ผ่าน filterwp_delete_fileแล้วเรียก@unlink()ซึ่งไม่ก่อความเสียหายใด ๆ เมื่อไฟล์หายไปแล้ว
เมื่อยืนยันทั้งสองข้อแล้ว เงื่อนไขนั้นคือ dead code และ dead code ใน branch ที่ลบไฟล์จะทำให้คนที่อ่านโค้ดต่อเข้าใจผิดว่าไฟล์อาจหายไประหว่างที่ method ทำงาน การแก้ไขจึงลบเงื่อนไขนี้ออก:
-if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) {+if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path ) { wp_delete_file( $main_file_path ); }สิ่งที่พบไม่ใช่ crash คุณค่าของมันอยู่ที่สิ่งที่บอกเกี่ยวกับ method นี้ สมมติฐานของโค้ดเคลื่อนออกจากโครงสร้างของโค้ด ใน branch เดียวที่ลบไฟล์ของผู้ใช้ บทเรียนที่เราได้แคบกว่า "จงเชื่อเครื่องมือวิเคราะห์" รายงานว่าเป็นจริงเสมอหรือเท็จเสมอบนฟังก์ชันด้านระบบไฟล์ เครือข่าย หรือเวลา ควรได้รับการตรวจด้วยตนเองว่าเกิดอะไรขึ้นระหว่างการเรียกสองครั้ง ก่อนจะลบเงื่อนไขทิ้ง
เทสต์อัตโนมัติสำหรับปลั๊กอิน WordPress โดยไม่ใช้ PHPUnit
ขั้นตอนสุดท้ายของแต่ละ job ใน matrix รันไฟล์ใน tests/ ไฟล์เหล่านี้ไม่ใช่เทสต์ PHPUnit แต่ละไฟล์เป็นสคริปต์ PHP ธรรมดาที่กำหนด stub ขั้นต่ำของฟังก์ชัน WordPress ที่ต้องใช้ (get_option(), update_option(), sanitize_key() และอื่น ๆ) โหลดคลาสของปลั๊กอินที่จะทดสอบ เรียกใช้งาน และจบด้วย exit code ที่ไม่ใช่ศูนย์ทันทีที่เจอความล้มเหลวครั้งแรก:
for t in tests/test_.php do echo "== $t" php "$t" exit 1doneปัจจุบันมีสคริปต์แบบนี้ 9 ตัว ครอบคลุมเส้นทางที่ความผิดพลาดทำให้ลูกค้าเสียข้อมูลหรือเสียเงิน ได้แก่ pipeline การแปลง WebP และ AVIF พร้อม fallback การส่งภาพ การ offload และลบไฟล์บน cloud storage การกู้คืนไฟล์ที่ offload ไปแล้วเป็นชุด พฤติกรรมเมื่อบัญชีหยุดชำระเงิน โควตาพื้นที่จัดเก็บ และการถอนการติดตั้ง เทสต์การถอนการติดตั้งรัน query ถอนการติดตั้งจริงกับฐานข้อมูล SQLite ในหน่วยความจำ แล้วตรวจว่าเหลืออะไรอยู่บ้าง:
check( array( '_smallpict_cdn_base', '_smallpict_cdn_key', '_smallpict_offloaded', '_smallpictx', '_wp_attached_file' ) === $meta, 'offload markers and foreign meta remain (got ' . implode( ',', $meta ) . ')');assertion นี้ปกป้องการตัดสินใจที่ย้อนกลับโดยไม่ตั้งใจได้ง่าย การถอนการติดตั้งปลั๊กอินจะลบการตั้งค่า แต่เก็บ post meta ที่บันทึกว่าไฟล์ใดอยู่บน cloud storage เท่านั้นไว้ เว็บไซต์ที่ติดตั้งปลั๊กอินใหม่จึงยังหาภาพของตัวเองเจอ DELETE ... LIKE '_smallpict%' ที่เขียนอย่างไม่ระวังจะลบเครื่องหมายเหล่านั้นทิ้ง และถ้าเป็นเช่นนั้น เทสต์จะล้มเหลว
เราเลือกสคริปต์ธรรมดาแทนชุดเทสต์ PHPUnit ของ WordPress เพราะความเร็วและต้นทุนการตั้งค่า สคริปต์เหล่านี้ไม่ต้องใช้เซิร์ฟเวอร์ฐานข้อมูลหรือ checkout ของ WordPress และทั้ง 9 ตัวรันเสร็จในเวลาไม่ถึงหนึ่งวินาที การรันบน PHP เจ็ดเวอร์ชันจึงแทบไม่เพิ่มเวลาให้ pull request ข้อแลกเปลี่ยนคือ stub จำลองได้เฉพาะสิ่งที่เราเขียนลงไป พฤติกรรมที่ขึ้นกับ WordPress core จริง เช่น การจัดการ metadata ของคลังสื่อเอง ไม่ได้รับการครอบคลุมจากเทสต์เหล่านี้
ด่านตรวจก่อน deploy ขึ้น WordPress.org
release ถูกส่งออกผ่าน workflow แยกต่างหาก การ push tag เช่น v1.2.9 จะเริ่ม workflow นี้ และ job ที่ commit ขึ้น SVN ของ WordPress.org ประกาศว่าขึ้นอยู่กับ matrix ของ lint:
jobs: lint: name: WPCS Check (PHP ${{ matrix.php-version }}) runs-on: ubuntu-26.04 strategy: matrix: php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - run: composer install --prefer-dist --no-progress --no-interaction - name: PHP CodeSniffer Check run: vendor/bin/phpcs --standard=phpcs.xml.dist
deploy: name: Deploy needs: lint runs-on: ubuntu-26.04 permissions: contents: read steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: '7.4' - name: Build WP.org Version run: | chmod +x build-wporg.sh ./build-wporg.sh - name: Deploy to WordPress.org uses: 10up/action-wordpress-plugin-deploy@stable with: generate-zip: false env: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SLUG: smallpict BUILD_DIR: build-wporg/smallpict ASSETS_DIR: .wordpress-orgneeds: lint หมายความว่า job deploy จะเริ่มก็ต่อเมื่อ job lint ทั้งเจ็ดสำเร็จ หาก WPCS ล้มเหลวบนเวอร์ชันใดก็ตาม GitHub จะข้าม deploy และไม่มีการ commit ขึ้น SVN ขั้นตอน build รันบน PHP 7.4 ซึ่งเป็นเวอร์ชันเก่าที่สุดที่เรารองรับ token ของ job มีสิทธิ์อ่านอย่างเดียวบน repository การเขียนเพียงอย่างเดียวคือการเขียนขึ้น SVN ของ WordPress.org โดยเก็บข้อมูลรับรองไว้เป็น secret ของ repository

ภาพที่ 4: workflow Deploy to WordPress.org ซึ่ง job WPCS Check ทั้งห้าจะรันก่อน ส่วน job Deploy ด้านล่างจะรันหลังจากทั้งห้าผ่านแล้วเท่านั้น
ด่านตรวจนี้รัน WPCS ซ้ำบน commit ที่ติด tag แทนที่จะเชื่อผลการรันของ pull request ก่อนหน้า tag ชี้ไปที่ commit ใดก็ได้ รวมถึง commit ที่ push ตรงเข้ามาหรือ commit ที่การตรวจยังรันไม่จบ และ workflow ของ release ไม่มีทางอื่นที่จะรู้ว่ากำลังส่งอะไรออกไป
รันการตรวจของ CI บนเครื่อง local
สคริปต์ของ Composer ครอบคำสั่งเดียวกับที่ CI รัน จึงจำลองความล้มเหลวได้ก่อน push:
composer installcomposer lint # vendor/bin/phpcs --standard=phpcs.xml.distcomposer lint-fix # vendor/bin/phpcbf: fixes spacing and formatting sniffscomposer phpstan # vendor/bin/phpstan analysefor t in tests/test_.php do php "$t" break doneหากต้องการจำลองความล้มเหลวจาก job หนึ่งใน matrix ให้รันคำสั่งเดียวกันภายใต้เวอร์ชัน PHP นั้น เช่น ใช้ image ทางการหลังจากรัน composer install:
docker run --rm -v "$PWD":/app -w /app php:7.4-cli \ sh -c 'vendor/bin/phpstan analyse --memory-limit=2G && for t in tests/test_*.php; do php "$t" || exit 1; done'phpcbf แก้เฉพาะสิ่งที่ sniff แก้ได้แบบกลไก ปัญหาด้าน escape, sanitize และ nonce ต้องให้คนตัดสินใจว่าฟังก์ชันใดเหมาะกับบริบท และเราไม่แก้ส่วนนี้อัตโนมัติ
สิ่งที่ pipeline ยังไม่ครอบคลุม
มีช่องว่างสามข้ออยู่ในรายการงานของเรา:
- ด่านตรวจก่อน deploy รันเฉพาะ WPCS ส่วน PHPStan และเทสต์อัตโนมัติรันเมื่อ push ไปที่
masterและdevelopและบน pull request แต่ไม่รันบน tag ของ release การเพิ่มทั้งสองขั้นตอนลงใน joblintของ workflow deploy จะปิดช่องว่างนี้ - ruleset ของเรายังไม่มี PHPCompatibility การเพิ่ม
phpcompatibility/phpcompatibility-wpโดยตั้งtestVersionเป็น7.4-จะตรวจพบ syntax และฟังก์ชันที่ไม่มีบน 7.4 ได้ในการรันครั้งเดียว - เทสต์ใช้ stub แทนการติดตั้ง WordPress จริง job แบบ integration ที่รันกับ WordPress 5.9 (เวอร์ชันต่ำสุดที่เรารองรับ) และ release ล่าสุดจะครอบคลุมพฤติกรรมของ core ที่ stub จำลองไม่ได้
FAQ
ทำไมการทดสอบแบบ matrix ตั้งแต่ PHP 7.4 ถึง 8.5 จึงสำคัญมากสำหรับปลั๊กอิน WordPress?
WordPress.org ส่ง release เดียวกันไปยังทุกเว็บไซต์ และ header ของปลั๊กอิน (Requires PHP: 7.4) เป็นตัวกรองเดียวที่กำหนดว่า PHP เวอร์ชันใดจะติดตั้งปลั๊กอินได้ ระหว่าง 7.4 ถึง 8.5 PHP เปลี่ยนวิธีที่ฟังก์ชันภายในจัดการอาร์กิวเมนต์ผิดชนิด วิธีเปรียบเทียบตัวเลขกับสตริง และรูปแบบโค้ดที่ทำให้เกิด deprecation โค้ดที่ถูกต้องบนเวอร์ชันหนึ่งจึงอาจเกิด fatal error หรือบันทึก warning บนอีกเวอร์ชัน การรัน CI บน PHP เวอร์ชันเดียวพิสูจน์ได้เพียงว่าปลั๊กอินทำงานบนเวอร์ชันนั้น การรัน PHPStan และเทสต์บนทุกเวอร์ชันที่รองรับจะจับความแตกต่างเหล่านี้ได้ก่อนที่ release จะไปถึงผู้ใช้
WPCS ใช้แทนการตรวจสอบความปลอดภัยแบบดั้งเดิมได้หรือไม่?
ไม่ได้ WPCS ตรวจรูปแบบโค้ดตรงบรรทัดที่รูปแบบนั้นปรากฏ เช่น ตัวแปรจาก request ที่อ่านโดยไม่ sanitize, echo ที่ไม่ escape, handler ที่ไม่ตรวจ nonce และ query ที่ไม่ใช้ $wpdb->prepare() มันบอกไม่ได้ว่า handler ตรวจ capability ถูกตัวหรือไม่ ฟังก์ชัน escape เหมาะกับบริบทของเอาต์พุตหรือไม่ request ฝั่งเซิร์ฟเวอร์ไปยัง URL ที่ผู้ใช้ควบคุมได้หรือไม่ หรือผู้ใช้คนหนึ่งเข้าถึงข้อมูลของผู้ใช้อีกคนได้หรือไม่ นอกจากนี้ คอมเมนต์ phpcs:ignore ยังปิด sniff ได้ด้วย เรามอง WPCS เป็นมาตรฐานขั้นต่ำที่กันความผิดพลาดที่รู้จักอยู่แล้วออกไป และรีวิวการเปลี่ยนแปลงที่เกี่ยวกับความปลอดภัยด้วยคน
PHPStan จัดการฟังก์ชันของ WordPress core อย่างไรโดยไม่ต้องรันโค้ด?
PHPStan ไม่เคยรันโค้ดที่มันวิเคราะห์ มันอ่านการประกาศและ docblock เพื่อสร้างแบบจำลองของชนิดข้อมูล สำหรับ WordPress นั้น szepeviktor/phpstan-wordpress ให้ไฟล์ stub ซึ่งมี signature ชนิดของพารามิเตอร์ และชนิดของค่าที่คืนของฟังก์ชันและคลาสใน WordPress core โดยไม่มีส่วนเนื้อหาของโค้ด PHPStan อ่าน stub เหล่านั้นเสมือนเป็น WordPress จึงตรวจได้ว่าการเรียก wp_delete_file() หรือ get_attached_file() ส่งและรับชนิดข้อมูลที่ถูกต้อง ค่าคงที่ที่ WordPress หรือปลั๊กอินกำหนดตอน runtime จะใส่ไว้ในไฟล์ bootstrap ซึ่ง PHPStan โหลดก่อนเริ่มวิเคราะห์
ปลั๊กอินนี้อยู่บน WordPress.org ในชื่อ smallPict วิธีติดตั้งและการตั้งค่ารูปแบบผลลัพธ์อยู่ใน คู่มือการติดตั้ง และ เอกสารอ้างอิงการตั้งค่ารูปภาพ