Skip to content

Commit bbbdaf0

Browse files
committed
add a size report to Pull Requests
1 parent b941dab commit bbbdaf0

File tree

3 files changed

+348
-25
lines changed

3 files changed

+348
-25
lines changed

.github/workflows/main_matrix.yml

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -238,47 +238,150 @@ jobs:
238238
description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation"
239239
github-token: ${{ secrets.GITHUB_TOKEN }}
240240

241-
shame:
241+
firmware-size-report:
242242
if: github.repository == 'meshtastic/firmware'
243243
continue-on-error: true
244+
permissions:
245+
contents: read
246+
pull-requests: write
247+
actions: read
244248
runs-on: ubuntu-latest
245249
needs: [build]
246250
steps:
247251
- uses: actions/checkout@v6
248-
if: github.event_name == 'pull_request_target'
249252
with:
250-
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
251-
fetch-depth: 0
252-
- name: Download the current manifests
253+
ref: ${{github.event.pull_request.head.ref}}
254+
repository: ${{github.event.pull_request.head.repo.full_name}}
255+
256+
- name: Download current manifests
253257
uses: actions/download-artifact@v8
254258
with:
255-
path: ./manifests-new/
259+
path: ./manifests/
256260
pattern: manifest-*
257261
merge-multiple: true
258-
- name: Upload combined manifests for later commit and global stats crunching.
262+
263+
- name: Collect current firmware sizes
264+
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
265+
266+
- name: Upload size report artifact
259267
uses: actions/upload-artifact@v7
260-
id: upload-manifest
261268
with:
262-
name: manifests-${{ github.sha }}
269+
name: firmware-sizes-${{ github.sha }}
263270
overwrite: true
264-
path: manifests-new/*.mt.json
265-
- name: Find the merge base
271+
path: ./current-sizes.json
272+
retention-days: 90
273+
274+
- name: Download baseline sizes from develop
266275
if: github.event_name == 'pull_request_target'
267-
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
276+
continue-on-error: true
277+
id: baseline-develop
268278
env:
269-
base: ${{ github.base_ref }}
270-
head: ${{ github.sha }}
271-
# Currently broken (for-loop through EVERY artifact -- rate limiting)
272-
# - name: Download the old manifests
273-
# if: github.event_name == 'pull_request_target'
274-
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
275-
# env:
276-
# GH_TOKEN: ${{ github.token }}
277-
# merge_base: ${{ env.MERGE_BASE }}
278-
# repo: ${{ github.repository }}
279-
# - name: Do scan and post comment
280-
# if: github.event_name == 'pull_request_target'
281-
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
279+
GH_TOKEN: ${{ github.token }}
280+
run: |
281+
# Find the latest successful CI run on develop and get its firmware-sizes artifact
282+
RUN_ID=$(gh run list -R "${{ github.repository }}" \
283+
--workflow CI --branch develop --status success \
284+
--limit 1 --json databaseId --jq '.[0].databaseId')
285+
if [ -n "$RUN_ID" ]; then
286+
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
287+
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
288+
if [ -n "$ARTIFACT_NAME" ]; then
289+
gh run download "$RUN_ID" -R "${{ github.repository }}" \
290+
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
291+
cp ./baseline-develop/current-sizes.json ./develop-sizes.json
292+
echo "found=true" >> "$GITHUB_OUTPUT"
293+
else
294+
echo "found=false" >> "$GITHUB_OUTPUT"
295+
fi
296+
else
297+
echo "found=false" >> "$GITHUB_OUTPUT"
298+
fi
299+
300+
- name: Download baseline sizes from master
301+
if: github.event_name == 'pull_request_target'
302+
continue-on-error: true
303+
id: baseline-master
304+
env:
305+
GH_TOKEN: ${{ github.token }}
306+
run: |
307+
RUN_ID=$(gh run list -R "${{ github.repository }}" \
308+
--workflow CI --branch master --status success \
309+
--limit 1 --json databaseId --jq '.[0].databaseId')
310+
if [ -n "$RUN_ID" ]; then
311+
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
312+
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
313+
if [ -n "$ARTIFACT_NAME" ]; then
314+
gh run download "$RUN_ID" -R "${{ github.repository }}" \
315+
--name "$ARTIFACT_NAME" --dir ./baseline-master/
316+
cp ./baseline-master/current-sizes.json ./master-sizes.json
317+
echo "found=true" >> "$GITHUB_OUTPUT"
318+
else
319+
echo "found=false" >> "$GITHUB_OUTPUT"
320+
fi
321+
else
322+
echo "found=false" >> "$GITHUB_OUTPUT"
323+
fi
324+
325+
- name: Generate size comparison report
326+
if: github.event_name == 'pull_request_target'
327+
id: report
328+
run: |
329+
ARGS="./current-sizes.json"
330+
if [ -f ./develop-sizes.json ]; then
331+
ARGS="$ARGS --baseline develop:./develop-sizes.json"
332+
fi
333+
if [ -f ./master-sizes.json ]; then
334+
ARGS="$ARGS --baseline master:./master-sizes.json"
335+
fi
336+
REPORT=$(python3 bin/size_report.py $ARGS)
337+
if [ -z "$REPORT" ]; then
338+
echo "has_report=false" >> "$GITHUB_OUTPUT"
339+
else
340+
echo "has_report=true" >> "$GITHUB_OUTPUT"
341+
{
342+
echo '<!-- firmware-size-report -->'
343+
echo '# Firmware Size Report'
344+
echo ''
345+
echo "$REPORT"
346+
echo ''
347+
echo '---'
348+
echo "*Updated for ${{ github.sha }}*"
349+
} > ./size-report.md
350+
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
351+
fi
352+
353+
- name: Post or update PR comment
354+
if: github.event_name == 'pull_request_target' && steps.report.outputs.has_report == 'true'
355+
uses: actions/github-script@v8
356+
with:
357+
script: |
358+
const fs = require('fs');
359+
const marker = '<!-- firmware-size-report -->';
360+
const body = fs.readFileSync('./size-report.md', 'utf8');
361+
const prNumber = context.payload.pull_request.number;
362+
363+
const { data: comments } = await github.rest.issues.listComments({
364+
owner: context.repo.owner,
365+
repo: context.repo.repo,
366+
issue_number: prNumber,
367+
});
368+
369+
const existing = comments.find(c => c.body.includes(marker));
370+
if (existing) {
371+
await github.rest.issues.updateComment({
372+
owner: context.repo.owner,
373+
repo: context.repo.repo,
374+
comment_id: existing.id,
375+
body,
376+
});
377+
} else {
378+
await github.rest.issues.createComment({
379+
owner: context.repo.owner,
380+
repo: context.repo.repo,
381+
issue_number: prNumber,
382+
body,
383+
});
384+
}
282385
283386
release-artifacts:
284387
runs-on: ubuntu-latest

bin/collect_sizes.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env python3
2+
3+
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
4+
5+
import json
6+
import os
7+
import sys
8+
9+
10+
def collect_sizes(manifest_dir):
11+
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
12+
sizes = {}
13+
for fname in sorted(os.listdir(manifest_dir)):
14+
if not fname.endswith(".mt.json"):
15+
continue
16+
path = os.path.join(manifest_dir, fname)
17+
with open(path) as f:
18+
data = json.load(f)
19+
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
20+
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
21+
bin_size = None
22+
for entry in data.get("files", []):
23+
name = entry.get("name", "")
24+
if name.startswith("firmware-") and name.endswith(".bin"):
25+
bin_size = entry["bytes"]
26+
break
27+
# Fallback: any .bin that isn't ota/littlefs/bleota
28+
if bin_size is None:
29+
for entry in data.get("files", []):
30+
name = entry.get("name", "")
31+
if name.endswith(".bin") and not any(
32+
x in name for x in ["littlefs", "bleota", "ota"]
33+
):
34+
bin_size = entry["bytes"]
35+
break
36+
if bin_size is not None:
37+
sizes[board] = bin_size
38+
return sizes
39+
40+
41+
if __name__ == "__main__":
42+
if len(sys.argv) != 3:
43+
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
44+
sys.exit(1)
45+
46+
manifest_dir = sys.argv[1]
47+
output_path = sys.argv[2]
48+
49+
sizes = collect_sizes(manifest_dir)
50+
with open(output_path, "w") as f:
51+
json.dump(sizes, f, indent=2, sort_keys=True)
52+
53+
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")

bin/size_report.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#!/usr/bin/env python3
2+
3+
"""Compare firmware size reports and generate a markdown summary.
4+
5+
Usage:
6+
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
7+
8+
Examples:
9+
# Compare PR against develop and master baselines
10+
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
11+
12+
# Single baseline comparison
13+
size_report.py pr.json --baseline develop:develop.json
14+
15+
# No baselines — shows sizes with blank delta columns
16+
size_report.py pr.json
17+
"""
18+
19+
import argparse
20+
import json
21+
import sys
22+
23+
24+
def load_sizes(path):
25+
with open(path) as f:
26+
return json.load(f)
27+
28+
29+
def format_delta(n):
30+
"""Format byte delta with sign and human-friendly suffix."""
31+
sign = "+" if n > 0 else ""
32+
if abs(n) >= 1024:
33+
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
34+
return f"{sign}{n:,}"
35+
36+
37+
def generate_markdown(new_sizes, baselines, top_n=5):
38+
"""Generate a single table with current size and delta columns per baseline.
39+
40+
baselines: list of (label, old_sizes_dict), may be empty
41+
"""
42+
labels = [label for label, _ in baselines]
43+
44+
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
45+
rows = []
46+
for board in sorted(new_sizes):
47+
current = new_sizes[board]
48+
deltas = []
49+
for _, old_sizes in baselines:
50+
old = old_sizes.get(board)
51+
if old is not None:
52+
d = current - old
53+
deltas.append((d, abs(d)))
54+
else:
55+
deltas.append((None, 0))
56+
# Sort key: max abs delta across baselines (biggest changes first)
57+
max_abs = max((ad for _, ad in deltas), default=0)
58+
rows.append((board, current, deltas, max_abs))
59+
60+
rows.sort(key=lambda r: r[3], reverse=True)
61+
62+
# Summary line
63+
sections = []
64+
summary_parts = [f"{len(new_sizes)} targets"]
65+
for i, (label, old_sizes) in enumerate(baselines):
66+
increases = sum(
67+
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
68+
)
69+
decreases = sum(
70+
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
71+
)
72+
net = sum(
73+
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
74+
)
75+
parts = []
76+
if increases:
77+
parts.append(f"{increases} increased")
78+
if decreases:
79+
parts.append(f"{decreases} decreased")
80+
if parts:
81+
parts.append(f"net {format_delta(net)}")
82+
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
83+
else:
84+
summary_parts.append(f"vs `{label}`: no changes")
85+
86+
if not baselines:
87+
summary_parts.append("no baseline available yet")
88+
89+
sections.append(f"**{' | '.join(summary_parts)}**\n")
90+
91+
# Table header
92+
header = "| Target | Size |"
93+
separator = "|--------|-----:|"
94+
for label in labels:
95+
header += f" vs `{label}` |"
96+
separator += "----------:|"
97+
sections.append(header)
98+
sections.append(separator)
99+
100+
def format_row(board, current, deltas):
101+
row = f"| `{board}` | {current:,} |"
102+
for d, _ in deltas:
103+
if d is None:
104+
row += " |"
105+
elif d == 0:
106+
row += " 0 |"
107+
else:
108+
icon = "📈" if d > 0 else "📉"
109+
row += f" {icon} {format_delta(d)} |"
110+
return row
111+
112+
# Top N rows always visible
113+
top = rows[:top_n]
114+
for board, current, deltas, _ in top:
115+
sections.append(format_row(board, current, deltas))
116+
117+
# Remaining rows in expandable section
118+
rest = rows[top_n:]
119+
if rest:
120+
sections.append("")
121+
sections.append(
122+
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
123+
)
124+
sections.append(header)
125+
sections.append(separator)
126+
for board, current, deltas, _ in rest:
127+
sections.append(format_row(board, current, deltas))
128+
sections.append("\n</details>")
129+
130+
sections.append("")
131+
return "\n".join(sections)
132+
133+
134+
def main():
135+
parser = argparse.ArgumentParser(description="Compare firmware size reports")
136+
parser.add_argument("new_sizes", help="Path to new sizes JSON")
137+
parser.add_argument(
138+
"--baseline",
139+
action="append",
140+
default=[],
141+
metavar="LABEL:PATH",
142+
help="Baseline to compare against (e.g. develop:develop.json)",
143+
)
144+
parser.add_argument(
145+
"--top",
146+
type=int,
147+
default=5,
148+
help="Number of top changes to show before collapsing (default: 5)",
149+
)
150+
args = parser.parse_args()
151+
152+
new_sizes = load_sizes(args.new_sizes)
153+
154+
baselines = []
155+
for b in args.baseline:
156+
if ":" not in b:
157+
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
158+
sys.exit(1)
159+
label, path = b.split(":", 1)
160+
baselines.append((label, load_sizes(path)))
161+
162+
md = generate_markdown(new_sizes, baselines, top_n=args.top)
163+
print(md)
164+
165+
166+
if __name__ == "__main__":
167+
main()

0 commit comments

Comments
 (0)