diff --git a/.github/scripts/api_symbols_report.py b/.github/scripts/api_symbols_report.py new file mode 100644 index 000000000..5c585aa96 --- /dev/null +++ b/.github/scripts/api_symbols_report.py @@ -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//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": , "breaking": } + +Base/head sources: + * CI mode: --base-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"
{title} ({len(names)})", "", + "| Symbol | Signature |", "|---|---|"] + out += [fmt(n) for n in names] + out += ["", "
", ""] + 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() diff --git a/.github/scripts/fw_size_report.py b/.github/scripts/fw_size_report.py new file mode 100644 index 000000000..155c6ad72 --- /dev/null +++ b/.github/scripts/fw_size_report.py @@ -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 `. 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 --elf + * Test: --size-output +DFU size (optional): --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() diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 000000000..ddf80b2af --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,160 @@ +# Build & analyze: build the firmware on each PR, on pushes to dev, and on-demand +# ("Run workflow"); publish it as a downloadable artifact, and on PRs post a unified +# report comment (build status + flash budget + public-API changes). Push/dispatch +# runs (no PR) put the same report in the Actions job summary only. +# +# Scope (deliberately minimal): +# * Builds the f7 "clean" flavor only (firmware + in-tree apps). The default/extra +# app packs are PREBUILT binaries from xMasterX/all-the-plugins, NOT compiled +# from the PR, so bundling them validates nothing here. +# * Flash budget comes from the firmware ELF's `.free_flash` section — the linker +# fills the gap up to the radio stack with it (targets/f7/stm32wb55xx_flash.ld), +# so its size is exactly "how much flash is left". See fw_size_report.py. +# * The clean build also gates API drift: fbt fails if the exported API surface +# changes without targets/*/api_symbols.csv being updated + version-bumped. +# +# Artifacts/reports are named by branch + short SHA, so manual runs from dev work too. +# PR comment assumes PRs originate from this repo (not forks): the default token can +# post. The same report is always written to the Actions job summary. (Fork PRs get a +# read-only token; commenting there would need a `workflow_run` job.) + +name: Build & analyze + +on: + push: + branches: [dev] # dev builds (active once this workflow is merged to dev) + pull_request: + workflow_dispatch: # manual "Run workflow" button (appears once this is on the default branch) + +# Cancel an in-flight run when a newer commit (per PR) or dispatch (per ref) +# supersedes it. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write # for the PR report comment (same-repo PRs) + +env: + FBT_NO_SYNC: 0 + FORCE_NO_DIRTY: "yes" + FBT_GIT_SUBMODULE_SHALLOW: 1 + WORKFLOW_BRANCH_OR_TAG: ${{ github.head_ref || github.ref_name }} + +jobs: + build: + name: f7 firmware + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout (PR merge ref) + uses: actions/checkout@v6 + with: + submodules: recursive + clean: "true" + + - name: Compute version vars + id: vars + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + SHA="${{ github.event.pull_request.head.sha }}" + REF="${{ github.head_ref }}" + else + SHA="${{ github.sha }}" + REF="${{ github.ref_name }}" + fi + SHORT="${SHA:0:8}" + SAFE_REF="${REF//\//-}" # branch name, '/' -> '-' for artifact names + echo "sha8=$SHORT" >> "$GITHUB_OUTPUT" + echo "ref=$SAFE_REF" >> "$GITHUB_OUTPUT" + echo "dist=${SAFE_REF}-${SHORT}" >> "$GITHUB_OUTPUT" + + - name: Build firmware (minimal / clean) + id: build + shell: bash + env: + DIST_SUFFIX: ${{ steps.vars.outputs.sha8 }} + run: | + rm -rf applications/main/clock_app/resources/apps/ || true + ./fbt COMPACT=1 DEBUG=0 FBT_NO_SYNC="${FBT_NO_SYNC}" updater_package + + - name: Upload firmware artifact + if: success() + uses: actions/upload-artifact@v7 + with: + name: unleashed-fw-${{ steps.vars.outputs.dist }} + path: dist/f7-C/* + retention-days: 14 + if-no-files-found: error + + # ---------- Flash budget: DFU size + free flash ---------- + - name: Measure firmware size + if: always() + shell: bash + run: | + SIZE_BIN="$(find toolchain -type f -path '*/bin/arm-none-eabi-size' 2>/dev/null | head -1)" + [ -z "$SIZE_BIN" ] && SIZE_BIN="$(command -v arm-none-eabi-size || true)" + ELF="$(find build -name firmware.elf -type f 2>/dev/null | head -1)" + DFU="$(find build -name firmware.dfu -type f 2>/dev/null | head -1)" + echo "size-bin=$SIZE_BIN | elf=$ELF | dfu=$DFU" + python .github/scripts/fw_size_report.py \ + --size-bin "$SIZE_BIN" --elf "$ELF" --dfu "$DFU" --out size_report.md || true + [ -f size_report.md ] || echo "_Size report unavailable._" > size_report.md + + # ---------- Public API change report (PRs only) ---------- + - name: Fetch PR base for API diff + if: always() && github.event_name == 'pull_request' + shell: bash + run: git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}" + + - name: Generate API change report + if: always() && github.event_name == 'pull_request' + shell: bash + run: | + python .github/scripts/api_symbols_report.py \ + --base-ref "${{ github.event.pull_request.base.sha }}" \ + --out api_report.md + + # ---------- Assemble + publish the unified PR report ---------- + - name: Assemble PR report + if: always() + shell: bash + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + { + echo "" + echo "## 🤖 Build & analyze — \`${{ steps.vars.outputs.ref }}\` @ \`${{ steps.vars.outputs.sha8 }}\`" + echo "" + if [ "${{ steps.build.outcome }}" = "success" ]; then + echo "✅ **Firmware built** — artifact \`unleashed-fw-${{ steps.vars.outputs.dist }}\` ([run]($RUN_URL))" + else + echo "❌ **Firmware build failed** — see the [run log]($RUN_URL)" + fi + echo "" + cat size_report.md 2>/dev/null || true + echo "" + cat api_report.md 2>/dev/null || true + } > pr_report.md + cat pr_report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment PR report + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = fs.readFileSync('pr_report.md', 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, + { owner, repo, issue_number }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + }