Skip to content

AMYboard Web: per-bus effects UI (buses from amy#686) #250

AMYboard Web: per-bus effects UI (buses from amy#686)

AMYboard Web: per-bus effects UI (buses from amy#686) #250

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 });
}