From 87f9cc01cfd3134594d196210d28ee590c7a5fc3 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 1 Jun 2026 23:51:41 +0700 Subject: ci: re-enable Claude PR review and harden auth/permissions - claude-code-review.yml: re-enable (drop `if: false`); switch from pull_request_target to pull_request so fork PRs never receive the OAuth token (avoids prompt-injection token leak). Auto-review on open/synchronize/reopen/ready_for_review, skip drafts, sticky comment. - claude.yml: grant contents/pull-requests/issues write so @claude can reply and push fixes; @claude is the on-demand path for fork PRs. --- .github/workflows/claude-code-review.yml | 19 +++++++++++++++---- .github/workflows/claude.yml | 6 +++--- 2 files changed, 18 insertions(+), 7 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 43144bb5e..2f055287c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,18 +1,27 @@ name: Claude Code Review on: - pull_request_target: - types: [opened, synchronize, ready_for_review, reopened] + pull_request: + # opened/reopened/ready_for_review -> first auto review + # synchronize -> auto re-review on new pushes + # + # NOTE: pull_request (not _target) means fork PRs from non-write-access + # contributors get NO token, so they are not auto-reviewed -> use @claude + # on those. Same-repo branches (yours or write-access contributors) get + # full auto-review safely. + types: [opened, synchronize, reopened, ready_for_review] jobs: claude-review: - if: false + # Skip drafts; review real PRs only + if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: read id-token: write + actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository @@ -28,5 +37,7 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # Reuse one comment instead of posting a new one each push + use_sticky_comment: true + claude_args: '--max-turns 20' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 50f449949..660edfb7b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,9 +19,9 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write # allow Claude to push commits/branches when asked + pull-requests: write # allow Claude to comment on / update PRs + issues: write # allow Claude to comment on / update issues id-token: write actions: read # Required for Claude to read CI results on PRs steps: -- cgit v1.3.1 From 1ea04f7fe67bac927848e9dfd3ce2f607e7b93d7 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 2 Jun 2026 00:16:54 +0700 Subject: ci: address codex/copilot review on claude workflows - claude.yml: gate @claude on author_association (OWNER/MEMBER/COLLABORATOR) so the write-scoped token and OAuth secret are never issued for an untrusted commenter on this public repo (defense-in-depth). - claude-code-review.yml: skip fork PRs in the job condition (head.repo.full_name == github.repository) since forks get no secrets and would only fail noisily; fix the misleading token comment; pass additional_permissions: actions: read so actions: read is effective. - hil SKILL.md: reword hostname guidance, use full test/hil/* paths, and show an explicit CONFIG= assignment so the local command is runnable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/hil/SKILL.md | 14 ++++++++------ .github/workflows/claude-code-review.yml | 17 +++++++++++------ .github/workflows/claude.yml | 15 +++++++++++---- 3 files changed, 30 insertions(+), 16 deletions(-) (limited to '.github/workflows') diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md index c705c149c..22588eba3 100644 --- a/.claude/skills/hil/SKILL.md +++ b/.claude/skills/hil/SKILL.md @@ -5,12 +5,12 @@ description: Use when running TinyUSB Hardware-in-the-Loop (HIL) tests on physic # Hardware-in-the-Loop (HIL) Testing -Run TinyUSB HIL tests on real boards. **Run `hostname` first** — it sets the default config and whether remote mode is possible. +Run TinyUSB HIL tests on real boards. **Run `hostname` first** — it tells you which host you are on, which determines the default config and whether remote mode is possible. -| Host | Local boards | Remote (SSH → ci.lan)? | +| Host | Local config | Remote (SSH → ci.lan)? | |------|--------------|------------------------| -| `htpc` (dev PC) | `local.json` | yes (large pool, `tinyusb.json`) | -| `ci` (the rig) | `tinyusb.json` (large pool) | no — can't SSH to htpc, and boards are already local | +| `htpc` (dev PC) | `test/hil/local.json` | yes (large pool, `test/hil/tinyusb.json`) | +| `ci` (the rig) | `test/hil/tinyusb.json` (large pool) | no — can't SSH to htpc, and boards are already local | Default to **local**. Use **remote** only when on `htpc` and the user says `remote`/`ci.lan`. Never attempt remote on `ci`. @@ -27,10 +27,12 @@ If `local.json` is missing on `htpc`, ask the user to supply one (only fall back ## Local execution -Pick `$CONFIG` from `hostname`: `local.json` on `htpc`, `tinyusb.json` on `ci`. +Set `CONFIG` from `hostname` first, then run: ```bash -python3 test/hil/hil_test.py [-b BOARD_NAME] -B examples $CONFIG $EXTRA_ARGS +CONFIG=test/hil/local.json # on htpc +# CONFIG=test/hil/tinyusb.json # on ci +python3 test/hil/hil_test.py [-b BOARD_NAME] -B examples "$CONFIG" $EXTRA_ARGS ``` ## Remote execution (htpc → ci.lan only) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 2f055287c..6c8bbb03d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -5,16 +5,18 @@ on: # opened/reopened/ready_for_review -> first auto review # synchronize -> auto re-review on new pushes # - # NOTE: pull_request (not _target) means fork PRs from non-write-access - # contributors get NO token, so they are not auto-reviewed -> use @claude - # on those. Same-repo branches (yours or write-access contributors) get - # full auto-review safely. + # NOTE: pull_request (not _target) means fork PRs get a read-only GITHUB_TOKEN + # and NO repository secrets (CLAUDE_CODE_OAUTH_TOKEN), so they cannot be + # auto-reviewed. The job condition below skips them cleanly -> use @claude on + # those. Same-repo branches (yours or write-access contributors) auto-review. types: [opened, synchronize, reopened, ready_for_review] jobs: claude-review: - # Skip drafts; review real PRs only - if: github.event.pull_request.draft == false + # Skip drafts, and skip fork PRs (no secrets -> would only fail noisily) + if: > + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -34,6 +36,9 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Pairs with the actions: read permission so Claude can read CI results + additional_permissions: | + actions: read plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 660edfb7b..dedecd349 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -12,11 +12,18 @@ on: jobs: claude: + # Only trusted actors (repo owner/member/collaborator) may summon @claude, so the + # write-scoped token and OAuth secret are never issued for an outside contributor's + # comment on this public repo. Defense-in-depth on top of the action's own check. if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association)) runs-on: ubuntu-latest permissions: contents: write # allow Claude to push commits/branches when asked -- cgit v1.3.1 From 044cd06f87117afa541d8cc743fcc9ef2bdc8089 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 2 Jun 2026 00:24:05 +0700 Subject: ci: address second codex/copilot review round - claude.yml: drop the issues "assigned" trigger; its author_association gate keys on the issue author, not the assigner, so a maintainer assigning an outside contributor's issue would be wrongly skipped. - claude-code-review.yml: issues: read -> write so use_sticky_comment can create/update its PR comment via the issues API. - hil SKILL.md: make local/remote command blocks copy-pasteable (drop [-b BOARD_NAME] notation for concrete examples) and fix timeout (600000 ms is 10 min; use 1200000 ms for the stated 20 min). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/hil/SKILL.md | 22 ++++++++++++++++------ .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 5 ++++- 3 files changed, 21 insertions(+), 8 deletions(-) (limited to '.github/workflows') diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md index 22588eba3..a7a916907 100644 --- a/.claude/skills/hil/SKILL.md +++ b/.claude/skills/hil/SKILL.md @@ -27,27 +27,37 @@ If `local.json` is missing on `htpc`, ask the user to supply one (only fall back ## Local execution -Set `CONFIG` from `hostname` first, then run: +Set `CONFIG` from `hostname` first (`test/hil/local.json` on htpc, `test/hil/tinyusb.json` on ci): ```bash -CONFIG=test/hil/local.json # on htpc -# CONFIG=test/hil/tinyusb.json # on ci -python3 test/hil/hil_test.py [-b BOARD_NAME] -B examples "$CONFIG" $EXTRA_ARGS +CONFIG=test/hil/local.json # on ci use: CONFIG=test/hil/tinyusb.json + +# All boards in the config: +python3 test/hil/hil_test.py -B examples "$CONFIG" + +# A single board (replace stm32f723disco): +python3 test/hil/hil_test.py -b stm32f723disco -B examples "$CONFIG" ``` +Append pass-through flags (`-v`, `-r 1`, …) to either command as needed. + ## Remote execution (htpc → ci.lan only) `test/hil/hil_ci.sh` handles dir setup, scp of test scripts, rsync of firmware (`.elf`/`.bin`/`.hex`), and runs `hil_test.py` on `ci.lan` with `tinyusb.json`: ```bash -bash test/hil/hil_ci.sh [-b BOARD_NAME] [extra hil_test.py args...] +# All boards: +bash test/hil/hil_ci.sh + +# A single board, with pass-through flags: +bash test/hil/hil_ci.sh -b raspberry_pi_pico2 -t host/cdc_msc_hid -r 1 ``` Env overrides: `REMOTE`, `REMOTE_DIR`, `CONFIG`. Fails fast if the build dir/repo layout is missing. ## Timing -Runs take 2-5 min. Use a timeout ≥ 20 min (600000 ms). NEVER cancel early. +Runs take 2-5 min. Use a timeout ≥ 20 min (1200000 ms). NEVER cancel early. ## Reporting diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 6c8bbb03d..4a0e4639b 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read pull-requests: write - issues: read + issues: write # use_sticky_comment posts/updates a PR comment via the issues API id-token: write actions: read # Required for Claude to read CI results on PRs diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index dedecd349..bf7a401e4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -6,7 +6,10 @@ on: pull_request_review_comment: types: [created] issues: - types: [opened, assigned] + # only "opened" — an issue's author_association gates the summon below; + # "assigned" would gate on the issue author, not the assigner, so a + # maintainer assigning an outsider's issue would be wrongly skipped. + types: [opened] pull_request_review: types: [submitted] -- cgit v1.3.1 From b009ddb01232192538762f21371d65a4e6d04f14 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 2 Jun 2026 10:38:09 +0700 Subject: ci(claude): enable @claude to fix bugs and commit from comments Configure the @claude summon workflow so it can actually produce a verified fix when asked in an issue/PR comment: - use_commit_signing: bot commits show as Verified - --allowedTools Bash: lets Claude build/test to verify the fix before committing (default allowlist blocks Bash). Safe because the job `if` gate restricts this to OWNER/MEMBER/COLLABORATOR. - --max-turns 30: enough turns to investigate -> fix -> verify Auto-commit/PR is already built into claude-code-action and the required write permissions were already present, so no permission changes are needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bf7a401e4..9e7c8335f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -50,10 +50,15 @@ jobs: additional_permissions: | actions: read - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' + # Sign the bot's commits so they show as "Verified". The action commits + # automatically — on a PR comment it pushes to that PR's branch; on an + # issue comment it opens a new claude/* branch + PR with the fix. + use_commit_signing: true - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' + # No custom prompt: Claude performs the instructions in the @claude comment. + + # Let summoned runs actually fix bugs: allow Bash so Claude can build/test + # and verify the change before it commits, plus enough turns to investigate. + # File edits (Edit/Write) and git push are handled by the action itself. + # Safe because the job `if` gate restricts this to OWNER/MEMBER/COLLABORATOR. + claude_args: '--allowedTools Bash --max-turns 30' -- cgit v1.3.1 From 6936cc630dfc0d125337e3f4f6e9322b503df3b2 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 2 Jun 2026 10:42:39 +0700 Subject: ci(claude): scope Bash allowlist instead of wide-open (Codex P1) Codex flagged that @claude can be summoned on a fork PR (the review workflow even directs fork PRs here), so the checked-out PR content is potentially attacker-controlled. Unrestricted Bash in this write-token + OAuth-secret job let prompt injection steer Claude into arbitrary shell/network commands. Scope Bash to the repo's actual verification commands (cmake, ninja, make, ctest, python/python3, pre-commit, clang-format, codespell, git). This blocks the injection-to-arbitrary-command path while still letting Claude build/test before committing. Building fork code itself is already done by the existing CircleCI, so that surface is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 9e7c8335f..66a1098ab 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -57,8 +57,18 @@ jobs: # No custom prompt: Claude performs the instructions in the @claude comment. - # Let summoned runs actually fix bugs: allow Bash so Claude can build/test - # and verify the change before it commits, plus enough turns to investigate. - # File edits (Edit/Write) and git push are handled by the action itself. - # Safe because the job `if` gate restricts this to OWNER/MEMBER/COLLABORATOR. - claude_args: '--allowedTools Bash --max-turns 30' + # Let summoned runs actually fix bugs: allow the repo's build/test/lint + # commands so Claude can verify the change before it commits, plus enough + # turns to investigate. File edits (Edit/Write) and git push are handled + # by the action itself. + # + # Bash is scoped to a curated allowlist rather than wide-open: the job `if` + # gate trusts the *commenter*, but @claude can be summoned on a fork PR + # (claude-code-review.yml even directs fork PRs here), so the checked-out + # PR content is potentially attacker-controlled. Scoping blocks prompt + # injection from steering Claude into arbitrary shell/network commands + # while this job holds the OAuth secret + write token. Keep `bash`/`sh`/ + # `curl`/`wget`/`eval` OUT of this list. + claude_args: >- + --allowedTools "Bash(git:*),Bash(cmake:*),Bash(ninja:*),Bash(make:*),Bash(ctest:*),Bash(python3:*),Bash(python:*),Bash(pre-commit:*),Bash(clang-format:*),Bash(codespell:*)" + --max-turns 30 -- cgit v1.3.1 From 2fc46b690996f776e3b36bcaa9865e7d2a8f8c89 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 2 Jun 2026 11:18:14 +0700 Subject: ci(claude): drop Bash allowlist entirely (Codex P1, round 2) Codex correctly noted that allowing python3/python (and really cmake/make too) is arbitrary code + network execution: a command allowlist cannot contain a prompt-injected or malicious fork PR when this job holds the OAuth secret + write token, and the review workflow directs fork PRs to @claude. The Bash allowlist was beyond the original scope (auto-commit/PR) anyway. Remove it: Claude edits files and the action commits/opens the PR, and the resulting commit is verified by the existing CircleCI matrix. Keep use_commit_signing and --max-turns 30. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude.yml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 66a1098ab..66e36897c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -57,18 +57,12 @@ jobs: # No custom prompt: Claude performs the instructions in the @claude comment. - # Let summoned runs actually fix bugs: allow the repo's build/test/lint - # commands so Claude can verify the change before it commits, plus enough - # turns to investigate. File edits (Edit/Write) and git push are handled - # by the action itself. - # - # Bash is scoped to a curated allowlist rather than wide-open: the job `if` - # gate trusts the *commenter*, but @claude can be summoned on a fork PR - # (claude-code-review.yml even directs fork PRs here), so the checked-out - # PR content is potentially attacker-controlled. Scoping blocks prompt - # injection from steering Claude into arbitrary shell/network commands - # while this job holds the OAuth secret + write token. Keep `bash`/`sh`/ - # `curl`/`wget`/`eval` OUT of this list. - claude_args: >- - --allowedTools "Bash(git:*),Bash(cmake:*),Bash(ninja:*),Bash(make:*),Bash(ctest:*),Bash(python3:*),Bash(python:*),Bash(pre-commit:*),Bash(clang-format:*),Bash(codespell:*)" - --max-turns 30 + # Deliberately NO Bash in the tool allowlist. @claude can be summoned on a + # fork PR (claude-code-review.yml even directs fork PRs here), and this job + # holds the OAuth secret + a write token. Any build/interpreter command + # (python -c, cmake/make custom targets, etc.) run against attacker- + # controlled PR content is arbitrary code + network execution, so no + # command allowlist can safely contain it. Claude still edits files and + # the action commits/opens the PR; the resulting commit is verified by the + # repo's CircleCI matrix. --max-turns gives room to investigate + fix. + claude_args: '--max-turns 30' -- cgit v1.3.1 From d585977d9275d70a75003529136dec2f3de9a875 Mon Sep 17 00:00:00 2001 From: hathach Date: Wed, 3 Jun 2026 09:05:46 +0700 Subject: ci: allow claude[bot] pushes in code review workflow Add allowed_bots: 'claude' so that when claude[bot] pushes commits the workflow skips gracefully instead of erroring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude-code-review.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4a0e4639b..97950e888 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read pull-requests: write - issues: write # use_sticky_comment posts/updates a PR comment via the issues API + issues: write # Claude posts the review comment via the issues API id-token: write actions: read # Required for Claude to read CI results on PRs @@ -36,13 +36,17 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Allow claude[bot]'s own pushes to be handled gracefully (skip) instead + # of erroring out the workflow + allowed_bots: 'claude' # Pairs with the actions: read permission so Claude can read CI results additional_permissions: | actions: read plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # Reuse one comment instead of posting a new one each push - use_sticky_comment: true + # TEMPORARY: expose the full Claude transcript in the Actions log for + # debugging. Revert to remove once done. + show_full_output: true claude_args: '--max-turns 20' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md -- cgit v1.3.1 From 9a3e32bf549c5fdf6ef7931a47b8b8e2e483d856 Mon Sep 17 00:00:00 2001 From: hathach Date: Wed, 3 Jun 2026 15:15:07 +0700 Subject: ci(claude): post sticky summary comment on code review The review workflow posted nothing when a review found no issues: with use_sticky_comment unset, the only output path was inline comments, so a clean review surfaced no comment at all on the PR. Enable use_sticky_comment so a single summary comment is posted/ updated every run, making "no issues found" results visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude-code-review.yml | 3 +++ 1 file changed, 3 insertions(+) (limited to '.github/workflows') diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 97950e888..91859af9d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -44,6 +44,9 @@ jobs: actions: read plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' + # Post/update a single summary comment every run, so a clean review + # ("no issues found") is still visible instead of posting nothing. + use_sticky_comment: true prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # TEMPORARY: expose the full Claude transcript in the Actions log for # debugging. Revert to remove once done. -- cgit v1.3.1 From 824a2d6d8567deb163ecae79bf7cccfa59159c6a Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 4 Jun 2026 12:16:13 +0700 Subject: ci(labeler): add sponsor/Adafruit tiers, owner skip, and discussion support - rename priority labels usage to Prio / Prio Top - label Adafruit members (Adafruit + Sponsor + Prio Top) and public GitHub sponsors by tier; contributors get Prio - skip sponsor/Adafruit perks for the maintainer's own issues/PRs - support discussions via the GraphQL addLabelsToLabelable mutation Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/labeler.yml | 135 +++++++++++++++++++++++++++++++++++------- 1 file changed, 112 insertions(+), 23 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index c3cc59d0d..1fdd24bf8 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -5,6 +5,8 @@ on: types: [opened] pull_request_target: types: [opened] + discussion: + types: [created] jobs: label-priority: @@ -12,15 +14,17 @@ jobs: permissions: issues: write pull-requests: write + discussions: write steps: - - name: Label New Issue or PR + - name: Label New Issue, PR or Discussion uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - let label = ''; + let labels = []; let username = ''; let issueOrPrNumber = 0; + let discussionNodeId = ''; if (context.eventName === 'issues') { username = context.payload.issue.user.login; @@ -28,25 +32,85 @@ jobs: } else if (context.eventName === 'pull_request_target') { username = context.payload.pull_request.user.login; issueOrPrNumber = context.payload.pull_request.number; + } else if (context.eventName === 'discussion') { + username = context.payload.discussion.user.login; + discussionNodeId = context.payload.discussion.node_id; } - // Check if an Adafruit member - try { - const adafruitResponse = await github.rest.orgs.checkMembershipForUser({ - org: 'adafruit', - username: username - }); + // Maintainer is an Adafruit member; skip the Adafruit perks for their own + // issues/PRs and treat them as a plain contributor (Prio only). + const isOwner = username.toLowerCase() === 'hathach'; - if (adafruitResponse.status === 204) { - console.log('Adafruit Member'); - label = 'Prio Urgent'; + // Check if an Adafruit member: Adafruit + Sponsor + top priority + if (!isOwner) { + try { + const adafruitResponse = await github.rest.orgs.checkMembershipForUser({ + org: 'adafruit', + username: username + }); + + if (adafruitResponse.status === 204) { + console.log('Adafruit Member'); + labels = ['Adafruit', 'Sponsor', 'Prio Top']; + } + } catch (error) { + console.log('Not an Adafruit member'); + } + } + + // Check if a public GitHub Sponsor of the repo owner. + // Word ($32) tier and up get triage priority; DWORD/QWORD ($128+) go to the top. + // Private sponsorships are not visible to GITHUB_TOKEN, so only public sponsors are detected. + if (labels.length === 0) { + try { + const result = await github.graphql(` + query($sponsorable: String!, $sponsor: String!) { + user(login: $sponsorable) { + isSponsoredBy(accountLogin: $sponsor) + sponsorshipsAsMaintainer(includePrivate: false, first: 100) { + nodes { + sponsorEntity { + ... on User { login } + ... on Organization { login } + } + tier { monthlyPriceInDollars } + } + } + } + }`, { sponsorable: context.repo.owner, sponsor: username }); + + const owner = result.user; + if (owner && owner.isSponsoredBy) { + let monthly = 0; + const nodes = (owner.sponsorshipsAsMaintainer && owner.sponsorshipsAsMaintainer.nodes) || []; + for (const node of nodes) { + const login = node.sponsorEntity && node.sponsorEntity.login; + if (login && login.toLowerCase() === username.toLowerCase()) { + monthly = (node.tier && node.tier.monthlyPriceInDollars) || 0; + break; + } + } + + if (monthly >= 128) { + console.log('Sponsor (DWORD/QWORD tier)'); + labels = ['Sponsor', 'Prio Top']; + } else if (monthly >= 32) { + console.log('Sponsor (Word tier)'); + labels = ['Sponsor', 'Prio']; + } else { + console.log('Sponsor (below Word tier or tier not visible)'); + labels = ['Sponsor']; + } + } else { + console.log('Not a public sponsor'); + } + } catch (error) { + console.log('Sponsor lookup failed: ' + error.message); } - } catch (error) { - console.log('Not an Adafruit member'); } - // Check if a contributor - if (label == '') { + // Check if a contributor: prioritized in triage queue + if (labels.length === 0) { try { const collaboratorResponse = await github.rest.repos.checkCollaborator({ owner: context.repo.owner, @@ -56,18 +120,43 @@ jobs: if (collaboratorResponse.status === 204) { console.log('Contributor'); - label = 'Prio Higher'; + labels = ['Prio']; } } catch (error) { console.log('Not a contributor'); } } - if (label !== '') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueOrPrNumber, - labels: [label] - }); + if (labels.length !== 0) { + if (context.eventName === 'discussion') { + // Discussions are not covered by the REST issues API; resolve the label + // names to node IDs and attach them with the GraphQL labelable mutation. + const labelIds = []; + for (const name of labels) { + const res = await github.graphql(` + query($owner: String!, $repo: String!, $name: String!) { + repository(owner: $owner, name: $repo) { + label(name: $name) { id } + } + }`, { owner: context.repo.owner, repo: context.repo.repo, name: name }); + if (res.repository.label) { + labelIds.push(res.repository.label.id); + } + } + if (labelIds.length !== 0) { + await github.graphql(` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + clientMutationId + } + }`, { labelableId: discussionNodeId, labelIds: labelIds }); + } + } else { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueOrPrNumber, + labels: labels + }); + } } -- cgit v1.3.1 From 5e3a56a38731e21a46c9df121530644745d55685 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 4 Jun 2026 12:16:14 +0700 Subject: ci: add Sponsor Triage board sync workflow Cron (6h) + manual workflow that adds open issues opened by GitHub sponsors (public and private) and Adafruit org members across the adafruit org and the maintainer's repos to the private Sponsor Triage project board, setting Tier and Visibility. Logs counts only to avoid leaking private sponsor logins. Needs the SPONSOR_TOKEN PAT secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sponsor-triage.yml | 164 +++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/workflows/sponsor-triage.yml (limited to '.github/workflows') diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml new file mode 100644 index 000000000..19c1a6105 --- /dev/null +++ b/.github/workflows/sponsor-triage.yml @@ -0,0 +1,164 @@ +name: Sponsor Triage + +# Periodically add open issues opened by sponsors (public and private) to the private "Sponsor Triage" project board +# Requires a PAT in secret SPONSOR_TOKEN with scopes: +# - project (write project items / fields) +# - read:org (search org issues, check Adafruit membership) +# - read:user / sponsors (read own sponsorships incl. private) + +on: + schedule: + - cron: '0 */6 * * *' # every 6 hours + workflow_dispatch: + +concurrency: + group: sponsor-triage + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Sync sponsor issues to project board + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.SPONSOR_TOKEN }} + script: | + const PROJECT_OWNER = 'hathach'; + const PROJECT_NUMBER = 3; + // Where to look for sponsor-authored open issues. + const SEARCH_SCOPES = ['org:adafruit', 'user:hathach']; + + const tierFromMonthly = (m) => { + if (m >= 512) return 'QWORD'; + if (m >= 128) return 'DWORD'; + if (m >= 32) return 'Word'; + if (m >= 8) return 'Byte'; + if (m >= 2) return 'Bit'; + return null; + }; + + // --- 1. Resolve project id + field/option ids (by name, never hardcoded) --- + const proj = await github.graphql(` + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { + id name options { id name } + } + } + } + } + } + }`, { owner: PROJECT_OWNER, number: PROJECT_NUMBER }); + + const project = proj.user.projectV2; + const fieldByName = {}; + for (const f of project.fields.nodes) { + if (f && f.name) { + fieldByName[f.name] = { id: f.id, options: {} }; + for (const o of (f.options || [])) fieldByName[f.name].options[o.name] = o.id; + } + } + const tierField = fieldByName['Tier']; + const visField = fieldByName['Visibility']; + + // --- 2. Build sponsor map: loginLower -> { tier, visibility } --- + const sponsors = new Map(); + + // 2a. GitHub Sponsors of the owner, including private ones. + let after = null; + for (let page = 0; page < 20; page++) { + const res = await github.graphql(` + query($owner: String!, $after: String) { + user(login: $owner) { + sponsorshipsAsMaintainer(includePrivate: true, first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + privacyLevel + tier { monthlyPriceInDollars } + sponsorEntity { + ... on User { login } + ... on Organization { login } + } + } + } + } + }`, { owner: PROJECT_OWNER, after }); + const conn = res.user.sponsorshipsAsMaintainer; + for (const n of conn.nodes) { + const login = n.sponsorEntity && n.sponsorEntity.login; + if (!login) continue; + const tier = tierFromMonthly((n.tier && n.tier.monthlyPriceInDollars) || 0); + const visibility = n.privacyLevel === 'PRIVATE' ? 'Private' : 'Public'; + sponsors.set(login.toLowerCase(), { login, tier, visibility }); + } + if (!conn.pageInfo.hasNextPage) break; + after = conn.pageInfo.endCursor; + } + + core.info(`Resolved ${sponsors.size} sponsor account(s).`); + if (sponsors.size === 0) return; + + // 2b. Adafruit org members get the Adafruit tier regardless of $ amount. + for (const s of sponsors.values()) { + try { + const m = await github.rest.orgs.checkMembershipForUser({ org: 'adafruit', username: s.login }); + if (m.status === 204) s.tier = 'Adafruit'; + } catch (e) { /* not an Adafruit member */ } + } + + // --- 3. Find each sponsor's open issues in the search scopes --- + const found = new Map(); // contentId -> { tier, visibility } + for (const s of sponsors.values()) { + for (const scope of SEARCH_SCOPES) { + const q = `${scope} is:open is:issue author:${s.login}`; + try { + const res = await github.graphql(` + query($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { ... on Issue { id } } + } + }`, { q }); + for (const node of res.search.nodes) { + if (node && node.id && !found.has(node.id)) { + found.set(node.id, { tier: s.tier, visibility: s.visibility }); + } + } + } catch (e) { + core.warning(`Search failed for one scope: ${e.message}`); + } + } + } + core.info(`Found ${found.size} open sponsor issue(s) across scopes.`); + + // --- 4. Add to board + set Tier / Visibility (idempotent) --- + const setField = async (itemId, field, optionName) => { + if (!field || !optionName) return; + const optId = field.options[optionName]; + if (!optId) return; + await github.graphql(` + mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $o } + }) { projectV2Item { id } } + }`, { p: project.id, i: itemId, f: field.id, o: optId }); + }; + + let added = 0; + for (const [contentId, meta] of found) { + const res = await github.graphql(` + mutation($p: ID!, $c: ID!) { + addProjectV2ItemById(input: { projectId: $p, contentId: $c }) { + item { id } + } + }`, { p: project.id, c: contentId }); + const itemId = res.addProjectV2ItemById.item.id; + await setField(itemId, tierField, meta.tier); + await setField(itemId, visField, meta.visibility); + added++; + } + core.info(`Synced ${added} item(s) to the board.`); -- cgit v1.3.1 From c86fea62e72e0b75a42245b2200763922e2baaa7 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 4 Jun 2026 12:55:32 +0700 Subject: ci(sponsor-triage): include open PRs, not just issues Drop the is:issue qualifier so sponsor pull requests are synced to the board too (search type ISSUE already returns both). A sponsor's open PR is exactly the kind of work to prioritize reviewing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sponsor-triage.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml index 19c1a6105..1e7d39028 100644 --- a/.github/workflows/sponsor-triage.yml +++ b/.github/workflows/sponsor-triage.yml @@ -1,6 +1,6 @@ name: Sponsor Triage -# Periodically add open issues opened by sponsors (public and private) to the private "Sponsor Triage" project board +# Periodically add open issues and PRs opened by sponsors (public and private) to the private "Sponsor Triage" project board # Requires a PAT in secret SPONSOR_TOKEN with scopes: # - project (write project items / fields) # - read:org (search org issues, check Adafruit membership) @@ -111,11 +111,12 @@ jobs: } catch (e) { /* not an Adafruit member */ } } - // --- 3. Find each sponsor's open issues in the search scopes --- + // --- 3. Find each sponsor's open issues and PRs in the search scopes --- + // (search type ISSUE returns both issues and pull requests) const found = new Map(); // contentId -> { tier, visibility } for (const s of sponsors.values()) { for (const scope of SEARCH_SCOPES) { - const q = `${scope} is:open is:issue author:${s.login}`; + const q = `${scope} is:open author:${s.login}`; try { const res = await github.graphql(` query($q: String!) { -- cgit v1.3.1 From 10e57012078f68752699e896478bcf3d99dc391c Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 4 Jun 2026 13:06:13 +0700 Subject: ci(sponsor-triage): select PullRequest id in search results The search drops is:issue to include PRs, but the GraphQL selection only had '... on Issue { id }', so PR nodes returned no id and were skipped. Add '... on PullRequest { id }'. (Codex/Copilot review finding.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sponsor-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to '.github/workflows') diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml index 1e7d39028..728503532 100644 --- a/.github/workflows/sponsor-triage.yml +++ b/.github/workflows/sponsor-triage.yml @@ -121,7 +121,7 @@ jobs: const res = await github.graphql(` query($q: String!) { search(query: $q, type: ISSUE, first: 100) { - nodes { ... on Issue { id } } + nodes { ... on Issue { id } ... on PullRequest { id } } } }`, { q }); for (const node of res.search.nodes) { -- cgit v1.3.1 From 709b33d848e953eaacf41399a65ded1724917eb7 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Thu, 4 Jun 2026 13:55:17 +0700 Subject: ci: bump actions/github-script v7 -> v8 (Node.js 24) (#3671) Node.js 20 actions are deprecated; v8 runs on Node.js 24. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/labeler.yml | 2 +- .github/workflows/sponsor-triage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 1fdd24bf8..e860c36a3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -17,7 +17,7 @@ jobs: discussions: write steps: - name: Label New Issue, PR or Discussion - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml index 728503532..48b2ac7cc 100644 --- a/.github/workflows/sponsor-triage.yml +++ b/.github/workflows/sponsor-triage.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Sync sponsor issues to project board - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.SPONSOR_TOKEN }} script: | -- cgit v1.3.1 From 3d0516f439cbe2c8d69a9d8dd62f7effa935d0b2 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Thu, 4 Jun 2026 14:27:54 +0700 Subject: ci(labeler): match emoji-renamed labels (#3672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labels were renamed to add emojis (Adafruit 🌸, Sponsor 💖, Prio 🚩, Prio Top 🚨); update the hardcoded label names in the labeler script to match so they attach to the existing labels instead of recreating plain ones. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/labeler.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e860c36a3..87d416f58 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -51,7 +51,7 @@ jobs: if (adafruitResponse.status === 204) { console.log('Adafruit Member'); - labels = ['Adafruit', 'Sponsor', 'Prio Top']; + labels = ['Adafruit 🌸', 'Sponsor 💖', 'Prio Top 🚨']; } } catch (error) { console.log('Not an Adafruit member'); @@ -93,13 +93,13 @@ jobs: if (monthly >= 128) { console.log('Sponsor (DWORD/QWORD tier)'); - labels = ['Sponsor', 'Prio Top']; + labels = ['Sponsor 💖', 'Prio Top 🚨']; } else if (monthly >= 32) { console.log('Sponsor (Word tier)'); - labels = ['Sponsor', 'Prio']; + labels = ['Sponsor 💖', 'Prio 📌']; } else { console.log('Sponsor (below Word tier or tier not visible)'); - labels = ['Sponsor']; + labels = ['Sponsor 💖']; } } else { console.log('Not a public sponsor'); @@ -120,7 +120,7 @@ jobs: if (collaboratorResponse.status === 204) { console.log('Contributor'); - labels = ['Prio']; + labels = ['Prio 📌']; } } catch (error) { console.log('Not a contributor'); -- cgit v1.3.1 From ac32feafeb2fb0f6cc5c3010d32db319ff15ca64 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Thu, 4 Jun 2026 15:18:40 +0700 Subject: ci(labeler): auto-apply Port labels from changed driver files (#3673) * ci(labeler): auto-apply Port labels from changed driver files Add path-based labeling so a PR touching a dcd/hcd driver under src/portable/ gets the matching "Port " label automatically. --- .github/labeler.yml | 77 +++++++++++++++++++++++++++++++++++++++++++ .github/workflows/labeler.yml | 22 ++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .github/labeler.yml (limited to '.github/workflows') diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..6c7aa7e7d --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,77 @@ +# Path-based auto-labeling for USB IP / port drivers. +# Maps changed dcd/hcd files under src/portable/ to their "Port " label. +# Consumed by actions/labeler (see .github/workflows/labeler.yml -> label-port job). + +"Port DWC2": + - changed-files: + - any-glob-to-any-file: 'src/portable/synopsys/dwc2/**' + +"Port EHCI": + - changed-files: + - any-glob-to-any-file: 'src/portable/ehci/**' + +"Port OHCI": + - changed-files: + - any-glob-to-any-file: 'src/portable/ohci/**' + +"Port FSDev": + - changed-files: + - any-glob-to-any-file: 'src/portable/st/stm32_fsdev/**' + +"Port ChipIdea": + - changed-files: + - any-glob-to-any-file: 'src/portable/chipidea/**' + +"Port NXP IP3511": + - changed-files: + - any-glob-to-any-file: 'src/portable/nxp/lpc_ip3511/**' + +"Port NXP IP3516": + - changed-files: + - any-glob-to-any-file: 'src/portable/nxp/lpc_ip3516/**' + +"Port MUSB": + - changed-files: + - any-glob-to-any-file: + - 'src/portable/mentor/musb/**' + - 'src/portable/sunxi/**' + +"Port RUSB2": + - changed-files: + - any-glob-to-any-file: 'src/portable/renesas/rusb2/**' + +"Port WCH USBFS": + - changed-files: + - any-glob-to-any-file: 'src/portable/wch/*usbfs*' + +"Port WCH USBHS": + - changed-files: + - any-glob-to-any-file: 'src/portable/wch/*usbhs*' + +"Port MAX3421": + - changed-files: + - any-glob-to-any-file: 'src/portable/analog/max3421/**' + +"Port SAMD": + - changed-files: + - any-glob-to-any-file: 'src/portable/microchip/samd/**' + +"Port SAMG": + - changed-files: + - any-glob-to-any-file: 'src/portable/microchip/samg/**' + +"Port nRF": + - changed-files: + - any-glob-to-any-file: 'src/portable/nordic/nrf5x/**' + +"Port Nuvoton": + - changed-files: + - any-glob-to-any-file: 'src/portable/nuvoton/**' + +"Port RP2": + - changed-files: + - any-glob-to-any-file: 'src/portable/raspberrypi/**' + +"Port MSP430": + - changed-files: + - any-glob-to-any-file: 'src/portable/ti/msp430x5xx/**' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 87d416f58..fe09413f1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -4,12 +4,14 @@ on: issues: types: [opened] pull_request_target: - types: [opened] + types: [opened, synchronize, reopened] discussion: types: [created] jobs: label-priority: + # Author-based priority labels: only on issue/PR/discussion creation, not on PR updates. + if: github.event_name != 'pull_request_target' || github.event.action == 'opened' runs-on: ubuntu-latest permissions: issues: write @@ -160,3 +162,21 @@ jobs: }); } } + + # Path-based Port labels: attach "Port " when a PR touches the matching + # dcd/hcd driver under src/portable/. Mapping lives in .github/labeler.yml. + label-port: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write # allow auto-creating a Port label that doesn't exist yet + steps: + - uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + # sync-labels so a Port label is removed once a PR no longer touches + # that driver (job reruns on synchronize). Only labels listed in + # labeler.yml are managed, so author-based Prio/Sponsor labels are untouched. + sync-labels: true -- cgit v1.3.1 From ddc065dc9fd929fcc94b0993c3af4b56877b1cd6 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Thu, 4 Jun 2026 21:59:59 +0700 Subject: Remove Sponsor Triage workflow (migrated to hathach/hathach) (#3675) This personal automation now lives in the hathach/hathach repo alongside the other personal project-sync workflows; it has no place in the tinyusb library. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/sponsor-triage.yml | 165 ----------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 .github/workflows/sponsor-triage.yml (limited to '.github/workflows') diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml deleted file mode 100644 index 48b2ac7cc..000000000 --- a/.github/workflows/sponsor-triage.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Sponsor Triage - -# Periodically add open issues and PRs opened by sponsors (public and private) to the private "Sponsor Triage" project board -# Requires a PAT in secret SPONSOR_TOKEN with scopes: -# - project (write project items / fields) -# - read:org (search org issues, check Adafruit membership) -# - read:user / sponsors (read own sponsorships incl. private) - -on: - schedule: - - cron: '0 */6 * * *' # every 6 hours - workflow_dispatch: - -concurrency: - group: sponsor-triage - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - name: Sync sponsor issues to project board - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.SPONSOR_TOKEN }} - script: | - const PROJECT_OWNER = 'hathach'; - const PROJECT_NUMBER = 3; - // Where to look for sponsor-authored open issues. - const SEARCH_SCOPES = ['org:adafruit', 'user:hathach']; - - const tierFromMonthly = (m) => { - if (m >= 512) return 'QWORD'; - if (m >= 128) return 'DWORD'; - if (m >= 32) return 'Word'; - if (m >= 8) return 'Byte'; - if (m >= 2) return 'Bit'; - return null; - }; - - // --- 1. Resolve project id + field/option ids (by name, never hardcoded) --- - const proj = await github.graphql(` - query($owner: String!, $number: Int!) { - user(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2SingleSelectField { - id name options { id name } - } - } - } - } - } - }`, { owner: PROJECT_OWNER, number: PROJECT_NUMBER }); - - const project = proj.user.projectV2; - const fieldByName = {}; - for (const f of project.fields.nodes) { - if (f && f.name) { - fieldByName[f.name] = { id: f.id, options: {} }; - for (const o of (f.options || [])) fieldByName[f.name].options[o.name] = o.id; - } - } - const tierField = fieldByName['Tier']; - const visField = fieldByName['Visibility']; - - // --- 2. Build sponsor map: loginLower -> { tier, visibility } --- - const sponsors = new Map(); - - // 2a. GitHub Sponsors of the owner, including private ones. - let after = null; - for (let page = 0; page < 20; page++) { - const res = await github.graphql(` - query($owner: String!, $after: String) { - user(login: $owner) { - sponsorshipsAsMaintainer(includePrivate: true, first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - privacyLevel - tier { monthlyPriceInDollars } - sponsorEntity { - ... on User { login } - ... on Organization { login } - } - } - } - } - }`, { owner: PROJECT_OWNER, after }); - const conn = res.user.sponsorshipsAsMaintainer; - for (const n of conn.nodes) { - const login = n.sponsorEntity && n.sponsorEntity.login; - if (!login) continue; - const tier = tierFromMonthly((n.tier && n.tier.monthlyPriceInDollars) || 0); - const visibility = n.privacyLevel === 'PRIVATE' ? 'Private' : 'Public'; - sponsors.set(login.toLowerCase(), { login, tier, visibility }); - } - if (!conn.pageInfo.hasNextPage) break; - after = conn.pageInfo.endCursor; - } - - core.info(`Resolved ${sponsors.size} sponsor account(s).`); - if (sponsors.size === 0) return; - - // 2b. Adafruit org members get the Adafruit tier regardless of $ amount. - for (const s of sponsors.values()) { - try { - const m = await github.rest.orgs.checkMembershipForUser({ org: 'adafruit', username: s.login }); - if (m.status === 204) s.tier = 'Adafruit'; - } catch (e) { /* not an Adafruit member */ } - } - - // --- 3. Find each sponsor's open issues and PRs in the search scopes --- - // (search type ISSUE returns both issues and pull requests) - const found = new Map(); // contentId -> { tier, visibility } - for (const s of sponsors.values()) { - for (const scope of SEARCH_SCOPES) { - const q = `${scope} is:open author:${s.login}`; - try { - const res = await github.graphql(` - query($q: String!) { - search(query: $q, type: ISSUE, first: 100) { - nodes { ... on Issue { id } ... on PullRequest { id } } - } - }`, { q }); - for (const node of res.search.nodes) { - if (node && node.id && !found.has(node.id)) { - found.set(node.id, { tier: s.tier, visibility: s.visibility }); - } - } - } catch (e) { - core.warning(`Search failed for one scope: ${e.message}`); - } - } - } - core.info(`Found ${found.size} open sponsor issue(s) across scopes.`); - - // --- 4. Add to board + set Tier / Visibility (idempotent) --- - const setField = async (itemId, field, optionName) => { - if (!field || !optionName) return; - const optId = field.options[optionName]; - if (!optId) return; - await github.graphql(` - mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $o } - }) { projectV2Item { id } } - }`, { p: project.id, i: itemId, f: field.id, o: optId }); - }; - - let added = 0; - for (const [contentId, meta] of found) { - const res = await github.graphql(` - mutation($p: ID!, $c: ID!) { - addProjectV2ItemById(input: { projectId: $p, contentId: $c }) { - item { id } - } - }`, { p: project.id, c: contentId }); - const itemId = res.addProjectV2ItemById.item.id; - await setField(itemId, tierField, meta.tier); - await setField(itemId, visField, meta.visibility); - added++; - } - core.info(`Synced ${added} item(s) to the board.`); -- cgit v1.3.1 From 9dea2c8f397c004d3f3288bac3c0df9c842fe713 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Fri, 5 Jun 2026 14:34:27 +0700 Subject: ci: carry metrics baseline forward on no-code-change pushes (#3678) * ci: carry metrics baseline forward on no-code-change pushes The code-metrics job is gated on code_changed and only uploads the metrics-tinyusb artifact on push, so a workflow/docs-only push to master (e.g. removing an unrelated workflow) leaves the latest master Build run without a baseline. PRs download the baseline from the latest master run, so the size comparison then finds nothing and silently falls back to absolute sizes. Add a small metrics-carry-forward job that, on a non-code-change push, downloads the previous metrics-tinyusb artifact and re-publishes it, so the latest run always carries a usable baseline. Carry-forward runs re-upload too, so the baseline chains across consecutive no-code pushes (bounded by artifact retention). --- .github/workflows/build.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) (limited to '.github/workflows') diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af0191149..e5075f46f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -192,6 +192,36 @@ jobs: header: code-metrics path: metrics_compare.md + # --------------------------------------- + # Keep the metrics baseline available on no-code-change pushes + # The code-metrics job only runs (and uploads metrics-tinyusb) when code changed, so a + # workflow/docs-only push to master would leave the latest run without a baseline for PRs + # to compare against. Carry the previous artifact forward so the baseline is never missing. + # --------------------------------------- + metrics-carry-forward: + needs: [ check-paths ] + if: github.event_name == 'push' && needs.check-paths.outputs.code_changed != 'true' + runs-on: ubuntu-latest + steps: + - name: Download previous metrics baseline from this branch + uses: dawidd6/action-download-artifact@v11 + with: + workflow: build.yml + workflow_conclusion: '' # any conclusion, matching the PR-side baseline download + search_artifacts: true # scan back past runs that lack the artifact (e.g. earlier no-code pushes) + branch: ${{ github.ref_name }} + name: metrics-tinyusb + path: . + if_no_artifact_found: warn + continue-on-error: true # best-effort: never make a no-code push red + + - name: Re-publish baseline so the latest run keeps it + if: hashFiles('metrics.json') != '' + uses: actions/upload-artifact@v7 + with: + name: metrics-tinyusb + path: metrics.json + # --------------------------------------- # Build Make/CMake on Windows/MacOS # --------------------------------------- -- cgit v1.3.1 From 7c68545ab6bd0a2b7d4b59228d84d4c2085abae7 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Mon, 8 Jun 2026 09:42:08 +0700 Subject: ci: post auto-review findings to the PR (#3684) Add --comment so the auto-review is actually posted on the PR. --- .github/workflows/claude-code-review.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to '.github/workflows') diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 91859af9d..71c1cb8ab 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -47,7 +47,9 @@ jobs: # Post/update a single summary comment every run, so a clean review # ("no issues found") is still visible instead of posting nothing. use_sticky_comment: true - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # --comment makes the code-review command post its findings to the PR. + # Without it the command only prints the review to the Actions log. + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} --comment' # TEMPORARY: expose the full Claude transcript in the Actions log for # debugging. Revert to remove once done. show_full_output: true -- cgit v1.3.1 From fba8d257846ab9149f9db5443827d741492f837d Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:11:13 +0700 Subject: test/hil: accumulate HIL report across re-runs; post as sticky PR comment hil_test.py persists results in a hil_report.json sidecar and regenerates hil_report.md from it. A full run starts fresh; a re-run (--skip-board / -bt, i.e. the .skip file) merges into the existing report so already-passed boards/tests are preserved while only re-run cells update. The report dir is configurable via HIL_REPORT_DIR. build.yml: each HIL rig writes the report to a workspace-sibling dir that survives the per-attempt workspace clean, and uploads it as an artifact. A new hil-report job merges the rigs' reports into one sticky PR comment (marocchino) with one table per rig. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 54 +++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + test/hil/hil_test.py | 59 +++++++++++++++++++++++++++++++++------------ 3 files changed, 99 insertions(+), 15 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5075f46f..0ff29cdda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,6 +306,9 @@ jobs: env: HIL_JSON: ${{ matrix.hil_json }} steps: + - name: Set HIL report dir (sibling of workspace; persists across run attempts) + run: echo "HIL_REPORT_DIR=$(dirname "$GITHUB_WORKSPACE")/hil-report" >> "$GITHUB_ENV" + - name: Get Skip Boards from previous run if: github.run_attempt != '1' run: | @@ -344,6 +347,15 @@ jobs: exit 1 fi) + - name: Upload HIL report + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: hil-report-${{ matrix.display }} + path: ${{ env.HIL_REPORT_DIR }}/hil_report.md + if-no-files-found: ignore + overwrite: true + # --------------------------------------- # Hardware in the loop (HIL) # self-hosted by HFP, build with IAR toolchain, for attached hardware checkout test/hil/hfp.json @@ -390,3 +402,45 @@ jobs: - name: Test on actual hardware (hardware in the loop) run: | python3 test/hil/hil_test.py hfp.json + + # --------------------------------------- + # Combine HIL results from the rigs into a single sticky PR comment (one table per rig) + # --------------------------------------- + hil-report: + needs: hil-tinyusb + if: | + always() && + needs.hil-tinyusb.result != 'skipped' && + github.event_name == 'pull_request' && + github.repository_owner == 'hathach' && + github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download HIL reports + uses: actions/download-artifact@v5 + with: + pattern: hil-report-* + path: hil-reports + + - name: Combine rig reports (one table per rig) + run: | + { + echo "## HIL test results" + echo + for d in hil-reports/hil-report-*; do + [ -d "$d" ] || continue + echo "### ${d#hil-reports/hil-report-}" + echo + cat "$d/hil_report.md" 2>/dev/null || echo "_no report produced_" + echo + done + } > hil_combined.md + cat hil_combined.md + + - name: Post HIL report as sticky PR comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: hil-report + path: hil_combined.md diff --git a/.gitignore b/.gitignore index ba1574558..0f3c9ce49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ html latex hil_report.md +hil_report.json *.a *.d *.o diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 2758d093c..515c20e75 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -1694,18 +1694,17 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: return name, err_count, sorted(set(failed_tests)), rows -def generate_report(mret: list) -> str: - """Build a markdown matrix (rows = boards, columns = tests) from test_board - results. Each mret entry is (name, err, failed_tests, rows) where rows is a - list of (row_label, {example: status}). Columns are padded so the raw table - is aligned in plain text (boards left-aligned, test cells centered).""" +REPORT_MD = 'hil_report.md' +REPORT_JSON = 'hil_report.json' + + +def render_matrix(rows_all: list) -> str: + """Render rows (list of (row_label, {example: status})) as an aligned markdown + matrix: columns = tests (bare names) centered, boards left-aligned.""" canonical = device_tests + dual_tests + host_test + ['device/board_test'] - rows_all = [] # flattened (row_label, cells), preserving board/f1 order seen = set() - for _, _, _, rows in mret: - for row_label, cells in rows: - rows_all.append((row_label, cells)) - seen.update(cells) + for _, cells in rows_all: + seen.update(cells) if not seen: return 'No tests were run.' @@ -1734,6 +1733,34 @@ def generate_report(mret: list) -> str: return '\n'.join([header, sep] + body) + '\n\n' + legend +def accumulate_report(mret: list, report_dir: Path, fresh: bool) -> str: + """Merge this run's results into hil_report.json in report_dir, then (re)write + the markdown matrix to hil_report.md. `fresh` (a full run, no --skip-board/-bt) + starts a new report; otherwise a re-run accumulates so boards/tests that + already passed are preserved while re-run cells are updated. Returns the md.""" + acc = {} # ordered {row_label: {example: status}} + jpath = report_dir / REPORT_JSON + if not fresh and jpath.is_file(): + try: + for entry in json.loads(jpath.read_text()).get('rows', []): + acc[entry['board']] = dict(entry['cells']) + except (ValueError, KeyError, TypeError): + pass # corrupt/old sidecar: start fresh + + # merge this run: current cells override prior for boards/tests that ran + for _, _, _, rows in mret: + for row_label, cells in rows: + acc.setdefault(row_label, {}).update(cells) + + report_dir.mkdir(parents=True, exist_ok=True) + jpath.write_text(json.dumps({'rows': [{'board': k, 'cells': v} for k, v in acc.items()]}, + indent=2) + '\n') + + md = render_matrix(list(acc.items())) + (report_dir / REPORT_MD).write_text(md + '\n', encoding='utf-8') + return md + + def main() -> None: """ Hardware test on specified boards @@ -1823,13 +1850,15 @@ def main() -> None: elif skip_fname.exists(): skip_fname.unlink() - # board x test result matrix -> hil_report.md and stdout - report = generate_report(mret) - report_path = Path('hil_report.md') - report_path.write_text(report + '\n', encoding='utf-8') + # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout. + # A full run starts fresh; a re-run (--skip-board / -bt, i.e. the .skip file) merges + # into the existing report so already-passed boards/tests are preserved. + report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.')) + fresh = not (args.skip_board or args.board_test) + report = accumulate_report(mret, report_dir, fresh) print() print(report) - print(f'\nReport written to {report_path.resolve()}') + print(f'\nReport written to {(report_dir / REPORT_MD).resolve()}') duration = time.time() - duration print() -- cgit v1.3.1 From a70c5a626b161eae1211fa47e9929de35b18bcd7 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:21:34 +0700 Subject: ci: include hil-hfp-iar (IAR) results in the HIL PR comment hil-hfp-iar runs hil_test.py on hfp.json built with IAR on its own rig. Upload its report as the hil-report-hfp-iar artifact and add the job to the hil-report combine job's needs, so the sticky comment shows a third table for the IAR rig alongside tinyusb.json and hfp.json (gcc). The combine gate now runs if either HIL job produced results. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ff29cdda..ce94a9829 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -403,14 +403,23 @@ jobs: run: | python3 test/hil/hil_test.py hfp.json + - name: Upload HIL report + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: hil-report-hfp-iar + path: hil_report.md + if-no-files-found: ignore + overwrite: true + # --------------------------------------- # Combine HIL results from the rigs into a single sticky PR comment (one table per rig) # --------------------------------------- hil-report: - needs: hil-tinyusb + needs: [ hil-tinyusb, hil-hfp-iar ] if: | always() && - needs.hil-tinyusb.result != 'skipped' && + (needs.hil-tinyusb.result != 'skipped' || needs.hil-hfp-iar.result != 'skipped') && github.event_name == 'pull_request' && github.repository_owner == 'hathach' && github.event.pull_request.head.repo.fork == false -- cgit v1.3.1 From 71f7ba0415764ef3dad0fe0dec49cde4d490fdc5 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:28:53 +0700 Subject: ci: demote sticky-comment report headings to h2; rename HIL report The Size Difference Report and HIL comments rendered their titles at h1, which is oversized inside a PR comment. Use h2 for both titles (with subsections demoted to h3 to keep the hierarchy), and rename the HIL comment from "HIL test results" to "Hardware-in-the-loop (HIL) Test Report" for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 2 +- tools/metrics.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce94a9829..e22ba909c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -436,7 +436,7 @@ jobs: - name: Combine rig reports (one table per rig) run: | { - echo "## HIL test results" + echo "## Hardware-in-the-loop (HIL) Test Report" echo for d in hil-reports/hil-report-*; do [ -d "$d" ] || continue diff --git a/tools/metrics.py b/tools/metrics.py index f624f382f..05978b6ef 100644 --- a/tools/metrics.py +++ b/tools/metrics.py @@ -400,7 +400,7 @@ def write_combine_markdown(json_data, path, sort_order='name+', title="TinyUSB A def write_compare_markdown(comparison, path, sort_order='size'): """Write comparison data to markdown file.""" md_lines = [ - "# Size Difference Report", + "## Size Difference Report", "", "Because TinyUSB code size varies by port and configuration, the metrics below represent the averaged totals across all example builds.", "", @@ -415,7 +415,7 @@ def write_compare_markdown(comparison, path, sort_order='size'): md_lines.append(f"
{title}") md_lines.append("") else: - md_lines.append(f"## {title}") + md_lines.append(f"### {title}") md_lines.extend(render_compare_table(_build_rows(rows, sort_order), include_sum=True)) md_lines.append("") -- cgit v1.3.1 From 575a8fbcd0e5880791ea5f834649149a8f787d95 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Wed, 10 Jun 2026 18:04:54 +0700 Subject: Merge pull request #3690 from hathach/claude/board-test-idle-park hil: park boards with idle board_test instead of erasing flash --- .github/workflows/build_util.yml | 2 +- examples/device/board_test/src/main.c | 34 +++++++++-- hw/bsp/espressif/family.cmake | 7 +++ hw/bsp/family_support.cmake | 6 ++ test/hil/hil_test.py | 105 ++-------------------------------- 5 files changed, 48 insertions(+), 106 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml index 69b6f28d5..2532caebe 100644 --- a/.github/workflows/build_util.yml +++ b/.github/workflows/build_util.yml @@ -67,7 +67,7 @@ jobs: IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }} run: | if [ "${{ inputs.toolchain }}" == "esp-idf" ]; then - docker run --rm -e MEMBROWSE_API_KEY="$MEMBROWSE_API_KEY" -v $PWD:/project -w /project espressif/idf:tinyusb python tools/build.py --target all ${{ matrix.arg }} + docker run --rm -e MEMBROWSE_API_KEY="$MEMBROWSE_API_KEY" -e CI="$CI" -v $PWD:/project -w /project espressif/idf:tinyusb python tools/build.py --target all ${{ matrix.arg }} else BUILD_PY_ARGS="-s ${{ inputs.build-system }} ${{ steps.setup-toolchain.outputs.build_option }} ${{ inputs.build-options }} --target all" if [ "${{ inputs.upload-metrics }}" = "true" ]; then diff --git a/examples/device/board_test/src/main.c b/examples/device/board_test/src/main.c index 71e7e1da7..3d8cf9979 100644 --- a/examples/device/board_test/src/main.c +++ b/examples/device/board_test/src/main.c @@ -54,6 +54,11 @@ void tusb_time_delay_ms_api(uint32_t ms) { // //--------------------------------------------------------------------+ +// CI_BUILD (defined for all CI builds, see hw/bsp/family_support.cmake) skips the +// blink/echo loop below: after HIL tests, this firmware is flashed to park the +// board in a quiet, low-power idle state (no USB, LED, or UART activity). +#ifndef CI_BUILD + // Task parameter type: ULONG for ThreadX, void* for FreeRTOS and noos #if CFG_TUSB_OS == OPT_OS_THREADX #define RTOS_PARAM ULONG @@ -107,19 +112,37 @@ static void board_test_loop(RTOS_PARAM param) { } } +#endif // CI_BUILD + int main(void) { +#ifdef CI_BUILD + // Park the board in a quiet, low-power idle loop. board_init() is intentionally + // skipped: no clocks, peripherals, USB, LED, or UART are brought up, so the MCU + // just idles after CI flashes this over a board's previous test firmware. + while (1) { + #if defined(ESP_PLATFORM) + vTaskDelay(portMAX_DELAY); // ESP runs FreeRTOS: yield this task indefinitely + #elif defined(__ARM_ARCH) || defined(__arm__) + __asm volatile("wfe"); // Cortex-M: sleep until an event + #else + // other architectures (e.g. RISC-V): spin + #endif + } + // no return: the loop never exits (an unreachable return trips IAR's Pe111) +#else board_init(); board_led_write(true); -#if CFG_TUSB_OS == OPT_OS_FREERTOS + #if CFG_TUSB_OS == OPT_OS_FREERTOS freertos_init(); -#elif CFG_TUSB_OS == OPT_OS_THREADX + #elif CFG_TUSB_OS == OPT_OS_THREADX tx_kernel_enter(); -#else + #else board_test_loop(NULL); -#endif + #endif return 0; +#endif } #ifdef ESP_PLATFORM @@ -128,6 +151,7 @@ void app_main(void) { } #endif +#ifndef CI_BUILD //--------------------------------------------------------------------+ // FreeRTOS //--------------------------------------------------------------------+ @@ -173,3 +197,5 @@ void tx_application_define(void *first_unused_memory) { 1, 1, TX_NO_TIME_SLICE, TX_AUTO_START); } #endif + +#endif // CI_BUILD diff --git a/hw/bsp/espressif/family.cmake b/hw/bsp/espressif/family.cmake index 30d5a6ac9..b3bda4ad8 100644 --- a/hw/bsp/espressif/family.cmake +++ b/hw/bsp/espressif/family.cmake @@ -44,3 +44,10 @@ set(EXTRA_COMPONENT_DIRS "src" "${CMAKE_CURRENT_LIST_DIR}/boards" "${CMAKE_CURRE set(SDKCONFIG ${CMAKE_BINARY_DIR}/sdkconfig) include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# CI_BUILD marks firmware built in CI (GitHub Actions sets CI). Mirrors the +# non-espressif define added in family_configure_common(); applied build-wide +# here since espressif examples return before that function runs. +if(DEFINED ENV{CI}) + idf_build_set_property(COMPILE_DEFINITIONS "CI_BUILD=1" APPEND) +endif() diff --git a/hw/bsp/family_support.cmake b/hw/bsp/family_support.cmake index af2716b28..1f3952205 100644 --- a/hw/bsp/family_support.cmake +++ b/hw/bsp/family_support.cmake @@ -454,6 +454,12 @@ function(family_configure_common TARGET RTOS) BOARD_${BOARD_UPPER} ) + # CI_BUILD marks firmware built in CI (GitHub Actions sets CI). Examples can use + # it to alter behavior under test, e.g. board_test idles to park HIL boards. + if(DEFINED ENV{CI}) + target_compile_definitions(${TARGET} PUBLIC CI_BUILD=1) + endif() + # compile define from command line if(DEFINED CFLAGS_CLI) separate_arguments(CFLAGS_CLI) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 226e97780..45bad7a45 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -40,7 +40,6 @@ import os import random import re import select -import struct import sys import time import signal @@ -514,80 +513,6 @@ def reset_lm4flash(board): return subprocess.CompletedProcess(args=['dummy'], returncode=0) -# ------------------------------------------------------------- -# Erase: wipe the first flash sector (vector table) after a board's tests so the -# MCU faults to an idle state — no USB, lower power, and faster than programming -# device/board_test. Same (board, firmware) signature as flash_*; `firmware` is -# only used to find the flash origin (jlink) or the esp flash metadata. -# ------------------------------------------------------------- -def elf_flash_origin(elf_path: str) -> int: - """Flash base address (first PT_LOAD segment physical address) of a - little-endian ELF32 firmware — i.e. where the vector table is programmed.""" - data = Path(elf_path).read_bytes() - if data[:4] != b'\x7fELF': - raise ValueError(f'not an ELF: {elf_path}') - e_phoff = struct.unpack_from(' subprocess.CompletedProcess: - flasher = board['flasher'] - origin = elf_flash_origin(f'{firmware}.elf') - script = ['halt', f'erase 0x{origin:x} 0x{origin + 4:x}', 'exit'] - f_jlink = Path(f'{board["name"]}_erase.jlink') - with f_jlink.open('w') as f: - f.writelines(f'{s}\n' for s in script) - ret = run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}') - f_jlink.unlink(missing_ok=True) - return ret - - -def erase_stlink(board: Board, firmware: str) -> subprocess.CompletedProcess: - flasher = board['flasher'] - return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --erase 0') - - -def erase_openocd(board: Board, firmware: str) -> subprocess.CompletedProcess: - flasher = board['flasher'] - return run_cmd(f'openocd -c "tcl_port disabled" -c "gdb_port disabled" -c "adapter serial {flasher["uid"]}" ' - f'{flasher["args"]} -c "init; reset halt; flash erase_sector 0 0 0; exit"') - - -def erase_openocd_adi(board: Board, firmware: str) -> subprocess.CompletedProcess: - flasher = board['flasher'] - openocd = OPENCOD_ADI_PATH / 'src' / 'openocd' - tcl_dir = OPENCOD_ADI_PATH / 'tcl' - return run_cmd(f'{openocd} -c "adapter serial {flasher["uid"]}" -s {tcl_dir} ' - f'{flasher["args"]} -c "init; reset halt; flash erase_sector 0 0 0; exit"') - - -def erase_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess: - flasher = board['flasher'] - port = get_serial_dev(flasher["uid"], None, None, 0) - fw_dir = Path(f'{firmware}.bin').parent - with (fw_dir / 'config.env').open() as f: - idf_target = json.load(f)['IDF_TARGET'] - return run_cmd(f'esptool --chip {idf_target} -p {port} {flasher["args"]} erase_region 0x0 0x4000', - cwd=str(fw_dir)) - - -def erase_lm4flash(board: Board, firmware: str) -> subprocess.CompletedProcess: - # lm4flash has no erase command, but it erases the sectors it programs — so - # writing a blank (all-0xFF) image leaves the first sector erased. - flasher = board['flasher'] - blank = Path(f'{board["name"]}_blank.bin') - blank.write_bytes(b'\xff' * 4096) - ret = run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {blank}') - blank.unlink(missing_ok=True) - return ret - - # ------------------------------------------------------------- # Tests: dual # ------------------------------------------------------------- @@ -1718,28 +1643,6 @@ def build_board(board: Board) -> tuple[str, int]: return name, failed -def disable_board(board: Board, f1: str): - """Quiesce the board after its tests so it stops drawing power / enumerating - USB: erase the first flash sector (vector table) where the flasher supports - it, otherwise flash device/board_test. Skipped when --skip-flash is set. - Returns (report_key, status) or None.""" - if skip_flash: - return None - name = board['name'] - erase_fn = globals().get(f'erase_{board["flasher"]["name"].lower()}') - fw = find_firmware(name, f1, 'device/board_test') - if erase_fn and fw is not None: - start_s = time.time() - ret = erase_fn(board, str(fw)) - status = 'pass' if ret.returncode == 0 else 'fail' - st = STATUS_OK if status == 'pass' else STATUS_FAILED - log_line(f'{name:40} {"erase (disable)":30} ... {st} in {time.time() - start_s:.1f}s') - return 'erase', status - # flasher has no erase support (or board_test not built): flash board_test - _ec, status, _ = test_example(board, f1, 'device/board_test') - return 'device/board_test', status - - def test_board(board: Board) -> tuple[str, int, list[str], list]: name = board['name'] flasher = board['flasher'] @@ -1796,10 +1699,10 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: failed_tests.append(test) rows.append((name + f1_suffix(f1), cells)) - # disable the board's usb after its tests (erase first flash sector, or flash - # board_test where the flasher can't erase); skipped when --skip-flash is set. - # This is teardown, not a test — not recorded in the report. - disable_board(board, flags_on_list[0]) + # flash board_test last to disable board's usb (skipped when --skip-flash is set); + # this is teardown/park, not a test — not recorded in the report + if not skip_flash: + test_example(board, flags_on_list[0], 'device/board_test') return name, err_count, sorted(set(failed_tests)), rows -- cgit v1.3.1 From 6f35e76667f4015ef429ace5730e20cc0037e042 Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Thu, 11 Jun 2026 08:16:43 +0700 Subject: HIL: replace build.flags_on with named build variants (#3687) * test/hil: replace build.flags_on with named variant schema Boards declare build variants as `variant: [{name, flags}]` instead of `build.flags_on`. The variant `name` is the build dir (cmake-build-) and the HIL report row; `flags` is the raw CFLAGS string (-D...=1) injected via CFLAGS_CLI. No `variant` => a single build named after the board. - build.py: --build-name (dir) + --cflag= (raw CFLAGS, repeatable, =form survives the matrix's shell word-splitting); drop -f1/CFLAGS wrapping. - hil_ci_set_matrix.py: emit one build arg per variant. - hil_test.py: iterate variants; report row + build dir = variant name. - hil_ci.sh: copy all cmake-build-* dirs for -b runs. - get_deps.py: accept (ignore) --build-name/--cflag from matrix args. - tinyusb.json: migrate all 6 flags_on boards to variant. * board_test: park CI build with busy spin instead of wfe --- .github/workflows/build.yml | 10 ++++- examples/device/board_test/src/main.c | 50 ++++++++++--------------- test/hil/hfp.json | 4 ++ test/hil/hil_ci.sh | 39 +++++++++++++++++--- test/hil/hil_ci_set_matrix.py | 26 ++++++------- test/hil/hil_test.py | 69 ++++++++++++++++++----------------- test/hil/tinyusb.json | 58 ++++++++++++----------------- tools/build.py | 30 +++++++++------ tools/get_deps.py | 2 + 9 files changed, 157 insertions(+), 131 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e22ba909c..a7c7cf99a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -397,7 +397,15 @@ jobs: run: python3 tools/get_deps.py $BUILD_ARGS - name: Build - run: python3 tools/build.py --toolchain iar $BUILD_ARGS + run: | + # Each variant carries its own --build-name/--cflag, which are global to a + # single build.py invocation — so build one matrix entry at a time rather + # than joining them (joining would leak a variant's flags onto every board). + readarray -t ENTRIES < <(python test/hil/hil_ci_set_matrix.py test/hil/hfp.json | jq -r '.["arm-gcc"][]') + for entry in "${ENTRIES[@]}"; do + echo "+ tools/build.py --toolchain iar $entry" + python3 tools/build.py --toolchain iar $entry + done - name: Test on actual hardware (hardware in the loop) run: | diff --git a/examples/device/board_test/src/main.c b/examples/device/board_test/src/main.c index 3d8cf9979..96dc1bd30 100644 --- a/examples/device/board_test/src/main.c +++ b/examples/device/board_test/src/main.c @@ -57,8 +57,16 @@ void tusb_time_delay_ms_api(uint32_t ms) { // CI_BUILD (defined for all CI builds, see hw/bsp/family_support.cmake) skips the // blink/echo loop below: after HIL tests, this firmware is flashed to park the // board in a quiet, low-power idle state (no USB, LED, or UART activity). -#ifndef CI_BUILD +#ifdef CI_BUILD +int main(void) { + while (1) { + #if defined(ESP_PLATFORM) + vTaskDelay(portMAX_DELAY); + #endif + } +} +#else // Task parameter type: ULONG for ThreadX, void* for FreeRTOS and noos #if CFG_TUSB_OS == OPT_OS_THREADX #define RTOS_PARAM ULONG @@ -112,46 +120,21 @@ static void board_test_loop(RTOS_PARAM param) { } } -#endif // CI_BUILD - int main(void) { -#ifdef CI_BUILD - // Park the board in a quiet, low-power idle loop. board_init() is intentionally - // skipped: no clocks, peripherals, USB, LED, or UART are brought up, so the MCU - // just idles after CI flashes this over a board's previous test firmware. - while (1) { - #if defined(ESP_PLATFORM) - vTaskDelay(portMAX_DELAY); // ESP runs FreeRTOS: yield this task indefinitely - #elif defined(__ARM_ARCH) || defined(__arm__) - __asm volatile("wfe"); // Cortex-M: sleep until an event - #else - // other architectures (e.g. RISC-V): spin - #endif - } - // no return: the loop never exits (an unreachable return trips IAR's Pe111) -#else board_init(); board_led_write(true); - #if CFG_TUSB_OS == OPT_OS_FREERTOS +#if CFG_TUSB_OS == OPT_OS_FREERTOS freertos_init(); - #elif CFG_TUSB_OS == OPT_OS_THREADX +#elif CFG_TUSB_OS == OPT_OS_THREADX tx_kernel_enter(); - #else +#else board_test_loop(NULL); - #endif - - return 0; #endif -} -#ifdef ESP_PLATFORM -void app_main(void) { - main(); + return 0; } -#endif -#ifndef CI_BUILD //--------------------------------------------------------------------+ // FreeRTOS //--------------------------------------------------------------------+ @@ -197,5 +180,10 @@ void tx_application_define(void *first_unused_memory) { 1, 1, TX_NO_TIME_SLICE, TX_AUTO_START); } #endif - #endif // CI_BUILD + +#ifdef ESP_PLATFORM +void app_main(void) { + main(); +} +#endif diff --git a/test/hil/hfp.json b/test/hil/hfp.json index 8ba7a8f44..bb146d2fc 100644 --- a/test/hil/hfp.json +++ b/test/hil/hfp.json @@ -15,6 +15,10 @@ { "name": "stm32f746disco", "uid": "210041000C51343237303334", + "variant": [ + { "name": "stm32f746disco", "flags": "" }, + { "name": "stm32f746disco-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "device": true, "host": false, "dual": false }, diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh index 4f68ed067..3ec907979 100644 --- a/test/hil/hil_ci.sh +++ b/test/hil/hil_ci.sh @@ -66,14 +66,41 @@ copy_board_binaries() { } if [ -n "$BOARD" ]; then - BUILD_DIR="$ROOT_DIR/examples/cmake-build-$BOARD" - if [ ! -d "$BUILD_DIR" ]; then - echo "Error: build directory not found: $BUILD_DIR" - echo "Build first with: cd examples && cmake -DBOARD=$BOARD -G Ninja -B cmake-build-$BOARD . && cmake --build cmake-build-$BOARD" + # Copy the board's build dir plus its variant dirs. Variant names come from + # $CONFIG (they are not required to be prefixed with the board name); the + # cmake-build--* glob is kept as a fallback for ad-hoc local builds. + # Collect only dirs that actually exist, deduplicated. + declare -A SEEN_DIRS=() + BUILD_DIRS=() + add_build_dir() { + [[ -d "$1" && -z "${SEEN_DIRS[$1]:-}" ]] || return 0 + SEEN_DIRS[$1]=1 + BUILD_DIRS+=("$1") + } + shopt -s nullglob + for d in "$ROOT_DIR"/examples/cmake-build-"$BOARD" "$ROOT_DIR"/examples/cmake-build-"$BOARD"-*; do + add_build_dir "$d" + done + shopt -u nullglob + while IFS= read -r v; do + add_build_dir "$ROOT_DIR/examples/cmake-build-$v" + done < <(python3 -c ' +import json, sys +cfg = json.load(open(sys.argv[1])) +for b in cfg.get("boards", []): + if b["name"] == sys.argv[2]: + for v in b.get("variant") or []: + print(v["name"]) +' "$CONFIG" "$BOARD") + if [ ${#BUILD_DIRS[@]} -eq 0 ]; then + echo "Error: no build directory found for $BOARD under $ROOT_DIR/examples/" + echo "Build first with: cd examples && cmake --preset $BOARD && cmake --build --preset $BOARD" exit 1 fi - echo "==> Copying binaries for $BOARD" - copy_board_binaries "$BUILD_DIR" + echo "==> Copying binaries for $BOARD (${#BUILD_DIRS[@]} build dir(s))" + for d in "${BUILD_DIRS[@]}"; do + copy_board_binaries "$d" + done else echo "==> Copying all built binaries" # Use `%/` parameter expansion to strip the trailing slash from the glob — diff --git a/test/hil/hil_ci_set_matrix.py b/test/hil/hil_ci_set_matrix.py index 2cce35ae2..baa24afb1 100644 --- a/test/hil/hil_ci_set_matrix.py +++ b/test/hil/hil_ci_set_matrix.py @@ -44,19 +44,19 @@ def main(): toolchain = 'arm-gcc' build_board = f'-b {name}' - if 'build' in board: - if 'args' in board['build']: - build_board += ' ' + ' '.join(f'-D{a}' for a in board['build']['args']) - if 'flags_on' in board['build']: - for f in board['build']['flags_on']: - if f == '': - append_build_arg(toolchain, build_board) - else: - append_build_arg(toolchain, f'{build_board} -f1 {f.replace(" ", " -f1 ")}') - else: - append_build_arg(toolchain, build_board) - else: - append_build_arg(toolchain, build_board) + if 'build' in board and 'args' in board['build']: + build_board += ' ' + ' '.join(f'-D{a}' for a in board['build']['args']) + + # Each variant builds into cmake-build- with its raw CFLAGS. + # No 'variant' -> a single build named after the board. + variants = board.get('variant') or [{'name': name, 'flags': ''}] + for v in variants: + arg = build_board + if v['name'] != name: + arg += f' --build-name {v["name"]}' + for tok in v.get('flags', '').split(): + arg += f' --cflag={tok}' + append_build_arg(toolchain, arg) print(json.dumps(matrix)) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 45bad7a45..da13fcbaf 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -122,16 +122,21 @@ class TestsCfg(TypedDict, total=False): class BuildCfg(TypedDict, total=False): - flags_on: list[str] args: list[str] +class VariantCfg(TypedDict, total=False): + name: str # build dir (cmake-build-) and HIL report row + flags: str # raw CFLAGS, e.g. "-DCFG_TUD_DWC2_DMA_ENABLE=1" + + class Board(TypedDict): name: str uid: str tests: TestsCfg flasher: FlasherCfg build: NotRequired[BuildCfg] + variant: NotRequired[list[VariantCfg]] class HilConfig(TypedDict): @@ -223,7 +228,9 @@ def open_serial_dev(port: str): while timeout > 0: if os.path.exists(port): try: - ser = serial.Serial(port, baudrate=115200, timeout=5) + # write_timeout: a wedged device otherwise blocks ser.write() forever, + # hanging the worker until the pool/job timeout kills the whole run + ser = serial.Serial(port, baudrate=115200, timeout=5, write_timeout=5) break except serial.SerialException: print(f'serial {port} not reaady {timeout} sec') @@ -976,9 +983,9 @@ def test_device_cdc_msc_throughput(board): pass print(f' CDC read {cdc_r} write {cdc_w}, MSC read {msc_r} write {msc_w} ', end='') - # compact read/write speed for the report cell, e.g. "C 652k/422k M 1.1M/783k" + # compact read/write speed for the report cell, e.g. "✅ CDC 652k/422k MSC 1.1M/783k" short = lambda s: (s.split()[0].rstrip('0').rstrip('.') + s.split()[-1][0]) if ' ' in s else s - return f'C {short(cdc_r)}/{short(cdc_w)} M {short(msc_r)}/{short(msc_w)}' + return f'{REPORT_CELL["pass"]} CDC {short(cdc_r)}/{short(cdc_w)} MSC {short(msc_r)}/{short(msc_w)}' def test_device_dfu(board): @@ -1502,17 +1509,12 @@ host_test = [ ] -def f1_suffix(f1: str) -> str: - """Build dir / row-label suffix for a flags-on variant ('' for the default).""" - return '-f1_' + f1.replace(' ', '_') if f1 else '' - - -def find_firmware(name: str, f1: str, example: str): +def find_firmware(variant: str, example: str): """Locate a built example's firmware base path (no extension) under - cmake-build-[-f1_...]//. Accepts the single-config layout - (firmware directly in the example dir) or Ninja Multi-Config (a per-config - subdir like RelWithDebInfo/). Returns the base Path, or None if not built.""" - fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_suffix(f1)}' / example + cmake-build-//. Accepts the single-config layout (firmware + directly in the example dir) or Ninja Multi-Config (a per-config subdir like + RelWithDebInfo/). Returns the base Path, or None if not built.""" + fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{variant}' / example base = Path(example).name if fw_dir.is_dir(): for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base, @@ -1522,25 +1524,24 @@ def find_firmware(name: str, f1: str, example: str): return None -def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: +def test_example(board: Board, variant: str, example: str) -> tuple[int, str]: """ Test example firmware :param board: board dict - :param f1: flags on + :param variant: build variant name = build dir (cmake-build-) and report row :param example: example name :return: (err_count, status, metric) where err_count is 0 on success/skip or 1 on failure, status is one of 'pass'/'fail'/'skip' (a missing binary counts as 'skip'), and metric is an optional string a test returns to show in its report cell instead of the pass symbol (e.g. speed) """ - name = board['name'] err_count = 0 result_status = 'fail' metric = None - test_name = f'{name + f1_suffix(f1):40} {example:30} ...' + test_name = f'{variant:40} {example:30} ...' - fw_name = find_firmware(name, f1, example) + fw_name = find_firmware(variant, example) if fw_name is None: log_line(f'{test_name} Skip (no binary)') return 0, 'skip', None @@ -1619,21 +1620,22 @@ def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: def build_board(board: Board) -> tuple[str, int]: """Build firmware for this board via tools/build.py. - Honors board config's build.flags_on variants and build.args defines. - Output goes to cmake-build/cmake-build-BOARD[-f1_...]/ (tools/build.py layout).""" + Honors board config's variant list and build.args defines. + Output goes to cmake-build/cmake-build-/ (tools/build.py layout).""" name = board['name'] bcfg = cast(BuildCfg, board.get('build', {})) - flags_on_list = bcfg.get('flags_on', ['']) extra_defs = bcfg.get('args', []) + variants = board.get('variant') or [{'name': name, 'flags': ''}] failed = 0 - for f1 in flags_on_list: + for v in variants: cmd = [sys.executable, str(TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name] for d in extra_defs: cmd += ['-D', d] - if f1: - for flag in f1.split(): - cmd += ['-f1', flag] + if v['name'] != name: + cmd += ['--build-name', v['name']] + for tok in v.get('flags', '').split(): + cmd += [f'--cflag={tok}'] if verbose: cmd.append('-v') print(f' + {" ".join(cmd)}') @@ -1684,25 +1686,24 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: err_count = 0 failed_tests = [] - rows = [] # list of (row_label, {example: status}) — one row per board[-f1] variant - flags_on_list = [""] - if 'build' in board and 'flags_on' in board['build']: - flags_on_list = board['build']['flags_on'] + rows = [] # list of (row_label, {example: status}) — one row per build variant + variants = board.get('variant') or [{'name': name, 'flags': ''}] - for f1 in flags_on_list: + for v in variants: + vname = v['name'] cells = {} for test in test_list: - ec, status, metric = test_example(board, f1, test) + ec, status, metric = test_example(board, vname, test) err_count += ec cells[test] = metric if metric else status if ec > 0: failed_tests.append(test) - rows.append((name + f1_suffix(f1), cells)) + rows.append((vname, cells)) # flash board_test last to disable board's usb (skipped when --skip-flash is set); # this is teardown/park, not a test — not recorded in the report if not skip_flash: - test_example(board, flags_on_list[0], 'device/board_test') + test_example(board, variants[0]['name'], 'device/board_test') return name, err_count, sorted(set(failed_tests)), rows diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json index 319ee9a79..afe3c4d03 100644 --- a/test/hil/tinyusb.json +++ b/test/hil/tinyusb.json @@ -17,12 +17,10 @@ { "name": "espressif_p4_function_ev", "uid": "6055F9F98715", - "build": { - "flags_on": [ - "", - "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE" - ] - }, + "variant": [ + { "name": "espressif_p4_function_ev", "flags": "" }, + { "name": "espressif_p4_function_ev-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "only": [ "device/cdc_msc_freertos", @@ -58,12 +56,10 @@ { "name": "espressif_s3_devkitm", "uid": "84F703C084E4", - "build": { - "flags_on": [ - "", - "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE" - ] - }, + "variant": [ + { "name": "espressif_s3_devkitm", "flags": "" }, + { "name": "espressif_s3_devkitm-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "only": [ "device/cdc_msc_freertos", @@ -226,11 +222,9 @@ { "name": "raspberry_pi_pico", "uid": "E6614C311B764A37", - "build": { - "flags_on": [ - "CFG_TUH_RPI_PIO_USB" - ] - }, + "variant": [ + { "name": "raspberry_pi_pico", "flags": "-DCFG_TUH_RPI_PIO_USB=1" } + ], "tests": { "device": true, "host": true, @@ -374,12 +368,10 @@ { "name": "stm32f723disco", "uid": "460029001951373031313335", - "build": { - "flags_on": [ - "", - "CFG_TUH_DWC2_DMA_ENABLE" - ] - }, + "variant": [ + { "name": "stm32f723disco", "flags": "" }, + { "name": "stm32f723disco-DMA", "flags": "-DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "device": true, "host": true, @@ -410,12 +402,10 @@ { "name": "stm32h743nucleo", "uid": "110018000951383432343236", - "build": { - "flags_on": [ - "", - "CFG_TUD_DWC2_DMA_ENABLE" - ] - }, + "variant": [ + { "name": "stm32h743nucleo", "flags": "" }, + { "name": "stm32h743nucleo-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "device": true, "host": false, @@ -474,12 +464,10 @@ { "name": "stm32f769disco", "uid": "21002F000F51363531383437", - "build": { - "flags_on": [ - "", - "CFG_TUD_DWC2_DMA_ENABLE" - ] - }, + "variant": [ + { "name": "stm32f769disco", "flags": "" }, + { "name": "stm32f769disco-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" } + ], "tests": { "device": true, "host": false, diff --git a/tools/build.py b/tools/build.py index 3c5c3c077..86bc30d28 100755 --- a/tools/build.py +++ b/tools/build.py @@ -105,16 +105,14 @@ def print_build_result(board, build_target, status, duration): # ----------------------------- # CMake # ----------------------------- -def cmake_board(board, build_args, build_flags_on, build_targets): +def cmake_board(board, build_args, build_name, build_cflags, build_targets): ret = [0, 0, 0] start_time = time.monotonic() - build_dir = f'cmake-build/cmake-build-{board}' + build_dir = f'cmake-build/cmake-build-{build_name or board}' build_flags = [] - if len(build_flags_on) > 0: - cli_flags = ' '.join(f'-D{flag}=1' for flag in build_flags_on) - build_flags.append(f'-DCFLAGS_CLI={cli_flags}') - build_dir += '-f1_' + '_'.join(build_flags_on) + if build_cflags: + build_flags.append('-DCFLAGS_CLI=' + ' '.join(build_cflags)) family = find_family(board) if family == 'espressif': @@ -194,13 +192,13 @@ def make_board(board, build_args, build_targets): # ----------------------------- # Build Family # ----------------------------- -def build_boards_list(boards, build_defines, build_system, build_flags_on, build_targets): +def build_boards_list(boards, build_defines, build_system, build_name, build_cflags, build_targets): ret = [0, 0, 0] for b in boards: r = [0, 0, 0] if build_system == 'cmake': build_args = [f'-D{d}' for d in build_defines] - r = cmake_board(b, build_args, build_flags_on, build_targets) + r = cmake_board(b, build_args, build_name, build_cflags, build_targets) elif build_system == 'make': build_args = ' '.join(f'{d}' for d in build_defines) r = make_board(b, build_args, build_targets) @@ -261,7 +259,10 @@ def main(): parser.add_argument('-t', '--toolchain', default='gcc', help='Toolchain to use, default is gcc') parser.add_argument('-s', '--build-system', default='cmake', help='Build system to use, default is cmake') parser.add_argument('-D', '--define-symbol', action='append', default=[], help='Define to pass to build system') - parser.add_argument('-f1', '--build-flags-on', action='append', default=[], help='Build flag to pass to build system') + parser.add_argument('--build-name', default=None, + help='Override build dir name (cmake-build-); default is the board name. Used for HIL variants.') + parser.add_argument('--cflag', action='append', default=[], + help='Raw compiler flag appended to CFLAGS_CLI, e.g. --cflag=-DCFG_TUD_DWC2_DMA_ENABLE=1 (repeatable)') parser.add_argument('--one-random', action='store_true', default=False, help='Build only one random board of each specified family') parser.add_argument('--one-first', action='store_true', default=False, @@ -277,7 +278,8 @@ def main(): toolchain = args.toolchain build_system = args.build_system build_defines = args.define_symbol - build_flags_on = args.build_flags_on + build_name = args.build_name + build_cflags = args.cflag one_random = args.one_random one_first = args.one_first build_targets = args.target if args.target else ['all'] @@ -290,6 +292,12 @@ def main(): print("Please specify families or board to build") return 1 + # --build-name renames the single shared build dir, so building more than one + # board with it would clobber/mix artifacts + if build_name and (len(families) > 0 or len(boards) != 1): + print("--build-name requires exactly one board (-b) and no families") + return 1 + print(build_separator) print(build_format.format('Board', 'Target', '\033[39mResult\033[0m', 'Time')) total_time = time.monotonic() @@ -310,7 +318,7 @@ def main(): all_boards.extend(get_family_boards(f, one_random, one_first)) # build all boards - result = build_boards_list(all_boards, build_defines, build_system, build_flags_on, build_targets) + result = build_boards_list(all_boards, build_defines, build_system, build_name, build_cflags, build_targets) total_time = time.monotonic() - total_time print(build_separator) diff --git a/tools/get_deps.py b/tools/get_deps.py index eb87abf6e..abe5750f1 100755 --- a/tools/get_deps.py +++ b/tools/get_deps.py @@ -366,6 +366,8 @@ def main(): parser.add_argument('-b', '--board', action='append', default=[], help='Boards to fetch') parser.add_argument('-D', '--define', action='append', default=[], help='Have no effect') parser.add_argument('-f1', '--build-flags-on', action='append', default=[], help='Have no effect') + parser.add_argument('--build-name', default=None, help='Have no effect') + parser.add_argument('--cflag', action='append', default=[], help='Have no effect') args = parser.parse_args() families = args.families -- cgit v1.3.1