diff --git a/.github/actions/resolve-php-version/action.yml b/.github/actions/resolve-php-version/action.yml new file mode 100644 index 00000000..fd4b2870 --- /dev/null +++ b/.github/actions/resolve-php-version/action.yml @@ -0,0 +1,149 @@ +name: Resolve PHP Version +description: Resolve the PHP version and test matrix from composer.lock or composer.json. + +outputs: + php-version: + description: Resolved PHP version used by setup-php. + value: ${{ steps.resolve.outputs.php-version }} + php-version-source: + description: Source used to resolve the PHP version. + value: ${{ steps.resolve.outputs.php-version-source }} + test-matrix: + description: JSON matrix of supported PHP minors starting from the inferred minimum. + value: ${{ steps.resolve.outputs.test-matrix }} + warning: + description: Warning emitted when the workflow falls back to the default PHP version. + value: ${{ steps.resolve.outputs.warning }} + +runs: + using: composite + steps: + - name: Resolve workflow PHP version + id: resolve + shell: bash + run: | + python3 <<'PY' + from __future__ import annotations + + import json + import os + import re + from pathlib import Path + + SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] + DEFAULT_PHP_VERSION = "8.3" + + def version_to_tuple(version: str) -> tuple[int, int]: + major, minor = version.split(".") + return int(major), int(minor) + + def normalize_minor(version: str) -> str | None: + match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) + if match is None: + return None + return f"{match.group(1)}.{match.group(2)}" + + def next_supported_minor(version: str) -> str | None: + if version not in SUPPORTED_MINORS: + return None + index = SUPPORTED_MINORS.index(version) + 1 + if index >= len(SUPPORTED_MINORS): + major, minor = version_to_tuple(version) + return f"{major}.{minor + 1}" + return SUPPORTED_MINORS[index] + + def infer_clause_lower_bound(clause: str) -> str | None: + tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) + lower_bounds: list[str] = [] + for operator, version in tokens: + normalized = normalize_minor(version) + if normalized is None: + continue + if operator in ("", "=", "==", "^", "~", ">="): + lower_bounds.append(normalized) + continue + if operator == ">": + next_minor = next_supported_minor(normalized) + if next_minor is not None: + lower_bounds.append(next_minor) + if not lower_bounds: + return None + return max(lower_bounds, key=version_to_tuple) + + def infer_minimum_supported_minor(requirement: str) -> str | None: + clauses = [clause.strip() for clause in requirement.split("||")] + lower_bounds = [ + clause_lower_bound + for clause in clauses + if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None + ] + if not lower_bounds: + return None + return min(lower_bounds, key=version_to_tuple) + + def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: + if not composer_lock.exists(): + return None, None + try: + payload = json.loads(composer_lock.read_text()) + except json.JSONDecodeError: + return None, "composer.lock exists but could not be parsed" + platform_overrides = payload.get("platform-overrides") or {} + platform_php = platform_overrides.get("php") + if isinstance(platform_php, str): + resolved = normalize_minor(platform_php) + if resolved is not None: + return resolved, "composer.lock platform-overrides.php" + return None, "composer.lock platform-overrides.php is not a supported PHP version" + return None, None + + def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: + if not composer_json.exists(): + return None, "composer.json does not exist" + try: + payload = json.loads(composer_json.read_text()) + except json.JSONDecodeError: + return None, "composer.json could not be parsed" + config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) + if isinstance(config_platform_php, str): + resolved = normalize_minor(config_platform_php) + if resolved is not None: + return resolved, "composer.json config.platform.php" + return None, "composer.json config.platform.php is not a supported PHP version" + require_php = ((payload.get("require") or {}).get("php")) + if isinstance(require_php, str): + resolved = infer_minimum_supported_minor(require_php) + if resolved is not None: + return resolved, "composer.json require.php" + return None, "composer.json require.php could not be resolved safely" + return None, None + + def resolve_php_version() -> tuple[str, str, str | None]: + resolved, source = resolve_from_lock(Path("composer.lock")) + if resolved is None: + resolved, source = resolve_from_json(Path("composer.json")) + if resolved is None: + return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." + if resolved not in SUPPORTED_MINORS: + return DEFAULT_PHP_VERSION, "fallback", ( + f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." + ) + return resolved, source or "fallback", None + + resolved_version, source, warning = resolve_php_version() + matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] + matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) + + print(f"Resolved PHP version source: {source}") + print(f"Resolved PHP version: {resolved_version}") + print(f"Resolved PHP test matrix: {matrix_versions}") + if warning: + print(f"Warning: {warning}") + + github_output = os.environ["GITHUB_OUTPUT"] + with open(github_output, "a", encoding="utf-8") as handle: + handle.write(f"php-version={resolved_version}\n") + handle.write(f"php-version-source={source}\n") + handle.write(f"test-matrix={matrix}\n") + handle.write(f"warning={warning or ''}\n") + PY diff --git a/.github/wiki b/.github/wiki index 5246f541..5387fef9 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 5246f541a761712275b04ddd4dab1b135ace89f6 +Subproject commit 5387fef9e237adc67cb096510d692f320fbeaba3 diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index 94940d2c..f6e77ce2 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -30,7 +30,22 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + reports: + needs: resolve_php if: github.event_name != 'schedule' && !(github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) && (github.event_name != 'pull_request' || github.event.action != 'closed') name: Generate Reports runs-on: ubuntu-latest @@ -49,7 +64,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} extensions: pcov, pcntl coverage: pcov @@ -57,8 +72,9 @@ jobs: uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Install dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3b8345f..cc8a03ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,7 @@ on: - 'tests/**' - 'composer.json' - 'composer.lock' + - '.github/actions/**' - '.github/workflows/tests.yml' push: branches: [ "main" ] @@ -53,12 +54,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + test-matrix: ${{ steps.resolve.outputs.test-matrix }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + tests: + needs: resolve_php name: Run Tests runs-on: ubuntu-latest strategy: - matrix: - php-version: [ '8.3', '8.4', '8.5' ] + matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }} env: TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} steps: @@ -75,8 +91,9 @@ jobs: uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git @@ -106,6 +123,7 @@ jobs: run: composer dev-tools tests -- --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }} dependency-health: + needs: resolve_php name: Dependency Health if: ${{ github.event_name != 'workflow_call' || inputs.run-dependencies-check }} runs-on: ubuntu-latest @@ -118,14 +136,15 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 4b2b3ef1..2c360d66 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -18,7 +18,22 @@ concurrency: cancel-in-progress: true jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve workflow PHP version + id: resolve + uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main + preview: + needs: resolve_php name: Update Wiki Preview if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -42,14 +57,15 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.resolve_php.outputs.php-version }} - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /tmp/composer-cache - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | + ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}- ${{ runner.os }}-composer- - name: Mark workspace as safe for git diff --git a/docs/usage/github-actions.rst b/docs/usage/github-actions.rst index 3d12103b..1ac2ef00 100644 --- a/docs/usage/github-actions.rst +++ b/docs/usage/github-actions.rst @@ -34,6 +34,7 @@ The ``reports.yml`` workflow is responsible for generating technical documentati **Behavior:** * **Main Branch**: Runs all checks and deploys the final reports to the root of the ``gh-pages`` branch. * Runs a post-deploy health check against the published reports index and coverage URLs with retry/backoff to account for Pages propagation. + * Resolves the workflow PHP version from ``composer.lock`` or ``composer.json`` before installing dependencies. * **Pull Requests**: * Generates a **Preview** of the documentation, coverage, and metrics. * Deploys the preview to ``gh-pages`` under ``previews/pr-{number}/``. @@ -51,6 +52,7 @@ The ``wiki.yml`` workflow synchronizes the documentation generated by the ``dev- **Behavior:** * **Submodule Management**: It manages a submodule at ``.github/wiki`` that points to the actual wiki repository. * **Pull Requests**: Pushes documentation changes to a dedicated branch (e.g., ``pr-123``) in the wiki repository for review. +* **PHP Version Resolution**: Resolves the PHP version from ``composer.lock`` or ``composer.json`` before setting up PHP and installing dependencies. * **Merge**: When a PR is merged into ``main``, it pushes the changes to the ``master`` branch of the wiki, validates the remote branch SHA, and makes them live. * **Cleanup**: When a PR is closed, the workflow deletes the matching wiki preview branch. A scheduled cleanup also removes stale ``pr-{number}`` branches for already closed pull requests. @@ -65,6 +67,7 @@ Fast Forward Tests The ``tests.yml`` workflow provides standard Continuous Integration. * Runs PHPUnit tests across the supported PHP matrix. +* Resolves the minimum supported PHP minor version from ``composer.lock`` or ``composer.json`` and builds the test matrix from that floor upward. * Runs dependency health as a separate non-blocking job when enabled. * Uses PR-scoped concurrency so newer pushes cancel older in-progress runs for the same pull request.