Size change reporting (#10488)
* feat: add firmware size reporting and comparison scripts from #9860 * feat: add silence output feature to size_report and implement tests for size reporting scripts * rm shame * Fix baseline artifact paths in size report workflow * feat: add firmware size reporting and comparison scripts from #9860 * feat: add silence output feature to size_report and implement tests for size reporting scripts * rm shame * Fix baseline artifact paths in size report workflow * fix write permissions * tunk --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Austin <vidplace7@gmail.com>
This commit is contained in:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Austin
parent
de345939af
commit
5c1b6b2a23
@@ -0,0 +1,62 @@
|
|||||||
|
name: Post Firmware Size Comment
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
post-size-comment:
|
||||||
|
if: >
|
||||||
|
github.event.workflow_run.event == 'pull_request' &&
|
||||||
|
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||||
|
github.repository == 'meshtastic/firmware'
|
||||||
|
continue-on-error: true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download size report
|
||||||
|
id: download
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
name: size-report
|
||||||
|
path: ./
|
||||||
|
|
||||||
|
- name: Post or update PR comment
|
||||||
|
if: steps.download.outcome == 'success'
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const marker = '<!-- firmware-size-report -->';
|
||||||
|
const body = fs.readFileSync('./size-report.md', 'utf8');
|
||||||
|
const prNumber = parseInt(fs.readFileSync('./pr-number.txt', 'utf8').trim(), 10);
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -245,47 +245,126 @@ jobs:
|
|||||||
path: ./*.elf
|
path: ./*.elf
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
shame:
|
firmware-size-report:
|
||||||
if: github.repository == 'meshtastic/firmware'
|
if: github.repository == 'meshtastic/firmware'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [build]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
with:
|
- name: Download current manifests
|
||||||
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Download the current manifests
|
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ./manifests-new/
|
path: ./manifests/
|
||||||
pattern: manifest-*
|
pattern: manifest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Upload combined manifests for later commit and global stats crunching.
|
|
||||||
|
- name: Collect current firmware sizes
|
||||||
|
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
|
||||||
|
|
||||||
|
- name: Upload size report artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
id: upload-manifest
|
|
||||||
with:
|
with:
|
||||||
name: manifests-${{ github.sha }}
|
name: firmware-sizes-${{ github.sha }}
|
||||||
overwrite: true
|
overwrite: true
|
||||||
path: manifests-new/*.mt.json
|
path: ./current-sizes.json
|
||||||
- name: Find the merge base
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Download baseline sizes from develop
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
|
continue-on-error: true
|
||||||
|
id: baseline-develop
|
||||||
env:
|
env:
|
||||||
base: ${{ github.base_ref }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
head: ${{ github.sha }}
|
run: |
|
||||||
# Currently broken (for-loop through EVERY artifact -- rate limiting)
|
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||||
# - name: Download the old manifests
|
--workflow CI --branch develop --status success \
|
||||||
# if: github.event_name == 'pull_request'
|
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||||
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
|
if [ -n "$RUN_ID" ]; then
|
||||||
# env:
|
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||||
# GH_TOKEN: ${{ github.token }}
|
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
|
||||||
# merge_base: ${{ env.MERGE_BASE }}
|
if [ -n "$ARTIFACT_NAME" ]; then
|
||||||
# repo: ${{ github.repository }}
|
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||||
# - name: Do scan and post comment
|
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
|
||||||
# if: github.event_name == 'pull_request'
|
cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json
|
||||||
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
|
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download baseline sizes from master
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
|
id: baseline-master
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||||
|
--workflow CI --branch master --status success \
|
||||||
|
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||||
|
if [ -n "$RUN_ID" ]; then
|
||||||
|
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||||
|
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
|
||||||
|
if [ -n "$ARTIFACT_NAME" ]; then
|
||||||
|
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||||
|
--name "$ARTIFACT_NAME" --dir ./baseline-master/
|
||||||
|
cp "./baseline-master/${ARTIFACT_NAME}/current-sizes.json" ./master-sizes.json
|
||||||
|
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate size comparison report
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
id: report
|
||||||
|
run: |
|
||||||
|
ARGS="./current-sizes.json"
|
||||||
|
if [ -f ./develop-sizes.json ]; then
|
||||||
|
ARGS="$ARGS --baseline develop:./develop-sizes.json"
|
||||||
|
fi
|
||||||
|
if [ -f ./master-sizes.json ]; then
|
||||||
|
ARGS="$ARGS --baseline master:./master-sizes.json"
|
||||||
|
fi
|
||||||
|
REPORT=$(python3 bin/size_report.py $ARGS)
|
||||||
|
if [ -z "$REPORT" ]; then
|
||||||
|
echo "has_report=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_report=true" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo '<!-- firmware-size-report -->'
|
||||||
|
echo '# Firmware Size Report'
|
||||||
|
echo ''
|
||||||
|
echo "$REPORT"
|
||||||
|
echo ''
|
||||||
|
echo '---'
|
||||||
|
echo "*Updated for ${{ github.sha }}*"
|
||||||
|
} > ./size-report.md
|
||||||
|
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Save PR number
|
||||||
|
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||||
|
run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt
|
||||||
|
|
||||||
|
- name: Upload size report
|
||||||
|
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: size-report
|
||||||
|
path: |
|
||||||
|
./size-report.md
|
||||||
|
./pr-number.txt
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
release-artifacts:
|
release-artifacts:
|
||||||
permissions: # Needed for 'gh release upload'.
|
permissions: # Needed for 'gh release upload'.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sizes(manifest_dir):
|
||||||
|
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
|
||||||
|
sizes = {}
|
||||||
|
for fname in sorted(os.listdir(manifest_dir)):
|
||||||
|
if not fname.endswith(".mt.json"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(manifest_dir, fname)
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
|
||||||
|
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
|
||||||
|
bin_size = None
|
||||||
|
for entry in data.get("files", []):
|
||||||
|
name = entry.get("name", "")
|
||||||
|
if name.startswith("firmware-") and name.endswith(".bin"):
|
||||||
|
bin_size = entry["bytes"]
|
||||||
|
break
|
||||||
|
# Fallback: any .bin that isn't ota/littlefs/bleota
|
||||||
|
if bin_size is None:
|
||||||
|
for entry in data.get("files", []):
|
||||||
|
name = entry.get("name", "")
|
||||||
|
if name.endswith(".bin") and not any(
|
||||||
|
x in name for x in ["littlefs", "bleota", "ota"]
|
||||||
|
):
|
||||||
|
bin_size = entry["bytes"]
|
||||||
|
break
|
||||||
|
if bin_size is not None:
|
||||||
|
sizes[board] = bin_size
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
manifest_dir = sys.argv[1]
|
||||||
|
output_path = sys.argv[2]
|
||||||
|
|
||||||
|
sizes = collect_sizes(manifest_dir)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(sizes, f, indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
from github import Github
|
|
||||||
|
|
||||||
def parseFile(path):
|
|
||||||
with open(path, "r") as f:
|
|
||||||
data = json.loads(f)
|
|
||||||
for file in data["files"]:
|
|
||||||
if file["name"].endswith(".bin"):
|
|
||||||
return file["name"], file["bytes"]
|
|
||||||
|
|
||||||
if len(sys.argv) != 4:
|
|
||||||
print(f"expected usage: {sys.argv[0]} <PR number> <path to old-manifests> <path to new-manifests>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
pr_number = int(sys.argv[1])
|
|
||||||
|
|
||||||
token = os.getenv("GITHUB_TOKEN")
|
|
||||||
if not token:
|
|
||||||
raise EnvironmentError("GITHUB_TOKEN not found in environment.")
|
|
||||||
|
|
||||||
repo_name = os.getenv("GITHUB_REPOSITORY") # "owner/repo"
|
|
||||||
if not repo_name:
|
|
||||||
raise EnvironmentError("GITHUB_REPOSITORY not found in environment.")
|
|
||||||
|
|
||||||
oldFiles = sys.argv[2]
|
|
||||||
old = set(os.path.join(oldFiles, f) for f in os.listdir(oldFiles) if os.path.isfile(f))
|
|
||||||
newFiles = sys.argv[3]
|
|
||||||
new = set(os.path.join(newFiles, f) for f in os.listdir(newFiles) if os.path.isfile(f))
|
|
||||||
|
|
||||||
startMarkdown = "# Target Size Changes\n\n"
|
|
||||||
markdown = ""
|
|
||||||
|
|
||||||
newlyIntroduced = new - old
|
|
||||||
if len(newlyIntroduced) > 0:
|
|
||||||
markdown += "## Newly Introduced Targets\n\n"
|
|
||||||
# create a table
|
|
||||||
markdown += "| File | Size |\n"
|
|
||||||
markdown += "| ---- | ---- |\n"
|
|
||||||
for f in newlyIntroduced:
|
|
||||||
name, size = parseFile(f)
|
|
||||||
markdown += f"| `{name}` | {size}b |\n"
|
|
||||||
|
|
||||||
# do not log removed targets
|
|
||||||
# PRs only run a small subset of builds, so removed targets are not meaningful
|
|
||||||
# since they are very likely to just be not ran in PR CI
|
|
||||||
|
|
||||||
both = old & new
|
|
||||||
degradations = []
|
|
||||||
improvements = []
|
|
||||||
for f in both:
|
|
||||||
oldName, oldSize = parseFile(f)
|
|
||||||
_, newSize = parseFile(f)
|
|
||||||
if oldSize != newSize:
|
|
||||||
if newSize < oldSize:
|
|
||||||
improvements.append((oldName, oldSize, newSize))
|
|
||||||
else:
|
|
||||||
degradations.append((oldName, oldSize, newSize))
|
|
||||||
|
|
||||||
if len(degradations) > 0:
|
|
||||||
markdown += "\n## Degradation\n\n"
|
|
||||||
# create a table
|
|
||||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
|
||||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
|
||||||
for oldName, oldSize, newSize in degradations:
|
|
||||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
|
||||||
|
|
||||||
if len(improvements) > 0:
|
|
||||||
markdown += "\n## Improvement\n\n"
|
|
||||||
# create a table
|
|
||||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
|
||||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
|
||||||
for oldName, oldSize, newSize in improvements:
|
|
||||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
|
||||||
|
|
||||||
if len(markdown) == 0:
|
|
||||||
markdown = "No changes in target sizes detected."
|
|
||||||
|
|
||||||
g = Github(token)
|
|
||||||
repo = g.get_repo(repo_name)
|
|
||||||
pr = repo.get_pull(pr_number)
|
|
||||||
|
|
||||||
existing_comment = None
|
|
||||||
for comment in pr.get_issue_comments():
|
|
||||||
if comment.body.startswith(startMarkdown):
|
|
||||||
existing_comment = comment
|
|
||||||
break
|
|
||||||
|
|
||||||
final_markdown = startMarkdown + markdown
|
|
||||||
|
|
||||||
if existing_comment:
|
|
||||||
existing_comment.edit(body=final_markdown)
|
|
||||||
else:
|
|
||||||
pr.create_issue_comment(body=final_markdown)
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Compare firmware size reports and generate a markdown summary.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Compare PR against develop and master baselines
|
||||||
|
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
|
||||||
|
|
||||||
|
# Single baseline comparison
|
||||||
|
size_report.py pr.json --baseline develop:develop.json
|
||||||
|
|
||||||
|
# No baselines — shows sizes with blank delta columns
|
||||||
|
size_report.py pr.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def load_sizes(path):
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def format_delta(n):
|
||||||
|
"""Format byte delta with sign and human-friendly suffix."""
|
||||||
|
sign = "+" if n > 0 else ""
|
||||||
|
if abs(n) >= 1024:
|
||||||
|
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
|
||||||
|
return f"{sign}{n:,}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_markdown(new_sizes, baselines, top_n=5):
|
||||||
|
"""Generate a single table with current size and delta columns per baseline.
|
||||||
|
|
||||||
|
baselines: list of (label, old_sizes_dict), may be empty
|
||||||
|
"""
|
||||||
|
labels = [label for label, _ in baselines]
|
||||||
|
|
||||||
|
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
|
||||||
|
rows = []
|
||||||
|
for board in sorted(new_sizes):
|
||||||
|
current = new_sizes[board]
|
||||||
|
deltas = []
|
||||||
|
for _, old_sizes in baselines:
|
||||||
|
old = old_sizes.get(board)
|
||||||
|
if old is not None:
|
||||||
|
d = current - old
|
||||||
|
deltas.append((d, abs(d)))
|
||||||
|
else:
|
||||||
|
deltas.append((None, 0))
|
||||||
|
# Sort key: max abs delta across baselines (biggest changes first)
|
||||||
|
max_abs = max((ad for _, ad in deltas), default=0)
|
||||||
|
rows.append((board, current, deltas, max_abs))
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r[3], reverse=True)
|
||||||
|
|
||||||
|
# Summary line
|
||||||
|
sections = []
|
||||||
|
summary_parts = [f"{len(new_sizes)} targets"]
|
||||||
|
for i, (label, old_sizes) in enumerate(baselines):
|
||||||
|
increases = sum(
|
||||||
|
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
|
||||||
|
)
|
||||||
|
decreases = sum(
|
||||||
|
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
|
||||||
|
)
|
||||||
|
net = sum(
|
||||||
|
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
|
||||||
|
)
|
||||||
|
parts = []
|
||||||
|
if increases:
|
||||||
|
parts.append(f"{increases} increased")
|
||||||
|
if decreases:
|
||||||
|
parts.append(f"{decreases} decreased")
|
||||||
|
if parts:
|
||||||
|
parts.append(f"net {format_delta(net)}")
|
||||||
|
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
|
||||||
|
else:
|
||||||
|
summary_parts.append(f"vs `{label}`: no changes")
|
||||||
|
|
||||||
|
if not baselines:
|
||||||
|
summary_parts.append("no baseline available yet")
|
||||||
|
|
||||||
|
sections.append(f"**{' | '.join(summary_parts)}**\n")
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
header = "| Target | Size |"
|
||||||
|
separator = "|--------|-----:|"
|
||||||
|
for label in labels:
|
||||||
|
header += f" vs `{label}` |"
|
||||||
|
separator += "----------:|"
|
||||||
|
sections.append(header)
|
||||||
|
sections.append(separator)
|
||||||
|
|
||||||
|
def format_row(board, current, deltas):
|
||||||
|
row = f"| `{board}` | {current:,} |"
|
||||||
|
for d, _ in deltas:
|
||||||
|
if d is None:
|
||||||
|
row += " |"
|
||||||
|
elif d == 0:
|
||||||
|
row += " 0 |"
|
||||||
|
else:
|
||||||
|
icon = "📈" if d > 0 else "📉"
|
||||||
|
row += f" {icon} {format_delta(d)} |"
|
||||||
|
return row
|
||||||
|
|
||||||
|
# Top N rows always visible
|
||||||
|
top = rows[:top_n]
|
||||||
|
for board, current, deltas, _ in top:
|
||||||
|
sections.append(format_row(board, current, deltas))
|
||||||
|
|
||||||
|
# Remaining rows in expandable section
|
||||||
|
rest = rows[top_n:]
|
||||||
|
if rest:
|
||||||
|
sections.append("")
|
||||||
|
sections.append(
|
||||||
|
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
|
||||||
|
)
|
||||||
|
sections.append(header)
|
||||||
|
sections.append(separator)
|
||||||
|
for board, current, deltas, _ in rest:
|
||||||
|
sections.append(format_row(board, current, deltas))
|
||||||
|
sections.append("\n</details>")
|
||||||
|
|
||||||
|
sections.append("")
|
||||||
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Compare firmware size reports")
|
||||||
|
parser.add_argument("new_sizes", help="Path to new sizes JSON")
|
||||||
|
parser.add_argument(
|
||||||
|
"--baseline",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="LABEL:PATH",
|
||||||
|
help="Baseline to compare against (e.g. develop:develop.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--top",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="Number of top changes to show before collapsing (default: 5)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
new_sizes = load_sizes(args.new_sizes)
|
||||||
|
|
||||||
|
# Silence output when no targets were built — repo maintainer choice
|
||||||
|
if not new_sizes:
|
||||||
|
return
|
||||||
|
|
||||||
|
baselines = []
|
||||||
|
for b in args.baseline:
|
||||||
|
if ":" not in b:
|
||||||
|
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
label, path = b.split(":", 1)
|
||||||
|
baselines.append((label, load_sizes(path)))
|
||||||
|
|
||||||
|
md = generate_markdown(new_sizes, baselines, top_n=args.top)
|
||||||
|
print(md)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Tests for bin/collect_sizes.py and bin/size_report.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "bin")
|
||||||
|
|
||||||
|
|
||||||
|
def make_manifest(target, firmware_bytes, extra_files=None):
|
||||||
|
"""Create a minimal .mt.json manifest dict."""
|
||||||
|
files = [{"name": f"firmware-{target}-2.6.0.bin", "bytes": firmware_bytes}]
|
||||||
|
if extra_files:
|
||||||
|
files.extend(extra_files)
|
||||||
|
return {
|
||||||
|
"platformioTarget": target,
|
||||||
|
"version": "2.6.0.test",
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifests(tmpdir, manifests):
|
||||||
|
"""Write manifest dicts as .mt.json files into tmpdir."""
|
||||||
|
for target, data in manifests.items():
|
||||||
|
path = os.path.join(tmpdir, f"firmware-{target}.mt.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
|
||||||
|
def run_script(script, args):
|
||||||
|
"""Run a Python script and return (returncode, stdout, stderr)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, os.path.join(SCRIPTS_DIR, script)] + args,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_sizes_basic():
|
||||||
|
"""collect_sizes picks up firmware-*.bin entries from manifests."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
outfile = os.path.join(tmpdir, "sizes.json")
|
||||||
|
manifests = {
|
||||||
|
"heltec-v3": make_manifest("heltec-v3", 1048576),
|
||||||
|
"rak4631": make_manifest("rak4631", 524288),
|
||||||
|
"tbeam": make_manifest("tbeam", 786432),
|
||||||
|
}
|
||||||
|
write_manifests(tmpdir, manifests)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||||
|
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||||
|
assert "3 targets" in stdout
|
||||||
|
|
||||||
|
with open(outfile) as f:
|
||||||
|
sizes = json.load(f)
|
||||||
|
assert sizes == {"heltec-v3": 1048576, "rak4631": 524288, "tbeam": 786432}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_sizes_fallback_bin():
|
||||||
|
"""collect_sizes falls back to non-firmware-prefixed .bin if no firmware-*.bin."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
outfile = os.path.join(tmpdir, "sizes.json")
|
||||||
|
# Manifest with only a generic .bin (no firmware- prefix)
|
||||||
|
data = {
|
||||||
|
"platformioTarget": "custom-board",
|
||||||
|
"files": [
|
||||||
|
{"name": "littlefs-custom-board.bin", "bytes": 100000},
|
||||||
|
{"name": "custom-board.bin", "bytes": 500000},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
path = os.path.join(tmpdir, "firmware-custom-board.mt.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||||
|
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||||
|
|
||||||
|
with open(outfile) as f:
|
||||||
|
sizes = json.load(f)
|
||||||
|
assert sizes == {"custom-board": 500000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_sizes_skips_ota_littlefs():
|
||||||
|
"""collect_sizes ignores ota/littlefs/bleota .bin files in fallback."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
outfile = os.path.join(tmpdir, "sizes.json")
|
||||||
|
data = {
|
||||||
|
"platformioTarget": "board-x",
|
||||||
|
"files": [
|
||||||
|
{"name": "littlefs-board-x.bin", "bytes": 100000},
|
||||||
|
{"name": "bleota-board-x.bin", "bytes": 50000},
|
||||||
|
{"name": "mt-board-x-ota.bin", "bytes": 60000},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
path = os.path.join(tmpdir, "firmware-board-x.mt.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||||
|
assert rc == 0
|
||||||
|
with open(outfile) as f:
|
||||||
|
sizes = json.load(f)
|
||||||
|
# No valid firmware .bin found, board should be absent
|
||||||
|
assert sizes == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_sizes_ignores_non_mt_json():
|
||||||
|
"""collect_sizes skips non .mt.json files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
outfile = os.path.join(tmpdir, "sizes.json")
|
||||||
|
# Write a valid manifest
|
||||||
|
manifests = {"rak4631": make_manifest("rak4631", 500000)}
|
||||||
|
write_manifests(tmpdir, manifests)
|
||||||
|
# Write a decoy file
|
||||||
|
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||||
|
f.write("not a manifest")
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||||
|
assert rc == 0
|
||||||
|
with open(outfile) as f:
|
||||||
|
sizes = json.load(f)
|
||||||
|
assert list(sizes.keys()) == ["rak4631"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_no_baseline():
|
||||||
|
"""size_report with no baselines shows sizes only."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sizes_file = os.path.join(tmpdir, "new.json")
|
||||||
|
with open(sizes_file, "w") as f:
|
||||||
|
json.dump({"heltec-v3": 1000000, "rak4631": 500000}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script("size_report.py", [sizes_file])
|
||||||
|
assert rc == 0, f"size_report failed: {stderr}"
|
||||||
|
assert "2 targets" in stdout
|
||||||
|
assert "no baseline available yet" in stdout
|
||||||
|
assert "`heltec-v3`" in stdout
|
||||||
|
assert "`rak4631`" in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_with_baseline():
|
||||||
|
"""size_report shows deltas against a baseline."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
new_file = os.path.join(tmpdir, "new.json")
|
||||||
|
old_file = os.path.join(tmpdir, "old.json")
|
||||||
|
with open(new_file, "w") as f:
|
||||||
|
json.dump({"heltec-v3": 1050000, "rak4631": 500000, "tbeam": 800000}, f)
|
||||||
|
with open(old_file, "w") as f:
|
||||||
|
json.dump({"heltec-v3": 1000000, "rak4631": 500000, "tbeam": 810000}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script(
|
||||||
|
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||||
|
)
|
||||||
|
assert rc == 0, f"size_report failed: {stderr}"
|
||||||
|
assert "3 targets" in stdout
|
||||||
|
assert "1 increased" in stdout
|
||||||
|
assert "1 decreased" in stdout
|
||||||
|
# heltec-v3 grew by 50000
|
||||||
|
assert "📈" in stdout
|
||||||
|
# tbeam shrank by 10000
|
||||||
|
assert "📉" in stdout
|
||||||
|
# rak4631 unchanged
|
||||||
|
assert "vs `develop`" in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_multiple_baselines():
|
||||||
|
"""size_report handles multiple baselines."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
new_file = os.path.join(tmpdir, "new.json")
|
||||||
|
dev_file = os.path.join(tmpdir, "develop.json")
|
||||||
|
master_file = os.path.join(tmpdir, "master.json")
|
||||||
|
with open(new_file, "w") as f:
|
||||||
|
json.dump({"board-a": 100000}, f)
|
||||||
|
with open(dev_file, "w") as f:
|
||||||
|
json.dump({"board-a": 95000}, f)
|
||||||
|
with open(master_file, "w") as f:
|
||||||
|
json.dump({"board-a": 90000}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script(
|
||||||
|
"size_report.py",
|
||||||
|
[new_file, "--baseline", f"develop:{dev_file}", "--baseline", f"master:{master_file}"],
|
||||||
|
)
|
||||||
|
assert rc == 0, f"size_report failed: {stderr}"
|
||||||
|
assert "vs `develop`" in stdout
|
||||||
|
assert "vs `master`" in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_new_target_no_baseline_entry():
|
||||||
|
"""size_report handles targets not present in baseline (new boards)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
new_file = os.path.join(tmpdir, "new.json")
|
||||||
|
old_file = os.path.join(tmpdir, "old.json")
|
||||||
|
with open(new_file, "w") as f:
|
||||||
|
json.dump({"new-board": 300000, "existing": 500000}, f)
|
||||||
|
with open(old_file, "w") as f:
|
||||||
|
json.dump({"existing": 500000}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script(
|
||||||
|
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||||
|
)
|
||||||
|
assert rc == 0, f"size_report failed: {stderr}"
|
||||||
|
assert "`new-board`" in stdout
|
||||||
|
assert "no changes" in stdout # only existing is compared, delta=0
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_all_unchanged():
|
||||||
|
"""size_report shows 'no changes' when all sizes match."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||||
|
with open(sizes_file, "w") as f:
|
||||||
|
json.dump({"board-a": 100000, "board-b": 200000}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script(
|
||||||
|
"size_report.py", [sizes_file, "--baseline", f"develop:{sizes_file}"]
|
||||||
|
)
|
||||||
|
assert rc == 0, f"size_report failed: {stderr}"
|
||||||
|
assert "no changes" in stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_sizes_bad_args():
|
||||||
|
"""collect_sizes exits with error on wrong arg count."""
|
||||||
|
rc, stdout, stderr = run_script("collect_sizes.py", [])
|
||||||
|
assert rc == 1
|
||||||
|
assert "Usage" in stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_size_report_bad_baseline_format():
|
||||||
|
"""size_report exits with error on malformed --baseline."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||||
|
with open(sizes_file, "w") as f:
|
||||||
|
json.dump({"x": 1}, f)
|
||||||
|
|
||||||
|
rc, stdout, stderr = run_script(
|
||||||
|
"size_report.py", [sizes_file, "--baseline", "no-colon-here"]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "LABEL:PATH" in stderr
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
tests = [v for k, v in globals().items() if k.startswith("test_")]
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
print(f" PASS: {test.__name__}")
|
||||||
|
passed += 1
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f" FAIL: {test.__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {test.__name__}: {type(e).__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
Reference in New Issue
Block a user