CI: PoC GitHub Actions PR build + API-change report (#1005) [ci skip]

This commit is contained in:
Mykhailo Shevchuk
2026-06-10 22:58:17 +03:00
committed by GitHub
parent 2aeee10250
commit c74dce5776
3 changed files with 492 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Summarize public firmware API changes between two api_symbols.csv revisions.
The public API surface available to external apps (.fap) is defined by
``targets/<hw>/api_symbols.csv``. Only entries with status ``+`` are exported and
callable by apps; ``-`` entries are known-but-disabled (e.g. STM32 HAL symbols).
A removed or signature-changed ``+`` Function/Variable, or a MAJOR version bump,
is *breaking* for prebuilt apps: they fail at load time with an API-version
mismatch (lib/flipper_application/application_manifest.c) or MissingImports.
Additive-only changes bump the MINOR version and keep existing apps compatible.
Emits a Markdown report (``--out``) and prints a JSON status line to stdout:
{"changed": <bool>, "breaking": <bool>}
Base/head sources:
* CI mode: --base-ref <git-ref> (head = working tree; all targets scanned)
* Test mode: --base-file / --head-file (single --target)
"""
import argparse
import csv
import io
import json
import subprocess
from pathlib import Path
def parse_api(text):
"""Parse api_symbols.csv text.
Returns (version, funcs, variables, headers) where funcs/variables/headers
only contain status ``+`` (exported) entries:
funcs: name -> (return_type, params)
variables: name -> type
headers: set(name)
"""
version = None
funcs, variables, headers = {}, {}, set()
for row in csv.reader(io.StringIO(text)):
if not row or row[0] == "entry":
continue
entry = row[0]
status = row[1] if len(row) > 1 else ""
name = row[2] if len(row) > 2 else ""
rtype = row[3] if len(row) > 3 else ""
params = row[4] if len(row) > 4 else ""
if entry == "Version":
version = name
continue
if status != "+":
continue # disabled / not exported to apps
if entry == "Function":
funcs[name] = (rtype, params)
elif entry == "Variable":
variables[name] = rtype
elif entry == "Header":
headers.add(name)
return version, funcs, variables, headers
def _ver_tuple(v):
try:
parts = v.split(".")
return int(parts[0]), (int(parts[1]) if len(parts) > 1 else 0)
except (ValueError, AttributeError, IndexError):
return None
def classify_version(old, new):
o, n = _ver_tuple(old), _ver_tuple(new)
if not o or not n or o == n:
return "none"
if n[0] > o[0]:
return "major"
if n[0] == o[0] and n[1] > o[1]:
return "minor"
return "other"
def _table(title, names, fmt):
if not names:
return []
out = [f"<details><summary>{title} ({len(names)})</summary>", "",
"| Symbol | Signature |", "|---|---|"]
out += [fmt(n) for n in names]
out += ["", "</details>", ""]
return out
def diff_target(target, base_text, head_text):
"""Return (markdown_section, changed, breaking) for one target."""
b_ver, b_func, b_var, b_hdr = parse_api(base_text)
h_ver, h_func, h_var, h_hdr = parse_api(head_text)
func_added = sorted(set(h_func) - set(b_func))
func_removed = sorted(set(b_func) - set(h_func))
func_changed = sorted(n for n in set(b_func) & set(h_func) if b_func[n] != h_func[n])
var_added = sorted(set(h_var) - set(b_var))
var_removed = sorted(set(b_var) - set(h_var))
var_changed = sorted(n for n in set(b_var) & set(h_var) if b_var[n] != h_var[n])
hdr_added = sorted(h_hdr - b_hdr)
hdr_removed = sorted(b_hdr - h_hdr)
vbump = classify_version(b_ver, h_ver)
changed = any([b_ver != h_ver, func_added, func_removed, func_changed,
var_added, var_removed, var_changed, hdr_added, hdr_removed])
breaking = bool(func_removed or func_changed or var_removed or var_changed
or vbump == "major")
if not changed:
return "", False, False
L = [f"### Target `{target}`"]
if b_ver != h_ver:
tag = {"major": " &mdash; ⚠️ **MAJOR (breaking)**",
"minor": " &mdash; additive",
"other": "", "none": ""}.get(vbump, "")
L.append(f"**SDK API version:** `{b_ver}` → `{h_ver}`{tag}")
else:
L.append(f"**SDK API version:** `{h_ver}` (unchanged)")
L.append("")
if func_removed or var_removed or func_changed or var_changed:
L.append("#### \U0001f534 Removed / changed (breaking)")
L += _table("Functions removed", func_removed,
lambda n: f"| `{n}` | `{b_func[n][0]} {n}({b_func[n][1]})` |")
L += _table("Functions changed", func_changed,
lambda n: f"| `{n}` | `{b_func[n][0]} ({b_func[n][1]})` → "
f"`{h_func[n][0]} ({h_func[n][1]})` |")
L += _table("Variables removed", var_removed,
lambda n: f"| `{n}` | `{b_var[n]}` |")
L += _table("Variables changed", var_changed,
lambda n: f"| `{n}` | `{b_var[n]}` → `{h_var[n]}` |")
if func_added or var_added:
L.append("#### \U0001f7e2 Added (non-breaking)")
L += _table("Functions added", func_added,
lambda n: f"| `{n}` | `{h_func[n][0]} {n}({h_func[n][1]})` |")
L += _table("Variables added", var_added,
lambda n: f"| `{n}` | `{h_var[n]}` |")
if hdr_added or hdr_removed:
bits = []
if hdr_added:
bits.append(f"{len(hdr_added)} added")
if hdr_removed:
bits.append(f"{len(hdr_removed)} removed")
L.append(f"#### \U0001f4c4 Headers: {', '.join(bits)}")
L += [f"- \U0001f7e2 `{n}`" for n in hdr_added]
L += [f"- \U0001f534 `{n}`" for n in hdr_removed]
L.append("")
if (func_removed or func_changed or var_removed or var_changed) and vbump != "major":
L.append("> ⚠️ **Note:** symbols were removed/changed but the major "
"version was not bumped. fbt normally forces a MAJOR bump on removal "
"&mdash; double-check `api_symbols.csv`.")
L.append("")
return "\n".join(L), changed, breaking
def git_show(ref, path):
"""Return file content at a git ref, or '' if it does not exist there."""
if not ref:
return ""
try:
return subprocess.check_output(
["git", "show", f"{ref}:{path}"], stderr=subprocess.DEVNULL
).decode("utf-8", "replace")
except subprocess.CalledProcessError:
return ""
def _read(path):
p = Path(path)
return p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--base-ref", help="git ref for the PR base commit")
ap.add_argument("--out", default="api_report.md")
ap.add_argument("--base-file", help="test mode: base csv path")
ap.add_argument("--head-file", help="test mode: head csv path")
ap.add_argument("--target", default="f7", help="target label for test mode")
args = ap.parse_args()
sections, any_changed, any_breaking = [], False, False
if args.base_file or args.head_file:
# Test mode: a single explicit base/head pair.
pairs = [(args.target, _read(args.base_file), _read(args.head_file))]
else:
# CI mode: every target, base from git, head from the working tree.
pairs = [(p.parent.name, git_show(args.base_ref, p.as_posix()), _read(p))
for p in sorted(Path("targets").glob("*/api_symbols.csv"))]
for target, base_text, head_text in pairs:
section, changed, breaking = diff_target(target, base_text, head_text)
if section:
sections.append(section)
any_changed |= changed
any_breaking |= breaking
if any_changed:
head = "## \U0001f4cb Public API changes\n"
if any_breaking:
head += ("\n> \U0001f534 **This PR changes the public app API in a breaking way.** "
"Prebuilt community apps (all-the-plugins) built against the old API "
"will fail to load until rebuilt.\n")
else:
head += "\n> \U0001f7e2 Additive API changes only &mdash; existing apps stay compatible.\n"
body = head + "\n" + "\n".join(sections)
else:
body = "## \U0001f4cb Public API changes\n\n✅ No public API changes in this PR.\n"
Path(args.out).write_text(body, encoding="utf-8")
print(json.dumps({"changed": any_changed, "breaking": any_breaking}))
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Report f7 firmware flash/RAM usage and DFU size from a built firmware.elf.
Parses `arm-none-eabi-size -A <firmware.elf>`. On the STM32WB55 the firmware
grows up from the start of flash and the linker fills the gap up to the radio
stack / internal-storage boundary with a `.free_flash` section
(targets/f7/stm32wb55xx_flash.ld) — so its size *is* "how much flash is left".
flash used = .text + .rodata + .data (flash-resident)
flash free = .free_flash
RAM used = .data + .bss
Emits Markdown (--out) and prints JSON to stdout:
{"free_bytes":int|null,"used_bytes":int|null,"total_bytes":int|null,"dfu_bytes":int|null}
Sources (any one):
* CI: --size-bin <arm-none-eabi-size> --elf <firmware.elf>
* Test: --size-output <captured `size -A` text file>
DFU size (optional): --dfu <firmware.dfu>
"""
import argparse
import json
import os
import subprocess
from pathlib import Path
FLASH_SECTIONS = (".text", ".rodata", ".data") # flash-resident
RAM_SECTIONS = (".data", ".bss")
def parse_size(text):
"""Return {section: bytes} from `arm-none-eabi-size -A` (sysv) output."""
sizes = {}
for line in text.splitlines():
parts = line.split()
if len(parts) != 3: # "section size addr"; header/total rows differ
continue
section, size, _addr = parts
try:
sizes[section] = int(size)
except ValueError:
continue # e.g. the "section size addr" header row
return sizes
def human(n):
if n is None:
return "n/a"
if abs(n) < 1024:
return f"{n} B"
if abs(n) < 1024 * 1024:
return f"{n / 1024:.2f} KiB"
return f"{n / (1024 * 1024):.2f} MiB"
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--size-bin", help="path to arm-none-eabi-size")
ap.add_argument("--elf", help="firmware.elf")
ap.add_argument("--size-output", help="test mode: captured `size -A` output")
ap.add_argument("--dfu", help="firmware.dfu (optional, for file size)")
ap.add_argument("--out", default="size_report.md")
args = ap.parse_args()
if args.size_output:
size_text = Path(args.size_output).read_text(encoding="utf-8", errors="replace")
elif args.size_bin and args.elf and Path(args.elf).exists():
try:
size_text = subprocess.check_output(
[args.size_bin, "-A", args.elf]
).decode("utf-8", "replace")
except (OSError, subprocess.CalledProcessError):
size_text = "" # bad/non-executable size binary -> degrade gracefully
else:
size_text = ""
sizes = parse_size(size_text)
free = sizes.get(".free_flash")
used = sum(sizes[s] for s in FLASH_SECTIONS if s in sizes) or None
ram = sum(sizes[s] for s in RAM_SECTIONS if s in sizes) or None
total = used + free if (used is not None and free is not None) else None
dfu = os.path.getsize(args.dfu) if (args.dfu and Path(args.dfu).exists()) else None
lines = ["### 💾 Flash & RAM (f7)", ""]
if free is None and used is None:
lines.append("_Size data unavailable (firmware ELF not found)._")
else:
lines += ["| Metric | Size |", "|---|---|"]
if used is not None:
lines.append(f"| Firmware (flash-resident) | {human(used)} |")
if free is not None:
pct = f"{free / total * 100:.1f}% of region free" if total else ""
lines.append(f"| **Free flash** | **{human(free)}**{pct} |")
if total is not None:
lines.append(f"| Flash region (used + free) | {human(total)} |")
if dfu is not None:
lines.append(f"| DFU image | {human(dfu)} |")
if ram is not None:
lines.append(f"| RAM (.data + .bss) | {human(ram)} |")
Path(args.out).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps({
"free_bytes": free, "used_bytes": used,
"total_bytes": total, "dfu_bytes": dfu,
}))
if __name__ == "__main__":
main()