CI Size Report #194
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI Size Report | |
| # Uses workflow_run (rather than pull_request/push directly) so that | |
| # secrets are available even for PRs from forks — same reasoning as | |
| # pr-test-builds.yml. | |
| # | |
| # Listens to two different upstream workflows, for two different jobs: | |
| # - publish-baseline listens for "Build pre-release" (nightly-build.yml), | |
| # NOT "Build firmware" directly. ci.yml's own `on: push:` trigger is | |
| # broken (a branches: list with only a negative pattern matches | |
| # nothing — confirmed empirically, zero push-triggered "Build firmware" | |
| # runs exist). "Build pre-release" is push-triggered correctly and | |
| # already invokes ci.yml's jobs via `uses:` (workflow_call) as part of | |
| # building nightly releases — piggybacking here costs nothing extra, | |
| # no second build. Persists the size report as release assets in the | |
| # companion iNavFlight/pr-test-builds repo, so PR runs never need to | |
| # rebuild the base branch to get a comparison point. Two assets are | |
| # written per push: size-baseline-<BRANCH> (latest-tip pointer, kept | |
| # for backward compatibility) and size-baseline-<COMMIT_SHA> (primary — | |
| # PR comparisons key off the exact base commit). Per-commit baselines | |
| # are pruned to the newest 50 per branch (plus a global cap) so the | |
| # companion repo doesn't grow unbounded; baselines for since-deleted | |
| # branches aren't otherwise cleaned up (same known limitation | |
| # pr-test-builds has for old PR releases). | |
| # - pr-comment listens for "Build firmware" (ci.yml) directly — PR builds | |
| # genuinely do trigger it via `pull_request`, that part isn't broken. | |
| # Resolves the PR's TRUE base commit (merge-base of head and base ref), | |
| # fetches the per-commit baseline for it (falling back to the nearest | |
| # ancestor commit that has one), diffs the 4 representative targets, | |
| # and posts/updates a PR comment showing which baseline was used. | |
| # | |
| # Requires the same repository secret PR_BUILDS_TOKEN as pr-test-builds.yml | |
| # (Contents: write access to iNavFlight/pr-test-builds). | |
| on: | |
| workflow_run: | |
| workflows: ["Build firmware", "Build pre-release"] | |
| types: [completed] | |
| jobs: | |
| publish-baseline: | |
| runs-on: ubuntu-latest | |
| if: > | |
| github.event.workflow_run.name == 'Build pre-release' && | |
| github.event.workflow_run.event == 'push' | |
| concurrency: | |
| group: size-baseline-${{ github.event.workflow_run.head_branch }} | |
| cancel-in-progress: true | |
| permissions: | |
| actions: read # to download artifacts and query job conclusions from the triggering workflow run | |
| steps: | |
| # Don't gate on github.event.workflow_run.conclusion: "Build | |
| # pre-release" also runs a separate Release job (nightly upload to | |
| # iNavFlight/inav-nightly) that can fail for reasons unrelated to the | |
| # build itself (e.g. an expired NIGHTLY_TOKEN) and drags the whole | |
| # run's conclusion to failure even when the build succeeded and | |
| # produced the artifacts we actually need. Check the specific job | |
| # that produces them instead. | |
| - name: Check build job succeeded | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| run: | | |
| CONCLUSION=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs" --paginate \ | |
| --jq '.jobs[] | select(.name == "build / upload-artifacts") | .conclusion') | |
| if [ -z "$CONCLUSION" ]; then | |
| # Job not found at all usually means the caller/callee job names | |
| # changed (e.g. nightly-build.yml's "build" job or ci.yml's | |
| # "upload-artifacts" job got renamed) — that's a workflow | |
| # structure mismatch, not an expected build failure, and is | |
| # exactly the kind of thing that silently broke baseline | |
| # publishing before. Warn louder than a plain failed build. | |
| echo "::warning::build / upload-artifacts job not found in run ${RUN_ID} — job name may have changed, skipping baseline publish" | |
| echo "proceed=false" >> "$GITHUB_OUTPUT" | |
| elif [ "$CONCLUSION" != "success" ]; then | |
| echo "::notice::build / upload-artifacts did not succeed (conclusion: ${CONCLUSION}), skipping baseline publish" | |
| echo "proceed=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "proceed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Download size report | |
| if: steps.check.outputs.proceed == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: size-report | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download branch name | |
| if: steps.check.outputs.proceed == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: branch-name | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Publish baseline | |
| if: steps.check.outputs.proceed == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.PR_BUILDS_TOKEN }} | |
| COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| BRANCH=$(cat branch.txt) | |
| # Publishes size-baseline-<BRANCH> (latest-tip pointer) and | |
| # size-baseline-<COMMIT_SHA> (primary), then prunes old per-commit | |
| # baselines. Never delete+recreate the release: that leaves a | |
| # window where the release doesn't exist at all, which a concurrent | |
| # PR's "Fetch base branch baseline" step could hit and misreport as | |
| # "no baseline available yet". Assets are replaced in place | |
| # (--clobber) on later pushes — the release/tag stays continuously | |
| # resolvable. | |
| bash .github/scripts/publish-size-baseline.sh \ | |
| iNavFlight/pr-test-builds \ | |
| "${BRANCH}" \ | |
| "${COMMIT_SHA}" \ | |
| size-report.json | |
| pr-comment: | |
| runs-on: ubuntu-latest | |
| if: > | |
| github.event.workflow_run.name == 'Build firmware' && | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.conclusion == 'success' | |
| concurrency: | |
| group: pr-size-report-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} | |
| cancel-in-progress: true | |
| permissions: | |
| actions: read | |
| issues: write | |
| pull-requests: write | |
| contents: read | |
| steps: | |
| # Checks out this workflow's own ref (the default branch — workflow_run | |
| # always runs the workflow file from the default branch), NOT the PR's | |
| # head. We only need our own trusted .github/scripts/ here; the PR's | |
| # untrusted code is never checked out in this privileged context. | |
| - uses: actions/checkout@v4 | |
| - name: Download PR number | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: pr-number | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download base ref | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: base-ref | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download PR size report | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: size-report | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Read PR number and base ref | |
| id: pr | |
| env: | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| PR_NUM=$(tr -dc '0-9' < pr_number.txt) | |
| if [ -z "$PR_NUM" ]; then | |
| echo "::error::Invalid PR number in artifact" | |
| exit 1 | |
| fi | |
| # base_ref.txt round-trips through an artifact produced by the | |
| # (less-trusted) PR build, so validate it against safe git-ref | |
| # characters before it's ever used to build a shell command or | |
| # release tag downstream — never trust artifact content blindly. | |
| BASE_REF=$(head -n1 base_ref.txt | tr -d '\r\n') | |
| if ! [[ "$BASE_REF" =~ ^[A-Za-z0-9._/-]{1,100}$ ]]; then | |
| echo "::error::Invalid base ref in artifact: $BASE_REF" | |
| exit 1 | |
| fi | |
| echo "number=${PR_NUM}" >> "$GITHUB_OUTPUT" | |
| echo "base_ref=${BASE_REF}" >> "$GITHUB_OUTPUT" | |
| echo "short_sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT" | |
| - name: Compute PR base commit (merge-base) | |
| id: mergebase | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BASE_REF: ${{ steps.pr.outputs.base_ref }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| # The PR's TRUE base commit is the merge-base of the PR head and | |
| # base ref — NOT the base branch's latest tip (comparing against | |
| # the tip includes unrelated changes merged after the PR forked). | |
| # The compare API resolves fork-PR head SHAs too (verified). | |
| # base_ref was already regex-validated in the step above; the | |
| # returned SHA is validated here before any use. | |
| MERGE_BASE=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_REF}...${HEAD_SHA}" \ | |
| --jq '.merge_base_commit.sha // empty' 2>/dev/null || true) | |
| if [[ "$MERGE_BASE" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "merge_base=${MERGE_BASE}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "::warning::could not determine merge-base for ${BASE_REF}...${HEAD_SHA}" | |
| echo "merge_base=" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Fetch size baseline for PR base commit | |
| id: baseline | |
| env: | |
| GH_TOKEN: ${{ secrets.PR_BUILDS_TOKEN }} | |
| BASE_REF: ${{ steps.pr.outputs.base_ref }} | |
| MERGE_BASE: ${{ steps.mergebase.outputs.merge_base }} | |
| run: | | |
| mkdir -p baseline | |
| if [ -n "${MERGE_BASE}" ]; then | |
| # Exact per-commit baseline for the merge-base first, then the | |
| # nearest ancestor commit that has one; the branch-tip baseline | |
| # is deliberately NOT a fallback (stale-delta bug). | |
| bash .github/scripts/fetch-size-baseline.sh \ | |
| "${GITHUB_REPOSITORY}" \ | |
| iNavFlight/pr-test-builds \ | |
| "${BASE_REF}" \ | |
| "${MERGE_BASE}" \ | |
| baseline >> "$GITHUB_OUTPUT" | |
| else | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Check for doc on PR head commit | |
| id: doc | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} | |
| run: | | |
| if gh api "repos/${HEAD_REPO}/contents/docs/development/ram-and-flash-optimization.md?ref=${HEAD_SHA}" >/dev/null 2>&1; then | |
| echo "link=https://github.com/${HEAD_REPO}/blob/${HEAD_SHA}/docs/development/ram-and-flash-optimization.md" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "link=" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Post or update PR comment | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| SHORT_SHA: ${{ steps.pr.outputs.short_sha }} | |
| BASELINE_FOUND: ${{ steps.baseline.outputs.found }} | |
| BASELINE_COMMIT: ${{ steps.baseline.outputs.baseline_commit_short }} | |
| BASELINE_EXACT: ${{ steps.baseline.outputs.baseline_exact }} | |
| DOC_LINK: ${{ steps.doc.outputs.link }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { renderComment } = require( | |
| path.join(process.env.GITHUB_WORKSPACE, '.github', 'scripts', 'size-diff-comment.js') | |
| ); | |
| const prNumber = parseInt(process.env.PR_NUMBER, 10); | |
| if (isNaN(prNumber)) throw new Error(`Invalid PR number: ${process.env.PR_NUMBER}`); | |
| const prReport = JSON.parse(fs.readFileSync('size-report.json', 'utf8')); | |
| const baselineReport = process.env.BASELINE_FOUND === 'true' | |
| ? JSON.parse(fs.readFileSync('baseline/size-report.json', 'utf8')) | |
| : null; | |
| const marker = '<!-- pr-size-diff -->'; | |
| const body = renderComment({ | |
| prReport, | |
| baselineReport, | |
| shortSha: process.env.SHORT_SHA, | |
| baselineCommit: process.env.BASELINE_COMMIT || null, | |
| baselineIsNearest: !!process.env.BASELINE_COMMIT && process.env.BASELINE_EXACT !== 'true', | |
| docLink: process.env.DOC_LINK || null, | |
| marker, | |
| }); | |
| const comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber } | |
| ); | |
| const existing = comments.find(c => c.user.type === 'Bot' && 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, | |
| }); | |
| } |