From 18dcfd2c5b115a9b9704bf197fcc5ad0a59cd240 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 16 Sep 2026 18:45:25 -0400 Subject: [PATCH 1/6] Report firmware sizes from CI `build_release_files.py` records each language build's flash usage in `sizes/.json`, including sizes predicted for languages it skips. The board job uploads it as `zz-sizes-` with one-day retention, and `build-ci` merges the records with the new `ci_firmware_sizes.py` into a `zz-sizes` artifact holding `sizes.json` and a sortable `sizes.html`, plus a job summary of the boards with the least free flash. The report steps continue on error so they cannot fail the gate. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-boards.yml | 11 + .github/workflows/build.yml | 32 +++ .gitignore | 1 + tools/build_release_files.py | 69 ++++- tools/ci_firmware_sizes.py | 403 +++++++++++++++++++++++++++++ 5 files changed, 504 insertions(+), 12 deletions(-) create mode 100755 tools/ci_firmware_sizes.py diff --git a/.github/workflows/build-boards.yml b/.github/workflows/build-boards.yml index bf1734a19c2..e9ac9f47a4c 100644 --- a/.github/workflows/build-boards.yml +++ b/.github/workflows/build-boards.yml @@ -94,6 +94,17 @@ jobs: name: ${{ matrix.board }} path: bin/${{ matrix.board }} + - name: Upload firmware sizes + # "zz-" sorts the size records after every board in the run's artifact list. + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: zz-sizes-${{ matrix.board }} + path: sizes/${{ matrix.board }}.json + if-no-files-found: ignore + # build-ci merges these into the zz-sizes artifact; delete them after one day. + retention-days: 1 + - name: Upload to S3 uses: ./.github/actions/upload_aws with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e250c8debd7..5d3c1c17267 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -245,6 +245,38 @@ jobs: if: ${{ always() }} runs-on: ubuntu-24.04 steps: + # Firmware size report. Each board job uploads its sizes/.json as zz-sizes-; + # merge them into one table and a job summary. This is reporting only, so none of it + # may fail the gate: every step continues on error. + - name: Set up repository + continue-on-error: true + uses: actions/checkout@v6 + with: + submodules: false + show-progress: false + fetch-depth: 1 + persist-credentials: false + + - name: Download firmware sizes + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: zz-sizes-* + path: sizes + merge-multiple: true + + - name: Summarize firmware sizes + continue-on-error: true + run: python3 tools/ci_firmware_sizes.py sizes sizes-report + + - name: Upload firmware size report + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: zz-sizes + path: sizes-report + if-no-files-found: ignore + - name: Check build results run: | echo "Job results: ${{ toJSON(needs.*.result) }}" diff --git a/.gitignore b/.gitignore index 659801277f3..ed64a99deb1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ dist/ ###################### build/ bin/ +sizes/ circuitpython-stubs/ test-stubs/ build-*/ diff --git a/tools/build_release_files.py b/tools/build_release_files.py index b65b2aeb723..ac6580063de 100755 --- a/tools/build_release_files.py +++ b/tools/build_release_files.py @@ -70,6 +70,29 @@ def flash_usage(port, build_dir): return None, None +def record_size(port, board, language, used, region, status, source): + """Record one language build's flash usage in sizes/.json. + + CI uploads the file as its own small artifact so a later job can gather every + board's sizes without scraping logs. `source` is "measured" for a linked build and + "predicted" for a language skipped by the size prediction; `used` is None, written as + null, when the build failed or the port does not report its flash region. + """ + os.makedirs("../sizes", exist_ok=True) + path = f"../sizes/{board}.json" + try: + with open(path, "r") as f: + record = json.load(f) + except FileNotFoundError: + record = {"port": port, "board": board, "region": None, "languages": {}} + if region is not None: + record["region"] = region + record["languages"][language] = {"used": used, "status": status, "source": source} + with open(path, "w") as f: + json.dump(record, f, indent=1) + f.write("\n") + + def c_array_bytes(path): """Sum the bytes of the const arrays initialised in a generated C file.""" if not path.exists(): @@ -218,6 +241,15 @@ def generate_translation(port, board, build_dir, language): flush=True, ) if skip: + record_size( + board_info["port"], + board, + language, + predicted_flash, + flash_region, + "skipped", + "predicted", + ) continue make_result = subprocess.run( @@ -275,19 +307,32 @@ def generate_translation(port, board, build_dir, language): print(make_result.stdout.decode("utf-8")) print(other_output) - if predicted_flash is not None and make_result.returncode == 0: - actual_flash, _ = flash_usage(board_info["port"], build_dir) - if actual_flash is not None: - print( - "Flash size check {board} {language}: predicted {predicted}," - " actual {actual}, error {error:+d}".format( - board=board, - language=language, - predicted=predicted_flash, - actual=actual_flash, - error=predicted_flash - actual_flash, - ) + # Languages share a build directory, so a failed link leaves the previous + # language's firmware.size.json behind; only trust it after a successful build. + actual_flash, actual_region = None, None + if make_result.returncode == 0: + actual_flash, actual_region = flash_usage(board_info["port"], build_dir) + record_size( + board_info["port"], + board, + language, + actual_flash, + actual_region, + "succeeded" if make_result.returncode == 0 else "failed", + "measured", + ) + + if predicted_flash is not None and actual_flash is not None: + print( + "Flash size check {board} {language}: predicted {predicted}," + " actual {actual}, error {error:+d}".format( + board=board, + language=language, + predicted=predicted_flash, + actual=actual_flash, + error=predicted_flash - actual_flash, ) + ) # Flush so we will see something before 10 minutes has passed. print(flush=True) diff --git a/tools/ci_firmware_sizes.py b/tools/ci_firmware_sizes.py new file mode 100755 index 00000000000..182a7f6ed71 --- /dev/null +++ b/tools/ci_firmware_sizes.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2014 MicroPython & CircuitPython contributors (https://github.com/adafruit/circuitpython/graphs/contributors) +# +# SPDX-License-Identifier: MIT + +"""Merge the per-board firmware size records of a CI run into one report. + +build_release_files.py writes sizes/.json for every board it builds, and the board +job uploads it as the artifact zz-sizes-. The build-ci job downloads those into one +directory and runs: + + ci_firmware_sizes.py + +which writes + + /sizes.json every board with its per-language builds and derived free flash + /sizes.html a sortable, filterable table of the same boards, data embedded + +and appends a short markdown summary of the fullest boards to $GITHUB_STEP_SUMMARY when +that is set, or to --summary FILE. The run description in the report comes from the +GITHUB_* environment variables; outside CI the report says so instead. +""" + +import argparse +import collections +import datetime +import html +import json +import os +import pathlib +import sys + +# Boards at or above this percent full are listed in the job summary. +SUMMARY_THRESHOLD = 97.0 +SUMMARY_MAX_ROWS = 40 + + +def load_records(records_dir): + """Read every sizes/.json under records_dir, keyed by board.""" + records = {} + for path in sorted(pathlib.Path(records_dir).rglob("*.json")): + with path.open("r") as f: + record = json.load(f) + if not isinstance(record, dict) or "languages" not in record: + print(f"Skipping {path}: not a size record", file=sys.stderr) + continue + records[record["board"]] = record + return records + + +def summarize(record): + """Add the derived fields the table and the summary sort and colour by.""" + region = record.get("region") + languages = record["languages"] + board = { + "port": record["port"], + "board": record["board"], + "region": region, + "en_US": (languages.get("en_US") or {}).get("used"), + "largest_language": None, + "largest_used": None, + "largest_source": None, + "min_free": None, + "pct": None, + "measured": sum(1 for b in languages.values() if b["source"] == "measured"), + "predicted": sum(1 for b in languages.values() if b["source"] == "predicted"), + "failed": sorted(lang for lang, b in languages.items() if b["status"] == "failed"), + "languages": languages, + } + sized = [(b["used"], lang) for lang, b in languages.items() if b["used"] is not None] + if sized: + used, lang = max(sized) + board["largest_language"] = lang + board["largest_used"] = used + board["largest_source"] = languages[lang]["source"] + if region: + board["min_free"] = region - used + board["pct"] = round(100.0 * used / region, 2) + return board + + +def run_info(): + """Where the numbers came from, from the variables GitHub Actions sets.""" + env = os.environ + info = { + "repository": env.get("GITHUB_REPOSITORY"), + "run_id": env.get("GITHUB_RUN_ID"), + "event": env.get("GITHUB_EVENT_NAME"), + "ref": env.get("GITHUB_REF_NAME"), + "sha": env.get("GITHUB_SHA"), + "url": None, + } + if info["repository"] and info["run_id"]: + server = env.get("GITHUB_SERVER_URL", "https://github.com") + info["url"] = f"{server}/{info['repository']}/actions/runs/{info['run_id']}" + return info + + +def describe_run(info): + """One sentence for the report header.""" + if not info["url"]: + return "Built outside GitHub Actions." + parts = [f"{info['event']} build" if info["event"] else "build"] + if info["ref"]: + parts.append(f"of {info['ref']}") + if info["sha"]: + parts.append(f"at {info['sha'][:10]}") + return " ".join(parts) + + +def sort_key(board): + # Fullest first; boards without a measurable region go last, alphabetically. + return (board["pct"] is None, -(board["pct"] or 0), board["board"]) + + +def write_json(boards, info, out_dir): + document = { + "generated": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + "run": info, + "boards": boards, + } + with (out_dir / "sizes.json").open("w") as f: + json.dump(document, f, indent=1) + f.write("\n") + + +HTML_TEMPLATE = r""" + + + + +Firmware Sizes + + + +

Firmware sizes

+

__RUN__ __COUNTS__ +"Largest" is the language build that uses the most flash, which is what has to fit. +A size in grey was predicted for a language that was not built. +Click a column header to sort.

+
+ + +
+
Free flash: + + + + + +
+
+
+ + + + + + + + + + + + +
PortBoardRegionen_US usedLargest langLargest usedMin free% fullLangs
+ + + +""" + + +def write_html(boards, info, out_dir): + if info["url"]: + run = 'From GitHub Actions run {run_id} ({desc}).'.format( + url=html.escape(info["url"], quote=True), + run_id=html.escape(info["run_id"]), + desc=html.escape(describe_run(info)), + ) + else: + run = html.escape(describe_run(info)) + with_region = sum(1 for b in boards if b["region"]) + counts = f"{len(boards)} boards, {with_region} with a measurable flash region." + # The table rows carry only what the page shows; the per-language detail is in + # sizes.json. "= SUMMARY_THRESHOLD] + if not full: + lines.append(f"No board is {SUMMARY_THRESHOLD:g}% full or more.") + return "\n".join(lines) + "\n" + + lines.append(f"Boards at least {SUMMARY_THRESHOLD:g}% full (largest language build):") + lines.append("") + lines.append("| Board | Region | Largest | Used | Free | % full |") + lines.append("|---|---:|---|---:|---:|---:|") + for b in full[:SUMMARY_MAX_ROWS]: + largest = b["largest_language"] + if b["largest_source"] == "predicted": + largest += " (predicted)" + lines.append( + f"| `{b['board']}` | {b['region']} | {largest} | {b['largest_used']} " + f"| {b['min_free']} | {b['pct']:.2f} |" + ) + if len(full) > SUMMARY_MAX_ROWS: + lines.append("") + lines.append(f"... and {len(full) - SUMMARY_MAX_ROWS} more.") + return "\n".join(lines) + "\n" + + +FREE_STEPS = [(256, "<256 B"), (1024, "<1 KiB"), (4096, "<4 KiB"), (16384, "<16 KiB")] + + +def print_ports(boards): + """Per-port counts of boards with little flash left, for the job log.""" + ports = collections.defaultdict(collections.Counter) + for b in boards: + c = ports[b["port"]] + c["boards"] += 1 + if b["min_free"] is None: + continue + for limit, label in FREE_STEPS: + if b["min_free"] < limit: + c[label] += 1 + break + labels = [label for _, label in FREE_STEPS] + print(f"{'port':16} {'boards':>6}" + "".join(f" {label:>8}" for label in labels)) + for name in sorted(ports): + c = ports[name] + print(f"{name:16} {c['boards']:6}" + "".join(f" {c[label]:8}" for label in labels)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("records_dir", help="directory holding the per-board size records") + parser.add_argument("out_dir", help="where to write sizes.json and sizes.html") + parser.add_argument( + "--summary", + default=os.environ.get("GITHUB_STEP_SUMMARY"), + help="append the markdown summary to this file (default: $GITHUB_STEP_SUMMARY)", + ) + args = parser.parse_args() + + records = load_records(args.records_dir) + boards = sorted((summarize(r) for r in records.values()), key=sort_key) + info = run_info() + + out_dir = pathlib.Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + write_json(boards, info, out_dir) + write_html(boards, info, out_dir) + + summary = markdown_summary(boards, info) + if args.summary: + with open(args.summary, "a") as f: + f.write(summary) + else: + print(summary) + print_ports(boards) + + +if __name__ == "__main__": + main() From 9410f7af681ed5381387a9f0ebeccc46b01f0593 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 16 Sep 2026 18:59:07 -0400 Subject: [PATCH 2/6] Upload the firmware size report to S3 `build-ci` copies the merged report to `bin/0-sizes/` on pushes to main and release branches and on published releases. The files are named `adafruit-circuitpython-sizes-.{json,html}` like the firmware, so the flat directory keeps a history that the usual version-glob cleanup prunes. `ci_firmware_sizes.py` takes the version from `--version` or `$CP_VERSION`, which `build-ci` now gets from the scheduler job. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 15 ++++++++++++++- tools/ci_firmware_sizes.py | 38 +++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d3c1c17267..0e262aa1e3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -241,9 +241,11 @@ jobs: build-ci: # Final gate for ruleset checks: always runs so the required check is # reported, and fails if any job (or matrix leg) failed or was cancelled. - needs: [tests, zephyr-tests, mpy-cross, mpy-cross-mac, docs, ports] + needs: [scheduler, tests, zephyr-tests, mpy-cross, mpy-cross-mac, docs, ports] if: ${{ always() }} runs-on: ubuntu-24.04 + env: + CP_VERSION: ${{ needs.scheduler.outputs.cp-version }} steps: # Firmware size report. Each board job uploads its sizes/.json as zz-sizes-; # merge them into one table and a job summary. This is reporting only, so none of it @@ -277,6 +279,17 @@ jobs: path: sizes-report if-no-files-found: ignore + # The action itself only uploads on pushes to main and release branches and on + # published releases. The files carry the version, so the directory keeps a history. + - name: Upload firmware size report to S3 + continue-on-error: true + uses: ./.github/actions/upload_aws + with: + source: sizes-report/ + destination: 0-sizes/ + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Check build results run: | echo "Job results: ${{ toJSON(needs.*.result) }}" diff --git a/tools/ci_firmware_sizes.py b/tools/ci_firmware_sizes.py index 182a7f6ed71..06bddaa57fd 100755 --- a/tools/ci_firmware_sizes.py +++ b/tools/ci_firmware_sizes.py @@ -14,10 +14,13 @@ which writes - /sizes.json every board with its per-language builds and derived free flash - /sizes.html a sortable, filterable table of the same boards, data embedded + /adafruit-circuitpython-sizes-.json + every board with its per-language builds and derived free flash + /adafruit-circuitpython-sizes-.html + a sortable, filterable table of the same boards, data embedded -and appends a short markdown summary of the fullest boards to $GITHUB_STEP_SUMMARY when +named like the firmware files. The version comes from --version or $CP_VERSION; without +one the files are plain sizes.json and sizes.html. The script also appends a short markdown summary of the fullest boards to $GITHUB_STEP_SUMMARY when that is set, or to --summary FILE. The run description in the report comes from the GITHUB_* environment variables; outside CI the report says so instead. """ @@ -80,10 +83,11 @@ def summarize(record): return board -def run_info(): +def run_info(version): """Where the numbers came from, from the variables GitHub Actions sets.""" env = os.environ info = { + "version": version, "repository": env.get("GITHUB_REPOSITORY"), "run_id": env.get("GITHUB_RUN_ID"), "event": env.get("GITHUB_EVENT_NAME"), @@ -102,7 +106,9 @@ def describe_run(info): if not info["url"]: return "Built outside GitHub Actions." parts = [f"{info['event']} build" if info["event"] else "build"] - if info["ref"]: + if info["version"]: + parts.append(f"of CircuitPython {info['version']}") + elif info["ref"]: parts.append(f"of {info['ref']}") if info["sha"]: parts.append(f"at {info['sha'][:10]}") @@ -114,13 +120,20 @@ def sort_key(board): return (board["pct"] is None, -(board["pct"] or 0), board["board"]) +def output_name(info, extension): + """The report's filename, matching the firmware files' pattern when a version is known.""" + if info["version"]: + return f"adafruit-circuitpython-sizes-{info['version']}.{extension}" + return f"sizes.{extension}" + + def write_json(boards, info, out_dir): document = { "generated": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "run": info, "boards": boards, } - with (out_dir / "sizes.json").open("w") as f: + with (out_dir / output_name(info, "json")).open("w") as f: json.dump(document, f, indent=1) f.write("\n") @@ -292,7 +305,7 @@ def write_html(boards, info, out_dir): with_region = sum(1 for b in boards if b["region"]) counts = f"{len(boards)} boards, {with_region} with a measurable flash region." # The table rows carry only what the page shows; the per-language detail is in - # sizes.json. " Date: Wed, 16 Sep 2026 23:41:27 -0400 Subject: [PATCH 3/6] Upload the size reports unzipped as 0-sizes.html and 0-sizes.json Each report is its own artifact with `archive: false`, so it opens directly instead of arriving in a zip; the file name is the artifact name. The S3 copies keep the versioned names. The job summary is now one line giving the board count and pointing at the two artifacts, instead of the abbreviated table. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-boards.yml | 2 +- .github/workflows/build.yml | 33 +++++++++--- tools/ci_firmware_sizes.py | 85 ++++++++---------------------- 3 files changed, 49 insertions(+), 71 deletions(-) diff --git a/.github/workflows/build-boards.yml b/.github/workflows/build-boards.yml index e9ac9f47a4c..016a2f2f32e 100644 --- a/.github/workflows/build-boards.yml +++ b/.github/workflows/build-boards.yml @@ -102,7 +102,7 @@ jobs: name: zz-sizes-${{ matrix.board }} path: sizes/${{ matrix.board }}.json if-no-files-found: ignore - # build-ci merges these into the zz-sizes artifact; delete them after one day. + # build-ci merges these into the 0-sizes reports; delete them after one day. retention-days: 1 - name: Upload to S3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e262aa1e3d..6804cf39867 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,22 +271,41 @@ jobs: continue-on-error: true run: python3 tools/ci_firmware_sizes.py sizes sizes-report - - name: Upload firmware size report + # Uploaded unzipped, one file per artifact, so each opens directly; with + # `archive: false` the file name is the artifact name. + - name: Upload firmware size report (HTML) continue-on-error: true uses: actions/upload-artifact@v7 with: - name: zz-sizes - path: sizes-report + path: sizes-report/0-sizes.html + archive: false + if-no-files-found: ignore + + - name: Upload firmware size report (JSON) + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + path: sizes-report/0-sizes.json + archive: false if-no-files-found: ignore # The action itself only uploads on pushes to main and release branches and on - # published releases. The files carry the version, so the directory keeps a history. - - name: Upload firmware size report to S3 + # published releases. The S3 names carry the version, so the directory keeps a history. + - name: Upload firmware size report to S3 (HTML) + continue-on-error: true + uses: ./.github/actions/upload_aws + with: + source: sizes-report/0-sizes.html + destination: 0-sizes/adafruit-circuitpython-sizes-${{ env.CP_VERSION }}.html + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Upload firmware size report to S3 (JSON) continue-on-error: true uses: ./.github/actions/upload_aws with: - source: sizes-report/ - destination: 0-sizes/ + source: sizes-report/0-sizes.json + destination: 0-sizes/adafruit-circuitpython-sizes-${{ env.CP_VERSION }}.json AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/tools/ci_firmware_sizes.py b/tools/ci_firmware_sizes.py index 06bddaa57fd..94a8609c234 100755 --- a/tools/ci_firmware_sizes.py +++ b/tools/ci_firmware_sizes.py @@ -14,15 +14,14 @@ which writes - /adafruit-circuitpython-sizes-.json - every board with its per-language builds and derived free flash - /adafruit-circuitpython-sizes-.html - a sortable, filterable table of the same boards, data embedded - -named like the firmware files. The version comes from --version or $CP_VERSION; without -one the files are plain sizes.json and sizes.html. The script also appends a short markdown summary of the fullest boards to $GITHUB_STEP_SUMMARY when -that is set, or to --summary FILE. The run description in the report comes from the -GITHUB_* environment variables; outside CI the report says so instead. + /0-sizes.json every board with its per-language builds and derived free flash + /0-sizes.html a sortable, filterable table of the same boards, data embedded + +The names match the artifacts build-ci uploads them as, one file each, unzipped. The +CircuitPython version, from --version or $CP_VERSION, goes into the reports. The script +also appends a line to $GITHUB_STEP_SUMMARY when that is set, or to --summary FILE, +saying how many boards were built and where the reports are. The run description in the +reports comes from the GITHUB_* environment variables; outside CI the report says so. """ import argparse @@ -34,9 +33,8 @@ import pathlib import sys -# Boards at or above this percent full are listed in the job summary. -SUMMARY_THRESHOLD = 97.0 -SUMMARY_MAX_ROWS = 40 +# Both reports share this name; build-ci uploads each as an artifact of the same name. +REPORT_NAME = "0-sizes" def load_records(records_dir): @@ -120,20 +118,13 @@ def sort_key(board): return (board["pct"] is None, -(board["pct"] or 0), board["board"]) -def output_name(info, extension): - """The report's filename, matching the firmware files' pattern when a version is known.""" - if info["version"]: - return f"adafruit-circuitpython-sizes-{info['version']}.{extension}" - return f"sizes.{extension}" - - def write_json(boards, info, out_dir): document = { "generated": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "run": info, "boards": boards, } - with (out_dir / output_name(info, "json")).open("w") as f: + with (out_dir / f"{REPORT_NAME}.json").open("w") as f: json.dump(document, f, indent=1) f.write("\n") @@ -313,52 +304,20 @@ def write_html(boards, info, out_dir): .replace("__COUNTS__", counts) .replace("__DATA__", data) ) - with (out_dir / output_name(info, "html")).open("w") as f: + with (out_dir / f"{REPORT_NAME}.html").open("w") as f: f.write(page) -def markdown_summary(boards, info): - """The fullest and the failed boards, for the job's step summary.""" - lines = ["### Firmware sizes", ""] +def markdown_summary(boards): + """One line for the job's step summary; the detail is in the reports.""" if not boards: - lines.append("No boards were built in this run.") - return "\n".join(lines) + "\n" - with_region = [b for b in boards if b["pct"] is not None] - lines.append( - f"{len(boards)} boards built, {len(with_region)} with a measurable flash region. " - f"The full table is in the `zz-sizes` artifact." + return "No boards were built in this run, so there is no firmware size report.\n" + with_region = sum(1 for b in boards if b["pct"] is not None) + return ( + f"{len(boards)} boards built, {with_region} with a measurable flash region. " + f"The complete firmware size reports are in the `{REPORT_NAME}.html` and " + f"`{REPORT_NAME}.json` artifacts below.\n" ) - lines.append("") - - failed = [b for b in boards if b["failed"]] - if failed: - lines.append("Builds that failed:") - lines.append("") - for b in failed: - lines.append(f"- `{b['board']}`: {' '.join(b['failed'])}") - lines.append("") - - full = [b for b in with_region if b["pct"] >= SUMMARY_THRESHOLD] - if not full: - lines.append(f"No board is {SUMMARY_THRESHOLD:g}% full or more.") - return "\n".join(lines) + "\n" - - lines.append(f"Boards at least {SUMMARY_THRESHOLD:g}% full (largest language build):") - lines.append("") - lines.append("| Board | Region | Largest | Used | Free | % full |") - lines.append("|---|---:|---|---:|---:|---:|") - for b in full[:SUMMARY_MAX_ROWS]: - largest = b["largest_language"] - if b["largest_source"] == "predicted": - largest += " (predicted)" - lines.append( - f"| `{b['board']}` | {b['region']} | {largest} | {b['largest_used']} " - f"| {b['min_free']} | {b['pct']:.2f} |" - ) - if len(full) > SUMMARY_MAX_ROWS: - lines.append("") - lines.append(f"... and {len(full) - SUMMARY_MAX_ROWS} more.") - return "\n".join(lines) + "\n" FREE_STEPS = [(256, "<256 B"), (1024, "<1 KiB"), (4096, "<4 KiB"), (16384, "<16 KiB")] @@ -390,7 +349,7 @@ def main(): parser.add_argument( "--version", default=os.environ.get("CP_VERSION"), - help="CircuitPython version for the report filenames (default: $CP_VERSION)", + help="CircuitPython version named in the reports (default: $CP_VERSION)", ) parser.add_argument( "--summary", @@ -408,7 +367,7 @@ def main(): write_json(boards, info, out_dir) write_html(boards, info, out_dir) - summary = markdown_summary(boards, info) + summary = markdown_summary(boards) if args.summary: with open(args.summary, "a") as f: f.write(summary) From 7435c96aed4a4ffe1a7b798c1d14f7624c85a5e4 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Thu, 17 Sep 2026 09:36:31 -0400 Subject: [PATCH 4/6] Fetch the size records by exact artifact name `actions/download-artifact` matches its pattern against a listing of the run's artifacts that stops at 1000, and a full build has about 1350, so the report silently covered 486 of 673 boards. `ci_download_sizes.mjs` looks each `zz-sizes-` up by name with `@actions/artifact`, from the board list the scheduler produced, and reports any board whose record is missing as a run annotation. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 13 ++++++---- .gitignore | 1 + tools/ci_download_sizes.mjs | 52 +++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100755 tools/ci_download_sizes.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6804cf39867..daa209c8672 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -259,13 +259,16 @@ jobs: fetch-depth: 1 persist-credentials: false + # actions/download-artifact matches a pattern against a listing of the run's + # artifacts that stops at 1000, and a full build has more. The script fetches each + # board's record by exact name instead, from the board list the scheduler produced. - name: Download firmware sizes continue-on-error: true - uses: actions/download-artifact@v8 - with: - pattern: zz-sizes-* - path: sizes - merge-multiple: true + env: + BOARDS: ${{ needs.scheduler.outputs.ports }} + run: | + npm install --no-save --no-package-lock --no-audit --no-fund @actions/artifact@6 + node tools/ci_download_sizes.mjs sizes - name: Summarize firmware sizes continue-on-error: true diff --git a/.gitignore b/.gitignore index ed64a99deb1..ec4811a6e30 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ dist/ build/ bin/ sizes/ +node_modules/ circuitpython-stubs/ test-stubs/ build-*/ diff --git a/tools/ci_download_sizes.mjs b/tools/ci_download_sizes.mjs new file mode 100755 index 00000000000..0a5651c6af2 --- /dev/null +++ b/tools/ci_download_sizes.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +// SPDX-FileCopyrightText: 2014 MicroPython & CircuitPython contributors (https://github.com/adafruit/circuitpython/graphs/contributors) +// +// SPDX-License-Identifier: MIT + +// Download every board's firmware size record from this run's artifacts, by exact name. +// +// build_release_files.py records a board's sizes in sizes/.json and the board job +// uploads it as the artifact zz-sizes-. actions/download-artifact matches a name +// pattern against a listing of the run's artifacts, and that listing stops at 1000, which +// a full build exceeds. Looking each record up by name has no such limit and uses the same +// internal artifact API, so it costs nothing against the REST rate limit. +// +// Usage: BOARDS='' node ci_download_sizes.mjs +// Needs @actions/artifact where node can resolve it: `npm install` at the repository root. + +import { DefaultArtifactClient } from "@actions/artifact"; + +const outDir = process.argv[2] ?? "sizes"; +const spec = JSON.parse(process.env.BOARDS || "{}"); +const boards = (spec.ports ?? []).flatMap((port) => spec[port]); +const client = new DefaultArtifactClient(); +const missing = []; +let downloaded = 0; + +async function fetchRecord(board) { + try { + const { artifact } = await client.getArtifact(`zz-sizes-${board}`); + await client.downloadArtifact(artifact.id, { path: outDir }); + downloaded++; + } catch (error) { + missing.push(board); + console.log(`No size record for ${board}: ${error.message}`); + } +} + +// A few downloads at a time: enough to get through a full build in a minute or two, +// few enough not to trip the artifact service's throttling. +const queue = [...boards]; +await Promise.all( + Array.from({ length: 8 }, async () => { + while (queue.length) { + await fetchRecord(queue.shift()); + } + }), +); + +console.log(`Downloaded ${downloaded} of ${boards.length} size records to ${outDir}`); +if (missing.length) { + console.log(`::warning::No firmware size record for ${missing.length} board(s): ${missing.join(" ")}`); +} From 2682ba81d086bdd798b637dcafe20097cde4ef6f Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Thu, 17 Sep 2026 13:46:12 -0400 Subject: [PATCH 5/6] Run the size record download from github-script The artifact API token `ACTIONS_RUNTIME_TOKEN` is only given to action steps, not `run:` steps, so calling the script with `node` found no records at all. `ci_download_sizes.mjs` is now a module that an `actions/github-script` step imports and calls; the install of `@actions/artifact` stays a shell step. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 19 ++++++++-- tools/ci_download_sizes.mjs | 69 ++++++++++++++++++++----------------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daa209c8672..f0f57a74243 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -262,13 +262,26 @@ jobs: # actions/download-artifact matches a pattern against a listing of the run's # artifacts that stops at 1000, and a full build has more. The script fetches each # board's record by exact name instead, from the board list the scheduler produced. + # It runs from github-script because only action steps get the artifact API token. + - name: Install the artifact client + continue-on-error: true + run: npm install --no-save --no-package-lock --no-audit --no-fund @actions/artifact@6 + - name: Download firmware sizes continue-on-error: true + uses: actions/github-script@v9 env: BOARDS: ${{ needs.scheduler.outputs.ports }} - run: | - npm install --no-save --no-package-lock --no-audit --no-fund @actions/artifact@6 - node tools/ci_download_sizes.mjs sizes + with: + script: | + const { pathToFileURL } = require("node:url"); + const sizes = await import(pathToFileURL(`${process.env.GITHUB_WORKSPACE}/tools/ci_download_sizes.mjs`)); + await sizes.downloadSizes({ + boards: sizes.boardsFromSchedule(JSON.parse(process.env.BOARDS || "{}")), + outDir: "sizes", + info: core.info, + warning: core.warning, + }); - name: Summarize firmware sizes continue-on-error: true diff --git a/tools/ci_download_sizes.mjs b/tools/ci_download_sizes.mjs index 0a5651c6af2..4e199ab368c 100755 --- a/tools/ci_download_sizes.mjs +++ b/tools/ci_download_sizes.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - // SPDX-FileCopyrightText: 2014 MicroPython & CircuitPython contributors (https://github.com/adafruit/circuitpython/graphs/contributors) // // SPDX-License-Identifier: MIT @@ -12,41 +10,48 @@ // a full build exceeds. Looking each record up by name has no such limit and uses the same // internal artifact API, so it costs nothing against the REST rate limit. // -// Usage: BOARDS='' node ci_download_sizes.mjs -// Needs @actions/artifact where node can resolve it: `npm install` at the repository root. +// That API needs the runner's ACTIONS_RUNTIME_TOKEN, which only `uses:` action steps get, +// so build.yml calls downloadSizes() from an actions/github-script step rather than from +// `node` in a `run:` step. @actions/artifact must be installed where this file can resolve +// it: `npm install` at the repository root. import { DefaultArtifactClient } from "@actions/artifact"; -const outDir = process.argv[2] ?? "sizes"; -const spec = JSON.parse(process.env.BOARDS || "{}"); -const boards = (spec.ports ?? []).flatMap((port) => spec[port]); -const client = new DefaultArtifactClient(); -const missing = []; -let downloaded = 0; - -async function fetchRecord(board) { - try { - const { artifact } = await client.getArtifact(`zz-sizes-${board}`); - await client.downloadArtifact(artifact.id, { path: outDir }); - downloaded++; - } catch (error) { - missing.push(board); - console.log(`No size record for ${board}: ${error.message}`); - } +// The boards in the scheduler job's "ports" output: {: [boards...], ports: [...]}. +export function boardsFromSchedule(schedule) { + return (schedule.ports ?? []).flatMap((port) => schedule[port]); } -// A few downloads at a time: enough to get through a full build in a minute or two, -// few enough not to trip the artifact service's throttling. -const queue = [...boards]; -await Promise.all( - Array.from({ length: 8 }, async () => { - while (queue.length) { - await fetchRecord(queue.shift()); +export async function downloadSizes({ boards, outDir, info = console.log, warning = console.warn }) { + const client = new DefaultArtifactClient(); + const missing = []; + let downloaded = 0; + + async function fetchRecord(board) { + try { + const { artifact } = await client.getArtifact(`zz-sizes-${board}`); + await client.downloadArtifact(artifact.id, { path: outDir }); + downloaded++; + } catch (error) { + missing.push(board); + info(`No size record for ${board}: ${error.message}`); } - }), -); + } + + // A few downloads at a time: enough to get through a full build in a minute or two, + // few enough not to trip the artifact service's throttling. + const queue = [...boards]; + await Promise.all( + Array.from({ length: 8 }, async () => { + while (queue.length) { + await fetchRecord(queue.shift()); + } + }), + ); -console.log(`Downloaded ${downloaded} of ${boards.length} size records to ${outDir}`); -if (missing.length) { - console.log(`::warning::No firmware size record for ${missing.length} board(s): ${missing.join(" ")}`); + info(`Downloaded ${downloaded} of ${boards.length} size records to ${outDir}`); + if (missing.length) { + warning(`No firmware size record for ${missing.length} board(s): ${missing.join(" ")}`); + } + return { downloaded, missing }; } From 541fa36217d4247dcd28795bcd3b022c8179873d Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Thu, 17 Sep 2026 15:51:09 -0400 Subject: [PATCH 6/6] Start the size table with every board shown The free-flash filter defaulted to "under 1 KiB", which shows an empty table when no board is that tight, as on main today. Default to "all"; the colour bands still mark the tight boards. Co-Authored-By: Claude Fable 5.1 --- tools/ci_firmware_sizes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/ci_firmware_sizes.py b/tools/ci_firmware_sizes.py index 94a8609c234..ad8be014c21 100755 --- a/tools/ci_firmware_sizes.py +++ b/tools/ci_firmware_sizes.py @@ -191,10 +191,10 @@ def write_json(boards, info, out_dir):
Free flash: - + - +