AMYboard Web: per-bus effects UI (buses from amy#686) #250
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: AMYboard PR preview | |
| # Per-PR preview of the AMYboard web editor + flasher, with this PR's firmware | |
| # bundled in, deployed to a unique URL (amyboard-pr-<N>.vercel.app). The bundled | |
| # firmware means the preview flasher can ONLY flash this PR's build. Torn down | |
| # by amyboard-pr-preview-cleanup.yml when the PR closes. | |
| # | |
| # Requires repo secret VERCEL_TOKEN (scope: bwhitmans-projects). The amyboard-pr | |
| # Vercel project is deployed to as preview deployments and aliased per PR. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - 'amy' | |
| - 'tulip/amyboard/**' | |
| # AMYboard firmware also compiles shared ESP32-S3 sources from here | |
| # (e.g. tulip/esp32s3/usb.c), so changes there must rebuild the preview. | |
| - 'tulip/esp32s3/**' | |
| - 'tulip/amyboardweb/**' | |
| # All of shared/ (py + C) is frozen into AMYboard firmware, so any change must | |
| # rebuild the preview. It also produces the tulip-firmware artifact that | |
| # tulip-pr-preview reuses for tulip.upgrade(pr=N). | |
| - 'tulip/shared/**' | |
| - '.github/workflows/amyboard-pr-preview.yml' | |
| # Fired by shorepine/amy's amyboard-hwci-trigger.yml on an AMY PR: build this | |
| # pipeline with the `amy` submodule pinned to client_payload.amy_sha, then chain | |
| # to HW CI (which comments back on the AMY PR). See the AMY-PR plumbing below. | |
| repository_dispatch: | |
| types: [amy-pr] | |
| # Manual fallback for an AMY *fork* PR (auto-dispatch is same-repo only): a | |
| # maintainer supplies the fork's amy PR number + SHA after reviewing the diff. | |
| workflow_dispatch: | |
| inputs: | |
| amy_pr: | |
| description: "AMY PR number (for the preview alias + result comment)" | |
| required: true | |
| amy_sha: | |
| description: "AMY commit SHA to pin the submodule to" | |
| required: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| # Namespace by event so an AMY PR #N and a Tulip PR #N never cancel each other. | |
| group: amyboard-pr-preview-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.client_payload.amy_pr || github.event.inputs.amy_pr }} | |
| cancel-in-progress: true | |
| jobs: | |
| preview: | |
| runs-on: ubuntu-latest | |
| steps: | |
| # Normalize the trigger: a Tulip PR (pull_request) vs an AMY PR (dispatched | |
| # from shorepine/amy, or manual for a fork). Everything downstream keys off | |
| # these outputs instead of github.event.pull_request.* directly. | |
| - name: Resolve PR context (Tulip PR vs AMY PR) | |
| id: ctx | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const ev = context.eventName; | |
| let isAmy = false, num, amySha = '', amyRepo = 'shorepine/amy'; | |
| if (ev === 'pull_request') { | |
| num = context.payload.pull_request.number; // Tulip PR | |
| } else if (ev === 'repository_dispatch') { // AMY PR (auto) | |
| isAmy = true; | |
| const cp = context.payload.client_payload || {}; | |
| num = cp.amy_pr; amySha = cp.amy_sha || ''; amyRepo = cp.amy_repo || amyRepo; | |
| } else if (ev === 'workflow_dispatch') { // AMY fork PR (manual) | |
| isAmy = true; | |
| num = context.payload.inputs.amy_pr; amySha = context.payload.inputs.amy_sha || ''; | |
| } | |
| if (isAmy && !amySha) { core.setFailed('AMY run requires an amy SHA'); return; } | |
| core.setOutput('is_amy', String(isAmy)); | |
| core.setOutput('num', String(num)); | |
| core.setOutput('amy_sha', amySha); | |
| core.setOutput('amy_repo', amyRepo); | |
| core.setOutput('alias', `${isAmy ? 'amyboard-amypr' : 'amyboard-pr'}-${num}`); | |
| core.info(`event=${ev} pr=${num} amy=${isAmy} sha=${amySha || '-'}`); | |
| # Fail fast on a missing/mis-scoped token, before the ~15-min build. | |
| - name: Verify Vercel token can access the team | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| if [ -z "$VERCEL_TOKEN" ]; then | |
| echo "::error::VERCEL_TOKEN secret is not set — add it (scope bwhitmans-projects)."; exit 1 | |
| fi | |
| npm i -g vercel@latest >/dev/null 2>&1 | |
| if ! vercel project ls --scope bwhitmans-projects --token "$VERCEL_TOKEN" >/dev/null 2>&1; then | |
| echo "::error::VERCEL_TOKEN cannot access the 'bwhitmans-projects' team — recreate the token with that scope."; exit 1 | |
| fi | |
| echo "Vercel token OK for bwhitmans-projects." | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| # For an AMY PR, point the `amy` submodule at the PR's commit so the firmware | |
| # is built from THIS PR's AMY code — the whole reason for the dispatch. The | |
| # build reads amy sources by path (esp32_common.cmake → ../../amy/src). | |
| # | |
| # Checking out amy's working tree is NOT enough on its own: the esp-idf | |
| # firmware step below runs `git submodule update` inside a root Docker | |
| # container, which would reset amy back to the superproject's *recorded* | |
| # gitlink (main's pin) — silently building main's amy instead of the PR's. | |
| # So we also `git add amy` to record the PR SHA as the gitlink; a later | |
| # `git submodule update` then keeps the PR commit. | |
| - name: Pin amy submodule to the PR SHA (AMY runs only) | |
| if: steps.ctx.outputs.is_amy == 'true' | |
| env: | |
| AMY_SHA: ${{ steps.ctx.outputs.amy_sha }} | |
| run: | | |
| set -euo pipefail | |
| case "$AMY_SHA" in | |
| ""|*[!0-9a-fA-F]*) echo "::error::bad amy SHA: '$AMY_SHA'"; exit 1 ;; | |
| esac | |
| git -C amy fetch origin "$AMY_SHA" | |
| git -C amy checkout --detach "$AMY_SHA" | |
| echo "amy pinned to:"; git -C amy log -1 --oneline | |
| # Record the new gitlink in the superproject index so the container's | |
| # `git submodule update` can't revert amy to main's pin. | |
| git add amy | |
| # --- Firmware (.bin sets) --- | |
| # Build AMYBOARD then TULIP4_R11 in ONE esp-idf container: the two targets | |
| # share most sources, so the second build hits ccache for the common units | |
| # (we enable IDF_CCACHE_ENABLE) instead of recompiling from scratch. The | |
| # Tulip firmware is what the HW CI bench flashes onto the physical Tulip | |
| # (it shares the AMYboard bench); see tulip/amyboard/hwci/tulip_hwci.py. | |
| - name: Build AMYboard + Tulip firmware | |
| uses: espressif/esp-idf-ci-action@v1 | |
| with: | |
| esp_idf_version: v5.4.1 | |
| target: esp32s3 | |
| path: tulip/amyboard | |
| command: >- | |
| export IDF_CCACHE_ENABLE=1 && | |
| python -m pip install littlefs-python && | |
| idf.py -DMICROPY_BOARD=AMYBOARD build && | |
| cd .. && | |
| python fs_create.py amyboard && | |
| cd esp32s3 && | |
| idf.py -DMICROPY_BOARD=TULIP4_R11 build && | |
| cd .. && | |
| python fs_create.py tulip | |
| - name: Upload AMYboard firmware artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: amyboard-firmware | |
| if-no-files-found: error | |
| path: | | |
| tulip/amyboard/dist/amyboard-firmware-AMYBOARD.bin | |
| tulip/amyboard/dist/amyboard-full-AMYBOARD.bin | |
| tulip/amyboard/dist/amyboard-sys.bin | |
| # The HW CI workflow (chained off this one via workflow_run) downloads this | |
| # to flash the physical Tulip — there's no Vercel preview for Tulip firmware. | |
| - name: Upload Tulip firmware artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: tulip-firmware | |
| if-no-files-found: error | |
| path: | | |
| tulip/esp32s3/dist/tulip-firmware-TULIP4_R11.bin | |
| tulip/esp32s3/dist/tulip-full-TULIP4_R11.bin | |
| tulip/esp32s3/dist/tulip-sys.bin | |
| # The esp-idf firmware step above runs in a Docker container as root, so any | |
| # file it created or rewrote in the bind-mounted workspace is now owned by | |
| # root:root. The web build below runs as the (non-root) runner and, for AMY | |
| # PRs, regenerates amy/amy/constants.py via `make web` (the pinned amy.h is | |
| # newer than the committed patches.h). That write fails with "Permission | |
| # denied" on the root-owned file — so reclaim ownership first. | |
| - name: Reclaim workspace ownership after the root container build | |
| run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" | |
| # --- Web stage/ (amy + amyboard WASM + static) --- | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.13' | |
| - name: Install web build deps | |
| run: pip install numpy | |
| - uses: mymindstorm/setup-emsdk@v14 | |
| with: | |
| version: 4.0.22 | |
| - name: Build amyboardweb stage/ | |
| run: | | |
| cd tulip/amyboardweb | |
| python3 dev.py --build-only | |
| # --- Bundle this PR's firmware into the site --- | |
| - name: Bundle firmware into stage/ | |
| run: | | |
| mkdir -p tulip/amyboardweb/stage/firmware | |
| cp tulip/amyboard/dist/amyboard-full-AMYBOARD.bin \ | |
| tulip/amyboard/dist/amyboard-firmware-AMYBOARD.bin \ | |
| tulip/amyboard/dist/amyboard-sys.bin \ | |
| tulip/amyboardweb/stage/firmware/ | |
| # --- Deploy preview + stable per-PR alias --- | |
| - name: Deploy preview to Vercel | |
| id: deploy | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| # amyboard-pr-<N> for Tulip PRs, amyboard-amypr-<N> for AMY PRs — one | |
| # Vercel project, a distinct per-PR alias, so the two PR-number spaces | |
| # can never collide on the same hostname. | |
| ALIAS_HOST: ${{ steps.ctx.outputs.alias }} | |
| run: | | |
| if [ -z "$VERCEL_TOKEN" ]; then | |
| echo "::error::VERCEL_TOKEN secret is not set — add it (scope bwhitmans-projects) to enable PR previews." | |
| exit 1 | |
| fi | |
| npm i -g vercel@latest >/dev/null 2>&1 | |
| cd tulip/amyboardweb | |
| vercel link --yes --token "$VERCEL_TOKEN" --scope bwhitmans-projects --project amyboard-pr --cwd stage | |
| url=$(vercel deploy stage --token "$VERCEL_TOKEN" --scope bwhitmans-projects --yes) | |
| alias="${ALIAS_HOST}.vercel.app" | |
| vercel alias set "$url" "$alias" --token "$VERCEL_TOKEN" --scope bwhitmans-projects | |
| echo "url=https://$alias" >> "$GITHUB_OUTPUT" | |
| # Thread the AMY-PR identity to the HW CI run. HW CI chains off this run via | |
| # workflow_run and otherwise has no way to know which AMY PR built the | |
| # firmware, where to fetch it, or where to comment — so hand it off here. | |
| - name: Publish AMY context for HW CI | |
| if: steps.ctx.outputs.is_amy == 'true' | |
| env: | |
| AMY_PR: ${{ steps.ctx.outputs.num }} | |
| AMY_REPO: ${{ steps.ctx.outputs.amy_repo }} | |
| AMY_SHA: ${{ steps.ctx.outputs.amy_sha }} | |
| ALIAS_HOST: ${{ steps.ctx.outputs.alias }} | |
| run: | | |
| mkdir -p amy-ctx | |
| python3 - <<'PY' > amy-ctx/ctx.json | |
| import json, os | |
| json.dump({ | |
| "amy_pr": int(os.environ["AMY_PR"]), # int() validates it's numeric | |
| "amy_repo": os.environ["AMY_REPO"], | |
| "amy_sha": os.environ["AMY_SHA"], | |
| "alias": os.environ["ALIAS_HOST"], | |
| }, open(1, "w")) | |
| PY | |
| cat amy-ctx/ctx.json | |
| - name: Upload AMY context artifact | |
| if: steps.ctx.outputs.is_amy == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: amy-ctx | |
| path: amy-ctx/ctx.json | |
| if-no-files-found: error | |
| # --- Comment (upsert) the preview URL on the PR --- | |
| # Tulip PRs only. AMY-PR runs get a single result comment posted on the AMY | |
| # PR by the HW CI workflow instead (this run has no Tulip PR to comment on). | |
| - name: Comment preview URL | |
| if: success() && github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| env: | |
| PREVIEW_URL: ${{ steps.deploy.outputs.url }} | |
| with: | |
| script: | | |
| const url = process.env.PREVIEW_URL; | |
| const marker = '<!-- amyboard-pr-preview -->'; | |
| const body = [ | |
| marker, | |
| '### 🔌 AMYboard PR preview', | |
| '', | |
| `**Editor + flasher:** ${url}/editor/`, | |
| '', | |
| "This preview bundles **this PR's firmware** — its flasher only flashes this build (not the release). Rebuilt on every push; removed when the PR closes.", | |
| '', | |
| 'The hardware CI has been kicked off and should return within a few minutes, stand by!', | |
| ].join('\n'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, | |
| }); | |
| const existing = comments.find(c => c.body && 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: context.issue.number, body }); | |
| } |