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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .github/workflows/test-plugins.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
name: Plugin Safety

# Renders every changed plugin across a representative spread of matrix sizes and
# screens using the LEDMatrix core harness, and validates manifests. Gates PRs
# that touch plugin code so a change can't silently break a size or screen.

on:
pull_request:
paths:
- 'plugins/**'
workflow_dispatch:
inputs:
all:
description: 'Check every plugin (not just changed ones)'
type: boolean
default: false

env:
# The core repo provides the harness (scripts/check_plugin.py). Point these at
# the fork/branch where the harness lives until it is merged upstream.
CORE_REPO: ChuckBuilds/LEDMatrix
CORE_REF: main

jobs:
safety:
runs-on: ubuntu-latest
steps:
- name: Checkout plugins
uses: actions/checkout@v4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/test-plugins.yml"

echo "== File header/snippet =="
nl -ba "$FILE" | sed -n '1,120p'

echo
echo "== All uses: lines in workflow =="
rg -n '^\s*uses:\s*' "$FILE" || true

echo
echo "== checkout persist-credentials (if any) =="
rg -n 'persist-credentials' "$FILE" || true

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/test-plugins.yml"

echo "== Region around uses lines ~20-60 =="
cat -n "$FILE" | sed -n '1,120p'

echo
echo "== All uses: lines =="
rg -n '^\s*uses:\s*' "$FILE" || true

echo
echo "== checkout persist-credentials (if any) =="
rg -n 'persist-credentials' "$FILE" || true

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 5428


Pin GitHub Actions uses to commit SHAs.

.github/workflows/test-plugins.yml uses tag-based actions:

  • actions/checkout@v4 (lines 29, 35)
  • actions/setup-python@v5 (line 41)

Pin each uses: to an immutable commit SHA to reduce supply-chain risk and satisfy hardened workflow policy.

🧰 Tools
🪛 zizmor (1.25.2)

[error] 29-29: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test-plugins.yml at line 29, The workflow currently
references tag-based actions "actions/checkout@v4" and
"actions/setup-python@v5"; replace those tag refs with the corresponding
immutable commit SHAs for each action (e.g., use actions/checkout@<commit-sha>
and actions/setup-python@<commit-sha>) so the job uses specific commits rather
than tags; update both occurrences of actions/checkout@v4 and the occurrence of
actions/setup-python@v5, verify the SHAs come from the official action repos,
and commit the updated workflow.

Source: Linters/SAST tools

with:
fetch-depth: 0
path: plugins-repo

- name: Checkout core (harness)
uses: actions/checkout@v4
with:
repository: ${{ env.CORE_REPO }}
ref: ${{ env.CORE_REF }}
path: core

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip

- name: Install core + harness deps
working-directory: core
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator

- name: Determine changed plugins (non-test code only)
id: changed
working-directory: plugins-repo
env:
ALL_INPUT: ${{ github.event.inputs.all }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ "$ALL_INPUT" = "true" ]; then
ids=$(ls plugins)
else
# Plugins with a changed file outside their test/ dir.
ids=$(git diff --name-only "$BASE_SHA"...HEAD -- 'plugins/*' \
| grep -vE '^plugins/[^/]+/test/' \
| sed -E 's#^plugins/([^/]+)/.*#\1#' | sort -u)
fi
echo "ids<<EOF" >> "$GITHUB_OUTPUT"
echo "$ids" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "Changed plugins:"; echo "$ids"

- name: Enforce version bump on changed plugins
if: steps.changed.outputs.ids != '' && github.event.inputs.all != 'true'
working-directory: plugins-repo
env:
# Read IDs from env (never interpolate ${{ }} into the script body) and
# validate each one — plugin ids flow into python -c strings below, so a
# crafted directory name on a PR branch could otherwise inject code.
IDS: ${{ steps.changed.outputs.ids }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
fail=0
for pid in $IDS; do
case "$pid" in
'' | *[!a-z0-9._-]*) echo "::error::invalid plugin id '$pid'"; fail=1; continue ;;
esac
cur=$(python -c "import json;print(json.load(open('plugins/$pid/manifest.json'))['version'])")
old=$(git show "$BASE_SHA:plugins/$pid/manifest.json" 2>/dev/null \
| python -c "import json,sys;print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "")
if [ -n "$old" ] && [ "$cur" = "$old" ]; then
echo "::error::$pid code changed but version not bumped (still $cur). Users won't get the update."
fail=1
else
echo "[ok] $pid version $old -> $cur"
fi
done
exit $fail

- name: Validate manifests against schema
if: steps.changed.outputs.ids != ''
env:
IDS: ${{ steps.changed.outputs.ids }}
run: |
python - <<'PY'
import json, os, re, sys
from pathlib import Path
import jsonschema
schema = json.load(open("core/schema/manifest_schema.json"))
valid = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
ids = os.environ.get("IDS", "").split()
failed = False
for pid in ids:
if not valid.match(pid):
print(f"[FAIL] invalid plugin id: {pid!r}"); failed = True; continue
mpath = Path("plugins-repo/plugins") / pid / "manifest.json"
if not mpath.exists():
print(f"[skip] {pid}: no manifest (deleted?)"); continue
try:
jsonschema.validate(json.load(open(mpath)), schema)
print(f"[ok] {pid}: manifest valid")
except jsonschema.ValidationError as e:
print(f"[FAIL] {pid}: {e.message}"); failed = True
sys.exit(1 if failed else 0)
PY

- name: Run safety harness on changed plugins
if: steps.changed.outputs.ids != ''
env:
IDS: ${{ steps.changed.outputs.ids }}
run: |
set -e
fail=0
for pid in $IDS; do
case "$pid" in
'' | *[!a-z0-9._-]*) echo "::error::invalid plugin id '$pid'"; fail=1; continue ;;
esac
pdir="plugins-repo/plugins/$pid"
[ -d "$pdir" ] || { echo "::notice::$pid removed, skipping"; continue; }
echo "::group::$pid"
# Install the plugin's own runtime deps so it loads like a real install.
# A failure here is a real problem (the plugin can't load), so let it fail.
if [ -f "$pdir/requirements.txt" ]; then pip install -r "$pdir/requirements.txt"; fi
python core/scripts/check_plugin.py --plugin "$pid" \
--plugin-dir "$PWD/plugins-repo/plugins" || fail=1
echo "::endgroup::"
done
exit $fail

- name: Nothing to check
if: steps.changed.outputs.ids == ''
run: echo "No plugin code changed (test-only or docs change) — safety harness skipped."
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,34 @@ Third-party plugins keep their own `repo` URL and empty `plugin_path`.
## Git Hooks
- `scripts/pre-commit` — Auto-syncs `plugins.json` when manifest versions change
- Install: `cp scripts/pre-commit .git/hooks/pre-commit`

## Plugin Safety Harness (cross-size / cross-screen)

Each plugin can expose multiple screens and must render on every supported matrix
size (64×32, 128×32, 128×64, 256×32). The harness lives in the **core** repo
(`LEDMatrix/scripts/check_plugin.py`) and renders every screen at every size,
failing on crashes, content drawn past the panel edge, or visual drift vs
committed golden images.

**Before opening a PR that changes a plugin:**
```bash
# from a LEDMatrix (core) checkout, with the monorepo plugins on the path:
python scripts/check_plugin.py --plugin <id> \
--plugin-dir /path/to/ledmatrix-plugins/plugins --out-dir /tmp/preview
```
Eyeball the PNGs in `/tmp/preview`, then fix any FAIL (overflow/crash) before pushing.

**Golden images (optional, per plugin):** commit reference PNGs so visual drift is
caught automatically:
```text
plugins/<id>/test/harness.json # deterministic config / mock data / frozen time
plugins/<id>/test/golden/<WxH>/<mode>.png
```
Regenerate with `check_plugin.py --update-golden` and review the diff. See
`clock-simple/test/` for a worked example and `LEDMatrix/docs/plugin-safety-harness.md`
for the full reference.

**CI:** `.github/workflows/test-plugins.yml` runs the harness against every
*changed* plugin on each PR (installs that plugin's `requirements.txt` first),
validates its manifest against `schema/manifest_schema.json`, and enforces the
version bump. Test-only changes (`plugins/<id>/test/**`) don't trigger the gate.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions plugins/clock-simple/test/harness.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"_comment": "Settings the plugin safety harness uses to render clock-simple deterministically for golden-image comparison. timezone=UTC + a frozen UTC instant make the output identical on any host.",
"config": {
"timezone": "UTC",
"time_format": "12h",
"show_seconds": false,
"show_date": true
},
"mock_data": {},
"freeze_time": "2025-08-01 15:25:00"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading