smallPict의 WordPress 플러그인은 업로드된 이미지를 WebP와 AVIF로 변환해 방문자에게 제공합니다. 릴리스 태그를 만들면 GitHub Actions가 이를 WordPress.org SVN 저장소에 커밋하고, 거기서 플러그인이 설치된 모든 사이트로 배포됩니다. 자동 업데이트를 켠 사이트는 아무도 변경 사항을 검토하지 않은 상태에서 새 버전을 설치합니다. 플러그인은 Requires PHP: 7.4와 Requires at least: 5.9를 선언하므로, 하나의 빌드가 아직 PHP 7.4를 쓰는 공유 호스팅에서도, PHP 8.5를 실행하는 컨테이너에서도 동작해야 합니다.
플러그인의 fatal error 하나로 wp-admin 전체가 멈출 수 있습니다. 설정 페이지에서 이스케이프하지 않은 값 하나는 모든 사이트에서 동시에 XSS 버그가 됩니다. nonce 검사가 빠지면 관리자 작업이 CSRF 공격 대상이 됩니다. 이런 문제는 조용히 패치할 수 없습니다. 수정하려면 릴리스를 한 번 더 내야 하고, 그 릴리스가 도착할 때까지 일부 사이트는 문제가 있는 버전을 계속 사용합니다.
이 글에서는 저희 WordPress 플러그인 CI/CD 파이프라인을 설명합니다. 일곱 개 PHP 버전에서 실행되는 WordPress Coding Standards(WPCS), PHPStan, 자동 테스트, 그리고 WordPress.org 앞에 둔 배포 전 게이트입니다. 아래 내용은 모두 smallpict-wp 저장소의 1.2.9 릴리스를 기준으로 합니다.
핵심 요약
master로 들어오는 모든 pull request는 GitHub Actions에서 PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5를 병렬로 사용해 WPCS, PHPStan, 자동 테스트 스크립트 9개를 실행합니다.- WPCS는 입력 sanitize, 출력 이스케이프, nonce, prepared query를 강제합니다. PHPStan은 level 5에서 WordPress를 실행하지 않고 WordPress stub만으로 타입 오류와 실행될 수 없는 분기를 찾아냅니다.
- 릴리스 태그는 일곱 개 PHP 버전 모두에서 WPCS를 다시 실행하며, WordPress.org SVN에 커밋하는 job은 일곱 개가 모두 통과한 뒤에만 시작됩니다.
WordPress 플러그인 CI/CD 파이프라인 한눈에 보기
두 개의 GitHub Actions 워크플로가 플러그인을 보호합니다.
| 워크플로 | 실행 조건 | Job | 검사 항목 |
|---|---|---|---|
phpcs.yml (PHP Code Quality & Standards) | master 또는 develop으로 push, master로 들어오는 pull request | PHP 버전마다 job 하나: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 | WPCS, PHPStan, 자동 테스트 |
deploy-all.yml (Deploy to WordPress.org) | v* 태그 push | 7개 PHP 버전 각각에서 WPCS, 그다음 deploy | WPCS, 그다음 빌드 및 SVN 커밋 |
PHP 버전 지원 범위 안내: 이 글의 스크린샷은 PHP 7.4부터 8.3까지를 대상으로 한 이전 CI 실행을 보여 줍니다. 현재 저희 GitHub Actions 테스트 matrix는 PHP 7.4부터 PHP 8.5까지로 확장되어, 레거시 환경에서 회귀 없이 최신 PHP 런타임과의 상위 호환성을 계속 확인합니다.
모든 도구는 Composer dev 의존성이므로 사용자에게 배포되는 파일에는 하나도 포함되지 않습니다.
"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"}GitHub Actions에서 여러 PHP 버전으로 matrix 테스트하기
PHP 7.4와 PHP 8.5 사이에 달라지는 것
PHP 7.4와 PHP 8.5는 같은 플러그인 소스를 서로 다르게 실행합니다. WordPress 플러그인 코드에 영향을 주는 변경 사항 일부는 다음과 같습니다.
- PHP 8.0부터 많은 내장 함수가 잘못된 타입의 인자를 받으면
TypeError를 던집니다. 7.4에서는 경고와 함께null을 반환했습니다.strlen()에 배열을 넘기는 코드는 7.4에서는 계속 실행되지만 8.0에서는 멈춥니다. - PHP 8.0은 숫자와 숫자가 아닌 문자열을 비교하는 방식을 바꿨습니다.
0 == "abc"는 7.4에서true, 8.0에서false이므로, 옵션 값에 대한 느슨한 비교 일부는 아무 경고 없이 결과가 뒤집힙니다. - PHP 8.0에서
str_starts_with()와 관련 함수가 추가되었습니다. 저희 이미지 프로세서는str_starts_with()를 호출합니다. 이 코드가 PHP 7.4에서 동작하는 것은 WordPress 5.9 이상이 polyfill을 제공하기 때문이며,Requires at least: 5.9가 이를 보장합니다. - PHP 8.1은 내장 함수의 non-nullable 매개변수에
null을 넘기는 것을 deprecated로 지정했습니다.strtolower( wp_parse_url( $url, PHP_URL_HOST ) )는 7.4에서는 문제가 없지만, 8.1에서는$url이 상대 경로여서 host가null이면 deprecation을 로그에 남깁니다. 일부 호스팅은 deprecation 메시지를 페이지에 그대로 출력합니다. - PHP 8.2는 동적 프로퍼티를 deprecated로 지정했습니다. 직접 작성한 클래스에서 선언하지 않은 프로퍼티에 값을 할당하면 요청마다 deprecation이 기록됩니다.
이런 문제는 모두 동작이 달라지는 PHP 버전에서 코드를 분석하거나 실행해야만 드러납니다. 그래서 파이프라인은 master로 들어오는 모든 pull request를 일곱 개 버전에서 동시에 실행합니다.
GitHub Actions matrix

그림 1: 1.2.0 작업을 master에 병합할 때 실행된 PHP Code Quality & Standards 워크플로. PHP 버전마다 별도의 job이며, PHP 8.3 job에서 모든 job이 실행하는 단계를 볼 수 있습니다. PHPCS, PHPStan, 그다음 자동 테스트입니다.
PHP 버전 지원 범위 안내: 위 스크린샷은 PHP 7.4부터 8.3까지를 대상으로 한 이전 CI 실행을 보여 줍니다. 현재 저희 GitHub Actions 테스트 matrix는 PHP 7.4부터 PHP 8.5까지로 확장되어, 레거시 환경에서 회귀 없이 최신 PHP 런타임과의 상위 호환성을 계속 확인합니다.
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는 토큰 단위로 동작하므로 실제로는 일곱 버전 모두에서 같은 결과를 냅니다. matrix에서 PHPCS가 주로 증명하는 것은 도구 체인이 각 버전에서 설치되고 실행된다는 사실입니다. 여러 PHP 버전을 대상으로 문법과 함수를 한 번에 검사하는 것은 PHPCompatibility의 역할이며, 저희 ruleset에는 아직 포함되어 있지 않습니다.
WordPress Coding Standards(WPCS) 자동화
플러그인에 중요한 보안 sniff
WPCS는 WordPress용으로 작성된 PHP_CodeSniffer sniff 모음입니다. 플러그인 보안에 가장 중요한 규칙은 WordPress ruleset 자체에 들어 있습니다.
WordPress.Security.ValidatedSanitizedInput는 unslash와 sanitize를 거치지 않은$_POST,$_GET,$_REQUEST,$_COOKIE읽기를 모두 잡아냅니다.WordPress.Security.EscapeOutput는esc_html(),esc_attr(),esc_url()같은 이스케이프 함수를 거치지 않은 값을echo나print로 출력하는 코드를 모두 잡아냅니다.WordPress.Security.NonceVerification는 nonce를 검증하지 않고 요청 데이터를 읽는 핸들러를 잡아냅니다.WordPress.DB.PreparedSQL와PreparedSQLPlaceholders는$wpdb->prepare()대신 문자열 연결로 만든 쿼리와, 인자와 맞지 않는 placeholder를 잡아냅니다.
저희 WPCS ruleset
phpcs.xml.dist는 WordPress-Extra와 WordPress-Docs를 추가하고, 번역 sniff가 사용할 text domain을 고정하며, 모든 전역 함수, 클래스, 상수에 저희 접두사를 붙이도록 요구합니다. 그래서 같은 사이트에 설치된 다른 플러그인과 이름이 충돌하지 않습니다.
<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>WPCS를 통과하는 AJAX 핸들러
플러그인의 이 핸들러는 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는 요청이 저희 관리자 페이지에서 왔다는 것만 증명할 뿐 사용자에게 그 작업을 수행할 권한이 있다는 것까지 증명하지는 않기 때문입니다. $_POST['after']는 쿼리에 도달하기 전에 unslash하고 absint()로 변환하며, 값이 이미 정수여도 쿼리는 여전히 $wpdb->prepare()를 거칩니다.
억제할 때는 sniff 이름과 이유를 적습니다
위의 phpcs:ignore 줄은 의도적으로 넣은 것입니다. WPCS는 object cache 없이 직접 실행하는 데이터베이스 쿼리에 경고를 냅니다. 프런트엔드 읽기에는 맞는 조언이지만, 호출할 때마다 행이 바뀌는 관리자 작업에는 맞지 않습니다. 저희 규칙은 모든 ignore가 정확한 sniff 이름을 지정하고 -- 뒤에 이유를 적는 것입니다. 아무것도 붙이지 않은 // phpcs:ignore는 리뷰를 통과하지 못합니다.
CI에서 PHPCS는 checkstyle 리포트를 만들고, cs2pr가 이를 pull request diff의 annotation으로 바꿉니다. 그래서 리뷰어는 로그가 아니라 위반을 일으킨 줄에서 바로 각 위반 사항을 확인합니다.
PHPStan 정적 분석으로 로직 결함 잡기
WPCS는 코드를 한 줄씩 읽습니다. echo에 이스케이프 호출이 없다는 것은 알지만, 변수에 어떤 타입이 들어 있는지, 어떤 분기가 실제로 실행될 수 있는지는 모릅니다. PHPStan은 각 함수를 흐르는 타입의 모델을 만들고, 이 모델과 모순되는 코드를 보고합니다. 존재하지 않는 메서드 호출, string이 필요한 곳에 string|false 전달, 절대 같을 수 없는 값의 비교, 항상 참이거나 항상 거짓인 조건 등입니다.
WordPress를 실행하지 않고 PHPStan으로 WordPress 코드 분석하기
szepeviktor/phpstan-wordpress는 stub 파일을 통해 WordPress 코어 함수와 클래스의 시그니처를 PHPStan에 제공하고, WordPress 전용 타입 확장도 몇 가지 추가합니다. phpstan/extension-installer가 이를 자동으로 등록합니다. PHPStan은 이 선언만 읽으며 WordPress나 데이터베이스를 로드하지 않습니다.
저희 PHPStan 설정
parameters: level: 5 paths: - smallpict.php - includes/ - admin/ treatPhpDocTypesAsCertain: false bootstrapFiles: - phpstan-bootstrap.phpLevel 5는 하위 레벨의 검사(알 수 없는 클래스, 메서드, 프로퍼티, 반환 타입, dead code)에 더해 함수나 메서드에 전달하는 모든 인자의 타입을 검사합니다. treatPhpDocTypesAsCertain: false는 저희가 작성한 @param docblock을 확정된 근거로 취급하지 말라고 PHPStan에 지시합니다. WordPress는 filter가 반환하는 값을 그대로 넘기므로, int로 문서화한 매개변수에도 다른 플러그인 때문에 문자열이 들어올 수 있습니다. 이런 경우를 처리하는 방어적 검사를 PHPStan이 그대로 유지하게 하려는 설정입니다. 부트스트랩 파일은 보통 런타임에 설정되는 플러그인 상수(SMALLPICT_PATH, SMALLPICT_URL 등)를 정의합니다.
![GitHub Actions의 PHPStan WordPress 정적 분석 로그: WPCS 단계 문제 없음, PHPStan이 플러그인 파일 11개 중 11개를 분석하고 "[OK] No errors" 출력](/blog-assets/02_phpcs_phpstan_log.png)
그림 2: PHP 8.3 job의 PHPCS 및 PHPStan 단계. PHPCS는 보고할 내용이 없으면 아무것도 출력하지 않습니다. PHPStan은 플러그인 파일 11개(메인 플러그인 파일과 includes/의 클래스 10개)를 분석하고 오류가 없다고 보고합니다.
실제 PHPStan 발견 사항: 이미지 프로세서 626번째 줄
2026년 10월 2일, 파이프라인이 PHPStan 단계에서 오류 하나로 실패했습니다.

그림 3: PHP 8.2 job이 PHPStan 단계에서 includes/class-smallpict-image-processor.php 626번째 줄의 booleanAnd.rightAlwaysTrue로 중단되었습니다. 당시 분석 대상 경로에는 플러그인 파일이 9개 있었습니다.
이 코드는 변환의 마지막 단계입니다. 플러그인의 출력 형식 설정이 WebP나 AVIF이면 변환된 파일이 첨부 파일이 되고, 디스크 공간을 아끼기 위해 원본 업로드 파일을 삭제합니다. 나머지 선택지인 "AVIF(WebP를 백업으로 사용)"와 "원본 형식 유지"는 원본 JPEG 및 PNG 파일을 유지합니다. 삭제 분기는 다음과 같았습니다.
// 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줄 앞, 같은 메서드의 맨 위에 있습니다.
$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.' );}메서드는 파일이 존재할 때만 계속 진행하고, PHPStan은 이 사실을 626번째 줄까지 가져갑니다.
저희는 이 발견을 곧이곧대로 받아들이지 않았습니다. file_exists()는 파일 시스템에 질의하므로 두 번의 호출 사이에 결과가 바뀔 수 있습니다. PHPStan은 기본적으로 이런 호출의 결과를 기억하며(rememberPossiblyImpureFunctionValues 옵션), 그 사이에 디스크를 바꾼 것이 없다고 가정합니다. 검사를 지우기 전에 코드를 읽고 두 가지를 확인했습니다.
- 가드와 626번째 줄 사이에서 원본 파일을 삭제하거나 이동하는 코드는 없습니다. 변환은 각 출력을 임시 파일에 쓴 뒤 새 경로로 옮기며, 그 경로에 대한
wp_delete_file()호출은 모두 임시 파일을 지우는 것입니다. 로컬 파일을 실제로 삭제하는 offload 단계는 626번째 줄 이후에 실행됩니다. wp_delete_file()은 어차피 파일이 없어도 문제없이 동작합니다. 경로를wp_delete_filefilter에 통과시킨 뒤@unlink()를 호출하는데, 파일이 이미 없으면 해가 되는 동작은 일어나지 않습니다.
두 가지를 모두 확인하고 나니 이 조건은 dead code였습니다. 삭제 분기에 있는 dead code는 다음에 코드를 읽는 사람에게 메서드 실행 도중 파일이 사라질 수 있다는 잘못된 인상을 줍니다. 수정에서는 이 조건을 제거했습니다.
-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 ); }이 발견은 크래시가 아니었습니다. 가치는 이 메서드에 대해 드러낸 사실에 있었습니다. 사용자 파일을 삭제하는 유일한 분기에서 코드의 가정이 코드 구조와 어긋나 있었습니다. 저희가 얻은 교훈은 "분석기를 믿어라"보다 범위가 좁습니다. 파일 시스템, 네트워크, 시간 함수에 대해 항상 참이거나 항상 거짓이라는 보고가 나오면, 조건을 지우기 전에 두 호출 사이에서 무슨 일이 일어나는지 직접 확인해야 합니다.
PHPUnit 없이 WordPress 플러그인 자동 테스트하기
각 matrix job의 마지막 단계는 tests/의 파일을 실행합니다. 이 파일들은 PHPUnit 테스트가 아닙니다. 각 파일은 필요한 WordPress 함수(get_option(), update_option(), sanitize_key() 등)의 최소 stub을 정의하고, 테스트할 플러그인 클래스를 로드해 실행한 뒤, 첫 번째 실패에서 0이 아닌 코드로 종료하는 일반 PHP 스크립트입니다.
for t in tests/test_.php do echo "== $t" php "$t" exit 1done현재 이런 스크립트는 9개입니다. 실수가 고객의 데이터나 돈에 손해를 끼치는 경로를 다룹니다. WebP 및 AVIF 변환 파이프라인과 그 fallback, 이미지 전송, 클라우드 스토리지로의 offload와 파일 삭제, offload된 파일의 배치 복원, 결제가 중단된 계정의 동작, 스토리지 할당량, 그리고 uninstall입니다. uninstall 테스트는 메모리 내 SQLite 데이터베이스에서 실제 uninstall 쿼리를 실행하고 무엇이 남는지 확인합니다.
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은 실수로 되돌리기 쉬운 결정을 보호합니다. 플러그인을 uninstall하면 설정은 삭제되지만, 어떤 파일이 클라우드 스토리지에만 있는지 기록한 post meta는 남겨 둡니다. 그래야 플러그인을 다시 설치한 사이트가 이미지를 계속 찾을 수 있습니다. 부주의하게 작성한 DELETE ... LIKE '_smallpict%'는 이 마커까지 지워 버리며, 그런 경우 테스트가 실패합니다.
WordPress PHPUnit 테스트 스위트 대신 일반 스크립트를 선택한 이유는 속도와 설정 비용입니다. 데이터베이스 서버도 WordPress checkout도 필요 없고, 9개 모두 1초 안에 끝나므로 일곱 개 PHP 버전에서 실행해도 pull request에 추가되는 시간은 거의 없습니다. 대신 stub은 저희가 작성해 넣은 동작만 모델링합니다. 미디어 라이브러리 자체의 메타데이터 처리처럼 실제 WordPress 코어에 의존하는 동작은 이 테스트로 검증되지 않습니다.
WordPress.org 배포 전 게이트
릴리스는 별도의 워크플로로 배포됩니다. v1.2.9 같은 태그를 푸시하면 이 워크플로가 시작되며, WordPress.org SVN에 커밋하는 job은 lint matrix에 대한 의존성을 선언합니다.
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는 lint job 일곱 개가 모두 성공해야만 deploy job이 시작된다는 뜻입니다. 어느 한 버전에서라도 WPCS가 실패하면 GitHub는 deploy를 건너뛰고 SVN 커밋은 일어나지 않습니다. 빌드 단계는 저희가 지원하는 가장 오래된 버전인 PHP 7.4에서 실행됩니다. 이 job의 토큰은 저장소에 대해 읽기 전용 권한만 가집니다. 유일한 쓰기 대상은 WordPress.org SVN이며, 자격 증명은 저장소 secret으로 보관합니다.

그림 4: Deploy to WordPress.org 워크플로. WPCS Check job 다섯 개가 먼저 실행되고, 그 아래의 Deploy job은 다섯 개가 모두 통과한 뒤에만 실행됩니다.
게이트는 앞선 pull request 실행 결과를 믿지 않고, 태그가 가리키는 커밋에서 WPCS를 다시 실행합니다. 태그는 직접 푸시한 커밋이나 검사가 끝나지 않은 커밋을 포함해 어떤 커밋이든 가리킬 수 있고, 릴리스 워크플로에는 자신이 무엇을 배포하는지 알 다른 방법이 없습니다.
CI 검사를 로컬에서 실행하기
Composer 스크립트는 CI가 실행하는 것과 같은 명령을 감싸고 있으므로, 푸시하기 전에 실패를 재현할 수 있습니다.
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특정 matrix job의 실패를 재현하려면 해당 PHP 버전에서 같은 명령을 실행합니다. 예를 들어 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가 기계적으로 고칠 수 있는 것만 수정합니다. 이스케이프, sanitize, nonce 관련 지적은 문맥에 맞는 함수를 사람이 골라야 하므로 자동 수정하지 않습니다.
파이프라인이 아직 다루지 않는 부분
다음 세 가지 공백이 작업 목록에 올라 있습니다.
- 배포 전 게이트는 WPCS만 실행합니다. PHPStan과 자동 테스트는
master와develop으로의 push와 pull request에서 실행되지만, 릴리스 태그에서는 실행되지 않습니다. 배포 워크플로의lintjob에 두 단계를 추가하면 이 공백이 해결됩니다. - 저희 ruleset에는 PHPCompatibility가 포함되어 있지 않습니다.
phpcompatibility/phpcompatibility-wp를testVersion값7.4-로 추가하면 7.4에 존재하지 않는 문법과 함수를 한 번의 실행으로 잡아낼 수 있습니다. - 테스트는 실제 WordPress 설치 대신 stub을 사용합니다. WordPress 5.9(저희 최소 버전)와 최신 릴리스를 대상으로 하는 integration job을 추가하면 stub이 다루지 못하는 코어 동작을 검증할 수 있습니다.
FAQ
WordPress 플러그인에 PHP 7.4부터 8.5까지 matrix 테스트가 중요한 이유는 무엇인가요?
WordPress.org는 같은 릴리스를 모든 사이트에 배포하며, 어떤 PHP 버전에 플러그인이 설치될지 거르는 기준은 플러그인 헤더(Requires PHP: 7.4)뿐입니다. 7.4와 8.5 사이에 PHP는 내장 함수가 잘못된 인자 타입을 처리하는 방식, 숫자와 문자열을 비교하는 방식, deprecation을 발생시키는 패턴을 바꿨습니다. 그래서 한 버전에서 올바른 코드가 다른 버전에서는 fatal error를 내거나 경고를 남길 수 있습니다. 단일 PHP 버전에서 실행한 CI는 해당 버전에서 플러그인이 동작한다는 것만 증명합니다. 지원하는 모든 버전에서 PHPStan과 테스트를 실행하면 릴리스가 사용자에게 도달하기 전에 이런 차이를 잡아낼 수 있습니다.
WPCS가 기존의 보안 감사를 대체하나요?
아니요. WPCS는 패턴이 나타나는 줄에서 그 패턴을 검사합니다. sanitize 없이 읽은 요청 변수, 이스케이프 없는 echo, nonce 검사가 없는 핸들러, $wpdb->prepare()를 쓰지 않은 쿼리 같은 것입니다. 핸들러가 올바른 capability를 검사하는지, 이스케이프 함수가 출력 문맥에 맞는지, 서버 측 요청이 사용자가 제어하는 URL로 향하는지, 한 사용자가 다른 사용자의 데이터에 접근할 수 있는지는 판단하지 못합니다. 또한 phpcs:ignore 주석으로 sniff를 끌 수도 있습니다. 저희는 WPCS를 이미 알려진 실수를 막는 최소 기준으로 보고, 보안과 관련된 변경은 사람이 직접 리뷰합니다.
PHPStan은 코드를 실행하지 않고 WordPress 코어 함수를 어떻게 처리하나요?
PHPStan은 분석하는 코드를 절대 실행하지 않습니다. 선언과 docblock을 읽어 타입 모델을 만듭니다. WordPress의 경우 szepeviktor/phpstan-wordpress가 stub 파일을 제공합니다. stub 파일에는 WordPress 코어 함수와 클래스의 시그니처, 매개변수 타입, 반환 타입이 본문 없이 들어 있습니다. PHPStan은 이 stub을 WordPress처럼 읽으므로 wp_delete_file()나 get_attached_file() 호출이 올바른 타입을 넘기고 받는지 검사할 수 있습니다. WordPress나 플러그인이 런타임에 정의하는 상수는 부트스트랩 파일에 넣고, PHPStan은 분석 전에 이 파일을 로드합니다.
플러그인은 WordPress.org에서 smallPict라는 이름으로 제공됩니다. 설치 방법과 출력 형식 설정은 설치 가이드와 이미지 설정 레퍼런스에서 다룹니다.