Skip to content
Merged
68 changes: 68 additions & 0 deletions .github/scripts/extract-size-report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/bash
#
# Run the size tool on every built .elf and write a small JSON report of
# flash/RAM usage per target, consumed by ci-size-report.yml.
#
# Usage: extract-size-report.sh <build-dir> <output-json> [size-tool]
#
# flash = .text + .data (what's programmed into flash)
# ram = .data + .bss (what's reserved in RAM at runtime)
#
# Runs inside the (unprivileged) build job on the PR's own checkout, so a
# PR could in principle modify this script to misreport its own numbers.
# Accepted tradeoff: this feature is informational/non-gating, and a real
# overflow still fails the link step regardless of what this script says.

set -euo pipefail

BUILD_DIR=${1:?usage: extract-size-report.sh <build-dir> <output-json> [size-tool]}
OUTPUT_JSON=${2:?usage: extract-size-report.sh <build-dir> <output-json> [size-tool]}
SIZE_TOOL=${3:-}

if [ -z "$SIZE_TOOL" ]; then
if command -v arm-none-eabi-size >/dev/null 2>&1; then
SIZE_TOOL=arm-none-eabi-size
else
# cmake/arm-none-eabi-checks.cmake downloads its own toolchain into
# tools/ and adds it to PATH — but only inside that cmake process
# via set(ENV{PATH} ...), which doesn't persist to a later CI step's
# shell. Fall back to the same location CMake would have used,
# relative to the repo root (this script must be run from there).
SIZE_TOOL=$(compgen -G 'tools/arm-gnu-toolchain-*/bin/arm-none-eabi-size' 2>/dev/null | head -n1 || true)
if [ -z "$SIZE_TOOL" ]; then
echo "::error::arm-none-eabi-size not found on PATH or under tools/arm-gnu-toolchain-*/bin/" >&2
exit 1
fi
fi
fi

# CMake's RUNTIME_OUTPUT_DIRECTORY puts built executables under
# <build-dir>/bin/ (see cmake/main.cmake), not <build-dir> directly — search
# instead of assuming a fixed depth, in case that ever changes.
mapfile -t ELFS < <(find "$BUILD_DIR" -maxdepth 3 -name '*.elf' | sort)
if [ "${#ELFS[@]}" -eq 0 ]; then
echo "::warning::No .elf files found under $BUILD_DIR, writing empty size report"
echo '{}' > "$OUTPUT_JSON"
exit 0
fi

JQ_ARGS=()
for elf in "${ELFS[@]}"; do
target=$(basename "$elf" .elf)

# Berkeley format: " text data bss dec hex filename"
read -r text data bss _dec _hex _name < <("$SIZE_TOOL" -B "$elf" | tail -n1)

flash=$((text + data))
ram=$((data + bss))

JQ_ARGS+=(--argjson "entry_${#JQ_ARGS[@]}" "{\"target\":\"${target}\",\"flash\":${flash},\"ram\":${ram}}")
done
Comment thread
sensei-hacker marked this conversation as resolved.

# Build via jq rather than manual string concatenation, so the target name
# (an .elf basename, not otherwise validated) is JSON-escaped properly
# instead of relying on it never containing a special character.
jq -n "${JQ_ARGS[@]}" 'reduce $ARGS.named[] as $e ({}; .[$e.target] = {flash: $e.flash, ram: $e.ram})' \
> "$OUTPUT_JSON"

echo "Wrote size report for ${#ELFS[@]} target(s) to $OUTPUT_JSON"
19 changes: 19 additions & 0 deletions .github/scripts/merge-size-reports.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
#
# Merge multiple per-shard size-report.json files (see extract-size-report.sh)
# into one aggregate JSON, used by the "upload-artifacts" job in ci.yml.
#
# Usage: merge-size-reports.sh <output-json> <input-json>...

set -euo pipefail

OUTPUT_JSON=${1:?usage: merge-size-reports.sh <output-json> <input-json>...}
shift

if [ "$#" -eq 0 ]; then
echo '{}' > "$OUTPUT_JSON"
exit 0
fi

jq -s 'add' "$@" > "$OUTPUT_JSON"
echo "Merged $# size report(s) into $OUTPUT_JSON"
108 changes: 108 additions & 0 deletions .github/scripts/size-diff-comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Pure logic for the "PR RAM/Flash usage delta" comment: diffs a PR's
// size-report.json against a base-branch baseline and renders the markdown
// comment body. Deliberately has no network/filesystem/GitHub Actions
// dependency so it can be unit tested directly and reused (via require)
// from the actions/github-script step in ci-size-report.yml.
//
// Report shape: { "<target>": { "flash": <bytes>, "ram": <bytes> }, ... }

'use strict';

// 4 representative targets spanning flash/RAM size tiers (manager-approved
// set, 2026-08-17). Note MATEKF722 and MATEKF765 are both STM32F7 parts —
// "one per family" in the loose sense of distinct flash/RAM budgets, not
// one per silicon line. No AT32 target is covered; flagged back to the
// manager as a possible coverage gap, not decided unilaterally here.
const REPRESENTATIVE_TARGETS = ['MATEKF405', 'MATEKF722', 'MATEKF765', 'MATEKH743'];

// Below this magnitude a delta is noise (rounding/toolchain jitter), not a
// real change worth calling out.
const NOISE_THRESHOLD_BYTES = 32;

function formatDelta(deltaBytes, baseBytes) {
const sign = deltaBytes > 0 ? '+' : deltaBytes < 0 ? '' : '±';
const pct = baseBytes > 0 ? (deltaBytes / baseBytes) * 100 : 0;
const pctStr = baseBytes > 0 ? ` (${sign}${pct.toFixed(2)}%)` : '';
return `${sign}${deltaBytes} B${pctStr}`;
}

// Returns an array of row objects, one per representative target, each
// either a comparison row or a status row (missing from PR/baseline).
function diffSizeReports(prReport, baselineReport) {
return REPRESENTATIVE_TARGETS.map((target) => {
const pr = prReport[target];
const base = baselineReport ? baselineReport[target] : undefined;

if (!pr && !base) {
return { target, status: 'not-built' };
}
if (!pr) {
return { target, status: 'missing-from-pr' };
}
if (!base) {
return { target, status: 'no-baseline', flash: pr.flash, ram: pr.ram };
}

const flashDelta = pr.flash - base.flash;
const ramDelta = pr.ram - base.ram;
return {
target,
status: 'compared',
flash: pr.flash,
ram: pr.ram,
baseFlash: base.flash,
baseRam: base.ram,
flashDelta,
ramDelta,
notable: Math.abs(flashDelta) >= NOISE_THRESHOLD_BYTES || Math.abs(ramDelta) >= NOISE_THRESHOLD_BYTES,
};
});
}

// docLink: string URL to link, or null/undefined to omit the doc-link line.
function renderComment({ prReport, baselineReport, shortSha, docLink, marker }) {
const rows = diffSizeReports(prReport, baselineReport);
const lines = [marker, '**RAM / Flash usage vs. base branch** — commit `' + shortSha + '`', ''];

if (!baselineReport) {
lines.push(
'> No size baseline is available yet for this PR\'s base branch ' +
'(first run after this feature shipped, or a new branch). ' +
'This comment will show deltas once a baseline exists.',
''
);
}

const anyComparable = rows.some((r) => r.status === 'compared' || r.status === 'no-baseline');
if (anyComparable) {
lines.push('| Target | Flash Δ | RAM Δ |', '|---|---|---|');
for (const row of rows) {
if (row.status === 'compared') {
const flashCell = formatDelta(row.flashDelta, row.baseFlash);
const ramCell = formatDelta(row.ramDelta, row.baseRam);
const notableMark = row.notable ? ' ⚠️' : '';
lines.push(`| ${row.target}${notableMark} | ${flashCell} | ${ramCell} |`);
} else if (row.status === 'no-baseline') {
lines.push(`| ${row.target} | ${row.flash} B (no baseline) | ${row.ram} B (no baseline) |`);
} else if (row.status === 'missing-from-pr') {
lines.push(`| ${row.target} | not built by this PR | not built by this PR |`);
}
// 'not-built': omit entirely, nothing meaningful to say
}
lines.push('');
} else {
lines.push(
'_None of the representative targets (' + REPRESENTATIVE_TARGETS.join(', ') + ') ' +
'were built by this PR — no size comparison to show._',
''
);
}

if (docLink) {
lines.push(`See [RAM/flash optimization guide](${docLink}) for techniques to reduce usage.`, '');
}

return lines.join('\n').trimEnd() + '\n';
}

module.exports = { REPRESENTATIVE_TARGETS, NOISE_THRESHOLD_BYTES, diffSizeReports, renderComment, formatDelta };
Loading
Loading