PluginProbe
Simple Analytics / 1.110
Simple Analytics v1.110
1.110 1.109 1.108 1.107 1.106 1.73 1.74 1.75 1.76 1.77 1.78 1.79 1.8 1.80 1.81 1.82 1.83 1.84 1.85 1.86 1.87 1.88 1.89 1.9 1.90 All 99 releases
simpleanalytics / scripts / get-wordpress-matrix.py

get-wordpress-matrix.py in Simple Analytics 1.110, at scripts/get-wordpress-matrix.py

292 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 #!/usr/bin/env python3
2 """Generate a WordPress + PHP test matrix for GitHub Actions.
3
4 WordPress versions (from wordpress.org API + release zip probing):
5 - Latest release branch: min, middle, and max patch versions
6 - Second-latest release branch: last two patch versions
7
8 PHP versions (parsed from the WordPress PHP compatibility handbook table):
9 - For each WordPress version, run lowest and highest supported PHP
10 """
11 import json
12 import re
13 import urllib.error
14 import urllib.request
15
16 VERSIONS_API_URL = "https://api.wordpress.org/core/version-check/1.7/"
17 RELEASE_URL = "https://wordpress.org/wordpress-{version}.tar.gz"
18 HANDBOOK_URL = (
19 "https://make.wordpress.org/core/handbook/references/"
20 "php-compatibility-and-wordpress-versions/"
21 )
22 CHART_ID = "supported-version-chart"
23 OLDER_SECTION_ID = "older-wordpress-versions"
24
25
26 def branch_sort_key(branch: str):
27 major, minor = branch.split(".")
28 return int(major), int(minor)
29
30
31 def version_tuple(v: str) -> tuple[int, ...]:
32 return tuple(int(part) for part in v.split(".") if part.isdigit())
33
34
35 def major_minor(v: str) -> str:
36 parts = v.split(".")
37 return f"{parts[0]}.{parts[1]}"
38
39
40 def fetch_offers():
41 with urllib.request.urlopen(VERSIONS_API_URL) as response:
42 return json.load(response).get("offers", [])
43
44
45 def latest_version_per_branch(offers) -> dict[str, str]:
46 """API offers only include the current patch per branch."""
47 latest: dict[str, tuple[int, str]] = {}
48 for offer in offers:
49 version = offer.get("version", "")
50 if not version or version.count(".") < 1:
51 continue
52 parts = version.split(".")
53 branch = ".".join(parts[:2])
54 patch = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
55 current = latest.get(branch)
56 if current is None or patch > current[0]:
57 latest[branch] = (patch, version)
58 return {branch: version for branch, (_, version) in latest.items()}
59
60
61 def release_exists(version: str) -> bool:
62 request = urllib.request.Request(
63 RELEASE_URL.format(version=version),
64 method="HEAD",
65 )
66 try:
67 with urllib.request.urlopen(request, timeout=15) as response:
68 return response.status == 200
69 except urllib.error.HTTPError as error:
70 return error.code == 200
71 except urllib.error.URLError:
72 return False
73
74
75 def discover_versions_for_branch(branch: str, latest: str) -> list[str]:
76 """Discover published .tar.gz releases from wordpress.org (API has latest only)."""
77 parts = latest.split(".")
78 if len(parts) == 2:
79 return [latest] if release_exists(latest) else []
80
81 max_patch = int(parts[2])
82 versions = []
83 for patch in range(max_patch + 1):
84 version = f"{branch}.{patch}"
85 if release_exists(version):
86 versions.append(version)
87 return versions
88
89
90 def select_min_middle_max(versions: list[str]) -> list[str]:
91 """Pick three patch versions: earliest, middle, and latest."""
92 if not versions:
93 return []
94 if len(versions) == 1:
95 return [versions[0]]
96 if len(versions) == 2:
97 return [versions[0], versions[0], versions[1]]
98 return [versions[0], versions[len(versions) // 2], versions[-1]]
99
100
101 def select_last_two(versions: list[str]) -> list[str]:
102 if len(versions) >= 2:
103 return versions[-2:]
104 return versions
105
106
107 def strip_html(cell_html: str) -> str:
108 text = re.sub(r"<[^>]+>", "", cell_html)
109 return re.sub(r"\s+", " ", text).strip()
110
111
112 def parse_php_column(header_html: str) -> str | None:
113 text = strip_html(header_html)
114 if not text or "WP / PHP" in text:
115 return None
116 match = re.match(r"^(\d+\.\d+)", text)
117 return match.group(1) if match else None
118
119
120 def parse_wp_row_label(cell_html: str) -> str | None:
121 text = strip_html(cell_html)
122 match = re.match(r"^(\d+\.\d+)", text)
123 return match.group(1) if match else None
124
125
126 def is_supported(cell_html: str) -> bool:
127 """Only full support (Y). Beta (Y*) is excluded."""
128 text = strip_html(cell_html).upper()
129 return text == "Y"
130
131
132 def extract_primary_compatibility_table(html: str) -> str:
133 """Match: #supported-version-chart -> next table in the page."""
134 anchor = f'id="{CHART_ID}"'
135 start = html.find(anchor)
136 if start == -1:
137 raise ValueError(f'Could not find #{CHART_ID} on {HANDBOOK_URL}')
138
139 end = html.find(f'id="{OLDER_SECTION_ID}"', start)
140 section = html[start:end] if end != -1 else html[start:]
141
142 table_start = section.find("<table")
143 if table_start == -1:
144 raise ValueError("Could not find compatibility table after #supported-version-chart")
145
146 table_end = section.find("</table>", table_start)
147 if table_end == -1:
148 raise ValueError("Compatibility table is missing a closing tag")
149
150 return section[table_start : table_end + len("</table>")]
151
152
153 def parse_compatibility_table(table_html: str) -> dict[str, list[tuple[str, str]]]:
154 """
155 Parse the handbook matrix into {wp_branch: [(php_version, cell_text), ...]}.
156 Column order is highest PHP -> lowest PHP (left to right).
157 """
158 rows = re.findall(r"<tr[^>]*>(.*?)</tr>", table_html, flags=re.S | re.I)
159 if not rows:
160 raise ValueError("Compatibility table has no rows")
161
162 header_cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", rows[0], flags=re.S | re.I)
163 php_columns = [parse_php_column(cell) for cell in header_cells[1:]]
164 php_columns = [php for php in php_columns if php]
165
166 matrix: dict[str, list[tuple[str, str]]] = {}
167 for row_html in rows[1:]:
168 cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row_html, flags=re.S | re.I)
169 if len(cells) < 2:
170 continue
171
172 wp_branch = parse_wp_row_label(cells[0])
173 if not wp_branch:
174 continue
175
176 entries = []
177 for php, cell in zip(php_columns, cells[1:]):
178 entries.append((php, strip_html(cell)))
179 matrix[wp_branch] = entries
180
181 if not matrix:
182 raise ValueError("No WordPress rows found in compatibility table")
183
184 return matrix
185
186
187 def fetch_compatibility_matrix() -> dict[str, list[tuple[str, str]]]:
188 request = urllib.request.Request(
189 HANDBOOK_URL,
190 headers={"User-Agent": "simpleanalytics-wordpress-plugin-ci"},
191 )
192 with urllib.request.urlopen(request, timeout=30) as response:
193 html = response.read().decode("utf-8", errors="replace")
194
195 table_html = extract_primary_compatibility_table(html)
196 return parse_compatibility_table(table_html)
197
198
199 def php_bounds_for_wp(
200 wp_version: str, matrix: dict[str, list[tuple[str, str]]]
201 ) -> tuple[str, str]:
202 branch = major_minor(wp_version)
203 row = matrix.get(branch)
204 if not row:
205 raise ValueError(
206 f"No PHP compatibility row for WordPress {wp_version} (branch {branch}) "
207 f"in {HANDBOOK_URL}"
208 )
209
210 supported = [php for php, status in row if is_supported(status)]
211 if not supported:
212 raise ValueError(f"No supported PHP versions for WordPress branch {branch}")
213
214 supported.sort(key=version_tuple)
215 return supported[0], supported[-1]
216
217
218 def build_matrix() -> dict:
219 latest_by_branch = latest_version_per_branch(fetch_offers())
220 branches = sorted(latest_by_branch.keys(), key=branch_sort_key, reverse=True)
221 if len(branches) < 1:
222 return {"include": []}
223
224 latest_branch = branches[0]
225 second_branch = branches[1] if len(branches) > 1 else None
226
227 latest_versions = discover_versions_for_branch(
228 latest_branch, latest_by_branch[latest_branch]
229 )
230 wp_versions: list[str] = []
231 wp_versions.extend(select_min_middle_max(latest_versions))
232
233 if second_branch:
234 second_versions = discover_versions_for_branch(
235 second_branch, latest_by_branch[second_branch]
236 )
237 for version in select_last_two(second_versions):
238 if version not in wp_versions:
239 wp_versions.append(version)
240
241 compat_matrix = fetch_compatibility_matrix()
242 include = []
243 seen: set[tuple[str, str]] = set()
244
245 for wp in sorted(set(wp_versions), key=version_tuple, reverse=True):
246 min_php, max_php = php_bounds_for_wp(wp, compat_matrix)
247 for php in (min_php, max_php):
248 key = (wp, php)
249 if key in seen:
250 continue
251 seen.add(key)
252 include.append({"wp": wp, "php": php})
253
254 return {"include": include}
255
256
257 def format_requires_php(php: str) -> str:
258 """readme.txt uses a three-part PHP version (e.g. 7.2.0)."""
259 if php.count(".") == 1:
260 return f"{php}.0"
261 return php
262
263
264 def tested_versions_metadata() -> dict:
265 """Bounds of the CI matrix for release/readme metadata."""
266 matrix = build_matrix()
267 wp_versions = sorted(
268 {entry["wp"] for entry in matrix["include"]},
269 key=version_tuple,
270 )
271 php_versions = sorted(
272 {entry["php"] for entry in matrix["include"]},
273 key=version_tuple,
274 )
275 min_php = php_versions[0]
276 return {
277 "tested_up_to": wp_versions[-1],
278 "requires_at_least": wp_versions[0],
279 "requires_php": format_requires_php(min_php),
280 "wp_versions": wp_versions,
281 "php_versions": php_versions,
282 }
283
284
285 if __name__ == "__main__":
286 import sys
287
288 if "--metadata" in sys.argv:
289 print(json.dumps(tested_versions_metadata()))
290 else:
291 print(json.dumps(build_matrix()))
292