Files
actions/php-tests/action.yml
Ismo Vuorinen f37d940c72 fix: remove --verbose flag from PHPUnit command (#373)
Root Cause:
- PHPUnit 10 and 11 (released 2024) removed the --verbose flag
- Running `vendor/bin/phpunit --verbose` results in "Unknown option" error
- Multiple projects (Debian packages, Laravel) encountered this in 2024

Evidence:
- GitHub issues #5647 in sebastianbergmann/phpunit
- Debian bug reports #1070508, #1070509, #1070510
- Laravel framework discussion #46672

Solution:
- Remove --verbose flag from line 457
- PHPUnit's default output is sufficient for parsing
- Output parsing logic (lines 465-486) works with standard format
- Tests confirm action designed for PHPUnit 10+ output format

Impact:
- Fixes compatibility with PHPUnit 10.x and 11.x
- Maintains backward compatibility (flag was informational only)
- No change to test result parsing behavior
2025-11-25 12:25:29 +02:00

504 lines
17 KiB
YAML

# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
# permissions:
# - contents: read # Required for checking out repository
---
name: PHP Tests
description: Run PHPUnit tests with optional Laravel setup and Composer dependency management
author: Ismo Vuorinen
branding:
icon: check-circle
color: green
inputs:
framework:
description: 'Framework detection mode (auto=detect Laravel via artisan, laravel=force Laravel, generic=no framework)'
required: false
default: 'auto'
php-version:
description: 'PHP Version to use (latest, 8.4, 8.3, etc.)'
required: false
default: 'latest'
extensions:
description: 'PHP extensions to install (comma-separated)'
required: false
default: 'mbstring, intl, json, pdo_sqlite, sqlite3'
coverage:
description: 'Code-coverage driver (none, xdebug, pcov)'
required: false
default: 'none'
composer-args:
description: 'Arguments to pass to Composer install'
required: false
default: '--no-progress --prefer-dist --optimize-autoloader'
max-retries:
description: 'Maximum number of retry attempts for Composer commands'
required: false
default: '3'
token:
description: 'GitHub token for authentication'
required: false
default: ''
username:
description: 'GitHub username for commits'
required: false
default: 'github-actions'
email:
description: 'GitHub email for commits'
required: false
default: 'github-actions@github.com'
outputs:
framework:
description: 'Detected framework (laravel or generic)'
value: ${{ steps.detect-framework.outputs.framework }}
php-version:
description: 'The PHP version that was setup'
value: ${{ steps.setup-php.outputs.php-version }}
composer-version:
description: 'Installed Composer version'
value: ${{ steps.composer-config.outputs.version }}
cache-hit:
description: 'Indicates if there was a cache hit'
value: ${{ steps.composer-cache.outputs.cache-hit }}
test-status:
description: 'Test execution status (success/failure)'
value: ${{ steps.test.outputs.status }}
tests-run:
description: 'Number of tests executed'
value: ${{ steps.test.outputs.tests_run }}
tests-passed:
description: 'Number of tests passed'
value: ${{ steps.test.outputs.tests_passed }}
runs:
using: composite
steps:
- name: Mask Secrets
shell: sh
env:
GITHUB_TOKEN: ${{ inputs.token }}
run: |
set -eu
if [ -n "$GITHUB_TOKEN" ]; then
echo "::add-mask::$GITHUB_TOKEN"
fi
- name: Validate Inputs
id: validate
shell: sh
env:
FRAMEWORK: ${{ inputs.framework }}
PHP_VERSION: ${{ inputs.php-version }}
COVERAGE: ${{ inputs.coverage }}
MAX_RETRIES: ${{ inputs.max-retries }}
EMAIL: ${{ inputs.email }}
USERNAME: ${{ inputs.username }}
run: |
set -eu
# Validate framework mode
case "$FRAMEWORK" in
auto|laravel|generic)
echo "Framework mode: $FRAMEWORK"
;;
*)
echo "::error::Invalid framework: '$FRAMEWORK'. Must be 'auto', 'laravel', or 'generic'"
exit 1
;;
esac
# Validate PHP version format
if [ "$PHP_VERSION" != "latest" ]; then
case "$PHP_VERSION" in
[0-9]*\.[0-9]*\.[0-9]*)
# X.Y.Z format (e.g., 8.3.0)
;;
[0-9]*\.[0-9]*)
# X.Y format (e.g., 8.4)
;;
*)
echo "::error::Invalid php-version format: '$PHP_VERSION'. Expected format: X.Y or X.Y.Z (e.g., 8.4, 8.3.0)"
exit 1
;;
esac
fi
# Validate coverage driver
case "$COVERAGE" in
none|xdebug|pcov)
;;
*)
echo "::error::Invalid coverage driver: '$COVERAGE'. Must be 'none', 'xdebug', or 'pcov'"
exit 1
;;
esac
# Validate max retries (must be digits only)
case "$MAX_RETRIES" in
*[!0-9]*)
echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer between 1 and 10"
exit 1
;;
esac
# Validate max retries range
if [ "$MAX_RETRIES" -le 0 ] || [ "$MAX_RETRIES" -gt 10 ]; then
echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer between 1 and 10"
exit 1
fi
# Validate email format (must contain @ and .)
case "$EMAIL" in
*@*.*) ;;
*)
echo "::error::Invalid email format: '$EMAIL'. Expected valid email address"
exit 1
;;
esac
# Validate username format (reject command injection patterns)
case "$USERNAME" in
*";"*|*"&&"*|*"|"*)
echo "::error::Invalid username: '$USERNAME'. Command injection patterns not allowed"
exit 1
;;
esac
if [ ${#USERNAME} -gt 39 ]; then
echo "::error::Username too long: ${#USERNAME} characters. GitHub usernames are max 39 characters"
exit 1
fi
echo "Input validation completed successfully"
- name: Checkout Repository
uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta
with:
token: ${{ inputs.token || github.token }}
- name: Detect Framework
id: detect-framework
shell: sh
env:
FRAMEWORK_MODE: ${{ inputs.framework }}
run: |
set -eu
framework="generic"
if [ "$FRAMEWORK_MODE" = "laravel" ]; then
framework="laravel"
echo "Framework mode forced to Laravel"
elif [ "$FRAMEWORK_MODE" = "auto" ]; then
if [ -f "artisan" ]; then
framework="laravel"
echo "Detected Laravel framework (artisan file found)"
else
echo "No Laravel framework detected (no artisan file)"
fi
else
echo "Framework mode set to generic"
fi
printf 'framework=%s\n' "$framework" >> "$GITHUB_OUTPUT"
- name: Detect PHP Version
id: detect-php-version
shell: sh
env:
DEFAULT_VERSION: ${{ inputs.php-version }}
run: |
set -eu
# Function to validate version format
validate_version() {
version=$1
case "$version" in
[0-9]*\.[0-9]* | [0-9]*\.[0-9]*\.[0-9]*)
return 0
;;
*)
return 1
;;
esac
}
# Function to clean version string
clean_version() {
printf '%s' "$1" | sed 's/^[vV]//' | tr -d ' \n\r'
}
detected_version=""
# Parse .tool-versions file
if [ -f .tool-versions ]; then
echo "Checking .tool-versions for php..." >&2
version=$(awk '/^php[[:space:]]/ {gsub(/#.*/, ""); print $2; exit}' .tool-versions 2>/dev/null || echo "")
if [ -n "$version" ]; then
version=$(clean_version "$version")
if validate_version "$version"; then
echo "Found PHP version in .tool-versions: $version" >&2
detected_version="$version"
fi
fi
fi
# Parse Dockerfile
if [ -z "$detected_version" ] && [ -f Dockerfile ]; then
echo "Checking Dockerfile for php..." >&2
version=$(grep -iF "FROM" Dockerfile | grep -F "php:" | head -1 | \
sed -n -E "s/.*php:([0-9]+(\.[0-9]+)*)(-[^:]*)?.*/\1/p" || echo "")
if [ -n "$version" ]; then
version=$(clean_version "$version")
if validate_version "$version"; then
echo "Found PHP version in Dockerfile: $version" >&2
detected_version="$version"
fi
fi
fi
# Parse devcontainer.json
if [ -z "$detected_version" ] && [ -f .devcontainer/devcontainer.json ]; then
echo "Checking devcontainer.json for php..." >&2
if command -v jq >/dev/null 2>&1; then
version=$(jq -r '.image // empty' .devcontainer/devcontainer.json 2>/dev/null | sed -n -E "s/.*php:([0-9]+(\.[0-9]+)*)(-[^:]*)?.*/\1/p" || echo "")
if [ -n "$version" ]; then
version=$(clean_version "$version")
if validate_version "$version"; then
echo "Found PHP version in devcontainer: $version" >&2
detected_version="$version"
fi
fi
else
echo "jq not found; skipping devcontainer.json parsing" >&2
fi
fi
# Parse .php-version file
if [ -z "$detected_version" ] && [ -f .php-version ]; then
echo "Checking .php-version..." >&2
version=$(tr -d '\r' < .php-version | head -1)
if [ -n "$version" ]; then
version=$(clean_version "$version")
if validate_version "$version"; then
echo "Found PHP version in .php-version: $version" >&2
detected_version="$version"
fi
fi
fi
# Parse composer.json
if [ -z "$detected_version" ] && [ -f composer.json ]; then
echo "Checking composer.json..." >&2
if command -v jq >/dev/null 2>&1; then
version=$(jq -r '.require.php // empty' composer.json 2>/dev/null | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p')
if [ -z "$version" ]; then
version=$(jq -r '.config.platform.php // empty' composer.json 2>/dev/null | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p')
fi
if [ -n "$version" ]; then
version=$(clean_version "$version")
if validate_version "$version"; then
echo "Found PHP version in composer.json: $version" >&2
detected_version="$version"
fi
fi
else
echo "jq not found; skipping composer.json parsing" >&2
fi
fi
# Use default version if nothing detected
if [ -z "$detected_version" ]; then
detected_version="$DEFAULT_VERSION"
echo "Using default PHP version: $detected_version" >&2
fi
# Set output
printf 'detected-version=%s\n' "$detected_version" >> "$GITHUB_OUTPUT"
echo "Final detected PHP version: $detected_version" >&2
- name: Setup PHP
id: setup-php
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5
with:
php-version: ${{ steps.detect-php-version.outputs.detected-version }}
extensions: ${{ inputs.extensions }}
coverage: ${{ inputs.coverage }}
ini-values: memory_limit=1G, max_execution_time=600
fail-fast: true
- name: Configure Composer
id: composer-config
shell: sh
env:
GITHUB_TOKEN: ${{ inputs.token || github.token }}
run: |
set -eu
# Configure Composer environment
composer config --global process-timeout 600
composer config --global allow-plugins true
composer config --global github-oauth.github.com "$GITHUB_TOKEN"
# Verify Composer installation
composer_full_version=$(composer --version | sed -n 's/.*Composer version \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' || echo "")
if [ -z "$composer_full_version" ]; then
echo "::error::Failed to detect Composer version"
exit 1
fi
echo "Detected Composer version: $composer_full_version"
printf 'version=%s\n' "$composer_full_version" >> "$GITHUB_OUTPUT"
# Log Composer configuration
echo "Composer Configuration:"
composer config --list
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
vendor
~/.composer/cache
key: ${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-composer-${{ hashFiles('composer.lock', 'composer.json') }}
restore-keys: |
${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-composer-
${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-
${{ runner.os }}-php-
- name: Clear Composer Cache Before Install
if: steps.composer-cache.outputs.cache-hit != 'true'
shell: sh
run: |
set -eu
echo "Clearing Composer cache to ensure clean installation..."
composer clear-cache
- name: Install Composer Dependencies
uses: step-security/retry@e1d59ce1f574b32f0915e3a8df055cfe9f99be5d # v3
with:
timeout_minutes: 10
max_attempts: ${{ inputs.max-retries }}
retry_wait_seconds: 30
command: composer install ${{ inputs.composer-args }}
- name: Verify Composer Installation
shell: sh
run: |
set -eu
# Verify vendor directory
if [ ! -d "vendor" ]; then
echo "::error::vendor directory not found"
exit 1
fi
# Verify autoloader
if [ ! -f "vendor/autoload.php" ]; then
echo "::error::autoload.php not found"
exit 1
fi
echo "✅ Composer installation verified"
- name: Laravel Setup - Copy .env
if: steps.detect-framework.outputs.framework == 'laravel'
shell: sh
run: |
set -eu
php -r "file_exists('.env') || copy('.env.example', '.env');"
echo "✅ Laravel .env file configured"
- name: Laravel Setup - Generate Key
if: steps.detect-framework.outputs.framework == 'laravel'
shell: sh
run: |
set -eu
php artisan key:generate
echo "✅ Laravel application key generated"
- name: Laravel Setup - Directory Permissions
if: steps.detect-framework.outputs.framework == 'laravel'
shell: sh
run: |
set -eu
chmod -R 777 storage bootstrap/cache
echo "✅ Laravel directory permissions configured"
- name: Laravel Setup - Create Database
if: steps.detect-framework.outputs.framework == 'laravel'
shell: sh
run: |
set -eu
mkdir -p database
touch database/database.sqlite
echo "✅ Laravel SQLite database created"
- name: Run PHPUnit Tests
id: test
shell: sh
env:
IS_LARAVEL: ${{ steps.detect-framework.outputs.framework == 'laravel' }}
DB_CONNECTION: sqlite
DB_DATABASE: database/database.sqlite
run: |
set -eu
echo "Running PHPUnit tests..."
# Run PHPUnit and capture results
phpunit_exit_code=0
if [ "$IS_LARAVEL" = "true" ] && [ -f "composer.json" ] && grep -q '"test"' composer.json; then
echo "Running Laravel tests via composer test..."
phpunit_output=$(composer test 2>&1) || phpunit_exit_code=$?
elif [ -f "vendor/bin/phpunit" ]; then
echo "Running PHPUnit directly..."
phpunit_output=$(vendor/bin/phpunit 2>&1) || phpunit_exit_code=$?
else
echo "::error::PHPUnit not found. Ensure Composer dependencies are installed."
exit 1
fi
echo "$phpunit_output"
# Parse test results from output - handle various PHPUnit formats
tests_run="0"
tests_passed="0"
# Pattern 1: "OK (N test(s), M assertions)" - success case (handles both singular and plural)
if echo "$phpunit_output" | grep -qE 'OK \([0-9]+ tests?,'; then
tests_run=$(echo "$phpunit_output" | grep -oE 'OK \([0-9]+ tests?,' | grep -oE '[0-9]+' | head -1)
tests_passed="$tests_run"
# Pattern 2: "Tests: N" line - failure/error/skipped case
elif echo "$phpunit_output" | grep -qE '^Tests:'; then
tests_run=$(echo "$phpunit_output" | grep -E '^Tests:' | grep -oE '[0-9]+' | head -1)
# Calculate passed from failures and errors
failures=$(echo "$phpunit_output" | grep -oE 'Failures: [0-9]+' | grep -oE '[0-9]+' | head -1 || echo "0")
errors=$(echo "$phpunit_output" | grep -oE 'Errors: [0-9]+' | grep -oE '[0-9]+' | head -1 || echo "0")
tests_passed=$((tests_run - failures - errors))
# Ensure non-negative
if [ "$tests_passed" -lt 0 ]; then
tests_passed="0"
fi
fi
# Determine status
if [ $phpunit_exit_code -eq 0 ]; then
status="success"
echo "✅ Tests passed: $tests_passed/$tests_run"
else
status="failure"
echo "❌ Tests failed"
fi
# Output results
printf 'tests_run=%s\n' "$tests_run" >> "$GITHUB_OUTPUT"
printf 'tests_passed=%s\n' "$tests_passed" >> "$GITHUB_OUTPUT"
printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT"
# Exit with original code to maintain test failure behavior
exit $phpunit_exit_code