Skip to content

Commit 8db8b55

Browse files
authored
change(ci): Overhaul CI test flow management (espressif#11925)
* change(ci): Overhaul CI test flow management * fix(docs): Apply suggestions
1 parent 9432a20 commit 8db8b55

File tree

508 files changed

+2154
-1939
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

508 files changed

+2154
-1939
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@
7373
/libraries/Wire/ @me-no-dev
7474
/libraries/Zigbee/ @P-R-O-C-H-Y
7575

76-
# CI JSON
76+
# CI YAML
7777
# Keep this after other libraries and tests to avoid being overridden.
78-
**/ci.json @lucasssvaz
78+
**/ci.yml @lucasssvaz
7979

8080
# The CODEOWNERS file should be owned by the developers of the ESP32 Arduino Core.
8181
# Leave this entry as the last one to avoid being overridden.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import logging
5+
import os
6+
import re
7+
import sys
8+
from pathlib import Path
9+
from xml.etree.ElementTree import Element, SubElement, ElementTree
10+
import yaml
11+
12+
# Configure logging
13+
logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(message)s', stream=sys.stderr)
14+
15+
16+
def parse_array(value) -> list[str]:
17+
if isinstance(value, list):
18+
return [str(x) for x in value]
19+
if not isinstance(value, str):
20+
return []
21+
txt = value.strip()
22+
if not txt:
23+
return []
24+
# Try JSON
25+
try:
26+
return [str(x) for x in json.loads(txt)]
27+
except Exception as e:
28+
logging.debug(f"Failed to parse value as JSON: {e}")
29+
# Normalize single quotes then JSON
30+
try:
31+
fixed = txt.replace("'", '"')
32+
return [str(x) for x in json.loads(fixed)]
33+
except Exception as e:
34+
logging.debug(f"Failed to parse value as JSON with quote normalization: {e}")
35+
# Fallback: CSV
36+
logging.debug(f"Falling back to CSV parsing for value: {txt}")
37+
return [p.strip() for p in txt.strip("[]").split(",") if p.strip()]
38+
39+
40+
def _parse_ci_yml(content: str) -> dict:
41+
if not content:
42+
return {}
43+
try:
44+
data = yaml.safe_load(content) or {}
45+
if not isinstance(data, dict):
46+
logging.warning("YAML content is not a dictionary, returning empty dict")
47+
return {}
48+
return data
49+
except Exception as e:
50+
logging.error(f"Failed to parse ci.yml content: {e}")
51+
return {}
52+
53+
54+
def _fqbn_counts_from_yaml(ci: dict) -> dict[str, int]:
55+
counts: dict[str, int] = {}
56+
if not isinstance(ci, dict):
57+
return counts
58+
fqbn = ci.get("fqbn")
59+
if not isinstance(fqbn, dict):
60+
return counts
61+
for target, entries in fqbn.items():
62+
if isinstance(entries, list):
63+
counts[str(target)] = len(entries)
64+
elif entries is not None:
65+
# Single value provided as string
66+
counts[str(target)] = 1
67+
return counts
68+
69+
70+
def _sdkconfig_meets(ci_cfg: dict, sdk_text: str) -> bool:
71+
if not sdk_text:
72+
return True
73+
for req in ci_cfg.get("requires", []):
74+
if not req or not isinstance(req, str):
75+
continue
76+
if not any(line.startswith(req) for line in sdk_text.splitlines()):
77+
return False
78+
req_any = ci_cfg.get("requires_any", [])
79+
if req_any:
80+
if not any(any(line.startswith(r.strip()) for line in sdk_text.splitlines()) for r in req_any if isinstance(r, str)):
81+
return False
82+
return True
83+
84+
85+
def expected_from_artifacts(build_root: Path) -> dict[tuple[str, str, str, str], int]:
86+
"""Compute expected runs using ci.yml and sdkconfig found in build artifacts.
87+
Returns mapping (platform, target, type, sketch) -> expected_count
88+
"""
89+
expected: dict[tuple[str, str, str, str], int] = {}
90+
if not build_root.exists():
91+
return expected
92+
print(f"[DEBUG] Scanning build artifacts in: {build_root}", file=sys.stderr)
93+
for artifact_dir in build_root.iterdir():
94+
if not artifact_dir.is_dir():
95+
continue
96+
m = re.match(r"test-bin-([A-Za-z0-9_\-]+)-([A-Za-z0-9_\-]+)", artifact_dir.name)
97+
if not m:
98+
continue
99+
target = m.group(1)
100+
test_type = m.group(2)
101+
print(f"[DEBUG] Artifact group target={target} type={test_type} dir={artifact_dir}", file=sys.stderr)
102+
103+
# Group build*.tmp directories by sketch
104+
# Structure: test-bin-<target>-<type>/<sketch>/build*.tmp/
105+
sketches_processed = set()
106+
107+
# Find all build*.tmp directories and process each sketch once
108+
for build_tmp in artifact_dir.rglob("build*.tmp"):
109+
if not build_tmp.is_dir():
110+
continue
111+
if not re.search(r"build\d*\.tmp$", build_tmp.name):
112+
continue
113+
114+
# Path structure is: test-bin-<target>-<type>/<sketch>/build*.tmp/
115+
sketch = build_tmp.parent.name
116+
117+
# Skip if we already processed this sketch
118+
if sketch in sketches_processed:
119+
continue
120+
sketches_processed.add(sketch)
121+
122+
print(f"[DEBUG] Processing sketch={sketch} from artifact {artifact_dir.name}", file=sys.stderr)
123+
124+
ci_path = build_tmp / "ci.yml"
125+
sdk_path = build_tmp / "sdkconfig"
126+
127+
# Read ci.yml if it exists, otherwise use empty (defaults)
128+
ci_text = ""
129+
if ci_path.exists():
130+
try:
131+
ci_text = ci_path.read_text(encoding="utf-8")
132+
except Exception as e:
133+
logging.warning(f"Failed to read ci.yml from {ci_path}: {e}")
134+
else:
135+
logging.debug(f"No ci.yml found at {ci_path}, using defaults")
136+
137+
try:
138+
sdk_text = sdk_path.read_text(encoding="utf-8", errors="ignore") if sdk_path.exists() else ""
139+
except Exception as e:
140+
logging.warning(f"Failed to read sdkconfig from {sdk_path}: {e}")
141+
sdk_text = ""
142+
143+
ci = _parse_ci_yml(ci_text)
144+
fqbn_counts = _fqbn_counts_from_yaml(ci)
145+
146+
# Determine allowed platforms for this test
147+
# Performance tests are only run on hardware
148+
if test_type == "performance":
149+
allowed_platforms = ["hardware"]
150+
else:
151+
allowed_platforms = []
152+
platforms_cfg = ci.get("platforms") if isinstance(ci, dict) else None
153+
for plat in ("hardware", "wokwi", "qemu"):
154+
dis = None
155+
if isinstance(platforms_cfg, dict):
156+
dis = platforms_cfg.get(plat)
157+
if dis is False:
158+
continue
159+
allowed_platforms.append(plat)
160+
161+
# Requirements check
162+
minimal = {
163+
"requires": ci.get("requires") or [],
164+
"requires_any": ci.get("requires_any") or [],
165+
}
166+
if not _sdkconfig_meets(minimal, sdk_text):
167+
print(f"[DEBUG] Skip (requirements not met): target={target} type={test_type} sketch={sketch}", file=sys.stderr)
168+
continue
169+
170+
# Expected runs = number from fqbn_counts in ci.yml (how many FQBNs for this target)
171+
exp_runs = fqbn_counts.get(target, 0) or 1
172+
print(f"[DEBUG] ci.yml specifies {exp_runs} FQBN(s) for target={target}", file=sys.stderr)
173+
174+
for plat in allowed_platforms:
175+
expected[(plat, target, test_type, sketch)] = exp_runs
176+
print(f"[DEBUG] Expected: plat={plat} target={target} type={test_type} sketch={sketch} runs={exp_runs}", file=sys.stderr)
177+
178+
if len(sketches_processed) == 0:
179+
print(f"[DEBUG] No sketches found in this artifact group", file=sys.stderr)
180+
return expected
181+
182+
183+
def scan_executed_xml(xml_root: Path, valid_types: set[str]) -> dict[tuple[str, str, str, str], int]:
184+
"""Return executed counts per (platform, target, type, sketch).
185+
Type/sketch/target are inferred from .../<type>/<sketch>/<target>/<file>.xml
186+
"""
187+
counts: dict[tuple[str, str, str, str], int] = {}
188+
if not xml_root.exists():
189+
print(f"[DEBUG] Results root not found: {xml_root}", file=sys.stderr)
190+
return counts
191+
print(f"[DEBUG] Scanning executed XMLs in: {xml_root}", file=sys.stderr)
192+
for xml_path in xml_root.rglob("*.xml"):
193+
if not xml_path.is_file():
194+
continue
195+
rel = str(xml_path)
196+
platform = "hardware"
197+
if "test-results-wokwi-" in rel:
198+
platform = "wokwi"
199+
elif "test-results-qemu-" in rel:
200+
platform = "qemu"
201+
# Expect .../<type>/<sketch>/<target>/*.xml
202+
parts = xml_path.parts
203+
t_idx = -1
204+
for i, p in enumerate(parts):
205+
if p in valid_types:
206+
t_idx = i
207+
if t_idx == -1 or t_idx + 3 >= len(parts):
208+
continue
209+
test_type = parts[t_idx]
210+
sketch = parts[t_idx + 1]
211+
target = parts[t_idx + 2]
212+
key = (platform, target, test_type, sketch)
213+
old_count = counts.get(key, 0)
214+
counts[key] = old_count + 1
215+
print(f"[DEBUG] Executed XML #{old_count + 1}: plat={platform} target={target} type={test_type} sketch={sketch} file={xml_path.name}", file=sys.stderr)
216+
print(f"[DEBUG] Executed entries discovered: {len(counts)}", file=sys.stderr)
217+
return counts
218+
219+
220+
def write_missing_xml(out_root: Path, platform: str, target: str, test_type: str, sketch: str, missing_count: int):
221+
out_tests_dir = out_root / f"test-results-{platform}" / "tests" / test_type / sketch / target
222+
out_tests_dir.mkdir(parents=True, exist_ok=True)
223+
# Create one XML per missing index
224+
for idx in range(missing_count):
225+
suite_name = f"{test_type}_{platform}_{target}_{sketch}"
226+
root = Element("testsuite", name=suite_name, tests="1", failures="0", errors="1")
227+
case = SubElement(root, "testcase", classname=f"{test_type}.{sketch}", name="missing-run")
228+
error = SubElement(case, "error", message="Expected test run missing")
229+
error.text = "This placeholder indicates an expected test run did not execute."
230+
tree = ElementTree(root)
231+
out_file = out_tests_dir / f"{sketch}_missing_{idx}.xml"
232+
tree.write(out_file, encoding="utf-8", xml_declaration=True)
233+
234+
235+
def main():
236+
# Args: <build_artifacts_dir> <test_results_dir> <output_junit_dir>
237+
if len(sys.argv) != 4:
238+
print(f"Usage: {sys.argv[0]} <build_artifacts_dir> <test_results_dir> <output_junit_dir>", file=sys.stderr)
239+
return 2
240+
241+
build_root = Path(sys.argv[1]).resolve()
242+
results_root = Path(sys.argv[2]).resolve()
243+
out_root = Path(sys.argv[3]).resolve()
244+
245+
# Validate inputs
246+
if not build_root.is_dir():
247+
print(f"ERROR: Build artifacts directory not found: {build_root}", file=sys.stderr)
248+
return 2
249+
if not results_root.is_dir():
250+
print(f"ERROR: Test results directory not found: {results_root}", file=sys.stderr)
251+
return 2
252+
# Ensure output directory exists
253+
try:
254+
out_root.mkdir(parents=True, exist_ok=True)
255+
except Exception as e:
256+
print(f"ERROR: Failed to create output directory {out_root}: {e}", file=sys.stderr)
257+
return 2
258+
259+
# Read matrices from environment variables injected by workflow
260+
hw_enabled = (os.environ.get("HW_TESTS_ENABLED", "false").lower() == "true")
261+
wokwi_enabled = (os.environ.get("WOKWI_TESTS_ENABLED", "false").lower() == "true")
262+
qemu_enabled = (os.environ.get("QEMU_TESTS_ENABLED", "false").lower() == "true")
263+
264+
hw_targets = parse_array(os.environ.get("HW_TARGETS", "[]"))
265+
wokwi_targets = parse_array(os.environ.get("WOKWI_TARGETS", "[]"))
266+
qemu_targets = parse_array(os.environ.get("QEMU_TARGETS", "[]"))
267+
268+
hw_types = parse_array(os.environ.get("HW_TYPES", "[]"))
269+
wokwi_types = parse_array(os.environ.get("WOKWI_TYPES", "[]"))
270+
qemu_types = parse_array(os.environ.get("QEMU_TYPES", "[]"))
271+
272+
expected = expected_from_artifacts(build_root) # (platform, target, type, sketch) -> expected_count
273+
executed_types = set(hw_types + wokwi_types + qemu_types)
274+
executed = scan_executed_xml(results_root, executed_types) # (platform, target, type, sketch) -> count
275+
print(f"[DEBUG] Expected entries computed: {len(expected)}", file=sys.stderr)
276+
277+
# Filter expected by enabled platforms and target/type matrices
278+
enabled_plats = set()
279+
if hw_enabled:
280+
enabled_plats.add("hardware")
281+
if wokwi_enabled:
282+
enabled_plats.add("wokwi")
283+
if qemu_enabled:
284+
enabled_plats.add("qemu")
285+
286+
# Build platform-specific target and type sets
287+
plat_targets = {
288+
"hardware": set(hw_targets),
289+
"wokwi": set(wokwi_targets),
290+
"qemu": set(qemu_targets),
291+
}
292+
plat_types = {
293+
"hardware": set(hw_types),
294+
"wokwi": set(wokwi_types),
295+
"qemu": set(qemu_types),
296+
}
297+
298+
missing_total = 0
299+
extra_total = 0
300+
for (plat, target, test_type, sketch), exp_count in expected.items():
301+
if plat not in enabled_plats:
302+
continue
303+
# Check if target and type are valid for this specific platform
304+
if target not in plat_targets.get(plat, set()):
305+
continue
306+
if test_type not in plat_types.get(plat, set()):
307+
continue
308+
got = executed.get((plat, target, test_type, sketch), 0)
309+
if got < exp_count:
310+
print(f"[DEBUG] Missing: plat={plat} target={target} type={test_type} sketch={sketch} expected={exp_count} got={got}", file=sys.stderr)
311+
write_missing_xml(out_root, plat, target, test_type, sketch, exp_count - got)
312+
missing_total += (exp_count - got)
313+
elif got > exp_count:
314+
print(f"[DEBUG] Extra runs: plat={plat} target={target} type={test_type} sketch={sketch} expected={exp_count} got={got}", file=sys.stderr)
315+
extra_total += (got - exp_count)
316+
317+
# Check for executed tests that were not expected at all
318+
for (plat, target, test_type, sketch), got in executed.items():
319+
if (plat, target, test_type, sketch) not in expected:
320+
print(f"[DEBUG] Unexpected test: plat={plat} target={target} type={test_type} sketch={sketch} got={got} (not in expected)", file=sys.stderr)
321+
322+
print(f"Generated {missing_total} placeholder JUnit files for missing runs.", file=sys.stderr)
323+
if extra_total > 0:
324+
print(f"WARNING: {extra_total} extra test runs detected (more than expected).", file=sys.stderr)
325+
326+
327+
if __name__ == "__main__":
328+
sys.exit(main())
329+
330+

.github/scripts/get_affected.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
Build file patterns
6464
--------------------
6565
- **build_files**: Core Arduino build system files (platform.txt, variants/**, etc.)
66-
- **sketch_build_files**: Sketch-specific files (ci.json, *.csv in example directories)
66+
- **sketch_build_files**: Sketch-specific files (ci.yml, *.csv in example directories)
6767
- **idf_build_files**: Core IDF build system files (CMakeLists.txt, idf_component.yml, etc.)
6868
- **idf_project_files**: Project-specific IDF files (per-example CMakeLists.txt, sdkconfig, etc.)
6969
@@ -128,7 +128,7 @@
128128
# Files that are used by the sketch build system.
129129
# If any of these files change, the sketch should be recompiled.
130130
sketch_build_files = [
131-
"libraries/*/examples/**/ci.json",
131+
"libraries/*/examples/**/ci.yml",
132132
"libraries/*/examples/**/*.csv",
133133
]
134134

@@ -150,7 +150,7 @@
150150
# If any of these files change, the example that uses them should be recompiled.
151151
idf_project_files = [
152152
"idf_component_examples/*/CMakeLists.txt",
153-
"idf_component_examples/*/ci.json",
153+
"idf_component_examples/*/ci.yml",
154154
"idf_component_examples/*/*.csv",
155155
"idf_component_examples/*/sdkconfig*",
156156
"idf_component_examples/*/main/*",

.github/scripts/on-push-idf.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ fi
1717

1818
for example in $affected_examples; do
1919
example_path="$PWD/components/arduino-esp32/$example"
20-
if [ -f "$example_path/ci.json" ]; then
20+
if [ -f "$example_path/ci.yml" ]; then
2121
# If the target is listed as false, skip the sketch. Otherwise, include it.
22-
is_target=$(jq -r --arg target "$IDF_TARGET" '.targets[$target]' "$example_path/ci.json")
22+
is_target=$(yq eval ".targets.${IDF_TARGET}" "$example_path/ci.yml" 2>/dev/null)
2323
if [[ "$is_target" == "false" ]]; then
2424
printf "\n\033[93mSkipping %s for target %s\033[0m\n\n" "$example" "$IDF_TARGET"
2525
continue

0 commit comments

Comments
 (0)