Skip to content

chore(deps): bump ruff, boto3 and playwright; block librosa 1.0+ on t… #1045

chore(deps): bump ruff, boto3 and playwright; block librosa 1.0+ on t…

chore(deps): bump ruff, boto3 and playwright; block librosa 1.0+ on t… #1045

Workflow file for this run

name: Validation & Tests
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ develop ]
permissions:
contents: read
# Rapid pushes to the same PR/branch stack full 3-OS matrices; keep only the
# newest run per ref.
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
jobs:
validate:
name: Static Validation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml
- name: Validate JSON and YAML files
run: |
python -c "
import yaml
import sys
# Validate plugin.json can be read
try:
with open('.claude-plugin/plugin.json') as f:
import json
json.load(f)
print('[OK] plugin.json is valid')
except Exception as e:
print(f'[FAIL] plugin.json validation failed: {e}')
sys.exit(1)
# Validate marketplace.json can be read
try:
with open('.claude-plugin/marketplace.json') as f:
import json
json.load(f)
print('[OK] marketplace.json is valid')
except Exception as e:
print(f'[FAIL] marketplace.json validation failed: {e}')
sys.exit(1)
# Validate config.example.yaml
try:
with open('config/config.example.yaml') as f:
yaml.safe_load(f)
print('[OK] config.example.yaml is valid')
except Exception as e:
print(f'[FAIL] config.example.yaml validation failed: {e}')
sys.exit(1)
"
- name: Check version consistency
run: |
python -c "
import json
import sys
# Read plugin.json version
with open('.claude-plugin/plugin.json') as f:
plugin_version = json.load(f)['version']
# Read marketplace.json version
with open('.claude-plugin/marketplace.json') as f:
marketplace_version = json.load(f)['plugins'][0]['version']
print(f'plugin.json version: {plugin_version}')
print(f'marketplace.json version: {marketplace_version}')
if plugin_version != marketplace_version:
print('[FAIL] ERROR: Version mismatch!')
print(f' plugin.json: {plugin_version}')
print(f' marketplace.json: {marketplace_version}')
print('')
print('Fix by updating both files to the same version.')
sys.exit(1)
print('[OK] Versions match')
"
- name: Check SKILL.md structure and frontmatter
run: |
python -c "
import os
import sys
import yaml
import re
REQUIRED_FIELDS = ['name', 'description', 'model']
# Skills must use a tier alias (opus/sonnet/haiku) or inherit/default —
# never a pinned model ID. Aliases auto-track the frontier model.
MODEL_PATTERN = r'^(opus|sonnet|haiku|inherit|default)$'
skill_dirs = [d for d in os.listdir('skills') if os.path.isdir(f'skills/{d}')]
missing_skill_md = []
frontmatter_errors = []
for skill_dir in skill_dirs:
skill_md_path = f'skills/{skill_dir}/SKILL.md'
if not os.path.exists(skill_md_path):
missing_skill_md.append(skill_md_path)
continue
# Check frontmatter
with open(skill_md_path, 'r') as f:
content = f.read()
# Extract YAML frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
frontmatter_errors.append(f'{skill_md_path}: missing YAML frontmatter')
continue
try:
frontmatter = yaml.safe_load(match.group(1))
except yaml.YAMLError as e:
frontmatter_errors.append(f'{skill_md_path}: invalid YAML - {e}')
continue
# Check required fields
for field in REQUIRED_FIELDS:
if field not in frontmatter:
frontmatter_errors.append(f'{skill_md_path}: missing required field \"{field}\"')
# Validate model format (alias-only — pinned IDs are rejected)
if 'model' in frontmatter and not re.match(MODEL_PATTERN, frontmatter['model']):
frontmatter_errors.append(f'{skill_md_path}: invalid model format \"{frontmatter[\"model\"]}\"')
if missing_skill_md:
print('[FAIL] Missing SKILL.md files:')
for path in missing_skill_md:
print(f' - {path}')
if frontmatter_errors:
print('[FAIL] Frontmatter errors:')
for error in frontmatter_errors:
print(f' - {error}')
if missing_skill_md or frontmatter_errors:
sys.exit(1)
print(f'[OK] All {len(skill_dirs)} skills have valid SKILL.md with required frontmatter')
"
- name: Check CLAUDE.md size
run: |
python -c "
import sys
MAX_CHARS = 40000
with open('CLAUDE.md', 'r', encoding='utf-8') as f:
content = f.read()
char_count = len(content)
size_k = char_count / 1000
print(f'CLAUDE.md: {size_k:.1f}K chars (max {MAX_CHARS // 1000}K)')
if char_count > MAX_CHARS:
print(f'[FAIL] CLAUDE.md exceeds {MAX_CHARS // 1000}K character limit')
print(f' Current: {char_count} chars')
print(f' Over by: {char_count - MAX_CHARS} chars')
sys.exit(1)
print('[OK] CLAUDE.md size within limit')
"
test:
name: Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Skip for fork PRs — untrusted code could modify requirements/test files
if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
timeout-minutes: ${{ matrix.timeout }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
timeout: 30
- os: macos-latest
timeout: 45
- os: windows-latest
timeout: 60
env:
# Scoped to this job ON PURPOSE. The *test harness* still has encoding-less
# text I/O, so it needs UTF-8 mode on cp1252 Windows runners. Do NOT hoist
# to workflow level: mcp-boot must stay WITHOUT it (see that job's note).
PYTHONUTF8: "1"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: 'pip'
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install system dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew install ffmpeg
# ffmpeg is installed on all three OSes so the ffmpeg-gated audio tests
# run everywhere; AnthemScore/MuseScore remain WSL-recommended externals.
- name: Install system dependencies (Windows)
if: runner.os == 'Windows'
run: choco install ffmpeg -y --no-progress
# Every ffmpeg test gates on a module-level shutil.which("ffmpeg") skipif,
# so a half-succeeded install (or a PATH the shim never reached) would let
# ~10 audio tests silently skip while the leg still went green. Assert both
# binaries resolve here instead. shell: bash runs with -eo pipefail on all
# three OSes (Git Bash on Windows), so a missing binary fails the job.
- name: Verify ffmpeg and ffprobe are on PATH
shell: bash
run: |
ffmpeg -version
ffprobe -version
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-test.txt
pip install -r requirements.txt
- name: Import smoke test (fcntl regression, issue 476)
# Fast, legible failure signal for issue #476 regressions.
if: runner.os == 'Windows'
run: |
python -c "import tools.state.indexer; print('[OK] tools.state.indexer imports on Windows')"
# All three legs measure coverage. The gate itself lives in the `coverage`
# job, which merges the three data files — see that job's note for why a
# Linux-only measurement was actively misleading.
- name: Run tests with coverage (Linux)
if: runner.os == 'Linux'
run: |
python -m pytest tests/ -v --tb=short --cov=tools --cov=servers --cov-report=term-missing --cov-report=html:coverage-html
- name: Run tests with coverage (macOS/Windows)
# Stays parallel: -n auto is what keeps these legs inside their timeouts,
# and pytest-cov aggregates the xdist workers into one data file itself.
if: runner.os != 'Linux'
run: |
python -m pytest tests/ --tb=short -n auto --cov=tools --cov=servers --cov-report=term
# The raw .coverage SQLite file is what the combine job needs — the HTML
# report is a rendering and cannot be merged. Named WITHOUT a leading dot on
# purpose: upload-artifact skips hidden files unless include-hidden-files is
# set, which would silently ship an empty artifact and leave the combine job
# asserting against nothing.
- name: Stage raw coverage data
shell: bash
run: |
test -f .coverage
mkdir -p coverage-data
cp .coverage "coverage-data/coverage-${{ matrix.os }}.dat"
- name: Upload raw coverage data (${{ matrix.os }})
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-data-${{ matrix.os }}
path: coverage-data/
retention-days: 14
if-no-files-found: error
- name: Upload coverage report
if: runner.os == 'Linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-report
path: coverage-html/
retention-days: 14
# The coverage gate used to run on ubuntu only, with an explicit note that it
# avoided "cross-platform variance from win32/darwin platform branches". That
# variance was the signal, not noise: every `sys.platform == "win32"` and
# darwin branch in tools/ and servers/ is unreachable on Linux, so the gate
# was structurally blind to the code where this project's real bugs have
# lived (#476 unconditional `import fcntl`, #497 missing os.replace retry, a
# hardcoded venv/bin/python3, os.fsync on a read-only handle, POSIX-only slug
# sanitisation). Measuring all three legs and gating on their UNION means a
# Windows-only regression can now show up as a coverage change.
coverage:
name: Combined Coverage Gate
runs-on: ubuntu-latest
needs: test
# Same guard as `test`: on fork PRs that job does not run, so there is no
# data to combine.
if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
timeout-minutes: 15
steps:
# A checkout is mandatory, not incidental: coverage only applies a [paths]
# rule when the rewritten path exists on disk, so the source tree must be
# present for the three runner layouts to collapse onto one another.
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: 'pip'
# Installs the same requirements file the test legs do, so the coverage
# version reading the data files resolves identically to the one that wrote
# them. A bare `pip install coverage` could pick up a different release with
# a different data-file schema.
- name: Install coverage
run: |
python -m pip install --upgrade pip
pip install -r requirements-test.txt
- name: Download per-OS coverage data
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-data-*
path: coverage-parts
# Combines the three legs, then proves the merge actually happened before
# trusting the number: a mis-specified [paths] section makes `coverage
# combine` print "Combined 3 files" and still report the Linux-only total.
- name: Combine coverage and enforce the gate
run: |
python .github/scripts/assert_cross_os_coverage.py coverage-parts \
--expect-legs 3 --fail-under 80
- name: Upload combined coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-report-combined
path: coverage-html-combined/
retention-days: 14
if-no-files-found: ignore
# DELIBERATELY has no PYTHONUTF8. This job drives a real stdio JSON-RPC
# handshake through the same launcher `.mcp.json` invokes, so on windows-latest
# it runs in the cp1252 locale a real user's MCP server actually starts in —
# Claude Code sets no UTF-8 mode. That fidelity is the point: it is the only
# job that would catch an encoding regression in the boot/stdio path. Do not
# "fix the inconsistency" by adding PYTHONUTF8 here; that would mask exactly
# the class of bug this job exists to find. (The `test` job scopes the var to
# itself because the test *harness* still has encoding-less I/O.)
mcp-boot:
name: MCP Server Boot (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Skip for fork PRs — untrusted code could modify requirements/test files
if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: 'pip'
# Simulates the documented user install (requirements.txt header):
# python3 -m venv ~/.bitwize-music/venv && pip install -r requirements.txt
# The venv path is computed with Path.home() so it agrees byte-for-byte
# with run.py's venv discovery (on Windows, Path.home() uses USERPROFILE
# and ignores HOME, so a shell $HOME would be ambiguous here).
- name: Create user-style venv and install pinned requirements
run: |
python -c "import pathlib, venv; venv.create(pathlib.Path.home() / '.bitwize-music' / 'venv', with_pip=True)"
VENV_PY="$(python -c "import pathlib, sys; d = pathlib.Path.home() / '.bitwize-music' / 'venv'; print((d / ('Scripts/python.exe' if sys.platform == 'win32' else 'bin/python3')).as_posix())")"
"$VENV_PY" -m pip install --upgrade pip
"$VENV_PY" -m pip install -r requirements.txt
# Boot the server through the SAME launcher .mcp.json invokes, and drive a
# real stdio JSON-RPC handshake. Catches process-level startup failures
# (e.g. #476) that the in-process unit suite with its mocked FastMCP cannot
# see. .mcp.json's command is the extensionless mcp-launch, which Claude Code
# runs as the POSIX shebang script here and resolves to mcp-launch.cmd on
# Windows (validated against real Claude Code on a Windows VM).
- name: Boot check via mcp-launch (POSIX)
if: runner.os != 'Windows'
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
run: |
python tests/e2e/mcp_boot_check.py --timeout 120 --call-tool health_check --scenario state-workflow -- \
"$GITHUB_WORKSPACE/servers/bitwize-music-server/mcp-launch"
- name: Boot check via mcp-launch.cmd (Windows)
if: runner.os == 'Windows'
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
run: |
python tests/e2e/mcp_boot_check.py --timeout 240 --call-tool health_check --scenario state-workflow -- \
"$GITHUB_WORKSPACE/servers/bitwize-music-server/mcp-launch.cmd"
lint:
name: Lint
runs-on: ubuntu-latest
# Skip for fork PRs — untrusted code could modify requirements files
if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install linting dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-test.txt
# hooks/ is in scope: it auto-executes on every Write/Edit in every user
# session, so it gets the same lint gate as tools/ and servers/.
- name: Run ruff linter
run: |
ruff check tools/ servers/ hooks/
# PLW1514 (unspecified-encoding) is preview-only, so it runs as a
# dedicated scoped invocation instead of enabling preview repo-wide
- name: Run ruff encoding check (PLW1514)
run: |
ruff check tools/ servers/ hooks/ tests/ --select PLW1514 --preview
- name: Run bandit security linter
run: |
bandit -r tools/ servers/ -ll -q -s B108,B608
- name: Run mypy type checker
run: |
mypy
# These two catch platform-gated typing drift (fcntl/msvcrt) without
# needing a Windows runner.
- name: Run mypy (win32 cross-check)
run: |
mypy --platform win32
- name: Run mypy (darwin cross-check)
run: |
mypy --platform darwin
- name: Validation summary
run: |
echo "================================"
echo "All lint checks passed!"
echo "================================"
security:
name: Security Scan
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: 'pip'
- name: Install pip-audit
run: |
python -m pip install --upgrade pip
pip install pip-audit
- name: Audit dependencies
run: |
# Policy: only block on vulnerabilities that have a published fix.
# Unfixed CVEs are ignored here and tracked for follow-up.
pip-audit -r requirements.txt -r requirements-test.txt \
--ignore-vuln CVE-2026-4539 # pygments 2.19.2 - no fix available yet
plugin-install-smoke:
name: Plugin Install Smoke
runs-on: ubuntu-latest
# Skip for fork PRs — untrusted code could modify the plugin content this
# job feeds to the claude CLI (guard consistent with the other jobs)
if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Official install method from the install page — no node/npm dependency.
# install.sh accepts an optional target (stable|latest|X.Y.Z); we pass
# none and take its default (latest) deliberately: this job's purpose is
# "what users install today". The installer lands a symlink at
# ~/.local/bin/claude (verified from its own output: "Location:
# ~/.local/bin/claude") and that dir is not on the runner's PATH, so add
# it via GITHUB_PATH for the following steps.
- name: Install Claude Code CLI
run: |
curl -fsSL https://claude.ai/install.sh | bash
test -x "$HOME/.local/bin/claude"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# The plugin CLI needs no Claude auth — proven empirically by the
# capability probes on PR #485: every plugin subcommand (marketplace
# add/list, install, list) runs unauthenticated with exit 0, with no
# ANTHROPIC_API_KEY / OAuth token anywhere in the environment. No
# interactive session is ever started, so nothing here consumes tokens.
#
# `marketplace add ./` registers THIS CHECKOUT as the marketplace (the
# CLI accepts owner/repo, URLs, or a ./path — a bare `.` is rejected),
# so the install exercises the PR's own plugin content. The remote
# `marketplace add owner/repo` form would clone the default branch and
# test released content instead of this PR.
- name: Add this checkout as a marketplace and install the plugin
run: |
claude --version
claude plugin marketplace add ./
claude plugin install bitwize-music@bitwize-music
- name: Assert plugin is listed as enabled
run: |
claude plugin list | tee plugin-list.txt
grep -q "bitwize-music@bitwize-music" plugin-list.txt
grep -q "✔ enabled" plugin-list.txt
echo "[OK] plugin listed and enabled"
# Local-path marketplaces don't populate ~/.claude/plugins/marketplaces/
# (no clone needed) — the installed content lands in the versioned cache
# dir, which is what users' sessions actually load.
- name: Assert installed content matches this checkout
run: |
VERSION=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])")
CACHE_DIR="$HOME/.claude/plugins/cache/bitwize-music/bitwize-music/$VERSION"
echo "Expecting installed cache at: $CACHE_DIR"
test -d "$CACHE_DIR"
test -f "$CACHE_DIR/.mcp.json"
REPO_SKILLS=$(find skills/ -mindepth 1 -maxdepth 1 | wc -l)
INSTALLED_SKILLS=$(find "$CACHE_DIR/skills/" -mindepth 1 -maxdepth 1 | wc -l)
echo "skills: repo=$REPO_SKILLS installed=$INSTALLED_SKILLS"
test "$REPO_SKILLS" -eq "$INSTALLED_SKILLS"
echo "[OK] version $VERSION installed with $INSTALLED_SKILLS skills and .mcp.json"