AMY HW CI #144
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: AMY HW CI | |
| # Hardware half of AMY's CI, replacing the old cross-repo dispatch to the | |
| # tulipcc bench: flash THIS PR's LoadTestChord firmware (built and uploaded as | |
| # an artifact by "AMY HW CI build") onto the physical AMYboard on the | |
| # self-hosted bench Pi (amyboardci), capture 20 s of its debug UART while the | |
| # sketch stacks up an 8-note held Juno chord, and comment the measured render | |
| # load at each chord size back on the PR. (measure.py can average several | |
| # resets of one flash via --runs, but a 3x trial showed run-to-run spread of | |
| # only ±1-2 µs — the ±30 µs no-op deltas are per-BINARY layout jitter that | |
| # averaging can't remove — so CI captures once.) | |
| # | |
| # PASS/FAIL semantics: FAIL means only that the bench COULD NOT RUN the test | |
| # (flash/serial failure, board crash, capture interference). The load values | |
| # are informational — no audio compare, no load threshold. | |
| # | |
| # SECURITY: the bench is a self-hosted Pi and this repo is public, so fork | |
| # code must never reach it. This workflow chains off the build via | |
| # workflow_run — meaning the version that executes is always MAIN's copy, a | |
| # fork PR can't edit it — and the job gate requires the triggering build to | |
| # have run same-repo (maintainer branches only). To bench a fork PR, a | |
| # maintainer pushes its changes to a shorepine/amy branch, or uses this | |
| # workflow's workflow_dispatch after eyeballing the code. | |
| on: | |
| workflow_run: | |
| workflows: ["AMY HW CI build"] | |
| types: [completed] | |
| workflow_dispatch: | |
| inputs: | |
| pr: | |
| description: "PR number to bench (needs a successful 'AMY HW CI build' run at its head SHA)" | |
| required: true | |
| permissions: | |
| contents: read | |
| actions: read # download the firmware artifact from the build run | |
| pull-requests: write | |
| issues: write # delete the previous comment so each run re-notifies | |
| concurrency: | |
| group: amy-hwci-bench # one physical board → serialize every run | |
| cancel-in-progress: false | |
| jobs: | |
| bench: | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.head_repository.full_name == github.repository && | |
| github.event.workflow_run.event == 'pull_request') | |
| runs-on: [self-hosted, amyboard-hwci] | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Resolve the build run + head SHA | |
| id: ctx | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner, repo = context.repo.repo; | |
| let sha, buildRunId, pr = ''; | |
| if (context.eventName === 'workflow_dispatch') { | |
| pr = String(Number(context.payload.inputs.pr)); | |
| const { data } = await github.rest.pulls.get({ owner, repo, pull_number: Number(pr) }); | |
| sha = data.head.sha; | |
| const { data: runs } = await github.rest.actions.listWorkflowRunsForRepo({ | |
| owner, repo, head_sha: sha, per_page: 50 }); | |
| const build = runs.workflow_runs.find( | |
| r => r.name === 'AMY HW CI build' && r.conclusion === 'success'); | |
| if (!build) { | |
| core.setFailed(`No successful 'AMY HW CI build' run for ${sha}; push the PR (or wait for the build) first.`); | |
| return; | |
| } | |
| buildRunId = build.id; | |
| } else { | |
| const run = context.payload.workflow_run; | |
| sha = run.head_sha; | |
| buildRunId = run.id; // PR number comes from the artifact's ctx.json | |
| } | |
| core.setOutput('pr', pr); | |
| core.setOutput('sha', sha); | |
| core.setOutput('build_run_id', String(buildRunId)); | |
| core.info(`bench: pr=${pr || '(from ctx.json)'} @ ${sha} (build run ${buildRunId})`); | |
| # The harness scripts (measure.py, hwci_report.py) from MAIN — never the | |
| # PR head. Two reasons: nothing a PR controls executes on the bench (the | |
| # firmware artifact is the only PR input, and that runs on the board, not | |
| # the Pi), and a branch that predates the tooling (e.g. an old fork PR, | |
| # benched via workflow_dispatch) still benches — checking out ITS head | |
| # left the Pi with no scripts at all (bit PR #827). Harness changes take | |
| # effect once merged, like the workflow itself. | |
| - uses: actions/checkout@v4 | |
| - name: Download this PR's firmware (built by the build run) | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: amy-hwci-fw | |
| path: fw | |
| run-id: ${{ steps.ctx.outputs.build_run_id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve the PR to comment on | |
| run: | | |
| set -euo pipefail | |
| PR="${{ steps.ctx.outputs.pr }}" | |
| if [ -z "$PR" ]; then | |
| PR=$(python3 -c 'import json; pr=int(json.load(open("fw/ctx.json"))["pr"]); assert pr > 0; print(pr)') | |
| fi | |
| echo "PR=$PR" >> "$GITHUB_ENV" | |
| - name: Flash + capture the merge-base baseline (before) | |
| # A failed baseline never fails CI — the report just loses its | |
| # before/Δ columns. `|| true` rather than continue-on-error so the | |
| # step shows green unless the runner itself breaks. | |
| if: hashFiles('fw/base/*.ino.bin') != '' | |
| run: | | |
| set -o pipefail | |
| mkdir -p out-base | |
| BASE_SHA=$(python3 -c 'import json; print(json.load(open("fw/ctx.json")).get("base_sha", ""))') | |
| ~/hwci-venv/bin/python tools/arduino_loadsweep/measure.py fw/base \ | |
| --out out-base \ | |
| --port /dev/amyboard-dongle \ | |
| --esptool ~/hwci-venv/bin/esptool \ | |
| --seconds 20 \ | |
| --label "pr${PR}-base" \ | |
| --meta "{\"pr\": ${PR}, \"sha\": \"${BASE_SHA}\"}" \ | |
| 2>&1 | tee out-base/run.log || true | |
| - name: Flash + capture 20 s of render load (this PR) | |
| id: measure | |
| continue-on-error: true # the verdict is hwci_report.py's, below | |
| env: | |
| SHA: ${{ steps.ctx.outputs.sha }} | |
| run: | | |
| set -o pipefail | |
| mkdir -p out | |
| ~/hwci-venv/bin/python tools/arduino_loadsweep/measure.py fw \ | |
| --out out \ | |
| --port /dev/amyboard-dongle \ | |
| --esptool ~/hwci-venv/bin/esptool \ | |
| --seconds 20 \ | |
| --label "pr${PR}" \ | |
| --meta "{\"pr\": ${PR}, \"sha\": \"${SHA}\"}" \ | |
| 2>&1 | tee out/run.log | |
| - name: Build the report + verdict | |
| id: report | |
| continue-on-error: true # gate at the end, after commenting | |
| run: | | |
| set -o pipefail # a dead report script must not pass via tee's exit 0 | |
| mkdir -p out | |
| BASE_ARG="" | |
| [ -d fw/base ] && BASE_ARG="--base out-base" | |
| python3 tools/arduino_loadsweep/hwci_report.py out $BASE_ARG | tee report.md | |
| - name: Upload artifacts (serial log, load trace, report) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: amy-hwci-pr${{ env.PR }} | |
| if-no-files-found: warn | |
| path: | | |
| out/serial*.log | |
| out/load*.csv | |
| out/meta.json | |
| out/run.log | |
| out-base/serial*.log | |
| out-base/load*.csv | |
| out-base/meta.json | |
| out-base/run.log | |
| report.md | |
| - name: Comment results on the PR | |
| if: always() && env.PR != '' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| let report = ''; | |
| try { report = fs.readFileSync('report.md', 'utf8').trim(); } catch (e) {} | |
| if (!report) report = '❌ **FAIL** — the bench run died before producing a report.'; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const marker = '<!-- amy-hwci -->'; | |
| const body = [ | |
| marker, | |
| '### 🎛️ AMY HW CI (AMYboard bench)', | |
| '', | |
| "Flashed this PR's AMY (LoadTestChord: 6-voice Juno `patch=1`, one held note every 2 s) onto the physical AMYboard and measured the smoothed render load as the chord grows — back-to-back with the same sketch built at the PR's merge base, so Δ is this PR's own cost.", | |
| '', | |
| report, | |
| '', | |
| `**[⬇️ Artifacts: serial log · load trace · report](${runUrl})**`, | |
| '', | |
| '<sub>Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See `tools/arduino_loadsweep/`.</sub>', | |
| ].join('\n'); | |
| const { owner, repo } = context.repo; | |
| const pr = Number(process.env.PR); | |
| const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker)); | |
| // Post fresh then delete the old one: GitHub only notifies on NEW | |
| // comments, and this keeps exactly one on the thread. | |
| await github.rest.issues.createComment({ owner, repo, issue_number: pr, body }); | |
| if (existing) await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); | |
| - name: Mark the job failed if the test couldn't run | |
| if: always() | |
| run: | | |
| echo "measure: ${{ steps.measure.outcome }} report: ${{ steps.report.outcome }}" | |
| [ "${{ steps.report.outcome }}" = "success" ] |