diff --git a/.github/workflows/firmware-size-comment.yml b/.github/workflows/firmware-size-comment.yml new file mode 100644 index 000000000..8677e6850 --- /dev/null +++ b/.github/workflows/firmware-size-comment.yml @@ -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 = ''; + 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, + }); + } diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index f46bf4652..374cbbe0a 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -245,47 +245,126 @@ jobs: path: ./*.elf retention-days: 30 - shame: + firmware-size-report: if: github.repository == 'meshtastic/firmware' continue-on-error: true + permissions: + contents: read + actions: read runs-on: ubuntu-latest needs: [build] steps: - uses: actions/checkout@v6 - if: github.event_name == 'pull_request' - with: - 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 + + - name: Download current manifests uses: actions/download-artifact@v8 with: - path: ./manifests-new/ + path: ./manifests/ pattern: manifest-* 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 - id: upload-manifest with: - name: manifests-${{ github.sha }} + name: firmware-sizes-${{ github.sha }} overwrite: true - path: manifests-new/*.mt.json - - name: Find the merge base + path: ./current-sizes.json + retention-days: 90 + + - name: Download baseline sizes from develop 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: - base: ${{ github.base_ref }} - head: ${{ github.sha }} - # Currently broken (for-loop through EVERY artifact -- rate limiting) - # - name: Download the old manifests - # if: github.event_name == 'pull_request' - # run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/ - # env: - # GH_TOKEN: ${{ github.token }} - # merge_base: ${{ env.MERGE_BASE }} - # repo: ${{ github.repository }} - # - name: Do scan and post comment - # if: github.event_name == 'pull_request' - # run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/ + GH_TOKEN: ${{ github.token }} + run: | + RUN_ID=$(gh run list -R "${{ github.repository }}" \ + --workflow CI --branch develop --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-develop/ + cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json + 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 '' + 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: permissions: # Needed for 'gh release upload'. diff --git a/bin/collect_sizes.py b/bin/collect_sizes.py new file mode 100644 index 000000000..b9d04c2fc --- /dev/null +++ b/bin/collect_sizes.py @@ -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]} ", 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}") diff --git a/bin/shame.py b/bin/shame.py deleted file mode 100644 index f2253bfdc..000000000 --- a/bin/shame.py +++ /dev/null @@ -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]} ") - 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) diff --git a/bin/size_report.py b/bin/size_report.py new file mode 100644 index 000000000..fd31d12af --- /dev/null +++ b/bin/size_report.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +"""Compare firmware size reports and generate a markdown summary. + +Usage: + size_report.py [--baseline