@@ -24,64 +24,250 @@ concurrency:
2424 cancel-in-progress : ${{ github.event_name == 'pull_request' }}
2525
2626jobs :
27- phpunit-php-7-4 :
27+ phpunit :
2828 if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
29+ strategy :
30+ fail-fast : false
31+ matrix :
32+ php-version : ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
2933 uses : ./.github/workflows/phpunit-test.yml
3034 with :
31- php-version : ' 7.4'
32-
33- phpunit-php-8-0 :
34- if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
35- uses : ./.github/workflows/phpunit-test.yml
36- with :
37- php-version : ' 8.0'
38-
39- phpunit-php-8-1 :
40- if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
41- uses : ./.github/workflows/phpunit-test.yml
42- with :
43- php-version : ' 8.1'
44-
45- phpunit-php-8-2 :
46- if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
47- uses : ./.github/workflows/phpunit-test.yml
48- with :
49- php-version : ' 8.2'
50-
51- phpunit-php-8-3 :
52- if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
53- uses : ./.github/workflows/phpunit-test.yml
54- with :
55- php-version : ' 8.3'
56-
57- phpunit-php-8-4 :
58- if : github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')
59- uses : ./.github/workflows/phpunit-test.yml
60- with :
61- php-version : ' 8.4'
35+ php-version : ${{ matrix.php-version }}
6236
6337 test-result :
64- needs : [phpunit-php-7-4, phpunit-php-8-0, phpunit-php-8-1, phpunit-php-8-2, phpunit-php-8-3, phpunit-php-8-4 ]
65- if : always() && ( needs.phpunit-php-7-4 .result != 'skipped' || needs.phpunit-php-8-0.result != 'skipped' || needs.phpunit-php-8-1.result != 'skipped' || needs.phpunit-php-8-2.result != 'skipped' || needs.phpunit-php-8-3.result != 'skipped' || needs.phpunit-php-8-4.result != 'skipped')
38+ needs : [phpunit]
39+ if : always() && needs.phpunit.result != 'skipped'
6640 runs-on : ubuntu-22.04
6741 name : PHPUnit - Test Results Summary
42+ permissions :
43+ pull-requests : write
44+ issues : write
6845 steps :
6946 - name : Test status summary
7047 run : |
71- echo "PHP 7.4: ${{ needs.phpunit-php-7-4.result }}"
72- echo "PHP 8.0: ${{ needs.phpunit-php-8-0.result }}"
73- echo "PHP 8.1: ${{ needs.phpunit-php-8-1.result }}"
74- echo "PHP 8.2: ${{ needs.phpunit-php-8-2.result }}"
75- echo "PHP 8.3: ${{ needs.phpunit-php-8-3.result }}"
76- echo "PHP 8.4: ${{ needs.phpunit-php-8-4.result }}"
48+ echo "PHPUnit matrix result: ${{ needs.phpunit.result }}"
49+
50+ - name : Delete previous PR failure comment on success
51+ if : github.event_name == 'pull_request' && needs.phpunit.result == 'success'
52+ uses : actions/github-script@v7
53+ with :
54+ script : |
55+ const marker = '<!-- phpunit-result-comment -->';
56+
57+ const { data: comments } = await github.rest.issues.listComments({
58+ owner: context.repo.owner,
59+ repo: context.repo.repo,
60+ issue_number: context.issue.number,
61+ per_page: 100,
62+ });
63+
64+ const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker));
65+
66+ for (const comment of existing) {
67+ await github.rest.issues.deleteComment({
68+ owner: context.repo.owner,
69+ repo: context.repo.repo,
70+ comment_id: comment.id,
71+ });
72+ }
73+
74+ - name : Download PHPUnit artifacts
75+ if : github.event_name == 'pull_request' && needs.phpunit.result == 'failure'
76+ uses : actions/download-artifact@v5
77+ with :
78+ pattern : phpunit-test-results-php-*
79+ path : phpunit-artifacts
80+ merge-multiple : true
81+
82+ - name : Build distinct error summary
83+ if : github.event_name == 'pull_request' && needs.phpunit.result == 'failure'
84+ run : |
85+ set -euo pipefail
86+ python3 - <<'PY'
87+ import glob
88+ import os
89+ import re
90+ import xml.etree.ElementTree as ET
91+ from collections import defaultdict
92+
93+ artifacts_dir = 'phpunit-artifacts'
94+
95+ def version_from_path(path: str) -> str:
96+ base = os.path.basename(path)
97+ m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.xml$', base)
98+ if m:
99+ return m.group(1)
100+ m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.log$', base)
101+ if m:
102+ return m.group(1)
103+ return 'unknown'
104+
105+ def add_entry(grouped, key, version, message):
106+ if not message.strip():
107+ return
108+ grouped[key]['versions'].add(version)
109+ # Keep the first representative message we see for this key.
110+ if not grouped[key]['message']:
111+ grouped[key]['message'] = message.strip()
112+
113+ grouped = defaultdict(lambda: {'versions': set(), 'message': ''})
114+ versions_seen = set()
115+
116+ # Prefer JUnit XML when present.
117+ for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)):
118+ version = version_from_path(xml_path)
119+ versions_seen.add(version)
120+ try:
121+ root = ET.parse(xml_path).getroot()
122+ except Exception:
123+ continue
124+
125+ for testcase in root.iter('testcase'):
126+ for tag in ('error', 'failure'):
127+ for node in testcase.findall(tag):
128+ etype = (node.attrib.get('type') or tag).strip()
129+ msg = (node.attrib.get('message') or '').strip()
130+ details = (node.text or '').strip()
131+ combined = f"{etype}: {msg}".strip(': ')
132+ if details:
133+ combined = combined + "\n" + details
134+
135+ testcase_id = (
136+ (testcase.attrib.get('classname') or '').strip() +
137+ '::' +
138+ (testcase.attrib.get('name') or '').strip()
139+ ).strip(':')
140+
141+ extracted = ''
142+ if not msg and details:
143+ for line in details.splitlines():
144+ line = line.strip()
145+ if line.startswith('CI demo:'):
146+ extracted = line
147+ break
148+ if not extracted:
149+ extracted = details.splitlines()[0].strip()
150+
151+ # Dedupe key: prefer explicit message; otherwise use extracted details + testcase id.
152+ key_parts = [etype]
153+ if msg:
154+ key_parts.append(msg)
155+ elif extracted:
156+ key_parts.append(extracted)
157+ if testcase_id:
158+ key_parts.append(testcase_id)
159+ key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown')
160+ add_entry(grouped, key, version, combined)
161+
162+ # Fallback: scan logs for fatals if XML missing.
163+ fatal_re = re.compile(r'^(PHP\s+Fatal\s+error:.*|Fatal\s+error:.*)$', re.MULTILINE)
164+ for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)):
165+ version = version_from_path(log_path)
166+ versions_seen.add(version)
167+ try:
168+ log = open(log_path, 'r', encoding='utf-8', errors='replace').read()
169+ except Exception:
170+ continue
171+ m = fatal_re.search(log)
172+ if m:
173+ msg = m.group(1).strip()
174+ key = 'Fatal error\n' + msg
175+ add_entry(grouped, key, version, msg)
176+
177+ versions = sorted(v for v in versions_seen if v != 'unknown')
178+
179+ def versions_label(affected):
180+ affected = sorted(v for v in affected if v != 'unknown')
181+ if versions and affected == versions:
182+ return 'all'
183+ return ', '.join(affected) if affected else 'unknown'
184+
185+ items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0]))
186+ blocks = []
187+ for idx, (key, info) in enumerate(items):
188+ affected = versions_label(info['versions'])
189+ message = info['message']
190+ block = (
191+ "-----\n"
192+ f"Affected PHP version: `{affected}`\n"
193+ "```php\n"
194+ f"{message}\n"
195+ "```"
196+ )
197+ if idx == len(items) - 1:
198+ block += "\n-----"
199+ blocks.append(block)
200+
201+ if blocks:
202+ details = "\n\n".join(blocks)
203+ else:
204+ details = "No PHPUnit error details could be parsed from artifacts."
205+
206+ md = "\n".join([
207+ "<details>",
208+ "<summary>See all PHPUnit errors (click to expand)</summary>",
209+ "",
210+ details,
211+ "",
212+ "</details>",
213+ "",
214+ ])
215+
216+ with open('phpunit-errors.md', 'w', encoding='utf-8') as f:
217+ f.write(md)
218+ PY
219+
220+ - name : Post PR comment on failure
221+ if : github.event_name == 'pull_request' && needs.phpunit.result == 'failure'
222+ uses : actions/github-script@v7
223+ with :
224+ script : |
225+ const marker = '<!-- phpunit-result-comment -->';
226+
227+ const fs = require('fs');
228+ let details = '';
229+ try {
230+ details = fs.readFileSync('phpunit-errors.md', 'utf8').trim();
231+ } catch (e) {
232+ details = '';
233+ }
234+
235+ const body = [
236+ marker,
237+ '## PHPUnit Test Failure',
238+ '',
239+ `One or more PHP version targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`,
240+ '',
241+ details || '_No parsed error details found._',
242+ '',
243+ 'Please review the failing jobs and fix the issues before merging.',
244+ ].join('\n');
245+
246+ const { data: comments } = await github.rest.issues.listComments({
247+ owner: context.repo.owner,
248+ repo: context.repo.repo,
249+ issue_number: context.issue.number,
250+ per_page: 100,
251+ });
252+
253+ const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker));
254+
255+ for (const comment of existing) {
256+ await github.rest.issues.deleteComment({
257+ owner: context.repo.owner,
258+ repo: context.repo.repo,
259+ comment_id: comment.id,
260+ });
261+ }
262+
263+ await github.rest.issues.createComment({
264+ owner: context.repo.owner,
265+ repo: context.repo.repo,
266+ issue_number: context.issue.number,
267+ body,
268+ });
77269
78270 - name : Check overall status
79- if : |
80- (needs.phpunit-php-7-4.result != 'success' && needs.phpunit-php-7-4.result != 'skipped') ||
81- (needs.phpunit-php-8-0.result != 'success' && needs.phpunit-php-8-0.result != 'skipped') ||
82- (needs.phpunit-php-8-1.result != 'success' && needs.phpunit-php-8-1.result != 'skipped') ||
83- (needs.phpunit-php-8-2.result != 'success' && needs.phpunit-php-8-2.result != 'skipped') ||
84- (needs.phpunit-php-8-3.result != 'success' && needs.phpunit-php-8-3.result != 'skipped') ||
85- (needs.phpunit-php-8-4.result != 'success' && needs.phpunit-php-8-4.result != 'skipped')
271+ if : needs.phpunit.result != 'success'
86272 run : exit 1
87273
0 commit comments