Skip to content

Commit ab066a3

Browse files
[github-actions] Infer workflow PHP version (#76) (#114)
* [github-actions] Infer workflow PHP version (#76) * Update wiki submodule pointer for PR #114 * [github-actions] Use remote action for PHP version resolution (#76) * [github-actions] Resolve PHP action from workflow ref (#76) * [github-actions] Avoid workflow-ref checkout for PHP resolution (#76) * [tests] Drop brittle PHP resolve action test (#76) --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 9d21ffc commit ab066a3

6 files changed

Lines changed: 213 additions & 10 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
name: Resolve PHP Version
2+
description: Resolve the PHP version and test matrix from composer.lock or composer.json.
3+
4+
outputs:
5+
php-version:
6+
description: Resolved PHP version used by setup-php.
7+
value: ${{ steps.resolve.outputs.php-version }}
8+
php-version-source:
9+
description: Source used to resolve the PHP version.
10+
value: ${{ steps.resolve.outputs.php-version-source }}
11+
test-matrix:
12+
description: JSON matrix of supported PHP minors starting from the inferred minimum.
13+
value: ${{ steps.resolve.outputs.test-matrix }}
14+
warning:
15+
description: Warning emitted when the workflow falls back to the default PHP version.
16+
value: ${{ steps.resolve.outputs.warning }}
17+
18+
runs:
19+
using: composite
20+
steps:
21+
- name: Resolve workflow PHP version
22+
id: resolve
23+
shell: bash
24+
run: |
25+
python3 <<'PY'
26+
from __future__ import annotations
27+
28+
import json
29+
import os
30+
import re
31+
from pathlib import Path
32+
33+
SUPPORTED_MINORS = ["8.3", "8.4", "8.5"]
34+
DEFAULT_PHP_VERSION = "8.3"
35+
36+
def version_to_tuple(version: str) -> tuple[int, int]:
37+
major, minor = version.split(".")
38+
return int(major), int(minor)
39+
40+
def normalize_minor(version: str) -> str | None:
41+
match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version)
42+
if match is None:
43+
return None
44+
return f"{match.group(1)}.{match.group(2)}"
45+
46+
def next_supported_minor(version: str) -> str | None:
47+
if version not in SUPPORTED_MINORS:
48+
return None
49+
index = SUPPORTED_MINORS.index(version) + 1
50+
if index >= len(SUPPORTED_MINORS):
51+
major, minor = version_to_tuple(version)
52+
return f"{major}.{minor + 1}"
53+
return SUPPORTED_MINORS[index]
54+
55+
def infer_clause_lower_bound(clause: str) -> str | None:
56+
tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause)
57+
lower_bounds: list[str] = []
58+
for operator, version in tokens:
59+
normalized = normalize_minor(version)
60+
if normalized is None:
61+
continue
62+
if operator in ("", "=", "==", "^", "~", ">="):
63+
lower_bounds.append(normalized)
64+
continue
65+
if operator == ">":
66+
next_minor = next_supported_minor(normalized)
67+
if next_minor is not None:
68+
lower_bounds.append(next_minor)
69+
if not lower_bounds:
70+
return None
71+
return max(lower_bounds, key=version_to_tuple)
72+
73+
def infer_minimum_supported_minor(requirement: str) -> str | None:
74+
clauses = [clause.strip() for clause in requirement.split("||")]
75+
lower_bounds = [
76+
clause_lower_bound
77+
for clause in clauses
78+
if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None
79+
]
80+
if not lower_bounds:
81+
return None
82+
return min(lower_bounds, key=version_to_tuple)
83+
84+
def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]:
85+
if not composer_lock.exists():
86+
return None, None
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+
platform_overrides = payload.get("platform-overrides") or {}
92+
platform_php = platform_overrides.get("php")
93+
if isinstance(platform_php, str):
94+
resolved = normalize_minor(platform_php)
95+
if resolved is not None:
96+
return resolved, "composer.lock platform-overrides.php"
97+
return None, "composer.lock platform-overrides.php is not a supported PHP version"
98+
return None, None
99+
100+
def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]:
101+
if not composer_json.exists():
102+
return None, "composer.json does not exist"
103+
try:
104+
payload = json.loads(composer_json.read_text())
105+
except json.JSONDecodeError:
106+
return None, "composer.json could not be parsed"
107+
config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php"))
108+
if isinstance(config_platform_php, str):
109+
resolved = normalize_minor(config_platform_php)
110+
if resolved is not None:
111+
return resolved, "composer.json config.platform.php"
112+
return None, "composer.json config.platform.php is not a supported PHP version"
113+
require_php = ((payload.get("require") or {}).get("php"))
114+
if isinstance(require_php, str):
115+
resolved = infer_minimum_supported_minor(require_php)
116+
if resolved is not None:
117+
return resolved, "composer.json require.php"
118+
return None, "composer.json require.php could not be resolved safely"
119+
return None, None
120+
121+
def resolve_php_version() -> tuple[str, str, str | None]:
122+
resolved, source = resolve_from_lock(Path("composer.lock"))
123+
if resolved is None:
124+
resolved, source = resolve_from_json(Path("composer.json"))
125+
if resolved is None:
126+
return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3."
127+
if resolved not in SUPPORTED_MINORS:
128+
return DEFAULT_PHP_VERSION, "fallback", (
129+
f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3."
130+
)
131+
return resolved, source or "fallback", None
132+
133+
resolved_version, source, warning = resolve_php_version()
134+
matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)]
135+
matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":"))
136+
137+
print(f"Resolved PHP version source: {source}")
138+
print(f"Resolved PHP version: {resolved_version}")
139+
print(f"Resolved PHP test matrix: {matrix_versions}")
140+
if warning:
141+
print(f"Warning: {warning}")
142+
143+
github_output = os.environ["GITHUB_OUTPUT"]
144+
with open(github_output, "a", encoding="utf-8") as handle:
145+
handle.write(f"php-version={resolved_version}\n")
146+
handle.write(f"php-version-source={source}\n")
147+
handle.write(f"test-matrix={matrix}\n")
148+
handle.write(f"warning={warning or ''}\n")
149+
PY

.github/wiki

Submodule wiki updated from 5246f54 to 5387fef

.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+
uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main
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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ on:
4141
- 'tests/**'
4242
- 'composer.json'
4343
- 'composer.lock'
44+
- '.github/actions/**'
4445
- '.github/workflows/tests.yml'
4546
push:
4647
branches: [ "main" ]
@@ -53,12 +54,27 @@ concurrency:
5354
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
5455

5556
jobs:
57+
resolve_php:
58+
name: Resolve PHP Version
59+
runs-on: ubuntu-latest
60+
outputs:
61+
php-version: ${{ steps.resolve.outputs.php-version }}
62+
php-version-source: ${{ steps.resolve.outputs.php-version-source }}
63+
test-matrix: ${{ steps.resolve.outputs.test-matrix }}
64+
65+
steps:
66+
- uses: actions/checkout@v6
67+
68+
- name: Resolve workflow PHP version
69+
id: resolve
70+
uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main
71+
5672
tests:
73+
needs: resolve_php
5774
name: Run Tests
5875
runs-on: ubuntu-latest
5976
strategy:
60-
matrix:
61-
php-version: [ '8.3', '8.4', '8.5' ]
77+
matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }}
6278
env:
6379
TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }}
6480
steps:
@@ -75,8 +91,9 @@ jobs:
7591
uses: actions/cache@v5
7692
with:
7793
path: /tmp/composer-cache
78-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
94+
key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
7995
restore-keys: |
96+
${{ runner.os }}-composer-${{ matrix.php-version }}-
8097
${{ runner.os }}-composer-
8198
8299
- name: Mark workspace as safe for git
@@ -106,6 +123,7 @@ jobs:
106123
run: composer dev-tools tests -- --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }}
107124

108125
dependency-health:
126+
needs: resolve_php
109127
name: Dependency Health
110128
if: ${{ github.event_name != 'workflow_call' || inputs.run-dependencies-check }}
111129
runs-on: ubuntu-latest
@@ -118,14 +136,15 @@ jobs:
118136
- name: Setup PHP
119137
uses: shivammathur/setup-php@v2
120138
with:
121-
php-version: '8.3'
139+
php-version: ${{ needs.resolve_php.outputs.php-version }}
122140

123141
- name: Cache Composer dependencies
124142
uses: actions/cache@v5
125143
with:
126144
path: /tmp/composer-cache
127-
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
145+
key: ${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
128146
restore-keys: |
147+
${{ runner.os }}-composer-${{ needs.resolve_php.outputs.php-version }}-
129148
${{ runner.os }}-composer-
130149
131150
- 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+
uses: php-fast-forward/dev-tools/.github/actions/resolve-php-version@main
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)