mirror of
https://github.com/DarkFlippers/unleashed-firmware.git
synced 2026-08-29 01:18:36 +00:00
CI: PoC GitHub Actions PR build + API-change report (#1005) [ci skip]
This commit is contained in:
@@ -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": " — ⚠️ **MAJOR (breaking)**",
|
||||
"minor": " — 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 "
|
||||
"— 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 — 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user