name: 📁 PHP CI & Docker Build

on:
  push:
    branches:
      - '*.*.x'
    tags: ['*']
  pull_request: ~
  workflow_dispatch: ~

# Every job below only ever reads the repository. Third-party actions and the
# `phpqa` container therefore never receive a token able to write to contents,
# packages, issues or pull requests.
permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

jobs:
  pre_checks:
    name: "0️⃣ Pre-checks"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1

      - name: "Check file permissions"
        run: |
          NON_EXECUTABLE=$(find . -type f -not -path './.git/*' -not -path './bin/console' -not -path './frankenphp/docker-entrypoint.sh' -not -path './link' -executable)
          if [[ -n "$NON_EXECUTABLE" ]]; then
            echo "Found non-executable files with executable permission:"
            echo "$NON_EXECUTABLE"
            exit 1
          fi

      - name: "Find non-printable ASCII characters"
        run: |
          if LC_ALL=C.UTF-8 find . -type f -name "*.php" -print0 | xargs -0 grep -PHn "[[:cntrl:]]"; then
            echo "Non-printable ASCII characters found"
            exit 1
          fi

  prepare_dependencies:
    name: "📦 Prepare Composer Dependencies"
    needs: [pre_checks]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    outputs:
      cache-key: ${{ steps.cache-key-generator.outputs.key }}
    steps:
      - uses: actions/checkout@v7.0.1

      # `composer.lock` is gitignored (this is a library), so hashing it would
      # always yield the empty hash and freeze the cache key forever. The
      # manifest is the only committed input to dependency resolution.
      - id: cache-key-generator
        run: echo "key=composer-${{ runner.os }}-${{ hashFiles('composer.json') }}" >> "$GITHUB_OUTPUT"

      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ steps.cache-key-generator.outputs.key }}
          restore-keys: |
            composer-${{ runner.os }}-

      - uses: ramsey/composer-install@4.0.0
        with:
          dependency-versions: highest
          composer-options: --optimize-autoloader

  phpstan:
    name: "1️⃣ Static Analysis (PHPStan)"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      - run: castor phpstan

  ecs:
    name: "2️⃣ Coding Standards (ECS)"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      - run: castor ecs

  rector:
    name: "3️⃣ Refactoring (Rector)"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      - run: castor rector

  validate:
    name: "4️⃣ Validate composer.json"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      - run: |
          composer dump-autoload --optimize --strict-psr
          composer validate --strict
          composer normalize --dry-run --diff || true

  lint:
    name: "5️⃣ Syntax Check"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - run: composer exec -- parallel-lint src tests

  check_licenses:
    name: "6️⃣ License Check"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      # `composer licenses` reads the lock file, which is gitignored and not
      # part of the cache. Without this install the command reports zero
      # packages and the job passes without checking anything.
      - name: Install dependencies
        run: composer install --no-interaction --no-progress
      - run: castor check-licenses

  deptrac:
    name: "7️⃣ Architecture Layer Check (Deptrac)"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: actions/cache@v6.1.0
        with:
          path: |
            vendor
            ~/.composer/cache
          key: ${{ needs.prepare_dependencies.outputs.cache-key }}
      - run: castor deptrac

  tests:
    name: "🧪 Unit & Functional Tests (PHP ${{ matrix.php-version }}${{ matrix.lowest-deps && ' - Lowest Deps' || '' }})"
    needs:
      - prepare_dependencies
      - phpstan
      - ecs
      - rector
      - lint
      - validate
      - check_licenses
      - deptrac
    runs-on: ubuntu-latest
    continue-on-error: ${{ matrix.experimental || false }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - php-version: '8.2'
            lowest-deps: true
          - php-version: '8.2'
          - php-version: '8.3'
          - php-version: '8.4'
          - php-version: '8.5'
            experimental: true
    container:
      image: ghcr.io/spomky-labs/phpqa:${{ matrix.php-version }}
    env:
      XDEBUG_MODE: 'off'
      PHP_VERSION: ${{ matrix.php-version }}
    steps:
      - uses: actions/checkout@v7.0.1
      - name: Install dependencies
        run: |
          if [ "${{ matrix.lowest-deps || 'false' }}" = "true" ]; then
            composer update --prefer-lowest --no-interaction --no-progress
          else
            composer install --no-interaction --no-progress
          fi

      # PHPUnit is invoked directly rather than through `castor phpunit`: castor itself requires PHP >= 8.4 and
      # this job also runs on the older versions of the matrix. Coverage is produced by the mutation testing job.
      - name: Run PHPUnit
        run: composer exec -- phpunit-11 --configuration .ci-tools/phpunit.xml.dist

  infection:
    name: "🧬 Mutation Testing"
    needs: [tests]
    if: ${{ endsWith(github.ref_name, '.x') }}
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/spomky-labs/phpqa:8.4
    env:
      XDEBUG_MODE: coverage
    steps:
      - uses: actions/checkout@v7.0.1
      - name: Install dependencies
        run: composer install --no-interaction --no-progress
      - name: Run PHPUnit
        run: castor phpunit
      - name: Execute Infection
        run: castor infect

  exported_files:
    name: "8️⃣ Exported Files Check"
    needs: [prepare_dependencies]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1
      - name: Check exported files
        run: |
          EXPECTED="CODE_OF_CONDUCT.md,LICENSE,README.md,RELEASES.md,SECURITY.md,composer.json"
          CURRENT=$(git archive HEAD | tar --list | grep -v '/$' | grep -Ev '^(\.github|assets|bin|config|fixtures|frankenphp|migrations|public|src|templates|translations)/' | sort | paste -sd "," -)
          echo "CURRENT = ${CURRENT}"
          echo "EXPECTED = ${EXPECTED}"
          if [[ "$CURRENT" != "$EXPECTED" ]]; then
            echo "❌ Exported files do not match expected list"
            exit 1
          fi
