mirror of
https://github.com/ivuorinen/actions.git
synced 2026-01-26 11:34:00 +00:00
504 lines
17 KiB
YAML
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.0.4
|
|
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
|