Skip to content

Commit 1bb6122

Browse files
committed
Merge branch 'ci/fixes'
2 parents b20655a + 9d96b34 commit 1bb6122

File tree

507 files changed

+2003
-1924
lines changed

Some content is hidden

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

507 files changed

+2003
-1924
lines changed

.github/CODEOWNERS

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

75-
# CI JSON
75+
# CI YAML
7676
# Keep this after other libraries and tests to avoid being overridden.
77-
**/ci.json @lucasssvaz
77+
**/ci.yml @lucasssvaz
7878

7979
# The CODEOWNERS file should be owned by the developers of the ESP32 Arduino Core.
8080
# Leave this entry as the last one to avoid being overridden.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import re
6+
import sys
7+
from pathlib import Path
8+
from xml.etree.ElementTree import Element, SubElement, ElementTree
9+
import yaml
10+
11+
12+
def read_workflow_info(path: Path) -> dict:
13+
try:
14+
return json.loads(path.read_text(encoding="utf-8"))
15+
except Exception as e:
16+
print(f"WARN: Failed to read workflow info at {path}: {e}", file=sys.stderr)
17+
return {}
18+
19+
20+
def parse_array(value) -> list[str]:
21+
if isinstance(value, list):
22+
return [str(x) for x in value]
23+
if not isinstance(value, str):
24+
return []
25+
txt = value.strip()
26+
if not txt:
27+
return []
28+
# Try JSON
29+
try:
30+
return [str(x) for x in json.loads(txt)]
31+
except Exception:
32+
pass
33+
# Normalize single quotes then JSON
34+
try:
35+
fixed = txt.replace("'", '"')
36+
return [str(x) for x in json.loads(fixed)]
37+
except Exception:
38+
pass
39+
# Fallback: CSV
40+
return [p.strip() for p in txt.strip("[]").split(",") if p.strip()]
41+
42+
43+
def _parse_ci_yml(content: str) -> dict:
44+
if not content:
45+
return {}
46+
try:
47+
data = yaml.safe_load(content) or {}
48+
if not isinstance(data, dict):
49+
return {}
50+
return data
51+
except Exception:
52+
return {}
53+
54+
55+
def _fqbn_counts_from_yaml(ci: dict) -> dict[str, int]:
56+
counts: dict[str, int] = {}
57+
if not isinstance(ci, dict):
58+
return counts
59+
fqbn = ci.get("fqbn")
60+
if not isinstance(fqbn, dict):
61+
return counts
62+
for target, entries in fqbn.items():
63+
if isinstance(entries, list):
64+
counts[str(target)] = len(entries)
65+
elif entries is not None:
66+
# Single value provided as string
67+
counts[str(target)] = 1
68+
return counts
69+
70+
71+
def _sdkconfig_meets(ci_cfg: dict, sdk_text: str) -> bool:
72+
if not sdk_text:
73+
return True
74+
for req in ci_cfg.get("requires", []):
75+
if not req or not isinstance(req, str):
76+
continue
77+
if not any(line.startswith(req) for line in sdk_text.splitlines()):
78+
return False
79+
req_any = ci_cfg.get("requires_any", [])
80+
if req_any:
81+
if not any(any(line.startswith(r.strip()) for line in sdk_text.splitlines()) for r in req_any if isinstance(r, str)):
82+
return False
83+
return True
84+
85+
86+
def expected_from_artifacts(build_root: Path) -> dict[tuple[str, str, str, str], int]:
87+
"""Compute expected runs using ci.yml and sdkconfig found in build artifacts.
88+
Returns mapping (platform, target, type, sketch) -> expected_count
89+
"""
90+
expected: dict[tuple[str, str, str, str], int] = {}
91+
if not build_root.exists():
92+
return expected
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+
# Walk build*.tmp directories which contain ci.yml and sdkconfig
102+
for p in artifact_dir.rglob("*.tmp"):
103+
if not p.is_dir() or not re.search(r"build\d*\.tmp$", str(p)):
104+
continue
105+
# Infer sketch name from path
106+
parts = p.parts
107+
try:
108+
idx = parts.index(".arduino")
109+
except ValueError:
110+
continue
111+
if idx + 4 >= len(parts) or parts[idx + 1] != "tests":
112+
continue
113+
sketch_target = parts[idx + 2]
114+
sketch = parts[idx + 3]
115+
if sketch_target != target:
116+
continue
117+
ci_path = p / "ci.yml"
118+
sdk_path = p / "sdkconfig"
119+
try:
120+
ci_text = ci_path.read_text(encoding="utf-8") if ci_path.exists() else ""
121+
except Exception:
122+
ci_text = ""
123+
try:
124+
sdk_text = sdk_path.read_text(encoding="utf-8", errors="ignore") if sdk_path.exists() else ""
125+
except Exception:
126+
sdk_text = ""
127+
ci = _parse_ci_yml(ci_text)
128+
fqbn_counts = _fqbn_counts_from_yaml(ci)
129+
# Determine allowed platforms for this test
130+
allowed_platforms = []
131+
platforms_cfg = ci.get("platforms") if isinstance(ci, dict) else None
132+
for plat in ("hardware", "wokwi", "qemu"):
133+
dis = None
134+
if isinstance(platforms_cfg, dict):
135+
dis = platforms_cfg.get(plat)
136+
if dis is False:
137+
continue
138+
allowed_platforms.append(plat)
139+
# Requirements check
140+
minimal = {
141+
"requires": ci.get("requires") or [],
142+
"requires_any": ci.get("requires_any") or [],
143+
}
144+
if not _sdkconfig_meets(minimal, sdk_text):
145+
continue
146+
# Expected runs per target driven by fqbn count; default 1 when 0
147+
exp_runs = fqbn_counts.get(target, 0) or 1
148+
for plat in allowed_platforms:
149+
expected[(plat, target, test_type, sketch)] = max(expected.get((plat, target, test_type, sketch), 0), exp_runs)
150+
return expected
151+
152+
153+
def scan_executed_xml(xml_root: Path) -> dict[tuple[str, str, str, str], int]:
154+
"""Return executed counts per (platform, target, type, sketch)."""
155+
counts: dict[tuple[str, str, str, str], int] = {}
156+
if not xml_root.exists():
157+
return counts
158+
for xml_path in xml_root.rglob("*.xml"):
159+
if not xml_path.is_file():
160+
continue
161+
rel = str(xml_path)
162+
platform = "hardware"
163+
if "test-results-wokwi-" in rel:
164+
platform = "wokwi"
165+
elif "test-results-qemu-" in rel:
166+
platform = "qemu"
167+
# Expect tests/<type>/<sketch>/<target>/<sketch>.xml
168+
# Find 'tests' segment
169+
parts = xml_path.parts
170+
try:
171+
t_idx = parts.index("tests")
172+
except ValueError:
173+
continue
174+
if t_idx + 4 >= len(parts):
175+
continue
176+
test_type = parts[t_idx + 1]
177+
sketch = parts[t_idx + 2]
178+
target = parts[t_idx + 3]
179+
key = (platform, target, test_type, sketch)
180+
counts[key] = counts.get(key, 0) + 1
181+
return counts
182+
183+
184+
def write_missing_xml(out_root: Path, platform: str, target: str, test_type: str, sketch: str, missing_count: int):
185+
out_tests_dir = out_root / f"test-results-{platform}" / "tests" / test_type / sketch / target
186+
out_tests_dir.mkdir(parents=True, exist_ok=True)
187+
# Create one XML per missing index
188+
for idx in range(missing_count):
189+
suite_name = f"{test_type}_{platform}_{target}_{sketch}"
190+
root = Element("testsuite", name=suite_name, tests="1", failures="1", errors="0")
191+
case = SubElement(root, "testcase", classname=f"{test_type}.{sketch}", name="missing-run")
192+
fail = SubElement(case, "failure", message="Expected test run missing")
193+
fail.text = "This placeholder indicates an expected test run did not execute."
194+
tree = ElementTree(root)
195+
out_file = out_tests_dir / f"{sketch}_missing_{idx}.xml"
196+
tree.write(out_file, encoding="utf-8", xml_declaration=True)
197+
198+
199+
def main():
200+
# Args: <build_artifacts_dir> <output_junit_dir>
201+
if len(sys.argv) != 3:
202+
print(f"Usage: {sys.argv[0]} <build_artifacts_dir> <output_junit_dir>", file=sys.stderr)
203+
return 2
204+
205+
build_root = Path(sys.argv[1]).resolve()
206+
artifacts_root = Path(sys.argv[2]).resolve()
207+
wf_info = artifacts_root / "parent-artifacts" / "workflow_info.json"
208+
209+
info = read_workflow_info(wf_info)
210+
hw_enabled = str(info.get("hw_tests_enabled", "false")).lower() == "true"
211+
wokwi_enabled = str(info.get("wokwi_tests_enabled", "false")).lower() == "true"
212+
qemu_enabled = str(info.get("qemu_tests_enabled", "false")).lower() == "true"
213+
hw_targets = parse_array(info.get("hw_targets"))
214+
wokwi_targets = parse_array(info.get("wokwi_targets"))
215+
qemu_targets = parse_array(info.get("qemu_targets"))
216+
hw_types = parse_array(info.get("hw_types"))
217+
wokwi_types = parse_array(info.get("wokwi_types"))
218+
qemu_types = parse_array(info.get("qemu_types"))
219+
220+
expected = expected_from_artifacts(build_root) # (platform, target, type, sketch) -> expected_count
221+
executed = scan_executed_xml(artifacts_root) # (platform, target, type, sketch) -> count
222+
223+
# Filter expected by enabled platforms and target/type matrices
224+
enabled_plats = set()
225+
if hw_enabled:
226+
enabled_plats.add("hardware")
227+
if wokwi_enabled:
228+
enabled_plats.add("wokwi")
229+
if qemu_enabled:
230+
enabled_plats.add("qemu")
231+
232+
target_set = set(hw_targets + wokwi_targets + qemu_targets)
233+
type_set = set(hw_types + wokwi_types + qemu_types)
234+
235+
missing_total = 0
236+
for (plat, target, test_type, sketch), exp_count in expected.items():
237+
if plat not in enabled_plats:
238+
continue
239+
if target not in target_set or test_type not in type_set:
240+
continue
241+
got = executed.get((plat, target, test_type, sketch), 0)
242+
if got < exp_count:
243+
write_missing_xml(artifacts_root, plat, target, test_type, sketch, exp_count - got)
244+
missing_total += (exp_count - got)
245+
246+
print(f"Generated {missing_total} placeholder JUnit files for missing runs.", file=sys.stderr)
247+
248+
249+
if __name__ == "__main__":
250+
sys.exit(main())
251+
252+

.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)