Skip to content

Commit 1d00bad

Browse files
committed
[github-actions] Infer workflow PHP version (#76)
1 parent 9d21ffc commit 1d00bad

9 files changed

Lines changed: 611 additions & 11 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import os
8+
import re
9+
import sys
10+
from pathlib import Path
11+
12+
SUPPORTED_MINORS = ["8.3", "8.4", "8.5"]
13+
DEFAULT_PHP_VERSION = "8.3"
14+
15+
16+
def version_to_tuple(version: str) -> tuple[int, int]:
17+
major, minor = version.split(".")
18+
return int(major), int(minor)
19+
20+
21+
def normalize_minor(version: str) -> str | None:
22+
match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version)
23+
24+
if match is None:
25+
return None
26+
27+
return f"{match.group(1)}.{match.group(2)}"
28+
29+
30+
def next_supported_minor(version: str) -> str | None:
31+
if version not in SUPPORTED_MINORS:
32+
return None
33+
34+
index = SUPPORTED_MINORS.index(version) + 1
35+
36+
if index >= len(SUPPORTED_MINORS):
37+
major, minor = version_to_tuple(version)
38+
return f"{major}.{minor + 1}"
39+
40+
return SUPPORTED_MINORS[index]
41+
42+
43+
def infer_clause_lower_bound(clause: str) -> str | None:
44+
tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause)
45+
lower_bounds: list[str] = []
46+
47+
for operator, version in tokens:
48+
normalized = normalize_minor(version)
49+
50+
if normalized is None:
51+
continue
52+
53+
if operator in ("", "=", "==", "^", "~", ">="):
54+
lower_bounds.append(normalized)
55+
continue
56+
57+
if operator == ">":
58+
next_minor = next_supported_minor(normalized)
59+
60+
if next_minor is not None:
61+
lower_bounds.append(next_minor)
62+
63+
if not lower_bounds:
64+
return None
65+
66+
return max(lower_bounds, key=version_to_tuple)
67+
68+
69+
def infer_minimum_supported_minor(requirement: str) -> str | None:
70+
clauses = [clause.strip() for clause in requirement.split("||")]
71+
lower_bounds = [
72+
clause_lower_bound
73+
for clause in clauses
74+
if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None
75+
]
76+
77+
if not lower_bounds:
78+
return None
79+
80+
return min(lower_bounds, key=version_to_tuple)
81+
82+
83+
def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]:
84+
if not composer_lock.exists():
85+
return None, None
86+
87+
try:
88+
payload = json.loads(composer_lock.read_text())
89+
except json.JSONDecodeError:
90+
return None, "composer.lock exists but could not be parsed"
91+
92+
platform_overrides = payload.get("platform-overrides") or {}
93+
platform_php = platform_overrides.get("php")
94+
95+
if isinstance(platform_php, str):
96+
resolved = normalize_minor(platform_php)
97+
98+
if resolved is not None:
99+
return resolved, "composer.lock platform-overrides.php"
100+
101+
return None, "composer.lock platform-overrides.php is not a supported PHP version"
102+
103+
return None, None
104+
105+
106+
def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]:
107+
if not composer_json.exists():
108+
return None, "composer.json does not exist"
109+
110+
try:
111+
payload = json.loads(composer_json.read_text())
112+
except json.JSONDecodeError:
113+
return None, "composer.json could not be parsed"
114+
115+
config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php"))
116+
117+
if isinstance(config_platform_php, str):
118+
resolved = normalize_minor(config_platform_php)
119+
120+
if resolved is not None:
121+
return resolved, "composer.json config.platform.php"
122+
123+
return None, "composer.json config.platform.php is not a supported PHP version"
124+
125+
require_php = ((payload.get("require") or {}).get("php"))
126+
127+
if isinstance(require_php, str):
128+
resolved = infer_minimum_supported_minor(require_php)
129+
130+
if resolved is not None:
131+
return resolved, "composer.json require.php"
132+
133+
return None, "composer.json require.php could not be resolved safely"
134+
135+
return None, None
136+
137+
138+
def resolve_php_version(composer_json: Path, composer_lock: Path) -> tuple[str, str, str | None]:
139+
resolved, source = resolve_from_lock(composer_lock)
140+
141+
if resolved is None:
142+
resolved, source = resolve_from_json(composer_json)
143+
144+
if resolved is None:
145+
return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3."
146+
147+
if resolved not in SUPPORTED_MINORS:
148+
return DEFAULT_PHP_VERSION, "fallback", (
149+
f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3."
150+
)
151+
152+
return resolved, source or "fallback", None
153+
154+
155+
def write_output(name: str, value: str) -> None:
156+
github_output = os.environ.get("GITHUB_OUTPUT")
157+
158+
if github_output:
159+
with open(github_output, "a", encoding="utf-8") as handle:
160+
handle.write(f"{name}={value}\n")
161+
162+
163+
def main() -> int:
164+
parser = argparse.ArgumentParser(description="Resolve the PHP version used by Fast Forward workflows.")
165+
parser.add_argument("--composer-json", default="composer.json")
166+
parser.add_argument("--composer-lock", default="composer.lock")
167+
args = parser.parse_args()
168+
169+
resolved_version, source, warning = resolve_php_version(
170+
Path(args.composer_json),
171+
Path(args.composer_lock),
172+
)
173+
174+
matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)]
175+
matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":"))
176+
177+
print(f"Resolved PHP version source: {source}")
178+
print(f"Resolved PHP version: {resolved_version}")
179+
print(f"Resolved PHP test matrix: {matrix_versions}")
180+
181+
if warning:
182+
print(f"Warning: {warning}")
183+
184+
write_output("php-version", resolved_version)
185+
write_output("php-version-source", source)
186+
write_output("test-matrix", matrix)
187+
write_output("warning", warning or "")
188+
189+
return 0
190+
191+
192+
if __name__ == "__main__":
193+
sys.exit(main())

.github/workflows/reports.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,22 @@ concurrency:
3030
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }}
3131

3232
jobs:
33+
resolve_php:
34+
name: Resolve PHP Version
35+
runs-on: ubuntu-latest
36+
outputs:
37+
php-version: ${{ steps.resolve.outputs.php-version }}
38+
php-version-source: ${{ steps.resolve.outputs.php-version-source }}
39+
40+
steps:
41+
- uses: actions/checkout@v6
42+
43+
- name: Resolve workflow PHP version
44+
id: resolve
45+
run: python3 .github/scripts/resolve_php_version.py
46+
3347
reports:
48+
needs: resolve_php
3449
if: github.event_name != 'schedule' && !(github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) && (github.event_name != 'pull_request' || github.event.action != 'closed')
3550
name: Generate Reports
3651
runs-on: ubuntu-latest
@@ -49,16 +64,17 @@ jobs:
4964
- name: Setup PHP
5065
uses: shivammathur/setup-php@v2
5166
with:
52-
php-version: '8.3'
67+
php-version: ${{ needs.resolve_php.outputs.php-version }}
5368
extensions: pcov, pcntl
5469
coverage: pcov
5570

5671
- name: Cache Composer dependencies
5772
uses: actions/cache@v5
5873
with:
5974
path: /tmp/composer-cache
60-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
75+
key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
6176
restore-keys: |
77+
${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-
6278
${{ runner.os }}-composer-
6379
6480
- name: Install dependencies

.github/workflows/tests.yml

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ on:
4141
- 'tests/**'
4242
- 'composer.json'
4343
- 'composer.lock'
44+
- '.github/scripts/**'
45+
- 'resources/github-actions/scripts/**'
4446
- '.github/workflows/tests.yml'
4547
push:
4648
branches: [ "main" ]
@@ -53,12 +55,27 @@ concurrency:
5355
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
5456

5557
jobs:
58+
resolve_php:
59+
name: Resolve PHP Version
60+
runs-on: ubuntu-latest
61+
outputs:
62+
php-version: ${{ steps.resolve.outputs.php-version }}
63+
php-version-source: ${{ steps.resolve.outputs.php-version-source }}
64+
test-matrix: ${{ steps.resolve.outputs.test-matrix }}
65+
66+
steps:
67+
- uses: actions/checkout@v6
68+
69+
- name: Resolve workflow PHP version
70+
id: resolve
71+
run: python3 .github/scripts/resolve_php_version.py
72+
5673
tests:
74+
needs: resolve_php
5775
name: Run Tests
5876
runs-on: ubuntu-latest
5977
strategy:
60-
matrix:
61-
php-version: [ '8.3', '8.4', '8.5' ]
78+
matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }}
6279
env:
6380
TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }}
6481
steps:
@@ -75,8 +92,9 @@ jobs:
7592
uses: actions/cache@v5
7693
with:
7794
path: /tmp/composer-cache
78-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
95+
key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
7996
restore-keys: |
97+
${{ runner.os }}-composer-${{ matrix.php-version }}-
8098
${{ runner.os }}-composer-
8199
82100
- name: Mark workspace as safe for git
@@ -106,6 +124,7 @@ jobs:
106124
run: composer dev-tools tests -- --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }}
107125

108126
dependency-health:
127+
needs: resolve_php
109128
name: Dependency Health
110129
if: ${{ github.event_name != 'workflow_call' || inputs.run-dependencies-check }}
111130
runs-on: ubuntu-latest
@@ -118,14 +137,15 @@ jobs:
118137
- name: Setup PHP
119138
uses: shivammathur/setup-php@v2
120139
with:
121-
php-version: '8.3'
140+
php-version: ${{ needs.resolve_php.outputs.php-version }}
122141

123142
- name: Cache Composer dependencies
124143
uses: actions/cache@v5
125144
with:
126145
path: /tmp/composer-cache
127-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
146+
key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
128147
restore-keys: |
148+
${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-
129149
${{ runner.os }}-composer-
130150
131151
- name: Mark workspace as safe for git

.github/workflows/wiki.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,22 @@ concurrency:
1818
cancel-in-progress: true
1919

2020
jobs:
21+
resolve_php:
22+
name: Resolve PHP Version
23+
runs-on: ubuntu-latest
24+
outputs:
25+
php-version: ${{ steps.resolve.outputs.php-version }}
26+
php-version-source: ${{ steps.resolve.outputs.php-version-source }}
27+
28+
steps:
29+
- uses: actions/checkout@v6
30+
31+
- name: Resolve workflow PHP version
32+
id: resolve
33+
run: python3 .github/scripts/resolve_php_version.py
34+
2135
preview:
36+
needs: resolve_php
2237
name: Update Wiki Preview
2338
if: github.event_name == 'pull_request'
2439
runs-on: ubuntu-latest
@@ -42,14 +57,15 @@ jobs:
4257
- name: Setup PHP
4358
uses: shivammathur/setup-php@v2
4459
with:
45-
php-version: '8.3'
60+
php-version: ${{ needs.resolve_php.outputs.php-version }}
4661

4762
- name: Cache Composer dependencies
4863
uses: actions/cache@v5
4964
with:
5065
path: /tmp/composer-cache
51-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
66+
key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
5267
restore-keys: |
68+
${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-
5369
${{ runner.os }}-composer-
5470
5571
- name: Mark workspace as safe for git

docs/usage/github-actions.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ The ``reports.yml`` workflow is responsible for generating technical documentati
3434
**Behavior:**
3535
* **Main Branch**: Runs all checks and deploys the final reports to the root of the ``gh-pages`` branch.
3636
* Runs a post-deploy health check against the published reports index and coverage URLs with retry/backoff to account for Pages propagation.
37+
* Resolves the workflow PHP version from ``composer.lock`` or ``composer.json`` before installing dependencies.
3738
* **Pull Requests**:
3839
* Generates a **Preview** of the documentation, coverage, and metrics.
3940
* 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-
5152
**Behavior:**
5253
* **Submodule Management**: It manages a submodule at ``.github/wiki`` that points to the actual wiki repository.
5354
* **Pull Requests**: Pushes documentation changes to a dedicated branch (e.g., ``pr-123``) in the wiki repository for review.
55+
* **PHP Version Resolution**: Resolves the PHP version from ``composer.lock`` or ``composer.json`` before setting up PHP and installing dependencies.
5456
* **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.
5557
* **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.
5658

@@ -65,6 +67,7 @@ Fast Forward Tests
6567
The ``tests.yml`` workflow provides standard Continuous Integration.
6668

6769
* Runs PHPUnit tests across the supported PHP matrix.
70+
* Resolves the minimum supported PHP minor version from ``composer.lock`` or ``composer.json`` and builds the test matrix from that floor upward.
6871
* Runs dependency health as a separate non-blocking job when enabled.
6972
* Uses PR-scoped concurrency so newer pushes cancel older in-progress runs for the same pull request.
7073

0 commit comments

Comments
 (0)