Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build-boards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ jobs:
name: ${{ matrix.board }}
path: bin/${{ matrix.board }}

- name: Upload firmware sizes
# "zz-" sorts the size records after every board in the run's artifact list.
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: zz-sizes-${{ matrix.board }}
path: sizes/${{ matrix.board }}.json
if-no-files-found: ignore
# build-ci merges these into the 0-sizes reports; delete them after one day.
retention-days: 1

- name: Upload to S3
uses: ./.github/actions/upload_aws
with:
Expand Down
82 changes: 81 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,90 @@ jobs:
build-ci:
# Final gate for ruleset checks: always runs so the required check is
# reported, and fails if any job (or matrix leg) failed or was cancelled.
needs: [tests, zephyr-tests, mpy-cross, mpy-cross-mac, docs, ports]
needs: [scheduler, tests, zephyr-tests, mpy-cross, mpy-cross-mac, docs, ports]
if: ${{ always() }}
runs-on: ubuntu-24.04
env:
CP_VERSION: ${{ needs.scheduler.outputs.cp-version }}
steps:
# Firmware size report. Each board job uploads its sizes/<board>.json as zz-sizes-<board>;
# merge them into one table and a job summary. This is reporting only, so none of it
# may fail the gate: every step continues on error.
- name: Set up repository
continue-on-error: true
uses: actions/checkout@v6
with:
submodules: false
show-progress: false
fetch-depth: 1
persist-credentials: false

# actions/download-artifact matches a pattern against a listing of the run's
# artifacts that stops at 1000, and a full build has more. The script fetches each
# board's record by exact name instead, from the board list the scheduler produced.
# It runs from github-script because only action steps get the artifact API token.
- name: Install the artifact client
continue-on-error: true
run: npm install --no-save --no-package-lock --no-audit --no-fund @actions/artifact@6

- name: Download firmware sizes
continue-on-error: true
uses: actions/github-script@v9
env:
BOARDS: ${{ needs.scheduler.outputs.ports }}
with:
script: |
const { pathToFileURL } = require("node:url");
const sizes = await import(pathToFileURL(`${process.env.GITHUB_WORKSPACE}/tools/ci_download_sizes.mjs`));
await sizes.downloadSizes({
boards: sizes.boardsFromSchedule(JSON.parse(process.env.BOARDS || "{}")),
outDir: "sizes",
info: core.info,
warning: core.warning,
});

- name: Summarize firmware sizes
continue-on-error: true
run: python3 tools/ci_firmware_sizes.py sizes sizes-report

# Uploaded unzipped, one file per artifact, so each opens directly; with
# `archive: false` the file name is the artifact name.
- name: Upload firmware size report (HTML)
continue-on-error: true
uses: actions/upload-artifact@v7
with:
path: sizes-report/0-sizes.html
archive: false
if-no-files-found: ignore

- name: Upload firmware size report (JSON)
continue-on-error: true
uses: actions/upload-artifact@v7
with:
path: sizes-report/0-sizes.json
archive: false
if-no-files-found: ignore

# The action itself only uploads on pushes to main and release branches and on
# published releases. The S3 names carry the version, so the directory keeps a history.
- name: Upload firmware size report to S3 (HTML)
continue-on-error: true
uses: ./.github/actions/upload_aws
with:
source: sizes-report/0-sizes.html
destination: 0-sizes/adafruit-circuitpython-sizes-${{ env.CP_VERSION }}.html
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

- name: Upload firmware size report to S3 (JSON)
continue-on-error: true
uses: ./.github/actions/upload_aws
with:
source: sizes-report/0-sizes.json
destination: 0-sizes/adafruit-circuitpython-sizes-${{ env.CP_VERSION }}.json
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

- name: Check build results
run: |
echo "Job results: ${{ toJSON(needs.*.result) }}"
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ dist/
######################
build/
bin/
sizes/
node_modules/
circuitpython-stubs/
test-stubs/
build-*/
Expand Down
69 changes: 57 additions & 12 deletions tools/build_release_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ def flash_usage(port, build_dir):
return None, None


def record_size(port, board, language, used, region, status, source):
"""Record one language build's flash usage in sizes/<board>.json.

CI uploads the file as its own small artifact so a later job can gather every
board's sizes without scraping logs. `source` is "measured" for a linked build and
"predicted" for a language skipped by the size prediction; `used` is None, written as
null, when the build failed or the port does not report its flash region.
"""
os.makedirs("../sizes", exist_ok=True)
path = f"../sizes/{board}.json"
try:
with open(path, "r") as f:
record = json.load(f)
except FileNotFoundError:
record = {"port": port, "board": board, "region": None, "languages": {}}
if region is not None:
record["region"] = region
record["languages"][language] = {"used": used, "status": status, "source": source}
with open(path, "w") as f:
json.dump(record, f, indent=1)
f.write("\n")


def c_array_bytes(path):
"""Sum the bytes of the const arrays initialised in a generated C file."""
if not path.exists():
Expand Down Expand Up @@ -218,6 +241,15 @@ def generate_translation(port, board, build_dir, language):
flush=True,
)
if skip:
record_size(
board_info["port"],
board,
language,
predicted_flash,
flash_region,
"skipped",
"predicted",
)
continue

make_result = subprocess.run(
Expand Down Expand Up @@ -275,19 +307,32 @@ def generate_translation(port, board, build_dir, language):
print(make_result.stdout.decode("utf-8"))
print(other_output)

if predicted_flash is not None and make_result.returncode == 0:
actual_flash, _ = flash_usage(board_info["port"], build_dir)
if actual_flash is not None:
print(
"Flash size check {board} {language}: predicted {predicted},"
" actual {actual}, error {error:+d}".format(
board=board,
language=language,
predicted=predicted_flash,
actual=actual_flash,
error=predicted_flash - actual_flash,
)
# Languages share a build directory, so a failed link leaves the previous
# language's firmware.size.json behind; only trust it after a successful build.
actual_flash, actual_region = None, None
if make_result.returncode == 0:
actual_flash, actual_region = flash_usage(board_info["port"], build_dir)
record_size(
board_info["port"],
board,
language,
actual_flash,
actual_region,
"succeeded" if make_result.returncode == 0 else "failed",
"measured",
)

if predicted_flash is not None and actual_flash is not None:
print(
"Flash size check {board} {language}: predicted {predicted},"
" actual {actual}, error {error:+d}".format(
board=board,
language=language,
predicted=predicted_flash,
actual=actual_flash,
error=predicted_flash - actual_flash,
)
)

# Flush so we will see something before 10 minutes has passed.
print(flush=True)
Expand Down
57 changes: 57 additions & 0 deletions tools/ci_download_sizes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2014 MicroPython & CircuitPython contributors (https://github.com/adafruit/circuitpython/graphs/contributors)
//
// SPDX-License-Identifier: MIT

// Download every board's firmware size record from this run's artifacts, by exact name.
//
// build_release_files.py records a board's sizes in sizes/<board>.json and the board job
// uploads it as the artifact zz-sizes-<board>. actions/download-artifact matches a name
// pattern against a listing of the run's artifacts, and that listing stops at 1000, which
// a full build exceeds. Looking each record up by name has no such limit and uses the same
// internal artifact API, so it costs nothing against the REST rate limit.
//
// That API needs the runner's ACTIONS_RUNTIME_TOKEN, which only `uses:` action steps get,
// so build.yml calls downloadSizes() from an actions/github-script step rather than from
// `node` in a `run:` step. @actions/artifact must be installed where this file can resolve
// it: `npm install` at the repository root.

import { DefaultArtifactClient } from "@actions/artifact";

// The boards in the scheduler job's "ports" output: {<port>: [boards...], ports: [<port>...]}.
export function boardsFromSchedule(schedule) {
return (schedule.ports ?? []).flatMap((port) => schedule[port]);
}

export async function downloadSizes({ boards, outDir, info = console.log, warning = console.warn }) {
const client = new DefaultArtifactClient();
const missing = [];
let downloaded = 0;

async function fetchRecord(board) {
try {
const { artifact } = await client.getArtifact(`zz-sizes-${board}`);
await client.downloadArtifact(artifact.id, { path: outDir });
downloaded++;
} catch (error) {
missing.push(board);
info(`No size record for ${board}: ${error.message}`);
}
}

// A few downloads at a time: enough to get through a full build in a minute or two,
// few enough not to trip the artifact service's throttling.
const queue = [...boards];
await Promise.all(
Array.from({ length: 8 }, async () => {
while (queue.length) {
await fetchRecord(queue.shift());
}
}),
);

info(`Downloaded ${downloaded} of ${boards.length} size records to ${outDir}`);
if (missing.length) {
warning(`No firmware size record for ${missing.length} board(s): ${missing.join(" ")}`);
}
return { downloaded, missing };
}
Loading
Loading