Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .github/actions/resolve-php-version/action.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 5246f5 to 5387fe
20 changes: 18 additions & 2 deletions .github/workflows/reports.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,16 +64,17 @@ 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

- 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: Install dependencies
Expand Down
29 changes: 24 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ on:
- 'tests/**'
- 'composer.json'
- 'composer.lock'
- '.github/actions/**'
- '.github/workflows/tests.yml'
push:
branches: [ "main" ]
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/wiki.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/github-actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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}/``.
Expand All @@ -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.

Expand All @@ -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.

Expand Down
Loading