Track - Artifact Sizes #36649
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: Track - Artifact Sizes | |
| # Measures the on-disk size of SkiaSharp / HarfBuzzSharp packages and their native | |
| # binaries. ONE workflow, two modes chosen by a shared `resolve` job (mirroring the | |
| # benchmark tracker's resolve → … → report shape): | |
| # | |
| # * nightly (schedule / manual dispatch) | |
| # Measures the latest nightly + released reference versions from the feeds, persists | |
| # the time-series to the `aw-data` branch (sizes/index.json, via persist-aw-data.yml), | |
| # and emits the shared interactive perf dashboard. | |
| # | |
| # * pr (the AzDO nuget-publishing check run, or a manual dispatch with a build id) | |
| # Reacts to the Azure DevOps job that already packed the NuGets — no rebuild here. | |
| # Downloads that build's `nuget` artifact, measures every .nupkg + every file inside, | |
| # diffs against the latest nightly baseline on `aw-data`, and posts/updates a size-diff | |
| # PR comment. Informational only: never blocks, never writes to `aw-data`. | |
| # | |
| # Both modes share the measurement primitives in scripts/infra/perf/sizes/ (track.py's | |
| # measure_nupkg) and the render helpers in render_md.py. | |
| # | |
| # NOTE: `check_run` only triggers workflows on the default branch, so the pr mode is inert | |
| # until this file is merged to `main`. Validate it beforehand with `workflow_dispatch` and a | |
| # known PR `build_id`. | |
| on: | |
| schedule: | |
| - cron: "0 7 * * *" # Daily at 07:00 UTC (offset from the 08:00 benchmark tracker) | |
| workflow_dispatch: | |
| inputs: | |
| build_id: | |
| description: "PR mode: AzDO build id to measure. Leave empty for a nightly run." | |
| required: false | |
| type: string | |
| pr_number: | |
| description: "PR mode: PR number to comment on (optional; resolved from the build)." | |
| required: false | |
| type: string | |
| check_run: | |
| types: [completed] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # nightly runs are a singleton (never cancel a persist); pr runs are keyed per build. | |
| group: track-artifact-sizes-${{ github.event.check_run.id || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_id) || 'nightly' }} | |
| cancel-in-progress: false | |
| env: | |
| DATA_BRANCH: aw-data | |
| jobs: | |
| # --------------------------------------------------------------------------- # | |
| # Shared entrypoint: pick the mode and, for pr mode, resolve the build/PR. | |
| # --------------------------------------------------------------------------- # | |
| resolve: | |
| if: github.repository_owner == 'mono' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| mode: ${{ steps.resolve.outputs.mode }} | |
| build_id: ${{ steps.resolve.outputs.build_id }} | |
| pr_number: ${{ steps.resolve.outputs.pr_number }} | |
| build_url: ${{ steps.resolve.outputs.build_url }} | |
| steps: | |
| - name: Resolve mode + build | |
| id: resolve | |
| run: | | |
| python3 - <<'PY' | |
| import json, os, re, urllib.request | |
| PACKAGE_CHECK = "mono-SkiaSharp (Package NuGets Package NuGets)" | |
| event = os.environ["GITHUB_EVENT_NAME"] | |
| payload = json.load(open(os.environ["GITHUB_EVENT_PATH"])) | |
| mode, build_id, pr_number, build_url = "none", "", "", "" | |
| if event == "schedule": | |
| mode = "nightly" | |
| elif event == "workflow_dispatch": | |
| inp = payload.get("inputs") or {} | |
| build_id = (inp.get("build_id") or "").strip() | |
| if build_id: | |
| mode, pr_number = "pr", (inp.get("pr_number") or "").strip() | |
| else: | |
| mode = "nightly" | |
| elif event == "check_run": | |
| cr = payload.get("check_run") or {} | |
| app = (cr.get("app") or {}).get("slug") | |
| if (app == "azure-pipelines" | |
| and cr.get("name") == PACKAGE_CHECK | |
| and cr.get("conclusion") == "success"): | |
| m = re.search(r"[?&]buildId=(\d+)", cr.get("details_url") or "") | |
| if m: | |
| build_id = m.group(1) | |
| else: | |
| parts = (cr.get("external_id") or "").split("|") | |
| if len(parts) > 1 and parts[1].isdigit(): | |
| build_id = parts[1] | |
| prs = cr.get("pull_requests") or [] | |
| if prs: | |
| pr_number = str(prs[0]["number"]) | |
| if build_id: | |
| mode = "pr" | |
| # For a check-run build with no PR hint (fork PR, or a push to a branch/main), | |
| # verify via the build's source branch so main/push builds don't trigger a | |
| # pointless ~1 GB download. Only refs/pull/<n>/merge builds proceed. | |
| if mode == "pr" and build_id and not pr_number and event == "check_run": | |
| try: | |
| url = ("https://dev.azure.com/dnceng-public/public/_apis/build/builds/" | |
| f"{build_id}?api-version=7.1") | |
| req = urllib.request.Request(url, headers={"User-Agent": "skiasharp-size-pr/1.0"}) | |
| data = json.load(urllib.request.urlopen(req, timeout=60)) | |
| mm = re.search(r"refs/pull/(\d+)/merge", data.get("sourceBranch", "") or "", re.I) | |
| if mm: | |
| pr_number = mm.group(1) | |
| else: | |
| print(f"::notice::build {build_id} is not a PR build " | |
| f"({data.get('sourceBranch')!r}); skipping") | |
| mode = "none" | |
| except Exception as err: # noqa: BLE001 | |
| print(f"::warning::could not verify build {build_id}: {err}") | |
| mode = "none" | |
| if mode == "pr" and build_id: | |
| build_url = ("https://dev.azure.com/dnceng-public/public/_build/results" | |
| f"?buildId={build_id}") | |
| with open(os.environ["GITHUB_OUTPUT"], "a") as fh: | |
| fh.write(f"mode={mode}\n") | |
| fh.write(f"build_id={build_id}\n") | |
| fh.write(f"pr_number={pr_number}\n") | |
| fh.write(f"build_url={build_url}\n") | |
| print(f"mode={mode} build_id={build_id or '-'} pr_number={pr_number or '-'}") | |
| PY | |
| # --------------------------------------------------------------------------- # | |
| # Nightly: measure feed packages, persist history, render the dashboard. | |
| # --------------------------------------------------------------------------- # | |
| nightly: | |
| needs: resolve | |
| if: needs.resolve.outputs.mode == 'nightly' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Restore size history from data branch | |
| # Read the existing history from the aw-data branch (public raw URL, no auth). | |
| # The file is persisted as sizes/index.json (see persist-aw-data.yml); it must be | |
| # restored under that same name or every run starts fresh and overwrites history | |
| # with a single day's data point. Missing (first run) is fine. | |
| run: | | |
| url="https://raw.githubusercontent.com/${{ github.repository }}/${DATA_BRANCH}/sizes/index.json" | |
| if curl -fsSL "$url" -o artifact-sizes.json; then | |
| echo "restored size history from $DATA_BRANCH/sizes/index.json" | |
| else | |
| echo "no existing sizes/index.json on $DATA_BRANCH (fresh start)" | |
| rm -f artifact-sizes.json | |
| fi | |
| - name: Collect artifact sizes | |
| run: | | |
| python3 scripts/infra/perf/sizes/track.py \ | |
| --history artifact-sizes.json \ | |
| --max-nightly 365 \ | |
| --raw raw-sizes.json | |
| - name: Render Markdown summary (run summary) | |
| run: | | |
| python3 scripts/infra/perf/sizes/render_md.py \ | |
| --history artifact-sizes.json | |
| - name: Fetch benchmarks from data branch (for the unified dashboard) | |
| run: | | |
| url="https://raw.githubusercontent.com/${{ github.repository }}/${DATA_BRANCH}/benchmarks/index.json" | |
| curl -fsSL "$url" -o branch-benchmarks.json || echo "{}" > branch-benchmarks.json | |
| - name: Build interactive HTML dashboard | |
| # Sizes embedded fresh; benchmarks come from the branch -> the same unified page. | |
| run: | | |
| mkdir -p out | |
| python3 scripts/infra/perf/render_html.py \ | |
| scripts/infra/perf/templates/dashboard.html out/dashboard.html \ | |
| branch-benchmarks.json artifact-sizes.json | |
| - name: Upload HTML dashboard artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: perf-dashboard | |
| path: out/dashboard.html | |
| if-no-files-found: warn | |
| - name: Assemble data for the aw-data branch | |
| # Always build the payload (so dispatch/PR runs can download the exact `agent` | |
| # artifact and preview what would be persisted); the actual COMMIT to aw-data is | |
| # gated to main in persist-aw-data.yml (workflow_run head_branch == main). Writes | |
| # the size summary (index.json) and today's raw per-file snapshot (plain JSON, | |
| # additive) under agent/sizes/. The sentinel keeps the artifact's | |
| # least-common-ancestor at aw-upload/, preserving the required agent/ prefix. | |
| run: | | |
| mkdir -p aw-upload/agent/sizes/raw | |
| touch aw-upload/.artifact-root | |
| cp artifact-sizes.json aw-upload/agent/sizes/index.json | |
| [ -f raw-sizes.json ] && cp raw-sizes.json "aw-upload/agent/sizes/raw/$(date -u +%F).json" || true | |
| - name: Upload aw-data payload (agent) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: agent | |
| path: aw-upload | |
| if-no-files-found: warn | |
| # --------------------------------------------------------------------------- # | |
| # PR: measure the packages a PR build produced and post a size-diff comment. | |
| # --------------------------------------------------------------------------- # | |
| pr: | |
| needs: resolve | |
| if: needs.resolve.outputs.mode == 'pr' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Download + measure the PR's packages | |
| run: | | |
| python3 scripts/infra/perf/sizes/measure_pr.py \ | |
| --build-id "${{ needs.resolve.outputs.build_id }}" \ | |
| ${{ needs.resolve.outputs.pr_number && format('--pr-number {0}', needs.resolve.outputs.pr_number) || '' }} \ | |
| --output pr-sizes.json | |
| - name: Fetch the nightly baseline from the data branch | |
| # The nightly summary (sizes/index.json) points at the newest observation; the full | |
| # per-file breakdown for that day lives in sizes/raw/<date>.json. Missing baseline is | |
| # fine — the renderer then shows absolute sizes only. | |
| run: | | |
| base="https://raw.githubusercontent.com/${{ github.repository }}/${DATA_BRANCH}/sizes" | |
| date=$(curl -fsSL "$base/index.json" \ | |
| | python3 -c "import sys,json; n=json.load(sys.stdin).get('nightly',[]); print(n[-1]['date'] if n else '')" \ | |
| || true) | |
| if [ -n "$date" ] && curl -fsSL "$base/raw/$date.json" -o baseline.json; then | |
| echo "restored baseline for $date" | |
| else | |
| echo "no nightly baseline available (absolute-only report)" | |
| rm -f baseline.json | |
| fi | |
| - name: Render the size-diff comment | |
| run: | | |
| python3 scripts/infra/perf/sizes/render_pr_md.py \ | |
| --pr-sizes pr-sizes.json \ | |
| --baseline baseline.json \ | |
| --build-url "${{ needs.resolve.outputs.build_url }}" \ | |
| --output comment.md | |
| - name: Post or update the PR comment | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const measurement = JSON.parse(fs.readFileSync('pr-sizes.json', 'utf8')); | |
| const prNumber = measurement.prNumber | |
| || Number('${{ needs.resolve.outputs.pr_number }}') || null; | |
| if (!prNumber) { | |
| core.warning('No PR number could be resolved; skipping comment.'); | |
| return; | |
| } | |
| const body = fs.readFileSync('comment.md', 'utf8'); | |
| const marker = '<!-- skiasharp-pr-artifact-sizes -->'; | |
| const repo = { owner: context.repo.owner, repo: context.repo.repo }; | |
| let existing = null; | |
| for await (const page of github.paginate.iterator( | |
| github.rest.issues.listComments, | |
| { ...repo, issue_number: prNumber, per_page: 100 } | |
| )) { | |
| existing = page.data.find(c => c.body.includes(marker)); | |
| if (existing) break; | |
| } | |
| if (existing) { | |
| await github.rest.issues.updateComment({ ...repo, comment_id: existing.id, body }); | |
| core.info(`updated comment on PR #${prNumber}`); | |
| } else { | |
| await github.rest.issues.createComment({ ...repo, issue_number: prNumber, body }); | |
| core.info(`created comment on PR #${prNumber}`); | |
| } |